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.
@@ -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/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