@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/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
- private disposeConfirmationTarget: ViewableAgent | undefined;
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 sendJoinedResult: (message: AgentResultMessage) => void,
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.disposeConfirmationTarget = undefined;
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: { joinable: boolean },
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
- pendingJoinMessage: options.joinable ? resultMessage : undefined,
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.disposeConfirmationTarget = undefined;
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.disposeConfirmationTarget = undefined;
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.disposeConfirmationTarget = undefined;
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.disposeConfirmationTarget = undefined;
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.disposeConfirmationTarget = undefined;
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.disposeConfirmationTarget = undefined;
241
+ this.detachConfirmation.cancel();
234
242
  if (selected) this.openSelected(selected.agent);
235
243
  return { consume: true };
236
244
  }
237
- if (matchesKey(data, "x") && selected) {
238
- if (!this.confirmDispose(selected.agent)) return { consume: true };
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 confirmDispose(target: ViewableAgent): boolean {
249
- if (this.disposeConfirmationTarget === target) {
250
- this.disposeConfirmationTarget = undefined;
251
- return true;
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.disposeConfirmationTarget = undefined;
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.canJoinMainContext(agent.id),
288
- () => this.joinMainContext(agent.id),
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: "70%" } },
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 canJoinMainContext(agentId: string): boolean {
319
- if (this.idleAgent(agentId)?.pendingJoinMessage) return true;
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
- confirmMainContextJoin(agentId: string): boolean {
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-join");
336
+ ].filter((candidate) => candidate.mainContextState === "will-squash");
330
337
  if (agents.length === 0) return false;
331
- for (const agent of agents) agent.mainContextState = "joined";
338
+ for (const agent of agents) agent.mainContextState = "squashed";
332
339
  this.update();
333
340
  return true;
334
341
  }
335
342
 
336
- private joinMainContext(agentId: string): void {
343
+ private squashMainContext(agentId: string): void {
337
344
  const idleAgent = this.idleAgent(agentId);
338
345
  if (idleAgent) {
339
- this.joinIdleAgent(idleAgent);
346
+ this.squashIdleAgent(idleAgent);
340
347
  return;
341
348
  }
342
349
  const completedAgent = this.completedAgents.find((agent) => agent.id === agentId);
343
- const message = completedAgent?.pendingJoinMessage;
350
+ const message = completedAgent?.pendingSquashMessage;
344
351
  if (!completedAgent || !message) return;
345
352
 
346
- completedAgent.pendingJoinMessage = undefined;
347
- completedAgent.mainContextState = "will-join";
348
- message.details.mainContextState = "will-join";
349
- this.sendJoinedResult(message);
350
- logSteering(agentId, "joined-main-context", { display: message.display });
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
- /** Joining is one of the two terminal actions on a live idle agent: deliver the result, snapshot it, retire the session. */
355
- private joinIdleAgent(agent: RunningAgent): void {
356
- const message = agent.pendingJoinMessage;
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.pendingJoinMessage = undefined;
359
- agent.mainContextState = "will-join";
360
- message.details.mainContextState = "will-join";
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, { joinable: false });
363
- this.sendJoinedResult(message);
425
+ this.addCompleted(agent, message, { squashable: false });
426
+ this.sendSquashedResult(message);
364
427
  agent.retire?.();
365
- logSteering(agent.id, "joined-main-context", { display: message.display, live: true });
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 === "dispose" && isRunningAgent(agent) && this.runningAgents.has(agent)) {
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 === "dispose" && completedAgent) this.removeCompleted(completedAgent);
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 confirmingDispose = this.disposeConfirmationTarget === selected?.agent;
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
- !confirmingDispose &&
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
- confirmingDispose ? "error" : "accent",
427
- confirmingDispose ? "x again to confirm" : "x dispose",
491
+ confirmingDetach ? "error" : "accent",
492
+ confirmingDetach ? "d again to confirm" : "d detach",
428
493
  ),
429
494
  theme.fg("accent", "Esc back"),
430
495
  );