@ocis/myagent-cli 0.2.2 → 0.2.4

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
 
@@ -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
@@ -855,6 +861,9 @@ export class AgentRunner {
855
861
  // sweep announces a release for a call that was answered.
856
862
  if (call)
857
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);
858
867
  cb.onNotice(`Re-attached the result of ${call?.name ?? "a tool call"} to the agent's retry.`, "info");
859
868
  continue;
860
869
  }
@@ -862,18 +871,27 @@ export class AgentRunner {
862
871
  }
863
872
  // Pending calls this client never executed (e.g. the model retried with
864
873
  // different arguments): execute them through the normal approval path so
865
- // the run isn't left waiting on a call nobody will answer.
866
- if (unmatched.size > 0) {
867
- const extraCalls = [...unmatched.values()].map((p) => ({
868
- id: p.id,
869
- name: p.name,
870
- arguments: p.arguments,
871
- }));
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) {
872
886
  // Register before execution (same as event-driven calls via
873
887
  // executeAndSubmit) so a release arriving mid-execution is surfaced by
874
- // the release filter instead of only surfacing at submission time.
875
- 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) {
876
892
  this.pendingSubmission.add(call.id);
893
+ this.seenToolCallIds.add(call.id);
894
+ }
877
895
  live.push(...await this.executeToolCalls(extraCalls, signal));
878
896
  }
879
897
  return { live, released };
