pi-squad 0.16.3 → 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,7 +504,7 @@ export default function (pi: ExtensionAPI) {
504
504
  pi.registerTool({
505
505
  name: "squad_message",
506
506
  label: "Squad Message",
507
- description: "Send a request to a specific agent/task. The agent's next substantive assistant response is durably forwarded back to the main Pi session and wakes it.",
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" })),
@@ -513,23 +513,40 @@ export default function (pi: ExtensionAPI) {
513
513
  }),
514
514
 
515
515
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
516
- const activeScheduler = getActiveScheduler();
517
- if (!activeScheduler || !activeSquadId) {
516
+ if (!activeSquadId) {
518
517
  return { content: [{ type: "text" as const, text: "No active squad." }], details: undefined };
519
518
  }
520
519
 
520
+ let activeScheduler = getActiveScheduler();
521
521
  let taskId = params.taskId;
522
522
 
523
- // If agent specified but no taskId, find their current task
524
- if (!taskId && params.agent) {
525
- 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;
526
530
  }
527
531
 
528
532
  if (!taskId) {
529
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 };
530
534
  }
535
+ if (!store.loadTask(activeSquadId, taskId)) {
536
+ return { content: [{ type: "text" as const, text: `Task not found: ${taskId}` }], details: undefined };
537
+ }
538
+
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
+ }
531
548
 
532
- const sent = await activeScheduler!.sendHumanMessage(taskId, params.message, params.expectReply ?? true);
549
+ const sent = await activeScheduler.sendHumanMessage(taskId, params.message, params.expectReply ?? true);
533
550
  const status = sent ? "delivered" : "queued for when the agent starts";
534
551
 
535
552
  return { content: [{ type: "text" as const, text: `Message ${status}: "${params.message}"` }], details: undefined };
@@ -595,57 +612,7 @@ export default function (pi: ExtensionAPI) {
595
612
  widgetState.enabled = true;
596
613
  widgetControls?.requestUpdate();
597
614
 
598
- // Wire up events (same as startSquad)
599
- scheduler.onEvent((event: SchedulerEvent) => {
600
- forceWidgetUpdate();
601
- switch (event.type) {
602
- case "squad_review_required": {
603
- const tasks = store.loadAllTasks(squadId);
604
- const summary = buildCompletionSummary(tasks);
605
- const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
606
- const s = schedulers.get(squadId); if (s) s.updateContext();
607
- const squad = store.loadSquad(squadId)!;
608
- // Wake the main agent into an explicit acceptance gate. Agent reports
609
- // are untrusted inputs; only independent orchestrator evidence can pass it.
610
- pi.sendMessage({
611
- customType: "squad-review-required",
612
- 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)}`,
613
- display: true,
614
- }, { triggerTurn: true, deliverAs: "followUp" });
615
- schedulers.delete(squadId);
616
- forceWidgetUpdate();
617
- break;
618
- }
619
- case "squad_failed": {
620
- const tasks = store.loadAllTasks(squadId);
621
- const failed = tasks.filter((t) => t.status === "failed");
622
- const done = tasks.filter((t) => t.status === "done");
623
- pi.sendMessage({
624
- customType: "squad-failed",
625
- content: `[squad] Squad "${squadId}" has stalled. ${done.length}/${tasks.length} done, ${failed.length} failed.\nFailed: ${buildFailureSummary(tasks)}`,
626
- display: true,
627
- }, { triggerTurn: true, deliverAs: "followUp" });
628
- forceWidgetUpdate();
629
- break;
630
- }
631
- case "orchestrator_reply": {
632
- pi.sendMessage({
633
- customType: "squad-agent-reply",
634
- content: `[squad] Direct reply from '${event.agentName}' on task '${event.taskId}':\n${event.message}`,
635
- display: true,
636
- }, { triggerTurn: true, deliverAs: "followUp" });
637
- break;
638
- }
639
- case "escalation": {
640
- pi.sendMessage({
641
- customType: "squad-escalation",
642
- content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n${event.message}`,
643
- display: true,
644
- }, { triggerTurn: true });
645
- break;
646
- }
647
- }
648
- });
615
+ wireSchedulerEvents(pi, scheduler, squadId);
649
616
  }
650
617
 
651
618
  const resumeSched = schedulers.get(squadId)!;
@@ -795,6 +762,29 @@ export default function (pi: ExtensionAPI) {
795
762
  }
796
763
  }
797
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
+
798
788
  // Notify about paused squads only if they have real completed work
799
789
  const paused = store.findActiveSquads()
800
790
  .filter((s) => s.cwd === ctx.cwd && s.status === "paused");
