@giladbarnea/pi-user-agents 0.0.3 → 0.0.5

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/rebase.ts ADDED
@@ -0,0 +1,56 @@
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
+ * @example conversationFingerprint([]) === conversationFingerprint([]) // true
12
+ */
13
+ export function conversationFingerprint(messages: readonly AgentMessage[]): string {
14
+ return JSON.stringify(messages);
15
+ }
16
+
17
+ /** Rebuilding a session-sized context string is only worth doing when the branch tip moved. */
18
+ const contextFingerprintCache = new WeakMap<object, { leafId: string | null; fingerprint: string }>();
19
+
20
+ /** Fingerprint of the main session's current LLM context, cached by branch leaf. */
21
+ export function mainContextFingerprint(sessionManager: MainSessionManager): string {
22
+ const leafId = sessionManager.getLeafId();
23
+ const cached = contextFingerprintCache.get(sessionManager);
24
+ if (cached && cached.leafId === leafId) return cached.fingerprint;
25
+ const fingerprint = conversationFingerprint(
26
+ buildSessionContext(sessionManager.getBranch()).messages,
27
+ );
28
+ contextFingerprintCache.set(sessionManager, { leafId, fingerprint });
29
+ return fingerprint;
30
+ }
31
+
32
+ /**
33
+ * Select the child conversation messages to rebase onto the main session, so the record reads
34
+ * as if the user prompted the main session directly. The child's history passes through
35
+ * verbatim — compactions included, so main inherits the child's context boundary — except the
36
+ * first user message, which carries the extension's internal background-process prefix and is
37
+ * replaced by the plain task, and this extension's own custom messages, which are the one
38
+ * trace of the user-agents machinery.
39
+ */
40
+ export function selectRebaseMessages(
41
+ task: string,
42
+ messages: readonly AgentMessage[],
43
+ ): AgentMessage[] {
44
+ let firstUserMessage = true;
45
+ const selected: AgentMessage[] = [];
46
+ for (const message of messages) {
47
+ if (message.role === "custom" && message.customType === MESSAGE_TYPE) continue;
48
+ if (message.role === "user" && firstUserMessage) {
49
+ firstUserMessage = false;
50
+ selected.push({ ...message, content: task });
51
+ continue;
52
+ }
53
+ selected.push(message);
54
+ }
55
+ return selected;
56
+ }
package/runner.ts CHANGED
@@ -5,29 +5,41 @@ 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 {
37
+ errorMessage,
38
+ formatModel,
39
+ formatModelLabel,
40
+ logSteering,
41
+ REBASED_ENTRY_TYPE,
42
+ } from "./shared.js";
31
43
  import {
32
44
  buildAgentResultMessage,
33
45
  formatStartNotification,
@@ -96,7 +108,19 @@ async function startUserAgent(
96
108
  services.modelRuntime.getModel(selectedModel.provider, selectedModel.id) ?? selectedModel;
97
109
  const thinkingLevel = forwarded.thinkingLevel ?? pi.getThinkingLevel();
98
110
  const inheritedMessages = parsed.isolate ? [] : buildInheritedMessages(ctx);
99
- const runningAgent = createRunningAgent(nextAgentNumber(), command, model, parsed, invocation);
111
+ // A real session file from birth: the child outlives the widget row and stays resumable.
112
+ const childSessionManager = SessionManager.create(services.cwd, undefined, {
113
+ parentSession: ctx.sessionManager.getSessionFile(),
114
+ });
115
+ const runningAgent = createRunningAgent(
116
+ nextAgentNumber(),
117
+ childSessionManager.getSessionId(),
118
+ command,
119
+ model,
120
+ parsed,
121
+ invocation,
122
+ conversationFingerprint(inheritedMessages),
123
+ );
100
124
 
101
125
  runningAgents.add(runningAgent);
102
126
  logSteering(runningAgent.id, "agent-created", { command, taskLength: parsed.task.length });
@@ -117,6 +141,7 @@ async function startUserAgent(
117
141
  forwarded,
118
142
  inheritedMessages,
119
143
  services,
144
+ childSessionManager,
120
145
  runningAgent,
121
146
  widget,
122
147
  ).finally(() => {
@@ -129,21 +154,25 @@ async function startUserAgent(
129
154
 
130
155
  function createRunningAgent(
131
156
  sequenceNumber: number,
157
+ sessionId: string,
132
158
  command: AgentCommandName,
133
159
  model: Model,
134
160
  parsed: ParsedAgentCommand,
135
161
  invocation: string,
162
+ dispatchBaseFingerprint: string,
136
163
  ): RunningAgent {
137
164
  return {
138
165
  id: `user-${sequenceNumber.toString(36)}`,
166
+ sessionId,
139
167
  command,
140
168
  inheritedContext: !parsed.isolate,
141
169
  model: formatModel(model),
142
170
  modelLabel: formatModelLabel(model),
143
171
  task: parsed.task,
144
172
  invocation,
145
- notifyMainAgent: parsed.context,
146
- mainContextState: parsed.context ? "will-join" : "separate",
173
+ notifyMainAgent: parsed.squash,
174
+ dispatchBaseFingerprint,
175
+ mainContextState: parsed.squash ? "will-squash" : "separate",
147
176
  status: "starting",
148
177
  startedAt: Date.now(),
149
178
  turnStartedAt: Date.now(),
@@ -173,6 +202,66 @@ function postUserAgentResult(
173
202
  return true;
174
203
  }
175
204
 
205
+ /**
206
+ * The rebase mechanism: fast-forward a child conversation onto the session that dispatched it.
207
+ *
208
+ * Delivery appends the messages to the main session file, drops a persisted breadcrumb naming
209
+ * the child session, and asks the host to switch to that same file — the one path that rebuilds
210
+ * both the live LLM context and the TUI transcript (see INTERNALS.md).
211
+ */
212
+ export function createRebaseDelivery(
213
+ pi: ExtensionAPI,
214
+ getSessionContext: () => ExtensionCommandContext | undefined,
215
+ ): RebaseDelivery {
216
+ return {
217
+ canDeliver: (agent) => {
218
+ const ctx = getSessionContext();
219
+ return (
220
+ ctx !== undefined &&
221
+ mainContextFingerprint(ctx.sessionManager) === agent.dispatchBaseFingerprint
222
+ );
223
+ },
224
+ deliver: (agent, messages) => {
225
+ const ctx = getSessionContext();
226
+ if (!ctx) throw new Error("No dispatching session context to rebase onto");
227
+ // The runtime object behind the extension's readonly facade is the writable SessionManager.
228
+ const sessionManager = ctx.sessionManager as unknown as SessionManager;
229
+ // A replayed compaction's kept tail may reach back into main's existing context.
230
+ const priorContextEntryIds = sessionManager
231
+ .buildContextEntries()
232
+ .flatMap((entry) => sessionEntryToContextMessages(entry).map(() => entry.id));
233
+ persistMessages(sessionManager, messages, priorContextEntryIds);
234
+ pi.appendEntry<RebasedEntryData>(REBASED_ENTRY_TYPE, {
235
+ sessionId: agent.sessionId,
236
+ stats: {
237
+ messageCount: messages.length,
238
+ tokenEstimate: messages.reduce((total, message) => total + estimateTokens(message), 0),
239
+ compactionCount: messages.filter((message) => message.role === "compactionSummary")
240
+ .length,
241
+ },
242
+ });
243
+ const sessionFile = sessionManager.getSessionFile();
244
+ if (!sessionFile) throw new Error("The main session has no file to switch to");
245
+ void ctx.switchSession(sessionFile).then(
246
+ (result) => {
247
+ if (result.cancelled && ctx.hasUI)
248
+ ctx.ui.notify(
249
+ "The rebase is saved to the session file, but the session switch was cancelled — /resume this session to see it",
250
+ "warning",
251
+ );
252
+ },
253
+ (error) => {
254
+ if (ctx.hasUI)
255
+ ctx.ui.notify(
256
+ `The rebase is saved to the session file, but the session switch failed (${errorMessage(error)}) — /resume this session to see it`,
257
+ "error",
258
+ );
259
+ },
260
+ );
261
+ },
262
+ };
263
+ }
264
+
176
265
  /** Session inputs derived from forwarded pi CLI options, ready to hand to the child session. */
177
266
  type ChildResourceLoaderOptions = {
178
267
  systemPrompt?: string;
@@ -342,6 +431,7 @@ async function runAgentLifecycle(
342
431
  forwarded: ForwardedOptions,
343
432
  inheritedMessages: AgentMessage[],
344
433
  services: AgentSessionServices,
434
+ childSessionManager: SessionManager,
345
435
  runningAgent: RunningAgent,
346
436
  widget: UserAgentWidget,
347
437
  ): Promise<void> {
@@ -354,6 +444,7 @@ async function runAgentLifecycle(
354
444
  forwarded,
355
445
  inheritedMessages,
356
446
  services,
447
+ childSessionManager,
357
448
  runningAgent,
358
449
  widget,
359
450
  );
@@ -378,7 +469,7 @@ async function runAgentLifecycle(
378
469
  { ok: false, error: message },
379
470
  { display: runningAgent.notifyMainAgent },
380
471
  );
381
- widget.addCompleted(runningAgent, resultMessage, { joinable: !runningAgent.notifyMainAgent });
472
+ widget.addCompleted(runningAgent, resultMessage, { squashable: !runningAgent.notifyMainAgent });
382
473
  if (postUserAgentResult(pi, isShuttingDown, runningAgent.notifyMainAgent, resultMessage))
383
474
  runningAgent.status = "posted";
384
475
  } finally {
@@ -422,7 +513,7 @@ export async function runChildTurns(
422
513
  { display: false },
423
514
  );
424
515
  postUserAgentResult(pi, isShuttingDown, false, resultMessage);
425
- runningAgent.pendingJoinMessage = runningAgent.notifyMainAgent
516
+ runningAgent.pendingSquashMessage = runningAgent.notifyMainAgent
426
517
  ? undefined
427
518
  : resultMessage;
428
519
  if (isShuttingDown()) return;
@@ -454,13 +545,13 @@ export async function runChildTurns(
454
545
  resultMessage.details.mainContextState = "separate";
455
546
  return;
456
547
  }
457
- widget.addCompleted(runningAgent, resultMessage, { joinable: false });
548
+ widget.addCompleted(runningAgent, resultMessage, { squashable: false });
458
549
  const posted = postUserAgentResult(pi, isShuttingDown, true, resultMessage);
459
550
  runningAgent.status = posted ? "posted" : runningAgent.status;
460
551
  return;
461
552
  }
462
553
  postUserAgentResult(pi, isShuttingDown, false, resultMessage);
463
- runningAgent.pendingJoinMessage = resultMessage;
554
+ runningAgent.pendingSquashMessage = resultMessage;
464
555
  if (isShuttingDown()) return;
465
556
  runningAgent.status = "idle";
466
557
  logSteering(runningAgent.id, "agent-idle", { turnCount: runningAgent.turnCount });
@@ -487,12 +578,15 @@ async function createChildSession(
487
578
  forwarded: ForwardedOptions,
488
579
  inheritedMessages: AgentMessage[],
489
580
  services: AgentSessionServices,
581
+ childSessionManager: SessionManager,
490
582
  runningAgent: RunningAgent,
491
583
  widget: UserAgentWidget,
492
584
  ): Promise<AgentSession> {
585
+ // Built while the session is still empty: that is the only path on which pi records the
586
+ // model and thinking level, and without those entries a resumed child falls back to defaults.
493
587
  const { session } = await createAgentSessionFromServices({
494
588
  services,
495
- sessionManager: SessionManager.inMemory(services.cwd),
589
+ sessionManager: childSessionManager,
496
590
  model,
497
591
  thinkingLevel,
498
592
  tools: forwarded.tools,
@@ -509,12 +603,53 @@ async function createChildSession(
509
603
  await session.bindExtensions({ mode: "print" });
510
604
  logSteering(runningAgent.id, "session-bound", { streaming: session.isStreaming });
511
605
  if (inheritedMessages.length > 0) {
606
+ persistMessages(childSessionManager, inheritedMessages, []);
512
607
  session.agent.state.messages = inheritedMessages;
513
608
  logSteering(runningAgent.id, "context-inherited", { messageCount: inheritedMessages.length });
514
609
  }
515
610
  return session;
516
611
  }
517
612
 
613
+ /**
614
+ * Mirror a message stream into a session file, each role through its own append method.
615
+ * Seeds a child with its inherited snapshot (leading summary), and replays a rebase onto the
616
+ * main session. A compaction with a keptTailCount maps its kept-tail boundary onto this file's
617
+ * own entry ids, so the rebuilt context keeps the same messages verbatim the child kept — the
618
+ * tail may reach back into the pre-replay context, whose per-message ids arrive as
619
+ * priorContextEntryIds (empty when seeding a child).
620
+ */
621
+ export function persistMessages(
622
+ sessionManager: SessionManager,
623
+ messages: readonly AgentMessage[],
624
+ priorContextEntryIds: readonly string[],
625
+ ): void {
626
+ // One id per context message, prior context first; every appended entry projects to one message.
627
+ const entryIdsPerMessage: string[] = [...priorContextEntryIds];
628
+ for (const message of messages) {
629
+ if (message.role === "compactionSummary") {
630
+ const keptTailCount = (message as ChildCompactionMessage).keptTailCount ?? 0;
631
+ const firstKeptEntryId =
632
+ keptTailCount > 0 ? (entryIdsPerMessage[entryIdsPerMessage.length - keptTailCount] ?? "") : "";
633
+ entryIdsPerMessage.push(
634
+ sessionManager.appendCompaction(message.summary, firstKeptEntryId, message.tokensBefore),
635
+ );
636
+ } else if (message.role === "branchSummary")
637
+ entryIdsPerMessage.push(
638
+ sessionManager.branchWithSummary(sessionManager.getLeafId(), message.summary),
639
+ );
640
+ else if (message.role === "custom")
641
+ entryIdsPerMessage.push(
642
+ sessionManager.appendCustomMessageEntry(
643
+ message.customType,
644
+ message.content,
645
+ message.display,
646
+ message.details,
647
+ ),
648
+ );
649
+ else entryIdsPerMessage.push(sessionManager.appendMessage(message));
650
+ }
651
+ }
652
+
518
653
  /** Parks an idle agent until the user resumes it with another instruction (resolves with it) or retires it (resolves undefined). */
519
654
  export function waitForInstruction(
520
655
  agent: RunningAgent,
@@ -528,7 +663,7 @@ export function waitForInstruction(
528
663
  };
529
664
  agent.resume = (instruction) => {
530
665
  logSteering(agent.id, "agent-resumed", { instructionLength: instruction.length });
531
- agent.mainContextState = agent.notifyMainAgent ? "will-join" : "separate";
666
+ agent.mainContextState = agent.notifyMainAgent ? "will-squash" : "separate";
532
667
  agent.status = "running";
533
668
  agent.turnStartedAt = Date.now();
534
669
  agent.completedAt = undefined;
@@ -542,12 +677,26 @@ export function waitForInstruction(
542
677
  });
543
678
  }
544
679
 
545
- function subscribeToChildSession(
680
+ export function subscribeToChildSession(
546
681
  session: AgentSession,
547
682
  runningAgent: RunningAgent,
548
683
  widget: UserAgentWidget,
549
684
  ): () => void {
550
685
  return session.subscribe((event: AgentSessionEvent) => {
686
+ if (event.type === "compaction_end" && !event.aborted && event.result) {
687
+ // Compaction never emits message events; capture it here or the rebase stream misses it.
688
+ // At this instant the live context is exactly [summary, kept tail], so the tail is countable.
689
+ const keptTailCount = Math.max(0, session.agent.state.messages.length - 1);
690
+ const compactionMessage: ChildCompactionMessage = {
691
+ role: "compactionSummary",
692
+ summary: event.result.summary,
693
+ tokensBefore: event.result.tokensBefore,
694
+ timestamp: Date.now(),
695
+ keptTailCount,
696
+ };
697
+ runningAgent.conversationMessages.push(compactionMessage);
698
+ logSteering(runningAgent.id, "child-compacted", { keptTailCount });
699
+ }
551
700
  if (event.type === "agent_start")
552
701
  logSteering(runningAgent.id, "child-agent-started", { streaming: session.isStreaming });
553
702
  if (event.type === "agent_end")
package/shared.ts CHANGED
@@ -16,7 +16,7 @@ export type Theme = ExtensionCommandContext["ui"]["theme"];
16
16
  export type UIContext = ExtensionCommandContext["ui"];
17
17
 
18
18
  export type AgentCommandName = "agent";
19
- export type MainContextState = "separate" | "will-join" | "joined";
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;
@@ -40,6 +40,30 @@ export type AgentEntryData = {
40
40
  details: AgentCommandDetails;
41
41
  };
42
42
 
43
+ export type DetachedEntryData = {
44
+ sessionId: string;
45
+ };
46
+
47
+ export type RebaseStats = {
48
+ messageCount: number;
49
+ tokenEstimate: number;
50
+ compactionCount: number;
51
+ };
52
+
53
+ export type RebasedEntryData = {
54
+ sessionId: string;
55
+ /** Absent on entries persisted before stats existed. */
56
+ stats?: RebaseStats;
57
+ };
58
+
59
+ /**
60
+ * A child compaction captured from its compaction_end event. keptTailCount records how many
61
+ * messages the compaction kept verbatim, so a rebase replay can restore the exact boundary.
62
+ */
63
+ export type ChildCompactionMessage = Extract<AgentMessage, { role: "compactionSummary" }> & {
64
+ keptTailCount?: number;
65
+ };
66
+
43
67
  export type AgentResultMessage = {
44
68
  customType: string;
45
69
  content: string;
@@ -67,15 +91,19 @@ export type AgentCommandDetails = {
67
91
 
68
92
  export type RunningAgent = {
69
93
  id: string;
94
+ /** The child's own Pi session, resumable with `/resume <sessionId>` after the agent is detached. */
95
+ sessionId: string;
70
96
  command: AgentCommandName;
71
97
  inheritedContext: boolean;
72
98
  model: string;
73
99
  modelLabel: string;
74
100
  task: string;
75
- /** The slash command line as the user typed it, e.g. `/agent -j fix the bug`. */
101
+ /** The slash command line as the user typed it, e.g. `/agent -s fix the bug`. */
76
102
  invocation: string;
77
- /** -j/--join: post invocation+result to the main agent and trigger its turn, instead of waiting quietly for the next user prompt. */
103
+ /** -s/--squash: post invocation+result to the main agent and trigger its turn, instead of waiting quietly for the next user prompt. */
78
104
  notifyMainAgent: boolean;
105
+ /** Fingerprint of the main context the child was dispatched from ("[]" for -i); the rebase fast-forward base. */
106
+ dispatchBaseFingerprint: string;
79
107
  mainContextState: MainContextState;
80
108
  status: AgentStatus;
81
109
  startedAt: number;
@@ -91,8 +119,8 @@ export type RunningAgent = {
91
119
  latestFinalizedMessage?: AgentMessage;
92
120
  error?: string;
93
121
  session?: AgentSession;
94
- /** Latest completed turn's result message, deliverable to the main agent via the overlay's join action. */
95
- pendingJoinMessage?: AgentResultMessage;
122
+ /** Latest completed turn's result message, deliverable to the main agent via the overlay's squash action. */
123
+ pendingSquashMessage?: AgentResultMessage;
96
124
  /** Starts another turn on an idle agent; assigned while the lifecycle loop awaits the next instruction. */
97
125
  resume?: (instruction: string) => void;
98
126
  /** Ends an idle agent's lifecycle without another turn; assigned alongside resume. */
@@ -106,11 +134,13 @@ export type RunningAgent = {
106
134
 
107
135
  export type CompletedAgent = {
108
136
  id: string;
137
+ sessionId: string;
109
138
  command: AgentCommandName;
110
139
  modelLabel: string;
111
140
  task: string;
141
+ dispatchBaseFingerprint: string;
112
142
  mainContextState: MainContextState;
113
- pendingJoinMessage?: AgentResultMessage;
143
+ pendingSquashMessage?: AgentResultMessage;
114
144
  ok: boolean;
115
145
  responseText: string;
116
146
  messages: AgentMessage[];
@@ -122,7 +152,61 @@ export type CompletedAgent = {
122
152
  contextPercent?: number;
123
153
  };
124
154
 
155
+ /** The mechanism that fast-forwards a child conversation onto the main session, injected into the widget. */
156
+ export type RebaseDelivery = {
157
+ /** Whether the main session's live context still equals this agent's dispatch base. */
158
+ canDeliver(agent: RunningAgent | CompletedAgent): boolean;
159
+ /** Append the processed child conversation onto the main session. */
160
+ deliver(agent: RunningAgent | CompletedAgent, messages: AgentMessage[]): void;
161
+ };
162
+
163
+ /** How long a confirmation stays armed, and how long a transient footer notice shows. */
164
+ export const CONFIRMATION_WINDOW_MS = 4000;
165
+
166
+ /**
167
+ * A two-press confirmation with a timeout: the first press arms it, a repeat press on the
168
+ * same target within the window confirms. A press on another target re-arms, cancel or
169
+ * expiry disarms. One instance serves every confirmable action on a surface.
170
+ */
171
+ export class TimedConfirmation<Target> {
172
+ private armedOn: Target | undefined;
173
+ private expiry: ReturnType<typeof setTimeout> | undefined;
174
+
175
+ constructor(
176
+ private readonly onExpire: () => void,
177
+ private readonly windowMs: number = CONFIRMATION_WINDOW_MS,
178
+ ) {}
179
+
180
+ /** One press of the action key: true means confirmed, false means armed and waiting. */
181
+ press(target: Target): boolean {
182
+ if (this.armedOn === target) {
183
+ this.cancel();
184
+ return true;
185
+ }
186
+ this.armedOn = target;
187
+ if (this.expiry) clearTimeout(this.expiry);
188
+ this.expiry = setTimeout(() => {
189
+ this.armedOn = undefined;
190
+ this.expiry = undefined;
191
+ this.onExpire();
192
+ }, this.windowMs);
193
+ return false;
194
+ }
195
+
196
+ cancel(): void {
197
+ this.armedOn = undefined;
198
+ if (this.expiry) clearTimeout(this.expiry);
199
+ this.expiry = undefined;
200
+ }
201
+
202
+ isArmedOn(target: Target): boolean {
203
+ return this.armedOn !== undefined && this.armedOn === target;
204
+ }
205
+ }
206
+
125
207
  export const MESSAGE_TYPE = "pi-user-agents";
208
+ export const DETACHED_ENTRY_TYPE = "pi-user-agents-detached";
209
+ export const REBASED_ENTRY_TYPE = "pi-user-agents-rebased";
126
210
  export const WIDGET_KEY = "pi-user-agents";
127
211
  export const STEERING_LOG_PATH = `${tmpdir()}/pi-user-agents-steer.log`;
128
212
 
@@ -162,8 +246,9 @@ export function contextLabel(inheritedContext: boolean): string {
162
246
  }
163
247
 
164
248
  export function mainContextLabel(state: MainContextState | undefined): string | undefined {
165
- if (state === "will-join") return "will join context";
166
- if (state === "joined") return "joined context";
249
+ if (state === "will-squash") return "will squash into context";
250
+ if (state === "squashed") return "squashed messages into context";
251
+ if (state === "rebased") return "rebased into context";
167
252
  return undefined;
168
253
  }
169
254
 
package/transcript.ts CHANGED
@@ -6,13 +6,16 @@ import type {
6
6
  AgentEntryData,
7
7
  AgentMessage,
8
8
  AgentResultMessage,
9
+ DetachedEntryData,
9
10
  ExtensionAPI,
10
11
  ExtensionCommandContext,
12
+ RebasedEntryData,
11
13
  RunningAgent,
12
14
  Theme,
13
15
  } from "./shared.js";
14
16
  import {
15
17
  contextLabel,
18
+ DETACHED_ENTRY_TYPE,
16
19
  escapeAttribute,
17
20
  extractTag,
18
21
  formatMs,
@@ -20,6 +23,7 @@ import {
20
23
  formatTurns,
21
24
  mainContextLabel,
22
25
  MESSAGE_TYPE,
26
+ REBASED_ENTRY_TYPE,
23
27
  truncatePlain,
24
28
  } from "./shared.js";
25
29
 
@@ -51,14 +55,14 @@ export function reportCommandError(
51
55
  }
52
56
 
53
57
  /**
54
- * Select the joined role messages from a child transcript.
58
+ * Select the squashed role messages from a child transcript.
55
59
  *
56
- * @example selectJoinedMessages([{ role: "user", content: "hello", timestamp: 0 }]).length // 1
60
+ * @example selectSquashedMessages([{ role: "user", content: "hello", timestamp: 0 }]).length // 1
57
61
  */
58
- type JoinedMessage = Extract<AgentMessage, { role: "user" | "assistant" }>;
62
+ type SquashedMessage = Extract<AgentMessage, { role: "user" | "assistant" }>;
59
63
 
60
- export function selectJoinedMessages(messages: readonly AgentMessage[]): JoinedMessage[] {
61
- const selected: JoinedMessage[] = [];
64
+ export function selectSquashedMessages(messages: readonly AgentMessage[]): SquashedMessage[] {
65
+ const selected: SquashedMessage[] = [];
62
66
  let latestAssistant: Extract<AgentMessage, { role: "assistant" }> | undefined;
63
67
  for (const message of messages) {
64
68
  if (message.role === "assistant" && roleMessageText(message).trim()) {
@@ -108,13 +112,13 @@ export function buildAgentResultMessage(
108
112
  };
109
113
  }
110
114
 
111
- const JOINED_CONVERSATION_PREFACE =
115
+ const SQUASHED_CONVERSATION_PREFACE =
112
116
  "The user has dispatched a background sub-agent with a task. The sub-agent is done. The following is the back and forth between them:";
113
117
 
114
118
  export function formatResultMessage(agent: RunningAgent): string {
115
- const messages = selectJoinedMessages(agent.conversationMessages);
119
+ const messages = selectSquashedMessages(agent.conversationMessages);
116
120
  const lines = [
117
- JOINED_CONVERSATION_PREFACE,
121
+ SQUASHED_CONVERSATION_PREFACE,
118
122
  `<user_agent model="${escapeAttribute(agent.model)}" inherited_context="${agent.inheritedContext}">`,
119
123
  ];
120
124
  let firstUserMessage = true;
@@ -133,7 +137,7 @@ export function formatResultMessage(agent: RunningAgent): string {
133
137
  return lines.join("\n");
134
138
  }
135
139
 
136
- function roleMessageText(message: JoinedMessage): string {
140
+ function roleMessageText(message: SquashedMessage): string {
137
141
  if (typeof message.content === "string") return message.content;
138
142
  return message.content
139
143
  .flatMap((part) => (part.type === "text" ? [part.text] : []))
@@ -212,6 +216,37 @@ export function registerUserAgentRenderer(pi: ExtensionAPI): void {
212
216
  )
213
217
  : undefined,
214
218
  );
219
+ // Pi's chat container does not pad renderer components; its own chat lines self-pad with paddingX 1.
220
+ pi.registerEntryRenderer<DetachedEntryData>(DETACHED_ENTRY_TYPE, (entry, _state, theme) =>
221
+ entry.data
222
+ ? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`), 1, 0)
223
+ : undefined,
224
+ );
225
+ pi.registerEntryRenderer<RebasedEntryData>(REBASED_ENTRY_TYPE, (entry, _state, theme) =>
226
+ entry.data ? new Text(theme.fg("dim", formatRebasedLine(entry.data)), 1, 0) : undefined,
227
+ );
228
+ }
229
+
230
+ function formatRebasedLine(data: RebasedEntryData): string {
231
+ const base = `Rebased session ${data.sessionId} into this conversation`;
232
+ if (!data.stats) return base;
233
+ const parts = [
234
+ `added ${data.stats.messageCount} message${data.stats.messageCount === 1 ? "" : "s"}`,
235
+ `~${formatTokenCount(data.stats.tokenEstimate)} tokens`,
236
+ ];
237
+ if (data.stats.compactionCount > 0)
238
+ parts.push(
239
+ `${data.stats.compactionCount} compaction event${data.stats.compactionCount === 1 ? "" : "s"}`,
240
+ );
241
+ return `${base} (${parts.join(", ")})`;
242
+ }
243
+
244
+ /**
245
+ * @example formatTokenCount(135_264) // "135K"
246
+ * @example formatTokenCount(12) // "12"
247
+ */
248
+ function formatTokenCount(tokens: number): string {
249
+ return tokens < 1000 ? `${tokens}` : `${Math.round(tokens / 1000)}K`;
215
250
  }
216
251
 
217
252
  function liveAgentCard(
@@ -244,7 +279,7 @@ function renderAgentCard(
244
279
  if (parts.length > 0) text += ` ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}`;
245
280
  if (task) text += `\n ${theme.fg("dim", `task: ${truncatePlain(task, 88)}`)}`;
246
281
  const card = new Container();
247
- card.addChild(new Text(text, 0, 0));
282
+ card.addChild(new Text(text, 1, 0));
248
283
  card.addChild(body);
249
284
  return card;
250
285
  }
@@ -263,12 +298,12 @@ function renderAgentCardBody(
263
298
  "No output.";
264
299
  if (expanded)
265
300
  return ok
266
- ? new Markdown(output, 2, 0, getMarkdownTheme())
267
- : new Text(theme.fg("error", output), 2, 0);
301
+ ? new Markdown(output, 3, 0, getMarkdownTheme())
302
+ : new Text(theme.fg("error", output), 3, 0);
268
303
 
269
304
  const previewText = `⎿ ${truncatePlain(details?.responsePreview ?? output, 110)}`;
270
- if (!ok) return new Text(` ${theme.fg("error", previewText)}`, 0, 0);
271
- return new Markdown(previewText, 2, 0, getMarkdownTheme(), {
305
+ if (!ok) return new Text(` ${theme.fg("error", previewText)}`, 1, 0);
306
+ return new Markdown(previewText, 3, 0, getMarkdownTheme(), {
272
307
  color: (value) => theme.fg("dim", value),
273
308
  });
274
309
  }