local-commander-agent 0.3.7 → 0.3.9-issue11.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
@@ -19,8 +19,8 @@ Use **Windows 11 x64** and a currently supported Node.js LTS version meeting the
19
19
  ```
20
20
 
21
21
  2. The installer opens a browser. Sign in to [Local Commander](https://localcmd.app), check the displayed pairing code, and approve this computer. Approve only a pairing you just started yourself.
22
- 3. Choose access in the local Windows dialog:
23
- - **Full access - all connected drives (default):** approve the available drive letters, file reads/writes, installed commands and localhost services at once. There is no folder picker or separate approval for Node.
22
+ 3. Choose access in the local Windows dialog:
23
+ - **Full access - all connected drives (default):** approve the available drive letters, file reads/writes, installed commands and localhost services at once. There is no folder picker or separate approval for Node.
24
24
  - **Selected folders:** choose one folder in the folder picker. It starts read/write; more folders and write permission can be added later.
25
25
 
26
26
  Cancel leaves existing permissions unchanged. On first setup, cancellation stops before enabling autostart or starting the agent. Existing grants are preserved when repeating installation or updating: an update never opts you into all drives.
@@ -40,7 +40,7 @@ Installation and update also add `localcommander` to your Windows **user PATH**.
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
 
43
- Full access allows installed commands resolved from absolute owner PATH entries or an absolute executable path, plus localhost services. Explicit command/port denials and write restrictions still apply. It does not elevate Windows privileges. Protected Local Commander state/runtime and secret-file filters apply to file tools; programs run with your Windows account's rights, so these controls are not an OS sandbox. ChatGPT/Claude or your workspace may still ask for their own action confirmations. Existing installations keep their current permissions until the owner runs `configure-access` and selects full access using a release that supports it.
43
+ Full access allows installed commands resolved from absolute owner PATH entries or an absolute executable path, plus localhost services. Explicit command/port denials and write restrictions still apply. It does not elevate Windows privileges. Protected Local Commander state/runtime and secret-file filters apply to file tools; programs run with your Windows account's rights, so these controls are not an OS sandbox. ChatGPT/Claude or your workspace may still ask for their own action confirmations. Existing installations keep their current permissions until the owner runs `configure-access` and selects full access using a release that supports it.
44
44
 
45
45
  ## Manage the installation
46
46
 
@@ -74,7 +74,7 @@ npx local-commander-agent@latest workspaces
74
74
  npx local-commander-agent@latest revoke-workspace <workspaceGrantId>
75
75
  ```
76
76
 
77
- `configure-access` reopens the full-access/selected-folder choice. **It replaces current grants and their restrictions.** Full access enables commands and localhost by default; selected folders start read/write with separate command/port allowlists. Choosing a restricted folder removes old broader grants. `approve-workspace` adds another read/write folder without replacing the existing set. `workspaces` shows grant IDs, full/scoped mode and explicit denials. A successful change restarts an already-running agent to synchronize grants; cancellation does not restart it. A revoked drive is not silently re-added in the background.
77
+ `configure-access` reopens the full-access/selected-folder choice. **It replaces current grants and their restrictions.** Full access enables commands and localhost by default; selected folders start read/write with separate command/port allowlists. Choosing a restricted folder removes old broader grants. `approve-workspace` adds another read/write folder without replacing the existing set. `workspaces` shows grant IDs, full/scoped mode and explicit denials. A successful change restarts an already-running agent to synchronize grants; cancellation does not restart it. A revoked drive is not silently re-added in the background.
78
78
 
79
79
  To enable or remove individual capabilities, replace the placeholders with a grant ID from `workspaces` and an executable path that actually exists on your PC:
80
80
 
@@ -88,11 +88,11 @@ npx local-commander-agent@latest deny-command <workspaceGrantId> node
88
88
  npx local-commander-agent@latest deny-localhost <workspaceGrantId> 3000
