@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/INTERNALS.md +41 -4
- package/PARSER_SPEC.md +3 -3
- package/README.md +89 -24
- package/agent-viewer.ts +73 -20
- package/command-line.ts +10 -10
- package/index.ts +23 -11
- package/package.json +1 -1
- package/rebase.ts +56 -0
- package/runner.ts +160 -11
- package/shared.ts +94 -9
- package/transcript.ts +49 -14
- package/widget.ts +121 -56
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 {
|
|
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
|
-
|
|
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.
|
|
146
|
-
|
|
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, {
|
|
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.
|
|
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, {
|
|
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.
|
|
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:
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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 -
|
|
101
|
+
/** The slash command line as the user typed it, e.g. `/agent -s fix the bug`. */
|
|
76
102
|
invocation: string;
|
|
77
|
-
/** -
|
|
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
|
|
95
|
-
|
|
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
|
-
|
|
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-
|
|
166
|
-
if (state === "
|
|
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
|
|
58
|
+
* Select the squashed role messages from a child transcript.
|
|
55
59
|
*
|
|
56
|
-
* @example
|
|
60
|
+
* @example selectSquashedMessages([{ role: "user", content: "hello", timestamp: 0 }]).length // 1
|
|
57
61
|
*/
|
|
58
|
-
type
|
|
62
|
+
type SquashedMessage = Extract<AgentMessage, { role: "user" | "assistant" }>;
|
|
59
63
|
|
|
60
|
-
export function
|
|
61
|
-
const selected:
|
|
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
|
|
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 =
|
|
119
|
+
const messages = selectSquashedMessages(agent.conversationMessages);
|
|
116
120
|
const lines = [
|
|
117
|
-
|
|
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:
|
|
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,
|
|
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,
|
|
267
|
-
: new Text(theme.fg("error", output),
|
|
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)}`,
|
|
271
|
-
return new Markdown(previewText,
|
|
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
|
}
|