omnirush 0.8.5 → 0.9.0
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/assets/CHANGELOG.md +85 -0
- package/assets/collect-once.ts +6 -5
- package/assets/extensions/omnirush/agents-lib.ts +196 -12
- package/assets/extensions/omnirush/agents.ts +97 -105
- package/assets/extensions/omnirush/auth.js +399 -115
- package/assets/extensions/omnirush/bgshell-lib.ts +400 -0
- package/assets/extensions/omnirush/bgshell.ts +392 -0
- package/assets/extensions/omnirush/collector.ts +103 -16
- package/assets/extensions/omnirush/commands.ts +14 -15
- package/assets/extensions/omnirush/deliveries.ts +145 -0
- package/assets/extensions/omnirush/guard/UPSTREAM +2 -0
- package/assets/extensions/omnirush/guard/git-command-policy.ts +885 -0
- package/assets/extensions/omnirush/guard-lib.ts +230 -0
- package/assets/extensions/omnirush/guard.ts +340 -0
- package/assets/extensions/omnirush/index.ts +12 -0
- package/assets/extensions/omnirush/pi-engine.ts +65 -1
- package/assets/extensions/omnirush/sota.ts +104 -47
- package/assets/extensions/omnirush/status-lib.ts +3 -0
- package/assets/extensions/omnirush/subagents-lib.ts +623 -0
- package/assets/extensions/omnirush/subagents.ts +305 -0
- package/assets/extensions/omnirush/swarm-lib.ts +142 -0
- package/assets/extensions/omnirush/swarm.ts +95 -0
- package/assets/extensions/omnirush/voice/capture.ts +502 -0
- package/assets/extensions/omnirush/voice/core/UPSTREAM +16 -0
- package/assets/extensions/omnirush/voice/core/file-source.ts +70 -0
- package/assets/extensions/omnirush/voice/core/index.ts +21 -0
- package/assets/extensions/omnirush/voice/core/keyterms.ts +117 -0
- package/assets/extensions/omnirush/voice/core/resample.ts +63 -0
- package/assets/extensions/omnirush/voice/core/segmenter.ts +231 -0
- package/assets/extensions/omnirush/voice/core/session.ts +403 -0
- package/assets/extensions/omnirush/voice/core/text.ts +81 -0
- package/assets/extensions/omnirush/voice/core/transcriber.ts +135 -0
- package/assets/extensions/omnirush/voice/core/types.ts +102 -0
- package/assets/extensions/omnirush/voice/core/wav.ts +95 -0
- package/assets/extensions/omnirush/voice/keys.ts +435 -0
- package/assets/extensions/omnirush/voice/kitty.ts +64 -0
- package/assets/extensions/omnirush/voice/pvrecorder-worker.cjs +43 -0
- package/assets/extensions/omnirush/voice/settings.ts +67 -0
- package/assets/extensions/omnirush/voice.ts +838 -0
- package/assets/extensions/omnirush/yolo-lib.ts +80 -0
- package/assets/extensions/omnirush/yolo.ts +85 -0
- package/package.json +6 -2
- package/scripts/brand-engine.js +526 -0
- package/scripts/build-all-packages.py +29 -1
- package/scripts/smoke-packages.py +32 -1
- package/src/bin.js +252 -53
- package/src/compat.js +272 -0
- package/src/lib.js +64 -0
- package/scripts/patch-pi-branding.js +0 -251
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// bgshell-lib — shell commands that outlive their tool call.
|
|
2
|
+
//
|
|
3
|
+
// The bash tool (bgshell.ts) runs every command as a job here. A command
|
|
4
|
+
// that finishes within its foreground window answers the call as before; one
|
|
5
|
+
// that runs longer (or that the model started with run_in_background) is not
|
|
6
|
+
// killed: it moves to the background, keeps running, its output goes to a
|
|
7
|
+
// bounded buffer, and the call returns its id at once. bash_output /
|
|
8
|
+
// bash_wait / bash_kill read, wait for and stop it; when it finishes, its
|
|
9
|
+
// result is delivered to the main agent like a background sub-agent's
|
|
10
|
+
// (deliveries.ts). Jobs still running when the session ends are stopped.
|
|
11
|
+
//
|
|
12
|
+
// Pure apart from the injected `exec` (pi's own local shell execution, which
|
|
13
|
+
// runs the command in its own process group and kills the whole tree), so
|
|
14
|
+
// node:test drives it with fakes.
|
|
15
|
+
|
|
16
|
+
/** How long a command runs in the foreground before it moves to the background (default). */
|
|
17
|
+
export const DEFAULT_FOREGROUND_SECONDS = 300;
|
|
18
|
+
/** The longest foreground wait a call may ask for. */
|
|
19
|
+
export const MAX_FOREGROUND_SECONDS = 3_600;
|
|
20
|
+
/** Output kept per job (characters): the newest part of it. */
|
|
21
|
+
export const JOB_BUFFER_CHARS = 1_000_000;
|
|
22
|
+
/** Output a delivery or a read shows at most (characters): the newest part. */
|
|
23
|
+
export const SHOW_CHARS = 30_000;
|
|
24
|
+
/** Longest wait a bash_wait call may ask for (seconds). */
|
|
25
|
+
export const MAX_WAIT_SECONDS = 3_600;
|
|
26
|
+
|
|
27
|
+
export const BGSHELL_HOOK = Symbol.for("omnirush.bgshell");
|
|
28
|
+
export interface BgShellHook {
|
|
29
|
+
/** Stop every running job (of one session, or all) and record their output; resolves once they exited. */
|
|
30
|
+
stopAll(sessionId?: string): Promise<Array<{ id: string; command: string }>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** pi's shell execution (BashOperations.exec). */
|
|
34
|
+
export type ShellExec = (
|
|
35
|
+
command: string,
|
|
36
|
+
cwd: string,
|
|
37
|
+
options: { onData: (data: Buffer | string) => void; signal?: AbortSignal; timeout?: number; env?: NodeJS.ProcessEnv },
|
|
38
|
+
) => Promise<{ exitCode: number | null }>;
|
|
39
|
+
|
|
40
|
+
export type JobStatus = "running" | "exited" | "killed" | "failed";
|
|
41
|
+
|
|
42
|
+
/** The newest `max` characters of a stream, with absolute offsets. */
|
|
43
|
+
export class OutputBuffer {
|
|
44
|
+
private text = "";
|
|
45
|
+
/** Characters appended in total. */
|
|
46
|
+
total = 0;
|
|
47
|
+
constructor(private readonly max: number = JOB_BUFFER_CHARS) {}
|
|
48
|
+
|
|
49
|
+
append(chunk: string): void {
|
|
50
|
+
if (!chunk) return;
|
|
51
|
+
this.text += chunk;
|
|
52
|
+
this.total += chunk.length;
|
|
53
|
+
if (this.text.length > this.max * 1.25) this.text = this.text.slice(this.text.length - this.max);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Offset of the oldest character still kept. */
|
|
57
|
+
get start(): number {
|
|
58
|
+
return this.total - this.text.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Everything after `offset` that is still kept, and how much of it was dropped. */
|
|
62
|
+
since(offset: number): { text: string; dropped: number } {
|
|
63
|
+
const from = Math.max(offset, this.start);
|
|
64
|
+
return { text: this.text.slice(from - this.start), dropped: Math.max(0, this.start - offset) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// eslint-disable-next-line no-control-regex
|
|
69
|
+
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
|
|
70
|
+
// eslint-disable-next-line no-control-regex
|
|
71
|
+
const CONTROL = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
|
|
72
|
+
|
|
73
|
+
/** Terminal escapes and control characters out; \r\n and progress-bar \r become line ends. */
|
|
74
|
+
export function cleanOutput(text: string): string {
|
|
75
|
+
return text.replace(ANSI, "").replace(/\r\n/g, "\n").replace(/\r(?!\n)/g, "\n").replace(CONTROL, "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The newest `max` characters, starting at a line boundary when one is close. */
|
|
79
|
+
export function tailText(text: string, max: number = SHOW_CHARS): { text: string; truncated: boolean } {
|
|
80
|
+
if (text.length <= max) return { text, truncated: false };
|
|
81
|
+
let tail = text.slice(text.length - max);
|
|
82
|
+
const newline = tail.indexOf("\n");
|
|
83
|
+
if (newline >= 0 && newline < 2_000) tail = tail.slice(newline + 1);
|
|
84
|
+
return { text: tail, truncated: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ShellJob {
|
|
88
|
+
/** Short handle for the bash_* tools ("sh1", "sh2", ...). */
|
|
89
|
+
id: string;
|
|
90
|
+
session: string;
|
|
91
|
+
command: string;
|
|
92
|
+
cwd: string;
|
|
93
|
+
/** The bash call that started it. */
|
|
94
|
+
toolCallId: string | null;
|
|
95
|
+
startedAt: number;
|
|
96
|
+
finishedAt: number | null;
|
|
97
|
+
status: JobStatus;
|
|
98
|
+
exitCode: number | null;
|
|
99
|
+
error: string | null;
|
|
100
|
+
/** Moved to the background (its result is delivered when it finishes). */
|
|
101
|
+
background: boolean;
|
|
102
|
+
/** Started with run_in_background (the model did not wait for it). */
|
|
103
|
+
explicit: boolean;
|
|
104
|
+
backgroundedAt: number | null;
|
|
105
|
+
output: OutputBuffer;
|
|
106
|
+
/** Where bash_output continues ("new output"). */
|
|
107
|
+
readOffset: number;
|
|
108
|
+
/** Its final result reached the model (tool result, read, wait, kill or delivery). */
|
|
109
|
+
delivered: boolean;
|
|
110
|
+
/** Stopped because the session ended. */
|
|
111
|
+
interrupted: boolean;
|
|
112
|
+
controller: AbortController;
|
|
113
|
+
done: Promise<void>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Tracks the shell jobs of this process per session. */
|
|
117
|
+
export class ShellJobManager {
|
|
118
|
+
private readonly exec: ShellExec;
|
|
119
|
+
private readonly now: () => number;
|
|
120
|
+
private readonly onDeliverable: () => void;
|
|
121
|
+
private readonly jobs: ShellJob[] = [];
|
|
122
|
+
private readonly listeners = new Map<ShellJob, Set<(text: string) => void>>();
|
|
123
|
+
/** The foreground call's raw output sink, until the job moves to the background. */
|
|
124
|
+
private readonly forwards = new Map<ShellJob, (data: Buffer | string) => void>();
|
|
125
|
+
private changeWaiters: Array<() => void> = [];
|
|
126
|
+
private next = 1;
|
|
127
|
+
/** Jobs a bash_wait call is waiting for (it returns their result itself). */
|
|
128
|
+
private readonly awaited = new Map<ShellJob, number>();
|
|
129
|
+
/** Explicit background jobs the one-shot reminder already named. */
|
|
130
|
+
private readonly reminded = new Set<ShellJob>();
|
|
131
|
+
|
|
132
|
+
constructor(options: { exec: ShellExec; now?: () => number; onDeliverable?: () => void }) {
|
|
133
|
+
this.exec = options.exec;
|
|
134
|
+
this.now = options.now ?? Date.now;
|
|
135
|
+
this.onDeliverable = options.onDeliverable ?? (() => undefined);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
start(input: {
|
|
139
|
+
session: string;
|
|
140
|
+
command: string;
|
|
141
|
+
cwd: string;
|
|
142
|
+
env?: NodeJS.ProcessEnv;
|
|
143
|
+
toolCallId?: string | null;
|
|
144
|
+
onData?: (data: Buffer | string) => void;
|
|
145
|
+
}): ShellJob {
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
const job: ShellJob = {
|
|
148
|
+
id: `sh${this.next++}`,
|
|
149
|
+
session: input.session,
|
|
150
|
+
command: input.command,
|
|
151
|
+
cwd: input.cwd,
|
|
152
|
+
toolCallId: input.toolCallId ?? null,
|
|
153
|
+
startedAt: this.now(),
|
|
154
|
+
finishedAt: null,
|
|
155
|
+
status: "running",
|
|
156
|
+
exitCode: null,
|
|
157
|
+
error: null,
|
|
158
|
+
background: false,
|
|
159
|
+
explicit: false,
|
|
160
|
+
backgroundedAt: null,
|
|
161
|
+
output: new OutputBuffer(),
|
|
162
|
+
readOffset: 0,
|
|
163
|
+
delivered: false,
|
|
164
|
+
interrupted: false,
|
|
165
|
+
controller,
|
|
166
|
+
done: Promise.resolve(),
|
|
167
|
+
};
|
|
168
|
+
this.jobs.push(job);
|
|
169
|
+
const decoder = new TextDecoder();
|
|
170
|
+
const onData = (data: Buffer | string) => {
|
|
171
|
+
const text = cleanOutput(typeof data === "string" ? data : decoder.decode(data, { stream: true }));
|
|
172
|
+
job.output.append(text);
|
|
173
|
+
for (const listener of this.listeners.get(job) ?? []) listener(text);
|
|
174
|
+
};
|
|
175
|
+
if (input.onData) this.forwards.set(job, input.onData);
|
|
176
|
+
job.done = (async () => {
|
|
177
|
+
try {
|
|
178
|
+
const result = await this.exec(input.command, input.cwd, {
|
|
179
|
+
onData: (data) => {
|
|
180
|
+
this.forwards.get(job)?.(data);
|
|
181
|
+
onData(data);
|
|
182
|
+
},
|
|
183
|
+
signal: controller.signal,
|
|
184
|
+
...(input.env ? { env: input.env } : {}),
|
|
185
|
+
});
|
|
186
|
+
this.finish(job, controller.signal.aborted ? "killed" : "exited", result?.exitCode ?? null, null);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
189
|
+
if (controller.signal.aborted || message === "aborted") this.finish(job, "killed", null, null);
|
|
190
|
+
else this.finish(job, "failed", null, message);
|
|
191
|
+
}
|
|
192
|
+
})();
|
|
193
|
+
return job;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private finish(job: ShellJob, status: JobStatus, exitCode: number | null, error: string | null): void {
|
|
197
|
+
if (job.status !== "running") return;
|
|
198
|
+
job.status = status;
|
|
199
|
+
job.exitCode = exitCode;
|
|
200
|
+
job.error = error;
|
|
201
|
+
job.finishedAt = this.now();
|
|
202
|
+
this.listeners.delete(job);
|
|
203
|
+
this.forwards.delete(job);
|
|
204
|
+
// A waiting bash_wait hands the result over: no delivery message.
|
|
205
|
+
if ((this.awaited.get(job) ?? 0) > 0) job.delivered = true;
|
|
206
|
+
const waiters = this.changeWaiters;
|
|
207
|
+
this.changeWaiters = [];
|
|
208
|
+
waiters.forEach((resolve) => resolve());
|
|
209
|
+
if (job.background && !job.delivered) this.onDeliverable();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Hear the job's (cleaned) output from now on; returns the unsubscribe. */
|
|
213
|
+
listen(job: ShellJob, listener: (text: string) => void): () => void {
|
|
214
|
+
const set = this.listeners.get(job) ?? new Set();
|
|
215
|
+
set.add(listener);
|
|
216
|
+
this.listeners.set(job, set);
|
|
217
|
+
return () => set.delete(listener);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The foreground call stops waiting: the job keeps running in the background. */
|
|
221
|
+
toBackground(job: ShellJob, explicit = false): void {
|
|
222
|
+
if (job.background) return;
|
|
223
|
+
this.forwards.delete(job);
|
|
224
|
+
job.background = true;
|
|
225
|
+
job.explicit = explicit;
|
|
226
|
+
job.backgroundedAt = this.now();
|
|
227
|
+
// What the foreground call showed is not "new" for bash_output.
|
|
228
|
+
job.readOffset = job.output.total;
|
|
229
|
+
if (job.status !== "running" && !job.delivered) this.onDeliverable();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
list(session?: string): ShellJob[] {
|
|
233
|
+
return this.jobs.filter((job) => session === undefined || job.session === session);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Jobs by id (every background job of the session when `ids` is empty or "all"); unknown ids apart. */
|
|
237
|
+
resolve(session: string, ids: readonly string[] | undefined): { jobs: ShellJob[]; unknown: string[] } {
|
|
238
|
+
const all = this.list(session).filter((job) => job.background);
|
|
239
|
+
if (!ids || ids.length === 0 || ids.includes("all")) return { jobs: all, unknown: [] };
|
|
240
|
+
const jobs: ShellJob[] = [];
|
|
241
|
+
const unknown: string[] = [];
|
|
242
|
+
for (const raw of ids) {
|
|
243
|
+
const id = String(raw).trim();
|
|
244
|
+
const job = all.find((candidate) => candidate.id === id);
|
|
245
|
+
if (job && !jobs.includes(job)) jobs.push(job);
|
|
246
|
+
else if (!job) unknown.push(id);
|
|
247
|
+
}
|
|
248
|
+
return { jobs, unknown };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
running(session?: string): ShellJob[] {
|
|
252
|
+
return this.list(session).filter((job) => job.status === "running");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** New output since the last read (marks it read; a finished job's result is then delivered). */
|
|
256
|
+
read(job: ShellJob): { text: string; dropped: number } {
|
|
257
|
+
const out = job.output.since(job.readOffset);
|
|
258
|
+
job.readOffset = job.output.total;
|
|
259
|
+
if (job.status !== "running") job.delivered = true;
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Wait until the jobs finished, `timeoutMs` passed or `signal` fired (they keep running then). */
|
|
264
|
+
async wait(jobs: ShellJob[], options: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise<void> {
|
|
265
|
+
for (const job of jobs) this.awaited.set(job, (this.awaited.get(job) ?? 0) + 1);
|
|
266
|
+
const all = Promise.all(jobs.map((job) => job.done)).then(() => undefined);
|
|
267
|
+
const stops: Array<Promise<void>> = [all];
|
|
268
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
269
|
+
if (options.timeoutMs && options.timeoutMs > 0) stops.push(new Promise((resolve) => { timer = setTimeout(resolve, options.timeoutMs); }));
|
|
270
|
+
let onAbort: (() => void) | null = null;
|
|
271
|
+
if (options.signal) {
|
|
272
|
+
stops.push(new Promise((resolve) => {
|
|
273
|
+
onAbort = () => resolve();
|
|
274
|
+
if (options.signal!.aborted) resolve();
|
|
275
|
+
else options.signal!.addEventListener("abort", onAbort, { once: true });
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
await Promise.race(stops);
|
|
280
|
+
} finally {
|
|
281
|
+
if (timer) clearTimeout(timer);
|
|
282
|
+
if (onAbort) options.signal?.removeEventListener("abort", onAbort);
|
|
283
|
+
for (const job of jobs) {
|
|
284
|
+
const count = (this.awaited.get(job) ?? 1) - 1;
|
|
285
|
+
if (count > 0) this.awaited.set(job, count);
|
|
286
|
+
else this.awaited.delete(job);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Stop jobs (their process trees) and wait for them to exit. */
|
|
292
|
+
async kill(jobs: ShellJob[], options: { interrupted?: boolean; deliver?: boolean } = {}): Promise<ShellJob[]> {
|
|
293
|
+
const live = jobs.filter((job) => job.status === "running");
|
|
294
|
+
for (const job of live) {
|
|
295
|
+
if (options.interrupted) job.interrupted = true;
|
|
296
|
+
// The caller reports them itself (unless the user stopped them: then
|
|
297
|
+
// the model is told like for any finished background command).
|
|
298
|
+
if (!options.deliver) job.delivered = true;
|
|
299
|
+
job.controller.abort();
|
|
300
|
+
}
|
|
301
|
+
await Promise.all(live.map((job) => job.done));
|
|
302
|
+
return live;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Finished background jobs whose result the model has not seen (marked delivered). */
|
|
306
|
+
takeDeliverable(session: string): ShellJob[] {
|
|
307
|
+
const ready = this.list(session).filter((job) => job.background && job.status !== "running" && !job.delivered);
|
|
308
|
+
ready.forEach((job) => { job.delivered = true; });
|
|
309
|
+
return ready;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* One-shot runs wait for: jobs that moved to the background while the model
|
|
314
|
+
* waited for them, and finished results not yet seen. Jobs the model
|
|
315
|
+
* started in the background on purpose get one reminder instead
|
|
316
|
+
* (`remindable`).
|
|
317
|
+
*/
|
|
318
|
+
hasPending(session: string): boolean {
|
|
319
|
+
return this.list(session).some((job) => job.background && (job.status === "running" ? !job.explicit : !job.delivered));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Explicit background jobs still running that the model was not reminded of yet (marked reminded). */
|
|
323
|
+
remindable(session: string): ShellJob[] {
|
|
324
|
+
const jobs = this.running(session).filter((job) => job.background && job.explicit && !this.reminded.has(job));
|
|
325
|
+
jobs.forEach((job) => this.reminded.add(job));
|
|
326
|
+
return jobs;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
changed(): Promise<void> {
|
|
330
|
+
return new Promise((resolve) => this.changeWaiters.push(resolve));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function minutes(ms: number): string {
|
|
335
|
+
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
336
|
+
if (seconds < 90) return `${seconds} s`;
|
|
337
|
+
return `${Math.round((seconds / 60) * 10) / 10} min`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function shortCommand(command: string, max = 80): string {
|
|
341
|
+
const line = command.trim().replace(/\s+/g, " ");
|
|
342
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** "exit 0", "killed", "failed: ..." */
|
|
346
|
+
export function jobOutcome(job: ShellJob): string {
|
|
347
|
+
if (job.status === "running") return "running";
|
|
348
|
+
if (job.status === "killed") return job.interrupted ? "stopped (the session ended)" : "killed";
|
|
349
|
+
if (job.status === "failed") return `failed${job.error ? `: ${job.error}` : ""}`;
|
|
350
|
+
return `exit ${job.exitCode ?? "?"}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** One status line per job. */
|
|
354
|
+
export function renderJobStatus(jobs: ShellJob[], now: number = Date.now()): string {
|
|
355
|
+
if (jobs.length === 0) return "No background commands in this session.";
|
|
356
|
+
return jobs
|
|
357
|
+
.map((job) => `- ${job.id} [${jobOutcome(job)}] ${minutes((job.finishedAt ?? now) - job.startedAt)}, ${job.output.total} chars of output — ${shortCommand(job.command)}`)
|
|
358
|
+
.join("\n");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A job's result for the model: header, then the newest output. */
|
|
362
|
+
export function renderJobResult(job: ShellJob, text: string, options: { dropped?: number; heading?: string; now?: number } = {}): { text: string; output: string; truncated: boolean } {
|
|
363
|
+
const shown = tailText(text);
|
|
364
|
+
const lost = (options.dropped ?? 0) > 0 || shown.truncated;
|
|
365
|
+
const heading = options.heading ?? `${job.id} [${jobOutcome(job)}] after ${minutes((job.finishedAt ?? options.now ?? Date.now()) - job.startedAt)}: ${shortCommand(job.command, 200)}`;
|
|
366
|
+
const body = shown.text.trim() ? shown.text.replace(/\s+$/, "") : "(no new output)";
|
|
367
|
+
return {
|
|
368
|
+
text: `${heading}\n${lost ? "[earlier output omitted — only the newest part is kept]\n" : ""}${body}`,
|
|
369
|
+
output: shown.text,
|
|
370
|
+
truncated: lost,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The message that brings finished background commands back to the model. */
|
|
375
|
+
export function renderJobDelivery(jobs: Array<{ job: ShellJob; text: string; dropped: number }>): string {
|
|
376
|
+
const ids = jobs.map(({ job }) => job.id).join(", ");
|
|
377
|
+
return [
|
|
378
|
+
`Background command${jobs.length === 1 ? "" : "s"} finished (${ids}). Output since you last read it follows; continue your work with it.`,
|
|
379
|
+
"",
|
|
380
|
+
...jobs.map(({ job, text, dropped }) => renderJobResult(job, text, { dropped }).text).join("\n\n---\n\n").split("\n"),
|
|
381
|
+
].join("\n");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** A job's record in a message's details (the trace turns it into a bash tool part). */
|
|
385
|
+
export function jobDetails(job: ShellJob, output: string, truncated: boolean): Record<string, unknown> {
|
|
386
|
+
return {
|
|
387
|
+
id: job.id,
|
|
388
|
+
command: job.command,
|
|
389
|
+
cwd: job.cwd,
|
|
390
|
+
status: job.status,
|
|
391
|
+
exit_code: job.exitCode,
|
|
392
|
+
...(job.error ? { error: job.error } : {}),
|
|
393
|
+
...(job.interrupted ? { interrupted: true } : {}),
|
|
394
|
+
tool_call_id: job.toolCallId,
|
|
395
|
+
started_at: job.startedAt,
|
|
396
|
+
finished_at: job.finishedAt,
|
|
397
|
+
output,
|
|
398
|
+
truncated,
|
|
399
|
+
};
|
|
400
|
+
}
|