@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/runner.ts CHANGED
@@ -5,29 +5,43 @@ import {
5
5
  type AgentSessionServices,
6
6
  type Args,
7
7
  buildSessionContext,
8
+ estimateTokens,
8
9
  createAgentSessionFromServices,
9
10
  createAgentSessionServices,
10
11
  getAgentDir,
11
12
  type ModelRuntime,
12
13
  parseArgs,
13
14
  resolveCliModel,
15
+ sessionEntryToContextMessages,
14
16
  SessionManager,
15
17
  SettingsManager,
16
18
  } from "@earendil-works/pi-coding-agent";
17
19
  import { type AgentValueValidator, parseAgentCommand } from "./command-line.js";
20
+ import { conversationFingerprint, mainContextFingerprint } from "./rebase.js";
18
21
  import type {
19
22
  AgentCommandName,
20
23
  AgentMessage,
21
24
  AgentResultMessage,
22
25
  AgentSession,
26
+ ChildCompactionMessage,
23
27
  ExtensionAPI,
24
28
  ExtensionCommandContext,
25
29
  Model,
26
30
  ParsedAgentCommand,
31
+ RebaseDelivery,
32
+ RebasedEntryData,
27
33
  RunningAgent,
28
34
  ThinkingLevel,
29
35
  } from "./shared.js";
30
- import { errorMessage, formatModel, formatModelLabel, logSteering } from "./shared.js";
36
+ import type { DispatchRecordData } from "./shared.js";
37
+ import {
38
+ DISPATCH_ENTRY_TYPE,
39
+ errorMessage,
40
+ formatModel,
41
+ formatModelLabel,
42
+ logSteering,
43
+ REBASED_ENTRY_TYPE,
44
+ } from "./shared.js";
31
45
  import {
32
46
  buildAgentResultMessage,
33
47
  formatStartNotification,
@@ -78,17 +92,10 @@ async function startUserAgent(
78
92
  ): Promise<void> {
79
93
  const parsed = parseAgentCommand(args, command);
80
94
  const parsedForwardedArgs = parseForwardedArgs(parsed.forwardedArgs);
81
- const agentDir = getAgentDir();
82
- const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
83
- projectTrusted: ctx.isProjectTrusted(),
84
- });
85
- const services = await createAgentSessionServices({
86
- cwd: ctx.cwd,
87
- agentDir,
88
- settingsManager,
89
- modelRuntime: (ctx.modelRegistry as unknown as { runtime: ModelRuntime }).runtime,
90
- resourceLoaderOptions: buildChildResourceLoaderOptions(parsedForwardedArgs, ctx.cwd),
91
- });
95
+ const services = await createChildServices(
96
+ ctx,
97
+ buildChildResourceLoaderOptions(parsedForwardedArgs, ctx.cwd),
98
+ );
92
99
  const forwarded = resolveForwardedOptions(parsedForwardedArgs, services.modelRuntime);
93
100
  const selectedModel = forwarded.model ?? ctx.model;
94
101
  if (!selectedModel) throw new Error("No current model is selected; pass -m MODELNAME");
