local-commander-agent 0.3.13-launcher.1 → 0.3.14-cli.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -8
- package/bin/localcommander.mjs +28 -0
- package/bin/monitor-context.d.mts +2 -0
- package/bin/monitor-context.mjs +19 -0
- package/bin/monitor-mcp.mjs +61 -5
- package/bin/monitor.mjs +96 -27
- package/package.json +1 -1
- package/runtime/JobHost.exe +0 -0
- package/runtime/LocalCommanderAgent.exe +0 -0
- package/runtime/OwnerBridge.exe +0 -0
- package/runtime/agent.mjs +603 -341
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
> **Requires the Local Commander service at https://localcmd.app.** This npm package is its Windows companion, not a standalone MCP server, an offline AI assistant, or a JavaScript SDK. Installing the package alone is not enough: you need a Local Commander account, an approved device pairing, a running agent, an internet connection, and an authorized MCP connection from your AI client. Local management commands such as status and stop do not require the service to be online.
|
|
6
6
|
|
|
7
|
-
[Website and sign-in](https://localcmd.app)
|
|
7
|
+
[Website and sign-in](https://localcmd.app) ┬╖ [Support](https://localcmd.app/support) ┬╖ [Privacy](https://localcmd.app/privacy)
|
|
8
8
|
|
|
9
9
|
## Before you start
|
|
10
10
|
|
|
@@ -34,9 +34,9 @@ Use **Windows 11 x64** and a currently supported Node.js LTS version meeting the
|
|
|
34
34
|
|
|
35
35
|
You can close the installation terminal. The agent runs in the background and starts when this Windows user signs in. The PC must be awake and online for remote AI tasks. As a first test, ask the connected assistant to list the files in a folder you approved.
|
|
36
36
|
|
|
37
|
-
Installation and update also add `localcommander` to your Windows **user PATH
|
|
37
|
+
Installation and update also add `localcommander` to your Windows **user PATH**. Open a new CMD/terminal window, then run `localcommander status`, `localcommander monitor --watch`, or `localcommander logs --follow` from any folder. No global npm installation is needed. The launcher uses your required Node.js installation and an installed copy of the CLI, so clearing npm's cache does not remove the command. Fetch newer releases with `npx local-commander-agent@latest update`. Uninstall removes Local Commander's PATH entry and launcher while preserving your other entries.
|
|
38
38
|
|
|
39
|
-
## What
|
|
39
|
+
## What тАЬall connected drivesтАЭ means
|
|
40
40
|
|
|
41
41
|
It expands the **filesystem scope** to the drive letters shown at the moment you consent. It includes available local fixed and removable drives. Mapped network drives and `subst` letters are not included. New drive letters, unavailable drives and unmounted volumes are not automatically added; run `configure-access` again after making the desired drive available. Grants are path/drive-letter based, not tied to a particular removable disk's hardware identity: reusing an approved letter gives access under that letter.
|
|
42
42
|
|
|
@@ -90,6 +90,32 @@ npx local-commander-agent@latest deny-localhost <workspaceGrantId> 3000
|
|
|
90
90
|
|
|
91
91
|
`allow-command` associates an exact executable with the name the assistant uses; it does not accept a shell command string. Capability changes are local owner actions, not MCP settings the assistant can silently widen. The all-drives decision always requires the local dialog; there is no `--yes` or environment flag to approve it.
|
|
92
92
|
|
|
93
|
+
### Approve Git from CMD or PowerShell
|
|
94
|
+
|
|
95
|
+
When giving an owner Windows commands, identify the shell before using shell-specific syntax and label the example. Prefer explicit workspace IDs and quoted absolute executable paths that work in both CMD and PowerShell; do not assume a standard installation path.
|
|
96
|
+
|
|
97
|
+
Use the example for the shell you are running. A prompt such as `C:\project>` is normally **Command Prompt (CMD)**; `PS C:\project>` is normally **PowerShell**. Windows Terminal can host either shell.
|
|
98
|
+
|
|
99
|
+
First run `npx local-commander-agent@latest workspaces` and copy the intended workspace's grant ID.
|
|
100
|
+
|
|
101
|
+
**CMD:** run `where git.exe`. If it lists several paths, choose the Git executable you intend to approve. Replace `WORKSPACE_ID` and the example path below with those actual values; keep the path in double quotes.
|
|
102
|
+
|
|
103
|
+
```cmd
|
|
104
|
+
where git.exe
|
|
105
|
+
npx local-commander-agent@latest allow-command WORKSPACE_ID git "C:\Path To Git\git.exe"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**PowerShell:** replace `WORKSPACE_ID` with the grant ID. This selects the first Git executable found on PATH and passes its path as one argument. Inspect the other matches with `Get-Command git.exe -CommandType Application` if you intend to approve a different installation. `npx.cmd` explicitly selects npm's batch launcher.
|
|
109
|
+
|
|
110
|
+
```powershell
|
|
111
|
+
$gitExe = @(Get-Command git.exe -CommandType Application -ErrorAction Stop)[0].Source
|
|
112
|
+
npx.cmd local-commander-agent@latest allow-command WORKSPACE_ID git "$gitExe"
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Do not paste `(Get-Command git).Source` into CMD: CMD passes it literally instead of resolving an executable. Local Commander accepts a path, never evaluates a shell expression, and still requires the local owner confirmation. If lookup finds no Git executable, stop and locate the installed program before approving it.
|
|
116
|
+
|
|
117
|
+
`allow-command --help` shows these instructions without requiring an installed agent or opening a permission dialog. Malformed arguments keep the `ARGUMENT_INVALID` error and nonzero exit status, followed by usage and shell-specific guidance. Diagnostic output does not repeat the supplied arguments. These diagnostics require a release containing this fix; editing the documentation does not update an installed CLI.
|
|
118
|
+
|
|
93
119
|
Under full access, `deny-command` and `deny-localhost` exclude the named command or port while other commands/ports remain available. Native `.exe`/`.com` commands receive argument arrays directly. `.cmd`/`.bat` launchers use a hidden PowerShell wrapper and reject cmd metacharacters in arguments; use an explicit interpreter for more complex scripts. Allowing a general interpreter is not containment against it launching a denied program.
|
|
94
120
|
|
|
95
121
|
Full access does not include Windows shells, script hosts or administration binaries (`cmd`, `powershell`, `pwsh`, `wscript`, `cscript`, `mshta`, `rundll32`, `regsvr32`, `certutil`, `bitsadmin`, `msiexec`, `wmic`, `reg`, `schtasks`, `sc`, `net`, `netsh`, `icacls`, `takeown`, `runas`, `psexec`, `wsl`, `bash`, `sh`, `dism`, `winrm` and similar). An assistant request for one of these, by bare name or absolute path, fails with `COMMAND_NOT_ALLOWED` until you register that exact executable yourself with `allow-command <workspace-id> <name> <absolute .exe path>` and confirm the native dialog; it then runs only under the registered name or that exact path. Ordinary developer tools (`node`, `git`, `npm`, `python`, `dotnet`, ...) continue to resolve from your absolute `PATH` entries.
|
|
@@ -107,8 +133,8 @@ The MCP discovery sequence is `workspace_list`, `diagnostics`, `execution_sessio
|
|
|
107
133
|
| `STATE_CHANGED` | Local account/policy/permissions changed while the dialog was open. Review current state and reopen the dialog. |
|
|
108
134
|
| `WORKSPACE_INVALID` | Choose an existing ordinary folder, not a link or protected state directory. Use the all-drives choice for drive roots. |
|
|
109
135
|
| `LOCAL_PERMISSION_REQUIRED` | Inspect `workspaces`; enable only the exact write/command/localhost capability needed. |
|
|
136
|
+
| `ARGUMENT_INVALID` from `allow-command` | Supply the workspace ID, command name and one quoted absolute executable path. Use `where git.exe` in CMD or `Get-Command git.exe` in PowerShell; see the shell-specific examples above and `allow-command --help`. |
|
|
110
137
|
| `PATH_NOT_ALLOWED` | The target is outside the grant, resolves elsewhere through a link, or is protected. Do not bypass the boundary with another tool. |
|
|
111
|
-
| The Start menu entry is missing, or opens and closes immediately | Run `localcommander monitor --watch` in a new terminal. The entry runs a local PowerShell wrapper; an organization policy that blocks local scripts, or a same-name shortcut created by something else, prevents it. Rerun `npx local-commander-agent@latest update` to recreate it. |
|
|
112
138
|
| Paired and running, but the AI cannot reach the PC | Check that the PC is awake/online and that the AI client's remote MCP connection is authorized to the same account. Check service status/support. |
|
|
113
139
|
|
|
114
140
|
For help, use [Local Commander support](https://localcmd.app/support) or email [peter.mamrukov@localcmd.app](mailto:peter.mamrukov@localcmd.app). Share error codes and the installed version, not keys, tokens, pairing codes, private state files or unredacted logs.
|
|
@@ -140,7 +166,7 @@ From the development checkout, `npm run monitor` opens this live view using the
|
|
|
140
166
|
|
|
141
167
|
The live view also reads registered runtime presence from the current project's `.local-commander/agents.json` and native workers from the configured agent state's `.local-commander/fabric/mailbox.json`. It observes live local `codex`, `claude` and `cursor-agent` processes through hidden bounded Windows process metadata reads. It shows registered agents and observed hosts even while they have no active Local Commander command. Host display IDs are stable eight-character hashes of computer/name/PID/start time; they are process observations, not server-issued AI identities. CPU activity describes measured local CPU use; waiting on a provider or thinking is not inferred.
|
|
142
168
|
|
|
143
|
-
Reported task activity, confirmed worker model attribution and confirmed execution IDs are used when available. Linked commands appear beneath their agents; only measured savings from those exact linked executions contribute to an agent row. Unlinked host model/savings remain unavailable. A `+` on an agent savings figure marks partial retained execution coverage. The last finished command remains visible with its completion time, so short commands do not disappear between refreshes. `--json` includes all bounded agent/host rows, including those omitted for screen size. A missing/denied/corrupt presence source is isolated and reported as unavailable; the monitor never initializes or mutates those registries and never reads capabilities, credentials
|
|
169
|
+
Reported task activity, confirmed worker model attribution and confirmed execution IDs are used when available. Linked commands appear beneath their agents; only measured savings from those exact linked executions contribute to an agent row. Unlinked host model/savings remain unavailable. A `+` on an agent savings figure marks partial retained execution coverage. The last finished command remains visible with its completion time, so short commands do not disappear between refreshes. `--json` includes all bounded agent/host rows, including those omitted for screen size. A missing/denied/corrupt presence source is isolated and reported as unavailable; the monitor never initializes or mutates those registries and never reads capabilities, credentials or private chat histories. Current commands use bounded redacted previews emitted by the tool runtime.
|
|
144
170
|
|
|
145
171
|
The navy/cyan ASCII view groups requested/prepared/accepted/running processes beneath each computer. Each row shows its eight-character work ID (the execution ID prefix), actual child PID when available, command summary, declared model and estimated tokens saved. Connected idle computers show READY. The fixed footer reports Raw/Sent/Saved token estimates and savings percentage from measured output bytes for this run; partial totals are marked when some companions have older snapshots. Completed, rejected, unknown, stale and stopped executions stay out of this screen. Freshness follows authenticated transport activity when the relay advertises monitorHeartbeatV1; three missed negotiated heartbeat intervals mark it stale. Fresh local writes cannot renew transport proof. Older peers/snapshots retain the ten-second rule. Work IDs identify executions; sessions are not AI instance identities. Only computers with snapshots available locally can appear.
|
|
146
172
|
|
|
@@ -152,7 +178,7 @@ Savings use the existing output-compaction ledger: original/sent bytes are measu
|
|
|
152
178
|
|
|
153
179
|
The runtime reuses its last 64 shaped terminal results for later completed output reads, so repeated reads match the counted compacted result. The encrypted journal retains the original output. After restart or result-cache eviction, reads fall back to that original output; no historical model attribution or extra savings is invented.
|
|
154
180
|
|
|
155
|
-
Each instance retains its last 64 observed executions, 100 events, 32 sessions and up to 32 model groups in `%LOCALAPPDATA%\LocalCommander\Monitor`.
|
|
181
|
+
Each instance retains its last 64 observed executions, 100 events, 32 sessions and up to 32 model groups in `%LOCALAPPDATA%\LocalCommander\Monitor`. Legacy command classes remain compatible; optional previews show actual executable and simple arguments (24 arguments, 240 characters maximum). Inline scripts, credential flags/paths, headers, message bodies and other payload arguments are redacted. Execution result code, timeout, elapsed time and sanitized working directory provide context without retaining tool output. Child PIDs are runtime notifications; the companion PID is identified separately.
|
|
156
182
|
|
|
157
183
|
`logs` shows recent events, model/command classifications and measured compaction or passthrough sizes. Its initial review lists the five largest remaining outputs and suggests focused file reads/searches, lower command verbosity or less polling when the observed counts justify it. These are advisory review candidates, not proven avoidable tokens, automatic command changes or caching. `logs --follow` reports overwritten-history gaps. Last snapshots remain inspectable after a stop, and a new run replaces that state directory's snapshot. `monitor --json` includes all bounded observations, including finished work omitted from the live screen.
|
|
158
184
|
|
|
@@ -161,11 +187,17 @@ An older installed agent produces no monitoring snapshot even if it is running;
|
|
|
161
187
|
|
|
162
188
|
### Meaningful MCP activity
|
|
163
189
|
|
|
164
|
-
The terminal also reads the personal MCP server's bounded `.local-commander/activity/*.json` snapshots. It shows actual scheduled/queued calls, command classes, managed background processes through exit, recent failures and message-event metadata. The protected audit log and message bodies are not scanned. Snapshots coalesce changes for250ms;
|
|
190
|
+
The terminal also reads the personal MCP server's bounded `.local-commander/activity/*.json` snapshots. It shows actual scheduled/queued calls, command classes, managed background processes through exit, recent failures and message-event metadata. The protected audit log and message bodies are not scanned. Snapshots coalesce changes for250ms. The health row uses the existing loopback health endpoint, cached for30seconds with an800ms timeout; idle activity snapshots are not a failed health check. Relay connection/freshness, queue, diagnostic write errors and dropped events are separate facts; no AI parsing. `localcommander logs --follow` prints bounded completion history and updates deterministic optimization hints every five completions: largest response/output sources, repeated polling, calls/errors, tool-plus-queue duration and measured stdout savings. Hints propose review, not unproven extra savings; command status, failures, warnings and required detail must stay intact.
|
|
165
191
|
|
|
166
192
|
`agent_presence` allocates a stable alias of at most eight ASCII characters from eighty Jewish, Russian, Arabic, Hindi and Chinese transliterations. The random server-issued runtime UUID selects the pool locally (for example `Yael48` or `Priyabf`); collision resolution is bounded. Labels are optional. Reuse the existing `runtimeId`/`runtimeKey` pair on presence refresh and pass `agent: { runtimeId, runtimeKey }` on ordinary tools, or the same pair in request `_meta.localCommander`. The key stays private and is not retained in monitor snapshots. No second identity/name handshake occurs. `agent_message.to` accepts an active alias as well as existing runtime/role addresses. Names are display/routing hints; UUID/key authentication and ordinary permission checks remain authoritative.
|
|
167
193
|
|
|
168
|
-
Presence can declare `model`, `project`, `taskId`, `activity` and `phase` (`working`, `analysis`, `message`, `idle`). Model and analysis are labeled reports; the monitor does not read private thoughts or infer them from CPU. Calls without a binding remain visible as **unbound**. Observed CLI hosts get stable short display aliases but remain host observations; their task/model is unavailable without a reporter. Task-log filenames and PR-bearing directory/worktree names are parsed with bounded regex; inferred PRs are marked `(path)`. Sanitized directory context appears beneath current work,
|
|
194
|
+
Presence can declare `model`, `project`, `taskId`, `activity` and `phase` (`working`, `analysis`, `message`, `idle`). Model and analysis are labeled reports; the monitor does not read private thoughts or infer them from CPU. Calls without a binding remain visible as **unbound**. Observed CLI hosts get stable short display aliases but remain host observations; their task/model is unavailable without a reporter. Task-log filenames and PR-bearing directory/worktree names are parsed with bounded regex; inferred PRs are marked `(path)`. Sanitized directory context appears beneath current work; bounded simple command arguments appear in redacted previews, while credentials, inline scripts and payloads stay hidden.
|
|
195
|
+
|
|
196
|
+
The INVESTIGATION panel follows the selected PC: task/project/PR, working directory, current action, then recent outcomes with elapsed time and exit/error codes. Space freezes/resumes the view, Tab focuses the next PC, A shows all PCs and L expands/collapses history. These keys control the investigation view; command dispatch/cancellation still uses the existing authorized MCP tools. Missing model labels say not reported; client presence or explicit batch metadata must declare a known model.
|
|
197
|
+
|
|
198
|
+
Initial agent_presence and native project_snapshot startup context return a small per-PC memory: Windows/OS, architecture, Node, approved executable availability, eight worked commands and eight recent failures. Facts cache for60seconds;32 outcome signatures persist for30days in protected state. Refresh presence with includeHostMemory:true after a failure or environment change; routine refreshes omit it. Only fully completed exit0 counts as worked. Memory is advice and never widens permission. Package versions are not inferred.
|
|
199
|
+
|
|
200
|
+
Successful complete stdout can reuse the existing TAP compactor or a lossless JSON whitespace compactor. It preserves duplicate keys, number/string lexemes, stderr and result status; executed commands never change. The optional operator capture runs once for600seconds only with LCMCP_TRAFFIC_CAPTURE_SECONDS=600, retaining bounded pseudonymous authenticated MCP/device-relay metadata rather than raw bodies. Production capture is now off; its observed JSON outputs were already compact, so no extra production JSON savings were measured.
|
|
169
201
|
|
|
170
202
|
Response bytes count the JSON tool text once (batch child responses are delivered inside the parent). Output bytes and saved stdout bytes have separate counters; tokens are estimates at bytes/4. Runtime/model groups retain their original model declaration, so changing a reported model cannot reattribute earlier savings. Limits:256 in-flight calls,64 agent/model groups and managed process projections,100 completion events,32 source files,1MiB per snapshot. Dropped observations and diagnostic write failures are reported in log insights. Snapshot write failure cannot fail a tool.
|
|
171
203
|
|
package/bin/localcommander.mjs
CHANGED
|
@@ -411,6 +411,24 @@ async function uninstall(purgeState) {
|
|
|
411
411
|
}
|
|
412
412
|
process.stdout.write(purgeState ? "Local Commander removed, including local enrollment state.\n" : "Local Commander removed. Protected enrollment state was preserved.\n");
|
|
413
413
|
}
|
|
414
|
+
function allowCommandHelp() {
|
|
415
|
+
return [
|
|
416
|
+
"Usage: npx local-commander-agent@latest allow-command <workspaceGrantId> <name> <absolute-executable-path>",
|
|
417
|
+
"Exactly three arguments are required. Quote executable paths containing spaces.",
|
|
418
|
+
"Find the workspace ID with: npx local-commander-agent@latest workspaces",
|
|
419
|
+
"",
|
|
420
|
+
"CMD: find Git with where git.exe, then copy the intended full path:",
|
|
421
|
+
' npx local-commander-agent@latest allow-command WORKSPACE_ID git "C:\\Path To Git\\git.exe"',
|
|
422
|
+
"Replace WORKSPACE_ID and the example path with your actual values.",
|
|
423
|
+
"",
|
|
424
|
+
"PowerShell only:",
|
|
425
|
+
" $gitExe = @(Get-Command git.exe -CommandType Application -ErrorAction Stop)[0].Source",
|
|
426
|
+
' npx.cmd local-commander-agent@latest allow-command WORKSPACE_ID git "$gitExe"',
|
|
427
|
+
"CMD does not evaluate (Get-Command ...).Source. Local Commander never evaluates shell expressions.",
|
|
428
|
+
"A valid command still requires local owner approval.",
|
|
429
|
+
""
|
|
430
|
+
].join("\n");
|
|
431
|
+
}
|
|
414
432
|
function help() {
|
|
415
433
|
return [
|
|
416
434
|
"Local Commander agent " + VERSION,
|
|
@@ -433,6 +451,7 @@ function help() {
|
|
|
433
451
|
" localcommander fabric-worker restricted worker request through private stdin",
|
|
434
452
|
" npx local-commander-agent@latest allow-write <workspaceGrantId>",
|
|
435
453
|
" npx local-commander-agent@latest allow-command <workspaceGrantId> <name> <absolute-executable-path>",
|
|
454
|
+
" npx local-commander-agent@latest allow-command --help CMD and PowerShell examples",
|
|
436
455
|
" npx local-commander-agent@latest allow-localhost <workspaceGrantId> <port>",
|
|
437
456
|
" npx local-commander-agent@latest deny-write|deny-command|deny-localhost ...",
|
|
438
457
|
" npx local-commander-agent@latest uninstall [--purge-state]",
|
|
@@ -448,6 +467,14 @@ async function main() {
|
|
|
448
467
|
process.stdout.write(help());
|
|
449
468
|
return;
|
|
450
469
|
}
|
|
470
|
+
if (command === "allow-command") {
|
|
471
|
+
if (args.length === 1 && ["--help", "-h"].includes(args[0])) {
|
|
472
|
+
process.stdout.write(allowCommandHelp());
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
// Reject shell fragments before reading installed state or opening owner consent.
|
|
476
|
+
if (args.length !== PERMISSION_COMMANDS[command] || !path.win32.isAbsolute(args[2])) fail("ARGUMENT_INVALID");
|
|
477
|
+
}
|
|
451
478
|
if (command === "bootstrap" || command === "install") {
|
|
452
479
|
if (args.length) fail("ARGUMENT_INVALID");
|
|
453
480
|
await bootstrap();
|
|
@@ -530,5 +557,6 @@ async function main() {
|
|
|
530
557
|
void main().catch(error => {
|
|
531
558
|
const code = error && typeof error.message === "string" && /^[A-Z0-9_]{1,80}$/u.test(error.message) ? error.message : "LOCAL_COMMANDER_FAILED";
|
|
532
559
|
process.stderr.write(code + "\n");
|
|
560
|
+
if (code === "ARGUMENT_INVALID" && process.argv[2] === "allow-command") process.stderr.write(allowCommandHelp());
|
|
533
561
|
process.exitCode = 1;
|
|
534
562
|
});
|
|
@@ -3,3 +3,5 @@ export function agentName(id: string): string;
|
|
|
3
3
|
export interface MonitorContext { project: string | null; directory: string | null; taskId: string | null; pr: number | null; inferred: boolean }
|
|
4
4
|
export function contextSummary(meta?: Record<string, unknown>): MonitorContext;
|
|
5
5
|
export function commandSummary(tool: string, meta?: Record<string, unknown>): string;
|
|
6
|
+
|
|
7
|
+
export function commandPreview(tool: string, meta?: Record<string, unknown>): string;
|
package/bin/monitor-context.mjs
CHANGED
|
@@ -43,3 +43,22 @@ export function commandSummary(tool, meta = {}) {
|
|
|
43
43
|
if (name === "npm" && args[0] === "run" && ["test", "build", "typecheck", "lint", "dev", "start"].includes(args[1])) return "npm run " + args[1];
|
|
44
44
|
return name + (args.length ? " [args]" : "");
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/** Diagnostic preview only. Never used to construct or execute a command. */
|
|
48
|
+
export function commandPreview(tool, meta = {}) {
|
|
49
|
+
if (!["command_run", "process_start"].includes(tool)) return commandSummary(tool, meta);
|
|
50
|
+
const name = typeof meta.command === "string" && /^[A-Za-z0-9_.-]{1,40}$/.test(meta.command) ? meta.command : "command";
|
|
51
|
+
const args = Array.isArray(meta.args) ? meta.args.slice(0, 24) : [];
|
|
52
|
+
let hideNext = false, inline = false;
|
|
53
|
+
const safe = args.map(arg => {
|
|
54
|
+
if (inline || hideNext) { hideNext = false; return "[redacted]"; }
|
|
55
|
+
if (typeof arg !== "string") return "[value]";
|
|
56
|
+
if (/^(?:-e|--eval|-p|--print|-c|-command|--command|-encodedcommand|\/c|\/k)$/i.test(arg) &&
|
|
57
|
+
/^(?:node|python|bash|sh|powershell|pwsh|cmd)(?:\.[a-z]+)?$/i.test(name)) inline = true;
|
|
58
|
+
if (/^(?:[-/]+(?:.*(?:token|password|passwd|secret|credential|authorization|api[-_]?key)|auth|key|header|env|config|data|data-raw|data-binary|message|prompt)|-[uHpmd])$/i.test(arg)) hideNext = true;
|
|
59
|
+
if (/(?:token|password|passwd|secret|credential|authorization|api[-_]?key)\s*[=:]|(?:^|[/\\])(?:\.env(?:\.|$)|credentials|\.npmrc)|:\/\/|[\x00-\x1f\x7f]|[A-Za-z0-9+/_=-]{40,}/i.test(arg)) return "[redacted]";
|
|
60
|
+
// ponytail: printable simple argv only; embedded scripts and complex values stay private.
|
|
61
|
+
return arg.length <= 96 && /^[A-Za-z0-9 _./\\:@()+=-]+$/.test(arg) ? (arg.includes(" ") ? JSON.stringify(arg) : arg) : "[value]";
|
|
62
|
+
});
|
|
63
|
+
return [name, ...safe, ...(Array.isArray(meta.args) && meta.args.length > 24 ? ["…"] : [])].join(" ").slice(0, 240);
|
|
64
|
+
}
|
package/bin/monitor-mcp.mjs
CHANGED
|
@@ -9,6 +9,13 @@ const nullable = (value, test) => value === null || test(value);
|
|
|
9
9
|
const tool = value => typeof value === "string" && /^[a-z_]{1,64}$/.test(value);
|
|
10
10
|
const model = value => text(value, 80) && /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,79}$/.test(value);
|
|
11
11
|
const name = value => text(value, 8) && /^[A-Za-z][A-Za-z0-9]{0,7}$/.test(value);
|
|
12
|
+
const preview = row => {
|
|
13
|
+
if (row.preview === undefined) return row.command;
|
|
14
|
+
if (!text(row.preview, 240)) throw Error("MCP_MONITOR_INVALID");
|
|
15
|
+
return row.preview;
|
|
16
|
+
};
|
|
17
|
+
const optionalModel = row => row.model === undefined || row.model === null ? null : model(row.model) ? row.model : invalid();
|
|
18
|
+
const optionalExit = row => row.exitCode === undefined || row.exitCode === null ? null : Number.isInteger(row.exitCode) && Math.abs(row.exitCode) <= 2147483648 ? row.exitCode : invalid();
|
|
12
19
|
const invalid = () => { throw Error("MCP_MONITOR_INVALID"); };
|
|
13
20
|
function agentView(a) {
|
|
14
21
|
if (a === null) return null;
|
|
@@ -36,7 +43,7 @@ export function mcpView(raw, alive = pid => { try { process.kill(pid, 0); return
|
|
|
36
43
|
const calls = raw.calls.map(row => {
|
|
37
44
|
if (!count(row.id) || !["accepted", "queued", "running"].includes(row.state) || !Number.isSafeInteger(row.at) || row.at < 1 || row.at > Date.now() + 5000) invalid();
|
|
38
45
|
const agent = agentView(row.agent);
|
|
39
|
-
return { id: row.id, tool: row.tool, command: command(row), state: row.state, at: row.at, agent, context: contextView(row.context) };
|
|
46
|
+
return { id: row.id, tool: row.tool, command: command(row), preview: preview(row), state: row.state, at: row.at, agent, context: contextView(row.context) };
|
|
40
47
|
});
|
|
41
48
|
const metrics = raw.metrics.map(row => {
|
|
42
49
|
if (!tool(row.tool) || ![row.calls, row.errors, row.sentBytes, row.savedBytes, row.outputBytes, row.durationMs].every(count) || row.errors > row.calls) invalid();
|
|
@@ -51,17 +58,24 @@ export function mcpView(raw, alive = pid => { try { process.kill(pid, 0); return
|
|
|
51
58
|
if (raw.processes !== undefined && (!Array.isArray(raw.processes) || raw.processes.length > 64)) invalid();
|
|
52
59
|
const processes = (raw.processes ?? []).map(row => {
|
|
53
60
|
if (!id(row.id) || !count(row.pid) || !row.pid) invalid();
|
|
54
|
-
return { id: row.id, pid: row.pid, command: command({ tool: "process_start", command: row.command }), context: contextView(row.context), agent: agentView(row.agent) };
|
|
61
|
+
return { id: row.id, pid: row.pid, command: command({ tool: "process_start", command: row.command }), preview: preview(row), context: contextView(row.context), agent: agentView(row.agent) };
|
|
55
62
|
});
|
|
56
63
|
const events = raw.events.map(row => {
|
|
57
64
|
if (!count(row.id) || typeof row.at !== "string" || !Number.isFinite(Date.parse(row.at)) || !["failed", "finished"].includes(row.state) ||
|
|
58
65
|
!nullable(row.agent, id) || !nullable(row.name, name) ||
|
|
59
66
|
!nullable(row.code, value => /^[A-Z][A-Z0-9_]{0,63}$/.test(value)) || ![row.durationMs, row.sentBytes, row.savedBytes].every(count)) invalid();
|
|
60
|
-
return { id: row.id, at: row.at, tool: row.tool, command: command(row), state: row.state, agent: row.agent, name: row.name,
|
|
61
|
-
context: contextView(row.context), code: row.code, durationMs: row.durationMs, sentBytes: row.sentBytes, savedBytes: row.savedBytes };
|
|
67
|
+
return { id: row.id, at: row.at, tool: row.tool, command: command(row), preview: preview(row), state: row.state, agent: row.agent, name: row.name,
|
|
68
|
+
context: contextView(row.context), model: optionalModel(row), exitCode: optionalExit(row), timedOut: row.timedOut === true, code: row.code, durationMs: row.durationMs, sentBytes: row.sentBytes, savedBytes: row.savedBytes };
|
|
69
|
+
});
|
|
70
|
+
if (raw.transitions !== undefined && (!Array.isArray(raw.transitions) || raw.transitions.length > 100)) invalid();
|
|
71
|
+
const transitions = (raw.transitions ?? []).map(row => {
|
|
72
|
+
if (!count(row.seq) || !count(row.id) || row.state !== "running" || !tool(row.tool) ||
|
|
73
|
+
typeof row.at !== "string" || !Number.isFinite(Date.parse(row.at)) || !nullable(row.name, name) || !text(row.preview, 240)) invalid();
|
|
74
|
+
return { seq: row.seq, id: row.id, at: row.at, tool: row.tool, preview: row.preview, state: row.state,
|
|
75
|
+
context: contextView(row.context), name: row.name, model: optionalModel(row) };
|
|
62
76
|
});
|
|
63
77
|
return { instanceId: raw.instanceId, pid: raw.pid, computer: raw.computer, updatedAt: raw.updatedAt, running: alive(raw.pid), queue: raw.queue,
|
|
64
|
-
calls, processes, agents, metrics, events, dropped: raw.dropped, writeErrors: raw.writeErrors, writes: raw.writes };
|
|
78
|
+
calls, processes, agents, metrics, events, transitions, dropped: raw.dropped, writeErrors: raw.writeErrors, writes: raw.writes };
|
|
65
79
|
}
|
|
66
80
|
export async function readMcpActivity(projectRoot = process.cwd()) {
|
|
67
81
|
const directory = path.join(presenceDirectory(projectRoot), "activity");
|
|
@@ -78,3 +92,45 @@ export async function readMcpActivity(projectRoot = process.cwd()) {
|
|
|
78
92
|
}
|
|
79
93
|
return { instances: instances.sort((a, b) => a.instanceId.localeCompare(b.instanceId)), unavailable };
|
|
80
94
|
}
|
|
95
|
+
|
|
96
|
+
let healthCache;
|
|
97
|
+
/** Probe only the existing loopback health route, once per 30s; idle activity age is not health. */
|
|
98
|
+
export async function readMcpHealth(projectRoot = process.cwd()) {
|
|
99
|
+
if (healthCache && Date.now() - healthCache.time < 30_000) return healthCache.result;
|
|
100
|
+
let endpoint = "http://127.0.0.1:17321/health", configured = false;
|
|
101
|
+
try {
|
|
102
|
+
const file = path.resolve(projectRoot, process.env.LCMCP_CONFIG?.trim() || "local-commander.config.json");
|
|
103
|
+
const stat = await lstat(file);
|
|
104
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65_536) throw Error("CONFIG_INVALID");
|
|
105
|
+
const config = await readState(file);
|
|
106
|
+
const host = config.server?.host ?? "127.0.0.1", port = config.server?.port ?? 17321;
|
|
107
|
+
if (!["localhost", "127.0.0.1", "::1"].includes(host) || !Number.isInteger(port) || port < 1 || port > 65535) throw Error("HEALTH_NOT_LOOPBACK");
|
|
108
|
+
endpoint = "http://" + (host === "::1" ? "[::1]" : host) + ":" + port + "/health";
|
|
109
|
+
configured = true;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error.code !== "ENOENT") return { state: "unavailable", status: null, at: new Date().toISOString(), latencyMs: null };
|
|
112
|
+
}
|
|
113
|
+
const start = Date.now();
|
|
114
|
+
let result;
|
|
115
|
+
try {
|
|
116
|
+
const response = await fetch(endpoint, { signal: AbortSignal.timeout(800), redirect: "error" });
|
|
117
|
+
const reader = response.body?.getReader();
|
|
118
|
+
let body = "", bytes = 0;
|
|
119
|
+
if (reader) {
|
|
120
|
+
try {
|
|
121
|
+
while (true) {
|
|
122
|
+
const chunk = await reader.read();
|
|
123
|
+
if (chunk.done) break;
|
|
124
|
+
bytes += chunk.value.byteLength;
|
|
125
|
+
if (bytes > 2048) throw Error("HEALTH_RESPONSE_LIMIT");
|
|
126
|
+
body += Buffer.from(chunk.value).toString("utf8");
|
|
127
|
+
}
|
|
128
|
+
} finally { void reader.cancel().catch(() => {}); }
|
|
129
|
+
}
|
|
130
|
+
const value = JSON.parse(body);
|
|
131
|
+
result = { state: response.ok && value.status === "ok" ? "healthy" : "failed", status: response.status,
|
|
132
|
+
at: new Date().toISOString(), latencyMs: Date.now() - start };
|
|
133
|
+
} catch { result = { state: configured ? "unreachable" : "not observed", status: null, at: new Date().toISOString(), latencyMs: Date.now() - start }; }
|
|
134
|
+
healthCache = { time: Date.now(), result };
|
|
135
|
+
return result;
|
|
136
|
+
}
|
package/bin/monitor.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { lstat, open, readdir } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
4
|
import { readAgentActivity } from "./monitor-agents.mjs";
|
|
5
|
-
import { readMcpActivity, contextView } from "./monitor-mcp.mjs";
|
|
5
|
+
import { readMcpActivity, readMcpHealth, contextView } from "./monitor-mcp.mjs";
|
|
6
6
|
|
|
7
7
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
8
8
|
const STATES = new Set(["starting", "connecting", "connected", "disconnected", "stopped", "failed"]);
|
|
@@ -87,11 +87,14 @@ export function monitorView(raw, now = Date.now(), alive = pidAlive) {
|
|
|
87
87
|
const measured = execution.originalBytes !== null;
|
|
88
88
|
if (measured ? execution.sentBytes === null || execution.savedBytes === null || execution.sentBytes > execution.originalBytes ||
|
|
89
89
|
execution.savedBytes !== execution.originalBytes - execution.sentBytes : execution.sentBytes !== null || execution.savedBytes !== null) invalid();
|
|
90
|
+
if (execution.preview !== undefined && (typeof execution.preview !== "string" || execution.preview.length > 240 || /[\u0000-\u001f\u007f-\u009f]/u.test(execution.preview))) invalid();
|
|
91
|
+
for (const field of ["startedAt", "endedAt"]) if (execution[field] !== undefined && execution[field] !== null && (!Number.isSafeInteger(execution[field]) || execution[field] < 0 || execution[field] > now + 5000)) invalid();
|
|
92
|
+
if (execution.exitCode !== undefined && execution.exitCode !== null && (!Number.isInteger(execution.exitCode) || Math.abs(execution.exitCode) > 2147483648)) invalid();
|
|
90
93
|
const profile = profileView(execution.profile);
|
|
91
94
|
const active = ["requested", "prepared", "accepted", "running"].includes(execution.state);
|
|
92
95
|
const state = active && connection !== "connected" ? connection === "stale" ? "stale" : "unknown" : execution.state;
|
|
93
|
-
return { executionId: execution.executionId, sessionId: execution.sessionId, name: execution.name, command: execution.command,
|
|
94
|
-
state, pid: execution.pid, profile, context: execution.context === undefined ? null : contextView(execution.context), originalBytes: execution.originalBytes, sentBytes: execution.sentBytes,
|
|
96
|
+
return { executionId: execution.executionId, sessionId: execution.sessionId, name: execution.name, command: execution.command, preview: execution.preview ?? execution.command,
|
|
97
|
+
startedAt: execution.startedAt ?? null, endedAt: execution.endedAt ?? null, exitCode: execution.exitCode ?? null, timedOut: execution.timedOut === true, state, pid: execution.pid, profile, context: execution.context === undefined ? null : contextView(execution.context), originalBytes: execution.originalBytes, sentBytes: execution.sentBytes,
|
|
95
98
|
savedBytes: execution.savedBytes, reason: execution.reason, estimatedSavedTokens: measured ? Math.round(execution.savedBytes / 4) : null };
|
|
96
99
|
});
|
|
97
100
|
return { instanceId: raw.instanceId, pid: raw.pid, agentVersion: raw.agentVersion, updatedAt: raw.updatedAt,
|
|
@@ -134,11 +137,15 @@ export async function readMonitors(directory) {
|
|
|
134
137
|
function logLine(instance, event) {
|
|
135
138
|
const execution = instance.executions.find(row => row.executionId === event.executionId);
|
|
136
139
|
return [event.at, "pc=" + (instance.computer ?? "unknown"), "pid=" + instance.pid, event.event, event.tool, event.code,
|
|
137
|
-
execution && "cmd=" + execution.command,
|
|
140
|
+
execution && "cmd=" + (execution.preview ?? execution.command),
|
|
138
141
|
execution?.profile && "model=" + execution.profile.model,
|
|
139
142
|
execution && ["compacted", "passthrough"].includes(event.event) && "saved=~" + compact(execution.estimatedSavedTokens) +
|
|
140
143
|
"tok sent=" + execution.sentBytes + "B reason=" + execution.reason,
|
|
141
|
-
|
|
144
|
+
execution?.exitCode !== null && execution?.exitCode !== undefined && "exit=" + execution.exitCode,
|
|
145
|
+
execution?.timedOut && "timeout=true",
|
|
146
|
+
execution?.endedAt && execution.startedAt && "elapsed=" + (execution.endedAt - execution.startedAt) + "ms",
|
|
147
|
+
execution?.context?.directory && "dir=" + execution.context.directory, execution?.context?.taskId && "task=" + execution.context.taskId,
|
|
148
|
+
event.sessionId && "session=" + event.sessionId.slice(0, 8), event.executionId && "execution=" + event.executionId.slice(0, 8)].filter(Boolean).join(" ");
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
const compact = value => value === null ? "n/a" : value >= 1e6 ? (value / 1e6).toFixed(1) + "M" : value >= 1000 ? (value / 1000).toFixed(1) + "k" : String(value);
|
|
@@ -146,6 +153,33 @@ const fit = (value, width) => {
|
|
|
146
153
|
const text = String(value);
|
|
147
154
|
return (text.length > width ? width < 4 ? text.slice(0, width) : text.slice(0, width - 3) + "..." : text).padEnd(width);
|
|
148
155
|
};
|
|
156
|
+
|
|
157
|
+
function storyLines(result, computer) {
|
|
158
|
+
const personal = (result.mcp?.instances ?? []).filter(i => i.running && (!computer || i.computer === computer));
|
|
159
|
+
const native = result.instances.filter(i => i.running && (!computer || i.computer === computer));
|
|
160
|
+
const current = personal.flatMap(i => i.calls).find(c => c.state === "running") ?? personal.flatMap(i => i.calls)[0];
|
|
161
|
+
const history = [
|
|
162
|
+
...personal.flatMap(i => [...(i.transitions ?? []), ...i.events].map(e => ({ ...e, computer: i.computer }))),
|
|
163
|
+
...native.flatMap(i => i.events.filter(e => ["execute", "finished", "failed", "cancelled", "unknown"].includes(e.event)).map(e => {
|
|
164
|
+
const work = i.executions.find(x => x.executionId === e.executionId);
|
|
165
|
+
return { ...e, computer: i.computer, state: e.event, preview: work?.preview ?? work?.command ?? e.tool,
|
|
166
|
+
name: work?.executionId.slice(0, 8), context: work?.context, durationMs: work?.endedAt && work.startedAt ? work.endedAt - work.startedAt : null };
|
|
167
|
+
}))
|
|
168
|
+
].sort((a,b) => a.at.localeCompare(b.at));
|
|
169
|
+
const latest = history.at(-1), context = current?.context ?? latest?.context;
|
|
170
|
+
const lines = [
|
|
171
|
+
"INVESTIGATION " + (computer ?? "all PCs") + (result.paused ? " [VIEW FROZEN; work continues]" : " [LIVE]"),
|
|
172
|
+
" Task: " + (context?.taskId ?? context?.project ?? "not reported") + (context?.pr ? " | PR #" + context.pr : "") +
|
|
173
|
+
" | Where: " + (context?.directory ?? "not reported"),
|
|
174
|
+
" " + (current ? "Now: " + (current.agent?.name ?? "unbound caller") + " " + (current.preview ?? current.command) + " [" + current.state + "]" :
|
|
175
|
+
latest ? "Latest: " + (latest.name ?? "unbound caller") + " " + (latest.preview ?? latest.command ?? latest.state) + " [" + latest.state + "]" : "Waiting for the first reported operation.")
|
|
176
|
+
];
|
|
177
|
+
for (const event of history.slice(-(result.storyDepth ?? 2))) lines.push(" " + event.at.slice(11,19) + "Z " +
|
|
178
|
+
(event.name ?? "unbound") + " > " + (event.preview ?? event.command ?? event.state) + " > " + event.state.toUpperCase() +
|
|
179
|
+
(event.durationMs != null ? " (" + event.durationMs + "ms)" : "") + (event.code ? " " + event.code : ""));
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
149
183
|
export function formatMonitor(result, columns = process.stdout.columns ?? 120, height = 40, color = false) {
|
|
150
184
|
const width = Math.max(1, Math.min(160, Number.isFinite(columns) ? Math.floor(columns) : 120));
|
|
151
185
|
height = Math.max(1, Number.isFinite(height) ? Math.floor(height) : 40);
|
|
@@ -156,7 +190,7 @@ export function formatMonitor(result, columns = process.stdout.columns ?? 120, h
|
|
|
156
190
|
const mcpCalls = mcp.flatMap(instance => instance.calls);
|
|
157
191
|
const queued = mcp.reduce((sum, instance) => sum + (instance.queue ?? 0), 0);
|
|
158
192
|
const errors = mcpMetrics.reduce((sum, item) => sum + item.errors, 0);
|
|
159
|
-
for (const instance of
|
|
193
|
+
for (const instance of result.instances.filter(item => item.running && (item.computer || item.connection !== "stale")).sort((a, b) => a.instanceId.localeCompare(b.instanceId))) {
|
|
160
194
|
const name = instance.computer ?? "unknown";
|
|
161
195
|
if (!pcs.has(name)) pcs.set(name, []);
|
|
162
196
|
pcs.get(name).push(instance);
|
|
@@ -178,7 +212,7 @@ export function formatMonitor(result, columns = process.stdout.columns ?? 120, h
|
|
|
178
212
|
const pair = (left, right) => fit(left, Math.max(0, width - right.length - 1)) + " " + right;
|
|
179
213
|
const lines = [pair("LC >_ Local Commander MCP", "LIVE MONITOR"), "-".repeat(width), "> fleet status", ""];
|
|
180
214
|
const connected = [...pcs].filter(([name, instances]) => instances.some(instance => instance.connection === "connected") || mcp.some(instance => instance.computer === name)).length;
|
|
181
|
-
if (height >=
|
|
215
|
+
if (height >= 36 && width >= 60) lines.push(
|
|
182
216
|
"+-------------+ " + connected + " PCs reporting",
|
|
183
217
|
"| >_ " + fit(jobs ? "RUNNING" : agents.length ? "OBSERVING" : connected ? "READY" : "WAITING", 9) + "| " + jobs + " active commands",
|
|
184
218
|
"| LC >_ | " + agentSummary + " | " + hostSummary,
|
|
@@ -196,16 +230,17 @@ export function formatMonitor(result, columns = process.stdout.columns ?? 120, h
|
|
|
196
230
|
"Raw: n/a Sent: n/a Saved: n/a",
|
|
197
231
|
mcp.length ? "MCP this run: " + mcpMetrics.reduce((sum, row) => sum + row.calls, 0) + " calls | response " + compact(mcpMetrics.reduce((sum, row) => sum + row.sentBytes, 0)) +
|
|
198
232
|
"B | saved stdout " + tokens(mcpMetrics.reduce((sum, row) => sum + row.savedBytes, 0)) + " tok | errors " + errors :
|
|
199
|
-
"~ Tokens: bytes / 4, this run | model:
|
|
233
|
+
"~ Tokens: bytes / 4, this run | model: declared or not reported",
|
|
200
234
|
mcpMetrics.length ? "Review: " + [...mcpMetrics].sort((a, b) => b.sentBytes - a.sentBytes).slice(0, 2)
|
|
201
235
|
.map(row => row.tool + " " + compact(row.sentBytes) + "B / " + row.calls + " calls / " + Math.round(row.durationMs / Math.max(1, row.calls)) + "ms avg").join(" | ") :
|
|
202
236
|
"Server: " + ([...new Set(live.map(instance => instance.origin).filter(Boolean))].join(", ") || "not connected"),
|
|
203
|
-
"
|
|
237
|
+
"Space freeze/resume | Tab focus PC | A all PCs | L timeline | Ctrl+C exit" +
|
|
204
238
|
(result.mcp?.unavailable ? " | MCP snapshots unavailable: " + result.mcp.unavailable : "") +
|
|
205
239
|
(result.activity?.unavailable.length ? " | unavailable: " + result.activity.unavailable.join(",") : "")
|
|
206
240
|
];
|
|
241
|
+
if (height >= 30 && width >= 60) footer.unshift(...storyLines(result, result.focusComputer));
|
|
207
242
|
const capacity = Math.max(0, height - lines.length - footer.length - 1);
|
|
208
|
-
for (const [name, instances] of [...pcs].sort(([a], [b]) => a.localeCompare(b))) {
|
|
243
|
+
for (const [name, instances] of [...pcs].filter(([name]) => !result.focusComputer || name === result.focusComputer).sort(([a], [b]) => a.localeCompare(b))) {
|
|
209
244
|
const pcStats = instances.map(instance => instance.efficiency).filter(Boolean);
|
|
210
245
|
const pcMcp = mcp.filter(instance => instance.computer === name);
|
|
211
246
|
const pcSaved = pcStats.length || pcMcp.length ? tokens(pcStats.reduce((sum, item) => sum + item.savedBytes, 0) + pcMcp.flatMap(instance => instance.metrics).reduce((sum, row) => sum + row.savedBytes, 0)) : "n/a";
|
|
@@ -223,9 +258,13 @@ export function formatMonitor(result, columns = process.stdout.columns ?? 120, h
|
|
|
223
258
|
const mcpError = pcMcp.flatMap(instance => instance.events).filter(event => event.state === "failed").sort((a, b) => b.at.localeCompare(a.at))[0];
|
|
224
259
|
const mcpMessage = pcMcp.flatMap(instance => instance.events).filter(event => event.tool === "agent_message").sort((a, b) => b.at.localeCompare(a.at))[0];
|
|
225
260
|
const statusLines = [
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
...(
|
|
261
|
+
" Health: " + (instances.map(i => "relay " + i.connection.toUpperCase()).join(", ") || "relay not observed") +
|
|
262
|
+
(result.health ? " | MCP " + result.health.state + (result.health.status ? " HTTP " + result.health.status : "") + " @ " + result.health.at.slice(11, 19) + "Z" : " | MCP probe not run"),
|
|
263
|
+
...(pcMcp.length ? [" Diagnostics: queue " + pcMcp.reduce((s,i) => s + (i.queue ?? 0), 0) + " | write errors " +
|
|
264
|
+
pcMcp.reduce((s,i) => s + i.writeErrors, 0) + " | dropped " + pcMcp.reduce((s,i) => s + i.dropped, 0)] : []),
|
|
265
|
+
...(recent ? [" Last: " + latest.at.slice(11, 19) + "Z " + recent.executionId.slice(0, 8) + " " + (recent.preview ?? recent.command) + " " + recent.state.toUpperCase()] : []),
|
|
266
|
+
...(mcpRecent ? [" MCP last: " + mcpRecent.at.slice(11, 19) + "Z " + (mcpRecent.name ?? "unbound") + " " + (mcpRecent.preview ?? mcpRecent.command) + " " + mcpRecent.state.toUpperCase()] : []),
|
|
267
|
+
...(mcpError ? [ " Last error: " + mcpError.at.slice(11, 19) + "Z " + (mcpError.preview ?? mcpError.command) + " " + mcpError.code] : []),
|
|
229
268
|
...(mcpMessage && mcpMessage !== mcpRecent ? [" Message: " + (mcpMessage.name ?? "unbound") + " " + mcpMessage.command + " | task " + (mcpMessage.context.taskId ?? "unreported")] : [])
|
|
230
269
|
];
|
|
231
270
|
const rowCapacity = capacity - statusLines.length;
|
|
@@ -233,10 +272,10 @@ export function formatMonitor(result, columns = process.stdout.columns ?? 120, h
|
|
|
233
272
|
const rows = [
|
|
234
273
|
...pcMcp.flatMap(instance => instance.processes.map(row => ({ executionId: "process:" + row.id,
|
|
235
274
|
displayId: row.agent?.name ?? row.id.slice(0, 8), kind: "call", pid: row.pid, order: "0:process:" + row.id,
|
|
236
|
-
command: row.command, context: row.context, state: "running", profile: row.agent?.model ? { model: row.agent.model } : null, estimatedSavedTokens: null }))),
|
|
275
|
+
command: row.preview ?? row.command, context: row.context, state: "running", profile: row.agent?.model ? { model: row.agent.model } : null, estimatedSavedTokens: null }))),
|
|
237
276
|
...pcMcp.flatMap(instance => instance.calls.map(call => ({ executionId: "mcp:" + instance.instanceId + ":" + call.id,
|
|
238
277
|
displayId: call.agent?.name ?? ("mcp" + call.id).slice(0, 8).padEnd(8), kind: "call", pid: instance.pid,
|
|
239
|
-
order: "0:" + String(call.id).padStart(12, "0"), command: (call.agent ? "" : "unbound: ") + call.command,
|
|
278
|
+
order: "0:" + String(call.id).padStart(12, "0"), command: (call.agent ? "" : "unbound: ") + (call.preview ?? call.command),
|
|
240
279
|
state: call.state, profile: call.agent?.model ? { model: call.agent.model } : null, estimatedSavedTokens: call.agent ? savingsFor(call.agent.runtimeId) : null,
|
|
241
280
|
detail: [call.context.taskId && "task " + call.context.taskId, call.context.pr && "PR #" + call.context.pr + (call.context.inferred ? " (path)" : ""),
|
|
242
281
|
call.context.project && "project " + call.context.project, call.agent?.phase === "analysis" && "analysis (reported)",
|
|
@@ -257,12 +296,12 @@ export function formatMonitor(result, columns = process.stdout.columns ?? 120, h
|
|
|
257
296
|
estimatedSavedTokens: measured.length || usage.length ? Math.round((measured.reduce((sum, row) => sum + row.savedBytes, 0) + usage.reduce((sum, row) => sum + row.savedBytes, 0)) / 4) : null,
|
|
258
297
|
partial: measured.length > 0 && measured.length < agent.executionIds.length },
|
|
259
298
|
...linked.filter(row => ["requested", "prepared", "accepted", "running"].includes(row.state))
|
|
260
|
-
.map(row => ({ ...row, command: "-> " + row.command, order: "1:" + agent.id + ":work:" + row.executionId }))];
|
|
299
|
+
.map(row => ({ ...row, command: "-> " + (row.preview ?? row.command), order: "1:" + agent.id + ":work:" + row.executionId }))];
|
|
261
300
|
}),
|
|
262
|
-
...instances.flatMap(active).filter(row => !pcAgents.some(agent => agent.executionIds.includes(row.executionId)))
|
|
301
|
+
...instances.flatMap(active).filter(row => !pcAgents.some(agent => agent.executionIds.includes(row.executionId))).map(row => ({ ...row, command: row.preview ?? row.command }))
|
|
263
302
|
];
|
|
264
303
|
const state = instances.some(instance => active(instance).length) || pcMcp.some(instance => instance.calls.length) ? "RUNNING" : pcAgents.length || pcMcp.length ? "OBSERVING" :
|
|
265
|
-
instances.some(instance => instance.connection === "connected") ? "READY" : "
|
|
304
|
+
instances.some(instance => instance.connection === "connected") ? "READY" : instances[0]?.connection.toUpperCase() ?? "OBSERVING";
|
|
266
305
|
// Keep each process with its PC heading, including in a short terminal.
|
|
267
306
|
if (rowCapacity - body.length < (rows.length ? width < 60 ? 5 : 4 : 3)) break;
|
|
268
307
|
body.push(pair("PC " + name + " | saved " + pcSaved + " tokens", state));
|
|
@@ -271,7 +310,7 @@ export function formatMonitor(result, columns = process.stdout.columns ?? 120, h
|
|
|
271
310
|
if (width >= 100) body.push(" " + fit("ID", 8) + " " + fit("PID", 7) + " " + fit("AGENT / HOST / DOING", width - 71) +
|
|
272
311
|
" " + fit("MODEL", 24) + " " + fit("SAVED TOK", 10) + " STATE");
|
|
273
312
|
for (const row of rows.sort((a, b) => (a.order ?? "1:" + a.executionId).localeCompare(b.order ?? "1:" + b.executionId))) {
|
|
274
|
-
const id = row.displayId ?? row.executionId.slice(0, 8), model = row.profile?.model ?? "
|
|
313
|
+
const id = row.displayId ?? row.executionId.slice(0, 8), model = row.profile?.model ?? (row.kind === "host" ? "process only" : "not reported");
|
|
275
314
|
const saving = row.estimatedSavedTokens === null ? "n/a" : "~" + compact(row.estimatedSavedTokens) + (row.partial ? "+" : "");
|
|
276
315
|
const entry = width >= 100 ? [" " + fit(id, 8) + " " + fit(row.pid ?? "-", 7) + " " + fit(row.command, width - 71) +
|
|
277
316
|
" " + fit(model, 24) + " " + fit(saving, 10) + " " + row.state.toUpperCase()] : [
|
|
@@ -292,7 +331,7 @@ export function formatMonitor(result, columns = process.stdout.columns ?? 120, h
|
|
|
292
331
|
for (const line of statusLines) if (body.length + 2 <= capacity) body.push(line);
|
|
293
332
|
body.push("");
|
|
294
333
|
}
|
|
295
|
-
if (!pcs.size) body.push(result.instances.length ? "No
|
|
334
|
+
if (!pcs.size) body.push(result.instances.length ? "No live PC reported. See logs for completed work and connection history." :
|
|
296
335
|
"No monitoring snapshots. Update/start a monitoring-capable agent.");
|
|
297
336
|
lines.push(...body.slice(0, capacity));
|
|
298
337
|
while (lines.length < height - footer.length - 1) lines.push("");
|
|
@@ -364,34 +403,63 @@ export async function runMonitor(directory, command, args) {
|
|
|
364
403
|
const previousHosts = new Map();
|
|
365
404
|
const fixedScreen = command === "monitor" && repeat && process.stdout.isTTY && !args.includes("--json");
|
|
366
405
|
let previous = "", previousSize = "", initial = true, insightCheckpoint = "";
|
|
406
|
+
let paused = false, latestResult, focusComputer = null, storyDepth = 2;
|
|
407
|
+
const interactive = fixedScreen && process.stdin.isTTY && typeof process.stdin.setRawMode === "function";
|
|
408
|
+
const wasRaw = process.stdin.isRaw, wasPaused = process.stdin.isPaused();
|
|
409
|
+
const input = chunk => {
|
|
410
|
+
const key = chunk.toString().toLowerCase();
|
|
411
|
+
if (key === "\u0003" || key === "q") stop.abort();
|
|
412
|
+
else if (key === " ") paused = !paused;
|
|
413
|
+
else if (key === "a") focusComputer = null;
|
|
414
|
+
else if (key === "l") storyDepth = storyDepth === 2 ? 5 : 2;
|
|
415
|
+
else if (key === "\t" && latestResult) {
|
|
416
|
+
const computers = [...new Set([...latestResult.instances.map(i => i.computer),
|
|
417
|
+
...latestResult.mcp.instances.map(i => i.computer), ...(latestResult.activity?.rows ?? []).map(i => i.computer)].filter(Boolean))].sort();
|
|
418
|
+
focusComputer = computers[(computers.indexOf(focusComputer) + 1) % computers.length] ?? null;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
if (interactive) { process.stdin.setRawMode(true); process.stdin.on("data", input); process.stdin.resume(); }
|
|
367
422
|
if (fixedScreen) process.stdout.write("\x1b[?1049h\x1b[?25l");
|
|
368
423
|
try {
|
|
369
424
|
do {
|
|
370
|
-
const result = await readMonitors(directory);
|
|
371
|
-
|
|
372
|
-
|
|
425
|
+
const result = paused && latestResult ? latestResult : await readMonitors(directory);
|
|
426
|
+
if (!(paused && latestResult)) {
|
|
427
|
+
result.mcp = await readMcpActivity();
|
|
428
|
+
result.health = await readMcpHealth();
|
|
429
|
+
if (command === "monitor") result.activity = await readAgentActivity(directory, { previousHosts, signal: stop.signal });
|
|
430
|
+
latestResult = result;
|
|
431
|
+
}
|
|
432
|
+
result.paused = paused; result.focusComputer = focusComputer; result.storyDepth = storyDepth;
|
|
373
433
|
if (command === "logs" && (result.instances.length || result.mcp.instances.length)) {
|
|
374
434
|
const checkpoint = result.mcp.instances.map(instance => instance.instanceId + ":" + Math.floor(instance.metrics.reduce((sum, row) => sum + row.calls, 0) / 5)).join(",");
|
|
375
435
|
if (initial || checkpoint !== insightCheckpoint) { process.stdout.write(formatInsights(result)); insightCheckpoint = checkpoint; }
|
|
376
436
|
for (const instance of result.instances) {
|
|
377
437
|
const after = seen.get(instance.instanceId) ?? 0;
|
|
378
438
|
if (after && instance.events[0]?.seq > after + 1) process.stdout.write("Earlier events were overwritten in the 100-event buffer.\n");
|
|
379
|
-
for (const event of instance.events) if (event.seq > after) process.stdout.write(logLine(instance, event) + "\n");
|
|
439
|
+
for (const event of instance.events) if (event.seq > after && !["status", "output"].includes(event.event)) process.stdout.write(logLine(instance, event) + "\n");
|
|
380
440
|
seen.set(instance.instanceId, instance.events.at(-1)?.seq ?? after);
|
|
381
441
|
}
|
|
382
442
|
for (const instance of result.mcp.instances) {
|
|
383
443
|
const key = "mcp:" + instance.instanceId;
|
|
384
|
-
const
|
|
444
|
+
const transitionsKey = key + ":starts";
|
|
445
|
+
const afterStart = seen.get(transitionsKey) ?? 0;
|
|
446
|
+
for (const event of instance.transitions ?? []) if (event.seq > afterStart) {
|
|
447
|
+
process.stdout.write([event.at, "pc=" + instance.computer, "agent=" + (event.name ?? "unbound"), event.preview, "RUNNING",
|
|
448
|
+
"model=" + (event.model ?? "not_reported"), event.context.directory && "dir=" + event.context.directory,
|
|
449
|
+
event.context.taskId && "task=" + event.context.taskId].filter(Boolean).join(" ") + "\n");
|
|
450
|
+
}
|
|
451
|
+
seen.set(transitionsKey, instance.transitions?.at(-1)?.seq ?? afterStart);
|
|
385
452
|
// Request IDs finish out of order: a per-event key tracks actual completions, not numeric high-water IDs.
|
|
386
453
|
for (const event of instance.events) {
|
|
387
454
|
const eventKey = key + ":" + event.id;
|
|
388
455
|
if (seen.has(eventKey)) continue;
|
|
389
|
-
process.stdout.write([event.at, "pc=" + instance.computer, "agent=" + (event.name ?? "unbound"), event.command, event.state,
|
|
456
|
+
process.stdout.write([event.at, "pc=" + instance.computer, "agent=" + (event.name ?? "unbound"), event.preview ?? event.command, event.state, "model=" + (event.model ?? "not_reported"),
|
|
457
|
+
"elapsed=" + event.durationMs + "ms", event.exitCode !== null && "exit=" + event.exitCode, event.timedOut && "timeout=true",
|
|
390
458
|
event.code, event.context.directory && "dir=" + event.context.directory, event.context.taskId && "task=" + event.context.taskId,
|
|
391
459
|
event.context.pr && "PR=" + event.context.pr, "sent=" + event.sentBytes + "B", "saved=~" + Math.round(event.savedBytes / 4) + "tok"].filter(Boolean).join(" ") + "\n");
|
|
392
460
|
seen.set(eventKey, 1);
|
|
393
461
|
}
|
|
394
|
-
for (const seenKey of seen.keys()) if (seenKey.startsWith(key + ":") && !instance.events.some(event => seenKey === key + ":" + event.id)) seen.delete(seenKey);
|
|
462
|
+
for (const seenKey of seen.keys()) if (seenKey.startsWith(key + ":") && seenKey !== transitionsKey && !instance.events.some(event => seenKey === key + ":" + event.id)) seen.delete(seenKey);
|
|
395
463
|
}
|
|
396
464
|
for (const key of seen.keys()) if (!result.instances.some(instance => instance.instanceId === key) &&
|
|
397
465
|
!result.mcp.instances.some(instance => key.startsWith("mcp:" + instance.instanceId + ":"))) seen.delete(key);
|
|
@@ -413,6 +481,7 @@ export async function runMonitor(directory, command, args) {
|
|
|
413
481
|
if (repeat) await delay(2000, undefined, { signal: stop.signal }).catch(error => { if (error.name !== "AbortError") throw error; });
|
|
414
482
|
} while (repeat && !stop.signal.aborted);
|
|
415
483
|
} finally {
|
|
484
|
+
if (interactive) { process.stdin.removeListener("data", input); process.stdin.setRawMode(!!wasRaw); if (wasPaused) process.stdin.pause(); }
|
|
416
485
|
if (fixedScreen) process.stdout.write("\x1b[0m\x1b[?25h\x1b[?1049l");
|
|
417
486
|
process.removeListener("SIGINT", abort);
|
|
418
487
|
process.removeListener("SIGTERM", abort);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "local-commander-agent",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.14-cli.1",
|
|
4
4
|
"description": "Windows companion for Local Commander: connect AI clients to local files, approved commands and localhost. Requires https://localcmd.app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/runtime/JobHost.exe
CHANGED
|
Binary file
|
|
Binary file
|
package/runtime/OwnerBridge.exe
CHANGED
|
Binary file
|