@@ -851,7 +841,7 @@ export default function (pi: ExtensionAPI) {
851
841
  }
852
842
 
853
843
  if (activeSquadId) {
854
- openPanel(ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
844
+ openPanel(pi, ctx, schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext), activeSquadId);
855
845
  }
856
846
  return { consume: true };
857
847
  }
@@ -885,7 +875,7 @@ export default function (pi: ExtensionAPI) {
885
875
  { value: "agents", label: "agents", description: "List, view, or edit agent definitions" },
886
876
  { value: "defaults", label: "defaults", description: "Default model/thinking for agents (follow main session, pi default, or fixed)" },
887
877
  { value: "advisor", label: "advisor", description: "Advisor-first rescue for stuck agents (on/off, model, limits)" },
888
- { 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" },
889
879
  { value: "widget", label: "widget", description: "Toggle live widget" },
890
880
  { value: "panel", label: "panel", description: "Toggle overlay panel" },
891
881
  { value: "cancel", label: "cancel", description: "Cancel running squad" },
@@ -978,7 +968,7 @@ export default function (pi: ExtensionAPI) {
978
968
  }
979
969
  if (activeSquadId) {
980
970
  const sched = schedulers.get(activeSquadId) || new Scheduler(activeSquadId, squadSkillPaths, schedulerSpawnContext);
981
- openPanel(ctx, sched, activeSquadId);
971
+ openPanel(pi, ctx, sched, activeSquadId);
982
972
  }
983
973
  return;
984
974
  }
@@ -989,11 +979,8 @@ export default function (pi: ExtensionAPI) {
989
979
  return;
990
980
  }
991
981
  const msgSquad = store.loadSquad(activeSquadId);
992
- if (!msgSquad || msgSquad.status !== "running") {
993
- ctx.ui.notify("Squad is not running — messages only reach running agents.", "info");
994
- return;
995
- }
996
- // Parse: /squad msg [agent] message text
982
+ if (!msgSquad) return;
983
+ // Parse: /squad msg [task-id|running-agent] message text
997
984
  const msgParts = parts.slice(1);
998
985
  let targetAgent: string | undefined;
999
986
  let msgText: string;
@@ -1014,40 +1001,51 @@ export default function (pi: ExtensionAPI) {
1014
1001
  }
1015
1002
  }
1016
1003
 
1017
- // 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.
1018
1006
  const msgTasks = store.loadAllTasks(activeSquadId);
1019
1007
  let targetTaskId: string | undefined;
1020
-
1021
- if (targetAgent) {
1022
- const agentTask = msgTasks.find((t) => t.agent === targetAgent && t.status === "in_progress");
1023
- 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;
1024
1020
  if (!targetTaskId) {
1025
- 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");
1026
1022
  return;
1027
1023
  }
1028
1024
  } else {
1029
- const runningTask = msgTasks.find((t) => t.status === "in_progress");
1030
- targetTaskId = runningTask?.id;
1031
- targetAgent = runningTask?.agent;
1032
- if (!targetTaskId) {
1033
- 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");
1034
1033
  return;
1035
1034
  }
1036
1035
  }
1037
1036
 
1038
- const msgSched = getActiveScheduler();
1039
- if (msgSched) {
1040
- await msgSched.sendHumanMessage(targetTaskId, msgText);
1041
- ctx.ui.notify(`Sent to ${targetAgent}: "${msgText}"`, "info");
1042
- } else {
1043
- store.appendMessage(activeSquadId, targetTaskId, {
1044
- ts: store.now(),
1045
- from: "human",
1046
- type: "message",
1047
- text: msgText,
1048
- });
1049
- 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();
1050
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
+ );
1051
1049
  forceWidgetUpdate();
1052
1050
  return;
1053
1051
  }
@@ -1475,6 +1473,63 @@ function forceWidgetUpdate(): void {
1475
1473
  widgetControls?.requestUpdate();
1476
1474
  }
1477
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
+
1478
1533
  // ============================================================================
1479
1534
  // Panel — overlay via ctx.ui.custom() with proper done() lifecycle
1480
1535
  // ============================================================================