@@ -100,6 +107,12 @@ async function startUserAgent(
100
107
  const childSessionManager = SessionManager.create(services.cwd, undefined, {
101
108
  parentSession: ctx.sessionManager.getSessionFile(),
102
109
  });
110
+ // The dispatch record: /agent-attach restores tool and resource options from it later.
111
+ childSessionManager.appendCustomEntry(DISPATCH_ENTRY_TYPE, {
112
+ forwardedArgs: parsed.forwardedArgs,
113
+ task: parsed.task,
114
+ isolate: parsed.isolate,
115
+ } satisfies DispatchRecordData);
103
116
  const runningAgent = createRunningAgent(
104
117
  nextAgentNumber(),
105
118
  childSessionManager.getSessionId(),
@@ -107,6 +120,7 @@ async function startUserAgent(
107
120
  model,
108
121
  parsed,
109
122
  invocation,
123
+ conversationFingerprint(inheritedMessages),
110
124
  );
111
125
 
112
126
  runningAgents.add(runningAgent);
@@ -146,6 +160,7 @@ function createRunningAgent(
146
160
  model: Model,
147
161
  parsed: ParsedAgentCommand,
148
162
  invocation: string,
163
+ dispatchBaseFingerprint: string,
149
164
  ): RunningAgent {
150
165
  return {
151
166
  id: `user-${sequenceNumber.toString(36)}`,
@@ -156,8 +171,9 @@ function createRunningAgent(
156
171
  modelLabel: formatModelLabel(model),
157
172
  task: parsed.task,
158
173
  invocation,
159
- notifyMainAgent: parsed.context,
160
- mainContextState: parsed.context ? "will-join" : "separate",
174
+ notifyMainAgent: parsed.squash,
175
+ dispatchBaseFingerprint,
176
+ mainContextState: parsed.squash ? "will-squash" : "separate",
161
177
  status: "starting",
162
178
  startedAt: Date.now(),
163
179
  turnStartedAt: Date.now(),
@@ -187,8 +203,68 @@ function postUserAgentResult(
187
203
  return true;
188
204
  }
189
205
 
206
+ /**
207
+ * The rebase mechanism: fast-forward a child conversation onto the session that dispatched it.
208
+ *
209
+ * Delivery appends the messages to the main session file, drops a persisted breadcrumb naming
210
+ * the child session, and asks the host to switch to that same file — the one path that rebuilds
211
+ * both the live LLM context and the TUI transcript (see INTERNALS.md).
212
+ */
213
+ export function createRebaseDelivery(
214
+ pi: ExtensionAPI,
215
+ getSessionContext: () => ExtensionCommandContext | undefined,
216
+ ): RebaseDelivery {
217
+ return {
218
+ canDeliver: (agent) => {
219
+ const ctx = getSessionContext();
220
+ return (
221
+ ctx !== undefined &&
222
+ mainContextFingerprint(ctx.sessionManager) === agent.dispatchBaseFingerprint
223
+ );
224
+ },
225
+ deliver: (agent, messages) => {
226
+ const ctx = getSessionContext();
227
+ if (!ctx) throw new Error("No dispatching session context to rebase onto");
228
+ // The runtime object behind the extension's readonly facade is the writable SessionManager.
229
+ const sessionManager = ctx.sessionManager as unknown as SessionManager;
230
+ // A replayed compaction's kept tail may reach back into main's existing context.
231
+ const priorContextEntryIds = sessionManager
232
+ .buildContextEntries()
233
+ .flatMap((entry) => sessionEntryToContextMessages(entry).map(() => entry.id));
234
+ persistMessages(sessionManager, messages, priorContextEntryIds);
235
+ pi.appendEntry<RebasedEntryData>(REBASED_ENTRY_TYPE, {
236
+ sessionId: agent.sessionId,
237
+ stats: {
238
+ messageCount: messages.length,
239
+ tokenEstimate: messages.reduce((total, message) => total + estimateTokens(message), 0),
240
+ compactionCount: messages.filter((message) => message.role === "compactionSummary")
241
+ .length,
242
+ },
243
+ });
244
+ const sessionFile = sessionManager.getSessionFile();
245
+ if (!sessionFile) throw new Error("The main session has no file to switch to");
246
+ void ctx.switchSession(sessionFile).then(
247
+ (result) => {
248
+ if (result.cancelled && ctx.hasUI)
249
+ ctx.ui.notify(
250
+ "The rebase is saved to the session file, but the session switch was cancelled — /resume this session to see it",
251
+ "warning",
252
+ );
253
+ },
254
+ (error) => {
255
+ if (ctx.hasUI)
256
+ ctx.ui.notify(
257
+ `The rebase is saved to the session file, but the session switch failed (${errorMessage(error)}) — /resume this session to see it`,
258
+ "error",
259
+ );
260
+ },
261
+ );
262
+ },
263
+ };
264
+ }
265
+
190
266
  /** Session inputs derived from forwarded pi CLI options, ready to hand to the child session. */
191
- type ChildResourceLoaderOptions = {
267
+ export type ChildResourceLoaderOptions = {
192
268
  systemPrompt?: string;
193
269
  appendSystemPrompt?: string[];
194
270
  additionalExtensionPaths?: string[];
@@ -201,14 +277,37 @@ type ChildResourceLoaderOptions = {
201
277
  noContextFiles?: boolean;
202
278
  };
203
279
 
204
- type ForwardedOptions = {
205
- model?: Model;
206
- thinkingLevel?: ThinkingLevel;
280
+ export type ChildToolOptions = {
207
281
  tools?: string[];
208
282
  excludeTools?: string[];
209
283
  noTools?: "all" | "builtin";
210
284
  };
211
285
 
286
+ type ForwardedOptions = ChildToolOptions & {
287
+ model?: Model;
288
+ thinkingLevel?: ThinkingLevel;
289
+ };
290
+
291
+ /** The cwd-bound services for one child session; the parent's ModelRuntime rides along (see INTERNALS). */
292
+ export function createChildServices(
293
+ ctx: ExtensionCommandContext,
294
+ resourceLoaderOptions?: ChildResourceLoaderOptions,
295
+ ): Promise<AgentSessionServices> {
296
+ const agentDir = getAgentDir();
297
+ const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
298
+ projectTrusted: ctx.isProjectTrusted(),
299
+ });
300
+ return createAgentSessionServices({
301
+ cwd: ctx.cwd,
302
+ agentDir,
303
+ settingsManager,
304
+ // The extension facade wraps the parent's runtime as a plain property; sharing it keeps
305
+ // extension-registered providers (their process-global dedup guard) available to the child.
306
+ modelRuntime: (ctx.modelRegistry as unknown as { runtime: ModelRuntime }).runtime,
307
+ resourceLoaderOptions,
308
+ });
309
+ }
310
+
212
311
  /** Parse forwarded pi CLI tokens once before model and session resolution. */
213
312
  export function parseForwardedArgs(forwardedArgs: string[]): Args {
214
313
  const parsed = parseArgs(forwardedArgs);
@@ -307,12 +406,17 @@ export function resolveForwardedOptions(
307
406
  }
308
407
  }
309
408
  if (parsed.thinking) forwarded.thinkingLevel = parsed.thinking;
409
+ return { ...forwarded, ...resolveToolOptions(parsed) };
410
+ }
310
411
 
311
- if (parsed.noTools) forwarded.noTools = "all";
312
- else if (parsed.noBuiltinTools) forwarded.noTools = "builtin";
313
- if (parsed.tools) forwarded.tools = parsed.tools;
314
- if (parsed.excludeTools) forwarded.excludeTools = parsed.excludeTools;
315
- return forwarded;
412
+ /** Map parsed pi tool options onto child-session inputs; dispatch and attach share this. */
413
+ export function resolveToolOptions(parsed: Args): ChildToolOptions {
414
+ const options: ChildToolOptions = {};
415
+ if (parsed.noTools) options.noTools = "all";
416
+ else if (parsed.noBuiltinTools) options.noTools = "builtin";
417
+ if (parsed.tools) options.tools = parsed.tools;
418
+ if (parsed.excludeTools) options.excludeTools = parsed.excludeTools;
419
+ return options;
316
420
  }
317
421
 
318
422
  export function buildChildResourceLoaderOptions(
@@ -374,29 +478,9 @@ async function runAgentLifecycle(
374
478
  widget,
375
479
  );
376
480
  unsubscribe = subscribeToChildSession(session, runningAgent, widget);
377
- await runChildTurns(pi, isShuttingDown, task, session, runningAgent, widget);
481
+ await runChildTurns(pi, isShuttingDown, DISPATCH_PREAMBLE + task, session, runningAgent, widget);
378
482
  } catch (error) {
379
- if (runningAgent.aborted) {
380
- logSteering(runningAgent.id, "agent-aborted");
381
- return;
382
- }
383
- const message = errorMessage(error);
384
- logSteering(runningAgent.id, "agent-error", { error: message });
385
- runningAgent.status = "error";
386
- runningAgent.completedAt = Date.now();
387
- runningAgent.error = message;
388
- if (runningAgent.notifyMainAgent && isShuttingDown()) {
389
- runningAgent.mainContextState = "separate";
390
- return;
391
- }
392
- const resultMessage = buildAgentResultMessage(
393
- runningAgent,
394
- { ok: false, error: message },
395
- { display: runningAgent.notifyMainAgent },
396
- );
397
- widget.addCompleted(runningAgent, resultMessage, { joinable: !runningAgent.notifyMainAgent });
398
- if (postUserAgentResult(pi, isShuttingDown, runningAgent.notifyMainAgent, resultMessage))
399
- runningAgent.status = "posted";
483
+ reportAgentFailure(pi, isShuttingDown, runningAgent, widget, error);
400
484
  } finally {
401
485
  unsubscribe?.();
402
486
  if (session) {
@@ -406,16 +490,51 @@ async function runAgentLifecycle(
406
490
  }
407
491
  }
408
492
 
409
- /** Run turns on an initialized child session until it posts, retires, or shuts down. */
493
+ /** Settle a failed lifecycle: record the error and post the error card, unless the user aborted. */
494
+ export function reportAgentFailure(
495
+ pi: ExtensionAPI,
496
+ isShuttingDown: () => boolean,
497
+ runningAgent: RunningAgent,
498
+ widget: UserAgentWidget,
499
+ error: unknown,
500
+ ): void {
501
+ if (runningAgent.aborted) {
502
+ logSteering(runningAgent.id, "agent-aborted");
503
+ return;
504
+ }
505
+ const message = errorMessage(error);
506
+ logSteering(runningAgent.id, "agent-error", { error: message });
507
+ runningAgent.status = "error";
508
+ runningAgent.completedAt = Date.now();
509
+ runningAgent.error = message;
510
+ if (runningAgent.notifyMainAgent && isShuttingDown()) {
511
+ runningAgent.mainContextState = "separate";
512
+ return;
513
+ }
514
+ const resultMessage = buildAgentResultMessage(
515
+ runningAgent,
516
+ { ok: false, error: message },
517
+ { display: runningAgent.notifyMainAgent },
518
+ );
519
+ widget.addCompleted(runningAgent, resultMessage, { squashable: !runningAgent.notifyMainAgent });
520
+ if (postUserAgentResult(pi, isShuttingDown, runningAgent.notifyMainAgent, resultMessage))
521
+ runningAgent.status = "posted";
522
+ }
523
+
524
+ /** Every dispatch prefixes its task with this; attach recognizes it as the child's dispatch boundary. */
525
+ export const DISPATCH_PREAMBLE =
526
+ "You are running in an ephemeral, forked background process now, concurrently with the main session. ";
527
+
528
+ /** Run turns on an initialized child session until it posts, retires, or shuts down. The initial instruction is sent verbatim. */
410
529
  export async function runChildTurns(
411
530
  pi: ExtensionAPI,
412
531
  isShuttingDown: () => boolean,
413
- task: string,
532
+ initialInstruction: string,
414
533
  session: AgentSession,
415
534
  runningAgent: RunningAgent,
416
535
  widget: UserAgentWidget,
417
536
  ): Promise<void> {
418
- let instruction = `You are running in an ephemeral, forked background process now, concurrently with the main session. ${task}`;
537
+ let instruction = initialInstruction;
419
538
  while (true) {
420
539
  const turnMessageStart = session.agent.state.messages.length;
421
540
  if (!runningAgent.interruptRequested) {
@@ -438,7 +557,7 @@ export async function runChildTurns(
438
557
  { display: false },
439
558
  );
440
559
  postUserAgentResult(pi, isShuttingDown, false, resultMessage);
441
- runningAgent.pendingJoinMessage = runningAgent.notifyMainAgent
560
+ runningAgent.pendingSquashMessage = runningAgent.notifyMainAgent
442
561
  ? undefined
443
562
  : resultMessage;
444
563
  if (isShuttingDown()) return;
@@ -470,13 +589,13 @@ export async function runChildTurns(
470
589
  resultMessage.details.mainContextState = "separate";
471
590
  return;
472
591
  }
473
- widget.addCompleted(runningAgent, resultMessage, { joinable: false });
592
+ widget.addCompleted(runningAgent, resultMessage, { squashable: false });
474
593
  const posted = postUserAgentResult(pi, isShuttingDown, true, resultMessage);
475
594
  runningAgent.status = posted ? "posted" : runningAgent.status;
476
595
  return;
477
596
  }
478
597
  postUserAgentResult(pi, isShuttingDown, false, resultMessage);
479
- runningAgent.pendingJoinMessage = resultMessage;
598
+ runningAgent.pendingSquashMessage = resultMessage;
480
599
  if (isShuttingDown()) return;
481
600
  runningAgent.status = "idle";
482
601
  logSteering(runningAgent.id, "agent-idle", { turnCount: runningAgent.turnCount });
@@ -528,7 +647,7 @@ async function createChildSession(
528
647
  await session.bindExtensions({ mode: "print" });
529
648
  logSteering(runningAgent.id, "session-bound", { streaming: session.isStreaming });
530
649
  if (inheritedMessages.length > 0) {
531
- persistInheritedMessages(childSessionManager, inheritedMessages);
650
+ persistMessages(childSessionManager, inheritedMessages, []);
532
651
  session.agent.state.messages = inheritedMessages;
533
652
  logSteering(runningAgent.id, "context-inherited", { messageCount: inheritedMessages.length });
534
653
  }
@@ -536,27 +655,42 @@ async function createChildSession(
536
655
  }
537
656
 
538
657
  /**
539
- * Mirror the inherited snapshot into the child's session file so a resumed child sees the
540
- * same conversation the live one saw. Each role goes through its own append method: the
541
- * snapshot is already compaction-resolved, so its summary leads the child's own branch.
658
+ * Mirror a message stream into a session file, each role through its own append method.
659
+ * Seeds a child with its inherited snapshot (leading summary), and replays a rebase onto the
660
+ * main session. A compaction with a keptTailCount maps its kept-tail boundary onto this file's
661
+ * own entry ids, so the rebuilt context keeps the same messages verbatim the child kept — the
662
+ * tail may reach back into the pre-replay context, whose per-message ids arrive as
663
+ * priorContextEntryIds (empty when seeding a child).
542
664
  */
543
- export function persistInheritedMessages(
665
+ export function persistMessages(
544
666
  sessionManager: SessionManager,
545
667
  messages: readonly AgentMessage[],
668
+ priorContextEntryIds: readonly string[],
546
669
  ): void {
670
+ // One id per context message, prior context first; every appended entry projects to one message.
671
+ const entryIdsPerMessage: string[] = [...priorContextEntryIds];
547
672
  for (const message of messages) {
548
- if (message.role === "compactionSummary")
549
- sessionManager.appendCompaction(message.summary, "", message.tokensBefore);
550
- else if (message.role === "branchSummary")
551
- sessionManager.branchWithSummary(sessionManager.getLeafId(), message.summary);
673
+ if (message.role === "compactionSummary") {
674
+ const keptTailCount = (message as ChildCompactionMessage).keptTailCount ?? 0;
675
+ const firstKeptEntryId =
676
+ keptTailCount > 0 ? (entryIdsPerMessage[entryIdsPerMessage.length - keptTailCount] ?? "") : "";
677
+ entryIdsPerMessage.push(
678
+ sessionManager.appendCompaction(message.summary, firstKeptEntryId, message.tokensBefore),
679
+ );
680
+ } else if (message.role === "branchSummary")
681
+ entryIdsPerMessage.push(
682
+ sessionManager.branchWithSummary(sessionManager.getLeafId(), message.summary),
683
+ );
552
684
  else if (message.role === "custom")
553
- sessionManager.appendCustomMessageEntry(
554
- message.customType,
555
- message.content,
556
- message.display,
557
- message.details,
685
+ entryIdsPerMessage.push(
686
+ sessionManager.appendCustomMessageEntry(
687
+ message.customType,
688
+ message.content,
689
+ message.display,
690
+ message.details,
691
+ ),
558
692
  );
559
- else sessionManager.appendMessage(message);
693
+ else entryIdsPerMessage.push(sessionManager.appendMessage(message));
560
694
  }
561
695
  }
562
696
 
@@ -573,7 +707,7 @@ export function waitForInstruction(
573
707
  };
574
708
  agent.resume = (instruction) => {
575
709
  logSteering(agent.id, "agent-resumed", { instructionLength: instruction.length });
576
- agent.mainContextState = agent.notifyMainAgent ? "will-join" : "separate";
710
+ agent.mainContextState = agent.notifyMainAgent ? "will-squash" : "separate";
577
711
  agent.status = "running";
578
712
  agent.turnStartedAt = Date.now();
579
713
  agent.completedAt = undefined;
@@ -587,12 +721,26 @@ export function waitForInstruction(
587
721
  });
588
722
  }
589
723
 
590
- function subscribeToChildSession(
724
+ export function subscribeToChildSession(
591
725
  session: AgentSession,
592
726
  runningAgent: RunningAgent,
593
727
  widget: UserAgentWidget,
594
728
  ): () => void {
595
729
  return session.subscribe((event: AgentSessionEvent) => {
730
+ if (event.type === "compaction_end" && !event.aborted && event.result) {
731
+ // Compaction never emits message events; capture it here or the rebase stream misses it.
732
+ // At this instant the live context is exactly [summary, kept tail], so the tail is countable.
733
+ const keptTailCount = Math.max(0, session.agent.state.messages.length - 1);
734
+ const compactionMessage: ChildCompactionMessage = {
735
+ role: "compactionSummary",
736
+ summary: event.result.summary,
737
+ tokensBefore: event.result.tokensBefore,
738
+ timestamp: Date.now(),
739
+ keptTailCount,
740
+ };
741
+ runningAgent.conversationMessages.push(compactionMessage);
742
+ logSteering(runningAgent.id, "child-compacted", { keptTailCount });
743
+ }
596
744
  if (event.type === "agent_start")
597
745
  logSteering(runningAgent.id, "child-agent-started", { streaming: session.isStreaming });
598
746
  if (event.type === "agent_end")
@@ -641,7 +789,7 @@ function subscribeToChildSession(
641
789
  });
642
790
  }
643
791
 
644
- function assistantText(message: Extract<AgentMessage, { role: "assistant" }>): string {
792
+ export function assistantText(message: Extract<AgentMessage, { role: "assistant" }>): string {
645
793
  return message.content
646
794
  .filter((part) => part.type === "text")
647
795
  .map((part) => part.text)
package/shared.ts CHANGED
@@ -15,8 +15,8 @@ export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
15
15
  export type Theme = ExtensionCommandContext["ui"]["theme"];
16
16
  export type UIContext = ExtensionCommandContext["ui"];
17
17
 
18
- export type AgentCommandName = "agent";
19
- export type MainContextState = "separate" | "will-join" | "joined";
18
+ export type AgentCommandName = "agent" | "agent-attach";
19
+ export type MainContextState = "separate" | "will-squash" | "squashed" | "rebased";
20
20
  export type AgentStatus =
21
21
  | "starting"
22
22
  | "running"
@@ -27,7 +27,7 @@ export type AgentStatus =
27
27
 
28
28
  export type ParsedAgentCommand = {
29
29
  isolate: boolean;
30
- context: boolean;
30
+ squash: boolean;
31
31
  /** Leading pi CLI tokens (minus the extension's own options) to forward to the child, e.g. ["--thinking", "high"]. */
32
32
  forwardedArgs: string[];
33
33
  task: string;
@@ -44,6 +44,39 @@ export type DetachedEntryData = {
44
44
  sessionId: string;
45
45
  };
46
46
 
47
+ export type AttachedEntryData = {
48
+ sessionId: string;
49
+ };
50
+
51
+ /** Persisted in the child session at dispatch: the facts attach cannot recover from messages alone. */
52
+ export type DispatchRecordData = {
53
+ /** The pi CLI tokens the dispatch forwarded, e.g. ["--tools", "read,grep"]. */
54
+ forwardedArgs: string[];
55
+ /** The plain task, without the dispatch preamble. */
56
+ task: string;
57
+ isolate: boolean;
58
+ };
59
+
60
+ export type RebaseStats = {
61
+ messageCount: number;
62
+ tokenEstimate: number;
63
+ compactionCount: number;
64
+ };
65
+
66
+ export type RebasedEntryData = {
67
+ sessionId: string;
68
+ /** Absent on entries persisted before stats existed. */
69
+ stats?: RebaseStats;
70
+ };
71
+
72
+ /**
73
+ * A child compaction captured from its compaction_end event. keptTailCount records how many
74
+ * messages the compaction kept verbatim, so a rebase replay can restore the exact boundary.
75
+ */
76
+ export type ChildCompactionMessage = Extract<AgentMessage, { role: "compactionSummary" }> & {
77
+ keptTailCount?: number;
78
+ };
79
+
47
80
  export type AgentResultMessage = {
48
81
  customType: string;
49
82
  content: string;
@@ -55,7 +88,8 @@ export type AgentCommandDetails = {
55
88
  agentId?: string;
56
89
  command: AgentCommandName;
57
90
  mainContextState?: MainContextState;
58
- inheritedContext: boolean;
91
+ /** Absent on command-error cards: an error has no inherited-or-isolated notion. */
92
+ inheritedContext?: boolean;
59
93
  model: string;
60
94
  modelLabel: string;
61
95
  task: string;
@@ -78,10 +112,12 @@ export type RunningAgent = {
78
112
  model: string;
79
113
  modelLabel: string;
80
114
  task: string;
81
- /** The slash command line as the user typed it, e.g. `/agent -j fix the bug`. */
115
+ /** The slash command line as the user typed it, e.g. `/agent -s fix the bug`. */
82
116
  invocation: string;
83
- /** -j/--join: post invocation+result to the main agent and trigger its turn, instead of waiting quietly for the next user prompt. */
117
+ /** -s/--squash: post invocation+result to the main agent and trigger its turn, instead of waiting quietly for the next user prompt. */
84
118
  notifyMainAgent: boolean;
119
+ /** Fingerprint of the main context the child was dispatched from ("[]" for -i); the rebase fast-forward base. */
120
+ dispatchBaseFingerprint: string;
85
121
  mainContextState: MainContextState;
86
122
  status: AgentStatus;
87
123
  startedAt: number;
@@ -97,8 +133,8 @@ export type RunningAgent = {
97
133
  latestFinalizedMessage?: AgentMessage;
98
134
  error?: string;
99
135
  session?: AgentSession;
100
- /** Latest completed turn's result message, deliverable to the main agent via the overlay's join action. */
101
- pendingJoinMessage?: AgentResultMessage;
136
+ /** Latest completed turn's result message, deliverable to the main agent via the overlay's squash action. */
137
+ pendingSquashMessage?: AgentResultMessage;
102
138
  /** Starts another turn on an idle agent; assigned while the lifecycle loop awaits the next instruction. */
103
139
  resume?: (instruction: string) => void;
104
140
  /** Ends an idle agent's lifecycle without another turn; assigned alongside resume. */
@@ -116,8 +152,9 @@ export type CompletedAgent = {
116
152
  command: AgentCommandName;
117
153
  modelLabel: string;
118
154
  task: string;
155
+ dispatchBaseFingerprint: string;
119
156
  mainContextState: MainContextState;
120
- pendingJoinMessage?: AgentResultMessage;
157
+ pendingSquashMessage?: AgentResultMessage;
121
158
  ok: boolean;
122
159
  responseText: string;
123
160
  messages: AgentMessage[];
@@ -129,8 +166,63 @@ export type CompletedAgent = {
129
166
  contextPercent?: number;
130
167
  };
131
168
 
169
+ /** The mechanism that fast-forwards a child conversation onto the main session, injected into the widget. */
170
+ export type RebaseDelivery = {
171
+ /** Whether the main session's live context still equals this agent's dispatch base. */
172
+ canDeliver(agent: RunningAgent | CompletedAgent): boolean;
173
+ /** Append the processed child conversation onto the main session. */
174
+ deliver(agent: RunningAgent | CompletedAgent, messages: AgentMessage[]): void;
175
+ };
176
+
177
+ /** How long a confirmation stays armed, and how long a transient footer notice shows. */
178
+ export const CONFIRMATION_WINDOW_MS = 4000;
179
+
180
+ /**
181
+ * A two-press confirmation with a timeout: the first press arms it, a repeat press on the
182
+ * same target within the window confirms. A press on another target re-arms, cancel or
183
+ * expiry disarms. One instance serves every confirmable action on a surface.
184
+ */
185
+ export class TimedConfirmation<Target> {
186
+ private armedOn: Target | undefined;
187
+ private expiry: ReturnType<typeof setTimeout> | undefined;
188
+
189
+ constructor(
190
+ private readonly onExpire: () => void,
191
+ private readonly windowMs: number = CONFIRMATION_WINDOW_MS,
192
+ ) {}
193
+
194
+ /** One press of the action key: true means confirmed, false means armed and waiting. */
195
+ press(target: Target): boolean {
196
+ if (this.armedOn === target) {
197
+ this.cancel();
198
+ return true;
199
+ }
200
+ this.armedOn = target;
201
+ if (this.expiry) clearTimeout(this.expiry);
202
+ this.expiry = setTimeout(() => {
203
+ this.armedOn = undefined;
204
+ this.expiry = undefined;
205
+ this.onExpire();
206
+ }, this.windowMs);
207
+ return false;
208
+ }
209
+
210
+ cancel(): void {
211
+ this.armedOn = undefined;
212
+ if (this.expiry) clearTimeout(this.expiry);
213
+ this.expiry = undefined;
214
+ }
215
+
216
+ isArmedOn(target: Target): boolean {
217
+ return this.armedOn !== undefined && this.armedOn === target;
218
+ }
219
+ }
220
+
132
221
  export const MESSAGE_TYPE = "pi-user-agents";
133
222
  export const DETACHED_ENTRY_TYPE = "pi-user-agents-detached";
223
+ export const REBASED_ENTRY_TYPE = "pi-user-agents-rebased";
224
+ export const ATTACHED_ENTRY_TYPE = "pi-user-agents-attached";
225
+ export const DISPATCH_ENTRY_TYPE = "pi-user-agents-dispatch";
134
226
  export const WIDGET_KEY = "pi-user-agents";
135
227
  export const STEERING_LOG_PATH = `${tmpdir()}/pi-user-agents-steer.log`;
136
228
 
@@ -170,8 +262,9 @@ export function contextLabel(inheritedContext: boolean): string {
170
262
  }
171
263
 
172
264
  export function mainContextLabel(state: MainContextState | undefined): string | undefined {
173
- if (state === "will-join") return "will join context";
174
- if (state === "joined") return "joined context";
265
+ if (state === "will-squash") return "will squash into context";
266
+ if (state === "squashed") return "squashed messages into context";
267
+ if (state === "rebased") return "rebased into context";
175
268
  return undefined;
176
269
  }
177
270