omnirush 0.7.0 → 0.8.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.
Files changed (31) hide show
  1. package/assets/collect-once.ts +72 -141
  2. package/assets/extensions/omnirush/agents-lib.ts +23 -13
  3. package/assets/extensions/omnirush/agents.ts +19 -24
  4. package/assets/extensions/omnirush/capture/UPSTREAM +21 -0
  5. package/assets/extensions/omnirush/capture/collect-upload-budget.ts +39 -0
  6. package/assets/extensions/omnirush/capture/omnirush-swarm.ts +6 -0
  7. package/assets/extensions/omnirush/capture/server-fetch.ts +8 -0
  8. package/assets/extensions/omnirush/capture/session-archive/detect.ts +410 -0
  9. package/assets/extensions/omnirush/capture/session-archive/files.ts +85 -0
  10. package/assets/extensions/omnirush/capture/session-archive/ignored.ts +123 -0
  11. package/assets/extensions/omnirush/capture/session-archive/index.ts +1468 -0
  12. package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +606 -0
  13. package/assets/extensions/omnirush/capture/session-archive/manifest.ts +827 -0
  14. package/assets/extensions/omnirush/capture/session-archive/pack.ts +481 -0
  15. package/assets/extensions/omnirush/capture/session-archive/policy.ts +21 -0
  16. package/assets/extensions/omnirush/capture/session-archive/seal.ts +481 -0
  17. package/assets/extensions/omnirush/capture/session-archive/touched.ts +550 -0
  18. package/assets/extensions/omnirush/capture/session-archive/upload.ts +601 -0
  19. package/assets/extensions/omnirush/capture/turn-diff.ts +661 -0
  20. package/assets/extensions/omnirush/capture/vendor/minimatch.js +4 -0
  21. package/assets/extensions/omnirush/capture/vendor/zod.js +60 -0
  22. package/assets/extensions/omnirush/capture/workspace-collector.ts +5021 -0
  23. package/assets/extensions/omnirush/collector.ts +539 -171
  24. package/assets/extensions/omnirush/commands.ts +1 -1
  25. package/assets/extensions/omnirush/pi-engine.ts +655 -0
  26. package/assets/extensions/omnirush/plan.ts +6 -7
  27. package/package.json +5 -2
  28. package/scripts/build-capture-vendor.js +55 -0
  29. package/src/bin.js +30 -5
  30. package/assets/extensions/omnirush/collector-lib.ts +0 -2433
  31. package/assets/extensions/omnirush/trace-format.ts +0 -311
@@ -1,211 +1,579 @@
1
- // Omnirush workspace collection during agent sessions (pi extension).
1
+ // Omnirush session capture for the CLI (pi extension): the same uploads, in
2
+ // the same format, as the desktop app.
2
3
  //
3
- // Owns the lifecycle: start snapshot on session start (pi session id,
4
- // workspace root = session cwd), trace events per turn/tool/provider
5
- // response, debounced change snapshots from the collector's watcher, and
6
- // trace + end snapshots on session shutdown (the shutdown handler is
7
- // awaited by pi, bounded below so a slow upload can't wedge exit).
4
+ // The capture itself is the desktop's, ported unchanged but for a few
5
+ // marked seams (capture/: workspace-collector.ts, turn-diff.ts and the
6
+ // project archive in session-archive/, from omnirush-gui apps/server/src).
7
+ // This file is the CLI's counterpart of the desktop's turn observer
8
+ // (collector-observer.ts) and server hooks: it tells the collector and the
9
+ // project archive what the agent does, from pi's events.
8
10
  //
9
- // Silent no-op when there are no device credentials — collection only
10
- // runs for signed-in omnirush identities.
11
+ // a prompt goes out engine.request trace event (prompt, model,
12
+ // variant, system prompt; images as attachment
13
+ // events), the "prompt" change snapshot, the
14
+ // project archive's session start (its base)
15
+ // a run settles the conversation since the last flush as
16
+ // engine messages (pi-engine.ts): session.model,
17
+ // turn.messages parts when it is long, each
18
+ // sub-agent's new messages as session.child, the
19
+ // "turn_completed" snapshot with its turn.diff, and
20
+ // turn.completed; the project archive's delta
21
+ // the session ends the trace and the end snapshot; queued archive
22
+ // uploads finish within the shutdown budget, and
23
+ // the final archive of what changed after the
24
+ // last turn is packed at exit and uploaded by the
25
+ // next run, as the desktop does at quit
26
+ //
27
+ // One `start` snapshot per session segment: the session's first prompt in a
28
+ // process sends it (trigger session_start), a resumed session (`--continue`,
29
+ // `--resume`, `--session`, a second process on the same session) sends one
30
+ // with trigger resume, exactly as the desktop does per app run. A sub-agent
31
+ // process (spawn_agents sets OMNIRUSH_PARENT_SESSION) uploads nothing: its
32
+ // session reaches the parent's trace as session.child events, like the
33
+ // desktop's task sub-agents.
34
+ //
35
+ // Silent no-op without device credentials: collection only runs for
36
+ // signed-in omnirush identities.
11
37
 
