@giladbarnea/pi-user-agents 0.0.4 → 0.0.6

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/attach.ts ADDED
@@ -0,0 +1,339 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createAgentSessionFromServices, SessionManager } from "@earendil-works/pi-coding-agent";
4
+ import { conversationFingerprint } from "./rebase.js";
5
+ import {
6
+ assistantText,
7
+ buildChildResourceLoaderOptions,
8
+ createChildServices,
9
+ DISPATCH_PREAMBLE,
10
+ parseForwardedArgs,
11
+ reportAgentFailure,
12
+ resolveToolOptions,
13
+ runChildTurns,
14
+ subscribeToChildSession,
15
+ waitForInstruction,
16
+ } from "./runner.js";
17
+ import type {
18
+ AgentMessage,
19
+ AgentSession,
20
+ AttachedEntryData,
21
+ DispatchRecordData,
22
+ ExtensionAPI,
23
+ ExtensionCommandContext,
24
+ Model,
25
+ RunningAgent,
26
+ } from "./shared.js";
27
+ import {
28
+ ATTACHED_ENTRY_TYPE,
29
+ DISPATCH_ENTRY_TYPE,
30
+ errorMessage,
31
+ formatModel,
32
+ formatModelLabel,
33
+ logSteering,
34
+ } from "./shared.js";
35
+ import { buildAgentResultMessage, reportCommandError } from "./transcript.js";
36
+ import type { UserAgentWidget } from "./widget.js";
37
+
38
+ /** A dispatch base that can never fingerprint-match a live context, so rebase stays withheld. */
39
+ export const UNKNOWN_DISPATCH_BASE = "unknown-dispatch-base";
40
+
41
+ export type DispatchBoundary = {
42
+ /** The main-session context the child inherited at dispatch — the rebase fast-forward base. */
43
+ base: AgentMessage[];
44
+ /** The child's own conversation, starting at the preambled dispatch message. */
45
+ conversation: AgentMessage[];
46
+ /** The dispatch task, preamble stripped. */
47
+ task: string;
48
+ };
49
+
50
+ /**
51
+ * Split a child's resolved context at its dispatch boundary — the first user message carrying
52
+ * the dispatch preamble. Undefined when no message carries it (a /fork child, or a child whose
53
+ * own compaction summarized the boundary away).
54
+ */
55
+ export function splitAtDispatchBoundary(
56
+ messages: readonly AgentMessage[],
57
+ ): DispatchBoundary | undefined {
58
+ for (const [index, message] of messages.entries()) {
59
+ if (message.role !== "user") continue;
60
+ const text = userMessageText(message);
61
+ if (!text.startsWith(DISPATCH_PREAMBLE)) continue;
62
+ return {
63
+ base: messages.slice(0, index),
64
+ conversation: messages.slice(index),
65
+ task: text.slice(DISPATCH_PREAMBLE.length),
66
+ };
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function userMessageText(message: Extract<AgentMessage, { role: "user" }>): string {
72
+ if (typeof message.content === "string") return message.content;
73
+ return message.content
74
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
75
+ .join("\n");
76
+ }
77
+
78
+ export type ChildSessionFileMatch = { sessionId: string; file: string };
79
+
80
+ /**
81
+ * The session files in a directory whose id matches the query exactly or by prefix.
82
+ * Pi names session files `<timestamp>_<session-id>.jsonl`, so matching reads names only.
83
+ */
84
+ export function matchChildSessionFiles(
85
+ query: string,
86
+ sessionDirectory: string,
87
+ ): ChildSessionFileMatch[] {
88
+ const fileNames = existsSync(sessionDirectory) ? readdirSync(sessionDirectory) : [];
89
+ return fileNames.flatMap((name) => {
90
+ const sessionId = sessionIdFromFileName(name);
91
+ if (sessionId === undefined || !sessionId.startsWith(query)) return [];
92
+ return [{ sessionId, file: join(sessionDirectory, name) }];
93
+ });
94
+ }
95
+
96
+ function sessionIdFromFileName(fileName: string): string | undefined {
97
+ if (!fileName.endsWith(".jsonl")) return undefined;
98
+ const separator = fileName.indexOf("_");
99
+ if (separator === -1) return undefined;
100
+ return fileName.slice(separator + 1, -".jsonl".length);
101
+ }
102
+
103
+ /** The dispatch record persisted in the child's file, or undefined for pre-record and /fork children. */
104
+ export function readDispatchRecord(
105
+ childSessionManager: SessionManager,
106
+ ): DispatchRecordData | undefined {
107
+ for (const entry of childSessionManager.getEntries()) {
108
+ if (entry.type === "custom" && entry.customType === DISPATCH_ENTRY_TYPE)
109
+ return entry.data as DispatchRecordData | undefined;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ /**
115
+ * Rebuild an idle, steerable RunningAgent from a reopened child session and its resolved model.
116
+ * The child's own conversation, task, latest response, and rebase base all come from the file;
117
+ * the dispatch record refines the task and context flag, and with no recognizable dispatch
118
+ * boundary the whole context attaches and rebase stays withheld.
119
+ */
120
+ export function buildAttachedAgent(
121
+ sequenceNumber: number,
122
+ childSessionManager: SessionManager,
123
+ model: Model,
124
+ invocation: string,
125
+ record?: DispatchRecordData,
126
+ ): RunningAgent {
127
+ const contextMessages = childSessionManager.buildSessionContext().messages;
128
+ const boundary = splitAtDispatchBoundary(contextMessages);
129
+ const conversationMessages = structuredClone(boundary?.conversation ?? contextMessages);
130
+ const firstUserMessage = contextMessages.find((message) => message.role === "user");
131
+ const lastAssistantMessage = conversationMessages.findLast(
132
+ (message): message is Extract<AgentMessage, { role: "assistant" }> =>
133
+ message.role === "assistant",
134
+ );
135
+ const responseText = lastAssistantMessage ? assistantText(lastAssistantMessage).trim() : "";
136
+ const now = Date.now();
137
+ const agent: RunningAgent = {
138
+ id: `user-${sequenceNumber.toString(36)}`,
139
+ sessionId: childSessionManager.getSessionId(),
140
+ command: "agent",
141
+ inheritedContext: record
142
+ ? !record.isolate
143
+ : boundary
144
+ ? boundary.base.length > 0
145
+ : true,
146
+ model: formatModel(model),
147
+ modelLabel: formatModelLabel(model),
148
+ task:
149
+ record?.task ??
150
+ boundary?.task ??
151
+ (firstUserMessage === undefined ? "(no messages)" : userMessageText(firstUserMessage)),
152
+ invocation,
153
+ notifyMainAgent: false,
154
+ dispatchBaseFingerprint: boundary
155
+ ? conversationFingerprint(boundary.base)
156
+ : UNKNOWN_DISPATCH_BASE,
157
+ mainContextState: "separate",
158
+ status: "idle",
159
+ startedAt: now,
160
+ turnStartedAt: now,
161
+ completedAt: now,
162
+ activeTools: new Map(),
163
+ toolUses: 0,
164
+ turnCount: 0,
165
+ responseText,
166
+ conversationMessages,
167
+ finished: Promise.resolve(),
168
+ };
169
+ if (responseText)
170
+ agent.pendingSquashMessage = buildAgentResultMessage(
171
+ agent,
172
+ { ok: true, response: responseText },
173
+ { display: false },
174
+ );
175
+ return agent;
176
+ }
177
+
178
+ /**
179
+ * The attached agent's lifecycle: bind the child's extensions, park until the user steers or
180
+ * retires it, then run turns exactly like a dispatched child. Every failure — the bind's
181
+ * included — settles through the shared error path, and the end shuts the child's extensions
182
+ * down.
183
+ */
184
+ export async function runAttachedTurns(
185
+ pi: ExtensionAPI,
186
+ isShuttingDown: () => boolean,
187
+ session: AgentSession,
188
+ runningAgent: RunningAgent,
189
+ widget: UserAgentWidget,
190
+ ): Promise<void> {
191
+ let unsubscribe: (() => void) | undefined;
192
+ try {
193
+ await session.bindExtensions({ mode: "print" });
194
+ unsubscribe = subscribeToChildSession(session, runningAgent, widget);
195
+ // The user can detach during the extension bind, before this lifecycle parks.
196
+ if (runningAgent.aborted) return;
197
+ const instruction = await waitForInstruction(runningAgent, widget);
198
+ if (instruction !== undefined)
199
+ await runChildTurns(pi, isShuttingDown, instruction, session, runningAgent, widget);
200
+ } catch (error) {
201
+ reportAgentFailure(pi, isShuttingDown, runningAgent, widget, error);
202
+ } finally {
203
+ unsubscribe?.();
204
+ logSteering(runningAgent.id, "session-shutdown", { streaming: session.isStreaming });
205
+ await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
206
+ }
207
+ }
208
+
209
+ export async function handleAttachCommand(
210
+ pi: ExtensionAPI,
211
+ runningAgents: Set<RunningAgent>,
212
+ widget: UserAgentWidget,
213
+ isShuttingDown: () => boolean,
214
+ nextAgentNumber: () => number,
215
+ args: string,
216
+ ctx: ExtensionCommandContext,
217
+ ): Promise<void> {
218
+ try {
219
+ await attachUserAgent(pi, runningAgents, widget, isShuttingDown, nextAgentNumber, args, ctx);
220
+ } catch (error) {
221
+ reportCommandError(pi, "agent-attach", args, ctx, errorMessage(error));
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Reattach a detached child session as a live, parked agent row. Idempotent for a session
227
+ * that already has a row; errors for anything that is not a child session of this session.
228
+ * Both success states end by offering to open the agent's overlay.
229
+ */
230
+ async function attachUserAgent(
231
+ pi: ExtensionAPI,
232
+ runningAgents: Set<RunningAgent>,
233
+ widget: UserAgentWidget,
234
+ isShuttingDown: () => boolean,
235
+ nextAgentNumber: () => number,
236
+ args: string,
237
+ ctx: ExtensionCommandContext,
238
+ ): Promise<void> {
239
+ const query = args.trim();
240
+ if (!query || /\s/.test(query)) throw new Error("Usage: /agent-attach <child-session-id>");
241
+ if (ctx.hasUI) widget.setUI(ctx.ui);
242
+
243
+ // One resolution over the whole universe — attached rows and files on disk together — so a
244
+ // prefix unique among rows cannot mask an ambiguity with a detached session, and a live row
245
+ // whose file is not written yet still resolves. Dispatch creates children in pi's default
246
+ // session directory for the cwd; that is where the files are.
247
+ const attachedIds = widget.matchAttachedSessionIds(query);
248
+ const childSessionDirectory = SessionManager.create(ctx.cwd).getSessionDir();
249
+ const fileMatches = matchChildSessionFiles(query, childSessionDirectory);
250
+ const matchedSessionIds = [
251
+ ...new Set([...attachedIds, ...fileMatches.map((match) => match.sessionId)]),
252
+ ];
253
+ if (matchedSessionIds.length === 0) throw new Error(`No agent session matches "${query}"`);
254
+ if (matchedSessionIds.length > 1)
255
+ throw new Error(`"${query}" matches ${matchedSessionIds.length} sessions; use more characters`);
256
+ const sessionId = matchedSessionIds[0]!;
257
+
258
+ if (attachedIds.includes(sessionId)) {
259
+ await confirmViewAgent(ctx, widget, sessionId, "Agent already attached");
260
+ return;
261
+ }
262
+
263
+ const mainSessionFile = ctx.sessionManager.getSessionFile();
264
+ if (!mainSessionFile)
265
+ throw new Error("This session has no file; only a persisted session can attach agents");
266
+ const childSessionFile = fileMatches.find((match) => match.sessionId === sessionId)!.file;
267
+ const childSessionManager = SessionManager.open(childSessionFile);
268
+ if (childSessionManager.getHeader()?.parentSession !== mainSessionFile)
269
+ throw new Error(
270
+ `Session ${childSessionManager.getSessionId()} is not a child session of this session`,
271
+ );
272
+
273
+ // The record replays through the same projections dispatch uses; a record-less child (an old
274
+ // dispatch, or a /fork) restores pi defaults.
275
+ const record = readDispatchRecord(childSessionManager);
276
+ const dispatchArgs = parseForwardedArgs(record?.forwardedArgs ?? []);
277
+ const services = await createChildServices(
278
+ ctx,
279
+ buildChildResourceLoaderOptions(dispatchArgs, ctx.cwd),
280
+ );
281
+ // A non-empty session manager takes pi's resume path: messages, model, and thinking level
282
+ // are restored from the child's own file; tools and resources come from the dispatch record.
283
+ const { session, modelFallbackMessage } = await createAgentSessionFromServices({
284
+ services,
285
+ sessionManager: childSessionManager,
286
+ ...resolveToolOptions(dispatchArgs),
287
+ });
288
+ const model = session.agent.state.model as Model | undefined;
289
+ if (!model) {
290
+ session.dispose();
291
+ throw new Error(modelFallbackMessage ?? "No model is available for the attached session");
292
+ }
293
+ if (modelFallbackMessage && ctx.hasUI) ctx.ui.notify(modelFallbackMessage, "warning");
294
+
295
+ const runningAgent = buildAttachedAgent(
296
+ nextAgentNumber(),
297
+ childSessionManager,
298
+ model,
299
+ `/agent-attach ${query}`,
300
+ record,
301
+ );
302
+ runningAgent.session = session;
303
+ runningAgents.add(runningAgent);
304
+ logSteering(runningAgent.id, "agent-attached", { sessionId: runningAgent.sessionId });
305
+ if (ctx.hasUI) {
306
+ widget.ensureTimer();
307
+ widget.update();
308
+ }
309
+ // No awaits between the add above and this assignment: every later failure settles inside
310
+ // the lifecycle, so the row can never outlive its own removal hook.
311
+ runningAgent.finished = runAttachedTurns(
312
+ pi,
313
+ isShuttingDown,
314
+ session,
315
+ runningAgent,
316
+ widget,
317
+ ).finally(() => {
318
+ logSteering(runningAgent.id, "agent-disposed", { status: runningAgent.status });
319
+ runningAgent.session?.dispose();
320
+ runningAgents.delete(runningAgent);
321
+ widget.update();
322
+ });
323
+ // The detach breadcrumb's counterpart: after a reload the transcript still tells the truth.
324
+ pi.appendEntry<AttachedEntryData>(ATTACHED_ENTRY_TYPE, { sessionId: runningAgent.sessionId });
325
+ if (runningAgent.aborted) return;
326
+ await confirmViewAgent(ctx, widget, runningAgent.sessionId, "Agent attached");
327
+ }
328
+
329
+ /** The follow-up both success states share: offer to open the agent's overlay. */
330
+ async function confirmViewAgent(
331
+ ctx: ExtensionCommandContext,
332
+ widget: UserAgentWidget,
333
+ sessionId: string,
334
+ title: string,
335
+ ): Promise<void> {
336
+ if (!ctx.hasUI) return;
337
+ const view = await ctx.ui.confirm(title, "View agent?");
338
+ if (view) widget.openViewer(sessionId);
339
+ }
package/command-line.ts CHANGED
@@ -57,7 +57,7 @@ export type AgentSemanticToken = {
57
57
 
58
58
  export type AgentArgumentScan = {
59
59
  isolate: boolean;
60
- context: boolean;
60
+ squash: boolean;
61
61
  forwardedArgs: string[];
62
62
  /** Ordered, non-overlapping semantic tokens over the argument text. Prose stays untokenized. */
63
63
  tokens: AgentSemanticToken[];
@@ -115,12 +115,12 @@ export const AGENT_OPTIONS: readonly AgentOptionDefinition[] = [
115
115
  description: "Start without parent-session context",
116
116
  },
117
117
  {
118
- semanticId: "context",
119
- names: ["-j", "--join"],
118
+ semanticId: "squash",
119
+ names: ["-s", "--squash"],
120
120
  role: "extension",
121
121
  arity: "boolean",
122
122
  autocomplete: true,
123
- description: "Join the completed result into the main agent context",
123
+ description: "Squash the completed result into the main agent context",
124
124
  },
125
125
  {
126
126
  semanticId: "provider",
@@ -360,7 +360,7 @@ export function scanAgentArguments(
360
360
  const tokens: AgentSemanticToken[] = [];
361
361
  const forwardedArgs: string[] = [];
362
362
  let isolate = false;
363
- let context = false;
363
+ let squash = false;
364
364
  let blocked: AgentSemanticToken | undefined;
365
365
  let consumedOption = false;
366
366
  let position = 0;
@@ -393,8 +393,8 @@ export function scanAgentArguments(
393
393
  }
394
394
  } else if (option.semanticId === "isolate") {
395
395
  isolate = true;
396
- } else if (option.semanticId === "context") {
397
- context = true;
396
+ } else if (option.semanticId === "squash") {
397
+ squash = true;
398
398
  } else if (option.arity === "value") {
399
399
  forwardedArgs.push(option.forwardName ?? token);
400
400
  const read = readValueSpan(args, position);
@@ -427,7 +427,7 @@ export function scanAgentArguments(
427
427
  }
428
428
  const scan: AgentArgumentScan = {
429
429
  isolate,
430
- context,
430
+ squash,
431
431
  forwardedArgs,
432
432
  tokens,
433
433
  proseStart,
@@ -487,11 +487,11 @@ export function parseAgentCommand(args: string, command: AgentCommandName): Pars
487
487
  }
488
488
  if (!scan.prose.trim())
489
489
  throw new Error(
490
- `Usage: /${command} [pi options] [-m MODELNAME] [-i|--isolate] [-j|--join] "<task>"`,
490
+ `Usage: /${command} [pi options] [-m MODELNAME] [-i|--isolate] [-s|--squash] "<task>"`,
491
491
  );
492
492
  return {
493
493
  isolate: scan.isolate,
494
- context: scan.context,
494
+ squash: scan.squash,
495
495
  forwardedArgs: scan.forwardedArgs,
496
496
  task: scan.task,
497
497
  warnings: scan.warnings,
package/index.ts CHANGED
@@ -2,12 +2,14 @@ import type {
2
2
  MessageEndEvent,
3
3
  MessageEndEventResult,
4
4
  } from "@earendil-works/pi-coding-agent";
5
+ import { handleAttachCommand } from "./attach.js";
5
6
  import { registerAgentAutocomplete } from "./autocomplete.js";
6
- import { handleAgentCommand } from "./runner.js";
7
+ import { createRebaseDelivery, handleAgentCommand } from "./runner.js";
7
8
  import type {
8
9
  AgentCommandDetails,
9
10
  DetachedEntryData,
10
11
  ExtensionAPI,
12
+ ExtensionCommandContext,
11
13
  RunningAgent,
12
14
  } from "./shared.js";
13
15
  import { DETACHED_ENTRY_TYPE, MESSAGE_TYPE } from "./shared.js";
@@ -16,10 +18,13 @@ import { UserAgentWidget } from "./widget.js";
16
18
 
17
19
  export default function userAgent(pi: ExtensionAPI): void {
18
20
  const runningAgents = new Set<RunningAgent>();
21
+ // The session every agent was dispatched from; rebase fast-forwards onto it.
22
+ let mainSessionContext: ExtensionCommandContext | undefined;
19
23
  const widget = new UserAgentWidget(
20
24
  runningAgents,
21
25
  (message) => pi.sendMessage(message, { triggerTurn: true }),
22
26
  (sessionId) => pi.appendEntry<DetachedEntryData>(DETACHED_ENTRY_TYPE, { sessionId }),
27
+ createRebaseDelivery(pi, () => mainSessionContext),
23
28
  );
24
29
  let shuttingDown = false;
25
30
  let nextAgentNumber = 0;
@@ -27,12 +32,13 @@ export default function userAgent(pi: ExtensionAPI): void {
27
32
  registerUserAgentRenderer(pi);
28
33
  registerAgentAutocomplete(pi);
29
34
 
30
- pi.on("message_end", (event) => confirmMainContextJoin(event, widget));
35
+ pi.on("message_end", (event) => confirmMainContextSquash(event, widget));
31
36
 
32
37
  pi.registerCommand("agent", {
33
38
  description:
34
39
  "Run a background SDK agent with current session context; forwards pi CLI options like --thinking (-i/--isolate for none)",
35
40
  handler: async (args, ctx) => {
41
+ mainSessionContext = ctx;
36
42
  await handleAgentCommand(
37
43
  pi,
38
44
  runningAgents,
@@ -47,6 +53,22 @@ export default function userAgent(pi: ExtensionAPI): void {
47
53
  },
48
54
  });
49
55
 
56
+ pi.registerCommand("agent-attach", {
57
+ description: "Reattach a detached agent session by id, back into the widget",
58
+ handler: async (args, ctx) => {
59
+ mainSessionContext = ctx;
60
+ await handleAttachCommand(
61
+ pi,
62
+ runningAgents,
63
+ widget,
64
+ () => shuttingDown,
65
+ () => ++nextAgentNumber,
66
+ args,
67
+ ctx,
68
+ );
69
+ },
70
+ });
71
+
50
72
  pi.on("session_shutdown", async () => {
51
73
  shuttingDown = true;
52
74
  widget.dispose();
@@ -63,19 +85,19 @@ export default function userAgent(pi: ExtensionAPI): void {
63
85
  });
64
86
  }
65
87
 
66
- export function confirmMainContextJoin(
88
+ export function confirmMainContextSquash(
67
89
  event: MessageEndEvent,
68
90
  widget: UserAgentWidget,
69
91
  ): MessageEndEventResult | undefined {
70
92
  if (event.message.role !== "custom" || event.message.customType !== MESSAGE_TYPE) return;
71
93
  const details = event.message.details as AgentCommandDetails | undefined;
72
- if (!details?.agentId || details.mainContextState !== "will-join") return;
73
- widget.confirmMainContextJoin(details.agentId);
74
- details.mainContextState = "joined";
94
+ if (!details?.agentId || details.mainContextState !== "will-squash") return;
95
+ widget.confirmMainContextSquash(details.agentId);
96
+ details.mainContextState = "squashed";
75
97
  return {
76
98
  message: {
77
99
  ...event.message,
78
- details: { ...details, mainContextState: "joined" },
100
+ details: { ...details, mainContextState: "squashed" },
79
101
  },
80
102
  };
81
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giladbarnea/pi-user-agents",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Run, view, and control background Pi agents.",
5
5
  "keywords": [
6
6
  "pi-package",
package/rebase.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { buildSessionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentMessage, ExtensionCommandContext } from "./shared.js";
3
+ import { MESSAGE_TYPE } from "./shared.js";
4
+
5
+ type MainSessionManager = ExtensionCommandContext["sessionManager"];
6
+
7
+ /**
8
+ * Fingerprint a context snapshot for the rebase fast-forward precondition: the main session's
9
+ * live context must still fingerprint-equal the base the child was dispatched from.
10
+ *
11
+ * Compares content, not file bookkeeping: rebuilding custom, compaction, and branch summaries
12
+ * from a session file re-stamps their timestamps (and a branch summary's fromId), so a base
13
+ * round-tripped through a child's file would never equal main's live context otherwise.
14
+ *
15
+ * @example conversationFingerprint([]) === conversationFingerprint([]) // true
16
+ */
17
+ export function conversationFingerprint(messages: readonly AgentMessage[]): string {
18
+ return JSON.stringify(messages.map(fingerprintedMessage));
19
+ }
20
+
21
+ function fingerprintedMessage(message: AgentMessage): Omit<AgentMessage, "timestamp"> {
22
+ const { timestamp: _timestamp, ...content } = message;
23
+ if (content.role !== "branchSummary") return content;
24
+ const { fromId: _fromId, ...branchContent } = content;
25
+ return branchContent;
26
+ }
27
+
28
+ /** Rebuilding a session-sized context string is only worth doing when the branch tip moved. */
29
+ const contextFingerprintCache = new WeakMap<object, { leafId: string | null; fingerprint: string }>();
30
+
31
+ /** Fingerprint of the main session's current LLM context, cached by branch leaf. */
32
+ export function mainContextFingerprint(sessionManager: MainSessionManager): string {
33
+ const leafId = sessionManager.getLeafId();
34
+ const cached = contextFingerprintCache.get(sessionManager);
35
+ if (cached && cached.leafId === leafId) return cached.fingerprint;
36
+ const fingerprint = conversationFingerprint(
37
+ buildSessionContext(sessionManager.getBranch()).messages,
38
+ );
39
+ contextFingerprintCache.set(sessionManager, { leafId, fingerprint });
40
+ return fingerprint;
41
+ }
42
+
43
+ /**
44
+ * Select the child conversation messages to rebase onto the main session, so the record reads
45
+ * as if the user prompted the main session directly. The child's history passes through
46
+ * verbatim — compactions included, so main inherits the child's context boundary — except the
47
+ * first user message, which carries the extension's internal background-process prefix and is
48
+ * replaced by the plain task, and this extension's own custom messages, which are the one
49
+ * trace of the user-agents machinery.
50
+ */
51
+ export function selectRebaseMessages(
52
+ task: string,
53
+ messages: readonly AgentMessage[],
54
+ ): AgentMessage[] {
55
+ let firstUserMessage = true;
56
+ const selected: AgentMessage[] = [];
57
+ for (const message of messages) {
58
+ if (message.role === "custom" && message.customType === MESSAGE_TYPE) continue;
59
+ if (message.role === "user" && firstUserMessage) {
60
+ firstUserMessage = false;
61
+ selected.push({ ...message, content: task });
62
+ continue;
63
+ }
64
+ selected.push(message);
65
+ }
66
+ return selected;
67
+ }