@ocis/myagent-cli 0.2.1 → 0.2.3

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
@@ -42,9 +42,24 @@ npm install -g @ocis/myagent-cli # global
42
42
 
43
43
  The CLI runs on **Bun ≥1.0** or **Node.js ≥22.19**. The published bin is a small
44
44
  launcher that prefers Bun and falls back to Node, so `bunx` and `npm install -g`
45
- both work with whichever runtime you have. Configure credentials with
46
- `myagent config`, or set `MYAGENT_BASE_URL` / `MYAGENT_INTEGRATION_ID` /
47
- `MYAGENT_API_KEY`.
45
+ both work with whichever runtime you have. Everywhere below, `myagent` means the
46
+ installed binary — with `bunx`, substitute `bunx @ocis/myagent-cli`.
47
+
48
+ Point it at an integration — the **API Base URL** and **API key** are on the
49
+ Integrations page (the combined URL carries the integration id):
50
+
51
+ ```bash
52
+ # Env vars — handy for bunx and CI:
53
+ MYAGENT_BASE_URL=https://your-host/integrations/<id> \
54
+ MYAGENT_API_KEY=iak_... \
55
+ bunx @ocis/myagent-cli
56
+
57
+ # Or flags:
58
+ bunx @ocis/myagent-cli --base-url https://your-host/integrations/<id> --api-key iak_...
59
+
60
+ # Or persist them once (writes ~/.config/myagent/config.json, 0600):
61
+ myagent config
62
+ ```
48
63
 
49
64
  ## Plan / Coding / Cowork modes
50
65
 
@@ -111,14 +126,13 @@ you decide, can each need one). Each waiting approval keeps its own deadline
111
126
  and its own cancellation, so a release of one call never cancels another and
112
127
  `/stop` cancels every one of them.
113
128
 
114
- While you decide, the CLI watches the session's event channel (when the server
115
- advertises `sessionEvents`) and reports when the channel itself drops and is
116
- being reconnected. If the server releases the call while you decide (the
117
- 30-minute lease expired, the sandbox restarted, or the run ended), the
118
- approval is **cancelled** — the tool never runs, and the tool card in the
119
- transcript says so. Stopping the run (Esc, Ctrl+C, `/stop`) cancels a pending
120
- approval the same way. If the sandbox was paused while you were away, the next
121
- request wakes it automatically.
129
+ While you decide, the CLI watches the session's event channel and reports when
130
+ the channel itself drops and is being reconnected. If the server releases the
131
+ call while you decide (the 30-minute lease expired, the sandbox restarted, or
132
+ the run ended), the approval is **cancelled** — the tool never runs, and the
133
+ tool card in the transcript says so. Stopping the run (Esc, Ctrl+C, `/stop`)
134
+ cancels a pending approval the same way. If the sandbox was paused while you
135
+ were away, the next request wakes it automatically.
122
136
 
123
137
  ## Context files
124
138
 
package/bin/myagent.js CHANGED
@@ -21,13 +21,18 @@ for (const sig of ["SIGTERM", "SIGHUP"]) {
21
21
  process.on(sig, () => { try { child?.kill(sig); } catch { /* already gone */ } });
22
22
  }
23
23
 