89
89
  ```
90
90
 
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
-
93
- 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
-
95
- The MCP discovery sequence is `workspace_list`, `diagnostics`, `execution_session_open`, requested work, output/status, then session close. Diagnostics reports quota separately from the device's execution slot limit. Its optional `repairSafe` mode releases only expired reservations from this OAuth connection that never acquired a dispatch lease. It never retries work, changes permissions or clears unknown executions.
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
+
93
+ 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
+
95
+ The MCP discovery sequence is `workspace_list`, `diagnostics`, `execution_session_open`, requested work, output/status, then session close. Diagnostics reports quota separately from the device's execution slot limit. Its optional `repairSafe` mode releases only expired reservations from this OAuth connection that never acquired a dispatch lease. It never retries work, changes permissions or clears unknown executions.
96
96
 
97
97
  ## Troubleshooting
98
98
 
@@ -133,8 +133,20 @@ npx local-commander-agent@latest logs --follow
133
133
 
134
134
  If the CLI is already on PATH, use `localcommander monitor --watch` or `localcommander logs --follow`. `monitor --json` returns a machine-readable snapshot; `logs` prints recent events once. Ctrl+C exits a live view without stopping the agent. These commands only read local monitoring files; they do not install, pair, restart, dispatch cloud work or consume execution quota.
135
135
 
136
- The view lists this Windows user's reporting production-agent instances, process IDs, agent versions, enrolled server origin, relay connection state and recently observed execution sessions. A live process with a snapshot older than ten seconds is **stale**, not connected. An execution session is not an independently identified AI agent. **Model names are unavailable** because the current production relay does not transmit them.
136
+ From the development checkout, `npm run monitor` opens this live view using the same installed-agent snapshots. `npm run monitor -- --json` streams the bounded JSON observations. There is no install or build step for the monitor.
137
+
138
+ 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.
139
+
140
+ In a terminal, `monitor --watch` uses a fixed screen and rewrites only changed lines every two seconds. Computers and work IDs have stable ordering; the footer stays at the bottom as processes start and finish. It fits the terminal size, repaints on resize and reports omitted active processes. It does not scroll or clear/redraw on each refresh; Ctrl+C restores the terminal, colors and cursor. One-shot `monitor` prints the same active-work view; redirected watch output and `--json` are ordinary snapshots. `NO_COLOR=1` disables colors; terminal backgrounds use the sample's navy with cyan status highlights.
141
+
142
+ The first-use MCP greeting asks which model you use when unknown; approved work continues without an answer. The client can classify each batch with optional `efficiencyProfile: {model: "your-model"}`. Labels are declarations, never verified AI identity or permissions. Repeat the label on each batch; missing metadata stays unknown. No pricing is collected or calculated.
143
+
144
+ Savings use the existing output-compaction ledger: original/sent bytes are measured, and estimated saved tokens equal saved bytes divided by four. The monitor totals cover this agent run, grouped by declared model, and reset on restart. They are not provider token counts. Older snapshots show unavailable savings rather than a fabricated zero.
145
+
146
+ 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.
147
+
148
+ 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.
137
149
 
138
- Each instance retains its last 100 connection/tool events and 32 recently observed sessions in `%LOCALAPPDATA%\LocalCommander\Monitor`. Logs show timestamps, tool names, result codes and session/execution IDs. They exclude credentials, command arguments, file paths, request bodies and tool output. The monitor refreshes every two seconds. `logs --follow` reports a gap if the bounded history is overwritten between reads. Last snapshots remain available after a stop; a new run replaces that state directory's snapshot.
150
+ `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.
139
151
 
140
152
  An older installed agent produces no monitoring snapshot even if it is running; the CLI reports that monitoring is unavailable. Publishing/installing the updated agent is required before these views can report its connection. The existing `status` command remains available for installation and process state.
