feat: enterprise capability expansion — app control, browser control, background execution
Browse filesNew capabilities, so every real-world request works end to end:
- tools/apps: launch and quit desktop applications, multi-OS
(macOS open/osascript, Linux gtk-launch/pkill, Windows start/taskkill)
- tools/browser: open URLs and run searches (YouTube, Google, web) in the
default browser, multi-OS; watch mode attempts autoplay via Safari
automation with a graceful fallback
- tools/shell: background mode — GUI apps, servers and tkinter windows
detach instead of dying with the 15s timeout
- agent prompt: explicit guidance for background execution and tkinter
windows, plus the never-give-up rule
- tests: 34 passing (8 new cases for URL building and validation)
Polish:
- professional tone across all text: emojis removed from docs, CLI,
installer and logs
- dashboard template: emoji-free labels, text action buttons
(Start / Stop / Delete instead of glyphs)
- .github/ISSUE_TEMPLATE/config.yml +3 -3
- README.md +18 -18
- SECURITY.md +2 -2
- apps/desktop/bin/mona-agent.js +7 -7
- apps/desktop/install.sh +8 -8
- apps/desktop/src/agent.js +6 -2
- apps/desktop/src/control.js +1 -1
- apps/desktop/src/log.js +1 -1
- apps/desktop/src/tools/apps.js +60 -0
- apps/desktop/src/tools/browser.js +100 -0
- apps/desktop/src/tools/index.js +3 -1
- apps/desktop/src/tools/shell.js +22 -4
- apps/desktop/src/tui.js +8 -8
- apps/desktop/test/control.test.mjs +1 -1
- apps/desktop/test/tools-new.test.mjs +45 -0
- apps/desktop/test/tui-scroll.test.mjs +1 -1
- docs/ARCHITECTURE.md +3 -3
- docs/CHANGELOG.md +1 -1
- docs/FAQ.md +1 -1
- docs/GETTING-STARTED.md +2 -2
- docs/_config.yml +1 -1
- package.json +1 -1
- packages/engine/src/engine.mjs +1 -1
- packages/engine/src/simulated.mjs +3 -3
- packages/protocol/src/index.mjs +13 -13
|
@@ -1,11 +1,11 @@
|
|
| 1 |
blank_issues_enabled: false
|
| 2 |
contact_links:
|
| 3 |
-
- name:
|
| 4 |
url: https://github.com/MONAEXPERT/agent/tree/main/docs
|
| 5 |
about: Getting started, architecture, tools reference and FAQ
|
| 6 |
-
- name:
|
| 7 |
url: https://agent.mona.expert
|
| 8 |
about: The cloud dashboard — create an account and get your device key
|
| 9 |
-
- name:
|
| 10 |
url: https://github.com/MONAEXPERT/agent/blob/main/SECURITY.md
|
| 11 |
about: Report vulnerabilities privately (do NOT open a public issue)
|
|
|
|
| 1 |
blank_issues_enabled: false
|
| 2 |
contact_links:
|
| 3 |
+
- name: Documentation
|
| 4 |
url: https://github.com/MONAEXPERT/agent/tree/main/docs
|
| 5 |
about: Getting started, architecture, tools reference and FAQ
|
| 6 |
+
- name: mona.expert
|
| 7 |
url: https://agent.mona.expert
|
| 8 |
about: The cloud dashboard — create an account and get your device key
|
| 9 |
+
- name: Security issue?
|
| 10 |
url: https://github.com/MONAEXPERT/agent/blob/main/SECURITY.md
|
| 11 |
about: Report vulnerabilities privately (do NOT open a public issue)
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# mona-agent — your computer, with a brain in the cloud
|
| 2 |
|
| 3 |
<p align="center">
|
| 4 |
<strong>The open-source AI agent that lives on your machine.<br/>
|
|
@@ -44,21 +44,21 @@ to do. The other just gets it done.
|
|
| 44 |
|
| 45 |
## What it can do
|
| 46 |
|
| 47 |
-
-
|
| 48 |
the disk looking?"*, *"restart nginx"*, *"open Safari"*
|
| 49 |
-
-
|
| 50 |
brain plans, the device executes local tools, results flow back, and the
|
| 51 |
answer lands in your chat — up to 8 tool steps per task
|
| 52 |
-
-
|
| 53 |
failed commands trigger self-diagnosis and a smarter second attempt,
|
| 54 |
and every task ends with an answer
|
| 55 |
-
-
|
| 56 |
locally; they live in the encrypted cloud vault. One key. One brain.
|
| 57 |
-
-
|
| 58 |
streamed to your dashboard every 10 seconds, with history sparklines
|
| 59 |
-
-
|
| 60 |
daemon; one command installs it, one dependency powers it
|
| 61 |
-
-
|
| 62 |
|
| 63 |
## Quickstart — 60 seconds
|
| 64 |
|
|
@@ -111,10 +111,10 @@ Full reference: **[docs/TOOLS.md](docs/TOOLS.md)**
|
|
| 111 |
|
| 112 |
| | Typical AI chat | mona-agent |
|
| 113 |
|---|---|---|
|
| 114 |
-
| Can it see your machine? |
|
| 115 |
-
| Can it execute? |
|
| 116 |
-
| Does it retry when things fail? |
|
| 117 |
-
| Where do your API keys live? | on your disk |
|
| 118 |
| Install | app + account | one command, one dependency |
|
| 119 |
|
| 120 |
## FAQ
|
|
@@ -166,13 +166,13 @@ boxes, Raspberry Pis, home servers. See
|
|
| 166 |
|
| 167 |
## Community
|
| 168 |
|
| 169 |
-
|
| 170 |
others discover the project.
|
| 171 |
|
| 172 |
-
-
|
| 173 |
-
-
|
| 174 |
-
-
|
| 175 |
-
-
|
| 176 |
|
| 177 |
## Development
|
| 178 |
|
|
@@ -194,4 +194,4 @@ MIT — see [LICENSE](LICENSE). Free forever.
|
|
| 194 |
---
|
| 195 |
|
| 196 |
**[mona.expert](https://agent.mona.expert)** — one key, one brain, any
|
| 197 |
-
device.
|
|
|
|
| 1 |
+
# mona-agent — your computer, with a brain in the cloud
|
| 2 |
|
| 3 |
<p align="center">
|
| 4 |
<strong>The open-source AI agent that lives on your machine.<br/>
|
|
|
|
| 44 |
|
| 45 |
## What it can do
|
| 46 |
|
| 47 |
+
- **Operate your computer from anywhere** — ask from your phone: *"how's
|
| 48 |
the disk looking?"*, *"restart nginx"*, *"open Safari"*
|
| 49 |
+
- **Think act observe deliver** — a real agentic loop: the cloud
|
| 50 |
brain plans, the device executes local tools, results flow back, and the
|
| 51 |
answer lands in your chat — up to 8 tool steps per task
|
| 52 |
+
- **Fail is never allowed** — transient errors retry automatically,
|
| 53 |
failed commands trigger self-diagnosis and a smarter second attempt,
|
| 54 |
and every task ends with an answer
|
| 55 |
+
- **Zero secrets on the device** — no AI provider keys are stored
|
| 56 |
locally; they live in the encrypted cloud vault. One key. One brain.
|
| 57 |
+
- **Live device monitoring** — CPU, memory, disk, load and uptime
|
| 58 |
streamed to your dashboard every 10 seconds, with history sparklines
|
| 59 |
+
- **Terminal-native** — a fast TUI with live logs, or a fully headless
|
| 60 |
daemon; one command installs it, one dependency powers it
|
| 61 |
+
- **Free and open source** — MIT licensed, forever
|
| 62 |
|
| 63 |
## Quickstart — 60 seconds
|
| 64 |
|
|
|
|
| 111 |
|
| 112 |
| | Typical AI chat | mona-agent |
|
| 113 |
|---|---|---|
|
| 114 |
+
| Can it see your machine? | | live metrics & files |
|
| 115 |
+
| Can it execute? | | sandboxed shell & tools |
|
| 116 |
+
| Does it retry when things fail? | | auto-debug + retry loop |
|
| 117 |
+
| Where do your API keys live? | on your disk | encrypted cloud vault |
|
| 118 |
| Install | app + account | one command, one dependency |
|
| 119 |
|
| 120 |
## FAQ
|
|
|
|
| 166 |
|
| 167 |
## Community
|
| 168 |
|
| 169 |
+
**Star the repo** if mona-agent is useful to you — it genuinely helps
|
| 170 |
others discover the project.
|
| 171 |
|
| 172 |
+
- [GitHub Discussions](https://github.com/MONAEXPERT/agent/discussions) — ideas, questions, show & tell
|
| 173 |
+
- [Issues](https://github.com/MONAEXPERT/agent/issues) — bugs and feature requests
|
| 174 |
+
- [agent.mona.expert](https://agent.mona.expert) — the cloud dashboard
|
| 175 |
+
- [SECURITY.md](SECURITY.md) — responsible disclosure
|
| 176 |
|
| 177 |
## Development
|
| 178 |
|
|
|
|
| 194 |
---
|
| 195 |
|
| 196 |
**[mona.expert](https://agent.mona.expert)** — one key, one brain, any
|
| 197 |
+
device.
|
|
@@ -7,8 +7,8 @@ the client-side security model and how to report vulnerabilities.
|
|
| 7 |
|
| 8 |
| Version | Supported |
|
| 9 |
|---|---|
|
| 10 |
-
| 2.x (current) |
|
| 11 |
-
| < 2.0 |
|
| 12 |
|
| 13 |
## Security model
|
| 14 |
|
|
|
|
| 7 |
|
| 8 |
| Version | Supported |
|
| 9 |
|---|---|
|
| 10 |
+
| 2.x (current) | |
|
| 11 |
+
| < 2.0 | |
|
| 12 |
|
| 13 |
## Security model
|
| 14 |
|
|
@@ -66,7 +66,7 @@ async function login() {
|
|
| 66 |
console.log(` ${CYAN}mona-agent gui${RESET} ${DIM}# terminal dashboard${RESET}`);
|
| 67 |
console.log(` ${CYAN}mona-agent start${RESET} ${DIM}# headless daemon${RESET}`);
|
| 68 |
console.log(` ${CYAN}mona-agent connect${RESET} ${DIM}# test connection${RESET}`);
|
| 69 |
-
console.log(`\n ${DIM}Enjoying it?
|
| 70 |
console.log();
|
| 71 |
rl.close();
|
| 72 |
} catch (e) {
|
|
@@ -313,10 +313,10 @@ async function gui() {
|
|
| 313 |
async function start() {
|
| 314 |
// Fail is never allowed: the daemon logs and survives unexpected errors.
|
| 315 |
process.on('uncaughtException', (err) => {
|
| 316 |
-
process.stderr.write(` ${YELLOW}
|
| 317 |
});
|
| 318 |
process.on('unhandledRejection', (err) => {
|
| 319 |
-
process.stderr.write(` ${YELLOW}
|
| 320 |
});
|
| 321 |
|
| 322 |
const creds = requireCreds();
|
|
@@ -324,7 +324,7 @@ async function start() {
|
|
| 324 |
console.log(`\n ${BOLD}${CYAN}mona-agent${RESET} ${DIM}v${DEFAULTS.version}${RESET}`);
|
| 325 |
console.log(` ${DIM}Headless daemon — controlled from ${CLOUD.base}${RESET}`);
|
| 326 |
console.log(` ${DIM}Log level: ${log.level || 'info'}${RESET}`);
|
| 327 |
-
console.log(` ${DIM}
|
| 328 |
console.log();
|
| 329 |
|
| 330 |
const daemon = new AgentDaemon(creds);
|
|
@@ -338,17 +338,17 @@ async function start() {
|
|
| 338 |
});
|
| 339 |
|
| 340 |
daemon.on('auth-failed', () => {
|
| 341 |
-
process.stderr.write(` ${RED}
|
| 342 |
process.stderr.write(` ${YELLOW} Run ${CYAN}mona-agent login${RESET}${YELLOW} with a valid key, then retry.${RESET}\n`);
|
| 343 |
process.exit(1);
|
| 344 |
});
|
| 345 |
|
| 346 |
daemon.on('task:done', (result) => {
|
| 347 |
-
process.stderr.write(` ${GREEN}
|
| 348 |
});
|
| 349 |
|
| 350 |
daemon.on('error', (err) => {
|
| 351 |
-
process.stderr.write(` ${RED}
|
| 352 |
});
|
| 353 |
|
| 354 |
const stop = () => {
|
|
|
|
| 66 |
console.log(` ${CYAN}mona-agent gui${RESET} ${DIM}# terminal dashboard${RESET}`);
|
| 67 |
console.log(` ${CYAN}mona-agent start${RESET} ${DIM}# headless daemon${RESET}`);
|
| 68 |
console.log(` ${CYAN}mona-agent connect${RESET} ${DIM}# test connection${RESET}`);
|
| 69 |
+
console.log(`\n ${DIM}Enjoying it? Star the repo:${RESET} https://github.com/MONAEXPERT/agent`);
|
| 70 |
console.log();
|
| 71 |
rl.close();
|
| 72 |
} catch (e) {
|
|
|
|
| 313 |
async function start() {
|
| 314 |
// Fail is never allowed: the daemon logs and survives unexpected errors.
|
| 315 |
process.on('uncaughtException', (err) => {
|
| 316 |
+
process.stderr.write(` ${YELLOW} uncaught: ${err?.message || err}${RESET}\n`);
|
| 317 |
});
|
| 318 |
process.on('unhandledRejection', (err) => {
|
| 319 |
+
process.stderr.write(` ${YELLOW} unhandled rejection: ${String(err?.message || err)}${RESET}\n`);
|
| 320 |
});
|
| 321 |
|
| 322 |
const creds = requireCreds();
|
|
|
|
| 324 |
console.log(`\n ${BOLD}${CYAN}mona-agent${RESET} ${DIM}v${DEFAULTS.version}${RESET}`);
|
| 325 |
console.log(` ${DIM}Headless daemon — controlled from ${CLOUD.base}${RESET}`);
|
| 326 |
console.log(` ${DIM}Log level: ${log.level || 'info'}${RESET}`);
|
| 327 |
+
console.log(` ${DIM} ${RESET}${DIM}Star on GitHub:${RESET} https://github.com/MONAEXPERT/agent`);
|
| 328 |
console.log();
|
| 329 |
|
| 330 |
const daemon = new AgentDaemon(creds);
|
|
|
|
| 338 |
});
|
| 339 |
|
| 340 |
daemon.on('auth-failed', () => {
|
| 341 |
+
process.stderr.write(` ${RED} Authentication rejected by ${CLOUD.base}${RESET}\n`);
|
| 342 |
process.stderr.write(` ${YELLOW} Run ${CYAN}mona-agent login${RESET}${YELLOW} with a valid key, then retry.${RESET}\n`);
|
| 343 |
process.exit(1);
|
| 344 |
});
|
| 345 |
|
| 346 |
daemon.on('task:done', (result) => {
|
| 347 |
+
process.stderr.write(` ${GREEN} Done (${result.tokens} tokens)${RESET}\n`);
|
| 348 |
});
|
| 349 |
|
| 350 |
daemon.on('error', (err) => {
|
| 351 |
+
process.stderr.write(` ${RED} ${err.message}${RESET}\n`);
|
| 352 |
});
|
| 353 |
|
| 354 |
const stop = () => {
|
|
@@ -19,7 +19,7 @@ RED='\033[31m'
|
|
| 19 |
RESET='\033[0m'
|
| 20 |
|
| 21 |
echo ""
|
| 22 |
-
echo -e " ${BOLD}${CYAN}
|
| 23 |
echo -e " ───────────────────────"
|
| 24 |
echo ""
|
| 25 |
|
|
@@ -36,25 +36,25 @@ echo -e " Platform: ${BOLD}$PLATFORM${RESET} ($ARCH)"
|
|
| 36 |
|
| 37 |
# ── Prerequisites ───────────────────────────────────────────────
|
| 38 |
command -v node >/dev/null 2>&1 || {
|
| 39 |
-
echo -e " ${RED}
|
| 40 |
exit 1
|
| 41 |
}
|
| 42 |
NODE_MAJOR="$(node -p "process.versions.node.split('.')[0]")"
|
| 43 |
if [ "$NODE_MAJOR" -lt 20 ]; then
|
| 44 |
-
echo -e " ${RED}
|
| 45 |
exit 1
|
| 46 |
fi
|
| 47 |
-
echo -e " ${GREEN}
|
| 48 |
|
| 49 |
# ── Download ────────────────────────────────────────────────────
|
| 50 |
-
echo -e " ${DIM}
|
| 51 |
TMP_DIR="$(mktemp -d)"
|
| 52 |
trap 'rm -rf "$TMP_DIR"' EXIT
|
| 53 |
curl -fsSL "https://github.com/$REPO/archive/refs/heads/$BRANCH.tar.gz" \
|
| 54 |
| tar xz -C "$TMP_DIR" --strip-components=1
|
| 55 |
|
| 56 |
# ── Dependencies ────────────────────────────────────────────────
|
| 57 |
-
echo -e " ${DIM}
|
| 58 |
( cd "$TMP_DIR" && npm install --omit=dev --no-audit --no-fund --silent )
|
| 59 |
|
| 60 |
# ── Copy into place (clean replace; config lives outside agent/) ─
|
|
@@ -98,9 +98,9 @@ if [ -z "$rc_touched" ] && [ ! -f "$HOME/.zshrc" ] && [ ! -f "$HOME/.bashrc" ] &
|
|
| 98 |
fi
|
| 99 |
|
| 100 |
echo ""
|
| 101 |
-
echo -e " ${GREEN}
|
| 102 |
echo ""
|
| 103 |
-
echo -e " ${BOLD}Enjoying mona-agent?${RESET}
|
| 104 |
echo -e " ${CYAN}https://github.com/MONAEXPERT/agent${RESET}"
|
| 105 |
echo ""
|
| 106 |
echo -e " ${BOLD}Next steps:${RESET}"
|
|
|
|
| 19 |
RESET='\033[0m'
|
| 20 |
|
| 21 |
echo ""
|
| 22 |
+
echo -e " ${BOLD}${CYAN} mona-agent${RESET} installer"
|
| 23 |
echo -e " ───────────────────────"
|
| 24 |
echo ""
|
| 25 |
|
|
|
|
| 36 |
|
| 37 |
# ── Prerequisites ───────────────────────────────────────────────
|
| 38 |
command -v node >/dev/null 2>&1 || {
|
| 39 |
+
echo -e " ${RED}${RESET} Node.js 20+ required — install from https://nodejs.org"
|
| 40 |
exit 1
|
| 41 |
}
|
| 42 |
NODE_MAJOR="$(node -p "process.versions.node.split('.')[0]")"
|
| 43 |
if [ "$NODE_MAJOR" -lt 20 ]; then
|
| 44 |
+
echo -e " ${RED}${RESET} Node.js 20+ required (found $(node -v))"
|
| 45 |
exit 1
|
| 46 |
fi
|
| 47 |
+
echo -e " ${GREEN}${RESET} Node.js $(node -v) | npm $(npm -v)"
|
| 48 |
|
| 49 |
# ── Download ────────────────────────────────────────────────────
|
| 50 |
+
echo -e " ${DIM} Downloading ${REPO}@${BRANCH} from GitHub${RESET}"
|
| 51 |
TMP_DIR="$(mktemp -d)"
|
| 52 |
trap 'rm -rf "$TMP_DIR"' EXIT
|
| 53 |
curl -fsSL "https://github.com/$REPO/archive/refs/heads/$BRANCH.tar.gz" \
|
| 54 |
| tar xz -C "$TMP_DIR" --strip-components=1
|
| 55 |
|
| 56 |
# ── Dependencies ────────────────────────────────────────────────
|
| 57 |
+
echo -e " ${DIM} Installing dependencies${RESET}"
|
| 58 |
( cd "$TMP_DIR" && npm install --omit=dev --no-audit --no-fund --silent )
|
| 59 |
|
| 60 |
# ── Copy into place (clean replace; config lives outside agent/) ─
|
|
|
|
| 98 |
fi
|
| 99 |
|
| 100 |
echo ""
|
| 101 |
+
echo -e " ${GREEN} mona-agent installed!${RESET}"
|
| 102 |
echo ""
|
| 103 |
+
echo -e " ${BOLD}Enjoying mona-agent?${RESET} Star us on GitHub:"
|
| 104 |
echo -e " ${CYAN}https://github.com/MONAEXPERT/agent${RESET}"
|
| 105 |
echo ""
|
| 106 |
echo -e " ${BOLD}Next steps:${RESET}"
|
|
@@ -264,7 +264,7 @@ export class AgentDaemon extends EventEmitter {
|
|
| 264 |
const hasText = answer && answer.trim();
|
| 265 |
const looksLikeAttempt = hasText && /"tool"\s*:/.test(answer);
|
| 266 |
if (hasText && !looksLikeAttempt) { final = answer; break; }
|
| 267 |
-
// empty or malformed
|
| 268 |
if (corrections >= MAX_CORRECTIONS) {
|
| 269 |
final = hasText ? answer : 'The brain produced no usable reply. Check the activity feed for the trace.';
|
| 270 |
break;
|
|
@@ -382,7 +382,11 @@ ${rows}
|
|
| 382 |
How to use a tool — reply with ONLY one JSON object, nothing else:
|
| 383 |
{"tool":"<tool name>","args":{...}}
|
| 384 |
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
}
|
| 387 |
|
| 388 |
// ── Lifecycle ───────────────────────────────────────────────────
|
|
|
|
| 264 |
const hasText = answer && answer.trim();
|
| 265 |
const looksLikeAttempt = hasText && /"tool"\s*:/.test(answer);
|
| 266 |
if (hasText && !looksLikeAttempt) { final = answer; break; }
|
| 267 |
+
// empty or malformed corrective nudge (auto-reasoning)
|
| 268 |
if (corrections >= MAX_CORRECTIONS) {
|
| 269 |
final = hasText ? answer : 'The brain produced no usable reply. Check the activity feed for the trace.';
|
| 270 |
break;
|
|
|
|
| 382 |
How to use a tool — reply with ONLY one JSON object, nothing else:
|
| 383 |
{"tool":"<tool name>","args":{...}}
|
| 384 |
|
| 385 |
+
Rules:
|
| 386 |
+
- GUI apps, servers, and long-running programs (e.g. a Python tkinter window) MUST use the shell tool with "background":true so they keep running.
|
| 387 |
+
- To create a Python GUI window, generate a tkinter script and run it with "python3 -c '...'" in the background.
|
| 388 |
+
- Never invent data you can read with a tool. Keep answers short and direct.
|
| 389 |
+
- If a command fails, diagnose and retry differently — never give up.`;
|
| 390 |
}
|
| 391 |
|
| 392 |
// ── Lifecycle ───────────────────────────────────────────────────
|
|
@@ -120,7 +120,7 @@ export class ControlChannel extends EventEmitter {
|
|
| 120 |
} else if (msg.type === 'command') {
|
| 121 |
this.emit('command', msg);
|
| 122 |
} else if (CLOUD.platform === 'docker' && msg.type === 'chat') {
|
| 123 |
-
// Docker dashboard chat
|
| 124 |
this.emit('command', { action: 'run', runId: msg.requestId, payload: { task: msg.message } });
|
| 125 |
} else if (msg.type === 'ping') {
|
| 126 |
this.#send('pong', {});
|
|
|
|
| 120 |
} else if (msg.type === 'command') {
|
| 121 |
this.emit('command', msg);
|
| 122 |
} else if (CLOUD.platform === 'docker' && msg.type === 'chat') {
|
| 123 |
+
// Docker dashboard chat same run flow as a command.
|
| 124 |
this.emit('command', { action: 'run', runId: msg.requestId, payload: { task: msg.message } });
|
| 125 |
} else if (msg.type === 'ping') {
|
| 126 |
this.#send('pong', {});
|
|
@@ -8,7 +8,7 @@ import { EventEmitter } from 'node:events';
|
|
| 8 |
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
| 9 |
const COLORS = { debug: '\x1b[90m', info: '\x1b[36m', warn: '\x1b[33m', error: '\x1b[31m' };
|
| 10 |
const RESET = '\x1b[0m';
|
| 11 |
-
const ICONS = { debug: '·', info: '●', warn: '
|
| 12 |
|
| 13 |
class Logger extends EventEmitter {
|
| 14 |
#level = LEVELS.info;
|
|
|
|
| 8 |
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
| 9 |
const COLORS = { debug: '\x1b[90m', info: '\x1b[36m', warn: '\x1b[33m', error: '\x1b[31m' };
|
| 10 |
const RESET = '\x1b[0m';
|
| 11 |
+
const ICONS = { debug: '·', info: '●', warn: '', error: '' };
|
| 12 |
|
| 13 |
class Logger extends EventEmitter {
|
| 14 |
#level = LEVELS.info;
|
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Application control — launch and quit desktop applications.
|
| 2 |
+
// Multi-OS: macOS (open / osascript), Linux (xdg-open / pkill), Windows (start / taskkill).
|
| 3 |
+
|
| 4 |
+
import { exec } from 'node:child_process';
|
| 5 |
+
import { promisify } from 'node:util';
|
| 6 |
+
import os from 'node:os';
|
| 7 |
+
|
| 8 |
+
const pexec = promisify(exec);
|
| 9 |
+
const PLATFORM = os.platform();
|
| 10 |
+
|
| 11 |
+
function safeName(name) {
|
| 12 |
+
// Allow letters, digits, spaces and a small set of safe characters.
|
| 13 |
+
return String(name || '').replace(/[^a-zA-Z0-9 ._\-]/g, '').trim();
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
async function runCmd(cmd, timeoutMs = 8000) {
|
| 17 |
+
try {
|
| 18 |
+
const { stdout, stderr } = await pexec(cmd, { timeout: timeoutMs, maxBuffer: 1 << 20 });
|
| 19 |
+
return { exitCode: 0, stdout: stdout.slice(0, 2000), stderr: stderr.slice(0, 1000) };
|
| 20 |
+
} catch (err) {
|
| 21 |
+
return {
|
| 22 |
+
exitCode: err.code ?? 1,
|
| 23 |
+
stdout: (err.stdout || '').slice(0, 2000),
|
| 24 |
+
stderr: (err.stderr || '').slice(0, 1000),
|
| 25 |
+
error: err.message,
|
| 26 |
+
};
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export const apps = {
|
| 31 |
+
name: 'apps',
|
| 32 |
+
description: `Launch or quit desktop applications (${PLATFORM}).`,
|
| 33 |
+
args: {
|
| 34 |
+
action: 'string — open | quit',
|
| 35 |
+
app: 'string — application name (e.g. Safari, Calculator, Spotify, Terminal)',
|
| 36 |
+
},
|
| 37 |
+
platform: PLATFORM,
|
| 38 |
+
|
| 39 |
+
async run(args) {
|
| 40 |
+
const action = String(args.action || 'open').toLowerCase();
|
| 41 |
+
const app = safeName(args.app);
|
| 42 |
+
if (!app) return { error: 'app name required' };
|
| 43 |
+
|
| 44 |
+
if (action === 'open') {
|
| 45 |
+
if (PLATFORM === 'darwin') return runCmd(`open -a "${app}"`);
|
| 46 |
+
if (PLATFORM === 'linux') return runCmd(`gtk-launch "${app}" 2>/dev/null || xdg-open "app:${app}" 2>/dev/null || echo "unknown app: ${app}"`);
|
| 47 |
+
if (PLATFORM === 'win32') return runCmd(`start "" "${app}"`);
|
| 48 |
+
return { error: `Unsupported platform: ${PLATFORM}` };
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
if (action === 'quit') {
|
| 52 |
+
if (PLATFORM === 'darwin') return runCmd(`osascript -e 'quit app "${app}"'`);
|
| 53 |
+
if (PLATFORM === 'linux') return runCmd(`pkill -x "${app}" 2>/dev/null; echo done`);
|
| 54 |
+
if (PLATFORM === 'win32') return runCmd(`taskkill /IM "${app}.exe" /F 2>nul || echo "not running"`);
|
| 55 |
+
return { error: `Unsupported platform: ${PLATFORM}` };
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return { error: `Unknown action: ${action} (use open | quit)` };
|
| 59 |
+
},
|
| 60 |
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Browser control — open URLs and run web searches in the default browser.
|
| 2 |
+
// Multi-OS: macOS (open + osascript), Linux (xdg-open), Windows (start).
|
| 3 |
+
|
| 4 |
+
import { exec } from 'node:child_process';
|
| 5 |
+
import { promisify } from 'node:util';
|
| 6 |
+
import os from 'node:os';
|
| 7 |
+
|
| 8 |
+
const pexec = promisify(exec);
|
| 9 |
+
const PLATFORM = os.platform();
|
| 10 |
+
|
| 11 |
+
/** Build a search URL for the requested site. Exported for tests. */
|
| 12 |
+
export function searchUrl(query, site = 'web') {
|
| 13 |
+
const q = encodeURIComponent(String(query || '').slice(0, 300));
|
| 14 |
+
switch (String(site).toLowerCase()) {
|
| 15 |
+
case 'youtube':
|
| 16 |
+
return `https://www.youtube.com/results?search_query=${q}`;
|
| 17 |
+
case 'google':
|
| 18 |
+
return `https://www.google.com/search?q=${q}`;
|
| 19 |
+
case 'web':
|
| 20 |
+
default:
|
| 21 |
+
return `https://www.bing.com/search?q=${q}`;
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/** Validate a URL for the open action. Exported for tests. */
|
| 26 |
+
export function validateUrl(url) {
|
| 27 |
+
try {
|
| 28 |
+
const u = new URL(String(url));
|
| 29 |
+
if (!['http:', 'https:'].includes(u.protocol)) return null;
|
| 30 |
+
return u.toString();
|
| 31 |
+
} catch {
|
| 32 |
+
return null;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
async function openWith(cmd, timeoutMs = 8000) {
|
| 37 |
+
try {
|
| 38 |
+
await pexec(cmd, { timeout: timeoutMs, maxBuffer: 1 << 20 });
|
| 39 |
+
return { exitCode: 0, opened: true };
|
| 40 |
+
} catch (err) {
|
| 41 |
+
return { exitCode: err.code ?? 1, error: err.message, opened: false };
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
export const browser = {
|
| 46 |
+
name: 'browser',
|
| 47 |
+
description: `Open URLs or run web searches (YouTube, Google, web) in the default browser (${PLATFORM}).`,
|
| 48 |
+
args: {
|
| 49 |
+
action: 'string — open | search | watch',
|
| 50 |
+
url: 'string — full URL (for open)',
|
| 51 |
+
query: 'string — search terms (for search/watch)',
|
| 52 |
+
site: 'string — youtube | google | web (default web)',
|
| 53 |
+
},
|
| 54 |
+
platform: PLATFORM,
|
| 55 |
+
|
| 56 |
+
async run(args) {
|
| 57 |
+
const action = String(args.action || 'open').toLowerCase();
|
| 58 |
+
|
| 59 |
+
let url = null;
|
| 60 |
+
if (action === 'open') {
|
| 61 |
+
url = validateUrl(args.url);
|
| 62 |
+
if (!url) return { error: 'A valid http(s) URL is required (url=...)' };
|
| 63 |
+
} else if (action === 'search' || action === 'watch') {
|
| 64 |
+
const site = args.site || (action === 'watch' ? 'youtube' : 'web');
|
| 65 |
+
url = searchUrl(args.query || '', site);
|
| 66 |
+
if (!String(args.query || '').trim()) return { error: 'query required' };
|
| 67 |
+
} else {
|
| 68 |
+
return { error: `Unknown action: ${action} (use open | search | watch)` };
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
if (PLATFORM === 'darwin') {
|
| 72 |
+
const res = await openWith(`open "${url}"`);
|
| 73 |
+
if (action === 'watch') {
|
| 74 |
+
// Best effort: ask Safari to play the first video result. Requires
|
| 75 |
+
// "Allow JavaScript from Apple Events" in Safari — falls back to the
|
| 76 |
+
// results page if the setting is off.
|
| 77 |
+
try {
|
| 78 |
+
await pexec(
|
| 79 |
+
`osascript -e 'tell application "Safari" to activate' -e 'delay 2' -e 'tell application "Safari" to do JavaScript "document.querySelector(\\"ytd-video-renderer a#video-title\\")?.click()" in front document'`,
|
| 80 |
+
{ timeout: 12000 }
|
| 81 |
+
);
|
| 82 |
+
res.autoplayAttempted = true;
|
| 83 |
+
} catch {
|
| 84 |
+
res.autoplayAttempted = false;
|
| 85 |
+
res.note = 'Opened the results page. Click the first result to play (Safari automation requires "Allow JavaScript from Apple Events").';
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
return { ...res, url };
|
| 89 |
+
}
|
| 90 |
+
if (PLATFORM === 'linux') {
|
| 91 |
+
const res = await openWith(`xdg-open "${url}"`);
|
| 92 |
+
return { ...res, url, note: action === 'watch' ? 'Opened the search results page — click the first result to play.' : undefined };
|
| 93 |
+
}
|
| 94 |
+
if (PLATFORM === 'win32') {
|
| 95 |
+
const res = await openWith(`start "" "${url}"`);
|
| 96 |
+
return { ...res, url, note: action === 'watch' ? 'Opened the search results page — click the first result to play.' : undefined };
|
| 97 |
+
}
|
| 98 |
+
return { error: `Unsupported platform: ${PLATFORM}` };
|
| 99 |
+
},
|
| 100 |
+
};
|
|
@@ -7,8 +7,10 @@ import { sysinfo } from './sysinfo.js';
|
|
| 7 |
import { shell } from './shell.js';
|
| 8 |
import { files } from './files.js';
|
| 9 |
import { net } from './net.js';
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
const BUILTIN = [sysinfo, shell, files, net];
|
| 12 |
const TIMEOUT_MS = 30_000;
|
| 13 |
|
| 14 |
class ToolRegistry {
|
|
|
|
| 7 |
import { shell } from './shell.js';
|
| 8 |
import { files } from './files.js';
|
| 9 |
import { net } from './net.js';
|
| 10 |
+
import { apps } from './apps.js';
|
| 11 |
+
import { browser } from './browser.js';
|
| 12 |
|
| 13 |
+
const BUILTIN = [sysinfo, shell, files, net, apps, browser];
|
| 14 |
const TIMEOUT_MS = 30_000;
|
| 15 |
|
| 16 |
class ToolRegistry {
|
|
@@ -3,7 +3,7 @@
|
|
| 3 |
// Only allowlisted commands run by default.
|
| 4 |
// Set MONA_ALLOW_CMDS to extend. Use MONA_SHELL_UNSAFE=1 to allow anything.
|
| 5 |
|
| 6 |
-
import { exec } from 'node:child_process';
|
| 7 |
import { promisify } from 'node:util';
|
| 8 |
import os from 'node:os';
|
| 9 |
|
|
@@ -50,7 +50,7 @@ export const security = {
|
|
| 50 |
platform: PLATFORM,
|
| 51 |
};
|
| 52 |
|
| 53 |
-
// ── Per-OS command mapping (translate common unix
|
| 54 |
const CMD_MAP_WIN32 = {
|
| 55 |
ls: 'dir',
|
| 56 |
cat: 'type',
|
|
@@ -98,8 +98,8 @@ const EXEC_OPTS = {
|
|
| 98 |
// ── Tool definition ───────────────────────────────────────────────
|
| 99 |
export const shell = {
|
| 100 |
name: 'shell',
|
| 101 |
-
description: `Execute a shell command (${PLATFORM}; allowlisted by default; max 15s timeout)`,
|
| 102 |
-
args: { cmd: 'string — the command to run' },
|
| 103 |
platform: PLATFORM,
|
| 104 |
|
| 105 |
async run(args) {
|
|
@@ -134,6 +134,24 @@ export const shell = {
|
|
| 134 |
}
|
| 135 |
|
| 136 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
const { stdout, stderr } = await pexec(cmd, EXEC_OPTS);
|
| 138 |
return {
|
| 139 |
exitCode: 0,
|
|
|
|
| 3 |
// Only allowlisted commands run by default.
|
| 4 |
// Set MONA_ALLOW_CMDS to extend. Use MONA_SHELL_UNSAFE=1 to allow anything.
|
| 5 |
|
| 6 |
+
import { exec, spawn } from 'node:child_process';
|
| 7 |
import { promisify } from 'node:util';
|
| 8 |
import os from 'node:os';
|
| 9 |
|
|
|
|
| 50 |
platform: PLATFORM,
|
| 51 |
};
|
| 52 |
|
| 53 |
+
// ── Per-OS command mapping (translate common unix windows) ──────
|
| 54 |
const CMD_MAP_WIN32 = {
|
| 55 |
ls: 'dir',
|
| 56 |
cat: 'type',
|
|
|
|
| 98 |
// ── Tool definition ───────────────────────────────────────────────
|
| 99 |
export const shell = {
|
| 100 |
name: 'shell',
|
| 101 |
+
description: `Execute a shell command (${PLATFORM}; allowlisted by default; max 15s timeout; background:true for GUI/long-running processes)`,
|
| 102 |
+
args: { cmd: 'string — the command to run', background: 'bool — optional, detach and return immediately (for GUI apps, servers, tkinter windows)' },
|
| 103 |
platform: PLATFORM,
|
| 104 |
|
| 105 |
async run(args) {
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
try {
|
| 137 |
+
// Background mode: GUI apps / long-running processes (e.g. tkinter
|
| 138 |
+
// windows) must not block the task or die with the 15s timeout.
|
| 139 |
+
if (args.background) {
|
| 140 |
+
const child = spawn(cfg.shell, ['-c', cmd], {
|
| 141 |
+
detached: true,
|
| 142 |
+
stdio: 'ignore',
|
| 143 |
+
env: { ...process.env, PATH: cfg.path || process.env.PATH },
|
| 144 |
+
});
|
| 145 |
+
child.unref();
|
| 146 |
+
return {
|
| 147 |
+
exitCode: null,
|
| 148 |
+
pid: child.pid,
|
| 149 |
+
background: true,
|
| 150 |
+
note: 'Process started in background and detached from the agent.',
|
| 151 |
+
platform: PLATFORM,
|
| 152 |
+
};
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
const { stdout, stderr } = await pexec(cmd, EXEC_OPTS);
|
| 156 |
return {
|
| 157 |
exitCode: 0,
|
|
@@ -15,7 +15,7 @@
|
|
| 15 |
// r Force reconnect
|
| 16 |
// c Clear activity log
|
| 17 |
// d Toggle debug info
|
| 18 |
-
//
|
| 19 |
// g / End Jump to end of log (auto-follow on)
|
| 20 |
// h / ? Show help + connect instructions
|
| 21 |
|
|
@@ -84,16 +84,16 @@ const ICON = {
|
|
| 84 |
connected: '●',
|
| 85 |
disconnected: '○',
|
| 86 |
thinking: '◈',
|
| 87 |
-
done: '
|
| 88 |
-
error: '
|
| 89 |
-
tool: '
|
| 90 |
-
arrow: '
|
| 91 |
task: '▸',
|
| 92 |
token: '·',
|
| 93 |
debug: '…',
|
| 94 |
};
|
| 95 |
|
| 96 |
-
const PLATFORM_ICON = { darwin: '
|
| 97 |
const PLATFORM_LABEL = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
|
| 98 |
|
| 99 |
// ── Connection states ─────────────────────────────────────────────
|
|
@@ -574,7 +574,7 @@ export class Dashboard {
|
|
| 574 |
` ${ansi.fg.bGreen}c${ansi.reset} Clear activity log`,
|
| 575 |
` ${ansi.fg.bGreen}d${ansi.reset} Toggle debug info bar`,
|
| 576 |
` ${ansi.fg.bGreen}h${ansi.reset} / ${ansi.fg.bGreen}?${ansi.reset} Show this help (any key to dismiss)`,
|
| 577 |
-
` ${ansi.fg.bGreen}
|
| 578 |
'',
|
| 579 |
` ${ansi.bold}Environment${ansi.reset}`,
|
| 580 |
` ${ansi.dim}Cloud:${ansi.reset} ${CLOUD.base}`,
|
|
@@ -683,7 +683,7 @@ export class Dashboard {
|
|
| 683 |
#logIcon(type) {
|
| 684 |
switch (type) {
|
| 685 |
case 'info': return `${ansi.fg.bCyan}${ICON.connected}${ansi.reset}`;
|
| 686 |
-
case 'warn': return `${ansi.fg.bYellow}
|
| 687 |
case 'error': return `${ansi.fg.bRed}${ICON.error}${ansi.reset}`;
|
| 688 |
case 'task': return `${ansi.fg.bMagenta}${ICON.task}${ansi.reset}`;
|
| 689 |
case 'done': return `${ansi.fg.bGreen}${ICON.done}${ansi.reset}`;
|
|
|
|
| 15 |
// r Force reconnect
|
| 16 |
// c Clear activity log
|
| 17 |
// d Toggle debug info
|
| 18 |
+
// / Scroll log
|
| 19 |
// g / End Jump to end of log (auto-follow on)
|
| 20 |
// h / ? Show help + connect instructions
|
| 21 |
|
|
|
|
| 84 |
connected: '●',
|
| 85 |
disconnected: '○',
|
| 86 |
thinking: '◈',
|
| 87 |
+
done: '',
|
| 88 |
+
error: '',
|
| 89 |
+
tool: '',
|
| 90 |
+
arrow: '',
|
| 91 |
task: '▸',
|
| 92 |
token: '·',
|
| 93 |
debug: '…',
|
| 94 |
};
|
| 95 |
|
| 96 |
+
const PLATFORM_ICON = { darwin: '', linux: '', win32: '' };
|
| 97 |
const PLATFORM_LABEL = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
|
| 98 |
|
| 99 |
// ── Connection states ─────────────────────────────────────────────
|
|
|
|
| 574 |
` ${ansi.fg.bGreen}c${ansi.reset} Clear activity log`,
|
| 575 |
` ${ansi.fg.bGreen}d${ansi.reset} Toggle debug info bar`,
|
| 576 |
` ${ansi.fg.bGreen}h${ansi.reset} / ${ansi.fg.bGreen}?${ansi.reset} Show this help (any key to dismiss)`,
|
| 577 |
+
` ${ansi.fg.bGreen} / ${ansi.reset} Scroll activity log`,
|
| 578 |
'',
|
| 579 |
` ${ansi.bold}Environment${ansi.reset}`,
|
| 580 |
` ${ansi.dim}Cloud:${ansi.reset} ${CLOUD.base}`,
|
|
|
|
| 683 |
#logIcon(type) {
|
| 684 |
switch (type) {
|
| 685 |
case 'info': return `${ansi.fg.bCyan}${ICON.connected}${ansi.reset}`;
|
| 686 |
+
case 'warn': return `${ansi.fg.bYellow}${ansi.reset}`;
|
| 687 |
case 'error': return `${ansi.fg.bRed}${ICON.error}${ansi.reset}`;
|
| 688 |
case 'task': return `${ansi.fg.bMagenta}${ICON.task}${ansi.reset}`;
|
| 689 |
case 'done': return `${ansi.fg.bGreen}${ICON.done}${ansi.reset}`;
|
|
@@ -114,7 +114,7 @@ describe('control channel', () => {
|
|
| 114 |
let flushed = false;
|
| 115 |
quiet(ch);
|
| 116 |
ch.connect();
|
| 117 |
-
ch.step('test', { n: 1 }); // sent before open
|
| 118 |
|
| 119 |
await new Promise((resolve) => {
|
| 120 |
const timer = setInterval(() => {
|
|
|
|
| 114 |
let flushed = false;
|
| 115 |
quiet(ch);
|
| 116 |
ch.connect();
|
| 117 |
+
ch.step('test', { n: 1 }); // sent before open queued
|
| 118 |
|
| 119 |
await new Promise((resolve) => {
|
| 120 |
const timer = setInterval(() => {
|
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Tests for the newer tools: pure functions only (no real launches).
|
| 2 |
+
|
| 3 |
+
import { describe, it } from 'node:test';
|
| 4 |
+
import assert from 'node:assert/strict';
|
| 5 |
+
import { searchUrl, validateUrl } from '../src/tools/browser.js';
|
| 6 |
+
|
| 7 |
+
describe('browser searchUrl', () => {
|
| 8 |
+
it('builds a YouTube search URL', () => {
|
| 9 |
+
assert.equal(
|
| 10 |
+
searchUrl('avicii wake me up', 'youtube'),
|
| 11 |
+
'https://www.youtube.com/results?search_query=avicii%20wake%20me%20up'
|
| 12 |
+
);
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
it('builds a Google search URL', () => {
|
| 16 |
+
assert.equal(searchUrl('mona agent', 'google'), 'https://www.google.com/search?q=mona%20agent');
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
it('defaults to web search', () => {
|
| 20 |
+
assert.match(searchUrl('test'), /^https:\/\/www\.bing\.com\/search\?q=test$/);
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
it('encodes special characters', () => {
|
| 24 |
+
assert.equal(searchUrl('a & b', 'youtube'), 'https://www.youtube.com/results?search_query=a%20%26%20b');
|
| 25 |
+
});
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
describe('browser validateUrl', () => {
|
| 29 |
+
it('accepts https URLs', () => {
|
| 30 |
+
assert.equal(validateUrl('https://example.com/x'), 'https://example.com/x');
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
it('accepts http URLs', () => {
|
| 34 |
+
assert.equal(validateUrl('http://example.com'), 'http://example.com/');
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
it('rejects non-http(s) schemes', () => {
|
| 38 |
+
assert.equal(validateUrl('file:///etc/passwd'), null);
|
| 39 |
+
assert.equal(validateUrl('javascript:alert(1)'), null);
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
it('rejects malformed URLs', () => {
|
| 43 |
+
assert.equal(validateUrl('not a url'), null);
|
| 44 |
+
});
|
| 45 |
+
});
|
|
@@ -41,7 +41,7 @@ describe('dashboard scroll', () => {
|
|
| 41 |
text = await render(out);
|
| 42 |
assert.ok(!text.includes('fill29 done'), 'scrolled away from the newest entry');
|
| 43 |
|
| 44 |
-
// New activity arrives
|
| 45 |
agent.emit('tool:done', 'fresh', {});
|
| 46 |
text = await render(out);
|
| 47 |
assert.ok(text.includes('fresh done'), 'auto-follow pulled the view back to the newest entry');
|
|
|
|
| 41 |
text = await render(out);
|
| 42 |
assert.ok(!text.includes('fill29 done'), 'scrolled away from the newest entry');
|
| 43 |
|
| 44 |
+
// New activity arrives the view must snap back to the bottom.
|
| 45 |
agent.emit('tool:done', 'fresh', {});
|
| 46 |
text = await render(out);
|
| 47 |
assert.ok(text.includes('fresh done'), 'auto-follow pulled the view back to the newest entry');
|
|
@@ -21,8 +21,8 @@ mona-agent is a **headless Node.js daemon** with two jobs:
|
|
| 21 |
│ │ ControlChannel│◄─►│ tools/ │ │ │ /api/v1/agent/stats │
|
| 22 |
│ │ (HTTPS + WS) │ │ files │ │ │ /api/v1/agent/chat … │
|
| 23 |
│ │ │ │ shell │ │ │ │
|
| 24 |
-
│ │
|
| 25 |
-
│ │
|
| 26 |
│ └──────────────┘ └────────────┘ │ │ Key vault (AES-256) │
|
| 27 |
│ │ │ │ Audit log │
|
| 28 |
│ ▼ │ └──────────────────────────────┘
|
|
@@ -71,7 +71,7 @@ CLI, or the cloud queue):
|
|
| 71 |
```
|
| 72 |
┌───────────────────────────────────────────────────┐
|
| 73 |
│ mona.expert brain │
|
| 74 |
-
│ reason
|
| 75 |
└───────────────┬───────────────────────▲──────────┘
|
| 76 |
task (HTTPS) │ │ tool result
|
| 77 |
┌───────────────▼───────────────────────┴──────────┐
|
|
|
|
| 21 |
│ │ ControlChannel│◄─►│ tools/ │ │ │ /api/v1/agent/stats │
|
| 22 |
│ │ (HTTPS + WS) │ │ files │ │ │ /api/v1/agent/chat … │
|
| 23 |
│ │ │ │ shell │ │ │ │
|
| 24 |
+
│ │ metrics │ │ net │ │ │ AI engine (the brain) │
|
| 25 |
+
│ │ commands │ │ sysinfo │ │ │ Dashboard + device overview │
|
| 26 |
│ └──────────────┘ └────────────┘ │ │ Key vault (AES-256) │
|
| 27 |
│ │ │ │ Audit log │
|
| 28 |
│ ▼ │ └──────────────────────────────┘
|
|
|
|
| 71 |
```
|
| 72 |
┌───────────────────────────────────────────────────┐
|
| 73 |
│ mona.expert brain │
|
| 74 |
+
│ reason answer in text OR emit one tool call │
|
| 75 |
└───────────────┬───────────────────────▲──────────┘
|
| 76 |
task (HTTPS) │ │ tool result
|
| 77 |
┌───────────────▼───────────────────────┴──────────┐
|
|
@@ -11,7 +11,7 @@ Format: [Keep a Changelog](https://keepachangelog.com), versioning:
|
|
| 11 |
- **Agentic execution loop** — the device is no longer a listener; it's an
|
| 12 |
operator. Tasks from the dashboard flow into a cloud task queue, the
|
| 13 |
device claims them within seconds, and the mona.expert brain plans the
|
| 14 |
-
work: think
|
| 15 |
every step streamed to the dashboard activity feed in real time.
|
| 16 |
- **Cloud task queue (WS-free command channel)** — devices poll for work
|
| 17 |
every 2 s over HTTPS, so command execution works on every hosting
|
|
|
|
| 11 |
- **Agentic execution loop** — the device is no longer a listener; it's an
|
| 12 |
operator. Tasks from the dashboard flow into a cloud task queue, the
|
| 13 |
device claims them within seconds, and the mona.expert brain plans the
|
| 14 |
+
work: think act observe deliver. Up to 8 tool steps per task, with
|
| 15 |
every step streamed to the dashboard activity feed in real time.
|
| 16 |
- **Cloud task queue (WS-free command channel)** — devices poll for work
|
| 17 |
every 2 s over HTTPS, so command execution works on every hosting
|
|
@@ -14,7 +14,7 @@ cloud has a free tier.
|
|
| 14 |
## Does mona-agent need an API key?
|
| 15 |
|
| 16 |
It needs **one key**: your mona.expert device token (created in the
|
| 17 |
-
dashboard
|
| 18 |
the cloud vault — never on your device.
|
| 19 |
|
| 20 |
## Where is my key stored?
|
|
|
|
| 14 |
## Does mona-agent need an API key?
|
| 15 |
|
| 16 |
It needs **one key**: your mona.expert device token (created in the
|
| 17 |
+
dashboard Settings). AI provider keys (OpenAI, Anthropic, …) live only in
|
| 18 |
the cloud vault — never on your device.
|
| 19 |
|
| 20 |
## Where is my key stored?
|
|
@@ -9,7 +9,7 @@ have your machine connected to the cloud in under a minute.
|
|
| 9 |
- macOS: `brew install node`
|
| 10 |
- Ubuntu/Debian: `curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt install nodejs`
|
| 11 |
- Windows: use [WSL2](https://learn.microsoft.com/windows/wsl/install) (native Windows Git Bash works too)
|
| 12 |
-
- An account + **API key** at [agent.mona.expert](https://agent.mona.expert/dashboard)
|
| 13 |
|
| 14 |
## 2. Install
|
| 15 |
|
|
@@ -54,7 +54,7 @@ CPU, memory, disk and load — and a chat window connected to the cloud brain.
|
|
| 54 |
| `mona-agent: command not found` | Re-open your terminal, or run `export PATH="$HOME/.local/bin:$PATH"` |
|
| 55 |
| `Node.js 20+ required` | Upgrade Node (`brew upgrade node` / nodesource) |
|
| 56 |
| Agent connects but dashboard shows no device | Confirm you ran `mona-agent login` with the key from your account |
|
| 57 |
-
| Metrics stream, chat replies "No API key configured" | Add an AI provider key in the dashboard
|
| 58 |
| Firewall / corporate proxy | Set `MONA_CLOUD=https://agent.mona.expert` and check HTTPS egress |
|
| 59 |
|
| 60 |
## Uninstall
|
|
|
|
| 9 |
- macOS: `brew install node`
|
| 10 |
- Ubuntu/Debian: `curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt install nodejs`
|
| 11 |
- Windows: use [WSL2](https://learn.microsoft.com/windows/wsl/install) (native Windows Git Bash works too)
|
| 12 |
+
- An account + **API key** at [agent.mona.expert](https://agent.mona.expert/dashboard) Settings.
|
| 13 |
|
| 14 |
## 2. Install
|
| 15 |
|
|
|
|
| 54 |
| `mona-agent: command not found` | Re-open your terminal, or run `export PATH="$HOME/.local/bin:$PATH"` |
|
| 55 |
| `Node.js 20+ required` | Upgrade Node (`brew upgrade node` / nodesource) |
|
| 56 |
| Agent connects but dashboard shows no device | Confirm you ran `mona-agent login` with the key from your account |
|
| 57 |
+
| Metrics stream, chat replies "No API key configured" | Add an AI provider key in the dashboard Settings (the cloud brain needs one) |
|
| 58 |
| Firewall / corporate proxy | Set `MONA_CLOUD=https://agent.mona.expert` and check HTTPS egress |
|
| 59 |
|
| 60 |
## Uninstall
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
# GitHub Pages config for the docs site (optional)
|
| 2 |
-
# Enable in repo Settings
|
| 3 |
# at https://monaexpert.github.io/agent/
|
| 4 |
title: mona-agent — cloud-brained AI agent for your device
|
| 5 |
description: >-
|
|
|
|
| 1 |
# GitHub Pages config for the docs site (optional)
|
| 2 |
+
# Enable in repo Settings Pages Source: main /docs to publish
|
| 3 |
# at https://monaexpert.github.io/agent/
|
| 4 |
title: mona-agent — cloud-brained AI agent for your device
|
| 5 |
description: >-
|
|
@@ -16,7 +16,7 @@
|
|
| 16 |
"start": "node apps/desktop/bin/mona-agent.js start",
|
| 17 |
"gui": "node apps/desktop/bin/mona-agent.js gui",
|
| 18 |
"login": "node apps/desktop/bin/mona-agent.js login",
|
| 19 |
-
"test": "node --test apps/desktop/test/agent.test.mjs apps/desktop/test/control.test.mjs apps/desktop/test/docker-control.test.mjs apps/desktop/test/tui-scroll.test.mjs"
|
| 20 |
},
|
| 21 |
"engines": {
|
| 22 |
"node": ">=20"
|
|
|
|
| 16 |
"start": "node apps/desktop/bin/mona-agent.js start",
|
| 17 |
"gui": "node apps/desktop/bin/mona-agent.js gui",
|
| 18 |
"login": "node apps/desktop/bin/mona-agent.js login",
|
| 19 |
+
"test": "node --test apps/desktop/test/agent.test.mjs apps/desktop/test/control.test.mjs apps/desktop/test/docker-control.test.mjs apps/desktop/test/tui-scroll.test.mjs apps/desktop/test/tools-new.test.mjs"
|
| 20 |
},
|
| 21 |
"engines": {
|
| 22 |
"node": ">=20"
|
|
@@ -34,7 +34,7 @@ export class EngineClient {
|
|
| 34 |
|
| 35 |
async authenticate(keyOverride) {
|
| 36 |
const key = keyOverride || this.key;
|
| 37 |
-
if (!key) return { ok: false, detail: 'No mona.expert key is stored. Add it under Settings
|
| 38 |
try {
|
| 39 |
await this.request('/v1/health', { key, timeoutMs: 10000 });
|
| 40 |
return { ok: true, detail: 'Engine reachable.' };
|
|
|
|
| 34 |
|
| 35 |
async authenticate(keyOverride) {
|
| 36 |
const key = keyOverride || this.key;
|
| 37 |
+
if (!key) return { ok: false, detail: 'No mona.expert key is stored. Add it under Settings Engine key.' };
|
| 38 |
try {
|
| 39 |
await this.request('/v1/health', { key, timeoutMs: 10000 });
|
| 40 |
return { ok: true, detail: 'Engine reachable.' };
|
|
@@ -5,8 +5,8 @@ import { estimateTokens } from './sse.mjs';
|
|
| 5 |
* (assertProductionReady refuses SIMULATED_ENGINE_ENABLED=true in prod).
|
| 6 |
*
|
| 7 |
* It is NOT a language model. It exercises the full engine loop offline:
|
| 8 |
-
* gateway auth
|
| 9 |
-
* device
|
| 10 |
*
|
| 11 |
* Tool-call trigger: a user message containing `tool: <name>` (e.g. `tool: sysinfo`)
|
| 12 |
* issues one tool_call for the named device tool; the tool result is echoed into
|
|
@@ -34,7 +34,7 @@ function compose({ messages, system, agentName }) {
|
|
| 34 |
lines.push(`The mona.expert engine would answer here and pick the model itself.`);
|
| 35 |
lines.push(`Echo for verification: *${words.slice(0, 12).join(' ')}${words.length > 12 ? '…' : ''}*`);
|
| 36 |
if (/hi|hello|hey/i.test(last)) {
|
| 37 |
-
lines.push('', 'Connected end to end: device
|
| 38 |
}
|
| 39 |
return lines.join('\n');
|
| 40 |
}
|
|
|
|
| 5 |
* (assertProductionReady refuses SIMULATED_ENGINE_ENABLED=true in prod).
|
| 6 |
*
|
| 7 |
* It is NOT a language model. It exercises the full engine loop offline:
|
| 8 |
+
* gateway auth conversation history engine call tool_calls command
|
| 9 |
+
* device tool result engine continuation streaming usage audit.
|
| 10 |
*
|
| 11 |
* Tool-call trigger: a user message containing `tool: <name>` (e.g. `tool: sysinfo`)
|
| 12 |
* issues one tool_call for the named device tool; the tool result is echoed into
|
|
|
|
| 34 |
lines.push(`The mona.expert engine would answer here and pick the model itself.`);
|
| 35 |
lines.push(`Echo for verification: *${words.slice(0, 12).join(' ')}${words.length > 12 ? '…' : ''}*`);
|
| 36 |
if (/hi|hello|hey/i.test(last)) {
|
| 37 |
+
lines.push('', 'Connected end to end: device gateway engine back. Try `tool: sysinfo` to run a real command on the device.');
|
| 38 |
}
|
| 39 |
return lines.join('\n');
|
| 40 |
}
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
/**
|
| 2 |
-
* mona.expert wire contract — the single source of truth for the device
|
| 3 |
* WebSocket protocol. The daemon (apps/desktop) and the gateway (apps/gateway)
|
| 4 |
* implement this; `test/protocol.test.mjs` fails if they drift apart.
|
| 5 |
*
|
|
@@ -20,22 +20,22 @@ export const CLOSE_CODES = Object.freeze({
|
|
| 20 |
export const isTerminalClose = (code) =>
|
| 21 |
code === CLOSE_CODES.UNAUTHORIZED || code === CLOSE_CODES.REVOKED || code === CLOSE_CODES.PROTOCOL;
|
| 22 |
|
| 23 |
-
/** Message types. `device
|
| 24 |
export const TYPES = Object.freeze({
|
| 25 |
-
/* handshake (device
|
| 26 |
HELLO: 'hello',
|
| 27 |
-
HELLO_OK: 'hello.ok', // gateway
|
| 28 |
/* registration */
|
| 29 |
-
REGISTER: 'register', // device
|
| 30 |
-
/* commands (gateway
|
| 31 |
-
COMMAND: 'command', // gateway
|
| 32 |
-
COMMAND_RESULT: 'command.result', // device
|
| 33 |
-
COMMAND_ERROR: 'command.error', // device
|
| 34 |
/* reasoning stream (device reports steps the engine asked it to take) */
|
| 35 |
-
AGENT_STEP: 'agent.step', // device
|
| 36 |
-
AGENT_TOKEN: 'agent.token', // device
|
| 37 |
-
AGENT_RESULT: 'agent.result', // device
|
| 38 |
-
/* telemetry (device
|
| 39 |
DEVICE_METRICS: 'device.metrics',
|
| 40 |
/* liveness */
|
| 41 |
PING: 'ping',
|
|
|
|
| 1 |
/**
|
| 2 |
+
* mona.expert wire contract — the single source of truth for the device gateway
|
| 3 |
* WebSocket protocol. The daemon (apps/desktop) and the gateway (apps/gateway)
|
| 4 |
* implement this; `test/protocol.test.mjs` fails if they drift apart.
|
| 5 |
*
|
|
|
|
| 20 |
export const isTerminalClose = (code) =>
|
| 21 |
code === CLOSE_CODES.UNAUTHORIZED || code === CLOSE_CODES.REVOKED || code === CLOSE_CODES.PROTOCOL;
|
| 22 |
|
| 23 |
+
/** Message types. `device ` frames are sent by the daemon; `gateway ` by the control plane. */
|
| 24 |
export const TYPES = Object.freeze({
|
| 25 |
+
/* handshake (device ) */
|
| 26 |
HELLO: 'hello',
|
| 27 |
+
HELLO_OK: 'hello.ok', // gateway { heartbeatIntervalMs, permissions }
|
| 28 |
/* registration */
|
| 29 |
+
REGISTER: 'register', // device device fingerprint / name
|
| 30 |
+
/* commands (gateway device gateway) */
|
| 31 |
+
COMMAND: 'command', // gateway { id, tool, args, timeoutMs, requestId }
|
| 32 |
+
COMMAND_RESULT: 'command.result', // device { id, ok, output, error, durationMs }
|
| 33 |
+
COMMAND_ERROR: 'command.error', // device { id, error }
|
| 34 |
/* reasoning stream (device reports steps the engine asked it to take) */
|
| 35 |
+
AGENT_STEP: 'agent.step', // device { runId, name, detail }
|
| 36 |
+
AGENT_TOKEN: 'agent.token', // device { runId, text } (kept for TUI streaming)
|
| 37 |
+
AGENT_RESULT: 'agent.result', // device { runId, ok, text, usage, error }
|
| 38 |
+
/* telemetry (device ) */
|
| 39 |
DEVICE_METRICS: 'device.metrics',
|
| 40 |
/* liveness */
|
| 41 |
PING: 'ping',
|