@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 +25 -11
- package/bin/myagent.js +7 -2
- package/dist/agent/turn.d.ts +10 -9
- package/dist/agent/turn.js +39 -13
- package/dist/approval/policy.js +1 -1
- package/dist/headless.js +6 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +29 -21
- package/dist/runtime.d.ts +7 -2
- package/dist/runtime.js +51 -40
- 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 +65 -26
- package/dist/ui/app.d.ts +7 -1
- package/dist/ui/app.js +10 -1
- package/dist/ui/components.d.ts +4 -0
- package/dist/ui/components.js +40 -7
- 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/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
|
-
|
|
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",
|
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
|
|
@@ -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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
|
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
|
|
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)) {
|
package/dist/approval/policy.js
CHANGED
|
@@ -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
|
|
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,
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
|
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(`${
|
|
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
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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:
|
|
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),
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
11
|
+
import { realpathSync } from "node:fs";
|
|
12
12
|
import { Buffer } from "node:buffer";
|
|
13
|
-
import {
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
:
|
|
122
|
+
: emptyStream(),
|
|
112
123
|
stderr: child.stderr
|
|
113
124
|
? Readable.toWeb(child.stderr)
|
|
114
|
-
:
|
|
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
|
|
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";
|
|
11
|
-
import {
|
|
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
|
-
*
|
|
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([
|
|
@@ -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
|
-
|
|
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() +
|
|
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
|
|
193
|
+
let buffer;
|
|
159
194
|
try {
|
|
160
|
-
|
|
195
|
+
buffer = await readFile(full);
|
|
161
196
|
}
|
|
162
197
|
catch {
|
|
163
198
|
continue;
|
|
164
199
|
}
|
|
165
|
-
|
|
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 =
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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:
|
|
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.d.ts
CHANGED
|
@@ -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;
|
package/dist/ui/components.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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;
|
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
|
}
|