omnirush 0.8.6 → 0.9.1
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 +120 -0
- package/assets/extensions/omnirush/agents-lib.ts +501 -63
- package/assets/extensions/omnirush/agents.ts +140 -115
- package/assets/extensions/omnirush/bgshell-lib.ts +432 -0
- package/assets/extensions/omnirush/bgshell.ts +392 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +86 -22
- package/assets/extensions/omnirush/collector.ts +275 -43
- package/assets/extensions/omnirush/commands.ts +2 -0
- 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 +32 -6
- package/assets/extensions/omnirush/mcp.ts +103 -8
- package/assets/extensions/omnirush/memory-lib.ts +516 -0
- package/assets/extensions/omnirush/pi-engine.ts +115 -3
- package/assets/extensions/omnirush/plan-lib.ts +38 -9
- package/assets/extensions/omnirush/plan.ts +85 -30
- package/assets/extensions/omnirush/sota.ts +52 -4
- package/assets/extensions/omnirush/status-lib.ts +3 -0
- package/assets/extensions/omnirush/subagent-marker.ts +21 -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 +7 -3
- package/scripts/brand-engine.js +526 -0
- package/scripts/build-all-packages.py +29 -1
- package/scripts/smoke-packages.py +33 -1
- package/src/bin.js +232 -33
- package/src/compat.js +272 -0
- package/src/lib.js +64 -0
- package/src/sessions.js +222 -0
- package/scripts/patch-pi-branding.js +0 -251
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
//
|
|
18
18
|
// user message -> user message: text parts, image file parts
|
|
19
19
|
// (bytes not embedded, as the desktop's
|
|
20
|
-
// engine.request records attachments)
|
|
20
|
+
// engine.request records attachments); one
|
|
21
|
+
// the user did not submit (a sub-agent's task,
|
|
22
|
+
// an extension's sendUserMessage: the caller's
|
|
23
|
+
// `injectedPrompts`) has synthetic text parts,
|
|
24
|
+
// so it never becomes a turn's prompt
|
|
21
25
|
// assistant message -> assistant message: step-start, reasoning
|
|
22
26
|
// (the model's thinking), text and tool parts
|
|
23
27
|
// in the model's order, a patch part for the
|
|
@@ -33,6 +37,10 @@
|
|
|
33
37
|
// a command the user ran)
|
|
34
38
|
// custom / branch summary -> user message with synthetic text parts (what
|
|
35
39
|
// the model was shown; never a turn opener)
|
|
40
|
+
// background command -> the delivery message as above, answered by an
|
|
41
|
+
// results (bgshell.ts) assistant message holding one bash tool part
|
|
42
|
+
// per finished command (its output and exit
|
|
43
|
+
// code; no model step, no tokens)
|
|
36
44
|
// compaction -> user message with a compaction part and the
|
|
37
45
|
// summary as an assistant `summary` message
|
|
38
46
|
// system prompt -> not a message (the desktop records it in
|
|
@@ -49,6 +57,17 @@
|
|
|
49
57
|
import { createHash } from "node:crypto";
|
|
50
58
|
import { isAbsolute, resolve } from "node:path";
|
|
51
59
|
|
|
60
|
+
/** customType of bgshell.ts's finished-background-command message. */
|
|
61
|
+
export const BACKGROUND_BASH_TYPE = "omnirush-bash-result";
|
|
62
|
+
/**
|
|
63
|
+
* customType of the entry a sub-agent process writes at the start of its
|
|
64
|
+
* own session (data: `{parent_session_id}`): the session is a sub-agent's,
|
|
65
|
+
* and its first user message is the task the parent agent wrote.
|
|
66
|
+
*/
|
|
67
|
+
export const SUBAGENT_ENTRY_TYPE = "omnirush-subagent";
|
|
68
|
+
/** A sub-agent's prompt: the task the parent agent wrote, after this prefix (agents-lib.ts buildChildArgs). */
|
|
69
|
+
export const SUBAGENT_TASK_PREFIX = "Task: ";
|
|
70
|
+
|
|
52
71
|
export type EngineMessage = { info: Record<string, unknown>; parts: Array<Record<string, unknown>> };
|
|
53
72
|
|
|
54
73
|
/** One pi session entry (loosely typed: the files are parsed without validation). */
|
|
@@ -80,6 +99,13 @@ export type EngineConvertOptions = {
|
|
|
80
99
|
* `--model <id>:<effort>` flag of a resumed session.
|
|
81
100
|
*/
|
|
82
101
|
promptSettings?: ReadonlyMap<string, { model?: { providerID: string; modelID: string } | null; variant?: string | null }>;
|
|
102
|
+
/**
|
|
103
|
+
* User entries (by id) whose text the user did not submit, with the
|
|
104
|
+
* metadata their synthetic text parts carry: the task a parent agent gave
|
|
105
|
+
* a sub-agent whose session is continued as a main session, a prompt an
|
|
106
|
+
* extension sent. The viewer never takes them for a prompt.
|
|
107
|
+
*/
|
|
108
|
+
injectedPrompts?: ReadonlyMap<string, Record<string, unknown>>;
|
|
83
109
|
};
|
|
84
110
|
|
|
85
111
|
/** The shell-command marker the engine writes on a user message holding a command the user ran (public_trace.py `_USER_SHELL_TEXT`). */
|
|
@@ -295,6 +321,60 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
|
|
|
295
321
|
return messageVariant ?? null;
|
|
296
322
|
};
|
|
297
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Finished background shell commands (bgshell.ts delivery): their output
|
|
326
|
+
* as bash tool parts, the way the engine records a command the user ran.
|
|
327
|
+
*/
|
|
328
|
+
const backgroundBash = (parentId: string, entryId: string, details: unknown, at: number | null, index: number): void => {
|
|
329
|
+
const jobs = isRecord(details) && Array.isArray(details.jobs) ? details.jobs.filter(isRecord) : [];
|
|
330
|
+
if (jobs.length === 0) return;
|
|
331
|
+
const shellId = engineMessageId(entryId, "_bash");
|
|
332
|
+
const shellModel = model ?? nextAssistantModel.get(index) ?? null;
|
|
333
|
+
const time = at ?? 0;
|
|
334
|
+
const parts: Array<Record<string, unknown>> = jobs.map((job, jobIndex) => {
|
|
335
|
+
const start = typeof job.started_at === "number" ? job.started_at : time;
|
|
336
|
+
const end = typeof job.finished_at === "number" ? job.finished_at : time;
|
|
337
|
+
const failed = job.status === "failed";
|
|
338
|
+
return {
|
|
339
|
+
type: "tool",
|
|
340
|
+
callID: `bg_${typeof job.id === "string" ? job.id : jobIndex}_${entryId}`,
|
|
341
|
+
tool: "bash",
|
|
342
|
+
state: {
|
|
343
|
+
status: failed ? "error" : "completed",
|
|
344
|
+
input: { command: typeof job.command === "string" ? job.command : "", background_id: job.id ?? null },
|
|
345
|
+
...(failed ? { error: typeof job.error === "string" ? job.error : "failed" } : {}),
|
|
346
|
+
output: typeof job.output === "string" ? job.output : "",
|
|
347
|
+
metadata: {
|
|
348
|
+
background: true,
|
|
349
|
+
...(typeof job.exit_code === "number" ? { exit: job.exit_code } : {}),
|
|
350
|
+
...(typeof job.status === "string" ? { status: job.status } : {}),
|
|
351
|
+
...(job.interrupted === true ? { interrupted: true } : {}),
|
|
352
|
+
...(job.truncated === true ? { truncated: true } : {}),
|
|
353
|
+
...(typeof job.tool_call_id === "string" ? { startedBy: job.tool_call_id } : {}),
|
|
354
|
+
},
|
|
355
|
+
time: { start, end },
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
out.push({
|
|
360
|
+
info: {
|
|
361
|
+
id: shellId,
|
|
362
|
+
sessionID: sessionId,
|
|
363
|
+
role: "assistant",
|
|
364
|
+
parentID: parentId,
|
|
365
|
+
time: { created: time, completed: time },
|
|
366
|
+
...(shellModel ? { modelID: shellModel.modelID, providerID: shellModel.providerID } : {}),
|
|
367
|
+
agent,
|
|
368
|
+
mode: agent,
|
|
369
|
+
path: { cwd, root: cwd },
|
|
370
|
+
cost: 0,
|
|
371
|
+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
372
|
+
finish: "stop",
|
|
373
|
+
},
|
|
374
|
+
parts: parts.map((part, partIndex) => ({ id: partId(shellId, partIndex), sessionID: sessionId, messageID: shellId, ...part })),
|
|
375
|
+
});
|
|
376
|
+
};
|
|
377
|
+
|
|
298
378
|
entries.forEach((entry, index) => {
|
|
299
379
|
const entryId = typeof entry.id === "string" && entry.id ? entry.id : null;
|
|
300
380
|
if (!entryId) return;
|
|
@@ -340,7 +420,9 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
|
|
|
340
420
|
}
|
|
341
421
|
if (entry.type === "custom_message") {
|
|
342
422
|
const parts = contentParts(entry.content, { synthetic: true, metadata: { customType: entry.customType ?? null } });
|
|
343
|
-
|
|
423
|
+
const id = engineMessageId(entryId);
|
|
424
|
+
if (parts.length > 0) userMessage(id, at, parts, index);
|
|
425
|
+
if (entry.customType === BACKGROUND_BASH_TYPE) backgroundBash(id, entryId, (entry as any).details, at, index);
|
|
344
426
|
return;
|
|
345
427
|
}
|
|
346
428
|
if (entry.type !== "message" || !isRecord(entry.message)) return;
|
|
@@ -349,13 +431,15 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
|
|
|
349
431
|
const created = epochMs(message.timestamp) ?? at;
|
|
350
432
|
switch (message.role) {
|
|
351
433
|
case "user": {
|
|
352
|
-
const
|
|
434
|
+
const injected = options.injectedPrompts?.get(entryId);
|
|
435
|
+
const parts = contentParts(message.content, injected ? { synthetic: true, metadata: { ...injected } } : {});
|
|
353
436
|
if (parts.length > 0) turnVariant = userMessage(id, created, parts, index);
|
|
354
437
|
return;
|
|
355
438
|
}
|
|
356
439
|
case "custom": {
|
|
357
440
|
const parts = contentParts(message.content, { synthetic: true, metadata: { customType: message.customType ?? null } });
|
|
358
441
|
if (parts.length > 0) userMessage(id, created, parts, index);
|
|
442
|
+
if (message.customType === BACKGROUND_BASH_TYPE) backgroundBash(id, entryId, message.details, created, index);
|
|
359
443
|
return;
|
|
360
444
|
}
|
|
361
445
|
case "branchSummary":
|
|
@@ -509,6 +593,34 @@ export function engineMessagesFromEntries(entries: readonly PiEntry[], options:
|
|
|
509
593
|
return out;
|
|
510
594
|
}
|
|
511
595
|
|
|
596
|
+
/** The text of a pi user message (its text blocks). */
|
|
597
|
+
export function userText(message: Record<string, any> | undefined): string {
|
|
598
|
+
return message ? contentText(message.content) : "";
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* The first user message of a branch when it may be a sub-agent's task
|
|
603
|
+
* (the session is a sub-agent's, opened as a main session by `--continue`,
|
|
604
|
+
* `--resume` or `--session`): `marked` when the sub-agent's own marker
|
|
605
|
+
* entry comes before it (then `parentSessionId` is known), else when its
|
|
606
|
+
* text has the task prefix, for the caller to confirm (sessions of
|
|
607
|
+
* sub-agents that wrote no marker). Null for any other session.
|
|
608
|
+
*/
|
|
609
|
+
export function subagentTask(entries: readonly PiEntry[]): { entryId: string; marked: boolean; parentSessionId: string | null } | null {
|
|
610
|
+
let marker: PiEntry | null = null;
|
|
611
|
+
for (const entry of entries) {
|
|
612
|
+
if (entry.type === "custom" && entry.customType === SUBAGENT_ENTRY_TYPE) marker ??= entry;
|
|
613
|
+
if (entry.type !== "message" || !isRecord(entry.message) || entry.message.role !== "user") continue;
|
|
614
|
+
if (typeof entry.id !== "string" || !entry.id) return null;
|
|
615
|
+
if (marker) {
|
|
616
|
+
const parent = isRecord(marker.data) && typeof marker.data.parent_session_id === "string" ? marker.data.parent_session_id : null;
|
|
617
|
+
return { entryId: entry.id, marked: true, parentSessionId: parent };
|
|
618
|
+
}
|
|
619
|
+
return userText(entry.message).startsWith(SUBAGENT_TASK_PREFIX) ? { entryId: entry.id, marked: false, parentSessionId: null } : null;
|
|
620
|
+
}
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
|
|
512
624
|
/** The active branch of a pi session file's entries: from the last entry up its parent chain. */
|
|
513
625
|
export function branchOfEntries(entries: readonly PiEntry[]): PiEntry[] {
|
|
514
626
|
const withIds = entries.filter((entry) => entry.type !== "session" && typeof entry.id === "string");
|
|
@@ -16,6 +16,9 @@ export interface PlanFile {
|
|
|
16
16
|
schema_version: 1;
|
|
17
17
|
items: PlanItem[];
|
|
18
18
|
updated_at: string;
|
|
19
|
+
/** The session that wrote it (a new session in the same folder does
|
|
20
|
+
* not pick up another session's list). Absent in older files. */
|
|
21
|
+
session_id?: string;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export const PLAN_MAX_ITEMS = 50;
|
|
@@ -66,19 +69,44 @@ function clip(text: string, max: number): string {
|
|
|
66
69
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/** Non-plan tool calls after which an untouched list reads as stale. */
|
|
73
|
+
export const PLAN_STALE_TOOL_CALLS = 25;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How current the list is, for the footer:
|
|
77
|
+
* - "active": updated during the running request,
|
|
78
|
+
* - "idle": no request running, or the list is left over from an
|
|
79
|
+
* earlier request (restored on resume / not touched since).
|
|
80
|
+
*/
|
|
81
|
+
export type PlanFooterPhase = "active" | "idle";
|
|
82
|
+
|
|
83
|
+
export interface PlanFooterOptions {
|
|
84
|
+
phase?: PlanFooterPhase;
|
|
85
|
+
/** Non-plan tool calls since the last plan update. */
|
|
86
|
+
toolCallsSinceUpdate?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
/**
|
|
70
|
-
* One-line footer for ctx.ui.setStatus
|
|
71
|
-
*
|
|
72
|
-
*
|
|
90
|
+
* One-line footer for ctx.ui.setStatus. It reads as a checklist (never
|
|
91
|
+
* as a mode — the footer's status line also carries "guarded"/"yolo"
|
|
92
|
+
* right before it), e.g.
|
|
93
|
+
* "tasks 2/5 · ▸ wire tests" (being worked on)
|
|
94
|
+
* "tasks 2/5 · not updated for 30 tool calls" (stale while working)
|
|
95
|
+
* "tasks 2/5 · idle" (between requests)
|
|
96
|
+
* Null when there is nothing to show — including a finished list.
|
|
73
97
|
*/
|
|
74
|
-
export function renderPlanFooter(items: PlanItem[]): string | null {
|
|
98
|
+
export function renderPlanFooter(items: PlanItem[] | null, options: PlanFooterOptions = {}): string | null {
|
|
75
99
|
if (!Array.isArray(items) || items.length === 0) return null;
|
|
76
100
|
const { done, total, current } = planProgress(items);
|
|
101
|
+
if (done === total) return null;
|
|
102
|
+
const head = `tasks ${done}/${total}`;
|
|
103
|
+
if ((options.phase ?? "active") === "idle") return `${head} · idle`;
|
|
104
|
+
const since = options.toolCallsSinceUpdate ?? 0;
|
|
105
|
+
if (since >= PLAN_STALE_TOOL_CALLS) return `${head} · not updated for ${since} tool calls`;
|
|
77
106
|
// The ▸ task is the in-progress one, falling back to the first
|
|
78
107
|
// not-done item (what is next up).
|
|
79
108
|
const pointed = current ?? items.find((item) => item.status !== "done")?.title ?? null;
|
|
80
|
-
|
|
81
|
-
return `plan ${done}/${total} done`;
|
|
109
|
+
return pointed ? `${head} · ▸ ${clip(pointed, 60)}` : head;
|
|
82
110
|
}
|
|
83
111
|
|
|
84
112
|
/**
|
|
@@ -94,8 +122,8 @@ export function renderPlanItems(items: PlanItem[]): string {
|
|
|
94
122
|
return `${lines.join("\n")}\n\n${done}/${total} done`;
|
|
95
123
|
}
|
|
96
124
|
|
|
97
|
-
export function buildPlanFile(items: PlanItem[], updatedAt: string): PlanFile {
|
|
98
|
-
return { schema_version: 1, items, updated_at: updatedAt };
|
|
125
|
+
export function buildPlanFile(items: PlanItem[], updatedAt: string, sessionId?: string): PlanFile {
|
|
126
|
+
return { schema_version: 1, items, updated_at: updatedAt, ...(sessionId ? { session_id: sessionId } : {}) };
|
|
99
127
|
}
|
|
100
128
|
|
|
101
129
|
/** Parse a persisted plan.json, returning null on anything unusable. */
|
|
@@ -106,7 +134,8 @@ export function parsePlanFile(text: string): PlanFile | null {
|
|
|
106
134
|
const items = normalizePlanItems(parsed.items);
|
|
107
135
|
if (!items) return null;
|
|
108
136
|
const updatedAt = typeof parsed.updated_at === "string" ? parsed.updated_at : "";
|
|
109
|
-
|
|
137
|
+
const sessionId = typeof parsed.session_id === "string" && parsed.session_id ? parsed.session_id : undefined;
|
|
138
|
+
return { schema_version: 1, items, updated_at: updatedAt, ...(sessionId ? { session_id: sessionId } : {}) };
|
|
110
139
|
} catch {
|
|
111
140
|
return null;
|
|
112
141
|
}
|
|
@@ -7,9 +7,16 @@
|
|
|
7
7
|
// - appends an `omnirush.plan` custom entry to the session JSONL (the
|
|
8
8
|
// tool call itself, with the whole list, reaches the trace as a tool
|
|
9
9
|
// part of the session's engine messages),
|
|
10
|
-
// - renders one line in the footer via ctx.ui.setStatus
|
|
10
|
+
// - renders one line in the footer via ctx.ui.setStatus ("tasks 1/3 ·
|
|
11
|
+
// ▸ …": a checklist, never a mode), marked idle between requests and
|
|
12
|
+
// stale when the agent keeps working without updating it; a finished
|
|
13
|
+
// list clears,
|
|
11
14
|
// - returns the full list as the tool result.
|
|
12
15
|
//
|
|
16
|
+
// A sub-agent (OMNIRUSH_PARENT_SESSION set) runs in its parent's folder:
|
|
17
|
+
// it keeps its list in its own session only, so it never overwrites the
|
|
18
|
+
// parent's plan.json.
|
|
19
|
+
//
|
|
13
20
|
// Full-replace semantics (like codex update_plan): every call sends
|
|
14
21
|
// the WHOLE list with current statuses.
|
|
15
22
|
|
|
@@ -27,6 +34,7 @@ import {
|
|
|
27
34
|
planProgress,
|
|
28
35
|
renderPlanFooter,
|
|
29
36
|
renderPlanItems,
|
|
37
|
+
type PlanFooterPhase,
|
|
30
38
|
type PlanItem,
|
|
31
39
|
} from "./plan-lib";
|
|
32
40
|
|
|
@@ -42,7 +50,7 @@ const PlanParams = Type.Object({
|
|
|
42
50
|
Type.Object({
|
|
43
51
|
title: Type.String({ description: "Short task title (imperative, e.g. 'add failing test first')" }),
|
|
44
52
|
status: Type.Optional(StringEnum(["pending", "in-progress", "done"] as const, {
|
|
45
|
-
description: "Task status; defaults to pending. Exactly one item should be in-progress while working.",
|
|
53
|
+
description: "Task status; defaults to pending. Exactly one item should be in-progress while working; mark it done as soon as it is finished.",
|
|
46
54
|
})),
|
|
47
55
|
}),
|
|
48
56
|
{ description: "The FULL ordered task list with current statuses (replaces the previous plan)" },
|
|
@@ -50,10 +58,21 @@ const PlanParams = Type.Object({
|
|
|
50
58
|
});
|
|
51
59
|
|
|
52
60
|
export default function (pi: ExtensionAPI) {
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
// Footer state: the latest list, whether it was updated during the
|
|
62
|
+
// running request ("active") or is left over ("idle"), and how many
|
|
63
|
+
// other tool calls ran since its last update.
|
|
64
|
+
let items: PlanItem[] | null = null;
|
|
65
|
+
let phase: PlanFooterPhase = "idle";
|
|
66
|
+
let toolCallsSinceUpdate = 0;
|
|
67
|
+
let lastLine: string | null | undefined;
|
|
68
|
+
let lastCtx: any = null;
|
|
69
|
+
|
|
70
|
+
const showFooter = (ctx: any = lastCtx) => {
|
|
71
|
+
if (ctx) lastCtx = ctx;
|
|
72
|
+
const line = renderPlanFooter(items, { phase, toolCallsSinceUpdate });
|
|
73
|
+
if (line === lastLine) return;
|
|
74
|
+
lastLine = line;
|
|
55
75
|
try {
|
|
56
|
-
const line = items ? renderPlanFooter(items) : null;
|
|
57
76
|
ctx?.ui?.setStatus?.(PLAN_STATUS_KEY, line ?? undefined);
|
|
58
77
|
} catch {
|
|
59
78
|
/* non-TUI host */
|
|
@@ -62,55 +81,84 @@ export default function (pi: ExtensionAPI) {
|
|
|
62
81
|
|
|
63
82
|
pi.registerTool({
|
|
64
83
|
name: PLAN_TOOL_NAME,
|
|
65
|
-
label: "
|
|
84
|
+
label: "Tasks",
|
|
66
85
|
description: [
|
|
67
|
-
"Maintain the ordered task list for the current
|
|
68
|
-
"Send the FULL list with current statuses every time
|
|
69
|
-
"
|
|
86
|
+
"Maintain the ordered task list (todo list) for the current request.",
|
|
87
|
+
"Send the FULL list with current statuses every time.",
|
|
88
|
+
"Keep it current while you work: mark an item in-progress when you start it and done as soon as it is finished (not all at the end), and send the final list when the work is complete.",
|
|
89
|
+
"The user sees the list's progress in the footer.",
|
|
70
90
|
].join(" "),
|
|
71
91
|
promptSnippet: "plan: maintain an ordered task list (todo) with statuses",
|
|
72
92
|
promptGuidelines: [
|
|
73
|
-
"Use plan for multi-step work (3+ steps or user-requested tracking): send the full task list before starting, mark
|
|
93
|
+
"Use plan for multi-step work (3+ steps or user-requested tracking): send the full task list before starting, then update it as you go — mark each item done when you finish it and the next one in-progress, keeping exactly one item in-progress.",
|
|
94
|
+
"Before you end your reply, send the final list (every finished item done); drop items you decided not to do.",
|
|
74
95
|
"Do not use plan for single trivial actions.",
|
|
75
96
|
],
|
|
76
97
|
parameters: PlanParams,
|
|
77
98
|
|
|
78
99
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
79
|
-
const
|
|
80
|
-
if (!
|
|
100
|
+
const next = normalizePlanItems(params.items);
|
|
101
|
+
if (!next) {
|
|
81
102
|
throw new Error(
|
|
82
103
|
"invalid plan items: provide a non-empty array of {title, status} objects (status pending | in-progress | done)",
|
|
83
104
|
);
|
|
84
105
|
}
|
|
85
106
|
const updatedAt = new Date().toISOString();
|
|
86
107
|
const cwd = ctx?.cwd ? resolve(ctx.cwd) : process.cwd();
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
108
|
+
if (!isSubagent()) {
|
|
109
|
+
try {
|
|
110
|
+
const planPath = join(cwd, AGENT_DIR, "plan.json");
|
|
111
|
+
const sessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "") || undefined;
|
|
112
|
+
await mkdir(dirname(planPath), { recursive: true });
|
|
113
|
+
await writeFile(planPath, JSON.stringify(buildPlanFile(next, updatedAt, sessionId), null, 2) + "\n", "utf8");
|
|
114
|
+
} catch {
|
|
115
|
+
// Persistence is best effort: the session entry + footer still
|
|
116
|
+
// carry the plan (and the tool result returns it).
|
|
117
|
+
}
|
|
94
118
|
}
|
|
95
119
|
try {
|
|
96
|
-
pi.appendEntry(PLAN_ENTRY_TYPE, { items, updated_at: updatedAt });
|
|
120
|
+
pi.appendEntry(PLAN_ENTRY_TYPE, { items: next, updated_at: updatedAt });
|
|
97
121
|
} catch {
|
|
98
122
|
/* sessions may be unavailable in some hosts */
|
|
99
123
|
}
|
|
100
|
-
|
|
101
|
-
|
|
124
|
+
items = next;
|
|
125
|
+
phase = "active";
|
|
126
|
+
toolCallsSinceUpdate = 0;
|
|
127
|
+
showFooter(ctx);
|
|
128
|
+
const { done, total } = planProgress(next);
|
|
129
|
+
const reminder = done < total
|
|
130
|
+
? "\nKeep this list current: mark each item done as soon as it is finished and the next one in-progress."
|
|
131
|
+
: "";
|
|
102
132
|
return {
|
|
103
|
-
content: [{ type: "text", text: renderPlanItems(
|
|
104
|
-
details: { done, total, items },
|
|
133
|
+
content: [{ type: "text", text: renderPlanItems(next) + reminder }],
|
|
134
|
+
details: { done, total, items: next },
|
|
105
135
|
};
|
|
106
136
|
},
|
|
107
137
|
});
|
|
108
138
|
|
|
109
|
-
//
|
|
110
|
-
//
|
|
139
|
+
// Other tool calls age the list: after enough of them without an
|
|
140
|
+
// update the footer stops pointing at a task as if it were current.
|
|
141
|
+
pi.on("tool_execution_end", (event: any, ctx: any) => {
|
|
142
|
+
if (!items || event?.toolName === PLAN_TOOL_NAME) return;
|
|
143
|
+
toolCallsSinceUpdate += 1;
|
|
144
|
+
showFooter(ctx);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// The request is over: an unfinished list is shown as idle (a finished
|
|
148
|
+
// one is already cleared) until the model updates it again.
|
|
149
|
+
pi.on("agent_end", (_event: any, ctx: any) => {
|
|
150
|
+
phase = "idle";
|
|
151
|
+
showFooter(ctx);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Restore after reload/resume from THIS session's latest plan entry
|
|
155
|
+
// (a new session starts with no list, even when the folder still holds
|
|
156
|
+
// another session's plan.json). Restored lists are idle.
|
|
111
157
|
pi.on("session_start", async (_event: any, ctx: any) => {
|
|
158
|
+
items = null;
|
|
159
|
+
phase = "idle";
|
|
160
|
+
toolCallsSinceUpdate = 0;
|
|
112
161
|
try {
|
|
113
|
-
let items: PlanItem[] | null = null;
|
|
114
162
|
const entries = ctx?.sessionManager?.getEntries?.() ?? [];
|
|
115
163
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
116
164
|
const entry = entries[index];
|
|
@@ -119,17 +167,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
119
167
|
if (items) break;
|
|
120
168
|
}
|
|
121
169
|
}
|
|
122
|
-
|
|
170
|
+
const sessionId = String(ctx?.sessionManager?.getSessionId?.() ?? "");
|
|
171
|
+
if (!items && sessionId && ctx?.cwd) {
|
|
123
172
|
try {
|
|
124
173
|
const text = await readFile(join(resolve(ctx.cwd), AGENT_DIR, "plan.json"), "utf8");
|
|
125
|
-
|
|
174
|
+
const file = parsePlanFile(text);
|
|
175
|
+
if (file?.session_id === sessionId) items = file.items;
|
|
126
176
|
} catch {
|
|
127
177
|
/* no persisted plan */
|
|
128
178
|
}
|
|
129
179
|
}
|
|
130
|
-
showFooter(items, ctx);
|
|
131
180
|
} catch {
|
|
132
181
|
/* footer is cosmetic */
|
|
133
182
|
}
|
|
183
|
+
lastLine = undefined;
|
|
184
|
+
showFooter(ctx);
|
|
134
185
|
});
|
|
135
186
|
}
|
|
187
|
+
|
|
188
|
+
function isSubagent(): boolean {
|
|
189
|
+
return Boolean((process.env.OMNIRUSH_PARENT_SESSION || "").trim());
|
|
190
|
+
}
|
|
@@ -41,6 +41,14 @@ import {
|
|
|
41
41
|
streamIdleMs,
|
|
42
42
|
withStallWatchdog,
|
|
43
43
|
} from "./sota-lib";
|
|
44
|
+
import { omniDir } from "./auth";
|
|
45
|
+
import {
|
|
46
|
+
ENV_FALLBACK_EFFORT,
|
|
47
|
+
ENV_FALLBACK_MODEL,
|
|
48
|
+
FALLBACK_MARKER,
|
|
49
|
+
subagentFallbackFetch,
|
|
50
|
+
type FallbackRoute,
|
|
51
|
+
} from "./subagents-lib";
|
|
44
52
|
import {
|
|
45
53
|
formatUnavailableSummary,
|
|
46
54
|
isRetryableStatus,
|
|
@@ -222,7 +230,7 @@ async function readErrorBody(response: any): Promise<string> {
|
|
|
222
230
|
*/
|
|
223
231
|
function tapFetch(
|
|
224
232
|
baseFetch: any,
|
|
225
|
-
|
|
233
|
+
expectedModel: string | (() => string),
|
|
226
234
|
onViolation: (expected: string, served: string) => void,
|
|
227
235
|
idleMs: number,
|
|
228
236
|
): any {
|
|
@@ -356,6 +364,8 @@ function tapFetch(
|
|
|
356
364
|
const onData = (data: string) => {
|
|
357
365
|
if (reported) return;
|
|
358
366
|
const served = servedModelFromSseData(data);
|
|
367
|
+
// A sub-agent moved to the main model expects that one.
|
|
368
|
+
const expectedModelId = typeof expectedModel === "function" ? expectedModel() : expectedModel;
|
|
359
369
|
if (served === undefined || !isSotaViolation(expectedModelId, served)) return;
|
|
360
370
|
reported = true;
|
|
361
371
|
onViolation(expectedModelId, served);
|
|
@@ -390,22 +400,60 @@ export default function (pi: any) {
|
|
|
390
400
|
const responses = openAIResponsesApi();
|
|
391
401
|
const strictSota = (): boolean => pi.getFlag("strict-sota") === true;
|
|
392
402
|
|
|
403
|
+
// A sub-agent on a picked model: the main model it falls back to when the
|
|
404
|
+
// gateway refuses the picked one (set by the parent's spawn_agents).
|
|
405
|
+
const fallbackModel = (process.env[ENV_FALLBACK_MODEL] || "").trim();
|
|
406
|
+
const fallbackEffort = (process.env[ENV_FALLBACK_EFFORT] || "").trim() || null;
|
|
407
|
+
|
|
393
408
|
const streamWith =
|
|
394
409
|
(simple: boolean) =>
|
|
395
410
|
(model: any, context: any, options: any) => {
|
|
396
|
-
|
|
411
|
+
let baseFetch = options?.fetch ?? globalThis.fetch;
|
|
412
|
+
const route: FallbackRoute = { expected: model.id, movedTo: null };
|
|
413
|
+
if (fallbackModel && fallbackModel !== model.id) {
|
|
414
|
+
baseFetch = subagentFallbackFetch(baseFetch, {
|
|
415
|
+
model: fallbackModel,
|
|
416
|
+
effort: fallbackEffort,
|
|
417
|
+
dir: omniDir(),
|
|
418
|
+
route,
|
|
419
|
+
report: (event) => {
|
|
420
|
+
// The parent's spawn_agents reads this line from the child's stderr.
|
|
421
|
+
try {
|
|
422
|
+
process.stderr.write(`\n${FALLBACK_MARKER}${JSON.stringify(event)}\n`);
|
|
423
|
+
} catch {
|
|
424
|
+
/* stderr may be closed */
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
}
|
|
397
429
|
const tapped = {
|
|
398
430
|
...(options ?? {}),
|
|
399
431
|
fetch: tapFetch(
|
|
400
432
|
baseFetch,
|
|
401
|
-
|
|
433
|
+
() => route.expected,
|
|
402
434
|
(expected, served) => reportViolation(strictSota(), expected, served),
|
|
403
435
|
streamIdleMs(process.env.OMNIRUSH_STREAM_IDLE_MS, options?.timeoutMs),
|
|
404
436
|
),
|
|
405
437
|
};
|
|
406
|
-
|
|
438
|
+
const stream = simple
|
|
407
439
|
? responses.streamSimple(model, context, tapped)
|
|
408
440
|
: responses.stream(model, context, tapped);
|
|
441
|
+
if (fallbackModel && stream && typeof stream.push === "function") {
|
|
442
|
+
// The message records the model (and effort) that really answered.
|
|
443
|
+
const push = stream.push.bind(stream);
|
|
444
|
+
stream.push = (event: any) => {
|
|
445
|
+
if (route.movedTo) {
|
|
446
|
+
for (const message of [event?.partial, event?.message, event?.error]) {
|
|
447
|
+
if (message && typeof message === "object" && message.role === "assistant") {
|
|
448
|
+
message.model = route.movedTo.model;
|
|
449
|
+
if (route.movedTo.effort) message.providerThinkingLevel = route.movedTo.effort;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return push(event);
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
return stream;
|
|
409
457
|
};
|
|
410
458
|
|
|
411
459
|
pi.registerProvider({
|
|
@@ -52,6 +52,8 @@ export interface StatusInput {
|
|
|
52
52
|
origin: string;
|
|
53
53
|
/** omnirush CLI version ("unknown" when not provided by the launcher). */
|
|
54
54
|
version: string;
|
|
55
|
+
/** The approval mode line (yolo-lib formatModeStatus), when known. */
|
|
56
|
+
yolo?: string | null;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
/**
|
|
@@ -79,6 +81,7 @@ export function formatStatusLines(input: StatusInput): string[] {
|
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
lines.push(formatCollectorLine(input.collector));
|
|
84
|
+
if (input.yolo) lines.push(input.yolo);
|
|
82
85
|
lines.push(`manager: ${input.origin}`);
|
|
83
86
|
lines.push(`version: ${input.version}`);
|
|
84
87
|
return lines;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// A sub-agent process (spawn_agents sets OMNIRUSH_PARENT_SESSION) marks its
|
|
2
|
+
// own session with an `omnirush-subagent` entry naming its parent, before
|
|
3
|
+
// the task: should that session ever be continued as a main session, its
|
|
4
|
+
// first prompt is known to be the parent agent's (collector.ts records it
|
|
5
|
+
// as synthetic). Kept out of collector.ts because sub-agent processes do
|
|
6
|
+
// not load the collector (index.ts keeps children light).
|
|
7
|
+
|
|
8
|
+
import { SUBAGENT_ENTRY_TYPE } from "./pi-engine";
|
|
9
|
+
|
|
10
|
+
export default function markSubagentSession(pi: any, parentSession: string = (process.env.OMNIRUSH_PARENT_SESSION || "").trim()): void {
|
|
11
|
+
if (!parentSession) return;
|
|
12
|
+
pi.on("session_start", (_event: any, ctx: any) => {
|
|
13
|
+
try {
|
|
14
|
+
const entries = ctx?.sessionManager?.getEntries?.() ?? [];
|
|
15
|
+
if (entries.some((entry: any) => entry?.type === "custom" && entry.customType === SUBAGENT_ENTRY_TYPE)) return;
|
|
16
|
+
pi.appendEntry(SUBAGENT_ENTRY_TYPE, { parent_session_id: parentSession });
|
|
17
|
+
} catch {
|
|
18
|
+
/* never in the child's way */
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
}
|