@@ -1485,6 +1540,7 @@ function forceWidgetUpdate(): void {
1485
1540
  * that resolves when done() is called. The panel calls done() on close.
1486
1541
  */
1487
1542
  function openPanel(
1543
+ pi: ExtensionAPI,
1488
1544
  ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
1489
1545
  scheduler: Scheduler,
1490
1546
  squadId: string,
@@ -1502,18 +1558,19 @@ function openPanel(
1502
1558
  const task = store.loadTask(squadId, taskId);
1503
1559
  const agentName = task?.agent || taskId;
1504
1560
  const input = await ctx.ui.input(`Message to ${agentName}`, "Type your message...");
1505
- const panelSched = schedulers.get(squadId);
1506
- if (input && panelSched) {
1507
- await panelSched.sendHumanMessage(taskId, input);
1508
- ctx.ui.notify(`Sent to ${agentName}: "${input}"`, "info");
1509
- } else if (input) {
1510
- store.appendMessage(squadId, taskId, {
1511
- ts: store.now(),
1512
- from: "human",
1513
- type: "message",
1514
- text: input,
1515
- });
1516
- 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
+ );
1517
1574
  }
1518
1575
  tui.requestRender();
1519
1576
  };
@@ -1666,82 +1723,8 @@ async function startSquad(
1666
1723
  widgetState.enabled = true;
1667
1724
  widgetControls?.requestUpdate();
1668
1725
 
1669
- // Wire up completion/escalation notifications to main agent
1670
- scheduler.onEvent((event: SchedulerEvent) => {
1671
- // Update widget on every scheduler event
1672
- forceWidgetUpdate();
1673
- switch (event.type) {
1674
- case "squad_review_required": {
1675
- const tasks = store.loadAllTasks(squadId);
1676
- const summary = buildCompletionSummary(tasks);
1677
- const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
1678
-
1679
- // Final context update before clearing scheduler
1680
- const completedSched = schedulers.get(squadId);
1681
- if (completedSched) {
1682
- completedSched.updateContext();
1683
- }
1684
- const squad = store.loadSquad(squadId)!;
1685
-
1686
- // Wake the main agent into an explicit acceptance gate. Never frame
1687
- // agent execution or squad QA as trusted completion.
1688
- pi.sendMessage({
1689
- customType: "squad-review-required",
1690
- content: `[squad] TASK EXECUTION FINISHED for "${squadId}" — WORK IS UNTRUSTED AND NOT YET ACCEPTED.\n\n` +
1691
- `Squad claims (review inputs only):\n${summary}\n\n` +
1692
- `Total cost: $${totalCost.toFixed(4)}\n\n` +
1693
- buildOrchestratorReviewGate(squad, tasks),
1694
- display: true,
1695
- }, { triggerTurn: true, deliverAs: "followUp" });
1696
-
1697
- // Clear scheduler but keep activeSquadId so squad_status still works
1698
- schedulers.delete(squadId);
1699
- forceWidgetUpdate(); // Final update showing done state
1700
- break;
1701
- }
1702
-
1703
- case "squad_failed": {
1704
- const tasks = store.loadAllTasks(squadId);
1705
- const failed = tasks.filter((t) => t.status === "failed");
1706
- const done = tasks.filter((t) => t.status === "done");
1707
-
1708
- pi.sendMessage({
1709
- customType: "squad-failed",
1710
- content: `[squad] Squad "${squadId}" has stalled. ` +
1711
- `${done.length}/${tasks.length} tasks done, ${failed.length} failed.\n` +
1712
- `Failed: ${buildFailureSummary(tasks)}\n` +
1713
- `Use squad_status for details or squad_modify to adjust.`,
1714
- display: true,
1715
- }, { triggerTurn: true, deliverAs: "followUp" });
1716
- forceWidgetUpdate();
1717
- break;
1718
- }
1719
-
1720
- case "orchestrator_reply": {
1721
- // Push the complete requested response back into the main session and
1722
- // wake it; ordinary task activity remains panel-only.
1723
- pi.sendMessage({
1724
- customType: "squad-agent-reply",
1725
- content: `[squad] Direct reply from '${event.agentName}' on task '${event.taskId}':\n${event.message}`,
1726
- display: true,
1727
- }, { triggerTurn: true, deliverAs: "followUp" });
1728
- break;
1729
- }
1730
-
1731
- case "escalation": {
1732
- // Escalation — agent needs help. triggerTurn so the main agent
1733
- // can respond and relay help.
1734
- pi.sendMessage({
1735
- customType: "squad-escalation",
1736
- content: `[squad] Agent '${event.agentName}' on task '${event.taskId}' needs attention:\n` +
1737
- `${event.message}\n\n` +
1738
- `Reply to me and I'll forward your answer, or use the squad panel.`,
1739
- display: true,
1740
- }, { triggerTurn: true });
1741
- break;
1742
- }
1743
- }
1744
- });
1726
+ // Wire up completion/escalation notifications to main agent.
1727
+ wireSchedulerEvents(pi, scheduler, squadId);
1745
1728
 
1746
1729
  // Start scheduling — fire and forget, don't block the tool call.
1747
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);