@giladbarnea/pi-user-agents 0.0.4 → 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 +26 -6
- package/PARSER_SPEC.md +3 -3
- package/README.md +79 -25
- package/agent-viewer.ts +66 -14
- package/command-line.ts +10 -10
- package/index.ts +12 -7
- package/package.json +1 -1
- package/rebase.ts +56 -0
- package/runner.ts +128 -24
- package/shared.ts +86 -9
- package/transcript.ts +43 -15
- package/widget.ts +107 -47
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,
|
|
@@ -107,6 +119,7 @@ async function startUserAgent(
|
|
|
107
119
|
model,
|
|
108
120
|
parsed,
|
|
109
121
|
invocation,
|
|
122
|
+
conversationFingerprint(inheritedMessages),
|
|
110
123
|
);
|
|
111
124
|
|
|
112
125
|
runningAgents.add(runningAgent);
|
|
@@ -146,6 +159,7 @@ function createRunningAgent(
|
|
|
146
159
|
model: Model,
|
|
147
160
|
parsed: ParsedAgentCommand,
|
|
148
161
|
invocation: string,
|
|
162
|
+
dispatchBaseFingerprint: string,
|
|
149
163
|
): RunningAgent {
|
|
150
164
|
return {
|
|
151
165
|
id: `user-${sequenceNumber.toString(36)}`,
|
|
@@ -156,8 +170,9 @@ function createRunningAgent(
|
|
|
156
170
|
modelLabel: formatModelLabel(model),
|
|
157
171
|
task: parsed.task,
|
|
158
172
|
invocation,
|
|
159
|
-
notifyMainAgent: parsed.
|
|
160
|
-
|
|
173
|
+
notifyMainAgent: parsed.squash,
|
|
174
|
+
dispatchBaseFingerprint,
|
|
175
|
+
mainContextState: parsed.squash ? "will-squash" : "separate",
|
|
161
176
|
status: "starting",
|
|
162
177
|
startedAt: Date.now(),
|
|
163
178
|
turnStartedAt: Date.now(),
|
|
@@ -187,6 +202,66 @@ function postUserAgentResult(
|
|
|
187
202
|
return true;
|
|
188
203
|
}
|
|
189
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
|
+
|
|
190
265
|
/** Session inputs derived from forwarded pi CLI options, ready to hand to the child session. */
|
|
191
266
|
type ChildResourceLoaderOptions = {
|
|
192
267
|
systemPrompt?: string;
|
|
@@ -394,7 +469,7 @@ async function runAgentLifecycle(
|
|
|
394
469
|
{ ok: false, error: message },
|
|
395
470
|
{ display: runningAgent.notifyMainAgent },
|
|
396
471
|
);
|
|
397
|
-
widget.addCompleted(runningAgent, resultMessage, {
|
|
472
|
+
widget.addCompleted(runningAgent, resultMessage, { squashable: !runningAgent.notifyMainAgent });
|
|
398
473
|
if (postUserAgentResult(pi, isShuttingDown, runningAgent.notifyMainAgent, resultMessage))
|
|
399
474
|
runningAgent.status = "posted";
|
|
400
475
|
} finally {
|
|
@@ -438,7 +513,7 @@ export async function runChildTurns(
|
|
|
438
513
|
{ display: false },
|
|
439
514
|
);
|
|
440
515
|
postUserAgentResult(pi, isShuttingDown, false, resultMessage);
|
|
441
|
-
runningAgent.
|
|
516
|
+
runningAgent.pendingSquashMessage = runningAgent.notifyMainAgent
|
|
442
517
|
? undefined
|
|
443
518
|
: resultMessage;
|
|
444
519
|
if (isShuttingDown()) return;
|
|
@@ -470,13 +545,13 @@ export async function runChildTurns(
|
|
|
470
545
|
resultMessage.details.mainContextState = "separate";
|
|
471
546
|
return;
|
|
472
547
|
}
|
|
473
|
-
widget.addCompleted(runningAgent, resultMessage, {
|
|
548
|
+
widget.addCompleted(runningAgent, resultMessage, { squashable: false });
|
|
474
549
|
const posted = postUserAgentResult(pi, isShuttingDown, true, resultMessage);
|
|
475
550
|
runningAgent.status = posted ? "posted" : runningAgent.status;
|
|
476
551
|
return;
|
|
477
552
|
}
|
|
478
553
|
postUserAgentResult(pi, isShuttingDown, false, resultMessage);
|
|
479
|
-
runningAgent.
|
|
554
|
+
runningAgent.pendingSquashMessage = resultMessage;
|
|
480
555
|
if (isShuttingDown()) return;
|
|
481
556
|
runningAgent.status = "idle";
|
|
482
557
|
logSteering(runningAgent.id, "agent-idle", { turnCount: runningAgent.turnCount });
|
|
@@ -528,7 +603,7 @@ async function createChildSession(
|
|
|
528
603
|
await session.bindExtensions({ mode: "print" });
|
|
529
604
|
logSteering(runningAgent.id, "session-bound", { streaming: session.isStreaming });
|
|
530
605
|
if (inheritedMessages.length > 0) {
|
|
531
|
-
|
|
606
|
+
persistMessages(childSessionManager, inheritedMessages, []);
|
|
532
607
|
session.agent.state.messages = inheritedMessages;
|
|
533
608
|
logSteering(runningAgent.id, "context-inherited", { messageCount: inheritedMessages.length });
|
|
534
609
|
}
|
|
@@ -536,27 +611,42 @@ async function createChildSession(
|
|
|
536
611
|
}
|
|
537
612
|
|
|
538
613
|
/**
|
|
539
|
-
* Mirror
|
|
540
|
-
*
|
|
541
|
-
*
|
|
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).
|
|
542
620
|
*/
|
|
543
|
-
export function
|
|
621
|
+
export function persistMessages(
|
|
544
622
|
sessionManager: SessionManager,
|
|
545
623
|
messages: readonly AgentMessage[],
|
|
624
|
+
priorContextEntryIds: readonly string[],
|
|
546
625
|
): void {
|
|
626
|
+
// One id per context message, prior context first; every appended entry projects to one message.
|
|
627
|
+
const entryIdsPerMessage: string[] = [...priorContextEntryIds];
|
|
547
628
|
for (const message of messages) {
|
|
548
|
-
if (message.role === "compactionSummary")
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
+
);
|
|
552
640
|
else if (message.role === "custom")
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
641
|
+
entryIdsPerMessage.push(
|
|
642
|
+
sessionManager.appendCustomMessageEntry(
|
|
643
|
+
message.customType,
|
|
644
|
+
message.content,
|
|
645
|
+
message.display,
|
|
646
|
+
message.details,
|
|
647
|
+
),
|
|
558
648
|
);
|
|
559
|
-
else sessionManager.appendMessage(message);
|
|
649
|
+
else entryIdsPerMessage.push(sessionManager.appendMessage(message));
|
|
560
650
|
}
|
|
561
651
|
}
|
|
562
652
|
|
|
@@ -573,7 +663,7 @@ export function waitForInstruction(
|
|
|
573
663
|
};
|
|
574
664
|
agent.resume = (instruction) => {
|
|
575
665
|
logSteering(agent.id, "agent-resumed", { instructionLength: instruction.length });
|
|
576
|
-
agent.mainContextState = agent.notifyMainAgent ? "will-
|
|
666
|
+
agent.mainContextState = agent.notifyMainAgent ? "will-squash" : "separate";
|
|
577
667
|
agent.status = "running";
|
|
578
668
|
agent.turnStartedAt = Date.now();
|
|
579
669
|
agent.completedAt = undefined;
|
|
@@ -587,12 +677,26 @@ export function waitForInstruction(
|
|
|
587
677
|
});
|
|
588
678
|
}
|
|
589
679
|
|
|
590
|
-
function subscribeToChildSession(
|
|
680
|
+
export function subscribeToChildSession(
|
|
591
681
|
session: AgentSession,
|
|
592
682
|
runningAgent: RunningAgent,
|
|
593
683
|
widget: UserAgentWidget,
|
|
594
684
|
): () => void {
|
|
595
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
|
+
}
|
|
596
700
|
if (event.type === "agent_start")
|
|
597
701
|
logSteering(runningAgent.id, "child-agent-started", { streaming: session.isStreaming });
|
|
598
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;
|
|
@@ -44,6 +44,26 @@ export type DetachedEntryData = {
|
|
|
44
44
|
sessionId: string;
|
|
45
45
|
};
|
|
46
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
|
+
|
|
47
67
|
export type AgentResultMessage = {
|
|
48
68
|
customType: string;
|
|
49
69
|
content: string;
|
|
@@ -78,10 +98,12 @@ export type RunningAgent = {
|
|
|
78
98
|
model: string;
|
|
79
99
|
modelLabel: string;
|
|
80
100
|
task: string;
|
|
81
|
-
/** 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`. */
|
|
82
102
|
invocation: string;
|
|
83
|
-
/** -
|
|
103
|
+
/** -s/--squash: post invocation+result to the main agent and trigger its turn, instead of waiting quietly for the next user prompt. */
|
|
84
104
|
notifyMainAgent: boolean;
|
|
105
|
+
/** Fingerprint of the main context the child was dispatched from ("[]" for -i); the rebase fast-forward base. */
|
|
106
|
+
dispatchBaseFingerprint: string;
|
|
85
107
|
mainContextState: MainContextState;
|
|
86
108
|
status: AgentStatus;
|
|
87
109
|
startedAt: number;
|
|
@@ -97,8 +119,8 @@ export type RunningAgent = {
|
|
|
97
119
|
latestFinalizedMessage?: AgentMessage;
|
|
98
120
|
error?: string;
|
|
99
121
|
session?: AgentSession;
|
|
100
|
-
/** Latest completed turn's result message, deliverable to the main agent via the overlay's
|
|
101
|
-
|
|
122
|
+
/** Latest completed turn's result message, deliverable to the main agent via the overlay's squash action. */
|
|
123
|
+
pendingSquashMessage?: AgentResultMessage;
|
|
102
124
|
/** Starts another turn on an idle agent; assigned while the lifecycle loop awaits the next instruction. */
|
|
103
125
|
resume?: (instruction: string) => void;
|
|
104
126
|
/** Ends an idle agent's lifecycle without another turn; assigned alongside resume. */
|
|
@@ -116,8 +138,9 @@ export type CompletedAgent = {
|
|
|
116
138
|
command: AgentCommandName;
|
|
117
139
|
modelLabel: string;
|
|
118
140
|
task: string;
|
|
141
|
+
dispatchBaseFingerprint: string;
|
|
119
142
|
mainContextState: MainContextState;
|
|
120
|
-
|
|
143
|
+
pendingSquashMessage?: AgentResultMessage;
|
|
121
144
|
ok: boolean;
|
|
122
145
|
responseText: string;
|
|
123
146
|
messages: AgentMessage[];
|
|
@@ -129,8 +152,61 @@ export type CompletedAgent = {
|
|
|
129
152
|
contextPercent?: number;
|
|
130
153
|
};
|
|
131
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
|
+
|
|
132
207
|
export const MESSAGE_TYPE = "pi-user-agents";
|
|
133
208
|
export const DETACHED_ENTRY_TYPE = "pi-user-agents-detached";
|
|
209
|
+
export const REBASED_ENTRY_TYPE = "pi-user-agents-rebased";
|
|
134
210
|
export const WIDGET_KEY = "pi-user-agents";
|
|
135
211
|
export const STEERING_LOG_PATH = `${tmpdir()}/pi-user-agents-steer.log`;
|
|
136
212
|
|
|
@@ -170,8 +246,9 @@ export function contextLabel(inheritedContext: boolean): string {
|
|
|
170
246
|
}
|
|
171
247
|
|
|
172
248
|
export function mainContextLabel(state: MainContextState | undefined): string | undefined {
|
|
173
|
-
if (state === "will-
|
|
174
|
-
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";
|
|
175
252
|
return undefined;
|
|
176
253
|
}
|
|
177
254
|
|
package/transcript.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
DetachedEntryData,
|
|
10
10
|
ExtensionAPI,
|
|
11
11
|
ExtensionCommandContext,
|
|
12
|
+
RebasedEntryData,
|
|
12
13
|
RunningAgent,
|
|
13
14
|
Theme,
|
|
14
15
|
} from "./shared.js";
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
formatTurns,
|
|
23
24
|
mainContextLabel,
|
|
24
25
|
MESSAGE_TYPE,
|
|
26
|
+
REBASED_ENTRY_TYPE,
|
|
25
27
|
truncatePlain,
|
|
26
28
|
} from "./shared.js";
|
|
27
29
|
|
|
@@ -53,14 +55,14 @@ export function reportCommandError(
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/**
|
|
56
|
-
* Select the
|
|
58
|
+
* Select the squashed role messages from a child transcript.
|
|
57
59
|
*
|
|
58
|
-
* @example
|
|
60
|
+
* @example selectSquashedMessages([{ role: "user", content: "hello", timestamp: 0 }]).length // 1
|
|
59
61
|
*/
|
|
60
|
-
type
|
|
62
|
+
type SquashedMessage = Extract<AgentMessage, { role: "user" | "assistant" }>;
|
|
61
63
|
|
|
62
|
-
export function
|
|
63
|
-
const selected:
|
|
64
|
+
export function selectSquashedMessages(messages: readonly AgentMessage[]): SquashedMessage[] {
|
|
65
|
+
const selected: SquashedMessage[] = [];
|
|
64
66
|
let latestAssistant: Extract<AgentMessage, { role: "assistant" }> | undefined;
|
|
65
67
|
for (const message of messages) {
|
|
66
68
|
if (message.role === "assistant" && roleMessageText(message).trim()) {
|
|
@@ -110,13 +112,13 @@ export function buildAgentResultMessage(
|
|
|
110
112
|
};
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
const
|
|
115
|
+
const SQUASHED_CONVERSATION_PREFACE =
|
|
114
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:";
|
|
115
117
|
|
|
116
118
|
export function formatResultMessage(agent: RunningAgent): string {
|
|
117
|
-
const messages =
|
|
119
|
+
const messages = selectSquashedMessages(agent.conversationMessages);
|
|
118
120
|
const lines = [
|
|
119
|
-
|
|
121
|
+
SQUASHED_CONVERSATION_PREFACE,
|
|
120
122
|
`<user_agent model="${escapeAttribute(agent.model)}" inherited_context="${agent.inheritedContext}">`,
|
|
121
123
|
];
|
|
122
124
|
let firstUserMessage = true;
|
|
@@ -135,7 +137,7 @@ export function formatResultMessage(agent: RunningAgent): string {
|
|
|
135
137
|
return lines.join("\n");
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
function roleMessageText(message:
|
|
140
|
+
function roleMessageText(message: SquashedMessage): string {
|
|
139
141
|
if (typeof message.content === "string") return message.content;
|
|
140
142
|
return message.content
|
|
141
143
|
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
|
@@ -214,11 +216,37 @@ export function registerUserAgentRenderer(pi: ExtensionAPI): void {
|
|
|
214
216
|
)
|
|
215
217
|
: undefined,
|
|
216
218
|
);
|
|
219
|
+
// Pi's chat container does not pad renderer components; its own chat lines self-pad with paddingX 1.
|
|
217
220
|
pi.registerEntryRenderer<DetachedEntryData>(DETACHED_ENTRY_TYPE, (entry, _state, theme) =>
|
|
218
221
|
entry.data
|
|
219
|
-
? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`),
|
|
222
|
+
? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`), 1, 0)
|
|
220
223
|
: undefined,
|
|
221
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`;
|
|
222
250
|
}
|
|
223
251
|
|
|
224
252
|
function liveAgentCard(
|
|
@@ -251,7 +279,7 @@ function renderAgentCard(
|
|
|
251
279
|
if (parts.length > 0) text += ` ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}`;
|
|
252
280
|
if (task) text += `\n ${theme.fg("dim", `task: ${truncatePlain(task, 88)}`)}`;
|
|
253
281
|
const card = new Container();
|
|
254
|
-
card.addChild(new Text(text,
|
|
282
|
+
card.addChild(new Text(text, 1, 0));
|
|
255
283
|
card.addChild(body);
|
|
256
284
|
return card;
|
|
257
285
|
}
|
|
@@ -270,12 +298,12 @@ function renderAgentCardBody(
|
|
|
270
298
|
"No output.";
|
|
271
299
|
if (expanded)
|
|
272
300
|
return ok
|
|
273
|
-
? new Markdown(output,
|
|
274
|
-
: new Text(theme.fg("error", output),
|
|
301
|
+
? new Markdown(output, 3, 0, getMarkdownTheme())
|
|
302
|
+
: new Text(theme.fg("error", output), 3, 0);
|
|
275
303
|
|
|
276
304
|
const previewText = `⎿ ${truncatePlain(details?.responsePreview ?? output, 110)}`;
|
|
277
|
-
if (!ok) return new Text(` ${theme.fg("error", previewText)}`,
|
|
278
|
-
return new Markdown(previewText,
|
|
305
|
+
if (!ok) return new Text(` ${theme.fg("error", previewText)}`, 1, 0);
|
|
306
|
+
return new Markdown(previewText, 3, 0, getMarkdownTheme(), {
|
|
279
307
|
color: (value) => theme.fg("dim", value),
|
|
280
308
|
});
|
|
281
309
|
}
|