pi-squad 0.16.2 → 0.16.4

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/src/index.ts CHANGED
@@ -504,31 +504,49 @@ export default function (pi: ExtensionAPI) {
504
504
  pi.registerTool({
505
505
  name: "squad_message",
506
506
  label: "Squad Message",
507
- description: "Send a message to a specific agent or task in the running squad.",
507
+ description: "Send a durable request to a specific task. Existing tasks reopen their original Pi session; only a currently running task may be selected by agent name.",
508
508
  parameters: Type.Object({
509
509
  message: Type.String({ description: "Message to send" }),
510
510
  taskId: Type.Optional(Type.String({ description: "Target task ID" })),
511
511
  agent: Type.Optional(Type.String({ description: "Target agent name" })),
512
+ expectReply: Type.Optional(Type.Boolean({ description: "Forward the agent's next substantive response back to main Pi and wake it (default true)" })),
512
513
  }),
513
514
 
514
515
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
515
- const activeScheduler = getActiveScheduler();
516
- if (!activeScheduler || !activeSquadId) {
516
+ if (!activeSquadId) {
517
517
  return { content: [{ type: "text" as const, text: "No active squad." }], details: undefined };
518
518
  }
519
519
 
520
+ let activeScheduler = getActiveScheduler();
520
521
  let taskId = params.taskId;
521
522
 
522
- // If agent specified but no taskId, find their current task
523
- if (!taskId && params.agent) {
524
- taskId = activeScheduler.getPool().getTaskIdForAgent(params.agent) || undefined;
523
+ // Agent name is safe shorthand only when it identifies one live task.
524
+ // Multiple concurrent tasks can share a role, so never guess between them.
525
+ if (!taskId && params.agent && activeScheduler) {
526
+ const liveMatches = store.loadAllTasks(activeSquadId).filter(
527
+ (task) => task.agent === params.agent && activeScheduler!.getPool().isRunning(task.id),
528
+ );
529
+ if (liveMatches.length === 1) taskId = liveMatches[0].id;
525
530
  }
526
531
 
527
532
  if (!taskId) {
528
533
  return { content: [{ type: "text" as const, text: "Could not determine target task. Provide taskId or an agent name that is currently running." }], details: undefined };
529
534
  }
535
+ if (!store.loadTask(activeSquadId, taskId)) {
536
+ return { content: [{ type: "text" as const, text: `Task not found: ${taskId}` }], details: undefined };
537
+ }
530
538
 
531
- const sent = await activeScheduler!.sendHumanMessage(taskId, params.message);
539
+ // Review/done squads have no live scheduler after a process restart. An
540
+ // exact task ID is enough to reconstruct it from disk and reopen only that
541
+ // task; the task's immutable session binding supplies --session.
542
+ if (!activeScheduler) {
543
+ activeScheduler = new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
544
+ schedulers.set(activeSquadId, activeScheduler);
545
+ wireSchedulerEvents(pi, activeScheduler, activeSquadId);
546
+ await activeScheduler.start();
547
+ }
548
+
549
+ const sent = await activeScheduler.sendHumanMessage(taskId, params.message, params.expectReply ?? true);
532
550
  const status = sent ? "delivered" : "queued for when the agent starts";
533
551
 
534
552
  return { content: [{ type: "text" as const, text: `Message ${status}: "${params.message}"` }], details: undefined };
@@ -594,49 +612,7 @@ export default function (pi: ExtensionAPI) {
594
612
  widgetState.enabled = true;
595
613
  widgetControls?.requestUpdate();
596
614
 
597
- // Wire up events (same as startSquad)
598
- scheduler.onEvent((event: SchedulerEvent) => {
599
- forceWidgetUpdate();
600
- switch (event.type) {
601
- case "squad_review_required": {
602
- const tasks = store.loadAllTasks(squadId);
603
- const summary = buildCompletionSummary(tasks);
604
- const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
605
- const s = schedulers.get(squadId); if (s) s.updateContext();
606
- const squad = store.loadSquad(squadId)!;
607
- // Wake the main agent into an explicit acceptance gate. Agent reports
608
- // are untrusted inputs; only independent orchestrator evidence can pass it.
609
- pi.sendMessage({
610
- customType: "squad-review-required",
611
- content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\nSquad claims (review inputs only):\n${summary}\n\nTotal cost: $${totalCost.toFixed(4)}\n\n${buildOrchestratorReviewGate(squad, tasks)}`,
612
- display: true,
613
- }, { triggerTurn: true, deliverAs: "followUp" });
614
- schedulers.delete(squadId);
615
- forceWidgetUpdate();
616
- break;
617
- }
618
- case "squad_failed": {
619
- const tasks = store.loadAllTasks(squadId);
620
- const failed = tasks.filter((t) => t.status === "failed");
621
- const done = tasks.filter((t) => t.status === "done");
622
- pi.sendMessage({
623
- customType: "squad-failed",
624
- content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\nFailed: ${buildFailureSummary(tasks)}`,
625
- display: true,
626
- }, { triggerTurn: true, deliverAs: "followUp" });
627
- forceWidgetUpdate();
628
- break;
629
- }
630
- case "escalation": {
631
- pi.sendMessage({
632
- customType: "squad-escalation",
633
- content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n${event.message}`,
634
- display: true,
635
- }, { triggerTurn: true });
636
- break;
637
- }
638
- }
639
- });
615
+ wireSchedulerEvents(pi, scheduler, squadId);
640
616
  }
641
617
 
642
618
  const resumeSched = schedulers.get(squadId)!;
@@ -786,6 +762,29 @@ export default function (pi: ExtensionAPI) {
786
762
  }
787
763
  }
788
764
 
765
+ // Mailbox recovery is automatic after extension/main-process restart. Scan
766
+ // every project squad (including accepted done squads) because a crash can
767
+ // occur after the mailbox-first write but before the squad/task is reopened.
768
+ const pendingMailSquads = store.listSquadsForProject(ctx.cwd)
769
+ .filter((squad) => store.loadAllTasks(squad.id)
770
+ .some((task) => store.loadPendingTaskMessages(squad.id, task.id).length > 0))
771
+ .sort((a, b) => b.created.localeCompare(a.created));
772
+ for (const squad of pendingMailSquads) {
773
+ let scheduler = schedulers.get(squad.id);
774
+ if (!scheduler) {
775
+ scheduler = new Scheduler(squad.id, squadSkillPaths, schedulerSpawnContext);
776
+ schedulers.set(squad.id, scheduler);
777
+ wireSchedulerEvents(pi, scheduler, squad.id);
778
+ }
779
+ await scheduler.start();
780
+ }
781
+ if (pendingMailSquads.length > 0) {
782
+ activeSquadId = pendingMailSquads[0].id;
783
+ widgetState.squadId = activeSquadId;
784
+ widgetState.enabled = true;
785
+ widgetControls?.requestUpdate();
786
+ }
787
+
789
788
  // Notify about paused squads only if they have real completed work
790
789
  const paused = store.findActiveSquads()
791
790
  .filter((s) => s.cwd === ctx.cwd && s.status === "paused");
@@ -842,7 +841,7 @@ export default function (pi: ExtensionAPI) {
842
841
  }
843
842
 
844
843
  if (activeSquadId) {
845
- openPanel(ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
844
+ openPanel(pi, ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
846
845
  }
847
846
  return { consume: true };
848
847
  }
@@ -876,7 +875,7 @@ export default function (pi: ExtensionAPI) {
876
875
  { value: "agents", label: "agents", description: "List, view, or edit agent definitions" },
877
876
  { value: "defaults", label: "defaults", description: "Default model/thinking for agents (follow main session, pi default, or fixed)" },
878
877
  { value: "advisor", label: "advisor", description: "Advisor-first rescue for stuck agents (on/off, model, limits)" },
879
- { value: "msg", label: "msg", description: "Send message to agent: /squad msg [agent] text" },
878
+ { value: "msg", label: "msg", description: "Message a task: /squad msg [task-id|running-agent] text" },
880
879
  { value: "widget", label: "widget", description: "Toggle live widget" },
881
880
  { value: "panel", label: "panel", description: "Toggle overlay panel" },
882
881
  { value: "cancel", label: "cancel", description: "Cancel running squad" },
@@ -969,7 +968,7 @@ export default function (pi: ExtensionAPI) {
969
968
  }
970
969
  if (activeSquadId) {
971
970
  const sched = schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
972
- openPanel(ctx, sched, activeSquadId);
971
+ openPanel(pi, ctx, sched, activeSquadId);
973
972
  }
974
973
  return;
975
974
  }
@@ -980,11 +979,8 @@ export default function (pi: ExtensionAPI) {
980
979
  return;
981
980
  }
982
981
  const msgSquad = store.loadSquad(activeSquadId);
983
- if (!msgSquad || msgSquad.status !== "running") {
984
- ctx.ui.notify("Squad is not running — messages only reach running agents.", "info");
985
- return;
986
- }
987
- // Parse: /squad msg [agent] message text
982
+ if (!msgSquad) return;
983
+ // Parse: /squad msg [task-id|running-agent] message text
988
984
  const msgParts = parts.slice(1);
989
985
  let targetAgent: string | undefined;
990
986
  let msgText: string;
@@ -1005,40 +1001,51 @@ export default function (pi: ExtensionAPI) {
1005
1001
  }
1006
1002
  }
1007
1003
 
1008
- // Find target task
1004
+ // Find one exact target task. A task ID can address completed or
1005
+ // stopped work; an agent name remains shorthand for a live task only.
1009
1006
  const msgTasks = store.loadAllTasks(activeSquadId);
1010
1007
  let targetTaskId: string | undefined;
1011
-
1012
- if (targetAgent) {
1013
- const agentTask = msgTasks.find((t) => t.agent === targetAgent && t.status === "in_progress");
1014
- targetTaskId = agentTask?.id;
1008
+ const explicitTask = msgParts.length > 1
1009
+ ? msgTasks.find((task) => task.id === msgParts[0])
1010
+ : undefined;
1011
+ if (explicitTask) {
1012
+ targetTaskId = explicitTask.id;
1013
+ targetAgent = explicitTask.agent;
1014
+ msgText = msgParts.slice(1).join(" ");
1015
+ } else if (targetAgent) {
1016
+ const liveMatches = msgTasks.filter(
1017
+ (task) => task.agent === targetAgent && getActiveScheduler()?.getPool().isRunning(task.id),
1018
+ );
1019
+ if (liveMatches.length === 1) targetTaskId = liveMatches[0].id;
1015
1020
  if (!targetTaskId) {
1016
- ctx.ui.notify(`Agent '${targetAgent}' has no running task`, "warning");
1021
+ ctx.ui.notify(`Agent '${targetAgent}' is not working on exactly one live task; provide an exact task ID`, "warning");
1017
1022
  return;
1018
1023
  }
1019
1024
  } else {
1020
- const runningTask = msgTasks.find((t) => t.status === "in_progress");
1021
- targetTaskId = runningTask?.id;
1022
- targetAgent = runningTask?.agent;
1023
- if (!targetTaskId) {
1024
- ctx.ui.notify("No running tasks to message", "warning");
1025
+ const liveTasks = msgTasks.filter((task) =>
1026
+ getActiveScheduler()?.getPool().isRunning(task.id),
1027
+ );
1028
+ if (liveTasks.length === 1) {
1029
+ targetTaskId = liveTasks[0].id;
1030
+ targetAgent = liveTasks[0].agent;
1031
+ } else {
1032
+ ctx.ui.notify("No unique live task to message; provide an exact task ID", "warning");
1025
1033
  return;
1026
1034
  }
1027
1035
  }
1028
1036
 
1029
- const msgSched = getActiveScheduler();
1030
- if (msgSched) {
1031
- await msgSched.sendHumanMessage(targetTaskId, msgText);
1032
- ctx.ui.notify(`Sent to ${targetAgent}: "${msgText}"`, "info");
1033
- } else {
1034
- store.appendMessage(activeSquadId, targetTaskId, {
1035
- ts: store.now(),
1036
- from: "human",
1037
- type: "message",
1038
- text: msgText,
1039
- });
1040
- ctx.ui.notify(`Logged to ${targetTaskId} (agent not running)`, "info");
1037
+ let msgSched = getActiveScheduler();
1038
+ if (!msgSched) {
1039
+ msgSched = new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
1040
+ schedulers.set(activeSquadId, msgSched);
1041
+ wireSchedulerEvents(pi, msgSched, activeSquadId);
1042
+ await msgSched.start();
1041
1043
  }
1044
+ const delivered = await msgSched.sendHumanMessage(targetTaskId, msgText);
1045
+ ctx.ui.notify(
1046
+ delivered ? `Sent to ${targetAgent}: "${msgText}"` : `Queued durably for ${targetTaskId}`,
1047
+ "info",
1048
+ );
1042
1049
  forceWidgetUpdate();
1043
1050
  return;
1044
1051
  }
@@ -1466,6 +1473,63 @@ function forceWidgetUpdate(): void {
1466
1473
  widgetControls?.requestUpdate();
1467
1474
  }
1468
1475
 
1476
+ /** Wire one scheduler to durable main-session notifications. */
1477
+ function wireSchedulerEvents(pi: ExtensionAPI, scheduler: Scheduler, squadId: string): void {
1478
+ scheduler.onEvent((event: SchedulerEvent) => {
1479
+ forceWidgetUpdate();
1480
+ switch (event.type) {
1481
+ case "squad_review_required": {
1482
+ const tasks = store.loadAllTasks(squadId);
1483
+ const summary = buildCompletionSummary(tasks);
1484
+ const totalCost = tasks.reduce((sum, task) => sum + task.usage.cost, 0);
1485
+ scheduler.updateContext();
1486
+ const squad = store.loadSquad(squadId);
1487
+ if (!squad) break;
1488
+ pi.sendMessage({
1489
+ customType: "squad-review-required",
1490
+ content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\n` +
1491
+ `Squad claims (review inputs only):\n${summary}\n\n` +
1492
+ `Total cost: $${totalCost.toFixed(4)}\n\n` +
1493
+ buildOrchestratorReviewGate(squad, tasks),
1494
+ display: true,
1495
+ }, { triggerTurn: true, deliverAs: "followUp" });
1496
+ // Keep the settled scheduler addressable. A later exact-task message
1497
+ // can reopen the task immediately on its bound durable Pi session.
1498
+ break;
1499
+ }
1500
+ case "squad_failed": {
1501
+ const tasks = store.loadAllTasks(squadId);
1502
+ const failed = tasks.filter((task) => task.status === "failed");
1503
+ const done = tasks.filter((task) => task.status === "done");
1504
+ pi.sendMessage({
1505
+ customType: "squad-failed",
1506
+ content: `[squad] Squad "${squadId}" has stalled. ` +
1507
+ `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
1508
+ `Failed: ${buildFailureSummary(tasks)}\n` +
1509
+ "Use squad_status for details or squad_modify to adjust.",
1510
+ display: true,
1511
+ }, { triggerTurn: true, deliverAs: "followUp" });
1512
+ break;
1513
+ }
1514
+ case "orchestrator_reply":
1515
+ pi.sendMessage({
1516
+ customType: "squad-agent-reply",
1517
+ content: `[squad] Direct reply from '${event.agentName}' on task '${event.taskId}':\n${event.message}`,
1518
+ display: true,
1519
+ }, { triggerTurn: true, deliverAs: "followUp" });
1520
+ break;
1521
+ case "escalation":
1522
+ pi.sendMessage({
1523
+ customType: "squad-escalation",
1524
+ content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n` +
1525
+ `${event.message}\n\nReply to me and I'll forward your answer, or use the squad panel.`,
1526
+ display: true,
1527
+ }, { triggerTurn: true });
1528
+ break;
1529
+ }
1530
+ });
1531
+ }
1532
+
1469
1533
  // ============================================================================
1470
1534
  // Panel — overlay via ctx.ui.custom() with proper done() lifecycle
1471
1535
  // ============================================================================
@@ -1476,6 +1540,7 @@ function forceWidgetUpdate(): void {
1476
1540
  * that resolves when done() is called. The panel calls done() on close.
1477
1541
  */
1478
1542
  function openPanel(
1543
+ pi: ExtensionAPI,
1479
1544
  ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
1480
1545
  scheduler: Scheduler,
1481
1546
  squadId: string,
@@ -1493,18 +1558,19 @@ function openPanel(
1493
1558
  const task = store.loadTask(squadId, taskId);
1494
1559
  const agentName = task?.agent || taskId;
1495
1560
  const input = await ctx.ui.input(`Message to ${agentName}`, "Type your message...");
1496
- const panelSched = schedulers.get(squadId);
1497
- if (input && panelSched) {
1498
- await panelSched.sendHumanMessage(taskId, input);
1499
- ctx.ui.notify(`Sent to ${agentName}: "${input}"`, "info");
1500
- } else if (input) {
1501
- store.appendMessage(squadId, taskId, {
1502
- ts: store.now(),
1503
- from: "human",
1504
- type: "message",
1505
- text: input,
1506
- });
1507
- ctx.ui.notify(`Logged to ${taskId}`, "info");
1561
+ if (input) {
1562
+ let panelSched = schedulers.get(squadId);
1563
+ if (!panelSched) {
1564
+ panelSched = scheduler;
1565
+ schedulers.set(squadId, panelSched);
1566
+ wireSchedulerEvents(pi, panelSched, squadId);
1567
+ await panelSched.start();
1568
+ }
1569
+ const delivered = await panelSched.sendHumanMessage(taskId, input);
1570
+ ctx.ui.notify(
1571
+ delivered ? `Sent to ${agentName}: "${input}"` : `Queued durably for ${taskId}`,
1572
+ "info",
1573
+ );
1508
1574
  }
1509
1575
  tui.requestRender();
1510
1576
  };
@@ -1657,71 +1723,8 @@ async function startSquad(
1657
1723
  widgetState.enabled = true;
1658
1724
  widgetControls?.requestUpdate();
1659
1725
 
1660
- // Wire up completion/escalation notifications to main agent
1661
- scheduler.onEvent((event: SchedulerEvent) => {
1662
- // Update widget on every scheduler event
1663
- forceWidgetUpdate();
1664
- switch (event.type) {
1665
- case "squad_review_required": {
1666
- const tasks = store.loadAllTasks(squadId);
1667
- const summary = buildCompletionSummary(tasks);
1668
- const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1669
-
1670
- // Final context update before clearing scheduler
1671
- const completedSched = schedulers.get(squadId);
1672
- if (completedSched) {
1673
- completedSched.updateContext();
1674
- }
1675
- const squad = store.loadSquad(squadId)!;
1676
-
1677
- // Wake the main agent into an explicit acceptance gate. Never frame
1678
- // agent execution or squad QA as trusted completion.
1679
- pi.sendMessage({
1680
- customType: "squad-review-required",
1681
- content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\n` +
1682
- `Squad claims (review inputs only):\n${summary}\n\n` +
1683
- `Total cost: $${totalCost.toFixed(4)}\n\n` +
1684
- buildOrchestratorReviewGate(squad, tasks),
1685
- display: true,
1686
- }, { triggerTurn: true, deliverAs: "followUp" });
1687
-
1688
- // Clear scheduler but keep activeSquadId so squad_status still works
1689
- schedulers.delete(squadId);
1690
- forceWidgetUpdate(); // Final update showing done state
1691
- break;
1692
- }
1693
-
1694
- case "squad_failed": {
1695
- const tasks = store.loadAllTasks(squadId);
1696
- const failed = tasks.filter((t) => t.status === "failed");
1697
- const done = tasks.filter((t) => t.status === "done");
1698
-
1699
- pi.sendMessage({
1700
- customType: "squad-failed",
1701
- content: `[squad] Squad "${squadId}" has stalled. ` +
1702
- `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
1703
- `Failed: ${buildFailureSummary(tasks)}\n` +
1704
- `Use squad_status for details or squad_modify to adjust.`,
1705
- display: true,
1706
- }, { triggerTurn: true, deliverAs: "followUp" });
1707
- forceWidgetUpdate();
1708
- break;
1709
- }
1710
-
1711
- case "escalation": {
1712
- // Escalation — agent needs help. triggerTurn so the main agent
1713
- // can respond and relay help.
1714
- pi.sendMessage({
1715
- customType: "squad-escalation",
1716
- content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n` +
1717
- `${event.message}\n\n` +
1718
- `Reply to me and I'll forward your answer, or use the squad panel.`,
1719
- display: true,
1720
- }, { triggerTurn: true });
1721
- break;
1722
- }
1723
- }
1724
- });
1726
+ // Wire up completion/escalation notifications to main agent.
1727
+ wireSchedulerEvents(pi, scheduler, squadId);
1725
1728
 
1726
1729
  // Start scheduling — fire and forget, don't block the tool call.
1727
1730
  // scheduler.start() spawns agents which can take seconds per agent.
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * message-view.ts — Scrollable message log for a task.
3
- * All lines truncated to width. Caps rendered messages to prevent
4
- * TUI corruption from large message histories.
3
+ * The durable history is never sliced; only the fixed-height viewport is
4
+ * collected for the TUI so large histories cannot change component height.
5
5
  */
6
6
 
7
7
  import type { Theme } from "@earendil-works/pi-coding-agent";
@@ -9,11 +9,6 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
9
  import type { TaskMessage } from "../types.js";
10
10
  import * as store from "../store.js";
11
11
 
12
- /** Max messages to render (most recent). Older messages are skipped. */
13
- const MAX_MESSAGES = 30;
14
- /** Max lines per text message before truncation */
15
- const MAX_TEXT_LINES = 5;
16
-
17
12
  export class MessageView {
18
13
  private theme: Theme;
19
14
  private squadId: string;
@@ -79,21 +74,13 @@ export class MessageView {
79
74
  return pad(header, maxLines);
80
75
  }
81
76
 
82
- // Only render recent messages to prevent TUI overload
83
- const messages = allMessages.slice(-MAX_MESSAGES);
84
- const skipped = allMessages.length - messages.length;
85
-
86
- const msgLines: string[] = [];
87
- if (skipped > 0) {
88
- msgLines.push(fit(th.fg("dim", ` ··· ${skipped} older messages ···`), w));
89
- msgLines.push("");
90
- }
91
- msgLines.push(...this.renderMessages(messages, w));
92
-
93
- // Fixed layout: header + scrollable content + status line = maxLines exactly
77
+ // Fixed layout: header + scrollable content + status line = maxLines exactly.
78
+ // Count lazily, then collect only the visible window. This keeps the renderer
79
+ // bounded without dropping any durable message or body line.
94
80
  const statusLines = 1; // always show status/scroll bar
95
81
  const contentHeight = Math.max(1, maxLines - header.length - statusLines);
96
- const maxScroll = Math.max(0, msgLines.length - contentHeight);
82
+ const messageLineCount = this.countMessageLines(allMessages, w);
83
+ const maxScroll = Math.max(0, messageLineCount - contentHeight);
97
84
 
98
85
  // Auto-scroll to bottom unless user scrolled up
99
86
  if (!this.userScrolled) {
@@ -108,10 +95,10 @@ export class MessageView {
108
95
  // Build output — exact height every time
109
96
  const lines = [...header];
110
97
 
111
- // Content area: pad to exact contentHeight
112
- const visible = msgLines.slice(this.scrollOffset, this.scrollOffset + contentHeight);
98
+ // Content area: collect and pad to exact contentHeight.
99
+ const visible = this.collectMessageLines(allMessages, w, this.scrollOffset, contentHeight);
113
100
  while (visible.length < contentHeight) visible.push("");
114
- lines.push(...visible.slice(0, contentHeight));
101
+ lines.push(...visible);
115
102
 
116
103
  // Status bar (always present, keeps layout stable)
117
104
  const pct = maxScroll > 0 ? Math.round((this.scrollOffset / maxScroll) * 100) : 100;
@@ -124,10 +111,32 @@ export class MessageView {
124
111
  return lines.slice(0, maxLines);
125
112
  }
126
113
 
127
- private renderMessages(messages: TaskMessage[], width: number): string[] {
114
+ private countMessageLines(messages: TaskMessage[], width: number): number {
115
+ let count = 0;
116
+ for (const _line of this.iterMessageLines(messages, width)) count++;
117
+ return count;
118
+ }
119
+
120
+ private collectMessageLines(
121
+ messages: TaskMessage[],
122
+ width: number,
123
+ start: number,
124
+ limit: number,
125
+ ): string[] {
126
+ const visible: string[] = [];
127
+ let index = 0;
128
+ for (const line of this.iterMessageLines(messages, width)) {
129
+ if (index++ < start) continue;
130
+ visible.push(line);
131
+ if (visible.length >= limit) break;
132
+ }
133
+ return visible;
134
+ }
135
+
136
+ private *iterMessageLines(messages: TaskMessage[], width: number): Generator<string> {
128
137
  const th = this.theme;
129
- const lines: string[] = [];
130
138
  let lastFrom: string | null = null;
139
+ let hasOutput = false;
131
140
 
132
141
  for (const msg of messages) {
133
142
  if (msg.type === "status" && msg.from === "system" && msg.text === "Agent starting work") continue;
@@ -136,54 +145,47 @@ export class MessageView {
136
145
  lastFrom = msg.from;
137
146
 
138
147
  if (showHeader) {
139
- if (lines.length > 0) lines.push("");
148
+ if (hasOutput) yield "";
140
149
  const time = fmtTime(msg.ts);
141
- const color = msg.from === "human" ? "accent" : msg.from === "system" ? "dim" : "success";
142
- const name = msg.from === "human" ? "YOU" : msg.from;
143
- lines.push(fit(` ${th.fg("dim", time)} ${th.fg(color as any, name)}`, width));
150
+ const color = msg.from === "human" || msg.from === "orchestrator"
151
+ ? "accent"
152
+ : msg.from === "system" ? "dim" : "success";
153
+ const name = senderLabel(msg.from);
154
+ yield fit(` ${th.fg("dim", time)} ${th.fg(color as any, name)}`, width);
155
+ hasOutput = true;
144
156
  }
145
157
 
146
158
  switch (msg.type) {
147
159
  case "tool": {
148
160
  const name = msg.name || msg.text;
149
- // Tool args can contain multi-line bash commands — take first line only
161
+ // Tool args can contain multi-line bash commands — take first line only.
150
162
  const rawArg = (msg.args?.path || msg.args?.command || "").toString();
151
163
  const arg = rawArg.split("\n")[0];
152
- lines.push(fit(` ${th.fg("muted", `→ ${name}${arg ? " " + arg : ""}`)}`, width));
164
+ yield fit(` ${th.fg("muted", `→ ${name}${arg ? " " + arg : ""}`)}`, width);
153
165
  break;
154
166
  }
155
- case "mention": {
156
- lines.push(fit(` ${th.fg("accent", `@${msg.to || "?"}`)} ${th.fg("dim", msg.text)}`, width));
167
+ case "mention":
168
+ yield fit(` ${th.fg("accent", `@${msg.to || "?"}`)} ${th.fg("dim", msg.text)}`, width);
157
169
  break;
158
- }
159
170
  case "text":
160
171
  case "message":
161
- case "reply": {
162
- const textLines = msg.text.replace(/\r/g, "").split("\n");
163
- const show = textLines.slice(0, MAX_TEXT_LINES);
164
- for (const tl of show) {
165
- // Wrap long lines instead of truncating
166
- const wrapped = wrap(` ${tl}`, width, " ");
167
- lines.push(...wrapped);
168
- }
169
- if (textLines.length > MAX_TEXT_LINES) {
170
- lines.push(fit(` ${th.fg("dim", `... +${textLines.length - MAX_TEXT_LINES} lines`)}`, width));
172
+ case "reply":
173
+ for (const textLine of msg.text.replace(/\r/g, "").split("\n")) {
174
+ for (const line of wrap(` ${textLine}`, width, " ")) yield line;
171
175
  }
172
176
  break;
173
- }
174
177
  case "done":
175
- lines.push(...wrap(` ✓ ${msg.text}`, width, " "));
178
+ for (const line of wrap(` ✓ ${msg.text}`, width, " ")) yield line;
176
179
  break;
177
180
  case "error":
178
- lines.push(...wrap(` ✗ ${msg.text}`, width, " "));
181
+ for (const line of wrap(` ✗ ${msg.text}`, width, " ")) yield line;
179
182
  break;
180
183
  case "status":
181
- lines.push(fit(` ${th.fg("dim", msg.text)}`, width));
184
+ yield fit(` ${th.fg("dim", msg.text)}`, width);
182
185
  break;
183
186
  }
187
+ hasOutput = true;
184
188
  }
185
-
186
- return lines;
187
189
  }
188
190
  }
189
191
 
@@ -235,6 +237,12 @@ function stripAnsi(str: string): string {
235
237
  return str.replace(/\x1b\[[0-9;]*m/g, "");
236
238
  }
237
239
 
240
+ function senderLabel(from: string): string {
241
+ if (from === "human") return "YOU";
242
+ if (from === "orchestrator") return "ORCHESTRATOR";
243
+ return from;
244
+ }
245
+
238
246
  function pad(lines: string[], max: number): string[] {
239
247
  while (lines.length < max) lines.push("");
240
248
  return lines.slice(0, max);