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 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) · [Support](https://localcmd.app/support) · [Privacy](https://localcmd.app/privacy)
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** and create a Start menu entry named **Local Commander Monitor** that opens the live connections/tasks view in its own window. 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 or the shortcut. Fetch newer releases with `npx local-commander-agent@latest update`. Uninstall removes Local Commander's PATH entry, launcher and shortcut while preserving your other entries; an unrelated shortcut with the same name is never replaced.
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 “all connected drives” means
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, command arguments or private chat histories.
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`. Known command classes such as `node --test`, `git status` and `npm run build` are shown; arbitrary arguments, paths, inline scripts, headers, credentials, request bodies and tool output are never retained. Child PIDs are runtime notifications; the companion PID is identified separately.
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; there is no heartbeat daemon, AI parsing or network polling. `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.
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, and credentials, inline scripts and arbitrary arguments never appear.
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
 
@@ -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;
@@ -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
+ }
@@ -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
- event.sessionId && "session=" + event.sessionId, event.executionId && "execution=" + event.executionId].filter(Boolean).join(" ");
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 [...live].sort((a, b) => a.instanceId.localeCompare(b.instanceId))) {
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 >= 26 && width >= 60) lines.push(
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: reported or unavailable",
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
- "Ctrl+C exits | logs --follow: history + optimization | --json: all observations" +
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
- ...(recent ? [" Last: " + latest.at.slice(11, 19) + "Z " + recent.executionId.slice(0, 8) + " " + recent.command + " " + recent.state.toUpperCase()] : []),
227
- ...(mcpRecent ? [" MCP last: " + mcpRecent.at.slice(11, 19) + "Z " + (mcpRecent.name ?? "unbound") + " " + mcpRecent.command + " " + mcpRecent.state.toUpperCase()] : []),
228
- ...(mcpError ? [" Error: " + mcpError.at.slice(11, 19) + "Z " + mcpError.command + " " + mcpError.code] : []),
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" : "CONNECTING";
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 ?? "unavailable";
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 active work. Stopped, stale and completed work is hidden." :
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
- result.mcp = await readMcpActivity();
372
- if (command === "monitor") result.activity = await readAgentActivity(directory, { previousHosts, signal: stop.signal });
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 after = seen.get(key) ?? 0;
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.13-launcher.1",
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": {
Binary file
Binary file
Binary file