@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/INTERNALS.md +36 -6
- package/PARSER_SPEC.md +3 -3
- package/README.md +83 -26
- package/agent-viewer.ts +66 -14
- package/attach.ts +339 -0
- package/command-line.ts +10 -10
- package/index.ts +29 -7
- package/package.json +1 -1
- package/rebase.ts +67 -0
- package/runner.ts +217 -69
- package/shared.ts +104 -11
- package/transcript.ts +51 -17
- package/widget.ts +122 -47
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 {
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
160
|
-
|
|
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
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (parsed.
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
532
|
+
initialInstruction: string,
|
|
414
533
|
session: AgentSession,
|
|
415
534
|
runningAgent: RunningAgent,
|
|
416
535
|
widget: UserAgentWidget,
|
|
417
536
|
): Promise<void> {
|
|
418
|
-
let instruction =
|
|
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.
|
|
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, {
|
|
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.
|
|
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
|
-
|
|
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
|
|
540
|
-
*
|
|
541
|
-
*
|
|
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
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
115
|
+
/** The slash command line as the user typed it, e.g. `/agent -s fix the bug`. */
|
|
82
116
|
invocation: string;
|
|
83
|
-
/** -
|
|
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
|
|
101
|
-
|
|
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
|
-
|
|
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-
|
|
174
|
-
if (state === "
|
|
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
|
|