@@ -172,7 +172,7 @@ function agentEnv() {
172
172
  function agentExe(runtimeDir) {
173
173
  return path.join(runtimeDir, "LocalCommanderAgent.exe");
174
174
  }
175
- function execAgent(runtimeDir, args, timeout = 30_000) {
175
+ function execAgent(runtimeDir, args, timeout = 30_000) {
176
176
  const result = spawnSync(agentExe(runtimeDir), args, {
177
177
  windowsHide: true,
178
178
  encoding: "utf8",
@@ -181,7 +181,22 @@ function execAgent(runtimeDir, args, timeout = 30_000) {
181
181
  });
182
182
  if (result.error) fail("AGENT_COMMAND_FAILED");
183
183
  return { status: result.status ?? -1, stdout: boundedText(result.stdout), stderr: boundedText(result.stderr) };
184
- }
184
+ }
185
+ async function fabricCommand(runtimeDir, command) {
186
+ const chunks = []; let bytes = 0;
187
+ for await (const chunk of process.stdin) {
188
+ bytes += chunk.length;
189
+ if (bytes > 16_384) fail("FABRIC_REQUEST_LIMIT");
190
+ chunks.push(chunk);
191
+ }
192
+ // Private stdin is explicit; no credential arguments, shell, listener or foreground window.
193
+ const result = spawnSync(path.join(runtimeDir, "node.exe"), [path.join(runtimeDir, "agent.mjs"), command], {
194
+ input: Buffer.concat(chunks), windowsHide: true, encoding: "utf8", timeout: 30_000,
195
+ maxBuffer: 2 * 1024 * 1024, env: agentEnv()
196
+ });
197
+ if (result.error || result.status !== 0) fail(/^[A-Z0-9_]{1,80}$/u.test(result.stderr?.trim()) ? result.stderr.trim() : "FABRIC_REQUEST_FAILED");
198
+ process.stdout.write(result.stdout);
199
+ }
185
200
  function openBrowser(url) {
186
201
  let parsed;
187
202
  try { parsed = new URL(url); } catch { fail("PAIRING_URL_INVALID"); }
@@ -406,7 +421,9 @@ function help() {
406
421
  " npx local-commander-agent@latest configure-access choose all connected drives or selected folders",
407
422
  " npx local-commander-agent@latest approve-workspace add another read/write folder",
408
423
  " npx local-commander-agent@latest revoke-workspace <workspaceGrantId>",
409
- " npx local-commander-agent@latest workspaces list approved workspaces and permissions",
424
+ " npx local-commander-agent@latest workspaces list approved workspaces and permissions",
425
+ " localcommander fabric-owner owner mailbox request through private stdin",
426
+ " localcommander fabric-worker restricted worker request through private stdin",
410
427
  " npx local-commander-agent@latest allow-write <workspaceGrantId>",
411
428
  " npx local-commander-agent@latest allow-command <workspaceGrantId> <name> <absolute-executable-path>",
412
429
  " npx local-commander-agent@latest allow-localhost <workspaceGrantId> <port>",
@@ -458,10 +475,17 @@ async function main() {
458
475
  process.stdout.write(JSON.stringify(await statusObject(), null, 2) + "\n");
459
476
  return;
460
477
  }
461
- if (command === "monitor" || command === "logs") {
478
+ if (command === "monitor" || command === "logs") {
462
479
  await runMonitor(path.join(appDataRoot(), "Monitor"), command, args);
463
- return;
464
- }
480
+ return;
481
+ }
482
+ if (command === "fabric-owner" || command === "fabric-worker") {
483
+ if (args.length) fail("ARGUMENT_INVALID");
484
+ const current = await readCurrent();
485
+ if (!current) fail("AGENT_NOT_INSTALLED");
486
+ await fabricCommand(current.runtimeDir, command);
487
+ return;
488
+ }
465
489
  if (command === "configure-access") {
466
490
  if (args.length) fail("ARGUMENT_INVALID");
467
491
  const current = await readCurrent();
package/bin/monitor.mjs CHANGED
@@ -4,9 +4,38 @@ import { setTimeout as delay } from "node:timers/promises";
4
4
 
5
5
  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;
6
6
  const STATES = new Set(["starting", "connecting", "connected", "disconnected", "stopped", "failed"]);
7
- const EVENTS = new Set([...STATES, "execute", "prepare", "status", "output", "cancel", "finished", "cancelled", "unknown", "result", "request_failed", "rejected", "connection_error"]);
7
+ const EVENTS = new Set([...STATES, "execute", "prepare", "status", "output", "cancel", "finished", "cancelled", "unknown", "result", "request_failed", "rejected", "connection_error", "compacted", "passthrough"]);
8
8
  const iso = value => typeof value === "string" && /^\d{4}-\d\d-\d\dT[\d:.]+Z$/.test(value) && Number.isFinite(Date.parse(value));
9
9
  const invalid = () => { throw Error("MONITOR_DATA_INVALID"); };
10
+ const count = value => Number.isSafeInteger(value) && value >= 0 && value <= 1_000_000_000_000;
11
+ const label = value => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,79}$/.test(value);
12
+ const EXECUTION_STATES = new Set(["requested", "prepared", "accepted", "running", "finished", "failed", "cancelled", "rejected", "unknown", "missing"]);
13
+ const PROCESS_NAMES = new Set(["node", "npm", "npx", "git", "rg", "powershell", "pwsh", "cmd", "tsc", "python", "dotnet", "command",
14
+ "fs_list", "fs_read", "fs_search", "fs_write", "fs_replace", "git_status", "git_diff", "git_log", "project_snapshot", "localhost_request"]);
15
+ const COMMANDS = new Set(["node --test", ...["status", "diff", "log", "show", "fetch", "pull", "push", "commit"].map(value => "git " + value),
16
+ ...["test", "install", "ci", "pack", "publish"].map(value => "npm " + value),
17
+ ...["test", "build", "typecheck", "lint", "dev", "start"].map(value => "npm run " + value),
18
+ ...[...PROCESS_NAMES].flatMap(value => [value, value + " [args]"])]);
19
+ function profileView(raw) {
20
+ if (raw === null) return null;
21
+ if (!raw || !label(raw.model)) invalid();
22
+ return { model: raw.model };
23
+ }
24
+ function efficiencyView(raw) {
25
+ if (raw === undefined) return null; // Older agents did not measure monitor savings.
26
+ if (!raw || raw.scope !== "this_run" || !count(raw.calls) || !count(raw.polls?.status) || !count(raw.polls?.output) ||
27
+ !Array.isArray(raw.models) || raw.models.length > 32) invalid();
28
+ const models = raw.models.map(group => {
29
+ if (!group || (group.model !== null && !label(group.model)) || !count(group.calls) || !count(group.savedBytes) || !count(group.sentBytes)) invalid();
30
+ return { model: group.model, calls: group.calls, savedBytes: group.savedBytes,
31
+ sentBytes: group.sentBytes, estimatedSavedTokens: Math.round(group.savedBytes / 4) };
32
+ });
33
+ if (models.reduce((sum, group) => sum + group.calls, 0) !== raw.calls) invalid();
34
+ const savedBytes = models.reduce((sum, group) => sum + group.savedBytes, 0);
35
+ return { scope: "this_run", calls: raw.calls, polls: { status: raw.polls.status, output: raw.polls.output }, models,
36
+ savedBytes, estimatedSavedTokens: Math.round(savedBytes / 4),
37
+ unclassifiedSavedBytes: models.filter(group => group.model === null).reduce((sum, group) => sum + group.savedBytes, 0) };
38
+ }
10
39
  function pidAlive(pid) {
11
40
  try { process.kill(pid, 0); return true; }
12
41
  catch (error) { return error?.code === "EPERM"; }
@@ -39,10 +68,33 @@ export function monitorView(raw, now = Date.now(), alive = pidAlive) {
39
68
  ...(event.tool ? { tool: event.tool } : {}), ...(event.code ? { code: event.code } : {}) };
40
69
  });
41
70
  const running = alive(raw.pid);
42
- const age = now - Date.parse(raw.updatedAt);
43
- const connection = !running ? "stopped" : age < -5000 || age > 10_000 ? "stale" : raw.connection.state;
71
+ if (raw.transportAt !== undefined && !iso(raw.transportAt)) invalid();
72
+ const age = now - Date.parse(raw.connection.state === "connected" ? raw.transportAt ?? raw.updatedAt : raw.updatedAt);
73
+ const freshnessMs = raw.freshnessMs ?? 10_000;
74
+ if (!Number.isInteger(freshnessMs) || freshnessMs < 1000 || freshnessMs > 180_000) invalid();
75
+ const connection = !running ? "stopped" : age < -5000 || age > freshnessMs ? "stale" : raw.connection.state;
76
+ if (raw.computer !== undefined && (typeof raw.computer !== "string" || !/^[A-Za-z0-9._-]{1,64}$/.test(raw.computer))) invalid();
77
+ if (raw.processName !== undefined && raw.processName !== "lc-agent") invalid();
78
+ if (raw.executions !== undefined && (!Array.isArray(raw.executions) || raw.executions.length > 64)) invalid();
79
+ const executions = (raw.executions ?? []).map(execution => {
80
+ if (!execution || !UUID.test(execution.executionId) || !UUID.test(execution.sessionId) || !PROCESS_NAMES.has(execution.name) ||
81
+ !COMMANDS.has(execution.command) || !EXECUTION_STATES.has(execution.state) ||
82
+ (execution.pid !== null && (!Number.isSafeInteger(execution.pid) || execution.pid <= 0)) ||
83
+ ![execution.originalBytes, execution.sentBytes, execution.savedBytes].every(value => value === null || count(value)) ||
84
+ (execution.reason !== null && (typeof execution.reason !== "string" || !/^[a-z_]{1,80}$/.test(execution.reason)))) invalid();
85
+ const measured = execution.originalBytes !== null;
86
+ if (measured ? execution.sentBytes === null || execution.savedBytes === null || execution.sentBytes > execution.originalBytes ||
87
+ execution.savedBytes !== execution.originalBytes - execution.sentBytes : execution.sentBytes !== null || execution.savedBytes !== null) invalid();
88
+ const profile = profileView(execution.profile);
89
+ const active = ["requested", "prepared", "accepted", "running"].includes(execution.state);
90
+ const state = active && connection !== "connected" ? connection === "stale" ? "stale" : "unknown" : execution.state;
91
+ return { executionId: execution.executionId, sessionId: execution.sessionId, name: execution.name, command: execution.command,
92
+ state, pid: execution.pid, profile, originalBytes: execution.originalBytes, sentBytes: execution.sentBytes,
93
+ savedBytes: execution.savedBytes, reason: execution.reason, estimatedSavedTokens: measured ? Math.round(execution.savedBytes / 4) : null };
94
+ });
44
95
  return { instanceId: raw.instanceId, pid: raw.pid, agentVersion: raw.agentVersion, updatedAt: raw.updatedAt,
45
- running, connection, origin, sessions, events };
96
+ running, connection, origin, sessions, events, computer: raw.computer ?? null, processName: "lc-agent", executions,
97
+ efficiency: efficiencyView(raw.efficiency) };
46
98
  }
47
99
 
48
100
  export async function readMonitors(directory) {
@@ -78,25 +130,137 @@ export async function readMonitors(directory) {
78
130
  }
79
131
 
80
132
  function logLine(instance, event) {
81
- return [event.at, "pid=" + instance.pid, event.event, event.tool, event.code,
133
+ const execution = instance.executions.find(row => row.executionId === event.executionId);
134
+ return [event.at, "pc=" + (instance.computer ?? "unknown"), "pid=" + instance.pid, event.event, event.tool, event.code,
135
+ execution && "cmd=" + execution.command,
136
+ execution?.profile && "model=" + execution.profile.model,
137
+ execution && ["compacted", "passthrough"].includes(event.event) && "saved=~" + compact(execution.estimatedSavedTokens) +
138
+ "tok sent=" + execution.sentBytes + "B reason=" + execution.reason,
82
139
  event.sessionId && "session=" + event.sessionId, event.executionId && "execution=" + event.executionId].filter(Boolean).join(" ");
83
140
  }
84
141
 
85
- export function formatMonitor(result) {
86
- const lines = ["Local Commander production monitor", ""];
87
- if (!result.instances.length) lines.push("No monitoring snapshots. Start an agent version that supports monitoring; older agents need an update.");
142
+ const compact = value => value === null ? "n/a" : value >= 1e6 ? (value / 1e6).toFixed(1) + "M" : value >= 1000 ? (value / 1000).toFixed(1) + "k" : String(value);
143
+ const fit = (value, width) => {
144
+ const text = String(value);
145
+ return (text.length > width ? width < 4 ? text.slice(0, width) : text.slice(0, width - 3) + "..." : text).padEnd(width);
146
+ };
147
+ export function formatMonitor(result, columns = process.stdout.columns ?? 120, height = 40, color = false) {
148
+ const width = Math.max(1, Math.min(160, Number.isFinite(columns) ? Math.floor(columns) : 120));
149
+ height = Math.max(1, Number.isFinite(height) ? Math.floor(height) : 40);
150
+ const live = result.instances.filter(instance => instance.running && ["starting", "connecting", "connected"].includes(instance.connection));
151
+ const pcs = new Map();
152
+ for (const instance of [...live].sort((a, b) => a.instanceId.localeCompare(b.instanceId))) {
153
+ const name = instance.computer ?? "unknown";
154
+ if (!pcs.has(name)) pcs.set(name, []);
155
+ pcs.get(name).push(instance);
156
+ }
157
+ const active = instance => instance.executions.filter(row => ["requested", "prepared", "accepted", "running"].includes(row.state));
158
+ const jobs = live.reduce((sum, instance) => sum + active(instance).length, 0);
159
+ const stats = live.map(instance => instance.efficiency).filter(Boolean);
160
+ const saved = stats.reduce((sum, item) => sum + item.savedBytes, 0);
161
+ const sent = stats.reduce((sum, item) => sum + item.models.reduce((bytes, group) => bytes + group.sentBytes, 0), 0);
162
+ const tokens = bytes => "~" + Math.round(bytes / 4).toLocaleString("en-US");
163
+ const pair = (left, right) => fit(left, Math.max(0, width - right.length - 1)) + " " + right;
164
+ const lines = [pair("LC >_ Local Commander MCP", "LIVE MONITOR"), "-".repeat(width), "> fleet status", ""];
165
+ const connected = [...pcs.values()].filter(instances => instances.some(instance => instance.connection === "connected")).length;
166
+ if (height >= 26 && width >= 60) lines.push(
167
+ "+-------------+ " + connected + " PCs connected",
168
+ "| >_ " + fit(jobs ? "RUNNING" : connected ? "READY" : "WAITING", 9) + "| " + jobs + " active processes",
169
+ "| LC >_ | Separate agent sessions",
170
+ "+-------------+ Parallel work across machines",
171
+ " ||",
172
+ " ___||___", "");
173
+ else lines.push(connected + " PCs connected | " + jobs + " active processes", "");
174
+
175
+ const body = [];
176
+ let shown = 0, shownPcs = 0;
177
+ const footer = [
178
+ "-".repeat(width),
179
+ stats.length ? "Raw: " + tokens(saved + sent) + " tokens Sent: " + tokens(sent) + " Saved: " + tokens(saved) +
180
+ " " + (saved + sent ? Math.round(100 * saved / (saved + sent)) : 0) + "%" + (stats.length < live.length ? " (partial)" : "") :
181
+ "Raw: n/a Sent: n/a Saved: n/a",
182
+ "~ Tokens: bytes / 4, this run | model: " + (live.some(instance => active(instance).some(row => row.profile)) ? "declared" : "unavailable") +
183
+ " | Sessions are not AI instance identities.",
184
+ "Server: " + ([...new Set(live.map(instance => instance.origin).filter(Boolean))].join(", ") || "not connected"),
185
+ "Ctrl+C exits | logs --follow: history | --json: all observations"
186
+ ];
187
+ const capacity = Math.max(0, height - lines.length - footer.length - 1);
188
+ for (const [name, instances] of [...pcs].sort(([a], [b]) => a.localeCompare(b))) {
189
+ const pcStats = instances.map(instance => instance.efficiency).filter(Boolean);
190
+ const pcSaved = pcStats.length ? tokens(pcStats.reduce((sum, item) => sum + item.savedBytes, 0)) : "n/a";
191
+ const rows = instances.flatMap(active);
192
+ const state = rows.length ? "RUNNING" : instances.some(instance => instance.connection === "connected") ? "READY" : "CONNECTING";
193
+ // Keep each process with its PC heading, including in a short terminal.
194
+ if (capacity - body.length < (rows.length ? width < 60 ? 5 : 4 : 3)) break;
195
+ body.push(pair("PC " + name + " | saved " + pcSaved + " tokens", state));
196
+ shownPcs++;
197
+ if (rows.length) {
198
+ if (width >= 100) body.push(" " + fit("WORK ID", 8) + " " + fit("PID", 7) + " " + fit("DOING", width - 70) +
199
+ " " + fit("MODEL", 24) + " " + fit("SAVED TOK", 10) + " STATE");
200
+ for (const row of rows.sort((a, b) => a.executionId.localeCompare(b.executionId))) {
201
+ const id = row.executionId.slice(0, 8), model = row.profile?.model ?? "unavailable";
202
+ const saving = row.estimatedSavedTokens === null ? "n/a" : "~" + compact(row.estimatedSavedTokens);
203
+ const entry = width >= 100 ? [" " + id + " " + fit(row.pid ?? "-", 7) + " " + fit(row.command, width - 70) +
204
+ " " + fit(model, 24) + " " + fit(saving, 10) + " " + row.state.toUpperCase()] : [
205
+ pair(" " + id + " " + row.command, row.state.toUpperCase()),
206
+ ...(width >= 60 ? [" PID " + (row.pid ?? "-") + " | model " + fit(model, width - 51).trimEnd() + " | saved " + saving + " tok"] :
207
+ [" model " + model, " PID " + (row.pid ?? "-") + " | saved " + saving + " tok"])
208
+ ];
209
+ if (body.length + entry.length + 1 > capacity) break;
210
+ body.push(...entry);
211
+ shown++;
212
+ }
213
+ } else body.push(" Waiting for work");
214
+ body.push("");
215
+ }
216
+ if (!pcs.size) body.push(result.instances.length ? "No active work. Stopped, stale and completed work is hidden." :
217
+ "No monitoring snapshots. Update/start a monitoring-capable agent.");
218
+ lines.push(...body.slice(0, capacity));
219
+ while (lines.length < height - footer.length - 1) lines.push("");
220
+ lines.push((jobs > shown ? (jobs - shown) + " more active processes | " : "") +
221
+ (pcs.size > shownPcs ? (pcs.size - shownPcs) + " more PCs | " : "") +
222
+ "Unavailable/stale/stopped snapshots: " + (result.unavailable + result.instances.length - live.length));
223
+ lines.push(...footer);
224
+ // Paint after clipping so ANSI sequences never count toward the terminal width.
225
+ return lines.slice(0, height).map(line => {
226
+ const text = fit(line, width);
227
+ if (!color) return text.trimEnd();
228
+ const cyan = /^LC |^> |^PC |^[+|]|^\s+[_|]/.test(line);
229
+ return "\x1b[48;2;15;23;42m\x1b[38;2;" + (cyan ? "0;255;213" : "191;219;254") + "m" +
230
+ text.replace(/\b(RUNNING|READY|CONNECTED)\b|Saved:.*$/g, value => "\x1b[38;2;0;255;213m" + value + "\x1b[38;2;191;219;254m") + "\x1b[0m";
231
+ }).join("\n") + "\n";
232
+ }
233
+
234
+ export function formatInsights(result) {
235
+ const lines = ["OUTPUT TO REVIEW (recent measured bytes; potential savings unproven):"];
236
+ const candidates = result.instances.flatMap(instance => instance.executions.filter(row => row.sentBytes > 0)
237
+ .map(row => ({ ...row, computer: instance.computer ?? "unknown" })))
238
+ .sort((a, b) => b.sentBytes - a.sentBytes).slice(0, 5);
239
+ if (!candidates.length) lines.push(" No measured output candidates yet.");
240
+ for (const row of candidates) lines.push(" " + row.computer + " " + row.executionId.slice(0, 8) + " " + row.command + " | " + compact(row.sentBytes) +
241
+ " B sent | " + (row.reason ?? "unknown") + " | " +
242
+ (row.reason === "validated_compact" ? "already compacted; review remaining detail" :
243
+ row.name === "fs_read" ? "request a focused line range" : row.name === "fs_search" ? "narrow query and maxResults" :
244
+ "review command verbosity; preserve failures/warnings"));
88
245
  for (const instance of result.instances) {
89
- lines.push("PID " + instance.pid + " | v" + instance.agentVersion + " | " + instance.connection.toUpperCase(),
90
- "Server: " + (instance.origin ?? "Not connected yet") + " | Updated: " + instance.updatedAt,
91
- "Observed execution sessions (not AI instance identities): " + instance.sessions.length);
92
- for (const session of instance.sessions) lines.push(" " + session.sessionId + " | model: unavailable | last seen: " + session.lastSeenAt);
93
- lines.push("Recent events:", ...instance.events.slice(-10).map(event => " " + logLine(instance, event)), "");
246
+ const stats = instance.efficiency;
247
+ if (stats && stats.polls.status + stats.polls.output >= 10) lines.push("POLLING: " + stats.polls.status + " status / " + stats.polls.output +
248
+ " output calls on " + (instance.computer ?? "unknown") + "; wait between polls and fetch final output once when practical.");
94
249
  }
95
- if (result.unavailable) lines.push("Unavailable or omitted snapshots: " + result.unavailable);
96
- lines.push("Models are unavailable: production relay does not report AI model names.");
97
250
  return lines.join("\n") + "\n";
98
251
  }
99
252
 
253
+ // Fixed cursor positions: rewrite changed lines only, with no clear/redraw or scrolling.
254
+ export function monitorFrame(previous, next, maxRows = Infinity) {
255
+ const before = previous ? previous.trimEnd().split("\n") : [];
256
+ const after = next.trimEnd().split("\n");
257
+ let output = "";
258
+ for (let index = 0; index < Math.min(maxRows, Math.max(before.length, after.length)); index++) {
259
+ if (before[index] !== after[index]) output += "\x1b[" + (index + 1) + ";1H\x1b[2K" + (after[index] ?? "");
260
+ }
261
+ return output;
262
+ }
263
+
100
264
  export async function runMonitor(directory, command, args) {
101
265
  const allowed = command === "logs" ? ["--follow"] : ["--watch", "--json"];
102
266
  if (new Set(args).size !== args.length || args.some(arg => !allowed.includes(arg))) throw Error("ARGUMENT_INVALID");
@@ -106,10 +270,14 @@ export async function runMonitor(directory, command, args) {
106
270
  process.once("SIGINT", abort);
107
271
  process.once("SIGTERM", abort);
108
272
  const seen = new Map();
273
+ const fixedScreen = command === "monitor" && repeat && process.stdout.isTTY && !args.includes("--json");
274
+ let previous = "", previousSize = "", initial = true;
275
+ if (fixedScreen) process.stdout.write("\x1b[?1049h\x1b[?25l");
109
276
  try {
110
277
  do {
111
278
  const result = await readMonitors(directory);
112
279
  if (command === "logs" && result.instances.length) {
280
+ if (initial) process.stdout.write(formatInsights(result));
113
281
  for (const instance of result.instances) {
114
282
  const after = seen.get(instance.instanceId) ?? 0;
115
283
  if (after && instance.events[0]?.seq > after + 1) process.stdout.write("Earlier events were overwritten in the 100-event buffer.\n");
@@ -119,12 +287,23 @@ export async function runMonitor(directory, command, args) {
119
287
  for (const key of seen.keys()) if (!result.instances.some(instance => instance.instanceId === key)) seen.delete(key);
120
288
  if (result.unavailable) process.stderr.write("Some monitoring snapshots are unavailable.\n");
121
289
  } else {
122
- if (repeat && process.stdout.isTTY && !args.includes("--json")) console.clear();
123
- process.stdout.write(args.includes("--json") ? JSON.stringify(result) + "\n" : formatMonitor(result));
290
+ const columns = Math.max(1, (process.stdout.columns ?? 120) - 1);
291
+ const rows = Math.max(1, (process.stdout.rows ?? 40) - 1);
292
+ const size = columns + "x" + rows;
293
+ // A resized terminal must repaint even when the snapshot is unchanged.
294
+ if (fixedScreen && size !== previousSize) previous = "";
295
+ const next = args.includes("--json") ? JSON.stringify(result) + "\n" :
296
+ formatMonitor(result, fixedScreen ? columns : undefined, fixedScreen ? rows : undefined,
297
+ process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb");
298
+ process.stdout.write(fixedScreen ? monitorFrame(previous, next, Math.max(1, (process.stdout.rows ?? 40) - 1)) : next);
299
+ previous = next;
300
+ previousSize = size;
124
301
  }
302
+ initial = false;
125
303
  if (repeat) await delay(2000, undefined, { signal: stop.signal }).catch(error => { if (error.name !== "AbortError") throw error; });
126
304
  } while (repeat && !stop.signal.aborted);
127
305
  } finally {
306
+ if (fixedScreen) process.stdout.write("\x1b[0m\x1b[?25h\x1b[?1049l");
128
307
  process.removeListener("SIGINT", abort);
129
308
  process.removeListener("SIGTERM", abort);
130
309
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-commander-agent",
3
- "version": "0.3.7",
3
+ "version": "0.3.9-issue11.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