24
+ /** pi-tui's regex needs the `v` flag, which Node only has from 22.19. */
25
+ function nodeTooOld() {
26
+ const [major, minor] = process.versions.node.split(".").map(Number);
27
+ return major < 22 || (major === 22 && minor < 19);
28
+ }
29
+
24
30
  function launch(runtime) {
25
31
  child = spawn(runtime, [entry, ...args], { stdio: "inherit" });
26
32
 
27
33
  child.once("error", (err) => {
28
34
  if (err.code === "ENOENT" && runtime !== process.execPath) {
29
- const [major, minor] = process.versions.node.split(".").map(Number);
30
- if (major < 22 || (major === 22 && minor < 19)) {
35
+ if (nodeTooOld()) {
31
36
  console.error(
32
37
  `myagent requires Bun or Node.js >= 22.19 (found Node ${process.versions.node}).\n` +
33
38
  "Install Bun: https://bun.sh",
@@ -74,7 +74,7 @@ export declare class AgentRunner {
74
74
  * before an in-flight round resumes, so it cannot be used there).
75
75
  */
76
76
  private stopped;
77
- private followUps;
77
+ private readonly followUps;
78
78
  /**
79
79
  * Chain of fire-and-forget follow-up turns (user follow-ups).
80
80
  * `waitForIdle()` awaits it so headless runs don't exit before a queued
@@ -88,15 +88,16 @@ export declare class AgentRunner {
88
88
  * filter: a replayed CUSTOM_TOOL_CALL for an abandoned call is never
89
89
  * executed again.
90
90
  */
91
- private abandonedToolCalls;
91
+ private readonly abandonedToolCalls;
92
92
  /**
93
- * Every CUSTOM_TOOL_CALL id received this session. The replay guard: an id
93
+ * Every client tool call id this session has taken ownership of — from a
94
+ * CUSTOM_TOOL_CALL event or a reconcile adoption. The replay guard: an id
94
95
  * seen once is never enqueued again, whatever the delivery path (run stream,
95
96
  * event-channel replay) — a duplicate would execute a side-effecting tool
96
97
  * twice. Cumulative like abandonedToolCalls: ids are unique per call, so
97
98
  * there is nothing to reset.
98
99
  */
99
- private seenToolCallIds;
100
+ private readonly seenToolCallIds;
100
101
  /**
101
102
  * Unix ms of a server-reported interruption of the previous run (restart /
102
103
  * idle scale-down) — wording only: released calls are explained as an
@@ -108,7 +109,7 @@ export declare class AgentRunner {
108
109
  * An id is removed BEFORE its submission request goes out, so the server's
109
110
  * echo of our own result can never be misread as a release.
110
111
  */
111
- private pendingSubmission;
112
+ private readonly pendingSubmission;
112
113
  /**
113
114
  * Steers acked but not yet injected into the run, keyed by the persisted
114
115
  * message id (from INPUT_ACCEPTED). The ack only means "persisted + queued
@@ -118,13 +119,13 @@ export declare class AgentRunner {
118
119
  * as a fresh prompt by the manager, and the message is in the session
119
120
  * either way, so the float must not linger.
120
121
  */
121
- private pendingSteerDeliveries;
122
+ private readonly pendingSteerDeliveries;
122
123
  /**
123
124
  * Tool rounds scheduled or executing (debounce included). The turn drains
124
125
  * them before it settles, so a round is never raced by the followUps drain.
125
126
  */
126
127
  private roundsInFlight;
127
- private roundsDrained;
128
+ private readonly roundsDrained;
128
129
  /**
129
130
  * True once the current run segment ended (RUN_FINISHED/RUN_ERROR) — a
130
131
  * stream that closes without this observed a drop, not a settle.
@@ -225,7 +226,7 @@ export declare class AgentRunner {
225
226
  waitForIdle(): Promise<void>;
226
227
  /**
227
228
  * Deliver a round's tool results, reconciling against the server's truth
228
- * first when it advertises `toolCallExpiry`:
229
+ * first (the pending registry is authoritative):
229
230
  *
230
231
  * - still pending (exact id) → submit as-is (native tool result, same run);
231
232
  * - the model retried the same action (same name + canonical args) → adopt
@@ -266,7 +267,7 @@ export declare class AgentRunner {
266
267
  */
267
268
  private markAbandoned;
268
269
  private latchThread;
269
- /** Open the session event subscription for the recovery window (capability-gated). */
270
+ /** Open the session event subscription for the recovery window. */
270
271
  private startEventSubscription;
271
272
  private stopEventSubscription;
272
273
  /**
@@ -148,7 +148,8 @@ export class AgentRunner {
148
148
  */
149
149
  abandonedToolCalls = new Set();
150
150
  /**
151
- * Every CUSTOM_TOOL_CALL id received this session. The replay guard: an id
151
+ * Every client tool call id this session has taken ownership of — from a
152
+ * CUSTOM_TOOL_CALL event or a reconcile adoption. The replay guard: an id
152
153
  * seen once is never enqueued again, whatever the delivery path (run stream,
153
154
  * event-channel replay) — a duplicate would execute a side-effecting tool
154
155
  * twice. Cumulative like abandonedToolCalls: ids are unique per call, so
@@ -480,6 +481,11 @@ export class AgentRunner {
480
481
  batch = [];
481
482
  void this.executeAndSubmit(calls, cb, signal)
482
483
  .catch((err) => {
484
+ // A stop aborts the round mid-flight — "Stopped." is the message;
485
+ // a "Tool round failed" notice would read as an error the user
486
+ // didn't cause.
487
+ if (signal.aborted)
488
+ return;
483
489
  cb.onNotice(`Tool round failed: ${err instanceof Error ? err.message : String(err)}`, "error");
484
490
  })
485
491
  .finally(() => {
@@ -694,7 +700,7 @@ export class AgentRunner {
694
700
  }
695
701
  /**
696
702
  * Deliver a round's tool results, reconciling against the server's truth
697
- * first when it advertises `toolCallExpiry`:
703
+ * first (the pending registry is authoritative):
698
704
  *
699
705
  * - still pending (exact id) → submit as-is (native tool result, same run);
700
706
  * - the model retried the same action (same name + canonical args) → adopt
@@ -850,6 +856,14 @@ export class AgentRunner {
850
856
  if (adopted) {
851
857
  live.push({ ...output, tool_call_id: adopted.id });
852
858
  unmatched.delete(adopted.id);
859
+ // The original id is never submitted (its result was re-attached to
860
+ // the retry), so it must leave the pending set — otherwise the run-end
861
+ // sweep announces a release for a call that was answered.
862
+ if (call)
863
+ this.pendingSubmission.delete(call.id);
864
+ // The retry id is answered by the re-attached result — a replayed
865
+ // CUSTOM_TOOL_CALL for it must never execute it again.
866
+ this.seenToolCallIds.add(adopted.id);
853
867
  cb.onNotice(`Re-attached the result of ${call?.name ?? "a tool call"} to the agent's retry.`, "info");
854
868
  continue;
855
869
  }
@@ -857,18 +871,27 @@ export class AgentRunner {
857
871
  }
858
872
  // Pending calls this client never executed (e.g. the model retried with
859
873
  // different arguments): execute them through the normal approval path so
860
- // the run isn't left waiting on a call nobody will answer.
861
- if (unmatched.size > 0) {
862
- const extraCalls = [...unmatched.values()].map((p) => ({
863
- id: p.id,
864
- name: p.name,
865
- arguments: p.arguments,
866
- }));
874
+ // the run isn't left waiting on a call nobody will answer. Skip ids
875
+ // another round already claimed (pendingSubmission) or executed
876
+ // (seenToolCallIds) — concurrent rounds reconcile against the same server
877
+ // snapshot, and adopting an id twice would run a side-effecting tool twice.
878
+ const extraCalls = [...unmatched.values()]
879
+ .filter((p) => !this.pendingSubmission.has(p.id) && !this.seenToolCallIds.has(p.id))
880
+ .map((p) => ({
881
+ id: p.id,
882
+ name: p.name,
883
+ arguments: p.arguments,
884
+ }));
885
+ if (extraCalls.length > 0) {
867
886
  // Register before execution (same as event-driven calls via
868
887
  // executeAndSubmit) so a release arriving mid-execution is surfaced by
869
- // the release filter instead of only surfacing at submission time.
870
- for (const call of extraCalls)
888
+ // the release filter instead of only surfacing at submission time. The
889
+ // replay guard too: a replayed CUSTOM_TOOL_CALL for an adopted id must
890
+ // never execute it again.
891
+ for (const call of extraCalls) {
871
892
  this.pendingSubmission.add(call.id);
893
+ this.seenToolCallIds.add(call.id);
894
+ }
872
895
  live.push(...await this.executeToolCalls(extraCalls, signal));
873
896
  }
874
897
  return { live, released };
@@ -918,7 +941,7 @@ export class AgentRunner {
918
941
  // -------------------------------------------------------------------------
919
942
  // Fallback event channel (reconcile window only — no run stream attached)
920
943
  // -------------------------------------------------------------------------
921
- /** Open the session event subscription for the recovery window (capability-gated). */
944
+ /** Open the session event subscription for the recovery window. */
922
945
  startEventSubscription() {
923
946
  if (!this.threadId)
924
947
  return;
@@ -1193,8 +1216,11 @@ export class AgentRunner {
1193
1216
  calls.push(call);
1194
1217
  // Register before execution (same as executeAndSubmit/reconcileRound):
1195
1218
  // a release arriving while an approval is pending must cancel it via
1196
- // the release filter, not wait out the approval timeout.
1219
+ // the release filter, not wait out the approval timeout. The replay
1220
+ // guard too — a replayed CUSTOM_TOOL_CALL for this id must never
1221
+ // execute it again.
1197
1222
  this.pendingSubmission.add(item.id);
1223
+ this.seenToolCallIds.add(item.id);
1198
1224
  const handler = this.opts.registry.get(item.name);
1199
1225
  const parsed = this.parseCall(call);
1200
1226
  if (!isMutatingCall(handler, parsed.args)) {
@@ -142,7 +142,7 @@ export class ApprovalPolicy {
142
142
  try {
143
143
  // A cancel/timeout while queued settles immediately — no card, no wait.
144
144
  if (wait)
145
- await Promise.race([previous.catch(() => { }), deadline.promise]);
145
+ await Promise.race([previous, deadline.promise]);
146
146
  if (settled)
147
147
  return await deadline.promise;
148
148
  return await Promise.race([this.prompt(request, controller.signal), deadline.promise]);
package/dist/headless.js CHANGED
@@ -282,6 +282,12 @@ export async function runHeadless(opts) {
282
282
  // Same for the best-effort title update latched by onThread above.
283
283
  await titleUpdate;
284
284
  }
285
+ catch (err) {
286
+ // The output contract is "always a parseable object" (json/stream-json):
287
+ // an escaping exception would leave stdout empty and exit 1 without an
288
+ // error_code. Record it and fall through to the normal output below.
289
+ firstError.record(err instanceof Error ? err.message : String(err), "run_failed");
290
+ }
285
291
  finally {
286
292
  stop.dispose();
287
293
  }
package/dist/index.d.ts CHANGED
@@ -45,9 +45,12 @@ export declare function assemblePrompt(args: Args, stdinText: string): string;
45
45
  * Report a pre-run failure (usage/config/connection) in the requested output
46
46
  * format and return the exit code. `json` always emits a parseable object — a
47
47
  * CI parser must never see an empty stdout; `stream-json` emits a terminal
48
- * `done` event; text goes to stderr.
48
+ * `done` event; text goes to stderr. The default exit code is 2
49
+ * (usage/config/connection); a failure that is really a failed run (e.g. a
50
+ * `--timeout` expiry) passes 1 so the same `error_code` always maps to the
51
+ * same code.
49
52
  */
50
- export declare function fail(format: OutputFormat | undefined, message: string, errorCode?: string): number;
53
+ export declare function fail(format: OutputFormat | undefined, message: string, errorCode?: string, exitCode?: number): number;
51
54
  /**
52
55
  * Read piped stdin as prompt text. Bounded by the run's `--timeout` when one
53
56
  * is set: a non-TTY stdin that never closes (a CI misconfiguration like
package/dist/index.js CHANGED
@@ -8,8 +8,8 @@
8
8
  import { createInterface } from "node:readline/promises";
9
9
  import { stdin as input, stdout as output } from "node:process";
10
10
  import { homedir } from "node:os";
11
- import { readFile } from "node:fs/promises";
12
11
  import { resolve } from "node:path";
12
+ import pkg from "../package.json" with { type: "json" };
13
13
  import { IntegrationClient } from "./protocol/client.js";
14
14
  import { ToolRegistry } from "./tools/registry.js";
15
15
  import { combinedIntegrationUrl, configDir, isConfigured, loadConfig, migrateLegacyConfigDir, resolveConfig, saveConfig, sessionsPath, splitIntegrationUrl, } from "./config.js";
@@ -21,18 +21,11 @@ import { discoverSkills, formatSkillList, resolveSkillDirs } from "./skills/disc
21
21
  import { sanitizeForTerminal } from "./sanitize.js";
22
22
  import { isMainModule, readStdinText } from "./runtime.js";
23
23
  /**
24
- * Package version, read from package.json so npm releases never drift from
25
- * `--version`. Resolves to the package root from both src/ (dev) and dist/.
24
+ * Package version, bundled from package.json so npm releases and the compiled
25
+ * binary never drift from `--version` (a runtime read cannot resolve inside
26
+ * `bun build --compile`'s virtual filesystem).
26
27
  */
27
- async function readVersion() {
28
- try {
29
- const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
30
- return pkg.version ?? "0.0.0";
31
- }
32
- catch {
33
- return "0.0.0";
34
- }
35
- }
28
+ const VERSION = pkg.version;
36
29
  const HELP = `myagent — a coding agent for your local repository, powered by MyAgent
37
30
 
38
31
  Usage:
@@ -250,9 +243,12 @@ export function assemblePrompt(args, stdinText) {
250
243
  * Report a pre-run failure (usage/config/connection) in the requested output
251
244
  * format and return the exit code. `json` always emits a parseable object — a
252
245
  * CI parser must never see an empty stdout; `stream-json` emits a terminal
253
- * `done` event; text goes to stderr.
246
+ * `done` event; text goes to stderr. The default exit code is 2
247
+ * (usage/config/connection); a failure that is really a failed run (e.g. a
248
+ * `--timeout` expiry) passes 1 so the same `error_code` always maps to the
249
+ * same code.
254
250
  */
255
- export function fail(format, message, errorCode) {
251
+ export function fail(format, message, errorCode, exitCode = 2) {
256
252
  if (format === "json") {
257
253
  process.stdout.write(failureResult(message, errorCode) + "\n");
258
254
  }
@@ -262,7 +258,7 @@ export function fail(format, message, errorCode) {
262
258
  else {
263
259
  process.stderr.write(message + "\n");
264
260
  }
265
- return 2;
261
+ return exitCode;
266
262
  }
267
263
  /**
268
264
  * Read piped stdin as prompt text. Bounded by the run's `--timeout` when one
@@ -304,7 +300,7 @@ async function main() {
304
300
  return 0;
305
301
  }
306
302
  if (args.command === "version") {
307
- process.stdout.write(`${await readVersion()}\n`);
303
+ process.stdout.write(`${VERSION}\n`);
308
304
  return 0;
309
305
  }
310
306
  const stored = await loadConfig();
@@ -383,11 +379,15 @@ async function main() {
383
379
  if (readsStdin && !process.stdin.isTTY && args.promptParts.length > 0 && !args.flags.timeout) {
384
380
  process.stderr.write("Reading additional prompt text from stdin until EOF — pass `< /dev/null` or --timeout <sec> if nothing is piped.\n");
385
381
  }
386
- const stdinText = readsStdin
387
- ? await readStdin(args.flags.timeout ? args.flags.timeout * 1000 : undefined)
388
- : "";
382
+ // The --timeout budget covers the whole invocation, not each phase: the
383
+ // stdin wait below consumes part of it, and the run gets what is left.
384
+ const timeoutMs = args.flags.timeout ? args.flags.timeout * 1000 : undefined;
385
+ const startedAt = Date.now();
386
+ const stdinText = readsStdin ? await readStdin(timeoutMs) : "";
389
387
  if (stdinText === null) {
390
- return fail(args.flags.outputFormat, `Timed out after ${args.flags.timeout}s waiting for stdin.`, "timeout");
388
+ // Same `error_code` as a run timeout, so the same exit code: the help
389
+ // documents 1 = run failed (incl. timeout), 2 = usage/config/connection.
390
+ return fail(args.flags.outputFormat, `Timed out after ${args.flags.timeout}s waiting for stdin.`, "timeout", 1);
391
391
  }
392
392
  const prompt = assemblePrompt(args, stdinText);
393
393
  const registry = new ToolRegistry();
@@ -413,6 +413,14 @@ async function main() {
413
413
  if (!prompt) {
414
414
  return fail(args.flags.outputFormat, "No prompt provided. Usage: myagent -p \"...\" or pipe text via stdin.", "no_prompt");
415
415
  }
416
+ // What is left of the --timeout budget after the stdin wait and setup. A
417
+ // non-positive remainder means the budget is already spent — fail as a
418
+ // timeout instead of handing the run a zero timeout (which would disable
419
+ // the timer entirely).
420
+ const remainingTimeoutMs = timeoutMs === undefined ? undefined : timeoutMs - (Date.now() - startedAt);
421
+ if (remainingTimeoutMs !== undefined && remainingTimeoutMs <= 0) {
422
+ return fail(args.flags.outputFormat, `Timed out after ${args.flags.timeout}s (the stdin wait consumed the budget).`, "timeout", 1);
423
+ }
416
424
  return await runHeadless({
417
425
  client,
418
426
  registry,
@@ -430,7 +438,7 @@ async function main() {
430
438
  outputFormat: args.flags.outputFormat ?? "text",
431
439
  quiet: args.flags.quiet,
432
440
  lastMessageOnly: promptMode,
433
- timeoutMs: args.flags.timeout ? args.flags.timeout * 1000 : undefined,
441
+ timeoutMs: remainingTimeoutMs,
434
442
  });
435
443
  }
436
444
  function printSessions(sessions) {
package/dist/runtime.d.ts CHANGED
@@ -1,5 +1,3 @@
1
- /** Resolve an executable on PATH (Bun.which on Bun, a PATH scan on Node). */
2
- export declare function which(cmd: string): string | null;
3
1
  /** Read all of stdin as UTF-8 text. Callers check `process.stdin.isTTY` first. */
4
2
  export declare function readStdinText(): Promise<string>;
5
3
  /**
@@ -24,3 +22,10 @@ export interface SpawnOptions {
24
22
  detached?: boolean;
25
23
  }
26
24
  export declare function spawnProcess(args: string[], opts?: SpawnOptions): ProcessHandle;
25
+ /**
26
+ * Exit code for a child that ended, matching Bun's convention: a signal death
27
+ * is 128 + signal number (SIGKILL → 137). Node's `close` reports `code: null`
28
+ * with the signal name, so without this the same kill reads as 0 on Node and
29
+ * 137 on Bun — abort paths key on the code.
30
+ */
31
+ export declare function exitCodeFromClose(code: number | null, signal: NodeJS.Signals | null): number;
package/dist/runtime.js CHANGED
@@ -2,40 +2,18 @@
2
2
  // Runtime shims: the few places where Bun and Node genuinely differ.
3
3
  //
4
4
  // Everything else uses node: builtins, which Bun implements natively — only
5
- // subprocess spawning (Bun.spawn vs node:child_process), PATH lookup
6
- // (Bun.which), stdin (Bun.stdin) and entry-point detection (import.meta.main)
7
- // need a runtime branch. The Bun branches are guarded by `typeof Bun`, so the
8
- // emitted JS never touches the `Bun` global on Node.
5
+ // subprocess spawning (Bun.spawn vs node:child_process), stdin (Bun.stdin) and
6
+ // entry-point detection (import.meta.main) need a runtime branch. The Bun
7
+ // branches are guarded by `typeof Bun`, so the emitted JS never touches the
8
+ // `Bun` global on Node.
9
9
  // ---------------------------------------------------------------------------
10
10
  import { spawn } from "node:child_process";
11
- import { realpathSync, statSync } from "node:fs";
11
+ import { realpathSync } from "node:fs";
12
12
  import { Buffer } from "node:buffer";
13
- import { delimiter, join } from "node:path";
13
+ import { constants as osConstants } from "node:os";
14
14
  import { Readable } from "node:stream";
15
15
  import { fileURLToPath } from "node:url";
16
16
  const isBun = typeof Bun !== "undefined";
17
- // --- which -----------------------------------------------------------------
18
- /** Resolve an executable on PATH (Bun.which on Bun, a PATH scan on Node). */
19
- export function which(cmd) {
20
- if (isBun && Bun.which)
21
- return Bun.which(cmd);
22
- const exts = process.platform === "win32"
23
- ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
24
- : [""];
25
- for (const dir of (process.env.PATH ?? "").split(delimiter)) {
26
- for (const ext of exts) {
27
- const candidate = join(dir, cmd + ext);
28
- try {
29
- if (statSync(candidate).isFile())
30
- return candidate;
31
- }
32
- catch {
33
- /* keep looking */
34
- }
35
- }
36
- }
37
- return null;
38
- }
39
17
  // --- stdin -----------------------------------------------------------------
40
18
  /** Read all of stdin as UTF-8 text. Callers check `process.stdin.isTTY` first. */
41
19
  export async function readStdinText() {
@@ -70,18 +48,51 @@ export function isMainModule(meta) {
70
48
  export function spawnProcess(args, opts) {
71
49
  return isBun ? spawnBun(args, opts) : spawnNode(args, opts);
72
50
  }
51
+ /** An already-ended stream, for pipes that were not requested. */
52
+ function emptyStream() {
53
+ return new ReadableStream();
54
+ }
55
+ /**
56
+ * Exit code for a child that ended, matching Bun's convention: a signal death
57
+ * is 128 + signal number (SIGKILL → 137). Node's `close` reports `code: null`
58
+ * with the signal name, so without this the same kill reads as 0 on Node and
59
+ * 137 on Bun — abort paths key on the code.
60
+ */
61
+ export function exitCodeFromClose(code, signal) {
62
+ if (code !== null)
63
+ return code;
64
+ if (!signal)
65
+ return 0;
66
+ return 128 + (osConstants.signals[signal] ?? 0);
67
+ }
73
68
  function spawnBun(args, opts) {
74
- const proc = Bun.spawn(args, {
75
- cwd: opts?.cwd,
76
- stdin: "ignore",
77
- stdout: opts?.stdout === "pipe" ? "pipe" : "ignore",
78
- stderr: opts?.stderr === "pipe" ? "pipe" : "ignore",
79
- detached: opts?.detached,
80
- });
69
+ let proc;
70
+ try {
71
+ proc = Bun.spawn(args, {
72
+ cwd: opts?.cwd,
73
+ stdin: "ignore",
74
+ stdout: opts?.stdout === "pipe" ? "pipe" : "ignore",
75
+ stderr: opts?.stderr === "pipe" ? "pipe" : "ignore",
76
+ detached: opts?.detached,
77
+ });
78
+ }
79
+ catch (err) {
80
+ // Bun throws synchronously for a missing binary; Node reports it through
81
+ // `exited` = -1. Normalize so callers see one contract on both runtimes.
82
+ if (err?.code !== "ENOENT")
83
+ throw err;
84
+ return {
85
+ pid: 0,
86
+ stdout: emptyStream(),
87
+ stderr: emptyStream(),
88
+ exited: Promise.resolve(-1),
89
+ kill: () => { },
90
+ };
91
+ }
81
92
  return {
82
93
  pid: proc.pid,
83
- stdout: proc.stdout,
84
- stderr: proc.stderr,
94
+ stdout: opts?.stdout === "pipe" ? proc.stdout : emptyStream(),
95
+ stderr: opts?.stderr === "pipe" ? proc.stderr : emptyStream(),
85
96
  exited: proc.exited,
86
97
  kill: (signal) => { try {
87
98
  proc.kill(signal);
@@ -101,17 +112,17 @@ function spawnNode(args, opts) {
101
112
  });
102
113
  // First of close/error wins: a spawn failure (ENOENT) may emit only `error`.
103
114
  const exited = new Promise((resolve) => {
104
- child.once("close", (code) => resolve(code ?? 0));
115
+ child.once("close", (code, signal) => resolve(exitCodeFromClose(code, signal)));
105
116
  child.once("error", () => resolve(-1));
106
117
  });
107
118
  return {
108
119
  pid: child.pid ?? 0,
109
120
  stdout: child.stdout
110
121
  ? Readable.toWeb(child.stdout)
111
- : new ReadableStream(),
122
+ : emptyStream(),
112
123
  stderr: child.stderr
113
124
  ? Readable.toWeb(child.stderr)
114
- : new ReadableStream(),
125
+ : emptyStream(),
115
126
  exited,
116
127
  kill: (signal) => { try {
117
128
  child.kill(signal);
package/dist/sanitize.js CHANGED
@@ -10,6 +10,10 @@
10
10
  export function sanitizeForTerminal(text) {
11
11
  return text
12
12
  .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "") // OSC … (BEL / ST)
13
+ // DCS/APC/PM/SOS … (BEL / ST) — the same string-terminated convention as
14
+ // OSC. Without this only the 2-byte opener is removed and the payload
15
+ // (Kitty graphics, Sixel, …) leaks through as literal text.
16
+ .replace(/\u001b[P_^X][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "")
13
17
  // CSI: parameter + intermediate bytes then a final byte. SPACE (0x20) is a
14
18
  // legal intermediate (`CSI Ps SP q` sets the cursor shape), so it belongs
15
19
  // in the class — without it that sequence's tail leaked through as noise.
package/dist/tools/fs.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Filesystem tools: local_read, local_write, local_edit, local_ls.
3
3
  // ---------------------------------------------------------------------------
4
- import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
4
+ import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
5
  import { dirname, join, relative } from "node:path";
6
6
  import { defineTool, optionalBoolean, optionalNumber, optionalString, requireString } from "./types.js";
7
7
  import { displayPath, resolveToolPath } from "./paths.js";
@@ -80,21 +80,21 @@ const writeTool = defineTool({
80
80
  const content = args.content;
81
81
  const append = optionalBoolean(args, "append") ?? false;
82
82
  const absolute = await resolveToolPath(path, ctx);
83
- const existed = await stat(absolute).then((s) => s.isFile()).catch(() => false);
83
+ const info = await stat(absolute).catch(() => null);
84
+ // Same guard as local_read/local_edit: without it, append reports "does not
85
+ // exist" for a directory and overwrite leaks a raw EISDIR.
86
+ if (info?.isDirectory())
87
+ throw new Error(`"${path}" is a directory — use local_ls.`);
88
+ const existed = info?.isFile() ?? false;
84
89
  if (append && !existed)
85
90
  throw new Error(`Cannot append: ${path} does not exist.`);
86
- if (append) {
87
- // Same cap as local_read/local_edit: appending to a huge existing file
88
- // would otherwise read it all into memory before the write.
89
- const size = await stat(absolute).then((s) => s.size).catch(() => 0);
90
- if (size > MAX_READ_BYTES) {
91
- throw new Error(`Cannot append: ${path} is too large (${size} bytes, limit ${MAX_READ_BYTES}).`);
92
- }
93
- }
94
91
  await mkdir(dirname(absolute), { recursive: true });
95
92
  if (append) {
96
- const previous = await readFile(absolute, "utf-8").catch(() => "");
97
- await writeFile(absolute, previous + content, "utf-8");
93
+ // O_APPEND, not read-modify-write: a failed read used to be swallowed
94
+ // into "" and the rewrite then truncated the file to just `content`
95
+ // while reporting success. It also avoids re-encoding a non-UTF-8 file
96
+ // and the memory cost of reading it (the old size cap guarded that read).
97
+ await appendFile(absolute, content, "utf-8");
98
98
  }
99
99
  else {
100
100
  await writeFile(absolute, content, "utf-8");
@@ -45,6 +45,14 @@ function splitAlternatives(body) {
45
45
  parts.push(body.slice(start));
46
46
  return parts;
47
47
  }
48
+ /**
49
+ * Recursion depth cap. Each frame advances one pattern or path character (or
50
+ * substitutes a shorter pattern for a brace group), so depth grows with the
51
+ * input — a model-supplied pattern or a very long path could otherwise
52
+ * overflow the stack with a RangeError. The cap is far above any real glob
53
+ * and fails with a clear message instead of a crash.
54
+ */
55
+ const MAX_MATCH_DEPTH = 2000;
48
56
  /**
49
57
  * Match one pattern against one path with a memoized walk. Brace groups are
50
58
  * tried in place (each alternative is matched as `alternative + suffix`), so
@@ -53,66 +61,75 @@ function splitAlternatives(body) {
53
61
  function matchPattern(pattern, path) {
54
62
  const n = path.length;
55
63
  const memos = new Map();
64
+ let depth = 0;
56
65
  function match(pat, pi, si) {
57
- let memo = memos.get(pat);
58
- if (!memo) {
59
- memo = new Map();
60
- memos.set(pat, memo);
66
+ if (++depth > MAX_MATCH_DEPTH) {
67
+ throw new Error(`Glob pattern is too complex (exceeds ${MAX_MATCH_DEPTH} steps).`);
61
68
  }
62
- const key = pi * (n + 1) + si;
63
- const hit = memo.get(key);
64
- if (hit !== undefined)
65
- return hit;
66
- let result;
67
- if (pi >= pat.length) {
68
- result = si >= n;
69
- }
70
- else {
71
- const ch = pat[pi];
72
- if (ch === "*") {
73
- if (pat[pi + 1] === "*") {
74
- if (pat[pi + 2] === "/") {
75
- // `**/`: zero or more directories — stop now, or consume one
76
- // segment (up to and including its `/`) and continue. Consuming a
77
- // whole segment at a time is what keeps the token from stopping
78
- // mid-name (`**` + `/a` must not match "ba").
79
- result = match(pat, pi + 3, si);
80
- if (!result) {
81
- let k = si;
82
- while (k < n && path[k] !== "/")
83
- k++;
84
- result = k < n && match(pat, pi, k + 1);
69
+ try {
70
+ let memo = memos.get(pat);
71
+ if (!memo) {
72
+ memo = new Map();
73
+ memos.set(pat, memo);
74
+ }
75
+ const key = pi * (n + 1) + si;
76
+ const hit = memo.get(key);
77
+ if (hit !== undefined)
78
+ return hit;
79
+ let result;
80
+ if (pi >= pat.length) {
81
+ result = si >= n;
82
+ }
83
+ else {
84
+ const ch = pat[pi];
85
+ if (ch === "*") {
86
+ if (pat[pi + 1] === "*") {
87
+ if (pat[pi + 2] === "/") {
88
+ // `**/`: zero or more directories — stop now, or consume one
89
+ // segment (up to and including its `/`) and continue. Consuming a
90
+ // whole segment at a time is what keeps the token from stopping
91
+ // mid-name (`**` + `/a` must not match "ba").
92
+ result = match(pat, pi + 3, si);
93
+ if (!result) {
94
+ let k = si;
95
+ while (k < n && path[k] !== "/")
96
+ k++;
97
+ result = k < n && match(pat, pi, k + 1);
98
+ }
99
+ }
100
+ else {
101
+ // `**`: anything, slashes included.
102
+ result = match(pat, pi + 2, si) || (si < n && match(pat, pi, si + 1));
85
103
  }
86
104
  }
87
105
  else {
88
- // `**`: anything, slashes included.
89
- result = match(pat, pi + 2, si) || (si < n && match(pat, pi, si + 1));
106
+ // `*`: anything but a slash.
107
+ result = match(pat, pi + 1, si) || (si < n && path[si] !== "/" && match(pat, pi, si + 1));
90
108
  }
91
109
  }
92
- else {
93
- // `*`: anything but a slash.
94
- result = match(pat, pi + 1, si) || (si < n && path[si] !== "/" && match(pat, pi, si + 1));
110
+ else if (ch === "?") {
111
+ result = si < n && path[si] !== "/" && match(pat, pi + 1, si + 1);
95
112
  }
96
- }
97
- else if (ch === "?") {
98
- result = si < n && path[si] !== "/" && match(pat, pi + 1, si + 1);
99
- }
100
- else if (ch === "{") {
101
- const close = matchingBrace(pat, pi);
102
- if (close < 0) {
103
- result = si < n && path[si] === "{" && match(pat, pi + 1, si + 1);
113
+ else if (ch === "{") {
114
+ const close = matchingBrace(pat, pi);
115
+ if (close < 0) {
116
+ result = si < n && path[si] === "{" && match(pat, pi + 1, si + 1);
117
+ }
118
+ else {
119
+ const suffix = pat.slice(close + 1);
120
+ result = splitAlternatives(pat.slice(pi + 1, close)).some((alt) => match(alt + suffix, 0, si));
121
+ }
104
122
  }
105
123
  else {
106
- const suffix = pat.slice(close + 1);
107
- result = splitAlternatives(pat.slice(pi + 1, close)).some((alt) => match(alt + suffix, 0, si));
124
+ result = si < n && path[si] === ch && match(pat, pi + 1, si + 1);
108
125
  }
109
126
  }
110
- else {
111
- result = si < n && path[si] === ch && match(pat, pi + 1, si + 1);
112
- }
127
+ memo.set(key, result);
128
+ return result;
129
+ }
130
+ finally {
131
+ depth--;
113
132
  }
114
- memo.set(key, result);
115
- return result;
116
133
  }
117
134
  return match(pattern, 0, 0);
118
135
  }
@@ -1,3 +1,13 @@
1
1
  import type { ToolHandler } from "./types.js";
2
2
  export declare const SKIP_DIRS: Set<string>;
3
+ interface Match {
4
+ path: string;
5
+ line: number;
6
+ content: string;
7
+ }
8
+ export declare function searchJs(pattern: RegExp, root: string, signal: AbortSignal | undefined, budgetMs?: number): Promise<{
9
+ matches: Match[];
10
+ timedOut: boolean;
11
+ }>;
3
12
  export declare const grepToolInstance: ToolHandler;
13
+ export {};
@@ -6,17 +6,19 @@
6
6
  // ---------------------------------------------------------------------------
7
7
  import { readdir, readFile, stat } from "node:fs/promises";
8
8
  import { join, relative } from "node:path";
9
+ import { createContext, runInContext } from "node:vm";
9
10
  import { defineTool, optionalString, requireString } from "./types.js";
10
11
  import { resolveToolPath } from "./paths.js";
11
- import { spawnProcess, which } from "../runtime.js";
12
+ import { isBinaryBuffer } from "./binary.js";
13
+ import { spawnProcess } from "../runtime.js";
12
14
  const MAX_MATCHES = 100;
13
15
  /**
14
16
  * Wall-clock budget for the JS fallback walk. The pattern is LLM-authored and
15
17
  * compiled with `new RegExp` — a catastrophic-backtracking pattern would
16
- * otherwise pin the single-threaded process inside `pattern.test()` with
17
- * no abort able to interrupt it. The budget bounds the aggregate; a single
18
- * pathological match on one huge line can still overshoot, but the window
19
- * shrinks from unbounded to one line.
18
+ * otherwise pin the single-threaded process inside `pattern.test()` with no
19
+ * abort able to interrupt it. The per-line loop runs inside `node:vm` with a
20
+ * timeout (see `testLines`), so even a single pathological match is
21
+ * interruptible; the budget bounds the aggregate.
20
22
  */
21
23
  const JS_SEARCH_BUDGET_MS = 10_000;
22
24
  export const SKIP_DIRS = new Set([
@@ -31,7 +33,9 @@ function formatMatches(matches, workspace) {
31
33
  return lines.join("\n") + (truncated ? `\n… (results capped at ${MAX_MATCHES} matches)` : "");
32
34
  }
33
35
  /**
34
- * Search with ripgrep when available. Returns null when rg is unavailable.
36
+ * Search with ripgrep when available. Returns null when rg is unavailable
37
+ * (missing binary, rejected pattern, spawn failure) so the caller falls back
38
+ * to the JS walk.
35
39
  *
36
40
  * The cap is enforced HERE, on the parse: rg's `--max-count` is per FILE, so
37
41
  * on a large tree it can still produce a huge JSON stream. The output is read
@@ -39,9 +43,6 @@ function formatMatches(matches, workspace) {
39
43
  * buffer nor the child outlives the cap.
40
44
  */
41
45
  async function searchWithRipgrep(pattern, searchPath, include, signal) {
42
- const rg = which("rg");
43
- if (!rg)
44
- return null;
45
46
  // --max-count bounds the per-file work rg does; the global cap is ours.
46
47
  const args = ["--json", "--max-count", String(MAX_MATCHES), "--no-ignore", "-e", pattern];
47
48
  if (include)
@@ -50,7 +51,7 @@ async function searchWithRipgrep(pattern, searchPath, include, signal) {
50
51
  args.push("--glob", `!**/${dir}/**`);
51
52
  args.push(searchPath);
52
53
  try {
53
- const proc = spawnProcess([rg, ...args], { stdout: "pipe", stderr: "ignore" });
54
+ const proc = spawnProcess(["rg", ...args], { stdout: "pipe", stderr: "ignore" });
54
55
  const onAbort = () => { try {
55
56
  proc.kill();
56
57
  }
@@ -121,9 +122,43 @@ async function searchWithRipgrep(pattern, searchPath, include, signal) {
121
122
  return null; // fall back to the JS walk
122
123
  }
123
124
  }
124
- async function searchJs(pattern, root, signal) {
125
+ /** One reusable context — creating one per file would dominate the walk.
126
+ * Safe under the parallel read-only batches executeToolCalls runs: testLines
127
+ * is synchronous end-to-end, so concurrent searches can never interleave
128
+ * inside it (an async refactor would need per-call state). */
129
+ const regexSandbox = { pattern: null, lines: [], out: [], signal: null, deadline: 0, max: 0 };
130
+ const regexContext = createContext(regexSandbox);
131
+ /**
132
+ * Test one file's lines inside the sandbox. The vm timeout is the remaining
133
+ * search budget, so a single pathological `test()` call can never outlive the
134
+ * budget. A timeout loses the file's partial matches — the search is reported
135
+ * as budget-exceeded either way.
136
+ */
137
+ function testLines(pattern, lines, signal, deadline, max) {
138
+ const timeout = deadline - Date.now();
139
+ if (timeout <= 0)
140
+ return { matches: [], timedOut: true };
141
+ regexSandbox.pattern = pattern;
142
+ regexSandbox.lines = lines;
143
+ regexSandbox.out = [];
144
+ regexSandbox.signal = signal ?? null;
145
+ regexSandbox.deadline = deadline;
146
+ regexSandbox.max = max;
147
+ try {
148
+ runInContext("for (let i = 0; i < lines.length; i++) {" +
149
+ " if (out.length >= max || (signal && signal.aborted) || Date.now() > deadline) break;" +
150
+ " if (pattern.test(lines[i])) out.push(i);" +
151
+ " pattern.lastIndex = 0;" +
152
+ "}", regexContext, { timeout });
153
+ return { matches: regexSandbox.out, timedOut: false };
154
+ }
155
+ catch {
156
+ return { matches: [], timedOut: true };
157
+ }
158
+ }
159
+ export async function searchJs(pattern, root, signal, budgetMs = JS_SEARCH_BUDGET_MS) {
125
160
  const matches = [];
126
- const deadline = Date.now() + JS_SEARCH_BUDGET_MS;
161
+ const deadline = Date.now() + budgetMs;
127
162
  let timedOut = false;
128
163
  const overBudget = () => {
129
164
  if (Date.now() > deadline)
@@ -155,27 +190,31 @@ async function searchJs(pattern, root, signal) {
155
190
  const info = await stat(full).catch(() => null);
156
191
  if (!info || info.size > 2 * 1024 * 1024)
157
192
  continue;
158
- let text;
193
+ let buffer;
159
194
  try {
160
- text = await readFile(full, "utf-8");
195
+ buffer = await readFile(full);
161
196
  }
162
197
  catch {
163
198
  continue;
164
199
  }
165
- if (text.includes("\0"))
200
+ // Shared sniffer (NUL byte or invalid UTF-8) — a NUL-only check would
201
+ // search binary content that happens to decode into U+FFFD.
202
+ if (isBinaryBuffer(buffer))
166
203
  continue;
167
- const lines = text.split("\n");
168
- for (let i = 0; i < lines.length && matches.length < MAX_MATCHES; i++) {
169
- // Abort and the budget must hold inside the per-line loop too — a
170
- // large file must not run to its last line after the user pressed
171
- // stop or the budget expired.
172
- if ((i & 31) === 0 && (signal?.aborted || overBudget()))
173
- return;
174
- if (pattern.test(lines[i])) {
175
- matches.push({ path: full, line: i + 1, content: lines[i] });
176
- }
177
- pattern.lastIndex = 0;
204
+ const lines = buffer.toString("utf-8").split("\n");
205
+ const { matches: lineMatches, timedOut: fileTimedOut } = testLines(pattern, lines, signal, deadline, MAX_MATCHES - matches.length);
206
+ for (const i of lineMatches) {
207
+ matches.push({ path: full, line: i + 1, content: lines[i] });
178
208
  }
209
+ // A single test() call outlived the remaining budget — the pattern is
210
+ // pathological; stop the walk and report the budget as exceeded.
211
+ if (fileTimedOut) {
212
+ timedOut = true;
213
+ return;
214
+ }
215
+ // The loop may also have stopped on the deadline or an abort.
216
+ if (overBudget() || signal?.aborted)
217
+ return;
179
218
  }
180
219
  }
181
220
  await walk(root, 0);
package/dist/ui/app.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { IntegrationClient } from "../protocol/client.js";
2
- import type { AgentUsage } from "../protocol/types.js";
2
+ import type { AgentUsage, SessionSummary } from "../protocol/types.js";
3
3
  import type { ToolRegistry } from "../tools/registry.js";
4
4
  import type { StoredConfig } from "../config.js";
5
5
  import { type AgentMode } from "../agent/modes.js";
@@ -32,6 +32,12 @@ export interface TuiOptions {
32
32
  */
33
33
  export declare function sidebarWidth(columns: number): number;
34
34
  export declare function runTui(opts: TuiOptions): Promise<number>;
35
+ /**
36
+ * One `/session` picker row label. The title is remote free text (the model
37
+ * sets it via update_topic) — sanitize it like the sidebar does, or an escape
38
+ * sequence in it would reach the real terminal.
39
+ */
40
+ export declare function sessionItemLabel(s: SessionSummary, current: string | null, folderTag: string): string;
35
41
  /**
36
42
  * Footer usage: `current` minus the pre-run `baseline`, so the status bar
37
43
  * reports the current/last run instead of the session total.
package/dist/ui/app.js CHANGED
@@ -15,6 +15,7 @@ import { deriveTitle } from "../agent/sessions.js";
15
15
  import { TodoStore } from "../agent/todo.js";
16
16
  import { ApprovalPolicy } from "../approval/policy.js";
17
17
  import { collectFileDiff, collectGitStatus, EMPTY_GIT_STATUS } from "../git/status.js";
18
+ import { sanitizeForTerminal } from "../sanitize.js";
18
19
  import { bgRgb, isColorEnabled, modeBorder, overlayBackgroundRgb, panelBackgroundRgb, style, userMessageBackgroundRgb } from "./colors.js";
19
20
  import { helpLines } from "./help.js";
20
21
  import { buildModelItems, currentModelId } from "./model-list.js";
@@ -723,7 +724,7 @@ export async function runTui(opts) {
723
724
  : "";
724
725
  return {
725
726
  value: s.thread_id,
726
- label: `${s.thread_id === current ? "● " : " "}${folderTag}${s.title || s.thread_id}`,
727
+ label: sessionItemLabel(s, current, folderTag),
727
728
  description: `${s.status} · ${s.message_count} msgs · ${new Date(s.updated_at).toLocaleString()}${s.thread_id === current ? " · current" : ""}`,
728
729
  };
729
730
  });
@@ -1011,6 +1012,14 @@ export async function runTui(opts) {
1011
1012
  // ---------------------------------------------------------------------------
1012
1013
  // Small helpers
1013
1014
  // ---------------------------------------------------------------------------
1015
+ /**
1016
+ * One `/session` picker row label. The title is remote free text (the model
1017
+ * sets it via update_topic) — sanitize it like the sidebar does, or an escape
1018
+ * sequence in it would reach the real terminal.
1019
+ */
1020
+ export function sessionItemLabel(s, current, folderTag) {
1021
+ return `${s.thread_id === current ? "● " : " "}${folderTag}${sanitizeForTerminal(s.title || s.thread_id)}`;
1022
+ }
1014
1023
  /**
1015
1024
  * Footer usage: `current` minus the pre-run `baseline`, so the status bar
1016
1025
  * reports the current/last run instead of the session total.
@@ -42,6 +42,9 @@ export declare class AssistantMessageComponent implements Component {
42
42
  /** Raw stream (unsanitized) — the sanitizing pass needs the whole text. */
43
43
  private raw;
44
44
  private text;
45
+ /** Set once any delta carried a control char: from then on the accumulated
46
+ * text must be re-sanitized (a sequence can span deltas). */
47
+ private dirty;
45
48
  constructor();
46
49
  append(delta: string): void;
47
50
  get content(): string;
@@ -51,6 +54,7 @@ export declare class AssistantMessageComponent implements Component {
51
54
  export declare class ReasoningBlockComponent implements Component {
52
55
  private raw;
53
56
  private text;
57
+ private dirty;
54
58
  expanded: boolean;
55
59
  append(delta: string): void;
56
60
  toggle(): void;
@@ -84,11 +84,21 @@ export class UserMessageComponent {
84
84
  ];
85
85
  }
86
86
  }
87
+ /**
88
+ * Characters `sanitizeForTerminal` would change (ESC and C0/C1 controls; tab
89
+ * and newline are kept). While a stream has never contained one, the sanitizer
90
+ * is the identity — re-running it over the whole accumulated text on every
91
+ * delta is O(n²) for no effect.
92
+ */
93
+ const CONTROL_CHARS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/;
87
94
  export class AssistantMessageComponent {
88
95
  md;
89
96
  /** Raw stream (unsanitized) — the sanitizing pass needs the whole text. */
90
97
  raw = "";
91
98
  text = "";
99
+ /** Set once any delta carried a control char: from then on the accumulated
100
+ * text must be re-sanitized (a sequence can span deltas). */
101
+ dirty = false;
92
102
  constructor() {
93
103
  this.md = new Markdown("", 0, 0, markdownTheme, { color: assistantColor });
94
104
  }
@@ -97,9 +107,16 @@ export class AssistantMessageComponent {
97
107
  // material (file bodies, fetched pages) verbatim — strip terminal
98
108
  // sequences, like tool output and headless deltas do. Sanitizing the
99
109
  // ACCUMULATED text, not the delta: a sequence split across two deltas
100
- // would slip through a per-delta pass.
110
+ // would slip through a per-delta pass. The clean fast path is exact: with
111
+ // no control char anywhere, the sanitizer returns the input unchanged.
101
112
  this.raw += delta;
102
- this.text = sanitizeForTerminal(this.raw);
113
+ if (!this.dirty && !CONTROL_CHARS.test(delta)) {
114
+ this.text = this.raw;
115
+ }
116
+ else {
117
+ this.dirty = true;
118
+ this.text = sanitizeForTerminal(this.raw);
119
+ }
103
120
  this.md.setText(this.text);
104
121
  }
105
122
  get content() {
@@ -164,12 +181,19 @@ export class AssistantMessageComponent {
164
181
  export class ReasoningBlockComponent {
165
182
  raw = "";
166
183
  text = "";
184
+ dirty = false;
167
185
  expanded = false;
168
186
  append(delta) {
169
187
  // Same rule as assistant text: reasoning is model output rendered to the
170
188
  // real terminal, and it quotes tool output freely.
171
189
  this.raw += delta;
172
- this.text = sanitizeForTerminal(this.raw);
190
+ if (!this.dirty && !CONTROL_CHARS.test(delta)) {
191
+ this.text = this.raw;
192
+ }
193
+ else {
194
+ this.dirty = true;
195
+ this.text = sanitizeForTerminal(this.raw);
196
+ }
173
197
  }
174
198
  toggle() {
175
199
  this.expanded = !this.expanded;
@@ -313,8 +337,11 @@ export class SidebarComponent {
313
337
  // The session title is the panel's headline — brighter than the section
314
338
  // headers below it. The "New session" placeholder stays at section-header
315
339
  // level so it doesn't read as a real title.
340
+ // The session title is remote free text (the model sets it via
341
+ // update_topic; a resumed thread's title comes from the server) — same
342
+ // sanitizing rule as the todo content below.
316
343
  push(this.state.title
317
- ? ` ${style.bold(SESSION_TITLE_COLOR(this.state.title))}`
344
+ ? ` ${style.bold(SESSION_TITLE_COLOR(sanitizeForTerminal(this.state.title)))}`
318
345
  : ` ${style.bold(TITLE_COLOR("New session"))}`);
319
346
  blank();
320
347
  const usage = this.state.usage;
@@ -331,7 +358,10 @@ export class SidebarComponent {
331
358
  if (todoItems.length > 0) {
332
359
  section("todo", `TODO (${this.opts.todos.doneCount}/${todoItems.length})`);
333
360
  for (const item of todoItems) {
334
- const content = item.content.replace(/\s+/g, " ");
361
+ // Todo content is model-authored (the update_todo args) and reaches
362
+ // the real terminal on every sidebar repaint — same sanitizing rule as
363
+ // every other transcript component.
364
+ const content = sanitizeForTerminal(item.content.replace(/\s+/g, " "));
335
365
  value(`${todoGlyph(item.status)} ${item.status === "completed" ? GRAY_DIM_COLOR(content) : GRAY_COLOR(content)}`);
336
366
  }
337
367
  blank();
@@ -412,8 +442,11 @@ export class PromptEditor extends Editor {
412
442
  }
413
443
  renderBottomBorder(width, hiddenLineCount) {
414
444
  const label = ` ${MODES[this.mode].label} `;
415
- const modelText = this.model ?? "no model";
416
- const thinkingText = this.thinking ?? "default";
445
+ // Both are remote text (session detail, model list, thinking levels) and
446
+ // this border is redrawn every frame — sanitize before measuring so the
447
+ // chip hit-test ranges match what is actually rendered.
448
+ const modelText = sanitizeForTerminal(this.model ?? "no model");
449
+ const thinkingText = sanitizeForTerminal(this.thinking ?? "default");
417
450
  const info = `· ${modelText} · ${thinkingText} `;
418
451
  const suffix = hiddenLineCount > 0 ? `↓ ${hiddenLineCount} more ` : "";
419
452
  const fill = width - visibleWidth(label) - visibleWidth(info) - visibleWidth(suffix) - 1;
@@ -24,5 +24,10 @@ export declare function todoMark(status: TodoStatus): string;
24
24
  * Render a `local_update_todo` request's args as display lines — the task
25
25
  * list lives in the tool request, not in the system prompt. Empty for
26
26
  * malformed args (the tool call itself surfaces the validation error).
27
+ *
28
+ * The content is model-authored and rendered to the real terminal, so it is
29
+ * sanitized here: this custom renderer bypasses `paramValueLines`' stringify
30
+ * pass (and `sanitizeArgs` only covers top-level strings — `todos` is an
31
+ * array).
27
32
  */
28
33
  export declare function formatTodoArgs(args: Record<string, unknown>): string[];
package/dist/ui/format.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // ---------------------------------------------------------------------------
4
4
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
5
  import { parseTodoItems } from "../agent/todo.js";
6
+ import { sanitizeForTerminal } from "../sanitize.js";
6
7
  /** 999 → "999", 12345 → "12.3k", 1234567 → "1.2M". */
7
8
  export function formatTokens(n) {
8
9
  if (!Number.isFinite(n))
@@ -63,6 +64,11 @@ export function todoMark(status) {
63
64
  * Render a `local_update_todo` request's args as display lines — the task
64
65
  * list lives in the tool request, not in the system prompt. Empty for
65
66
  * malformed args (the tool call itself surfaces the validation error).
67
+ *
68
+ * The content is model-authored and rendered to the real terminal, so it is
69
+ * sanitized here: this custom renderer bypasses `paramValueLines`' stringify
70
+ * pass (and `sanitizeArgs` only covers top-level strings — `todos` is an
71
+ * array).
66
72
  */
67
73
  export function formatTodoArgs(args) {
68
74
  let items;
@@ -72,5 +78,5 @@ export function formatTodoArgs(args) {
72
78
  catch {
73
79
  return [];
74
80
  }
75
- return items.map((t) => `${todoMark(t.status)} ${t.content}`);
81
+ return items.map((t) => `${todoMark(t.status)} ${sanitizeForTerminal(t.content)}`);
76
82
  }
@@ -5,6 +5,7 @@
5
5
  // "default". Current wins when both apply, so the row the user is actually
6
6
  // running carries exactly one marker.
7
7
  // ---------------------------------------------------------------------------
8
+ import { sanitizeForTerminal } from "../sanitize.js";
8
9
  /**
9
10
  * The id the picker marks as current: the model in use (a thread's model or a
10
11
  * fresh selection), otherwise the user's pending selection, otherwise the
@@ -26,7 +27,9 @@ export function buildModelItems(models, currentId) {
26
27
  const isDefault = m.default === true || (!hasDefaultFlag && index === 0);
27
28
  return {
28
29
  value: m.id,
29
- label: m.label ? `${m.label} (${m.id})` : m.id,
30
+ // Both fields are remote text (the server's model list) — sanitize them
31
+ // like every other remote string the UI renders.
32
+ label: sanitizeForTerminal(m.label ? `${m.label} (${m.id})` : m.id),
30
33
  description: isCurrent ? "current" : isDefault ? "default" : "",
31
34
  };
32
35
  });
@@ -88,11 +88,13 @@ function toolSummaryRaw(name, args) {
88
88
  switch (displayToolName(name)) {
89
89
  case "read": {
90
90
  const path = stringArg(args.path) ?? "";
91
+ // Mirror the tool's clamp (fs.ts) so a limit/offset of 0 or a negative
92
+ // number can't render a nonsense range like "lines 1–0".
91
93
  const offset = numberArg(args.offset);
92
94
  const limit = numberArg(args.limit);
93
- const start = offset ?? 1;
95
+ const start = Math.max(1, Math.floor(offset ?? 1));
94
96
  const range = limit !== undefined
95
- ? `lines ${start}–${start + limit - 1}`
97
+ ? `lines ${start}–${start + Math.max(1, Math.floor(limit)) - 1}`
96
98
  : offset !== undefined ? `from line ${start}` : "";
97
99
  return [path, range].filter(Boolean).join(" · ");
98
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ocis/myagent-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Terminal coding agent for your local repository, powered by a remote MyAgent server",
5
5
  "type": "module",
6
6
  "bin": {