12
- import fs from "node:fs";
38
+ import { createHash } from "node:crypto";
39
+ import { readdirSync, readFileSync, rmSync } from "node:fs";
40
+ import { readdir, readFile, stat } from "node:fs/promises";
13
41
  import os from "node:os";
14
42
  import path from "node:path";
15
43
 
16
- import { deviceMe, gatewayUrlForOrigin, omniDir, resolveOrigin } from "./auth";
17
- import { WorkspaceCollector } from "./collector-lib";
44
+ import { gatewayUrlForOrigin, omniDir, resolveOrigin } from "./auth";
45
+ import { WorkspaceCollector } from "./capture/workspace-collector";
46
+ import { SessionArchiver } from "./capture/session-archive/index";
47
+ import { ProjectArchiveLifecycle, projectArchiveEnabled } from "./capture/session-archive/lifecycle";
48
+ import {
49
+ branchOfEntries,
50
+ engineMessagesFromEntries,
51
+ MAX_TURN_MESSAGE_BYTES,
52
+ messageParts,
53
+ parseSessionFile,
54
+ turnHistory,
55
+ turnModelFromMessages,
56
+ type EngineMessage,
57
+ type PiEntry,
58
+ } from "./pi-engine";
18
59
  import { sharedRefresher } from "./refresh";
19
- import { recordGatewayUsage, storedUsageRecord } from "./usage";
20
60
 
21
- // End snapshots are full-tree (reproducible environment) and chunked, so
22
- // shutdown can legitimately take minutes on big workspaces. Bounded so a
23
- // wedged upload still can't wedge exit.
24
- const SHUTDOWN_UPLOAD_BUDGET_MS = 120_000;
61
+ /** info.agent of the root session's messages (a sub-agent's is its role). */
62
+ export const ROOT_AGENT = "omnirush-cli";
63
+ /** Sub-agent layers captured below the root session (the desktop's MAX_COLLECTOR_CHILD_SESSION_DEPTH). */
64
+ const MAX_CHILD_DEPTH = 3;
65
+ /** Sub-agent sessions followed per session (the desktop's MAX_CHILD_SESSIONS). */
66
+ const MAX_CHILDREN = 200;
67
+ /** A base archive waits for a quiet moment after prompts (the desktop's PROJECT_ARCHIVE_BASE_IDLE_MS / _MAX_DEFER_MS). */
68
+ const ARCHIVE_BASE_IDLE_MS = 2_000;
69
+ const ARCHIVE_BASE_MAX_DEFER_MS = 10_000;
70
+ /**
71
+ * Shutdown: queued captures and archive uploads get this long in all before
72
+ * the process exits (what does not fit is uploaded at the next start from the
73
+ * durable queue). A print-mode run exits once it is spent.
74
+ */
75
+ const SHUTDOWN_BUDGET_MS = 120_000;
76
+ /** The same when the runtime is replaced (reload, another session) rather than quit. */
77
+ const SWITCH_BUDGET_MS = 30_000;
78
+ /**
79
+ * Print mode: once the session ended, a process the gateway's keep-alive
80
+ * sockets still hold open exits after this grace (the reply is printed and
81
+ * flushed before pi ends the session).
82
+ */
83
+ const PRINT_EXIT_GRACE_MS = 2_000;
25
84
 
26
- /** The pi agent config dir — session transcripts live under <dir>/sessions. */
85
+ /** The pi agent config dir: session files live under <dir>/sessions. */
27
86
  function piAgentDir(): string {
28
- const override =
29
- process.env.PI_CODING_AGENT_DIR || process.env.OMNIRUSH_CODING_AGENT_DIR || "";
87
+ const override = process.env.PI_CODING_AGENT_DIR || process.env.OMNIRUSH_CODING_AGENT_DIR || "";
30
88
  if (override.trim()) return path.resolve(override.trim());
31
89
  return path.join(os.homedir(), ".pi", "agent");
32
90
  }
33
91
 
