@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/widget.ts
CHANGED
|
@@ -15,9 +15,11 @@ import {
|
|
|
15
15
|
type ViewableAgent,
|
|
16
16
|
} from "./agent-viewer.js";
|
|
17
17
|
import { renderAgentContextMeter } from "./context-meter.js";
|
|
18
|
+
import { selectRebaseMessages } from "./rebase.js";
|
|
18
19
|
import type {
|
|
19
20
|
AgentResultMessage,
|
|
20
21
|
CompletedAgent,
|
|
22
|
+
RebaseDelivery,
|
|
21
23
|
RunningAgent,
|
|
22
24
|
Theme,
|
|
23
25
|
UIContext,
|
|
@@ -25,6 +27,7 @@ import type {
|
|
|
25
27
|
import {
|
|
26
28
|
contextLabel,
|
|
27
29
|
formatMs,
|
|
30
|
+
TimedConfirmation,
|
|
28
31
|
formatToolUses,
|
|
29
32
|
formatTurns,
|
|
30
33
|
logSteering,
|
|
@@ -49,13 +52,15 @@ export class UserAgentWidget {
|
|
|
49
52
|
private active = false;
|
|
50
53
|
private selectedIndex = 0;
|
|
51
54
|
private viewerOpen = false;
|
|
52
|
-
|
|
55
|
+
/** One confirmation slot for detaching the selected agent. */
|
|
56
|
+
private readonly detachConfirmation = new TimedConfirmation<ViewableAgent>(() => this.update());
|
|
53
57
|
private readonly completedAgents: CompletedAgent[] = [];
|
|
54
58
|
|
|
55
59
|
constructor(
|
|
56
60
|
private readonly runningAgents: Set<RunningAgent>,
|
|
57
|
-
private readonly
|
|
61
|
+
private readonly sendSquashedResult: (message: AgentResultMessage) => void,
|
|
58
62
|
private readonly announceDetached: (sessionId: string) => void,
|
|
63
|
+
private readonly rebaseDelivery: RebaseDelivery,
|
|
59
64
|
) {}
|
|
60
65
|
|
|
61
66
|
setUI(ui: UIContext): void {
|
|
@@ -64,14 +69,14 @@ export class UserAgentWidget {
|
|
|
64
69
|
this.ui = ui;
|
|
65
70
|
this.widgetRegistered = false;
|
|
66
71
|
this.tui = undefined;
|
|
67
|
-
this.
|
|
72
|
+
this.detachConfirmation.cancel();
|
|
68
73
|
this.inputUnsub = ui.onTerminalInput((data) => this.handleKey(data));
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
addCompleted(
|
|
72
77
|
agent: RunningAgent,
|
|
73
78
|
resultMessage: AgentResultMessage,
|
|
74
|
-
options: {
|
|
79
|
+
options: { squashable: boolean },
|
|
75
80
|
): void {
|
|
76
81
|
this.completedAgents.push({
|
|
77
82
|
id: agent.id,
|
|
@@ -79,8 +84,9 @@ export class UserAgentWidget {
|
|
|
79
84
|
command: agent.command,
|
|
80
85
|
modelLabel: agent.modelLabel,
|
|
81
86
|
task: agent.task,
|
|
87
|
+
dispatchBaseFingerprint: agent.dispatchBaseFingerprint,
|
|
82
88
|
mainContextState: agent.mainContextState,
|
|
83
|
-
|
|
89
|
+
pendingSquashMessage: options.squashable ? resultMessage : undefined,
|
|
84
90
|
ok: resultMessage.details.ok,
|
|
85
91
|
responseText: agent.responseText,
|
|
86
92
|
messages: structuredClone(transcriptMessages(agent)),
|
|
@@ -142,7 +148,7 @@ export class UserAgentWidget {
|
|
|
142
148
|
this.widgetRegistered = false;
|
|
143
149
|
this.tui = undefined;
|
|
144
150
|
this.active = false;
|
|
145
|
-
this.
|
|
151
|
+
this.detachConfirmation.cancel();
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
private clear(): void {
|
|
@@ -157,7 +163,7 @@ export class UserAgentWidget {
|
|
|
157
163
|
}
|
|
158
164
|
this.active = false;
|
|
159
165
|
this.selectedIndex = 0;
|
|
160
|
-
this.
|
|
166
|
+
this.detachConfirmation.cancel();
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
private runningAgentsForWidget(): RunningAgent[] {
|
|
@@ -201,13 +207,13 @@ export class UserAgentWidget {
|
|
|
201
207
|
}
|
|
202
208
|
|
|
203
209
|
if (matchesKey(data, "down")) {
|
|
204
|
-
this.
|
|
210
|
+
this.detachConfirmation.cancel();
|
|
205
211
|
this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
|
|
206
212
|
this.update();
|
|
207
213
|
return { consume: true };
|
|
208
214
|
}
|
|
209
215
|
if (matchesKey(data, "up")) {
|
|
210
|
-
this.
|
|
216
|
+
this.detachConfirmation.cancel();
|
|
211
217
|
if (this.selectedIndex === 0) {
|
|
212
218
|
this.deactivate();
|
|
213
219
|
return { consume: true };
|
|
@@ -227,12 +233,12 @@ export class UserAgentWidget {
|
|
|
227
233
|
selected?.kind === "running" &&
|
|
228
234
|
isLiveAgent(selected.agent)
|
|
229
235
|
) {
|
|
230
|
-
this.
|
|
236
|
+
this.detachConfirmation.cancel();
|
|
231
237
|
this.interruptRunning(selected.agent);
|
|
232
238
|
return { consume: true };
|
|
233
239
|
}
|
|
234
240
|
if (matchesKey(data, "enter")) {
|
|
235
|
-
this.
|
|
241
|
+
this.detachConfirmation.cancel();
|
|
236
242
|
if (selected) this.openSelected(selected.agent);
|
|
237
243
|
return { consume: true };
|
|
238
244
|
}
|
|
@@ -248,19 +254,15 @@ export class UserAgentWidget {
|
|
|
248
254
|
}
|
|
249
255
|
|
|
250
256
|
private confirmDetach(target: ViewableAgent): boolean {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
this.detachConfirmationTarget = target;
|
|
256
|
-
this.update();
|
|
257
|
-
return false;
|
|
257
|
+
const confirmed = this.detachConfirmation.press(target);
|
|
258
|
+
if (!confirmed) this.update();
|
|
259
|
+
return confirmed;
|
|
258
260
|
}
|
|
259
261
|
|
|
260
262
|
private deactivate(): void {
|
|
261
263
|
this.active = false;
|
|
262
264
|
this.selectedIndex = 0;
|
|
263
|
-
this.
|
|
265
|
+
this.detachConfirmation.cancel();
|
|
264
266
|
this.update();
|
|
265
267
|
}
|
|
266
268
|
|
|
@@ -286,8 +288,12 @@ export class UserAgentWidget {
|
|
|
286
288
|
agent,
|
|
287
289
|
theme,
|
|
288
290
|
done,
|
|
289
|
-
() => this.
|
|
290
|
-
() => this.
|
|
291
|
+
() => this.canSquashMainContext(agent.id),
|
|
292
|
+
() => this.squashMainContext(agent.id),
|
|
293
|
+
() => this.canRebaseMainContext(agent.id),
|
|
294
|
+
() => this.rebaseMainContext(agent.id),
|
|
295
|
+
() => this.rebaseBlockReason(agent.id),
|
|
296
|
+
() => this.rebaseDetachCount(agent.id),
|
|
291
297
|
() => {
|
|
292
298
|
if (isRunningAgent(agent)) this.interruptRunning(agent);
|
|
293
299
|
},
|
|
@@ -319,54 +325,107 @@ export class UserAgentWidget {
|
|
|
319
325
|
return [...this.runningAgents].find((agent) => agent.id === agentId && agent.status === "idle");
|
|
320
326
|
}
|
|
321
327
|
|
|
322
|
-
private
|
|
323
|
-
|
|
324
|
-
return this.completedAgents.some(
|
|
325
|
-
(agent) => agent.id === agentId && agent.pendingJoinMessage !== undefined,
|
|
326
|
-
);
|
|
328
|
+
private canSquashMainContext(agentId: string): boolean {
|
|
329
|
+
return this.deliverableAgent(agentId) !== undefined;
|
|
327
330
|
}
|
|
328
331
|
|
|
329
|
-
|
|
332
|
+
confirmMainContextSquash(agentId: string): boolean {
|
|
330
333
|
const agents = [
|
|
331
334
|
...[...this.runningAgents].filter((candidate) => candidate.id === agentId),
|
|
332
335
|
...this.completedAgents.filter((candidate) => candidate.id === agentId),
|
|
333
|
-
].filter((candidate) => candidate.mainContextState === "will-
|
|
336
|
+
].filter((candidate) => candidate.mainContextState === "will-squash");
|
|
334
337
|
if (agents.length === 0) return false;
|
|
335
|
-
for (const agent of agents) agent.mainContextState = "
|
|
338
|
+
for (const agent of agents) agent.mainContextState = "squashed";
|
|
336
339
|
this.update();
|
|
337
340
|
return true;
|
|
338
341
|
}
|
|
339
342
|
|
|
340
|
-
private
|
|
343
|
+
private squashMainContext(agentId: string): void {
|
|
341
344
|
const idleAgent = this.idleAgent(agentId);
|
|
342
345
|
if (idleAgent) {
|
|
343
|
-
this.
|
|
346
|
+
this.squashIdleAgent(idleAgent);
|
|
344
347
|
return;
|
|
345
348
|
}
|
|
346
349
|
const completedAgent = this.completedAgents.find((agent) => agent.id === agentId);
|
|
347
|
-
const message = completedAgent?.
|
|
350
|
+
const message = completedAgent?.pendingSquashMessage;
|
|
348
351
|
if (!completedAgent || !message) return;
|
|
349
352
|
|
|
350
|
-
completedAgent.
|
|
351
|
-
completedAgent.mainContextState = "will-
|
|
352
|
-
message.details.mainContextState = "will-
|
|
353
|
-
this.
|
|
354
|
-
logSteering(agentId, "
|
|
353
|
+
completedAgent.pendingSquashMessage = undefined;
|
|
354
|
+
completedAgent.mainContextState = "will-squash";
|
|
355
|
+
message.details.mainContextState = "will-squash";
|
|
356
|
+
this.sendSquashedResult(message);
|
|
357
|
+
logSteering(agentId, "squashed-main-context", { display: message.display });
|
|
358
|
+
this.update();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** An agent whose latest turn result is still deliverable: idle live, or completed and squashable. */
|
|
362
|
+
private deliverableAgent(agentId: string): RunningAgent | CompletedAgent | undefined {
|
|
363
|
+
const idleAgent = this.idleAgent(agentId);
|
|
364
|
+
if (idleAgent?.pendingSquashMessage) return idleAgent;
|
|
365
|
+
return this.completedAgents.find(
|
|
366
|
+
(agent) => agent.id === agentId && agent.pendingSquashMessage !== undefined,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private canRebaseMainContext(agentId: string): boolean {
|
|
371
|
+
return this.deliverableAgent(agentId) !== undefined && this.rebaseBlockReason(agentId) === undefined;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** How many other agent sessions the rebase cascade would detach. */
|
|
375
|
+
private rebaseDetachCount(agentId: string): number {
|
|
376
|
+
return (
|
|
377
|
+
[...this.runningAgents].filter((agent) => agent.id !== agentId).length +
|
|
378
|
+
this.completedAgents.filter((agent) => agent.id !== agentId).length
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Why a deliverable result cannot rebase right now; undefined when it can, or when nothing is deliverable. */
|
|
383
|
+
private rebaseBlockReason(agentId: string): string | undefined {
|
|
384
|
+
const agent = this.deliverableAgent(agentId);
|
|
385
|
+
if (!agent) return undefined;
|
|
386
|
+
// The session switch tears the extension runtime down, which would abort a mid-turn sibling.
|
|
387
|
+
if ([...this.runningAgents].some(isLiveAgent)) return "another agent is mid-turn";
|
|
388
|
+
if (!this.rebaseDelivery.canDeliver(agent))
|
|
389
|
+
return "the main session has drifted since dispatch";
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Fast-forward the raw child conversation onto the main session, then retire the agent. */
|
|
394
|
+
private rebaseMainContext(agentId: string): void {
|
|
395
|
+
if (!this.canRebaseMainContext(agentId)) return;
|
|
396
|
+
const agent = this.deliverableAgent(agentId);
|
|
397
|
+
if (!agent) return;
|
|
398
|
+
// The reload will not preserve live rows; give every sibling its resumable-session breadcrumb.
|
|
399
|
+
for (const other of [...this.runningAgents]) if (other !== agent) this.closeRunning(other);
|
|
400
|
+
for (const other of [...this.completedAgents]) if (other !== agent) this.removeCompleted(other);
|
|
401
|
+
const message = agent.pendingSquashMessage;
|
|
402
|
+
this.rebaseDelivery.deliver(
|
|
403
|
+
agent,
|
|
404
|
+
selectRebaseMessages(agent.task, structuredClone(transcriptMessages(agent))),
|
|
405
|
+
);
|
|
406
|
+
agent.pendingSquashMessage = undefined;
|
|
407
|
+
agent.mainContextState = "rebased";
|
|
408
|
+
if (isRunningAgent(agent)) {
|
|
409
|
+
agent.status = "posted";
|
|
410
|
+
if (message) this.addCompleted(agent, message, { squashable: false });
|
|
411
|
+
agent.retire?.();
|
|
412
|
+
}
|
|
413
|
+
logSteering(agentId, "rebased-main-context");
|
|
355
414
|
this.update();
|
|
356
415
|
}
|
|
357
416
|
|
|
358
|
-
/**
|
|
359
|
-
private
|
|
360
|
-
const message = agent.
|
|
417
|
+
/** Squashing is one of the two terminal actions on a live idle agent: deliver the result, snapshot it, retire the session. */
|
|
418
|
+
private squashIdleAgent(agent: RunningAgent): void {
|
|
419
|
+
const message = agent.pendingSquashMessage;
|
|
361
420
|
if (!message) return;
|
|
362
|
-
agent.
|
|
363
|
-
agent.mainContextState = "will-
|
|
364
|
-
message.details.mainContextState = "will-
|
|
421
|
+
agent.pendingSquashMessage = undefined;
|
|
422
|
+
agent.mainContextState = "will-squash";
|
|
423
|
+
message.details.mainContextState = "will-squash";
|
|
365
424
|
agent.status = "posted";
|
|
366
|
-
this.addCompleted(agent, message, {
|
|
367
|
-
this.
|
|
425
|
+
this.addCompleted(agent, message, { squashable: false });
|
|
426
|
+
this.sendSquashedResult(message);
|
|
368
427
|
agent.retire?.();
|
|
369
|
-
logSteering(agent.id, "
|
|
428
|
+
logSteering(agent.id, "squashed-main-context", { display: message.display, live: true });
|
|
370
429
|
this.update();
|
|
371
430
|
}
|
|
372
431
|
|
|
@@ -418,7 +477,8 @@ export class UserAgentWidget {
|
|
|
418
477
|
lines.push(truncateToWidth(` ${theme.fg("dim", "←/↓ select agent")}`, width));
|
|
419
478
|
} else {
|
|
420
479
|
const selected = entries[this.selectedIndex];
|
|
421
|
-
const confirmingDetach =
|
|
480
|
+
const confirmingDetach =
|
|
481
|
+
selected !== undefined && this.detachConfirmation.isArmedOn(selected.agent);
|
|
422
482
|
const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
|
|
423
483
|
if (
|
|
424
484
|
!confirmingDetach &&
|