omnirush 0.10.1 → 0.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/extensions/omnirush/agents-lib.ts +66 -11
- package/assets/extensions/omnirush/agents.ts +85 -18
- package/assets/extensions/omnirush/auth.js +3 -2
- package/assets/extensions/omnirush/bgshell.ts +6 -6
- package/assets/extensions/omnirush/capture/UPSTREAM +7 -4
- package/assets/extensions/omnirush/capture/omnirush-swarm.ts +2 -2
- package/assets/extensions/omnirush/capture/server-fetch.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/detect.ts +3 -3
- package/assets/extensions/omnirush/capture/session-archive/files.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/ignored.ts +2 -2
- package/assets/extensions/omnirush/capture/session-archive/index.ts +8 -8
- package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +1 -1
- package/assets/extensions/omnirush/capture/session-archive/manifest.ts +4 -4
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +2 -2
- package/assets/extensions/omnirush/capture/session-archive/upload.ts +3 -3
- package/assets/extensions/omnirush/capture/{collect-upload-budget.ts → sync-upload-budget.ts} +5 -5
- package/assets/extensions/omnirush/capture/turn-diff.ts +6 -6
- package/assets/extensions/omnirush/capture/{workspace-collector.ts → workspace-sync.ts} +253 -204
- package/assets/extensions/omnirush/commands.ts +6 -6
- package/assets/extensions/omnirush/{pi-engine.ts → engine-messages.ts} +7 -7
- package/assets/extensions/omnirush/index.ts +4 -4
- package/assets/extensions/omnirush/plan.ts +2 -2
- package/assets/extensions/omnirush/refresh.ts +1 -1
- package/assets/extensions/omnirush/retry.js +1 -1
- package/assets/extensions/omnirush/{collector.ts → session-sync.ts} +134 -58
- package/assets/extensions/omnirush/status-lib.ts +9 -9
- package/assets/extensions/omnirush/stream-timing.ts +4 -4
- package/assets/extensions/omnirush/subagent-marker.ts +4 -4
- package/assets/extensions/omnirush/subagents-lib.ts +113 -13
- package/assets/extensions/omnirush/subagents.ts +13 -2
- package/assets/extensions/omnirush/swarm-lib.ts +2 -2
- package/assets/extensions/omnirush/voice.ts +1 -1
- package/assets/{collect-once.ts → sync-once.ts} +20 -19
- package/package.json +1 -1
- package/src/bin.js +20 -8
- package/src/sessions.js +3 -3
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* /usage full usage from the manager (grant date, token counters,
|
|
6
6
|
* devices)
|
|
7
7
|
* /status one-screen session summary (identity, model/effort, grant,
|
|
8
|
-
*
|
|
8
|
+
* session uploader, origin, version)
|
|
9
9
|
* /verify prove the served model is genuine — full implementation
|
|
10
10
|
* lands with the gateway's model-attestation endpoint (M2)
|
|
11
11
|
* plus the footer status line that keeps grant/model info visible while
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
* which is exactly how /grant and /verify broke the first time.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { readSessionLedger, SESSION_LEDGER_FILE } from "./capture/workspace-
|
|
20
|
+
import { readSessionLedger, SESSION_LEDGER_FILE } from "./capture/workspace-sync";
|
|
21
21
|
import { deviceMe, omniDir, resolveOrigin, sessionEndedMessage } from "./auth";
|
|
22
22
|
import { sharedRefresher } from "./refresh";
|
|
23
|
-
import {
|
|
23
|
+
import { formatSyncLine, formatStatusLines } from "./status-lib";
|
|
24
24
|
import { formatModeStatus } from "./yolo-lib";
|
|
25
25
|
import {
|
|
26
26
|
formatDeviceLines,
|
|
@@ -129,7 +129,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
129
129
|
});
|
|
130
130
|
|
|
131
131
|
pi.registerCommand("status", {
|
|
132
|
-
description: "Show the Omnirush session status (identity, model, grant,
|
|
132
|
+
description: "Show the Omnirush session status (identity, model, grant, session uploads)",
|
|
133
133
|
handler: async (_args: string, ctx: any) => {
|
|
134
134
|
const { me } = await fetchMe();
|
|
135
135
|
const usage = gatewayUsage();
|
|
@@ -140,7 +140,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
140
140
|
const ledger = await readSessionLedger(`${omniDir()}/${SESSION_LEDGER_FILE}`);
|
|
141
141
|
record = ledger.sessions[sessionId] ?? null;
|
|
142
142
|
} catch {
|
|
143
|
-
/*
|
|
143
|
+
/* session upload state is best effort */
|
|
144
144
|
}
|
|
145
145
|
const model = ctx?.model;
|
|
146
146
|
const statusLines = formatStatusLines({
|
|
@@ -151,7 +151,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
151
151
|
effort: usage?.["x-omnirush-effort"] ?? null,
|
|
152
152
|
grant: grant?.text ?? null,
|
|
153
153
|
grantDate: grant?.date ?? null,
|
|
154
|
-
|
|
154
|
+
sync: { sessionId, record },
|
|
155
155
|
origin: resolveOrigin(process.env),
|
|
156
156
|
version: CLI_VERSION,
|
|
157
157
|
yolo: formatModeStatus(),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The CLI's session, in the desktop app's engine message format.
|
|
2
2
|
//
|
|
3
|
-
// The desktop
|
|
3
|
+
// The desktop app records the conversation as the engine (opencode)
|
|
4
4
|
// keeps it: one message per user prompt and per model step,
|
|
5
5
|
//
|
|
6
6
|
// {info: {id, sessionID, role, time: {created, completed}, modelID,
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
// outcome) comes from stream-timing.ts's `omnirush-timing` entries.
|
|
73
73
|
//
|
|
74
74
|
// Also here, pure and shared with the tests: the helpers the desktop's turn
|
|
75
|
-
// observer (apps/server/src
|
|
75
|
+
// observer (apps/server/src) applies to the engine's
|
|
76
76
|
// messages when a turn settles: the turn's model, the message budget of a
|
|
77
77
|
// flush and its split into `turn.messages` parts.
|
|
78
78
|
|
|
@@ -1010,9 +1010,9 @@ export function parseSessionFile(text: string): { header: PiEntry | null; entrie
|
|
|
1010
1010
|
return { header, entries };
|
|
1011
1011
|
}
|
|
1012
1012
|
|
|
1013
|
-
// --- the turn observer's helpers (
|
|
1013
|
+
// --- the turn observer's helpers (the desktop turn observer) -------------------------
|
|
1014
1014
|
|
|
1015
|
-
/** Latest model selection of a turn, as the
|
|
1015
|
+
/** Latest model selection of a turn, as the session uploader's session.model event records it. */
|
|
1016
1016
|
export type TurnModel = { provider_id: string | null; model_id: string | null; variant: string | null; agent: string | null };
|
|
1017
1017
|
|
|
1018
1018
|
function optionalText(value: unknown): string | null {
|
|
@@ -1023,7 +1023,7 @@ function optionalText(value: unknown): string | null {
|
|
|
1023
1023
|
* The model the turn ran on, read from the turn's first assistant message
|
|
1024
1024
|
* (providerID / modelID / mode or agent); the user message that opened the
|
|
1025
1025
|
* turn supplies the variant and agent when the assistant record lacks them.
|
|
1026
|
-
* (
|
|
1026
|
+
* (the desktop turn observer `turnModelFromMessages`.)
|
|
1027
1027
|
*/
|
|
1028
1028
|
export function turnModelFromMessages(messages: unknown): TurnModel | null {
|
|
1029
1029
|
if (!Array.isArray(messages)) return null;
|
|
@@ -1071,7 +1071,7 @@ export type TurnHistory = {
|
|
|
1071
1071
|
* The part of a session's messages a settled turn records: every message
|
|
1072
1072
|
* after the checkpoint message (all of them without one, or when the
|
|
1073
1073
|
* checkpoint is not on the branch), newest first up to `budget` bytes, and
|
|
1074
|
-
* once the budget is spent only the user's own prompts. (
|
|
1074
|
+
* once the budget is spent only the user's own prompts. (the desktop turn observer
|
|
1075
1075
|
* `readEngineHistory`, over messages already in memory.)
|
|
1076
1076
|
*/
|
|
1077
1077
|
export function turnHistory(messages: readonly EngineMessage[], checkpoint: string | undefined, budget = MAX_BACKLOG_MESSAGE_BYTES): TurnHistory {
|
|
@@ -1112,7 +1112,7 @@ export type MessagePart = { messages: unknown[]; bytes: number };
|
|
|
1112
1112
|
* A settled turn's messages (oldest first) cut into the parts its trace
|
|
1113
1113
|
* flushes carry, oldest part first: the newest messages up to `maxBytes` make
|
|
1114
1114
|
* the last part, and each earlier part takes the next older ones. One part,
|
|
1115
|
-
* empty, when there are no messages. (
|
|
1115
|
+
* empty, when there are no messages. (the desktop turn observer `messageParts`.)
|
|
1116
1116
|
*/
|
|
1117
1117
|
export function messageParts(messages: readonly unknown[], sizes: readonly number[], maxBytes = MAX_TURN_MESSAGE_BYTES): MessagePart[] {
|
|
1118
1118
|
const parts: MessagePart[] = [];
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// A sub-agent (a headless spawn_agents child: OMNIRUSH_PARENT_SESSION is
|
|
10
10
|
// set) does not load what only the interactive top agent uses — the slash
|
|
11
|
-
// commands (commands), the session capture (
|
|
11
|
+
// commands (commands), the session capture (session uploader: the parent records
|
|
12
12
|
// its children) and voice input (TUI only) — so each of the many child
|
|
13
13
|
// processes a fan-out starts stays smaller. They are imported on demand,
|
|
14
14
|
// in the same order as before, so the top agent registers exactly what it
|
|
@@ -39,11 +39,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
39
39
|
if (!subagent) (await import("./commands")).default(pi as any);
|
|
40
40
|
recorder(pi as any);
|
|
41
41
|
// Every process times its own runs (the 2.x trace fields); before the
|
|
42
|
-
//
|
|
42
|
+
// session uploader, so a settled run's idle entry is written before it is read.
|
|
43
43
|
recordStreamTiming(pi as any);
|
|
44
44
|
sota(pi as any);
|
|
45
|
-
if (!subagent) (await import("./
|
|
46
|
-
// Children still mark their own session as a sub-agent's (the
|
|
45
|
+
if (!subagent) (await import("./session-sync")).default(pi as any);
|
|
46
|
+
// Children still mark their own session as a sub-agent's (the session uploader
|
|
47
47
|
// does that for the top agent's sessions; children do not load it).
|
|
48
48
|
else (await import("./subagent-marker")).default(pi as any);
|
|
49
49
|
mcp(pi as any);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The model maintains an ordered task list (items with pending /
|
|
4
4
|
// in-progress / done statuses) while it works. Every update:
|
|
5
|
-
// - persists to <cwd>/__agent__/plan.json (the workspace
|
|
5
|
+
// - persists to <cwd>/__agent__/plan.json (the workspace uploader
|
|
6
6
|
// ships it with the workspace snapshots),
|
|
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
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
const PLAN_TOOL_NAME = "plan";
|
|
42
42
|
const PLAN_ENTRY_TYPE = "omnirush.plan";
|
|
43
43
|
const PLAN_STATUS_KEY = "omnirush-plan";
|
|
44
|
-
// The plan file's folder in the workspace (the CLI's earlier
|
|
44
|
+
// The plan file's folder in the workspace (the CLI's earlier uploader
|
|
45
45
|
// kept its journal there too).
|
|
46
46
|
const AGENT_DIR = "__agent__";
|
|
47
47
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Process-wide single-flight refresher shared by the sota guard and the
|
|
2
|
-
//
|
|
2
|
+
// session uploader (both run inside the pi process). Lazily created from the
|
|
3
3
|
// environment; refreshes rotate both tokens back to the state dir (0600).
|
|
4
4
|
|
|
5
5
|
import { createRefresher, omniDir, resolveOrigin } from "./auth";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Omnirush retry layer — shared by every Omnirush API call path (model
|
|
2
|
-
// stream via the sota tap,
|
|
2
|
+
// stream via the sota tap, session uploads, /device and /device/me).
|
|
3
3
|
//
|
|
4
4
|
// Goal: users ride out a backend deploy or a rate-limit window without
|
|
5
5
|
// seeing a single raw error. Retryable = 429 / 5xx / network failures.
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// the same format, as the desktop app.
|
|
3
3
|
//
|
|
4
4
|
// The capture itself is the desktop's, ported unchanged but for a few
|
|
5
|
-
// marked seams (capture/: workspace-
|
|
5
|
+
// marked seams (capture/: workspace-sync.ts, turn-diff.ts and the
|
|
6
6
|
// project archive in session-archive/, from omnirush-gui apps/server/src).
|
|
7
7
|
// This file is the CLI's counterpart of the desktop's turn observer
|
|
8
|
-
// (
|
|
8
|
+
// (the desktop turn observer) and server hooks: it tells the session uploader and the
|
|
9
9
|
// project archive what the agent does, from pi's events.
|
|
10
10
|
//
|
|
11
11
|
// a prompt goes out engine.request trace event (prompt, model,
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// events), the "prompt" change snapshot, the
|
|
14
14
|
// project archive's session start (its base)
|
|
15
15
|
// a run settles the conversation since the last flush as
|
|
16
|
-
// engine messages (
|
|
16
|
+
// engine messages (engine-messages.ts): session.model,
|
|
17
17
|
// turn.messages parts when it is long, each
|
|
18
18
|
// sub-agent's new messages as session.child, the
|
|
19
19
|
// "turn_completed" snapshot with its turn.diff, and
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// it. A prompt an extension sends (pi's input event, source "extension")
|
|
42
42
|
// is synthetic too.
|
|
43
43
|
//
|
|
44
|
-
// Silent no-op without device credentials:
|
|
44
|
+
// Silent no-op without device credentials: session uploads only run for
|
|
45
45
|
// signed-in omnirush identities.
|
|
46
46
|
|
|
47
47
|
import { createHash } from "node:crypto";
|
|
@@ -53,7 +53,7 @@ import path from "node:path";
|
|
|
53
53
|
import { createInterface } from "node:readline";
|
|
54
54
|
|
|
55
55
|
import { gatewayUrlForOrigin, omniDir, resolveOrigin } from "./auth";
|
|
56
|
-
import {
|
|
56
|
+
import { WorkspaceSync } from "./capture/workspace-sync";
|
|
57
57
|
import { SessionArchiver } from "./capture/session-archive/index";
|
|
58
58
|
import { ProjectArchiveLifecycle, projectArchiveEnabled } from "./capture/session-archive/lifecycle";
|
|
59
59
|
import {
|
|
@@ -68,14 +68,14 @@ import {
|
|
|
68
68
|
userText,
|
|
69
69
|
type EngineMessage,
|
|
70
70
|
type PiEntry,
|
|
71
|
-
} from "./
|
|
71
|
+
} from "./engine-messages";
|
|
72
72
|
import { sharedRefresher } from "./refresh";
|
|
73
73
|
import { AGENTS_HOOK, SUBAGENT_SESSIONS_DIR, type AgentsHook } from "./agents-lib";
|
|
74
74
|
import { BGSHELL_HOOK, type BgShellHook } from "./bgshell-lib";
|
|
75
75
|
|
|
76
76
|
/** info.agent of the root session's messages (a sub-agent's is its role). */
|
|
77
77
|
export const ROOT_AGENT = "omnirush-cli";
|
|
78
|
-
/** Sub-agent layers captured below the root session (the desktop's
|
|
78
|
+
/** Sub-agent layers captured below the root session (the desktop's MAX_SYNC_CHILD_SESSION_DEPTH). */
|
|
79
79
|
const MAX_CHILD_DEPTH = 3;
|
|
80
80
|
/** Sub-agent sessions followed per session (the desktop's MAX_CHILD_SESSIONS). */
|
|
81
81
|
const MAX_CHILDREN = 200;
|
|
@@ -105,7 +105,7 @@ function piAgentDir(): string {
|
|
|
105
105
|
return path.join(stateDir ? path.resolve(stateDir) : path.join(os.homedir(), ".omnirush"), "agent");
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/** pi session ids are UUIDs; the
|
|
108
|
+
/** pi session ids are UUIDs; the session uploader refuses anything outside its id alphabet. */
|
|
109
109
|
function sanitizeSessionId(raw: string): string {
|
|
110
110
|
const id = String(raw || "");
|
|
111
111
|
return /^[A-Za-z0-9._:-]{8,128}$/.test(id) ? id : "";
|
|
@@ -128,7 +128,7 @@ function cliVersion(): string {
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/** The previous
|
|
131
|
+
/** The previous session uploader's per-session change journals (`journal-<session>.ndjson`); the ported session uploader keeps none. */
|
|
132
132
|
function removeLegacyJournals(stateDir: string): void {
|
|
133
133
|
try {
|
|
134
134
|
for (const name of readdirSync(stateDir)) {
|
|
@@ -159,13 +159,48 @@ function modelFallbackOf(result: any): ChildModelFallback | null {
|
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** A task's own model that did not run (spawn_agents' model_override). */
|
|
163
|
+
export type ChildModelOverride = { requested: string; used: string; effort: string | null; reason: string; note: string };
|
|
164
|
+
|
|
165
|
+
/** The model and effort a sub-agent really ran on, once its result is final. */
|
|
166
|
+
export type ChildRanOn = { model: string | null; effort: string | null; source: string | null; requested: string | null; status: string };
|
|
167
|
+
|
|
168
|
+
function modelOverrideOf(result: any): ChildModelOverride | null {
|
|
169
|
+
const override = result && typeof result.model_override === "object" ? result.model_override : null;
|
|
170
|
+
if (!override || typeof override.requested !== "string" || typeof override.used !== "string") return null;
|
|
171
|
+
return {
|
|
172
|
+
requested: override.requested,
|
|
173
|
+
used: override.used,
|
|
174
|
+
effort: typeof override.effort === "string" ? override.effort : null,
|
|
175
|
+
reason: typeof override.reason === "string" ? override.reason : "user_choice",
|
|
176
|
+
note: typeof override.note === "string" ? override.note : `ran on ${override.used}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A finished child's model and effort (the gateway fallback's when the gateway moved it). */
|
|
181
|
+
function ranOnOf(result: any, fallback: ChildModelFallback | null): ChildRanOn | null {
|
|
182
|
+
const status = result && typeof result.status === "string" ? result.status : "";
|
|
183
|
+
if (!status || status === "running" || status === "queued") return null;
|
|
184
|
+
const gateway = fallback?.kind === "gateway" ? fallback : null;
|
|
185
|
+
return {
|
|
186
|
+
model: gateway ? gateway.used : typeof result.model === "string" ? result.model : null,
|
|
187
|
+
effort: gateway ? gateway.effort : typeof result.effort === "string" ? result.effort : null,
|
|
188
|
+
source: typeof result.model_source === "string" ? result.model_source : null,
|
|
189
|
+
requested: typeof result.requested_model === "string" ? result.requested_model : null,
|
|
190
|
+
status,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type SpawnedChild = { id: string; role: string | null; task: string | null; modelFallback?: ChildModelFallback; modelOverride?: ChildModelOverride; ranOn?: ChildRanOn };
|
|
195
|
+
|
|
162
196
|
/**
|
|
163
197
|
* Sub-agents a session started: its spawn_agents results (session id, role,
|
|
164
198
|
* task), with the model fallback a later result reported (a background
|
|
165
|
-
* child's delivery, agents_wait / agents_result) when there is one
|
|
199
|
+
* child's delivery, agents_wait / agents_result) when there is one, a task
|
|
200
|
+
* model that did not run, and the model and effort it ran on once finished.
|
|
166
201
|
*/
|
|
167
|
-
export function spawnedChildren(entries: readonly PiEntry[]):
|
|
168
|
-
const children:
|
|
202
|
+
export function spawnedChildren(entries: readonly PiEntry[]): SpawnedChild[] {
|
|
203
|
+
const children: SpawnedChild[] = [];
|
|
169
204
|
for (const entry of entries) {
|
|
170
205
|
const message = entry.type === "message" ? entry.message : null;
|
|
171
206
|
const spawned = Boolean(message && message.role === "toolResult" && message.toolName === "spawn_agents");
|
|
@@ -182,16 +217,23 @@ export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string
|
|
|
182
217
|
if (!sanitizeSessionId(id)) continue;
|
|
183
218
|
const known = children.find((child) => child.id === id);
|
|
184
219
|
const modelFallback = modelFallbackOf(result);
|
|
220
|
+
const modelOverride = modelOverrideOf(result);
|
|
185
221
|
if (known) {
|
|
186
222
|
if (modelFallback) known.modelFallback = modelFallback;
|
|
223
|
+
if (modelOverride) known.modelOverride = modelOverride;
|
|
224
|
+
const ran = ranOnOf(result, known.modelFallback ?? null);
|
|
225
|
+
if (ran) known.ranOn = ran;
|
|
187
226
|
continue;
|
|
188
227
|
}
|
|
189
228
|
if (!spawned) continue;
|
|
229
|
+
const ran = ranOnOf(result, modelFallback);
|
|
190
230
|
children.push({
|
|
191
231
|
id,
|
|
192
232
|
role: typeof result.role === "string" ? result.role : null,
|
|
193
233
|
task: typeof result.task === "string" ? result.task : null,
|
|
194
234
|
...(modelFallback ? { modelFallback } : {}),
|
|
235
|
+
...(modelOverride ? { modelOverride } : {}),
|
|
236
|
+
...(ran ? { ranOn: ran } : {}),
|
|
195
237
|
});
|
|
196
238
|
}
|
|
197
239
|
}
|
|
@@ -200,6 +242,15 @@ export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string
|
|
|
200
242
|
|
|
201
243
|
/** Fallbacks already recorded as trace events (root session : child session). */
|
|
202
244
|
const recordedFallbacks = new Set<string>();
|
|
245
|
+
/** Overrides and ran-on models already recorded (same keys). */
|
|
246
|
+
const recordedOverrides = new Set<string>();
|
|
247
|
+
const recordedRanOn = new Set<string>();
|
|
248
|
+
|
|
249
|
+
/** A child's trace title: its task, with the model note when its model was not the one asked for. */
|
|
250
|
+
function childTitle(child: SpawnedChild): string | null {
|
|
251
|
+
const notes = [child.modelOverride?.note, child.modelFallback?.note].filter(Boolean);
|
|
252
|
+
return child.task && notes.length ? `${child.task} (${notes.join("; ")})` : child.task;
|
|
253
|
+
}
|
|
203
254
|
|
|
204
255
|
/** The session file of `sessionId`: `<ts>_<id>.jsonl` in one of the session dirs. */
|
|
205
256
|
async function findSessionFile(sessionId: string, dirs: readonly string[]): Promise<string | null> {
|
|
@@ -307,20 +358,20 @@ export async function parseSessionFileStream(file: string): Promise<{ header: Pi
|
|
|
307
358
|
return { header, entries };
|
|
308
359
|
}
|
|
309
360
|
|
|
310
|
-
type
|
|
311
|
-
|
|
361
|
+
type Sync = Pick<
|
|
362
|
+
WorkspaceSync,
|
|
312
363
|
"recordTrace" | "recordChildSession" | "childCheckpoints" | "childSessionIds"
|
|
313
364
|
>;
|
|
314
365
|
|
|
315
366
|
/**
|
|
316
367
|
* Records the sub-agent tree below a settled session: one session.child event
|
|
317
368
|
* per sub-agent carrying only its messages since its checkpoint, recursively
|
|
318
|
-
* for the sub-agents it started (
|
|
369
|
+
* for the sub-agents it started (the desktop turn observer
|
|
319
370
|
* `captureChildSessions`). `beforeRecord` hears the size of each event's
|
|
320
371
|
* messages before it is recorded.
|
|
321
372
|
*/
|
|
322
373
|
export async function captureChildSessions(input: {
|
|
323
|
-
|
|
374
|
+
sync: Sync;
|
|
324
375
|
rootSessionId: string;
|
|
325
376
|
parentSessionId: string;
|
|
326
377
|
parentEntries: readonly PiEntry[];
|
|
@@ -354,13 +405,13 @@ export async function captureChildSessions(input: {
|
|
|
354
405
|
const messages = engineMessagesFromEntries(entries, { sessionId: child.id, agent, cwd });
|
|
355
406
|
const history = turnHistory(messages, input.checkpoints[child.id], MAX_TURN_MESSAGE_BYTES);
|
|
356
407
|
if (history.omitted > 0) {
|
|
357
|
-
input.
|
|
408
|
+
input.sync.recordTrace(input.rootSessionId, "session.messages_omitted", { child_session_id: child.id, count: history.omitted });
|
|
358
409
|
}
|
|
359
410
|
const fallbackKey = `${input.rootSessionId}:${child.id}`;
|
|
360
411
|
if (child.modelFallback && !recordedFallbacks.has(fallbackKey)) {
|
|
361
412
|
// As the desktop: the sub-agent ran on the main model, and why.
|
|
362
413
|
recordedFallbacks.add(fallbackKey);
|
|
363
|
-
input.
|
|
414
|
+
input.sync.recordTrace(input.rootSessionId, "subagent.model_fallback", {
|
|
364
415
|
child_session_id: child.id,
|
|
365
416
|
requested: child.modelFallback.requested,
|
|
366
417
|
used: child.modelFallback.used,
|
|
@@ -369,13 +420,38 @@ export async function captureChildSessions(input: {
|
|
|
369
420
|
kind: child.modelFallback.kind,
|
|
370
421
|
});
|
|
371
422
|
}
|
|
423
|
+
if (child.modelOverride && !recordedOverrides.has(fallbackKey)) {
|
|
424
|
+
// The task named a model that did not run: the user's /subagents
|
|
425
|
+
// choice won, or the gateway does not serve it to this account.
|
|
426
|
+
recordedOverrides.add(fallbackKey);
|
|
427
|
+
input.sync.recordTrace(input.rootSessionId, "subagent.model_override", {
|
|
428
|
+
child_session_id: child.id,
|
|
429
|
+
requested: child.modelOverride.requested,
|
|
430
|
+
used: child.modelOverride.used,
|
|
431
|
+
effort: child.modelOverride.effort,
|
|
432
|
+
reason: child.modelOverride.reason,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
if (child.ranOn && !recordedRanOn.has(fallbackKey)) {
|
|
436
|
+
// What the sub-agent really ran on (after any gateway fallback).
|
|
437
|
+
recordedRanOn.add(fallbackKey);
|
|
438
|
+
input.sync.recordTrace(input.rootSessionId, "subagent.model", {
|
|
439
|
+
child_session_id: child.id,
|
|
440
|
+
role: child.role,
|
|
441
|
+
model: child.ranOn.model,
|
|
442
|
+
effort: child.ranOn.effort,
|
|
443
|
+
source: child.ranOn.source,
|
|
444
|
+
requested: child.ranOn.requested,
|
|
445
|
+
status: child.ranOn.status,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
372
448
|
if (history.delta.length > 0 || !input.known.has(child.id)) {
|
|
373
449
|
await input.beforeRecord(history.sizes.reduce((total, bytes) => total + bytes, 0));
|
|
374
|
-
input.
|
|
450
|
+
input.sync.recordChildSession(input.rootSessionId, {
|
|
375
451
|
childSessionId: child.id,
|
|
376
452
|
parentSessionId: input.parentSessionId,
|
|
377
453
|
depth: input.depth,
|
|
378
|
-
title:
|
|
454
|
+
title: childTitle(child),
|
|
379
455
|
agent: turnModelFromMessages(messages)?.agent ?? agent,
|
|
380
456
|
messages: history.delta,
|
|
381
457
|
lastMessageId: typeof messages.at(-1)?.info.id === "string" ? (messages.at(-1)!.info.id as string) : null,
|
|
@@ -391,7 +467,7 @@ export default function (pi: any) {
|
|
|
391
467
|
const parentSession = (process.env.OMNIRUSH_PARENT_SESSION || "").trim();
|
|
392
468
|
if (parentSession) {
|
|
393
469
|
// Its session says so (subagent-marker.ts; index.ts loads it for
|
|
394
|
-
// sub-agent processes, which do not load the
|
|
470
|
+
// sub-agent processes, which do not load the session uploader).
|
|
395
471
|
markSubagentSession(pi, parentSession);
|
|
396
472
|
return;
|
|
397
473
|
}
|
|
@@ -411,7 +487,7 @@ export default function (pi: any) {
|
|
|
411
487
|
if (level !== "warn" && !verbose) return;
|
|
412
488
|
try {
|
|
413
489
|
const detail = attributes && Object.keys(attributes).length > 0 ? ` ${JSON.stringify(attributes)}` : "";
|
|
414
|
-
process.stderr.write(`omnirush
|
|
490
|
+
process.stderr.write(`omnirush session upload: ${message}${detail}\n`);
|
|
415
491
|
} catch {
|
|
416
492
|
/* stderr may be closed late in shutdown */
|
|
417
493
|
}
|
|
@@ -431,7 +507,7 @@ export default function (pi: any) {
|
|
|
431
507
|
|
|
432
508
|
// Uploads carry the CURRENT on-disk token, not the one this process
|
|
433
509
|
// last saw: another terminal or a sub-agent may have rotated the pair.
|
|
434
|
-
// Only requests that already carry a bearer (
|
|
510
|
+
// Only requests that already carry a bearer (session upload, archive API) are
|
|
435
511
|
// touched — presigned S3 part PUTs pass through unchanged.
|
|
436
512
|
const withCurrentBearer = (input: string, init?: RequestInit): Promise<Response> => {
|
|
437
513
|
const headers = new Headers(init?.headers ?? {});
|
|
@@ -450,7 +526,7 @@ export default function (pi: any) {
|
|
|
450
526
|
return globalThis.fetch(input, init);
|
|
451
527
|
};
|
|
452
528
|
|
|
453
|
-
const
|
|
529
|
+
const sync = new WorkspaceSync({
|
|
454
530
|
gatewayUrl,
|
|
455
531
|
accessToken,
|
|
456
532
|
refreshAccessToken,
|
|
@@ -484,12 +560,12 @@ export default function (pi: any) {
|
|
|
484
560
|
});
|
|
485
561
|
const archive = new ProjectArchiveLifecycle({
|
|
486
562
|
archiver,
|
|
487
|
-
enabled: projectArchiveEnabled(process.env) &&
|
|
563
|
+
enabled: projectArchiveEnabled(process.env) && sync.enabled,
|
|
488
564
|
log,
|
|
489
565
|
baseIdleMs: ARCHIVE_BASE_IDLE_MS,
|
|
490
566
|
baseMaxDeferMs: ARCHIVE_BASE_MAX_DEFER_MS,
|
|
491
567
|
});
|
|
492
|
-
if (!
|
|
568
|
+
if (!sync.enabled) {
|
|
493
569
|
// Signed out: clear what a previous run left queued (no network).
|
|
494
570
|
archive.start();
|
|
495
571
|
return;
|
|
@@ -502,21 +578,21 @@ export default function (pi: any) {
|
|
|
502
578
|
id: string;
|
|
503
579
|
root: string;
|
|
504
580
|
ctx: any;
|
|
505
|
-
/** A prompt went out in this process: the
|
|
581
|
+
/** A prompt went out in this process: the session uploader follows the session. */
|
|
506
582
|
started: boolean;
|
|
507
583
|
/** A prompt went out and its run has not settled. */
|
|
508
584
|
running: boolean;
|
|
509
|
-
/** The id of the last engine message recorded (the
|
|
585
|
+
/** The id of the last engine message recorded (the session uploader's checkpoint). */
|
|
510
586
|
lastMessageId: string | undefined;
|
|
511
587
|
toolStarts: Map<string, number>;
|
|
512
588
|
seenFiles: Map<string, string>;
|
|
513
589
|
/** Prompts sent in this process with the model and effort they went with, until their user entry is found. */
|
|
514
590
|
pendingPrompts: Array<{ at: number; model: { providerID: string; modelID: string } | null; variant: string | null }>;
|
|
515
|
-
/** User entry id -> what its prompt was sent with (
|
|
591
|
+
/** User entry id -> what its prompt was sent with (engine-messages promptSettings). */
|
|
516
592
|
promptSettings: Map<string, { model: { providerID: string; modelID: string } | null; variant: string | null }>;
|
|
517
593
|
/** Prompts an extension sent (pi's input event, source "extension"), until their user entry is found. */
|
|
518
594
|
extensionInputs: Array<{ at: number; text: string }>;
|
|
519
|
-
/** User entries the user did not submit -> their parts' metadata (
|
|
595
|
+
/** User entries the user did not submit -> their parts' metadata (engine-messages injectedPrompts). */
|
|
520
596
|
injectedPrompts: Map<string, Record<string, unknown>>;
|
|
521
597
|
/** Whether the session is a sub-agent's (its first prompt the parent agent's task), once looked up. */
|
|
522
598
|
origin: Promise<void> | null;
|
|
@@ -581,7 +657,7 @@ export default function (pi: any) {
|
|
|
581
657
|
};
|
|
582
658
|
|
|
583
659
|
/**
|
|
584
|
-
* The session `ctx` is on. Its
|
|
660
|
+
* The session `ctx` is on. Its upload starts with its first prompt in
|
|
585
661
|
* this process, as the desktop starts it on a prompt dispatch: opening a
|
|
586
662
|
* session and leaving without a prompt uploads nothing.
|
|
587
663
|
*/
|
|
@@ -609,7 +685,7 @@ export default function (pi: any) {
|
|
|
609
685
|
session.ctx = ctx;
|
|
610
686
|
// Idempotent: a session already started in this process keeps its segment.
|
|
611
687
|
if (prompted) {
|
|
612
|
-
|
|
688
|
+
sync.startSession(id, workspaceIdFor(session.root), session.root);
|
|
613
689
|
session.started = true;
|
|
614
690
|
}
|
|
615
691
|
return session;
|
|
@@ -624,20 +700,20 @@ export default function (pi: any) {
|
|
|
624
700
|
const { id } = state;
|
|
625
701
|
try {
|
|
626
702
|
await captureChildSessions({
|
|
627
|
-
|
|
703
|
+
sync,
|
|
628
704
|
rootSessionId: id,
|
|
629
705
|
parentSessionId: id,
|
|
630
706
|
parentEntries: state.ctx?.sessionManager?.getBranch?.() ?? [],
|
|
631
707
|
depth: 1,
|
|
632
708
|
sessionDirs: sessionDirs(state.ctx),
|
|
633
|
-
checkpoints: await
|
|
634
|
-
known: new Set(await
|
|
709
|
+
checkpoints: await sync.childCheckpoints(id),
|
|
710
|
+
known: new Set(await sync.childSessionIds(id)),
|
|
635
711
|
visited: new Set([id]),
|
|
636
712
|
seenFiles: state.seenFiles,
|
|
637
713
|
beforeRecord: room,
|
|
638
714
|
});
|
|
639
715
|
} catch (error) {
|
|
640
|
-
|
|
716
|
+
sync.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
|
|
641
717
|
}
|
|
642
718
|
};
|
|
643
719
|
|
|
@@ -653,9 +729,9 @@ export default function (pi: any) {
|
|
|
653
729
|
let pending = 0;
|
|
654
730
|
return async (bytes: number): Promise<void> => {
|
|
655
731
|
if (pending > 0 && pending + bytes > MAX_TURN_MESSAGE_BYTES) {
|
|
656
|
-
|
|
732
|
+
sync.flushTrace(id);
|
|
657
733
|
pending = 0;
|
|
658
|
-
await
|
|
734
|
+
await sync.drained(id);
|
|
659
735
|
}
|
|
660
736
|
pending += bytes;
|
|
661
737
|
};
|
|
@@ -673,37 +749,37 @@ export default function (pi: any) {
|
|
|
673
749
|
let parts: Array<{ messages: unknown[]; bytes: number }> = [{ messages: [], bytes: 0 }];
|
|
674
750
|
try {
|
|
675
751
|
messages = messagesOf(state);
|
|
676
|
-
const checkpoint = state.lastMessageId ?? (await
|
|
752
|
+
const checkpoint = state.lastMessageId ?? (await sync.sessionCheckpoint(id)).lastMessageId;
|
|
677
753
|
const history = turnHistory(messages, checkpoint);
|
|
678
754
|
const lastId = messages.at(-1)?.info.id;
|
|
679
755
|
if (typeof lastId === "string") {
|
|
680
756
|
state.lastMessageId = lastId;
|
|
681
|
-
void
|
|
757
|
+
void sync.setSessionCheckpoint(id, lastId);
|
|
682
758
|
}
|
|
683
|
-
if (history.omitted > 0)
|
|
759
|
+
if (history.omitted > 0) sync.recordTrace(id, "session.messages_omitted", { count: history.omitted });
|
|
684
760
|
const model = turnModelFromMessages(history.delta);
|
|
685
|
-
if (model)
|
|
761
|
+
if (model) sync.recordSessionModel(id, model);
|
|
686
762
|
// More messages than one flush holds: the older ones go ahead, a flush per part.
|
|
687
763
|
parts = messageParts(history.delta, history.sizes);
|
|
688
764
|
for (const [index, part] of parts.slice(0, -1).entries()) {
|
|
689
|
-
|
|
690
|
-
|
|
765
|
+
sync.recordTrace(id, "turn.messages", { messages: part.messages, part: index + 1, parts: parts.length });
|
|
766
|
+
sync.flushTrace(id);
|
|
691
767
|
// One part waits in memory at a time (a resumed session's backlog is up to 32 MiB).
|
|
692
|
-
await
|
|
768
|
+
await sync.drained(id);
|
|
693
769
|
}
|
|
694
770
|
} catch (error) {
|
|
695
|
-
|
|
771
|
+
sync.recordTrace(id, "session.messages_failed", { error: error instanceof Error ? error.message : "unknown" });
|
|
696
772
|
}
|
|
697
773
|
const newest = parts.at(-1)!;
|
|
698
774
|
// Sub-agent messages flush on their own before they would crowd the turn's out.
|
|
699
775
|
const room = childRoom(id);
|
|
700
776
|
await captureChildren(state, room);
|
|
701
777
|
await room(newest.bytes);
|
|
702
|
-
|
|
778
|
+
sync.recordTrace(id, "session.idle", { status });
|
|
703
779
|
// The turn snapshot runs first so the artifacts it discovers are part of
|
|
704
780
|
// the trace flushed right behind it.
|
|
705
|
-
|
|
706
|
-
|
|
781
|
+
sync.captureSnapshot(id, "turn_completed");
|
|
782
|
+
sync.flushTrace(id, { messages: newest.messages });
|
|
707
783
|
archive.turnCompleted(id, messages);
|
|
708
784
|
};
|
|
709
785
|
|
|
@@ -735,14 +811,14 @@ export default function (pi: any) {
|
|
|
735
811
|
const state = session;
|
|
736
812
|
if (!state?.started) return;
|
|
737
813
|
for (const child of interrupted) {
|
|
738
|
-
|
|
814
|
+
sync.recordTrace(state.id, "session.child_interrupted", { child_session_id: child.sessionId, agent: child.role });
|
|
739
815
|
}
|
|
740
816
|
const lastId = messagesOf(state).at(-1)?.info.id;
|
|
741
817
|
if (typeof lastId !== "string" || lastId === state.lastMessageId) {
|
|
742
818
|
// No new message of its own: what its sub-agents did since the last settle.
|
|
743
819
|
settling = settling.then(async () => {
|
|
744
820
|
await captureChildren(state, childRoom(state.id));
|
|
745
|
-
|
|
821
|
+
sync.flushTrace(state.id);
|
|
746
822
|
}).catch(() => undefined);
|
|
747
823
|
await settling;
|
|
748
824
|
return;
|
|
@@ -755,7 +831,7 @@ export default function (pi: any) {
|
|
|
755
831
|
let stopping: Promise<void> | null = null;
|
|
756
832
|
const shutdown = (budgetMs: number): Promise<void> => (stopping ??= stop(budgetMs));
|
|
757
833
|
const stop = async (budgetMs: number): Promise<void> => {
|
|
758
|
-
// A ref'd timer: the
|
|
834
|
+
// A ref'd timer: the session uploader's and the archive's own timers are unref'd,
|
|
759
835
|
// and pi awaits this handler without holding the process open, so without
|
|
760
836
|
// it a process with nothing else to do exits in the middle of an upload.
|
|
761
837
|
let budget: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -770,12 +846,12 @@ export default function (pi: any) {
|
|
|
770
846
|
if (state) {
|
|
771
847
|
// The settled turn's own snapshot (its turn.diff) and trace go out
|
|
772
848
|
// before the session ends: a finished session skips queued captures.
|
|
773
|
-
await
|
|
774
|
-
|
|
849
|
+
await sync.idle(state.id);
|
|
850
|
+
sync.finishSession(state.id);
|
|
775
851
|
}
|
|
776
852
|
// The base and turn deltas upload now; what is left waits in the queue.
|
|
777
853
|
await archive.settled();
|
|
778
|
-
await
|
|
854
|
+
await sync.stop();
|
|
779
855
|
})(),
|
|
780
856
|
deadline,
|
|
781
857
|
]);
|
|
@@ -837,7 +913,7 @@ export default function (pi: any) {
|
|
|
837
913
|
// The prompt as the desktop records its engine request: text, attached
|
|
838
914
|
// files (inline bytes replaced by a marker), model, variant, agent and
|
|
839
915
|
// the system prompt it ran with.
|
|
840
|
-
|
|
916
|
+
sync.recordTrace(state.id, "engine.request", {
|
|
841
917
|
method: "POST",
|
|
842
918
|
path: `/session/${state.id}/prompt`,
|
|
843
919
|
body: {
|
|
@@ -861,7 +937,7 @@ export default function (pi: any) {
|
|
|
861
937
|
images.forEach((image: any, index: number) => {
|
|
862
938
|
if (typeof image?.data !== "string") return;
|
|
863
939
|
const bytes = Buffer.from(image.data, "base64");
|
|
864
|
-
|
|
940
|
+
sync.recordAttachment(state.id, {
|
|
865
941
|
name: `image-${index + 1}`,
|
|
866
942
|
mime: typeof image.mimeType === "string" ? image.mimeType : "application/octet-stream",
|
|
867
943
|
bytes: bytes.length,
|
|
@@ -869,7 +945,7 @@ export default function (pi: any) {
|
|
|
869
945
|
text: null,
|
|
870
946
|
});
|
|
871
947
|
});
|
|
872
|
-
|
|
948
|
+
sync.captureSnapshot(state.id, "prompt");
|
|
873
949
|
archive.sessionStarted({
|
|
874
950
|
sessionId: state.id,
|
|
875
951
|
root: state.root,
|