@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/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,12 +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,
|
|
62
|
+
private readonly announceDetached: (sessionId: string) => void,
|
|
63
|
+
private readonly rebaseDelivery: RebaseDelivery,
|
|
58
64
|
) {}
|
|
59
65
|
|
|
60
66
|
setUI(ui: UIContext): void {
|
|
@@ -63,22 +69,24 @@ export class UserAgentWidget {
|
|
|
63
69
|
this.ui = ui;
|
|
64
70
|
this.widgetRegistered = false;
|
|
65
71
|
this.tui = undefined;
|
|
66
|
-
this.
|
|
72
|
+
this.detachConfirmation.cancel();
|
|
67
73
|
this.inputUnsub = ui.onTerminalInput((data) => this.handleKey(data));
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
addCompleted(
|
|
71
77
|
agent: RunningAgent,
|
|
72
78
|
resultMessage: AgentResultMessage,
|
|
73
|
-
options: {
|
|
79
|
+
options: { squashable: boolean },
|
|
74
80
|
): void {
|
|
75
81
|
this.completedAgents.push({
|
|
76
82
|
id: agent.id,
|
|
83
|
+
sessionId: agent.sessionId,
|
|
77
84
|
command: agent.command,
|
|
78
85
|
modelLabel: agent.modelLabel,
|
|
79
86
|
task: agent.task,
|
|
87
|
+
dispatchBaseFingerprint: agent.dispatchBaseFingerprint,
|
|
80
88
|
mainContextState: agent.mainContextState,
|
|
81
|
-
|
|
89
|
+
pendingSquashMessage: options.squashable ? resultMessage : undefined,
|
|
82
90
|
ok: resultMessage.details.ok,
|
|
83
91
|
responseText: agent.responseText,
|
|
84
92
|
messages: structuredClone(transcriptMessages(agent)),
|
|
@@ -140,7 +148,7 @@ export class UserAgentWidget {
|
|
|
140
148
|
this.widgetRegistered = false;
|
|
141
149
|
this.tui = undefined;
|
|
142
150
|
this.active = false;
|
|
143
|
-
this.
|
|
151
|
+
this.detachConfirmation.cancel();
|
|
144
152
|
}
|
|
145
153
|
|
|
146
154
|
private clear(): void {
|
|
@@ -155,7 +163,7 @@ export class UserAgentWidget {
|
|
|
155
163
|
}
|
|
156
164
|
this.active = false;
|
|
157
165
|
this.selectedIndex = 0;
|
|
158
|
-
this.
|
|
166
|
+
this.detachConfirmation.cancel();
|
|
159
167
|
}
|
|
160
168
|
|
|
161
169
|
private runningAgentsForWidget(): RunningAgent[] {
|
|
@@ -199,13 +207,13 @@ export class UserAgentWidget {
|
|
|
199
207
|
}
|
|
200
208
|
|
|
201
209
|
if (matchesKey(data, "down")) {
|
|
202
|
-
this.
|
|
210
|
+
this.detachConfirmation.cancel();
|
|
203
211
|
this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
|
|
204
212
|
this.update();
|
|
205
213
|
return { consume: true };
|
|
206
214
|
}
|
|
207
215
|
if (matchesKey(data, "up")) {
|
|
208
|
-
this.
|
|
216
|
+
this.detachConfirmation.cancel();
|
|
209
217
|
if (this.selectedIndex === 0) {
|
|
210
218
|
this.deactivate();
|
|
211
219
|
return { consume: true };
|
|
@@ -225,17 +233,17 @@ export class UserAgentWidget {
|
|
|
225
233
|
selected?.kind === "running" &&
|
|
226
234
|
isLiveAgent(selected.agent)
|
|
227
235
|
) {
|
|
228
|
-
this.
|
|
236
|
+
this.detachConfirmation.cancel();
|
|
229
237
|
this.interruptRunning(selected.agent);
|
|
230
238
|
return { consume: true };
|
|
231
239
|
}
|
|
232
240
|
if (matchesKey(data, "enter")) {
|
|
233
|
-
this.
|
|
241
|
+
this.detachConfirmation.cancel();
|
|
234
242
|
if (selected) this.openSelected(selected.agent);
|
|
235
243
|
return { consume: true };
|
|
236
244
|
}
|
|
237
|
-
if (matchesKey(data, "
|
|
238
|
-
if (!this.
|
|
245
|
+
if (matchesKey(data, "d") && selected) {
|
|
246
|
+
if (!this.confirmDetach(selected.agent)) return { consume: true };
|
|
239
247
|
if (selected.kind === "running") this.closeRunning(selected.agent);
|
|
240
248
|
else this.dismissCompleted(selected.agent);
|
|
241
249
|
return { consume: true };
|
|
@@ -245,20 +253,16 @@ export class UserAgentWidget {
|
|
|
245
253
|
return undefined;
|
|
246
254
|
}
|
|
247
255
|
|
|
248
|
-
private
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
this.disposeConfirmationTarget = target;
|
|
254
|
-
this.update();
|
|
255
|
-
return false;
|
|
256
|
+
private confirmDetach(target: ViewableAgent): boolean {
|
|
257
|
+
const confirmed = this.detachConfirmation.press(target);
|
|
258
|
+
if (!confirmed) this.update();
|
|
259
|
+
return confirmed;
|
|
256
260
|
}
|
|
257
261
|
|
|
258
262
|
private deactivate(): void {
|
|
259
263
|
this.active = false;
|
|
260
264
|
this.selectedIndex = 0;
|
|
261
|
-
this.
|
|
265
|
+
this.detachConfirmation.cancel();
|
|
262
266
|
this.update();
|
|
263
267
|
}
|
|
264
268
|
|
|
@@ -284,13 +288,17 @@ export class UserAgentWidget {
|
|
|
284
288
|
agent,
|
|
285
289
|
theme,
|
|
286
290
|
done,
|
|
287
|
-
() => this.
|
|
288
|
-
() => 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),
|
|
289
297
|
() => {
|
|
290
298
|
if (isRunningAgent(agent)) this.interruptRunning(agent);
|
|
291
299
|
},
|
|
292
300
|
),
|
|
293
|
-
{ overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "
|
|
301
|
+
{ overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "85%" } },
|
|
294
302
|
)
|
|
295
303
|
.then(
|
|
296
304
|
(action) => this.onViewerClosed(agent, action),
|
|
@@ -298,10 +306,12 @@ export class UserAgentWidget {
|
|
|
298
306
|
);
|
|
299
307
|
}
|
|
300
308
|
|
|
309
|
+
/** Detaching ends the live session and leaves its session file on disk, untouched. */
|
|
301
310
|
private closeRunning(agent: RunningAgent): void {
|
|
302
311
|
agent.aborted = true;
|
|
303
312
|
void agent.session?.abort();
|
|
304
313
|
agent.retire?.();
|
|
314
|
+
this.announceDetached(agent.sessionId);
|
|
305
315
|
this.update();
|
|
306
316
|
}
|
|
307
317
|
|
|
@@ -315,65 +325,118 @@ export class UserAgentWidget {
|
|
|
315
325
|
return [...this.runningAgents].find((agent) => agent.id === agentId && agent.status === "idle");
|
|
316
326
|
}
|
|
317
327
|
|
|
318
|
-
private
|
|
319
|
-
|
|
320
|
-
return this.completedAgents.some(
|
|
321
|
-
(agent) => agent.id === agentId && agent.pendingJoinMessage !== undefined,
|
|
322
|
-
);
|
|
328
|
+
private canSquashMainContext(agentId: string): boolean {
|
|
329
|
+
return this.deliverableAgent(agentId) !== undefined;
|
|
323
330
|
}
|
|
324
331
|
|
|
325
|
-
|
|
332
|
+
confirmMainContextSquash(agentId: string): boolean {
|
|
326
333
|
const agents = [
|
|
327
334
|
...[...this.runningAgents].filter((candidate) => candidate.id === agentId),
|
|
328
335
|
...this.completedAgents.filter((candidate) => candidate.id === agentId),
|
|
329
|
-
].filter((candidate) => candidate.mainContextState === "will-
|
|
336
|
+
].filter((candidate) => candidate.mainContextState === "will-squash");
|
|
330
337
|
if (agents.length === 0) return false;
|
|
331
|
-
for (const agent of agents) agent.mainContextState = "
|
|
338
|
+
for (const agent of agents) agent.mainContextState = "squashed";
|
|
332
339
|
this.update();
|
|
333
340
|
return true;
|
|
334
341
|
}
|
|
335
342
|
|
|
336
|
-
private
|
|
343
|
+
private squashMainContext(agentId: string): void {
|
|
337
344
|
const idleAgent = this.idleAgent(agentId);
|
|
338
345
|
if (idleAgent) {
|
|
339
|
-
this.
|
|
346
|
+
this.squashIdleAgent(idleAgent);
|
|
340
347
|
return;
|
|
341
348
|
}
|
|
342
349
|
const completedAgent = this.completedAgents.find((agent) => agent.id === agentId);
|
|
343
|
-
const message = completedAgent?.
|
|
350
|
+
const message = completedAgent?.pendingSquashMessage;
|
|
344
351
|
if (!completedAgent || !message) return;
|
|
345
352
|
|
|
346
|
-
completedAgent.
|
|
347
|
-
completedAgent.mainContextState = "will-
|
|
348
|
-
message.details.mainContextState = "will-
|
|
349
|
-
this.
|
|
350
|
-
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");
|
|
351
414
|
this.update();
|
|
352
415
|
}
|
|
353
416
|
|
|
354
|
-
/**
|
|
355
|
-
private
|
|
356
|
-
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;
|
|
357
420
|
if (!message) return;
|
|
358
|
-
agent.
|
|
359
|
-
agent.mainContextState = "will-
|
|
360
|
-
message.details.mainContextState = "will-
|
|
421
|
+
agent.pendingSquashMessage = undefined;
|
|
422
|
+
agent.mainContextState = "will-squash";
|
|
423
|
+
message.details.mainContextState = "will-squash";
|
|
361
424
|
agent.status = "posted";
|
|
362
|
-
this.addCompleted(agent, message, {
|
|
363
|
-
this.
|
|
425
|
+
this.addCompleted(agent, message, { squashable: false });
|
|
426
|
+
this.sendSquashedResult(message);
|
|
364
427
|
agent.retire?.();
|
|
365
|
-
logSteering(agent.id, "
|
|
428
|
+
logSteering(agent.id, "squashed-main-context", { display: message.display, live: true });
|
|
366
429
|
this.update();
|
|
367
430
|
}
|
|
368
431
|
|
|
369
432
|
private onViewerClosed(agent: ViewableAgent, action: AgentViewerAction): void {
|
|
370
433
|
this.viewerOpen = false;
|
|
371
|
-
if (action === "
|
|
434
|
+
if (action === "detach" && isRunningAgent(agent) && this.runningAgents.has(agent)) {
|
|
372
435
|
this.closeRunning(agent);
|
|
373
436
|
return;
|
|
374
437
|
}
|
|
375
438
|
const completedAgent = this.completedAgents.find((candidate) => candidate.id === agent.id);
|
|
376
|
-
if (action === "
|
|
439
|
+
if (action === "detach" && completedAgent) this.removeCompleted(completedAgent);
|
|
377
440
|
else this.update();
|
|
378
441
|
}
|
|
379
442
|
|
|
@@ -384,6 +447,7 @@ export class UserAgentWidget {
|
|
|
384
447
|
private removeCompleted(agent: CompletedAgent): void {
|
|
385
448
|
const index = this.completedAgents.indexOf(agent);
|
|
386
449
|
if (index >= 0) this.completedAgents.splice(index, 1);
|
|
450
|
+
this.announceDetached(agent.sessionId);
|
|
387
451
|
const total = this.entries().length;
|
|
388
452
|
if (total === 0) {
|
|
389
453
|
this.deactivate();
|
|
@@ -413,18 +477,19 @@ export class UserAgentWidget {
|
|
|
413
477
|
lines.push(truncateToWidth(` ${theme.fg("dim", "←/↓ select agent")}`, width));
|
|
414
478
|
} else {
|
|
415
479
|
const selected = entries[this.selectedIndex];
|
|
416
|
-
const
|
|
480
|
+
const confirmingDetach =
|
|
481
|
+
selected !== undefined && this.detachConfirmation.isArmedOn(selected.agent);
|
|
417
482
|
const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
|
|
418
483
|
if (
|
|
419
|
-
!
|
|
484
|
+
!confirmingDetach &&
|
|
420
485
|
selected?.kind === "running" &&
|
|
421
486
|
isLiveAgent(selected.agent)
|
|
422
487
|
)
|
|
423
488
|
segments.push(theme.fg("accent", "Ctrl+x interrupt"));
|
|
424
489
|
segments.push(
|
|
425
490
|
theme.fg(
|
|
426
|
-
|
|
427
|
-
|
|
491
|
+
confirmingDetach ? "error" : "accent",
|
|
492
|
+
confirmingDetach ? "d again to confirm" : "d detach",
|
|
428
493
|
),
|
|
429
494
|
theme.fg("accent", "Esc back"),
|
|
430
495
|
);
|