@giladbarnea/pi-user-agents 0.0.8 → 0.0.9

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
@@ -8,6 +8,7 @@ import {
8
8
  import {
9
9
  AgentViewer,
10
10
  type AgentViewerAction,
11
+ type AgentViewerActions,
11
12
  describeActivity,
12
13
  isLiveAgent,
13
14
  isRunningAgent,
@@ -21,6 +22,7 @@ import type {
21
22
  AgentMessage,
22
23
  AgentResultMessage,
23
24
  CompletedAgent,
25
+ HerdrDelivery,
24
26
  RebaseDelivery,
25
27
  RunningAgent,
26
28
  Theme,
@@ -29,10 +31,12 @@ import type {
29
31
  import {
30
32
  CONFIRMATION_WINDOW_MS,
31
33
  contextLabel,
34
+ errorMessage,
32
35
  formatMs,
33
36
  TimedConfirmation,
34
37
  formatToolUses,
35
38
  formatTurns,
39
+ herdrPaneLabel,
36
40
  logSteering,
37
41
  mainContextLabel,
38
42
  MAX_WIDGET_LINES,
@@ -48,6 +52,7 @@ type UserAgentWidgetEntry =
48
52
  type CachedActivityPreview = {
49
53
  source: AgentMessage | string | undefined;
50
54
  status: RunningAgent["status"] | boolean;
55
+ herdrPane: string | undefined;
51
56
  error: string | undefined;
52
57
  width: number;
53
58
  theme: Theme;
@@ -70,8 +75,11 @@ export class UserAgentWidget {
70
75
  private viewerOpen = false;
71
76
  private copied: { agentId: string; target: CopyTarget } | undefined;
72
77
  private copiedTimer: ReturnType<typeof setTimeout> | undefined;
73
- private rebaseWarning: { agentId: string; text: string } | undefined;
74
- private rebaseWarningTimer: ReturnType<typeof setTimeout> | undefined;
78
+ /** A transient per-row notice explaining why an action did nothing. */
79
+ private warning: { agentId: string; text: string } | undefined;
80
+ private warningTimer: ReturnType<typeof setTimeout> | undefined;
81
+ /** The agent whose herdr pane is being opened right now. */
82
+ private herdrPending: string | undefined;
75
83
  private activityPreviews = new WeakMap<ViewableAgent, CachedActivityPreview>();
76
84
  /** One confirmation slot for both destructive selected-row actions. */
77
85
  private readonly confirmation = new TimedConfirmation<string>(() => this.update());
@@ -82,6 +90,7 @@ export class UserAgentWidget {
82
90
  private readonly sendSquashedResult: (message: AgentResultMessage) => void,
83
91
  private readonly announceDetached: (sessionId: string) => void,
84
92
  private readonly rebaseDelivery: RebaseDelivery,
93
+ private readonly herdrDelivery: HerdrDelivery,
85
94
  ) {}
86
95
 
87
96
  setUI(ui: UIContext): void {
@@ -100,7 +109,22 @@ export class UserAgentWidget {
100
109
  resultMessage: AgentResultMessage,
101
110
  options: { squashable: boolean },
102
111
  ): void {
103
- this.completedAgents.push({
112
+ this.snapshot(agent, {
113
+ ok: resultMessage.details.ok,
114
+ pendingSquashMessage: options.squashable ? resultMessage : undefined,
115
+ });
116
+ }
117
+
118
+ /** The row for an agent dispatched straight into a herdr pane: nothing ran here, so nothing squashes. */
119
+ addHerdrDispatch(agent: RunningAgent, paneId: string): void {
120
+ this.snapshot(agent, { ok: true, herdrPane: paneId });
121
+ }
122
+
123
+ private snapshot(
124
+ agent: RunningAgent,
125
+ outcome: { ok: boolean; pendingSquashMessage?: AgentResultMessage; herdrPane?: string },
126
+ ): CompletedAgent {
127
+ const completed: CompletedAgent = {
104
128
  id: agent.id,
105
129
  sessionId: agent.sessionId,
106
130
  command: agent.command,
@@ -108,8 +132,8 @@ export class UserAgentWidget {
108
132
  task: agent.task,
109
133
  dispatchBaseFingerprint: agent.dispatchBaseFingerprint,
110
134
  mainContextState: agent.mainContextState,
111
- pendingSquashMessage: options.squashable ? resultMessage : undefined,
112
- ok: resultMessage.details.ok,
135
+ pendingSquashMessage: outcome.pendingSquashMessage,
136
+ ok: outcome.ok,
113
137
  responseText: agent.responseText,
114
138
  messages: structuredClone(transcriptMessages(agent)),
115
139
  error: agent.error,
@@ -118,8 +142,11 @@ export class UserAgentWidget {
118
142
  toolUses: agent.toolUses,
119
143
  turnCount: agent.turnCount,
120
144
  contextPercent: agent.session?.getContextUsage()?.percent ?? undefined,
121
- });
145
+ herdrPane: outcome.herdrPane,
146
+ };
147
+ this.completedAgents.push(completed);
122
148
  this.update();
149
+ return completed;
123
150
  }
124
151
 
125
152
  ensureTimer(): void {
@@ -290,6 +317,10 @@ export class UserAgentWidget {
290
317
  this.requestRebase(selected);
291
318
  return { consume: true };
292
319
  }
320
+ if (matchesKey(data, "h")) {
321
+ if (selected) this.requestHerdr(selected.agent.id);
322
+ return { consume: true };
323
+ }
293
324
  if (matchesKey(data, "d") && selected) {
294
325
  if (!this.confirmDetach(selected.agent)) return { consume: true };
295
326
  if (selected.kind === "running") this.closeRunning(selected.agent);
@@ -313,7 +344,7 @@ export class UserAgentWidget {
313
344
  const reason = this.rebaseBlockReason(agentId);
314
345
  if (reason) {
315
346
  this.confirmation.cancel();
316
- this.showRebaseWarning(agentId, `Can't rebase: ${reason}`);
347
+ this.showWarning(agentId, `Can't rebase: ${reason}`);
317
348
  return;
318
349
  }
319
350
  if (!this.deliverableAgent(agentId)) {
@@ -330,6 +361,58 @@ export class UserAgentWidget {
330
361
  this.rebaseMainContext(agentId);
331
362
  }
332
363
 
364
+ private requestHerdr(agentId: string): void {
365
+ this.openInHerdr(agentId).catch((error) =>
366
+ this.showWarning(agentId, `Can't open in herdr: ${errorMessage(error)}`),
367
+ );
368
+ }
369
+
370
+ /** Hand the agent's session to a Pi in a new herdr pane, then return focus to the editor. */
371
+ async openInHerdr(agentId: string): Promise<void> {
372
+ const reason = this.herdrBlockReason(agentId);
373
+ if (reason) throw new Error(reason);
374
+ this.herdrPending = agentId;
375
+ this.update();
376
+ try {
377
+ // The pane comes first: a herdr failure here leaves the agent untouched. Pi starts in it
378
+ // only after the session is free here, so the file has one writer at a time.
379
+ const target = this.idleAgent(agentId) ?? this.completedAgents.find((agent) => agent.id === agentId);
380
+ if (!target) throw new Error("the agent has no row anymore");
381
+ const pane = await this.herdrDelivery.split(target);
382
+ const snapshot = await this.releaseForHerdr(agentId);
383
+ await pane.start();
384
+ snapshot.herdrPane = pane.paneId;
385
+ logSteering(agentId, "opened-in-herdr", { paneId: pane.paneId });
386
+ } finally {
387
+ this.herdrPending = undefined;
388
+ this.update();
389
+ }
390
+ this.deactivate();
391
+ }
392
+
393
+ /** Why the agent cannot open in a herdr pane right now; undefined when it can. */
394
+ private herdrBlockReason(agentId: string): string | undefined {
395
+ if (!this.herdrDelivery.available()) return "Pi is not running inside a herdr pane";
396
+ if (this.herdrPending === agentId) return "a herdr pane is already opening";
397
+ const completed = this.completedAgents.find((agent) => agent.id === agentId);
398
+ if (completed?.herdrPane) return `already in ${herdrPaneLabel(completed.herdrPane)}`;
399
+ if (!completed && !this.idleAgent(agentId)) return "the agent is mid-turn (Ctrl+x interrupts it)";
400
+ return undefined;
401
+ }
402
+
403
+ /** End the agent's life here and keep its row as a snapshot; resolves once its session is disposed. */
404
+ private async releaseForHerdr(agentId: string): Promise<CompletedAgent> {
405
+ const idleAgent = this.idleAgent(agentId);
406
+ if (idleAgent) {
407
+ idleAgent.status = "posted";
408
+ this.retireRunning(idleAgent);
409
+ this.snapshot(idleAgent, { ok: true, pendingSquashMessage: idleAgent.pendingSquashMessage });
410
+ }
411
+ // The lifecycle disposes the child session asynchronously; another Pi may open the file only after that.
412
+ await [...this.runningAgents].find((agent) => agent.id === agentId)?.finished;
413
+ return this.completedAgents.find((agent) => agent.id === agentId)!;
414
+ }
415
+
333
416
  private copyToClipboard(agentId: string, text: string, target: CopyTarget): void {
334
417
  const child = childProcess.execFile("pbcopy", (error) => {
335
418
  if (error) return;
@@ -345,12 +428,12 @@ export class UserAgentWidget {
345
428
  child.stdin?.end(text);
346
429
  }
347
430
 
348
- private showRebaseWarning(agentId: string, text: string): void {
349
- this.rebaseWarning = { agentId, text };
350
- if (this.rebaseWarningTimer) clearTimeout(this.rebaseWarningTimer);
351
- this.rebaseWarningTimer = setTimeout(() => {
352
- this.rebaseWarning = undefined;
353
- this.rebaseWarningTimer = undefined;
431
+ private showWarning(agentId: string, text: string): void {
432
+ this.warning = { agentId, text };
433
+ if (this.warningTimer) clearTimeout(this.warningTimer);
434
+ this.warningTimer = setTimeout(() => {
435
+ this.warning = undefined;
436
+ this.warningTimer = undefined;
354
437
  this.update();
355
438
  }, CONFIRMATION_WINDOW_MS);
356
439
  this.update();
@@ -395,21 +478,7 @@ export class UserAgentWidget {
395
478
  void ui
396
479
  .custom<AgentViewerAction>(
397
480
  (tui, theme, _keybindings, done) =>
398
- new AgentViewer(
399
- tui,
400
- agent,
401
- theme,
402
- done,
403
- () => this.canSquashMainContext(agent.id),
404
- () => this.squashMainContext(agent.id),
405
- () => this.canRebaseMainContext(agent.id),
406
- () => this.rebaseMainContext(agent.id),
407
- () => this.rebaseBlockReason(agent.id),
408
- () => this.rebaseDetachCount(agent.id),
409
- () => {
410
- if (isRunningAgent(agent)) this.interruptRunning(agent);
411
- },
412
- ),
481
+ new AgentViewer(tui, agent, theme, done, this.viewerActions(agent)),
413
482
  { overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "85%" } },
414
483
  )
415
484
  .then(
@@ -418,13 +487,34 @@ export class UserAgentWidget {
418
487
  );
419
488
  }
420
489
 
490
+ private viewerActions(agent: ViewableAgent): AgentViewerActions {
491
+ return {
492
+ canSquashMainContext: () => this.canSquashMainContext(agent.id),
493
+ squashMainContext: () => this.squashMainContext(agent.id),
494
+ canRebaseMainContext: () => this.canRebaseMainContext(agent.id),
495
+ rebaseMainContext: () => this.rebaseMainContext(agent.id),
496
+ rebaseBlockReason: () => this.rebaseBlockReason(agent.id),
497
+ rebaseDetachCount: () => this.rebaseDetachCount(agent.id),
498
+ interrupt: () => {
499
+ if (isRunningAgent(agent)) this.interruptRunning(agent);
500
+ },
501
+ herdrBlockReason: () => this.herdrBlockReason(agent.id),
502
+ openInHerdr: () => this.openInHerdr(agent.id),
503
+ };
504
+ }
505
+
421
506
  /** Detaching ends the live session and leaves its session file on disk, untouched. */
422
507
  private closeRunning(agent: RunningAgent): void {
508
+ this.retireRunning(agent);
509
+ this.announceDetached(agent.sessionId);
510
+ this.update();
511
+ }
512
+
513
+ /** End a live agent's session here; its row and any breadcrumb are the caller's business. */
514
+ private retireRunning(agent: RunningAgent): void {
423
515
  agent.aborted = true;
424
516
  void agent.session?.abort();
425
517
  agent.retire?.();
426
- this.announceDetached(agent.sessionId);
427
- this.update();
428
518
  }
429
519
 
430
520
  private interruptRunning(agent: RunningAgent): void {
@@ -606,10 +696,10 @@ export class UserAgentWidget {
606
696
  width,
607
697
  ),
608
698
  );
609
- } else if (selected && this.rebaseWarning?.agentId === selected.agent.id) {
610
- lines.push(
611
- truncateToWidth(` ${theme.fg("warning", `⚠ ${this.rebaseWarning.text}`)}`, width),
612
- );
699
+ } else if (selected && this.herdrPending === selected.agent.id) {
700
+ lines.push(truncateToWidth(` ${theme.fg("accent", "⧉ Opening a herdr pane…")}`, width));
701
+ } else if (selected && this.warning?.agentId === selected.agent.id) {
702
+ lines.push(truncateToWidth(` ${theme.fg("warning", `⚠ ${this.warning.text}`)}`, width));
613
703
  } else {
614
704
  const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
615
705
  if (!confirmingDetach)
@@ -627,6 +717,8 @@ export class UserAgentWidget {
627
717
  segments.push(theme.fg("accent", "s squash"));
628
718
  if (!confirmingDetach && selected && this.canRebaseMainContext(selected.agent.id))
629
719
  segments.push(theme.fg("accent", "r rebase"));
720
+ if (!confirmingDetach && selected && this.herdrBlockReason(selected.agent.id) === undefined)
721
+ segments.push(theme.fg("accent", "h herdr"));
630
722
  if (
631
723
  !confirmingDetach &&
632
724
  selected?.kind === "running" &&
@@ -682,27 +774,40 @@ export class UserAgentWidget {
682
774
  ): string[] {
683
775
  const running = isRunningAgent(agent);
684
776
  const inFlight = isLiveAgent(agent);
777
+ const herdrPane = running ? undefined : agent.herdrPane;
685
778
  const ok = running ? agent.error === undefined : agent.ok;
686
- const status = inFlight ? "running" : running ? "idle" : ok ? "completed" : "failed";
779
+ const status = inFlight
780
+ ? "running"
781
+ : running
782
+ ? "idle"
783
+ : herdrPane
784
+ ? herdrPaneLabel(herdrPane)
785
+ : ok
786
+ ? "completed"
787
+ : "failed";
687
788
  const marker = selected ? theme.fg("accent", "⏺") : theme.fg("dim", "◯");
688
789
  const icon = inFlight
689
790
  ? theme.fg("accent", SPINNER[this.frame % SPINNER.length])
690
791
  : running
691
792
  ? theme.fg("muted", "◉")
692
- : ok
693
- ? theme.fg("success", "✓")
694
- : theme.fg("error", "✗");
793
+ : herdrPane
794
+ ? theme.fg("muted", "⧉")
795
+ : ok
796
+ ? theme.fg("success", "✓")
797
+ : theme.fg("error", "✗");
695
798
  const parts = [agent.modelLabel, status];
696
799
  if (inFlight) parts.push(contextLabel(agent.inheritedContext));
697
800
  const mainContext = mainContextLabel(agent.mainContextState);
698
801
  if (mainContext) parts.push(mainContext);
699
802
  if (agent.turnCount > 0) parts.push(formatTurns(agent.turnCount));
700
803
  if (agent.toolUses > 0) parts.push(formatToolUses(agent.toolUses));
701
- parts.push(
702
- formatMs(
703
- running ? (agent.completedAt ?? Date.now()) - agent.turnStartedAt : agent.durationMs,
704
- ),
705
- );
804
+ // An agent born in a herdr pane never ran here; a duration would be noise.
805
+ if (!herdrPane || agent.turnCount > 0)
806
+ parts.push(
807
+ formatMs(
808
+ running ? (agent.completedAt ?? Date.now()) - agent.turnStartedAt : agent.durationMs,
809
+ ),
810
+ );
706
811
 
707
812
  const header = [
708
813
  theme.fg("dim", connector),
@@ -729,19 +834,31 @@ export class UserAgentWidget {
729
834
  const running = isRunningAgent(agent);
730
835
  const source = isLiveAgent(agent) ? agent.latestFinalizedMessage : agent.responseText;
731
836
  const status = running ? agent.status : agent.ok;
837
+ const herdrPane = running ? undefined : agent.herdrPane;
732
838
  const cached = this.activityPreviews.get(agent);
733
839
  if (
734
840
  cached && cached.source === source && cached.status === status &&
841
+ cached.herdrPane === herdrPane &&
735
842
  cached.error === agent.error && cached.width === width && cached.theme === theme
736
843
  ) return cached.text;
737
844
  const description = running
738
845
  ? describeActivity(agent)
739
846
  : {
740
- text: agent.ok ? agent.responseText : `Error: ${agent.error ?? "unknown"}`,
847
+ text: !agent.ok
848
+ ? `Error: ${agent.error ?? "unknown"}`
849
+ : agent.responseText || (herdrPane ? `runs in ${herdrPaneLabel(herdrPane)}` : ""),
741
850
  truncation: "tail" as const,
742
851
  };
743
852
  const text = renderActivity(description, width, theme);
744
- this.activityPreviews.set(agent, { source, status, error: agent.error, width, theme, text });
853
+ this.activityPreviews.set(agent, {
854
+ source,
855
+ status,
856
+ herdrPane,
857
+ error: agent.error,
858
+ width,
859
+ theme,
860
+ text,
861
+ });
745
862
  return text;
746
863
  }
747
864
  }