34
- /** pi session ids are UUIDs; still, never let a foreign format stall us. */
92
+ /** pi session ids are UUIDs; the collector refuses anything outside its id alphabet. */
35
93
  function sanitizeSessionId(raw: string): string {
36
- const id = String(raw || "").replace(/[^A-Za-z0-9._:-]/g, "-").slice(0, 128);
37
- return id.length >= 8 ? id : "";
94
+ const id = String(raw || "");
95
+ return /^[A-Za-z0-9._:-]{8,128}$/.test(id) ? id : "";
96
+ }
97
+
98
+ /** A stable, opaque workspace id per folder (the desktop sends its workspace record's id). */
99
+ function workspaceIdFor(root: string): string {
100
+ return `cli_${createHash("sha256").update(path.resolve(root)).digest("hex").slice(0, 16)}`;
101
+ }
102
+
103
+ /** The CLI release (the launcher passes it; a checkout reads package.json). */
104
+ function cliVersion(): string {
105
+ const fromEnv = (process.env.OMNIRUSH_VERSION || "").trim();
106
+ if (fromEnv) return fromEnv;
107
+ try {
108
+ const parsed = JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")) as { version?: unknown };
109
+ return typeof parsed.version === "string" ? parsed.version : "";
110
+ } catch {
111
+ return "";
112
+ }
113
+ }
114
+
115
+ /** The previous collector's per-session change journals (`journal-<session>.ndjson`); the ported collector keeps none. */
116
+ function removeLegacyJournals(stateDir: string): void {
117
+ try {
118
+ for (const name of readdirSync(stateDir)) {
119
+ if (/^journal-.+\.ndjson(\.compact)?$/.test(name)) rmSync(path.join(stateDir, name), { force: true });
120
+ }
121
+ } catch {
122
+ /* no state dir yet */
123
+ }
124
+ }
125
+
126
+ /** Sub-agents a session started: its spawn_agents results (session id, role, task). */
127
+ export function spawnedChildren(entries: readonly PiEntry[]): Array<{ id: string; role: string | null; task: string | null }> {
128
+ const children: Array<{ id: string; role: string | null; task: string | null }> = [];
129
+ for (const entry of entries) {
130
+ const message = entry.type === "message" ? entry.message : null;
131
+ if (!message || message.role !== "toolResult" || message.toolName !== "spawn_agents") continue;
132
+ const results = message.details && Array.isArray(message.details.results) ? message.details.results : [];
133
+ for (const result of results) {
134
+ const id = result && typeof result.session_id === "string" ? result.session_id : "";
135
+ if (!sanitizeSessionId(id) || children.some((child) => child.id === id)) continue;
136
+ children.push({
137
+ id,
138
+ role: typeof result.role === "string" ? result.role : null,
139
+ task: typeof result.task === "string" ? result.task : null,
140
+ });
141
+ }
142
+ }
143
+ return children;
144
+ }
145
+
146
+ /** The session file of `sessionId`: `<ts>_<id>.jsonl` in one of the session dirs. */
147
+ async function findSessionFile(sessionId: string, dirs: readonly string[]): Promise<string | null> {
148
+ for (const dir of dirs) {
149
+ let names: string[];
150
+ try {
151
+ names = await readdir(dir);
152
+ } catch {
153
+ continue;
154
+ }
155
+ const name = names.find((candidate) => candidate.endsWith(`_${sessionId}.jsonl`));
156
+ if (name) return path.join(dir, name);
157
+ }
158
+ return null;
159
+ }
160
+
161
+ type Collector = Pick<
162
+ WorkspaceCollector,
163
+ "recordTrace" | "recordChildSession" | "childCheckpoints" | "childSessionIds"
164
+ >;
165
+
166
+ /**
167
+ * Records the sub-agent tree below a settled session: one session.child event
168
+ * per sub-agent carrying only its messages since its checkpoint, recursively
169
+ * for the sub-agents it started (collector-observer.ts
170
+ * `captureChildSessions`). `beforeRecord` hears the size of each event's
171
+ * messages before it is recorded.
172
+ */
173
+ export async function captureChildSessions(input: {
174
+ collector: Collector;
175
+ rootSessionId: string;
176
+ parentSessionId: string;
177
+ parentEntries: readonly PiEntry[];
178
+ depth: number;
179
+ sessionDirs: readonly string[];
180
+ checkpoints: Record<string, string>;
181
+ known: Set<string>;
182
+ visited: Set<string>;
183
+ /** Unchanged session files are not read again in this process. */
184
+ seenFiles: Map<string, string>;
185
+ beforeRecord: (bytes: number) => void;
186
+ }): Promise<void> {
187
+ if (input.depth > MAX_CHILD_DEPTH) return;
188
+ for (const child of spawnedChildren(input.parentEntries).slice(0, MAX_CHILDREN)) {
189
+ if (child.id === input.rootSessionId || input.visited.has(child.id)) continue;
190
+ input.visited.add(child.id);
191
+ const file = await findSessionFile(child.id, input.sessionDirs);
192
+ let entries: PiEntry[] = [];
193
+ let cwd = "";
194
+ if (file) {
195
+ const stats = await stat(file).catch(() => null);
196
+ const signature = stats ? `${stats.size}:${stats.mtimeMs}` : "";
197
+ if (signature && input.seenFiles.get(child.id) === signature && input.known.has(child.id)) continue;
198
+ const parsed = parseSessionFile(await readFile(file, "utf8").catch(() => ""));
199
+ entries = branchOfEntries(parsed.entries);
200
+ cwd = typeof parsed.header?.cwd === "string" ? parsed.header.cwd : "";
201
+ if (signature) input.seenFiles.set(child.id, signature);
202
+ }
203
+ const agent = child.role ?? "subagent";
204
+ const messages = engineMessagesFromEntries(entries, { sessionId: child.id, agent, cwd });
205
+ const history = turnHistory(messages, input.checkpoints[child.id], MAX_TURN_MESSAGE_BYTES);
206
+ if (history.omitted > 0) {
207
+ input.collector.recordTrace(input.rootSessionId, "session.messages_omitted", { child_session_id: child.id, count: history.omitted });
208
+ }
209
+ if (history.delta.length > 0 || !input.known.has(child.id)) {
210
+ input.beforeRecord(history.sizes.reduce((total, bytes) => total + bytes, 0));
211
+ input.collector.recordChildSession(input.rootSessionId, {
212
+ childSessionId: child.id,
213
+ parentSessionId: input.parentSessionId,
214
+ depth: input.depth,
215
+ title: child.task,
216
+ agent: turnModelFromMessages(messages)?.agent ?? agent,
217
+ messages: history.delta,
218
+ lastMessageId: typeof messages.at(-1)?.info.id === "string" ? (messages.at(-1)!.info.id as string) : null,
219
+ });
220
+ input.known.add(child.id);
221
+ }
222
+ await captureChildSessions({ ...input, parentSessionId: child.id, parentEntries: entries, depth: input.depth + 1 });
223
+ }
38
224
  }
