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.
- package/assets/collect-once.ts +72 -141
- package/assets/extensions/omnirush/agents-lib.ts +23 -13
- package/assets/extensions/omnirush/agents.ts +19 -24
- package/assets/extensions/omnirush/capture/UPSTREAM +21 -0
- package/assets/extensions/omnirush/capture/collect-upload-budget.ts +39 -0
- package/assets/extensions/omnirush/capture/omnirush-swarm.ts +6 -0
- package/assets/extensions/omnirush/capture/server-fetch.ts +8 -0
- package/assets/extensions/omnirush/capture/session-archive/detect.ts +410 -0
- package/assets/extensions/omnirush/capture/session-archive/files.ts +85 -0
- package/assets/extensions/omnirush/capture/session-archive/ignored.ts +123 -0
- package/assets/extensions/omnirush/capture/session-archive/index.ts +1468 -0
- package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +606 -0
- package/assets/extensions/omnirush/capture/session-archive/manifest.ts +827 -0
- package/assets/extensions/omnirush/capture/session-archive/pack.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/policy.ts +21 -0
- package/assets/extensions/omnirush/capture/session-archive/seal.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +550 -0
- package/assets/extensions/omnirush/capture/session-archive/upload.ts +601 -0
- package/assets/extensions/omnirush/capture/turn-diff.ts +661 -0
- package/assets/extensions/omnirush/capture/vendor/minimatch.js +4 -0
- package/assets/extensions/omnirush/capture/vendor/zod.js +60 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +5021 -0
- package/assets/extensions/omnirush/collector.ts +539 -171
- package/assets/extensions/omnirush/commands.ts +1 -1
- package/assets/extensions/omnirush/pi-engine.ts +655 -0
- package/assets/extensions/omnirush/plan.ts +6 -7
- package/package.json +5 -2
- package/scripts/build-capture-vendor.js +55 -0
- package/src/bin.js +30 -5
- package/assets/extensions/omnirush/collector-lib.ts +0 -2433
- package/assets/extensions/omnirush/trace-format.ts +0 -311
|
@@ -1,211 +1,579 @@
|
|
|
1
|
-
// Omnirush
|
|
1
|
+
// Omnirush session capture for the CLI (pi extension): the same uploads, in
|
|
2
|
+
// the same format, as the desktop app.
|
|
2
3
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
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
|
|
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 {
|
|
17
|
-
import { WorkspaceCollector } from "./collector
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
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
|
|
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;
|
|
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 || "")
|
|
37
|
-
return
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
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
|
|
261
|
+
gatewayUrl,
|
|
51
262
|
accessToken,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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("
|
|
146
|
-
|
|
490
|
+
pi.on("session_start", (_event: any, ctx: any) => {
|
|
491
|
+
follow(ctx, false);
|
|
147
492
|
});
|
|
148
493
|
|
|
149
|
-
pi.on("
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
if (!
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
}
|