pi-squad 0.16.3 → 0.16.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.
@@ -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);
@@ -13,7 +13,7 @@
13
13
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
14
14
  import type { Component, TUI } from "@earendil-works/pi-tui";
15
15
  import type { Theme } from "@earendil-works/pi-coding-agent";
16
- import type { TaskStatus } from "../types.js";
16
+ import type { TaskMessage, TaskStatus } from "../types.js";
17
17
  import * as store from "../store.js";
18
18
 
19
19
  function statusIcon(status: TaskStatus, th: Theme): string {
@@ -76,14 +76,33 @@ export function setupSquadWidget(
76
76
  const totalCost = tasks.reduce((sum, t) => sum + t.usage.cost, 0);
77
77
  const doneCount = tasks.filter((t) => t.status === "done").length;
78
78
  const elapsed = Date.now() - new Date(squad.created).getTime();
79
+ const taskMessages = new Map<string, TaskMessage[]>();
80
+ const recentOrchestratorByTask = new Map<string, TaskMessage>();
81
+ const recentMessageKeys: string[] = [];
82
+ for (const task of tasks) {
83
+ if (task.status !== "in_progress") continue;
84
+ const messages = store.loadMessages(state.squadId, task.id);
85
+ taskMessages.set(task.id, messages);
86
+ const latest = messages.at(-1);
87
+ recentMessageKeys.push(`${task.id}:${messages.length}:${latest?.id || latest?.ts || "none"}`);
88
+ const recentOrchestrator = [...messages.slice(-5)].reverse().find((message) =>
89
+ message.from === "orchestrator" &&
90
+ (message.type === "text" || message.type === "message" ||
91
+ message.type === "reply" || message.type === "mention"),
92
+ );
93
+ if (recentOrchestrator) recentOrchestratorByTask.set(task.id, recentOrchestrator);
94
+ }
79
95
 
80
96
  const sIcon = squad.status === "done" ? th.fg("success", "✓")
81
97
  : squad.status === "failed" ? th.fg("error", "✗")
82
98
  : squad.status === "review" ? th.fg("warning", "◆")
83
99
  : th.fg("warning", "⏳");
84
100
 
101
+ const orchestratorSignal = recentOrchestratorByTask.size > 0
102
+ ? ` ${th.fg("accent", `✉ ${recentOrchestratorByTask.size > 1 ? recentOrchestratorByTask.size + " " : ""}ORCH`)}`
103
+ : "";
85
104
  lines.push(
86
- `${sIcon} ${th.fg("accent", "squad")} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
105
+ `${sIcon} ${th.fg("accent", "squad")}${orchestratorSignal} ${th.fg("dim", squad.goal.slice(0, 35))} ` +
87
106
  `${th.fg("muted", `${doneCount}/${tasks.length}`)} ` +
88
107
  `${th.fg("dim", `$${totalCost.toFixed(2)}`)} ` +
89
108
  `${th.fg("dim", formatElapsed(elapsed))} ` +
@@ -111,13 +130,19 @@ export function setupSquadWidget(
111
130
  const runningFor = task.started ? Date.now() - new Date(task.started).getTime() : 0;
112
131
  const timeColor = runningFor > 180_000 ? "warning" : "dim";
113
132
  line += ` ${th.fg(timeColor as any, formatElapsed(runningFor))}`;
114
- const messages = store.loadMessages(state.squadId!, task.id);
115
- const lastTool = [...messages].reverse().find(m => m.type === "tool");
116
- if (lastTool) {
117
- const rawDetail = (lastTool.args?.path || lastTool.args?.command || "").toString();
118
- const detail = rawDetail.split("\n")[0]; // first line only
119
- const toolStr = `→ ${lastTool.name || lastTool.text}`;
120
- line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
133
+ const messages = taskMessages.get(task.id) || [];
134
+ const recentOrchestrator = recentOrchestratorByTask.get(task.id);
135
+ if (recentOrchestrator) {
136
+ const preview = recentOrchestrator.text.split("\n")[0].slice(0, 24);
137
+ line += ` ${th.fg("accent", "← ORCH")} ${th.fg("dim", preview)}`;
138
+ } else {
139
+ const lastTool = [...messages].reverse().find(m => m.type === "tool");
140
+ if (lastTool) {
141
+ const rawDetail = (lastTool.args?.path || lastTool.args?.command || "").toString();
142
+ const detail = rawDetail.split("\n")[0]; // first line only
143
+ const toolStr = `→ ${lastTool.name || lastTool.text}`;
144
+ line += ` ${th.fg("dim", (detail ? `${toolStr} ${detail}` : toolStr).slice(0, 30))}`;
145
+ }
121
146
  }
122
147
  } else if (task.status === "blocked") {
123
148
  const blockers = task.depends.filter((d) => {
@@ -136,7 +161,7 @@ export function setupSquadWidget(
136
161
  lines.push(` ${th.fg("dim", ` +${tasks.length - maxVisible} more · ^q detail`)}`);
137
162
  }
138
163
 
139
- const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}`;
164
+ const cacheKey = `${squad.status}:${tasks.map(t => `${t.id}=${t.status}:${t.usage.turns}`).join(",")}:${recentMessageKeys.join(",")}`;
140
165
 
141
166
  const statusText = squad.status === "done"
142
167
  ? th.fg("success", `✓ squad ${doneCount}/${tasks.length}`)
@@ -200,15 +200,25 @@ export class TaskListView {
200
200
  const recent = messages.slice(-3);
201
201
 
202
202
  for (const msg of recent) {
203
- if (msg.type === "tool") {
203
+ const isConversation = msg.type === "text" || msg.type === "message" ||
204
+ msg.type === "reply" || msg.type === "mention";
205
+ if (msg.from === "orchestrator" && isConversation) {
206
+ const preview = msg.text.split("\n")[0];
207
+ lines.push(truncateToWidth(
208
+ ` ${th.fg("accent", "ORCHESTRATOR")} ${th.fg("dim", `"${preview}"`)}`,
209
+ width,
210
+ "…",
211
+ ));
212
+ } else if (msg.type === "tool") {
204
213
  const toolStr = `→ ${msg.name || msg.text}`;
205
214
  const rawArgs = (msg.args?.path || msg.args?.command || "").toString();
206
215
  const argsStr = rawArgs.split("\n")[0]; // first line only
207
216
  const preview = argsStr ? `${toolStr} ${argsStr}` : toolStr;
208
217
  lines.push(truncateToWidth(` ${th.fg("muted", preview)}`, width, "…"));
209
- } else if (msg.type === "text" && msg.from !== "system") {
218
+ } else if (isConversation && msg.from !== "system") {
210
219
  const preview = msg.text.split("\n")[0];
211
- lines.push(truncateToWidth(` ${th.fg("dim", `"${preview}"`)}`, width, "…"));
220
+ const sender = msg.from === "human" ? "YOU" : msg.from;
221
+ lines.push(truncateToWidth(` ${th.fg("dim", `${sender}: "${preview}"`)}`, width, "…"));
212
222
  }
213
223
  }
214
224
 
package/src/review.ts CHANGED
@@ -11,6 +11,18 @@ export interface OrchestratorReviewInput {
11
11
  issues: string[];
12
12
  }
13
13
 
14
+ /**
15
+ * Start same-squad rework without discarding completed review evidence.
16
+ * The next all-tasks-done transition creates a new active pending review.
17
+ */
18
+ export function beginOrchestratorRework(squad: Squad): void {
19
+ if (squad.review && squad.review.status !== "pending") {
20
+ squad.reviewHistory = [...(squad.reviewHistory ?? []), { ...squad.review }];
21
+ }
22
+ delete squad.review;
23
+ squad.status = "running";
24
+ }
25
+
14
26
  /** Move a squad from agent execution into mandatory independent main-session review. */
15
27
  export function beginOrchestratorReview(squad: Squad): void {
16
28
  squad.status = "review";
@@ -35,6 +47,9 @@ export function recordOrchestratorReview(squad: Squad, input: OrchestratorReview
35
47
  if (squad.status !== "review" || !squad.review) {
36
48
  throw new Error(`Squad '${squad.id}' is not awaiting orchestrator review`);
37
49
  }
50
+ if (squad.review.status !== "pending") {
51
+ throw new Error(`Squad '${squad.id}' review attempt is already ${squad.review.status}; begin same-squad rework before submitting a fresh review`);
52
+ }
38
53
 
39
54
  const contractChecks = cleanList(input.contractChecks);
40
55
  const verificationEvidence = cleanList(input.verificationEvidence);
package/src/router.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * router.ts — @mention parsing and cross-agent message delivery.
3
3
  *
4
4
  * Parses assistant text for @agentname patterns.
5
- * Routes messages to target agents via steer() if running,
6
- * or queues them for delivery on next spawn.
5
+ * Routes messages to exact target tasks via steer() if running,
6
+ * or a durable task-owned mailbox for delivery on next spawn.
7
7
  */
8
8
 
9
9
  import type { AgentPool } from "./agent-pool.js";
@@ -83,30 +83,41 @@ export class Router {
83
83
  text: message,
84
84
  });
85
85
 
86
- // Find if target agent is running
87
- const targetTaskId = this.pool.getTaskIdForAgent(targetAgent);
88
-
89
- if (targetTaskId && this.pool.isRunning(targetTaskId)) {
90
- // Target is running — steer them
91
- const steerMessage = `[squad] Message from @${fromAgent} (working on ${sourceTaskId}):\n${message}`;
92
- this.pool.steer(targetTaskId, steerMessage);
86
+ // Agent-name mentions are safe only when exactly one live task owns that
87
+ // role. Multiple concurrent same-role tasks must never receive guessed mail.
88
+ const liveTargets = store.loadAllTasks(this.squadId).filter(
89
+ (task) => task.agent === targetAgent && this.pool.isRunning(task.id),
90
+ );
91
+ const targetTaskId = liveTargets.length === 1 ? liveTargets[0].id : undefined;
93
92
 
94
- // Log in target task too
95
- store.appendMessage(this.squadId, targetTaskId, {
93
+ if (targetTaskId) {
94
+ const queued = store.queueTaskMessage(this.squadId, targetTaskId, {
96
95
  ts: store.now(),
97
96
  from: fromAgent,
98
97
  type: "mention",
99
98
  to: targetAgent,
100
99
  text: message,
101
100
  });
101
+ const steerMessage = `[squad] Message from @${fromAgent} (working on ${sourceTaskId}):\n${message}`;
102
+ void this.pool.steer(targetTaskId, steerMessage).then((delivered) => {
103
+ if (delivered) store.acknowledgeTaskMessages(this.squadId, targetTaskId, [queued.id]);
104
+ });
102
105
  return false;
103
106
  }
104
107
 
105
108
  const targetTasks = store.loadAllTasks(this.squadId).filter((task) => task.agent === targetAgent);
106
- const hasFutureRun = targetTasks.some((task) =>
107
- task.status === "pending" || task.status === "blocked" || task.status === "suspended" || task.status === "in_progress",
109
+ const futureTasks = targetTasks.filter((task) =>
110
+ task.status === "in_progress" || task.status === "pending" || task.status === "suspended" || task.status === "blocked",
108
111
  );
109
- if (!hasFutureRun) {
112
+ const interrupted = futureTasks.filter((task) => task.status === "in_progress");
113
+ // Never guess between two tasks assigned to the same role. A uniquely
114
+ // interrupted task is authoritative; otherwise only one future task is safe.
115
+ const futureTask = interrupted.length === 1
116
+ ? interrupted[0]
117
+ : futureTasks.length === 1
118
+ ? futureTasks[0]
119
+ : undefined;
120
+ if (futureTasks.length === 0) {
110
121
  const completed = targetTasks.filter((task) => task.status === "done" && task.output);
111
122
  if (completed.length > 0) {
112
123
  const durableReply = [
@@ -120,21 +131,20 @@ export class Router {
120
131
  to: fromAgent,
121
132
  text: durableReply,
122
133
  };
123
- store.appendMessage(this.squadId, sourceTaskId, reply);
134
+ const queued = store.queueTaskMessage(this.squadId, sourceTaskId, reply);
124
135
  if (this.pool.isRunning(sourceTaskId)) {
125
- this.pool.steer(sourceTaskId, durableReply);
126
- } else {
127
- this.pool.queueMessage(fromAgent, reply);
136
+ void this.pool.steer(sourceTaskId, durableReply).then((delivered) => {
137
+ if (delivered) store.acknowledgeTaskMessages(this.squadId, sourceTaskId, [queued.id]);
138
+ });
128
139
  }
129
140
  return true;
130
141
  }
131
142
  }
132
143
 
133
- // Target has future work and may spawn again queue for that run. If the
134
- // target is terminal with no output, do not create an undeliverable queue;
135
- // leave the blocker unresolved so it escalates to the main orchestrator.
136
- if (hasFutureRun) {
137
- this.pool.queueMessage(targetAgent, {
144
+ // Target has future work and may spawn again. Bind the message to exactly
145
+ // one durable task, never to the shared role name.
146
+ if (futureTask) {
147
+ store.queueTaskMessage(this.squadId, futureTask.id, {
138
148
  ts: store.now(),
139
149
  from: fromAgent,
140
150
  type: "mention",
@@ -149,7 +159,7 @@ export class Router {
149
159
  * Route a human message to an agent.
150
160
  */
151
161
  routeHumanMessage(taskId: string, message: string): void {
152
- store.appendMessage(this.squadId, taskId, {
162
+ const queued = store.queueTaskMessage(this.squadId, taskId, {
153
163
  ts: store.now(),
154
164
  from: "human",
155
165
  type: "message",
@@ -157,17 +167,9 @@ export class Router {
157
167
  });
158
168
 
159
169
  if (this.pool.isRunning(taskId)) {
160
- this.pool.steer(taskId, `[squad] Human: ${message}`);
161
- } else {
162
- const task = store.loadTask(this.squadId, taskId);
163
- if (task) {
164
- this.pool.queueMessage(task.agent, {
165
- ts: store.now(),
166
- from: "human",
167
- type: "message",
168
- text: message,
169
- });
170
- }
170
+ void this.pool.steer(taskId, `[squad] Human: ${message}`).then((delivered) => {
171
+ if (delivered) store.acknowledgeTaskMessages(this.squadId, taskId, [queued.id]);
172
+ });
171
173
  }
172
174
  }
173
175