39
225
 
40
226
  export default function (pi: any) {
227
+ // A sub-agent: the parent session captures it (session.child events).
228
+ if ((process.env.OMNIRUSH_PARENT_SESSION || "").trim()) return;
41
229
  const origin = resolveOrigin(process.env);
42
- const accessToken =
43
- (process.env.OMNIRUSH_ACCESS_TOKEN || process.env.OMNIRUSH_TOKEN || "").trim();
44
- // Linked/child mode (spawn_agents): the env var names the parent
45
- // session. This collector then ships only trace events + this
46
- // session's own transcript artifact (parent_session_id stamped) —
47
- // never a second workspace baseline for the same tree.
48
- const linkedParentSession = (process.env.OMNIRUSH_PARENT_SESSION || "").trim() || undefined;
230
+ const gatewayUrl = process.env.OMNIRUSH_GATEWAY_URL || gatewayUrlForOrigin(origin);
231
+ const accessToken = (process.env.OMNIRUSH_ACCESS_TOKEN || process.env.OMNIRUSH_TOKEN || "").trim();
232
+ const stateDir = omniDir();
233
+ const agentDir = piAgentDir();
234
+ const version = cliVersion();
235
+ const engineVersion = (process.env.OMNIRUSH_ENGINE_VERSION || "").trim();
236
+ const verbose = (process.env.OMNIRUSH_DEBUG || "").trim() === "1";
237
+ const log = (level: "info" | "warn", message: string, attributes?: Record<string, unknown>) => {
238
+ if (level !== "warn" && !verbose) return;
239
+ try {
240
+ const detail = attributes && Object.keys(attributes).length > 0 ? ` ${JSON.stringify(attributes)}` : "";
241
+ process.stderr.write(`omnirush collector: ${message}${detail}\n`);
242
+ } catch {
243
+ /* stderr may be closed late in shutdown */
244
+ }
245
+ };
246
+
247
+ // The bearer the uploads carry, rotated by the process-wide refresher (the
248
+ // sota guard shares it): a 401 asks it for the token after `current`.
249
+ let current = accessToken;
250
+ const refreshAccessToken = async (): Promise<string | null> => {
251
+ const refresher = sharedRefresher();
252
+ const refreshed = await refresher.refresh(current).catch(() => false);
253
+ const next = refresher.auth?.accessToken ?? null;
254
+ if (!refreshed || !next) return null;
255
+ current = next;
256
+ archiver.setAccessToken(next);
257
+ return next;
258
+ };
259
+
49
260
  const collector = new WorkspaceCollector({
50
- gatewayUrl: process.env.OMNIRUSH_GATEWAY_URL || gatewayUrlForOrigin(origin),
261
+ gatewayUrl,
51
262
  accessToken,
52
- stateDir: omniDir(),
53
- agentDir: piAgentDir(),
54
- linkedParentSession,
55
- clientId: (os.hostname() || "unknown").split(/[.\\s]/)[0].slice(0, 64) || null,
56
- identityProvider:
57
- accessToken
58
- ? async () => {
59
- // Best effort: the signed-in user id rides the trace header
60
- // (the acceptance contract for trace format spec). 401 ->
61
- // single-flight refresh -> retry once, like every other
62
- // Omnirush call; failures leave user_id null.
63
- const refresher = sharedRefresher();
64
- const token =
65
- refresher.auth?.accessToken ||
66
- (process.env.OMNIRUSH_TOKEN || "").trim();
67
- if (!token) return { userId: null };
68
- try {
69
- let me = await deviceMe(resolveOrigin(process.env), {
70
- accessToken: token,
71
- fetchImpl: globalThis.fetch,
72
- });
73
- if (me === null) {
74
- const refreshed = await refresher.refresh(token).catch(() => false);
75
- const next = refresher.auth?.accessToken;
76
- if (refreshed && next && next !== token) {
77
- me = await deviceMe(resolveOrigin(process.env), {
78
- accessToken: next,
79
- fetchImpl: globalThis.fetch,
80
- });
81
- }
82
- }
83
- return { userId: typeof me?.id === "string" ? me.id : null };
84
- } catch {
85
- return { userId: null };
86
- }
87
- }
88
- : undefined,
89
- refresh: accessToken
90
- ? async (tokenUsed: string) => {
91
- const refresher = sharedRefresher();
92
- const ok = await refresher.refresh(tokenUsed).catch(() => false);
93
- return ok ? refresher.auth?.accessToken ?? null : null;
94
- }
95
- : undefined,
96
- log: (level, message, attributes) => {
97
- if (level === "warn") {
98
- try {
99
- // Attributes carry the diagnosis (HTTP status, error message,
100
- // path, byte counts) — without them a remote failure is
101
- // undebuggable ("collection operation failed" and nothing
102
- // else). Attribute values are telemetry-safe (ids, sizes,
103
- // error strings; never tokens).
104
- const detail = attributes && Object.keys(attributes).length > 0
105
- ? ` ${JSON.stringify(attributes)}`
106
- : "";
107
- process.stderr.write(`omnirush collector: ${message}${detail}\n`);
108
- } catch {
109
- /* stderr may be closed late in shutdown */
110
- }
111
- }
263
+ refreshAccessToken,
264
+ stateDir,
265
+ log,
266
+ // environment.app_version tells the CLI apart from the desktop app (whose
267
+ // value is a bare release number); client_version / client_platform are
268
+ // the backend's optional envelope metadata.
269
+ appVersion: version ? `omnirush-cli/${version}` : "omnirush-cli",
270
+ ...(engineVersion ? { engineVersion: `pi/${engineVersion}` } : {}),
271
+ envelopeMetadata: {
272
+ ...(version ? { client_version: version } : {}),
273
+ client_platform: `${process.platform}/${process.arch}`,
112
274
  },
275
+ // Polled on snapshots (the desktop's mode past its watcher cap): no
276
+ // fs.watch handle can hold a short-lived CLI process open at exit.
277
+ watch: false,
278
+ onPathTouched: (sessionId, touched) => archive.pathTouched(sessionId, touched),
279
+ });
280
+ const archiver = new SessionArchiver({
281
+ stateDir,
282
+ gatewayUrl,
283
+ accessToken,
284
+ refreshAccessToken,
285
+ // The CLI's own state (auth, spool, archive queue) and the agent's
286
+ // session files are never archived when they sit inside a project.
287
+ excludedDirs: [stateDir, agentDir],
288
+ log,
113
289
  });
114
- if (!collector.enabled) return;
290
+ const archive = new ProjectArchiveLifecycle({
291
+ archiver,
292
+ enabled: projectArchiveEnabled(process.env) && collector.enabled,
293
+ log,
294
+ baseIdleMs: ARCHIVE_BASE_IDLE_MS,
295
+ baseMaxDeferMs: ARCHIVE_BASE_MAX_DEFER_MS,
296
+ });
297
+ if (!collector.enabled) {
298
+ // Signed out: clear what a previous run left queued (no network).
299
+ archive.start();
300
+ return;
301
+ }
302
+ removeLegacyJournals(stateDir);
303
+ archive.start();
115
304
 
116
- let sessionId = "";
305
+ /** The session this process is following. */
306
+ let session: {
307
+ id: string;
308
+ root: string;
309
+ ctx: any;
310
+ /** A prompt went out in this process: the collector follows the session. */
311
+ started: boolean;
312
+ /** A prompt went out and its run has not settled. */
313
+ running: boolean;
314
+ /** The id of the last engine message recorded (the collector's checkpoint). */
315
+ lastMessageId: string | undefined;
316
+ toolStarts: Map<string, number>;
317
+ seenFiles: Map<string, string>;
318
+ } | null = null;
319
+ let settling: Promise<void> = Promise.resolve();
117
320
 
118
- pi.on("session_start", (event: any, ctx: any) => {
119
- sessionId = sanitizeSessionId(ctx?.sessionManager?.getSessionId?.() ?? "");
120
- const root = ctx?.cwd || process.cwd();
121
- if (!sessionId) return;
122
- collector.startSession(sessionId, path.basename(root) || "workspace", root);
123
- collector.recordTrace(sessionId, "session.start", { reason: event?.reason });
124
- });
321
+ const sessionDirs = (ctx: any): string[] => {
322
+ const dirs: string[] = [];
323
+ const file = ctx?.sessionManager?.getSessionFile?.();
324
+ if (typeof file === "string" && file) dirs.push(path.dirname(file));
325
+ const sessionDir = ctx?.sessionManager?.getSessionDir?.();
326
+ if (typeof sessionDir === "string" && sessionDir && !dirs.includes(sessionDir)) dirs.push(sessionDir);
327
+ try {
328
+ const root = path.join(agentDir, "sessions");
329
+ for (const name of readdirSync(root)) {
330
+ const dir = path.join(root, name);
331
+ if (!dirs.includes(dir)) dirs.push(dir);
332
+ }
333
+ } catch {
334
+ /* no sessions yet */
335
+ }
336
+ return dirs;
337
+ };
125
338
 
126
- pi.on("turn_start", (event: any) => {
127
- collector.recordTrace(sessionId, "turn.start", { turn_index: event?.turnIndex });
128
- });
339
+ const messagesOf = (state: NonNullable<typeof session>): EngineMessage[] => {
340
+ const entries = state.ctx?.sessionManager?.getBranch?.() ?? [];
341
+ return engineMessagesFromEntries(entries, { sessionId: state.id, agent: ROOT_AGENT, cwd: state.root, toolStarts: state.toolStarts });
342
+ };
129
343
 
130
- pi.on("turn_end", (event: any) => {
131
- collector.recordTrace(sessionId, "turn.end", { turn_index: event?.turnIndex });
132
- // Per-turn trace flush: a complete valid trace artifact (lifecycle
133
- // events + transcript messages-so-far) ships after EVERY completed
134
- // turn on the priority lane, instead of waiting for the 2,000-event
135
- // relief threshold or shutdown. An interrupted process therefore
136
- // still leaves every turn it finished behind, and the artifacts
137
- // never queue behind a multi-part workspace baseline.
138
- collector.flushTrace(sessionId);
139
- });
344
+ /**
345
+ * The session `ctx` is on. Its collection starts with its first prompt in
346
+ * this process, as the desktop starts it on a prompt dispatch: opening a
347
+ * session and leaving without a prompt uploads nothing.
348
+ */
349
+ const follow = (ctx: any, prompted: boolean): typeof session => {
350
+ const id = sanitizeSessionId(ctx?.sessionManager?.getSessionId?.() ?? "");
351
+ if (!id) return null;
352
+ if (session?.id !== id) {
353
+ const root = ctx?.cwd || process.cwd();
354
+ session = { id, root, ctx, started: false, running: false, lastMessageId: undefined, toolStarts: new Map(), seenFiles: new Map() };
355
+ }
356
+ session.ctx = ctx;
357
+ // Idempotent: a session already started in this process keeps its segment.
358
+ if (prompted) {
359
+ collector.startSession(id, workspaceIdFor(session.root), session.root);
360
+ session.started = true;
361
+ }
362
+ return session;
363
+ };
140
364
 
141
- pi.on("tool_execution_start", (event: any) => {
142
- collector.recordTrace(sessionId, "tool.start", { tool: event?.toolName });
143
- });
365
+ /**
366
+ * Everything a settled run records, in the desktop observer's order:
367
+ * messages, sub-agents, the idle event, the snapshot, the trace and the
368
+ * archive delta.
369
+ */
370
+ const settle = async (state: NonNullable<typeof session>, status: string): Promise<void> => {
371
+ state.running = false;
372
+ const { id } = state;
373
+ let messages: EngineMessage[] = [];
374
+ let parts: Array<{ messages: unknown[]; bytes: number }> = [{ messages: [], bytes: 0 }];
375
+ try {
376
+ messages = messagesOf(state);
377
+ const checkpoint = state.lastMessageId ?? (await collector.sessionCheckpoint(id)).lastMessageId;
378
+ const history = turnHistory(messages, checkpoint);
379
+ const lastId = messages.at(-1)?.info.id;
380
+ if (typeof lastId === "string") {
381
+ state.lastMessageId = lastId;
382
+ void collector.setSessionCheckpoint(id, lastId);
383
+ }
384
+ if (history.omitted > 0) collector.recordTrace(id, "session.messages_omitted", { count: history.omitted });
385
+ const model = turnModelFromMessages(history.delta);
386
+ if (model) collector.recordSessionModel(id, model);
387
+ // More messages than one flush holds: the older ones go ahead, a flush per part.
388
+ parts = messageParts(history.delta, history.sizes);
389
+ for (const [index, part] of parts.slice(0, -1).entries()) {
390
+ collector.recordTrace(id, "turn.messages", { messages: part.messages, part: index + 1, parts: parts.length });
391
+ collector.flushTrace(id);
392
+ }
393
+ } catch (error) {
394
+ collector.recordTrace(id, "session.messages_failed", { error: error instanceof Error ? error.message : "unknown" });
395
+ }
396
+ const newest = parts.at(-1)!;
397
+ // Sub-agent messages flush on their own before they would crowd the turn's out.
398
+ let pending = 0;
399
+ const room = (bytes: number) => {
400
+ if (pending > 0 && pending + bytes > MAX_TURN_MESSAGE_BYTES) {
401
+ collector.flushTrace(id);
402
+ pending = 0;
403
+ }
404
+ pending += bytes;
405
+ };
406
+ try {
407
+ await captureChildSessions({
408
+ collector,
409
+ rootSessionId: id,
410
+ parentSessionId: id,
411
+ parentEntries: state.ctx?.sessionManager?.getBranch?.() ?? [],
412
+ depth: 1,
413
+ sessionDirs: sessionDirs(state.ctx),
414
+ checkpoints: await collector.childCheckpoints(id),
415
+ known: new Set(await collector.childSessionIds(id)),
416
+ visited: new Set([id]),
417
+ seenFiles: state.seenFiles,
418
+ beforeRecord: room,
419
+ });
420
+ } catch (error) {
421
+ collector.recordTrace(id, "session.children_failed", { error: error instanceof Error ? error.message : "unknown" });
422
+ }
423
+ room(newest.bytes);
424
+ collector.recordTrace(id, "session.idle", { status });
425
+ // The turn snapshot runs first so the artifacts it discovers are part of
426
+ // the trace flushed right behind it.
427
+ collector.captureSnapshot(id, "turn_completed");
428
+ collector.flushTrace(id, { messages: newest.messages });
429
+ archive.turnCompleted(id, messages);
430
+ };
431
+
432
+ /** Settles the running run, if any, once the previous settle finished. */
433
+ const settleRunning = (status: string): Promise<void> => {
434
+ const state = session;
435
+ if (!state?.running) return settling;
436
+ state.running = false;
437
+ settling = settling.then(() => settle(state, status)).catch(() => undefined);
438
+ return settling;
439
+ };
440
+
441
+ /**
442
+ * At the end of a followed session, messages recorded after its last
443
+ * settled run (a `!command` the user ran, a run that never settled) are
444
+ * settled too, so they reach the trace.
445
+ */
446
+ const settleRemaining = async (): Promise<void> => {
447
+ await settleRunning("interrupted");
448
+ const state = session;
449
+ if (!state?.started) return;
450
+ const lastId = messagesOf(state).at(-1)?.info.id;
451
+ if (typeof lastId !== "string" || lastId === state.lastMessageId) return;
452
+ settling = settling.then(() => settle(state, "idle")).catch(() => undefined);
453
+ await settling;
454
+ };
455
+
456
+ /** Everything queued goes out (bounded), then the session ends and the archive stops. Once per runtime. */
457
+ let stopping: Promise<void> | null = null;
458
+ const shutdown = (budgetMs: number): Promise<void> => (stopping ??= stop(budgetMs));
459
+ const stop = async (budgetMs: number): Promise<void> => {
460
+ // A ref'd timer: the collector's and the archive's own timers are unref'd,
461
+ // and pi awaits this handler without holding the process open, so without
462
+ // it a process with nothing else to do exits in the middle of an upload.
463
+ let budget: ReturnType<typeof setTimeout> | undefined;
464
+ const deadline = new Promise<void>((resolvePromise) => {
465
+ budget = setTimeout(resolvePromise, budgetMs);
466
+ });
467
+ await Promise.race([
468
+ (async () => {
469
+ await settleRemaining();
470
+ const state = session;
471
+ session = null;
472
+ if (state) {
473
+ // The settled turn's own snapshot (its turn.diff) and trace go out
474
+ // before the session ends: a finished session skips queued captures.
475
+ await collector.idle(state.id);
476
+ collector.finishSession(state.id);
477
+ }
478
+ // The base and turn deltas upload now; what is left waits in the queue.
479
+ await archive.settled();
480
+ await collector.stop();
481
+ })(),
482
+ deadline,
483
+ ]);
484
+ // Final archives of this run's sessions (packed within the archive's own
485
+ // 5 s budget, uploaded at the next start).
486
+ await archive.stop({ finals: true }).catch(() => undefined);
487
+ clearTimeout(budget);
488
+ };
144
489
 
145
- pi.on("agent_end", () => {
146
- collector.recordTrace(sessionId, "agent.end");
490
+ pi.on("session_start", (_event: any, ctx: any) => {
491
+ follow(ctx, false);
147
492
  });
148
493
 
149
- pi.on("after_provider_response", (event: any) => {
150
- const usage = recordGatewayUsage(event?.headers);
151
- collector.recordTrace(sessionId, "provider.response", {
152
- status: event?.status,
153
- // Stored records stay brand-neutral (the traces are the product).
154
- ...(usage ? { agent: storedUsageRecord(usage) } : {}),
494
+ pi.on("before_agent_start", (event: any, ctx: any) => {
495
+ const state = follow(ctx, true);
496
+ if (!state) return;
497
+ state.running = true;
498
+ const model = ctx?.model;
499
+ const images = Array.isArray(event?.images) ? event.images : [];
500
+ // The prompt as the desktop records its engine request: text, attached
501
+ // files (inline bytes replaced by a marker), model, variant, agent and
502
+ // the system prompt it ran with.
503
+ collector.recordTrace(state.id, "engine.request", {
504
+ method: "POST",
505
+ path: `/session/${state.id}/prompt`,
506
+ body: {
507
+ parts: [
508
+ ...(typeof event?.prompt === "string" && event.prompt ? [{ type: "text", text: event.prompt }] : []),
509
+ ...images.map((image: any) => ({
510
+ type: "file",
511
+ mime: typeof image?.mimeType === "string" ? image.mimeType : "application/octet-stream",
512
+ url: `data:${typeof image?.mimeType === "string" ? image.mimeType : "application/octet-stream"};omitted`,
513
+ omitted_url_chars: typeof image?.data === "string" ? image.data.length : 0,
514
+ })),
515
+ ],
516
+ ...(model && typeof model.provider === "string" && typeof model.id === "string"
517
+ ? { model: { providerID: model.provider, modelID: model.id } }
518
+ : {}),
519
+ ...(typeof ctx?.thinkingLevel === "string" && ctx.thinkingLevel !== "off" ? { variant: ctx.thinkingLevel } : {}),
520
+ agent: ROOT_AGENT,
521
+ ...(typeof event?.systemPrompt === "string" && event.systemPrompt ? { system: event.systemPrompt } : {}),
522
+ },
523
+ });
524
+ images.forEach((image: any, index: number) => {
525
+ if (typeof image?.data !== "string") return;
526
+ const bytes = Buffer.from(image.data, "base64");
527
+ collector.recordAttachment(state.id, {
528
+ name: `image-${index + 1}`,
529
+ mime: typeof image.mimeType === "string" ? image.mimeType : "application/octet-stream",
530
+ bytes: bytes.length,
531
+ sha256: createHash("sha256").update(bytes).digest("hex"),
532
+ text: null,
533
+ });
155
534
  });
535
+ collector.captureSnapshot(state.id, "prompt");
536
+ archive.sessionStarted({
537
+ sessionId: state.id,
538
+ root: state.root,
539
+ engine: {
540
+ // pi's forks are sessions of their own; sub-agents never reach here.
541
+ session: async () => ({ id: state.id, parentID: null }),
542
+ messages: async () => messagesOf(state),
543
+ },
544
+ });
545
+ archive.turnFollowed(state.id);
156
546
  });
157
547
 
158
- // Print-mode exit (issue: process hangs after the reply). The agent
159
- // finishes the turn and prints, but the gateway's keep-alive sockets
160
- // sometimes keep the runtime's event loop ref'd for minutes, so pi
161
- // never drains. When the one-shot run settles: finalize the collector
162
- // (journal + trace + transcripts + end snapshot ship, bounded), then
163
- // exit explicitly. TUI/RPC sessions never take this path.
164
- let printRunStarted = false;
165
- pi.on("agent_start", () => {
166
- printRunStarted = true;
548
+ pi.on("tool_execution_start", (event: any) => {
549
+ if (session && typeof event?.toolCallId === "string") session.toolStarts.set(event.toolCallId, Date.now());
167
550
  });
551
+
552
+ let printMode = false;
553
+ // A run settled: no retry, compaction or queued follow-up will run.
554
+ // (agent_settled also fires at startup with nothing to do.)
168
555
  pi.on("agent_settled", async (_event: any, ctx: any) => {
169
- // agent_settled also fires at startup with nothing to do — only the
170
- // completion of an ACTUAL run finalizes and exits here.
171
- if (!printRunStarted) return;
172
- printRunStarted = false;
173
- if (ctx?.mode !== "print" || !sessionId) return;
174
- collector.finishSession(sessionId);
175
- await Promise.race([
176
- collector.stop(),
177
- new Promise((resolve) => {
178
- const timer = setTimeout(resolve, 45_000);
179
- timer.unref?.();
180
- }),
181
- ]);
182
- // Flush buffered stdout (the reply) before the hard exit — bun
183
- // buffers pipe writes and process.exit would drop them. Bun needs
184
- // the explicit flush() (the empty-write callback from the previous
185
- // attempt fired immediately and the exit still ate the reply); a
186
- // short grace covers runtimes without flush().
187
- try {
188
- (process.stdout as any).flush?.();
189
- } catch {
190
- /* non-bun runtime */
191
- }
192
- await new Promise((resolve) => {
193
- const grace = setTimeout(resolve, 250);
194
- grace.unref?.();
195
- });
196
- process.exit(0);
556
+ if (ctx?.mode === "print" || ctx?.mode === "json") printMode = true;
557
+ const state = session;
558
+ if (!state?.running) return;
559
+ state.ctx = ctx ?? state.ctx;
560
+ await settleRunning("idle");
197
561
  });
198
562
 
199
- pi.on("session_shutdown", async () => {
200
- if (!sessionId) return;
201
- collector.finishSession(sessionId);
202
- await Promise.race([
203
- collector.stop(),
204
- new Promise((resolve) => {
205
- const timer = setTimeout(resolve, SHUTDOWN_UPLOAD_BUDGET_MS);
206
- timer.unref?.();
207
- }),
208
- ]);
209
- sessionId = "";
563
+ // pi tears this extension runtime down on quit, reload and session
564
+ // replacement (/new, /resume, /fork): the session's trace and end snapshot
565
+ // go out and the archive stops (the next runtime starts its own).
566
+ pi.on("session_shutdown", async (event: any) => {
567
+ if (event?.reason !== "quit") {
568
+ await shutdown(SWITCH_BUDGET_MS);
569
+ return;
570
+ }
571
+ await shutdown(SHUTDOWN_BUDGET_MS);
572
+ if (printMode && event?.reason === "quit") {
573
+ // The one-shot run is over and its uploads are done: the gateway's
574
+ // keep-alive sockets can hold the event loop open for minutes.
575
+ const timer = setTimeout(() => process.exit(process.exitCode ?? 0), PRINT_EXIT_GRACE_MS);
576
+ timer.unref?.();
577
+ }
210
578
  });
211
579
  }