@ocis/myagent-cli 0.2.2 → 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 +25 -11
- package/dist/agent/turn.d.ts +10 -9
- package/dist/agent/turn.js +34 -13
- package/dist/headless.js +6 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +23 -8
- package/dist/sanitize.js +4 -0
- package/dist/tools/fs.js +12 -12
- package/dist/tools/glob.js +64 -47
- package/dist/tools/grep.d.ts +10 -0
- package/dist/tools/grep.js +60 -20
- package/dist/ui/app.d.ts +7 -1
- package/dist/ui/app.js +10 -1
- package/dist/ui/components.js +13 -4
- package/dist/ui/format.d.ts +5 -0
- package/dist/ui/format.js +7 -1
- package/dist/ui/model-list.js +4 -1
- package/dist/ui/tool-view.js +4 -2
- package/package.json +1 -1
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.
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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/dist/agent/turn.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
270
|
+
/** Open the session event subscription for the recovery window. */
|
|
270
271
|
private startEventSubscription;
|
|
271
272
|
private stopEventSubscription;
|
|
272
273
|
/**
|
package/dist/agent/turn.js
CHANGED
|
@@ -148,7 +148,8 @@ export class AgentRunner {
|
|
|
148
148
|
*/
|
|
149
149
|
abandonedToolCalls = new Set();
|
|
150
150
|
/**
|
|
151
|
-
* Every
|
|
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
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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");
|
package/dist/tools/glob.js
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
//
|
|
89
|
-
result = match(pat, pi +
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
}
|
package/dist/tools/grep.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/tools/grep.js
CHANGED
|
@@ -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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
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() +
|
|
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
|
|
193
|
+
let buffer;
|
|
158
194
|
try {
|
|
159
|
-
|
|
195
|
+
buffer = await readFile(full);
|
|
160
196
|
}
|
|
161
197
|
catch {
|
|
162
198
|
continue;
|
|
163
199
|
}
|
|
164
|
-
|
|
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 =
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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,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:
|
|
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.
|
package/dist/ui/components.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
|
@@ -436,8 +442,11 @@ export class PromptEditor extends Editor {
|
|
|
436
442
|
}
|
|
437
443
|
renderBottomBorder(width, hiddenLineCount) {
|
|
438
444
|
const label = ` ${MODES[this.mode].label} `;
|
|
439
|
-
|
|
440
|
-
|
|
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");
|
|
441
450
|
const info = `· ${modelText} · ${thinkingText} `;
|
|
442
451
|
const suffix = hiddenLineCount > 0 ? `↓ ${hiddenLineCount} more ` : "";
|
|
443
452
|
const fill = width - visibleWidth(label) - visibleWidth(info) - visibleWidth(suffix) - 1;
|
package/dist/ui/format.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/ui/model-list.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/dist/ui/tool-view.js
CHANGED
|
@@ -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
|
}
|