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,392 @@
|
|
|
1
|
+
// omnirush-bgshell — long shell commands move to the background instead of
|
|
2
|
+
// being killed.
|
|
3
|
+
//
|
|
4
|
+
// Overrides the built-in bash tool (same execution, output handling and
|
|
5
|
+
// rendering: pi's own bash tool definition runs each call, with an
|
|
6
|
+
// execution backend that keeps the process in a job — bgshell-lib.ts):
|
|
7
|
+
// - a command that finishes within its foreground window (the call's
|
|
8
|
+
// `timeout` in seconds, default 300, OMNIRUSH_BASH_FOREGROUND_SECONDS)
|
|
9
|
+
// answers the call as before;
|
|
10
|
+
// - one still running then is NOT killed: it moves to the background and
|
|
11
|
+
// the call returns its id and the output so far; `run_in_background:
|
|
12
|
+
// true` does that at once (servers, watchers, long builds);
|
|
13
|
+
// - bash_output (new output and status), bash_wait and bash_kill manage
|
|
14
|
+
// background commands; when one finishes, its result comes back to the
|
|
15
|
+
// main agent as a message, the way background sub-agents' results do
|
|
16
|
+
// (deliveries.ts): steered into a running turn, starting a turn when the
|
|
17
|
+
// agent is idle, and holding a one-shot run open until it arrives;
|
|
18
|
+
// - background commands still running when the session ends (exit, /new,
|
|
19
|
+
// /resume) are stopped with a message saying so; their output is kept
|
|
20
|
+
// in the session (and so in the trace) as a bash tool part.
|
|
21
|
+
|
|
22
|
+
import { createBashToolDefinition, createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { writeSync } from "node:fs";
|
|
24
|
+
import { Type } from "typebox";
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
BGSHELL_HOOK,
|
|
28
|
+
type BgShellHook,
|
|
29
|
+
DEFAULT_FOREGROUND_SECONDS,
|
|
30
|
+
jobDetails,
|
|
31
|
+
jobOutcome,
|
|
32
|
+
MAX_FOREGROUND_SECONDS,
|
|
33
|
+
MAX_WAIT_SECONDS,
|
|
34
|
+
renderJobDelivery,
|
|
35
|
+
renderJobResult,
|
|
36
|
+
renderJobStatus,
|
|
37
|
+
shortCommand,
|
|
38
|
+
type ShellExec,
|
|
39
|
+
type ShellJob,
|
|
40
|
+
ShellJobManager,
|
|
41
|
+
tailText,
|
|
42
|
+
} from "./bgshell-lib";
|
|
43
|
+
import { deliveryHub } from "./deliveries";
|
|
44
|
+
import { BACKGROUND_BASH_TYPE } from "./pi-engine";
|
|
45
|
+
|
|
46
|
+
/** customType of the message that brings finished background commands back (the trace maps it to bash tool parts). */
|
|
47
|
+
export const BASH_DELIVERY_TYPE = BACKGROUND_BASH_TYPE;
|
|
48
|
+
/** customType of the one-shot reminder about background commands still running. */
|
|
49
|
+
export const BASH_REMINDER_TYPE = "omnirush-bash-reminder";
|
|
50
|
+
|
|
51
|
+
class MovedToBackground extends Error {
|
|
52
|
+
constructor() {
|
|
53
|
+
super("moved to the background");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The default foreground window (seconds), OMNIRUSH_BASH_FOREGROUND_SECONDS overriding it. */
|
|
58
|
+
export function defaultForegroundSeconds(env: NodeJS.ProcessEnv = process.env): number {
|
|
59
|
+
const raw = Number(String(env.OMNIRUSH_BASH_FOREGROUND_SECONDS ?? "").trim());
|
|
60
|
+
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, MAX_FOREGROUND_SECONDS) : DEFAULT_FOREGROUND_SECONDS;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The foreground window of one call: its `timeout` (seconds), bounded; placeholders (<= 0) take the default. */
|
|
64
|
+
export function foregroundSeconds(timeout: unknown, fallback: number = defaultForegroundSeconds()): number {
|
|
65
|
+
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) return fallback;
|
|
66
|
+
return Math.min(Math.max(timeout, 1), MAX_FOREGROUND_SECONDS);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function plural(count: number, word: string): string {
|
|
70
|
+
return `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default function (pi: any, options: { exec?: ShellExec; now?: () => number } = {}) {
|
|
74
|
+
const hub = deliveryHub(pi);
|
|
75
|
+
const exec: ShellExec = options.exec ?? (createLocalBashOperations().exec as ShellExec);
|
|
76
|
+
const manager = new ShellJobManager({ exec, ...(options.now ? { now: options.now } : {}), onDeliverable: () => hub.notify() });
|
|
77
|
+
const fgDefault = defaultForegroundSeconds();
|
|
78
|
+
|
|
79
|
+
/** New output of each job, marked read. */
|
|
80
|
+
const collect = (jobs: ShellJob[]) => jobs.map((job) => ({ job, ...manager.read(job) }));
|
|
81
|
+
|
|
82
|
+
hub.addSource({
|
|
83
|
+
hasPending: (session) => manager.hasPending(session),
|
|
84
|
+
take: (session) => {
|
|
85
|
+
const jobs = manager.takeDeliverable(session);
|
|
86
|
+
if (jobs.length === 0) return [];
|
|
87
|
+
const parts = collect(jobs);
|
|
88
|
+
return [{
|
|
89
|
+
customType: BASH_DELIVERY_TYPE,
|
|
90
|
+
content: renderJobDelivery(parts),
|
|
91
|
+
details: {
|
|
92
|
+
jobs: parts.map(({ job, text, dropped }) => {
|
|
93
|
+
const shown = tailText(text);
|
|
94
|
+
return jobDetails(job, shown.text, shown.truncated || dropped > 0);
|
|
95
|
+
}),
|
|
96
|
+
},
|
|
97
|
+
}];
|
|
98
|
+
},
|
|
99
|
+
changed: () => manager.changed(),
|
|
100
|
+
beforeEnd: (session) => {
|
|
101
|
+
const jobs = manager.remindable(session);
|
|
102
|
+
if (jobs.length === 0) return null;
|
|
103
|
+
return {
|
|
104
|
+
customType: BASH_REMINDER_TYPE,
|
|
105
|
+
content: [
|
|
106
|
+
`This run is about to end while ${plural(jobs.length, "background command")} you started still run${jobs.length === 1 ? "s" : ""}:`,
|
|
107
|
+
renderJobStatus(jobs),
|
|
108
|
+
"If you need their results, wait for them with bash_wait; stop the ones you no longer need (servers, watchers) with bash_kill. Background commands still running when you finish are stopped.",
|
|
109
|
+
].join("\n"),
|
|
110
|
+
details: { jobs: jobs.map((job) => job.id) },
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// --- stopping at the end of the session -------------------------------------
|
|
116
|
+
|
|
117
|
+
/** What the session's end stopped (the collector's hook may do it before our own handler runs). */
|
|
118
|
+
let stoppedAtEnd: ShellJob[] = [];
|
|
119
|
+
const stopAll = async (session?: string): Promise<ShellJob[]> => {
|
|
120
|
+
const live = manager.running(session);
|
|
121
|
+
if (live.length === 0) return [];
|
|
122
|
+
const stopped = await manager.kill(live, { interrupted: true });
|
|
123
|
+
stoppedAtEnd.push(...stopped);
|
|
124
|
+
// Kept in the session (and so in the trace) like any tool output; the
|
|
125
|
+
// model never sees it — nothing runs after this.
|
|
126
|
+
const background = stopped.filter((job) => job.background);
|
|
127
|
+
if (background.length > 0) {
|
|
128
|
+
try {
|
|
129
|
+
const parts = collect(background);
|
|
130
|
+
pi.sendMessage(
|
|
131
|
+
{
|
|
132
|
+
customType: BASH_DELIVERY_TYPE,
|
|
133
|
+
content: [
|
|
134
|
+
`The session ended: ${plural(background.length, "background command")} stopped. Output since it was last read:`,
|
|
135
|
+
"",
|
|
136
|
+
parts.map(({ job, text, dropped }) => renderJobResult(job, text, { dropped }).text).join("\n\n---\n\n"),
|
|
137
|
+
].join("\n"),
|
|
138
|
+
display: false,
|
|
139
|
+
details: {
|
|
140
|
+
jobs: parts.map(({ job, text, dropped }) => {
|
|
141
|
+
const shown = tailText(text);
|
|
142
|
+
return jobDetails(job, shown.text, shown.truncated || dropped > 0);
|
|
143
|
+
}),
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{ triggerTurn: false },
|
|
147
|
+
);
|
|
148
|
+
} catch {
|
|
149
|
+
/* the session may already be gone */
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return stopped;
|
|
153
|
+
};
|
|
154
|
+
const hook: BgShellHook = {
|
|
155
|
+
async stopAll(sessionId?: string) {
|
|
156
|
+
const stopped = await stopAll(sessionId);
|
|
157
|
+
return stopped.map((job) => ({ id: job.id, command: job.command }));
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
(globalThis as any)[BGSHELL_HOOK] = hook;
|
|
161
|
+
|
|
162
|
+
pi.on("session_shutdown", async (event: any, ctx: any) => {
|
|
163
|
+
await stopAll();
|
|
164
|
+
const stopped = stoppedAtEnd.filter((job) => job.background || job.status === "killed");
|
|
165
|
+
stoppedAtEnd = [];
|
|
166
|
+
if (stopped.length === 0) return;
|
|
167
|
+
const text = `omnirush: stopped ${plural(stopped.length, "background command")} as the session ended: ${stopped
|
|
168
|
+
.map((job) => `${job.id} (${shortCommand(job.command, 60)})`)
|
|
169
|
+
.join(", ")}`;
|
|
170
|
+
if (!event?.reason || event.reason === "quit") {
|
|
171
|
+
// After the terminal is restored, so the line stays readable.
|
|
172
|
+
process.once("exit", () => {
|
|
173
|
+
try {
|
|
174
|
+
writeSync(2, `${text}\n`);
|
|
175
|
+
} catch {
|
|
176
|
+
/* stderr may be closed */
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
} else if (ctx?.ui?.notify) {
|
|
180
|
+
ctx.ui.notify(text, "warning");
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// --- the bash tool ---------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const base = createBashToolDefinition(process.cwd());
|
|
187
|
+
const BashParams = Type.Object({
|
|
188
|
+
command: Type.String({ description: "Shell command to execute" }),
|
|
189
|
+
timeout: Type.Optional(Type.Number({
|
|
190
|
+
description: `Seconds to wait for the command in the foreground (default ${fgDefault}, at most ${MAX_FOREGROUND_SECONDS}). A command still running then is NOT killed: it moves to the background, you get its id and the output so far, and its result is delivered to you when it finishes`,
|
|
191
|
+
})),
|
|
192
|
+
run_in_background: Type.Optional(Type.Boolean({
|
|
193
|
+
description: "true: start the command in the background and return at once with its id (dev servers, watchers, long builds or test suites while you do other work). Its result is delivered to you as a message when it finishes",
|
|
194
|
+
})),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
pi.registerTool({
|
|
198
|
+
...base,
|
|
199
|
+
name: "bash",
|
|
200
|
+
label: "bash",
|
|
201
|
+
description: [
|
|
202
|
+
"Execute a bash command in the current working directory. Returns stdout and stderr (output is truncated to the last lines; full output is saved to a temp file).",
|
|
203
|
+
`A command that runs longer than its foreground window (timeout, default ${fgDefault} s) is not killed: it continues in the background and its result arrives later as a message.`,
|
|
204
|
+
"run_in_background: true starts it in the background right away.",
|
|
205
|
+
"bash_output reads a background command's new output and status, bash_wait waits for it, bash_kill stops it.",
|
|
206
|
+
].join(" "),
|
|
207
|
+
promptSnippet: "Execute bash commands (ls, grep, find, etc.); long-running commands continue in the background",
|
|
208
|
+
promptGuidelines: [
|
|
209
|
+
...(Array.isArray(base.promptGuidelines) ? base.promptGuidelines : []),
|
|
210
|
+
"Long-running commands (dev servers, watchers, long builds or test suites) you do not need to wait for: bash with run_in_background: true, then keep working; their result arrives as a message when they finish, so do not poll bash_output in a loop.",
|
|
211
|
+
"Stop background commands you no longer need (servers, watchers) with bash_kill before you finish.",
|
|
212
|
+
],
|
|
213
|
+
parameters: BashParams,
|
|
214
|
+
|
|
215
|
+
async execute(toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
|
|
216
|
+
const session = hub.sessionOf(ctx) || "session";
|
|
217
|
+
const explicit = params?.run_in_background === true;
|
|
218
|
+
const windowMs = explicit ? 0 : foregroundSeconds(params?.timeout, fgDefault) * 1000;
|
|
219
|
+
let job: ShellJob | null = null;
|
|
220
|
+
|
|
221
|
+
const operations = {
|
|
222
|
+
exec: (command: string, cwd: string, execOptions: any) =>
|
|
223
|
+
new Promise<{ exitCode: number | null }>((resolve, reject) => {
|
|
224
|
+
const current = manager.start({ session, command, cwd, env: execOptions?.env, toolCallId, onData: execOptions?.onData });
|
|
225
|
+
job = current;
|
|
226
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
227
|
+
const onAbort = () => { void manager.kill([current]); };
|
|
228
|
+
const cleanup = () => {
|
|
229
|
+
if (timer) clearTimeout(timer);
|
|
230
|
+
execOptions?.signal?.removeEventListener?.("abort", onAbort);
|
|
231
|
+
};
|
|
232
|
+
const moveToBackground = () => {
|
|
233
|
+
if (current.status !== "running") return;
|
|
234
|
+
cleanup();
|
|
235
|
+
manager.toBackground(current, explicit);
|
|
236
|
+
reject(new MovedToBackground());
|
|
237
|
+
};
|
|
238
|
+
if (execOptions?.signal) {
|
|
239
|
+
if (execOptions.signal.aborted) onAbort();
|
|
240
|
+
else execOptions.signal.addEventListener("abort", onAbort, { once: true });
|
|
241
|
+
}
|
|
242
|
+
if (windowMs <= 0) moveToBackground();
|
|
243
|
+
else timer = setTimeout(moveToBackground, windowMs);
|
|
244
|
+
void current.done.then(() => {
|
|
245
|
+
if (current.background) return;
|
|
246
|
+
cleanup();
|
|
247
|
+
// Its output reaches the model through this tool result.
|
|
248
|
+
current.delivered = true;
|
|
249
|
+
if (current.status === "killed") reject(new Error("aborted"));
|
|
250
|
+
else if (current.status === "failed") reject(new Error(current.error ?? "command failed"));
|
|
251
|
+
else resolve({ exitCode: current.exitCode });
|
|
252
|
+
});
|
|
253
|
+
}),
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const definition = createBashToolDefinition(ctx?.cwd ? String(ctx.cwd) : process.cwd(), { operations: operations as any });
|
|
257
|
+
try {
|
|
258
|
+
return await definition.execute(toolCallId, { command: params?.command }, signal, onUpdate, ctx);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const moved = job as ShellJob | null;
|
|
261
|
+
if (!(error instanceof MovedToBackground) || !moved) throw error;
|
|
262
|
+
const hint = "Its result arrives as a message in this conversation when it finishes (no need to poll). bash_output reads its new output, bash_wait waits for it, bash_kill stops it.";
|
|
263
|
+
let text: string;
|
|
264
|
+
if (explicit) {
|
|
265
|
+
text = `Started in the background as ${moved.id}: ${shortCommand(moved.command, 200)}\n${hint}`;
|
|
266
|
+
} else {
|
|
267
|
+
const soFar = tailText(moved.output.since(0).text);
|
|
268
|
+
text = [
|
|
269
|
+
`Still running after ${Math.round(windowMs / 1000)} s — moved to the background as ${moved.id} (not killed).`,
|
|
270
|
+
hint,
|
|
271
|
+
"",
|
|
272
|
+
"Output so far:",
|
|
273
|
+
`${soFar.truncated ? "[earlier output omitted]\n" : ""}${soFar.text.trim() ? soFar.text.replace(/\s+$/, "") : "(no output yet)"}`,
|
|
274
|
+
].join("\n");
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
content: [{ type: "text", text }],
|
|
278
|
+
details: { background: { id: moved.id, command: moved.command, explicit, started_at: moved.startedAt } },
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// --- companion tools -----------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
const IdsParam = Type.Optional(Type.Array(Type.String(), {
|
|
287
|
+
description: 'Background command ids (e.g. "sh2"); omit (or "all") for every background command of this session',
|
|
288
|
+
}));
|
|
289
|
+
|
|
290
|
+
const readResult = (jobs: ShellJob[], unknown: string[]) => {
|
|
291
|
+
const parts = collect(jobs);
|
|
292
|
+
const sections = parts.map(({ job, text, dropped }) => renderJobResult(job, text, { dropped }).text);
|
|
293
|
+
if (unknown.length) sections.push(`Unknown ids: ${unknown.join(", ")}`);
|
|
294
|
+
return {
|
|
295
|
+
content: [{ type: "text", text: sections.join("\n\n---\n\n") || "No background commands in this session." }],
|
|
296
|
+
details: {
|
|
297
|
+
jobs: parts.map(({ job, text, dropped }) => {
|
|
298
|
+
const shown = tailText(text);
|
|
299
|
+
return jobDetails(job, shown.text, shown.truncated || dropped > 0);
|
|
300
|
+
}),
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
pi.registerTool({
|
|
306
|
+
name: "bash_output",
|
|
307
|
+
label: "bash output",
|
|
308
|
+
description: "A background command's output since you last read it, and its status (running, or its exit code). Without an id: the status of every background command of this session. Results are delivered automatically when a command finishes — use this to check progress, not to poll in a loop.",
|
|
309
|
+
promptSnippet: "bash_output: new output and status of a background command",
|
|
310
|
+
parameters: Type.Object({
|
|
311
|
+
id: Type.Optional(Type.String({ description: 'Background command id (e.g. "sh2"); omit for the status of all' })),
|
|
312
|
+
}),
|
|
313
|
+
async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
|
|
314
|
+
const session = hub.sessionOf(ctx) || "session";
|
|
315
|
+
const id = typeof params?.id === "string" ? params.id.trim() : "";
|
|
316
|
+
if (!id || id === "all") {
|
|
317
|
+
const jobs = manager.resolve(session, []).jobs;
|
|
318
|
+
return {
|
|
319
|
+
content: [{ type: "text", text: renderJobStatus(jobs) }],
|
|
320
|
+
details: { jobs: jobs.map((job) => ({ id: job.id, command: job.command, status: job.status, exit_code: job.exitCode })) },
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const { jobs, unknown } = manager.resolve(session, [id]);
|
|
324
|
+
return readResult(jobs, unknown);
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
pi.registerTool({
|
|
329
|
+
name: "bash_wait",
|
|
330
|
+
label: "bash wait",
|
|
331
|
+
description: "Wait until background commands finish (default: every running one) and return their new output and exit codes. timeout_seconds bounds the WAIT only — the commands keep running past it.",
|
|
332
|
+
promptSnippet: "bash_wait: wait for background commands to finish",
|
|
333
|
+
parameters: Type.Object({
|
|
334
|
+
ids: IdsParam,
|
|
335
|
+
timeout_seconds: Type.Optional(Type.Number({ description: `Leave out (or 0) to wait until they finish (at most ${MAX_WAIT_SECONDS} s per call); otherwise stop WAITING after this many seconds` })),
|
|
336
|
+
}),
|
|
337
|
+
async execute(_toolCallId: string, params: any, signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
|
|
338
|
+
const session = hub.sessionOf(ctx) || "session";
|
|
339
|
+
const explicit = Array.isArray(params?.ids) && params.ids.length > 0 && !params.ids.includes("all");
|
|
340
|
+
const resolved = manager.resolve(session, params?.ids);
|
|
341
|
+
const jobs = explicit ? resolved.jobs : resolved.jobs.filter((job) => job.status === "running" || !job.delivered);
|
|
342
|
+
if (jobs.length === 0 && resolved.unknown.length === 0) {
|
|
343
|
+
return { content: [{ type: "text", text: "No background commands to wait for." }], details: { jobs: [] } };
|
|
344
|
+
}
|
|
345
|
+
const seconds = typeof params?.timeout_seconds === "number" && params.timeout_seconds > 0
|
|
346
|
+
? Math.min(params.timeout_seconds, MAX_WAIT_SECONDS)
|
|
347
|
+
: MAX_WAIT_SECONDS;
|
|
348
|
+
onUpdate?.({ content: [{ type: "text", text: `bash_wait: waiting for ${jobs.filter((job) => job.status === "running").map((job) => job.id).join(", ") || "nothing"}` }], details: {} });
|
|
349
|
+
await manager.wait(jobs, { timeoutMs: seconds * 1000, ...(signal ? { signal } : {}) });
|
|
350
|
+
return readResult(jobs, resolved.unknown);
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
pi.registerTool({
|
|
355
|
+
name: "bash_kill",
|
|
356
|
+
label: "bash kill",
|
|
357
|
+
description: 'Stop background commands (the whole process tree) by id, or ["all"]; returns their last output.',
|
|
358
|
+
promptSnippet: "bash_kill: stop background commands",
|
|
359
|
+
parameters: Type.Object({
|
|
360
|
+
ids: Type.Array(Type.String(), { description: 'Background command ids, or ["all"]', minItems: 1 }),
|
|
361
|
+
}),
|
|
362
|
+
async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
|
|
363
|
+
const session = hub.sessionOf(ctx) || "session";
|
|
364
|
+
const { jobs, unknown } = manager.resolve(session, params?.ids);
|
|
365
|
+
const live = jobs.filter((job) => job.status === "running");
|
|
366
|
+
await manager.kill(live);
|
|
367
|
+
if (live.length === 0) {
|
|
368
|
+
const done = jobs.map((job) => `${job.id} already ${jobOutcome(job)}`);
|
|
369
|
+
return {
|
|
370
|
+
content: [{ type: "text", text: `Nothing to stop${done.length ? ` (${done.join(", ")})` : ""}${unknown.length ? `; unknown ids: ${unknown.join(", ")}` : ""}.` }],
|
|
371
|
+
details: { jobs: [] },
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return readResult(live, unknown);
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
pi.registerCommand("jobs", {
|
|
379
|
+
description: "Background shell commands of this session; /jobs kill <id|all> stops them",
|
|
380
|
+
handler: async (args: string, ctx: any) => {
|
|
381
|
+
const session = hub.sessionOf(ctx) || "session";
|
|
382
|
+
const words = String(args ?? "").trim().split(/\s+/).filter(Boolean);
|
|
383
|
+
if (words[0] === "kill") {
|
|
384
|
+
const { jobs } = manager.resolve(session, words.slice(1).length ? words.slice(1) : ["all"]);
|
|
385
|
+
const stopped = await manager.kill(jobs.filter((job) => job.status === "running"), { deliver: true });
|
|
386
|
+
ctx?.ui?.notify?.(stopped.length ? `Stopped ${stopped.map((job) => job.id).join(", ")}.` : "No running background commands.");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
ctx?.ui?.notify?.(renderJobStatus(manager.resolve(session, []).jobs));
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
}
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
} from "./pi-engine";
|
|
59
59
|
import { sharedRefresher } from "./refresh";
|
|
60
60
|
import { AGENTS_HOOK, type AgentsHook } from "./agents-lib";
|
|
61
|
+
import { BGSHELL_HOOK, type BgShellHook } from "./bgshell-lib";
|
|
61
62
|
|
|
62
63
|
/** info.agent of the root session's messages (a sub-agent's is its role). */
|
|
63
64
|
export const ROOT_AGENT = "omnirush-cli";
|
|
@@ -83,11 +84,12 @@ const SWITCH_BUDGET_MS = 30_000;
|
|
|
83
84
|
*/
|
|
84
85
|
const PRINT_EXIT_GRACE_MS = 2_000;
|
|
85
86
|
|
|
86
|
-
/** The
|
|
87
|
+
/** The agent data dir: session files live under <dir>/sessions (the launcher always sets it). */
|
|
87
88
|
function piAgentDir(): string {
|
|
88
|
-
const override = process.env.
|
|
89
|
+
const override = process.env.OMNIRUSH_CODING_AGENT_DIR || process.env.PI_CODING_AGENT_DIR || "";
|
|
89
90
|
if (override.trim()) return path.resolve(override.trim());
|
|
90
|
-
|
|
91
|
+
const stateDir = (process.env.OMNIRUSH_DIR || "").trim();
|
|
92
|
+
return path.join(stateDir ? path.resolve(stateDir) : path.join(os.homedir(), ".omnirush"), "agent");
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
/** pi session ids are UUIDs; the collector refuses anything outside its id alphabet. */
|
|
@@ -124,26 +126,68 @@ function removeLegacyJournals(stateDir: string): void {
|
|
|
124
126
|
}
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
/**
|
|
128
|
-
export
|
|
129
|
-
|
|
129
|
+
/** A sub-agent that ran on the main model instead of the picked one (spawn_agents' model_fallback). */
|
|
130
|
+
export type ChildModelFallback = { requested: string; used: string; effort: string | null; reason: string; kind: string; note: string };
|
|
131
|
+
|
|
132
|
+
/** Tools and messages that report sub-agent results after spawn_agents named them. */
|
|
133
|
+
const CHILD_RESULT_TOOLS = new Set(["agents_wait", "agents_result", "agents_cancel"]);
|
|
134
|
+
const CHILD_RESULT_MESSAGE = "omnirush-agents-result";
|
|
135
|
+
|
|
136
|
+
function modelFallbackOf(result: any): ChildModelFallback | null {
|
|
137
|
+
const fallback = result && typeof result.model_fallback === "object" ? result.model_fallback : null;
|
|
138
|
+
if (!fallback || typeof fallback.requested !== "string" || typeof fallback.used !== "string") return null;
|
|
139
|
+
return {
|
|
140
|
+
requested: fallback.requested,
|
|
141
|
+
used: fallback.used,
|
|
142
|
+
effort: typeof fallback.effort === "string" ? fallback.effort : null,
|
|
143
|
+
reason: typeof fallback.reason === "string" ? fallback.reason : "refused",
|
|
144
|
+
kind: typeof fallback.kind === "string" ? fallback.kind : "selection",
|
|
145
|
+
note: typeof fallback.note === "string" ? fallback.note : `ran on ${fallback.used}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Sub-agents a session started: its spawn_agents results (session id, role,
|
|
151
|
+
* task), with the model fallback a later result reported (a background
|
|
152
|
+
* child's delivery, agents_wait / agents_result) when there is one.
|
|
153
|
+
*/
|
|
154
|
+
export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback }> {
|
|
155
|
+
const children: Array<{ id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback }> = [];
|
|
130
156
|
for (const entry of entries) {
|
|
131
157
|
const message = entry.type === "message" ? entry.message : null;
|
|
132
|
-
|
|
133
|
-
const
|
|
158
|
+
const spawned = Boolean(message && message.role === "toolResult" && message.toolName === "spawn_agents");
|
|
159
|
+
const details = spawned
|
|
160
|
+
? message!.details
|
|
161
|
+
: message && message.role === "toolResult" && CHILD_RESULT_TOOLS.has(message.toolName)
|
|
162
|
+
? message.details
|
|
163
|
+
: entry.type === "custom_message" && (entry as any).customType === CHILD_RESULT_MESSAGE
|
|
164
|
+
? (entry as any).details
|
|
165
|
+
: null;
|
|
166
|
+
const results = details && Array.isArray(details.results) ? details.results : [];
|
|
134
167
|
for (const result of results) {
|
|
135
168
|
const id = result && typeof result.session_id === "string" ? result.session_id : "";
|
|
136
|
-
if (!sanitizeSessionId(id)
|
|
169
|
+
if (!sanitizeSessionId(id)) continue;
|
|
170
|
+
const known = children.find((child) => child.id === id);
|
|
171
|
+
const modelFallback = modelFallbackOf(result);
|
|
172
|
+
if (known) {
|
|
173
|
+
if (modelFallback) known.modelFallback = modelFallback;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (!spawned) continue;
|
|
137
177
|
children.push({
|
|
138
178
|
id,
|
|
139
179
|
role: typeof result.role === "string" ? result.role : null,
|
|
140
180
|
task: typeof result.task === "string" ? result.task : null,
|
|
181
|
+
...(modelFallback ? { modelFallback } : {}),
|
|
141
182
|
});
|
|
142
183
|
}
|
|
143
184
|
}
|
|
144
185
|
return children;
|
|
145
186
|
}
|
|
146
187
|
|
|
188
|
+
/** Fallbacks already recorded as trace events (root session : child session). */
|
|
189
|
+
const recordedFallbacks = new Set<string>();
|
|
190
|
+
|
|
147
191
|
/** The session file of `sessionId`: `<ts>_<id>.jsonl` in one of the session dirs. */
|
|
148
192
|
async function findSessionFile(sessionId: string, dirs: readonly string[]): Promise<string | null> {
|
|
149
193
|
for (const dir of dirs) {
|
|
@@ -233,13 +277,26 @@ export async function captureChildSessions(input: {
|
|
|
233
277
|
if (history.omitted > 0) {
|
|
234
278
|
input.collector.recordTrace(input.rootSessionId, "session.messages_omitted", { child_session_id: child.id, count: history.omitted });
|
|
235
279
|
}
|
|
280
|
+
const fallbackKey = `${input.rootSessionId}:${child.id}`;
|
|
281
|
+
if (child.modelFallback && !recordedFallbacks.has(fallbackKey)) {
|
|
282
|
+
// As the desktop: the sub-agent ran on the main model, and why.
|
|
283
|
+
recordedFallbacks.add(fallbackKey);
|
|
284
|
+
input.collector.recordTrace(input.rootSessionId, "subagent.model_fallback", {
|
|
285
|
+
child_session_id: child.id,
|
|
286
|
+
requested: child.modelFallback.requested,
|
|
287
|
+
used: child.modelFallback.used,
|
|
288
|
+
effort: child.modelFallback.effort,
|
|
289
|
+
reason: child.modelFallback.reason,
|
|
290
|
+
kind: child.modelFallback.kind,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
236
293
|
if (history.delta.length > 0 || !input.known.has(child.id)) {
|
|
237
294
|
input.beforeRecord(history.sizes.reduce((total, bytes) => total + bytes, 0));
|
|
238
295
|
input.collector.recordChildSession(input.rootSessionId, {
|
|
239
296
|
childSessionId: child.id,
|
|
240
297
|
parentSessionId: input.parentSessionId,
|
|
241
298
|
depth: input.depth,
|
|
242
|
-
title: child.task,
|
|
299
|
+
title: child.task && child.modelFallback ? `${child.task} (${child.modelFallback.note})` : child.task,
|
|
243
300
|
agent: turnModelFromMessages(messages)?.agent ?? agent,
|
|
244
301
|
messages: history.delta,
|
|
245
302
|
lastMessageId: typeof messages.at(-1)?.info.id === "string" ? (messages.at(-1)!.info.id as string) : null,
|
|
@@ -255,7 +312,11 @@ export default function (pi: any) {
|
|
|
255
312
|
if ((process.env.OMNIRUSH_PARENT_SESSION || "").trim()) return;
|
|
256
313
|
const origin = resolveOrigin(process.env);
|
|
257
314
|
const gatewayUrl = process.env.OMNIRUSH_GATEWAY_URL || gatewayUrlForOrigin(origin);
|
|
258
|
-
|
|
315
|
+
// The shared auth file is the source of truth (another process may have
|
|
316
|
+
// rotated since launch); the launcher's environment value is a fallback.
|
|
317
|
+
const accessToken = (
|
|
318
|
+
sharedRefresher().accessToken() || process.env.OMNIRUSH_ACCESS_TOKEN || process.env.OMNIRUSH_TOKEN || ""
|
|
319
|
+
).trim();
|
|
259
320
|
const stateDir = omniDir();
|
|
260
321
|
const agentDir = piAgentDir();
|
|
261
322
|
const version = cliVersion();
|
|
@@ -275,26 +336,47 @@ export default function (pi: any) {
|
|
|
275
336
|
// sota guard shares it): a 401 asks it for the token after `current`.
|
|
276
337
|
let current = accessToken;
|
|
277
338
|
const refreshAccessToken = async (): Promise<string | null> => {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
const next =
|
|
281
|
-
if (!refreshed || !next) return null;
|
|
339
|
+
const outcome = await sharedRefresher().recover(current).catch(() => null);
|
|
340
|
+
if (outcome?.status !== "ok") return null;
|
|
341
|
+
const next = outcome.accessToken;
|
|
282
342
|
current = next;
|
|
283
343
|
archiver.setAccessToken(next);
|
|
284
344
|
return next;
|
|
285
345
|
};
|
|
286
346
|
|
|
347
|
+
// Uploads carry the CURRENT on-disk token, not the one this process
|
|
348
|
+
// last saw: another terminal or a sub-agent may have rotated the pair.
|
|
349
|
+
// Only requests that already carry a bearer (collect, archive API) are
|
|
350
|
+
// touched — presigned S3 part PUTs pass through unchanged.
|
|
351
|
+
const withCurrentBearer = (input: string, init?: RequestInit): Promise<Response> => {
|
|
352
|
+
const headers = new Headers(init?.headers ?? {});
|
|
353
|
+
if (/^Bearer /i.test(headers.get("authorization") ?? "")) {
|
|
354
|
+
let latest = "";
|
|
355
|
+
try {
|
|
356
|
+
latest = sharedRefresher().accessToken();
|
|
357
|
+
} catch {
|
|
358
|
+
/* keep the caller's bearer */
|
|
359
|
+
}
|
|
360
|
+
if (latest) {
|
|
361
|
+
headers.set("Authorization", `Bearer ${latest}`);
|
|
362
|
+
return globalThis.fetch(input, { ...init, headers });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return globalThis.fetch(input, init);
|
|
366
|
+
};
|
|
367
|
+
|
|
287
368
|
const collector = new WorkspaceCollector({
|
|
288
369
|
gatewayUrl,
|
|
289
370
|
accessToken,
|
|
290
371
|
refreshAccessToken,
|
|
372
|
+
fetch: withCurrentBearer,
|
|
291
373
|
stateDir,
|
|
292
374
|
log,
|
|
293
375
|
// environment.app_version tells the CLI apart from the desktop app (whose
|
|
294
376
|
// value is a bare release number); client_version / client_platform are
|
|
295
377
|
// the backend's optional envelope metadata.
|
|
296
378
|
appVersion: version ? `omnirush-cli/${version}` : "omnirush-cli",
|
|
297
|
-
...(engineVersion ? { engineVersion: `
|
|
379
|
+
...(engineVersion ? { engineVersion: `omnirush-core/${engineVersion}` } : {}),
|
|
298
380
|
envelopeMetadata: {
|
|
299
381
|
...(version ? { client_version: version } : {}),
|
|
300
382
|
client_platform: `${process.platform}/${process.arch}`,
|
|
@@ -309,6 +391,7 @@ export default function (pi: any) {
|
|
|
309
391
|
gatewayUrl,
|
|
310
392
|
accessToken,
|
|
311
393
|
refreshAccessToken,
|
|
394
|
+
fetch: withCurrentBearer,
|
|
312
395
|
// The CLI's own state (auth, spool, archive queue) and the agent's
|
|
313
396
|
// session files are never archived when they sit inside a project.
|
|
314
397
|
excludedDirs: [stateDir, agentDir],
|
|
@@ -501,6 +584,10 @@ export default function (pi: any) {
|
|
|
501
584
|
const hook = (globalThis as any)[AGENTS_HOOK] as AgentsHook | undefined;
|
|
502
585
|
const followed = session;
|
|
503
586
|
const interrupted = followed?.started && hook ? await hook.interruptAll(followed.id).catch(() => []) : [];
|
|
587
|
+
// Background shell commands still running are stopped too; their output
|
|
588
|
+
// is added to the session as a message, so the last capture keeps it.
|
|
589
|
+
const shell = (globalThis as any)[BGSHELL_HOOK] as BgShellHook | undefined;
|
|
590
|
+
if (followed?.started && shell) await shell.stopAll(followed.id).catch(() => []);
|
|
504
591
|
await settleRunning("interrupted");
|
|
505
592
|
const state = session;
|
|
506
593
|
if (!state?.started) return;
|
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { readSessionLedger, SESSION_LEDGER_FILE } from "./capture/workspace-collector";
|
|
21
|
-
import { deviceMe, omniDir, resolveOrigin } from "./auth";
|
|
21
|
+
import { deviceMe, omniDir, resolveOrigin, sessionEndedMessage } from "./auth";
|
|
22
22
|
import { sharedRefresher } from "./refresh";
|
|
23
23
|
import { formatCollectorLine, formatStatusLines } from "./status-lib";
|
|
24
|
+
import { formatModeStatus } from "./yolo-lib";
|
|
24
25
|
import {
|
|
25
26
|
formatDeviceLines,
|
|
26
27
|
formatGrantLines,
|
|
@@ -42,27 +43,24 @@ interface MeResult {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
* Fetch the signed-in identity + server-side usage with the
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* Fetch the signed-in identity + server-side usage with the current
|
|
47
|
+
* on-disk token; a 401 adopts a newer on-disk pair or refreshes under the
|
|
48
|
+
* cross-process lock and retries (auth.js). Never throws; errors come back
|
|
49
|
+
* as `error` strings safe to display.
|
|
49
50
|
*/
|
|
50
51
|
async function fetchMe(): Promise<MeResult> {
|
|
51
52
|
const origin = resolveOrigin(process.env);
|
|
52
53
|
const refresher = sharedRefresher();
|
|
53
|
-
|
|
54
|
-
// in-flight 401 recovery — and fall back to the launcher-provided one.
|
|
55
|
-
const token = refresher.auth?.accessToken || (process.env.OMNIRUSH_TOKEN || "").trim();
|
|
54
|
+
let token = refresher.accessToken() || (process.env.OMNIRUSH_TOKEN || "").trim();
|
|
56
55
|
if (!token) return { me: null, error: "not signed in — run `omnirush login` first" };
|
|
57
56
|
try {
|
|
58
|
-
let
|
|
59
|
-
|
|
60
|
-
// 401: single-flight refresh, retry once with the rotated token.
|
|
61
|
-
const refreshed = await refresher.refresh(token).catch(() => false);
|
|
62
|
-
const next = refresher.auth?.accessToken;
|
|
63
|
-
if (refreshed && next && next !== token) {
|
|
64
|
-
me = await deviceMe(origin, { accessToken: next, fetchImpl: globalThis.fetch });
|
|
57
|
+
for (let round = 0; round < 3; round++) {
|
|
58
|
+
const me = await deviceMe(origin, { accessToken: token, fetchImpl: globalThis.fetch });
|
|
65
59
|
if (me !== null) return { me };
|
|
60
|
+
const outcome = await refresher.recover(token);
|
|
61
|
+
if (outcome.status === "transient") return { me: null, error: `manager unreachable (${outcome.error})` };
|
|
62
|
+
if (outcome.status !== "ok") return { me: null, error: sessionEndedMessage(outcome).replace(/^Omnirush: /, "") };
|
|
63
|
+
token = outcome.accessToken;
|
|
66
64
|
}
|
|
67
65
|
return { me: null, error: "credentials rejected — run `omnirush login` again" };
|
|
68
66
|
} catch (error) {
|
|
@@ -156,6 +154,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
156
154
|
collector: { sessionId, record },
|
|
157
155
|
origin: resolveOrigin(process.env),
|
|
158
156
|
version: CLI_VERSION,
|
|
157
|
+
yolo: formatModeStatus(),
|
|
159
158
|
});
|
|
160
159
|
notify(ctx, ["Omnirush status:", ...statusLines.map((line) => ` ${line}`)].join("\n"));
|
|
161
160
|
},
|