@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/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
- private detachConfirmationTarget: 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,
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.detachConfirmationTarget = undefined;
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: { joinable: boolean },
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
- pendingJoinMessage: options.joinable ? resultMessage : undefined,
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.detachConfirmationTarget = undefined;
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.detachConfirmationTarget = undefined;
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.detachConfirmationTarget = undefined;
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.detachConfirmationTarget = undefined;
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.detachConfirmationTarget = undefined;
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.detachConfirmationTarget = undefined;
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
- if (this.detachConfirmationTarget === target) {
252
- this.detachConfirmationTarget = undefined;
253
- return true;
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.detachConfirmationTarget = undefined;
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.canJoinMainContext(agent.id),
290
- () => 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),
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 canJoinMainContext(agentId: string): boolean {
323
- if (this.idleAgent(agentId)?.pendingJoinMessage) return true;
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
- confirmMainContextJoin(agentId: string): boolean {
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-join");
336
+ ].filter((candidate) => candidate.mainContextState === "will-squash");
334
337
  if (agents.length === 0) return false;
335
- for (const agent of agents) agent.mainContextState = "joined";
338
+ for (const agent of agents) agent.mainContextState = "squashed";
336
339
  this.update();
337
340
  return true;
338
341
  }
339
342
 
340
- private joinMainContext(agentId: string): void {
343
+ private squashMainContext(agentId: string): void {
341
344
  const idleAgent = this.idleAgent(agentId);
342
345
  if (idleAgent) {
343
- this.joinIdleAgent(idleAgent);
346
+ this.squashIdleAgent(idleAgent);
344
347
  return;
345
348
  }
346
349
  const completedAgent = this.completedAgents.find((agent) => agent.id === agentId);
347
- const message = completedAgent?.pendingJoinMessage;
350
+ const message = completedAgent?.pendingSquashMessage;
348
351
  if (!completedAgent || !message) return;
349
352
 
350
- completedAgent.pendingJoinMessage = undefined;
351
- completedAgent.mainContextState = "will-join";
352
- message.details.mainContextState = "will-join";
353
- this.sendJoinedResult(message);
354
- 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");
355
414
  this.update();
356
415
  }
357
416
 
358
- /** Joining is one of the two terminal actions on a live idle agent: deliver the result, snapshot it, retire the session. */
359
- private joinIdleAgent(agent: RunningAgent): void {
360
- 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;
361
420
  if (!message) return;
362
- agent.pendingJoinMessage = undefined;
363
- agent.mainContextState = "will-join";
364
- message.details.mainContextState = "will-join";
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, { joinable: false });
367
- this.sendJoinedResult(message);
425
+ this.addCompleted(agent, message, { squashable: false });
426
+ this.sendSquashedResult(message);
368
427
  agent.retire?.();
369
- logSteering(agent.id, "joined-main-context", { display: message.display, live: true });
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 = this.detachConfirmationTarget === selected?.agent;
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 &&