@@ -923,7 +941,7 @@ export class AgentRunner {
923
941
  // -------------------------------------------------------------------------
924
942
  // Fallback event channel (reconcile window only — no run stream attached)
925
943
  // -------------------------------------------------------------------------
926
- /** Open the session event subscription for the recovery window (capability-gated). */
944
+ /** Open the session event subscription for the recovery window. */
927
945
  startEventSubscription() {
928
946
  if (!this.threadId)
929
947
  return;
@@ -1198,8 +1216,11 @@ export class AgentRunner {
1198
1216
  calls.push(call);
1199
1217
  // Register before execution (same as executeAndSubmit/reconcileRound):
1200
1218
  // a release arriving while an approval is pending must cancel it via
1201
- // 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.
1202
1222
  this.pendingSubmission.add(item.id);
1223
+ this.seenToolCallIds.add(item.id);
1203
1224
  const handler = this.opts.registry.get(item.name);
1204
1225
  const parsed = this.parseCall(call);
1205
1226
  if (!isMutatingCall(handler, parsed.args)) {
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
@@ -243,9 +243,12 @@ export function assemblePrompt(args, stdinText) {
243
243
  * Report a pre-run failure (usage/config/connection) in the requested output
244
244
  * format and return the exit code. `json` always emits a parseable object — a
245
245
  * CI parser must never see an empty stdout; `stream-json` emits a terminal
246
- * `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.
247
250
  */
248
- export function fail(format, message, errorCode) {
251
+ export function fail(format, message, errorCode, exitCode = 2) {
249
252
  if (format === "json") {
250
253
  process.stdout.write(failureResult(message, errorCode) + "\n");
251
254
  }
@@ -255,7 +258,7 @@ export function fail(format, message, errorCode) {
255
258
  else {
256
259
  process.stderr.write(message + "\n");
257
260
  }
258
- return 2;
261
+ return exitCode;
259
262
  }
260
263
  /**
261
264
  * Read piped stdin as prompt text. Bounded by the run's `--timeout` when one
@@ -376,11 +379,15 @@ async function main() {
376
379
  if (readsStdin && !process.stdin.isTTY && args.promptParts.length > 0 && !args.flags.timeout) {
377
380
  process.stderr.write("Reading additional prompt text from stdin until EOF — pass `< /dev/null` or --timeout <sec> if nothing is piped.\n");
378
381
  }
379
- const stdinText = readsStdin
380
- ? await readStdin(args.flags.timeout ? args.flags.timeout * 1000 : undefined)
381
- : "";
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) : "";
382
387
  if (stdinText === null) {
383
- 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);
384
391
  }
385
392
  const prompt = assemblePrompt(args, stdinText);
386
393
  const registry = new ToolRegistry();
@@ -406,6 +413,14 @@ async function main() {
406
413
  if (!prompt) {
407
414
  return fail(args.flags.outputFormat, "No prompt provided. Usage: myagent -p \"...\" or pipe text via stdin.", "no_prompt");
408
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
+ }
409
424
  return await runHeadless({
410
425
  client,
411
426
  registry,
@@ -423,7 +438,7 @@ async function main() {
423
438
  outputFormat: args.flags.outputFormat ?? "text",
424
439
  quiet: args.flags.quiet,
425
440
  lastMessageOnly: promptMode,
426
- timeoutMs: args.flags.timeout ? args.flags.timeout * 1000 : undefined,
441
+ timeoutMs: remainingTimeoutMs,
427
442
  });
428
443
  }
429
444
  function printSessions(sessions) {
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";
12
+ import { isBinaryBuffer } from "./binary.js";
11
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([
@@ -120,9 +122,43 @@ async function searchWithRipgrep(pattern, searchPath, include, signal) {
120
122
  return null; // fall back to the JS walk
121
123
  }
122
124
  }
123
- 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) {
124
160
  const matches = [];
125
- const deadline = Date.now() + JS_SEARCH_BUDGET_MS;
161
+ const deadline = Date.now() + budgetMs;
126
162
  let timedOut = false;
127
163
  const overBudget = () => {
128
164
  if (Date.now() > deadline)
@@ -154,27 +190,31 @@ async function searchJs(pattern, root, signal) {
154
190
  const info = await stat(full).catch(() => null);
155
191
  if (!info || info.size > 2 * 1024 * 1024)
156
192
  continue;
157
- let text;
193
+ let buffer;
158
194
  try {
159
- text = await readFile(full, "utf-8");
195
+ buffer = await readFile(full);
160
196
  }
161
197
  catch {
162
198
  continue;
163
199
  }
164
- 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))
165
203
  continue;
166
- const lines = text.split("\n");
167
- for (let i = 0; i < lines.length && matches.length < MAX_MATCHES; i++) {
168
- // Abort and the budget must hold inside the per-line loop too — a
169
- // large file must not run to its last line after the user pressed
170
- // stop or the budget expired.
171
- if ((i & 31) === 0 && (signal?.aborted || overBudget()))
172
- return;
173
- if (pattern.test(lines[i])) {
174
- matches.push({ path: full, line: i + 1, content: lines[i] });
175
- }
176
- 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] });
177
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;
178
218
  }
179
219
  }
180
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,9 +15,10 @@ 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
- import { buildModelItems, currentModelId } from "./model-list.js";
21
+ import { buildModelItems, currentModelId, modelDisplayName } from "./model-list.js";
21
22
  import { createQuitConfirm } from "./quit-confirm.js";
22
23
  import { selectCardWidth, selectDescriptionMinWidth, selectLabelColumn, selectVisibleRows } from "./select-popup.js";
23
24
  import { editorTheme, selectListTheme } from "./theme.js";
@@ -348,24 +349,30 @@ export async function runTui(opts) {
348
349
  if (opts.initialModel)
349
350
  editor.model = opts.initialModel;
350
351
  /**
351
- * Cached GET /v1/models list — the picker refreshes on open; this cache is
352
- * for the startup default display (and stays empty when the server is
353
- * unreachable or has no models configured).
352
+ * Cached GET /v1/models list — the picker refreshes on open; this cache backs
353
+ * the model display (label, not id) and the startup default. Stays empty when
354
+ * the server is unreachable or has no models configured.
354
355
  */
356
+ let modelList = [];
355
357
  let defaultModelId = null;
358
+ /** Display name for a model id — the server's label when known, else the id. */
359
+ const modelName = (id) => modelDisplayName(modelList, id);
356
360
  void opts.client.listModels().then((models) => {
357
361
  if (models.length === 0)
358
362
  return;
363
+ modelList = models;
359
364
  defaultModelId = models.find((m) => m.default)?.id ?? models[0].id;
360
365
  if (opts.initialModel && !models.some((m) => m.id === opts.initialModel)) {
361
366
  addTranscript(new NoticeComponent(`Model "${opts.initialModel}" is not available on this integration — the server default applies instead.`, "warn"));
362
367
  }
363
- // Only fill the display before any thread/model knowledge exists.
368
+ // Only fill the model before any thread/model knowledge exists.
364
369
  if (!runner.threadId && !runner.selectedModel) {
365
370
  runner.model = defaultModelId;
366
- editor.model = defaultModelId;
367
- tui.requestRender();
368
371
  }
372
+ // The display may have been seeded from an id before the list arrived —
373
+ // refresh it now that labels are known.
374
+ editor.model = modelName(runner.model);
375
+ tui.requestRender();
369
376
  }).catch(() => { });
370
377
  function log(line) {
371
378
  logLines.push(line);
@@ -376,7 +383,7 @@ export async function runTui(opts) {
376
383
  function applySessionDetail(detail, options = {}) {
377
384
  if (detail.model)
378
385
  runner.model = detail.model;
379
- editor.model = detail.model ?? runner.model;
386
+ editor.model = modelName(detail.model ?? runner.model);
380
387
  if (detail.title)
381
388
  sidebar.state.title = detail.title;
382
389
  const usage = usageFromSession(detail.usage);
@@ -674,7 +681,7 @@ export async function runTui(opts) {
674
681
  runner.setThinkingLevel(opts.initialThinking);
675
682
  // Back to the pending client-choice selection (or the server default).
676
683
  runner.model = runner.selectedModel ?? defaultModelId;
677
- editor.model = runner.model;
684
+ editor.model = modelName(runner.model);
678
685
  firstUserMessage = null;
679
686
  clearView();
680
687
  addTranscript(new NoticeComponent("New session.", "info"));
@@ -723,7 +730,7 @@ export async function runTui(opts) {
723
730
  : "";
724
731
  return {
725
732
  value: s.thread_id,
726
- label: `${s.thread_id === current ? "● " : " "}${folderTag}${s.title || s.thread_id}`,
733
+ label: sessionItemLabel(s, current, folderTag),
727
734
  description: `${s.status} · ${s.message_count} msgs · ${new Date(s.updated_at).toLocaleString()}${s.thread_id === current ? " · current" : ""}`,
728
735
  };
729
736
  });
@@ -811,16 +818,18 @@ export async function runTui(opts) {
811
818
  tui.requestRender();
812
819
  return;
813
820
  }
821
+ // Refresh the display cache — the policy may have changed since startup.
822
+ modelList = models;
814
823
  const current = currentModelId(models, runner.model, runner.selectedModel);
815
824
  showSelectOverlay("Model (applies to a new session)", buildModelItems(models, current), (value) => {
816
825
  runner.setModel(value);
817
826
  if (runner.threadId) {
818
- addTranscript(new NoticeComponent(`Model for new sessions: ${value}. This thread keeps ${runner.model ?? "its pinned model"} — run /new to switch.`, "info"));
827
+ addTranscript(new NoticeComponent(`Model for new sessions: ${modelName(value)}. This thread keeps ${modelName(runner.model) ?? "its pinned model"} — run /new to switch.`, "info"));
819
828
  }
820
829
  else {
821
830
  runner.model = value;
822
- editor.model = value;
823
- addTranscript(new NoticeComponent(`Model: ${value}`, "info"));
831
+ editor.model = modelName(value);
832
+ addTranscript(new NoticeComponent(`Model: ${modelName(value)}`, "info"));
824
833
  }
825
834
  tui.requestRender();
826
835
  }, {
@@ -1011,6 +1020,14 @@ export async function runTui(opts) {
1011
1020
  // ---------------------------------------------------------------------------
1012
1021
  // Small helpers
1013
1022
  // ---------------------------------------------------------------------------
1023
+ /**
1024
+ * One `/session` picker row label. The title is remote free text (the model
1025
+ * sets it via update_topic) — sanitize it like the sidebar does, or an escape
1026
+ * sequence in it would reach the real terminal.
1027
+ */
1028
+ export function sessionItemLabel(s, current, folderTag) {
1029
+ return `${s.thread_id === current ? "● " : " "}${folderTag}${sanitizeForTerminal(s.title || s.thread_id)}`;
1030
+ }
1014
1031
  /**
1015
1032
  * Footer usage: `current` minus the pre-run `baseline`, so the status bar
1016
1033
  * reports the current/last run instead of the session total.
@@ -135,6 +135,7 @@ export declare class SidebarComponent implements Component {
135
135
  export type EditorChip = "mode" | "model" | "thinking";
136
136
  export declare class PromptEditor extends Editor {
137
137
  mode: AgentMode;
138
+ /** Display name of the model in use (the server's label when known, else its id). */
138
139
  model: string | null;
139
140
  thinking: string | undefined;
140
141
  /** Fired when a bottom-edge chip (mode / model / thinking) is clicked. */
@@ -337,8 +337,11 @@ export class SidebarComponent {
337
337
  // The session title is the panel's headline — brighter than the section
338
338
  // headers below it. The "New session" placeholder stays at section-header
339
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.
340
343
  push(this.state.title
341
- ? ` ${style.bold(SESSION_TITLE_COLOR(this.state.title))}`
344
+ ? ` ${style.bold(SESSION_TITLE_COLOR(sanitizeForTerminal(this.state.title)))}`
342
345
  : ` ${style.bold(TITLE_COLOR("New session"))}`);
343
346
  blank();
344
347
  const usage = this.state.usage;
@@ -355,7 +358,10 @@ export class SidebarComponent {
355
358
  if (todoItems.length > 0) {
356
359
  section("todo", `TODO (${this.opts.todos.doneCount}/${todoItems.length})`);
357
360
  for (const item of todoItems) {
358
- 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, " "));
359
365
  value(`${todoGlyph(item.status)} ${item.status === "completed" ? GRAY_DIM_COLOR(content) : GRAY_COLOR(content)}`);
360
366
  }
361
367
  blank();
@@ -400,6 +406,7 @@ export class SidebarComponent {
400
406
  }
401
407
  export class PromptEditor extends Editor {
402
408
  mode;
409
+ /** Display name of the model in use (the server's label when known, else its id). */
403
410
  model = null;
404
411
  thinking;
405
412
  /** Fired when a bottom-edge chip (mode / model / thinking) is clicked. */
@@ -436,8 +443,11 @@ export class PromptEditor extends Editor {
436
443
  }
437
444
  renderBottomBorder(width, hiddenLineCount) {
438
445
  const label = ` ${MODES[this.mode].label} `;
439
- const modelText = this.model ?? "no model";
440
- const thinkingText = this.thinking ?? "default";
446
+ // Both are remote text (session detail, model list, thinking levels) and
447
+ // this border is redrawn every frame — sanitize before measuring so the
448
+ // chip hit-test ranges match what is actually rendered.
449
+ const modelText = sanitizeForTerminal(this.model ?? "no model");
450
+ const thinkingText = sanitizeForTerminal(this.thinking ?? "default");
441
451
  const info = `· ${modelText} · ${thinkingText} `;
442
452
  const suffix = hiddenLineCount > 0 ? `↓ ${hiddenLineCount} more ` : "";
443
453
  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
  }
@@ -8,3 +8,10 @@ import type { ModelInfo } from "../protocol/types.js";
8
8
  export declare function currentModelId(models: ModelInfo[], activeModel: string | null, selectedModel: string | null): string | undefined;
9
9
  /** Build the `/model` picker rows for a model list. */
10
10
  export declare function buildModelItems(models: ModelInfo[], currentId: string | undefined): SelectItem[];
11
+ /**
12
+ * Display name for a model id: the server's label when the list knows the id,
13
+ * else the id itself (a model the list no longer carries, or a list that has
14
+ * not loaded yet). The label is remote text — callers render it through the
15
+ * usual sanitizing path (the editor border and notices both do).
16
+ */
17
+ export declare function modelDisplayName(models: ModelInfo[], id: string | null): string | null;
@@ -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,8 +27,21 @@ 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
  });
33
36
  }
37
+ /**
38
+ * Display name for a model id: the server's label when the list knows the id,
39
+ * else the id itself (a model the list no longer carries, or a list that has
40
+ * not loaded yet). The label is remote text — callers render it through the
41
+ * usual sanitizing path (the editor border and notices both do).
42
+ */
43
+ export function modelDisplayName(models, id) {
44
+ if (!id)
45
+ return null;
46
+ return models.find((m) => m.id === id)?.label ?? id;
47
+ }
@@ -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.2",
3
+ "version": "0.2.4",
4
4
  "description": "Terminal coding agent for your local repository, powered by a remote MyAgent server",
5
5
  "type": "module",
6
6
  "bin": {