pi-messenger 0.10.0 → 0.11.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0] - 2026-02-08
4
+
5
+ ### Added
6
+ - **Test suite** — 53 tests across 7 Vitest suites covering store CRUD, state machine, config merging, agent discovery, model resolution, graceful shutdown, and live progress (including cwd isolation). Includes test helpers for temp directories and mock contexts.
7
+ - **Per-agent runtime config** — Model override with 4-level priority: per-task > per-wave `model` param > config `crew.models.worker` > agent `.md` frontmatter. Environment variable override via `crew.work.env` config (not exposed as tool param to prevent API keys in logs).
8
+ - **Graceful shutdown** — `AbortSignal` threaded from tool execute through to spawned workers. On abort: discovers worker name via PID-based registry scan, writes shutdown message to worker inbox, waits 30s grace period, SIGTERM, waits 5s, SIGKILL. Tasks reset to `todo` for retry. Workers instructed to release reservations and exit without committing.
9
+ - **Live crew progress** — In-memory pub/sub store fed by worker JSONL events. Overlay Crew tab shows Active Workers section with tool name, call count, tokens, and elapsed time updating every second. Status bar shows active worker count during autonomous mode (`🔨N`).
10
+ - **`shutdownGracePeriodMs` config** — Configurable grace period before SIGTERM (default: 30000).
11
+
12
+ ### Changed
13
+ - **Dynamic overlay height** — Content area scales with terminal size (8-25 lines) instead of hardcoded 10. On a standard 24-row terminal, visible content goes from 10 to 15 lines.
14
+ - **Handler signatures simplified** — Removed unused `state` and `dirs` parameters from plan and review handlers.
15
+
16
+ ### Fixed
17
+ - **`deepMerge` crash** — Merging config with `models` key crashed when the target didn't have the key. Hardened for undefined target keys.
18
+ - **Result/task association** — Worker results now matched by `taskId` field instead of array index, since `spawnAgents` returns in completion order not submission order.
19
+
20
+ ### Removed
21
+ - `attemptsPerTask` field from `AutonomousState` — declared but never populated by any code.
22
+ - `ARCHITECTURE.md` from repo (moved to external docs).
23
+
3
24
  ## [0.10.0] - 2026-02-05
4
25
 
5
26
  ### Fixed
package/README.md CHANGED
@@ -122,13 +122,31 @@ Add to `~/.pi/agent/pi-messenger.json`:
122
122
  {
123
123
  "crew": {
124
124
  "concurrency": { "workers": 2 },
125
+ "models": { "worker": "claude-sonnet-4-20250514" },
125
126
  "review": { "enabled": true, "maxIterations": 3 },
126
127
  "planning": { "maxPasses": 3 },
127
- "work": { "maxAttemptsPerTask": 5, "maxWaves": 50 }
128
+ "work": {
129
+ "maxAttemptsPerTask": 5,
130
+ "maxWaves": 50,
131
+ "shutdownGracePeriodMs": 30000,
132
+ "env": { "NODE_ENV": "test" }
133
+ }
128
134
  }
129
135
  }
130
136
  ```
131
137
 
138
+ | Setting | Description | Default |
139
+ |---------|-------------|---------|
140
+ | `concurrency.workers` | Max parallel workers per wave | `2` |
141
+ | `models.worker` | Model for spawned workers (overridden by per-task or per-wave `model` param) | agent `.md` frontmatter |
142
+ | `review.enabled` | Auto-review after task completion | `true` |
143
+ | `review.maxIterations` | Max review/fix cycles per task | `3` |
144
+ | `planning.maxPasses` | Max planner/reviewer refinement passes | `3` |
145
+ | `work.maxAttemptsPerTask` | Auto-block after N failures | `5` |
146
+ | `work.maxWaves` | Max autonomous waves | `50` |
147
+ | `work.shutdownGracePeriodMs` | Grace period before SIGTERM on abort | `30000` |
148
+ | `work.env` | Environment variables passed to spawned workers | `{}` |
149
+
132
150
  Crew agents (planner, worker, reviewer, interview-generator, plan-sync) are **auto-installed** on first use. Run `npx pi-messenger --crew-install` to manually install or update.
133
151
 
134
152
  ## API Reference
@@ -216,7 +234,7 @@ Pi-messenger is a [pi extension](https://github.com/badlogic/pi-mono) that hooks
216
234
 
217
235
  Incoming messages wake the receiving agent via `pi.sendMessage()` with `triggerTurn: true` and `deliverAs: "steer"`, which injects the message as a steering prompt that resumes the agent. File reservations are enforced by returning `{ block: true }` from a `tool_call` hook on write/edit operations. The `/messenger` overlay uses `ctx.ui.custom()` for the chat TUI, and `ctx.ui.setStatus()` keeps the status bar updated with peer count and unread messages.
218
236
 
219
- Crew workers are spawned as `pi --mode json` subprocesses with the agent's system prompt, model, and tool restrictions from their `.md` definitions. Progress is tracked via JSONL streaming. The planner and reviewer work the same way — just pi instances with different agent configs.
237
+ Crew workers are spawned as `pi --mode json` subprocesses with the agent's system prompt, model, and tool restrictions from their `.md` definitions. Progress is tracked via JSONL streaming — the overlay subscribes to a live progress store that shows each worker's current tool, call count, and token usage in real time. Aborting a work run triggers graceful shutdown: each worker receives an inbox message asking it to stop, followed by a grace period before SIGTERM. The planner and reviewer work the same way — just pi instances with different agent configs.
220
238
 
221
239
  All coordination is file-based, no daemon required. Global state (registry, inboxes, activity feed) lives in `~/.pi/agent/messenger/`. Per-project crew data (plan, tasks, artifacts) lives in `.pi/messenger/crew/` inside your project. Dead agents are detected via PID checks and cleaned up automatically.
222
240
 
@@ -85,6 +85,15 @@ pi_messenger({
85
85
  })
86
86
  ```
87
87
 
88
+ ## Shutdown Handling
89
+
90
+ If you receive a message saying "SHUTDOWN REQUESTED":
91
+ 1. Stop what you're doing
92
+ 2. Release reservations: `pi_messenger({ action: "release" })`
93
+ 3. Do NOT mark the task as done — leave it as in_progress for retry
94
+ 4. Do NOT commit anything
95
+ 5. Exit immediately
96
+
88
97
  ## Important Rules
89
98
 
90
99
  - ALWAYS join first, before any other pi_messenger calls
package/crew/agents.ts CHANGED
@@ -11,13 +11,14 @@ import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { discoverCrewAgents, type CrewAgentConfig } from "./utils/discover.js";
14
- import { truncateOutput, type MaxOutputConfig } from "./utils/truncate.js";
14
+ import { truncateOutput } from "./utils/truncate.js";
15
15
  import {
16
16
  createProgress,
17
17
  parseJsonlLine,
18
18
  updateProgress,
19
19
  getFinalOutput,
20
- type AgentProgress
20
+ type AgentProgress,
21
+ type PiEvent,
21
22
  } from "./utils/progress.js";
22
23
  import {
23
24
  getArtifactPaths,
@@ -27,6 +28,7 @@ import {
27
28
  appendJsonl
28
29
  } from "./utils/artifacts.js";
29
30
  import { loadCrewConfig, getTruncationForRole, type CrewConfig } from "./utils/config.js";
31
+ import { removeLiveWorker, updateLiveWorker } from "./live-progress.js";
30
32
  import type { AgentTask, AgentResult } from "./types.js";
31
33
 
32
34
  // Extension directory (parent of crew/) - passed to subagents so they can use pi_messenger
@@ -39,8 +41,55 @@ export interface SpawnOptions {
39
41
  onProgress?: (results: AgentResult[]) => void;
40
42
  crewDir?: string;
41
43
  signal?: AbortSignal;
44
+ messengerDirs?: { registry: string; inbox: string };
42
45
  }
43
46
 
47
+ export function resolveModel(
48
+ taskModel?: string,
49
+ paramModel?: string,
50
+ configModel?: string,
51
+ agentModel?: string,
52
+ ): string | undefined {
53
+ return taskModel ?? paramModel ?? configModel ?? agentModel;
54
+ }
55
+
56
+ export function raceTimeout(promise: Promise<void>, ms: number): Promise<boolean> {
57
+ return new Promise<boolean>(resolve => {
58
+ const timer = setTimeout(() => resolve(false), ms);
59
+ promise.then(
60
+ () => {
61
+ clearTimeout(timer);
62
+ resolve(true);
63
+ },
64
+ () => {
65
+ clearTimeout(timer);
66
+ resolve(false);
67
+ },
68
+ );
69
+ });
70
+ }
71
+
72
+ function discoverWorkerName(
73
+ pid: number | undefined,
74
+ registryDir: string | undefined
75
+ ): string | null {
76
+ if (!pid || !registryDir || !fs.existsSync(registryDir)) return null;
77
+ try {
78
+ for (const file of fs.readdirSync(registryDir)) {
79
+ if (!file.endsWith(".json")) continue;
80
+ const reg = JSON.parse(fs.readFileSync(path.join(registryDir, file), "utf-8"));
81
+ if (reg.pid === pid) return reg.name;
82
+ }
83
+ } catch {}
84
+ return null;
85
+ }
86
+
87
+ const SHUTDOWN_MESSAGE = `⚠️ SHUTDOWN REQUESTED: Please wrap up your current work.
88
+ 1. Release any file reservations
89
+ 2. If the task is not complete, leave it as in_progress (do NOT mark done)
90
+ 3. Do NOT commit anything
91
+ 4. Exit`;
92
+
44
93
  /**
45
94
  * Spawn multiple agents in parallel with concurrency limit.
46
95
  */
@@ -66,7 +115,10 @@ export async function spawnAgents(
66
115
  const running: Promise<void>[] = [];
67
116
 
68
117
  while (queue.length > 0 || running.length > 0) {
118
+ if (options.signal?.aborted && running.length === 0) break;
119
+
69
120
  while (running.length < concurrency && queue.length > 0) {
121
+ if (options.signal?.aborted) break;
70
122
  const { task, index } = queue.shift()!;
71
123
  const promise = runAgent(task, index, cwd, agents, config, runId, artifactsDir, options)
72
124
  .then(result => {
@@ -78,6 +130,7 @@ export async function spawnAgents(
78
130
  }
79
131
  if (running.length > 0) {
80
132
  await Promise.race(running);
133
+ if (options.signal?.aborted) continue;
81
134
  }
82
135
  }
83
136
 
@@ -117,7 +170,8 @@ async function runAgent(
117
170
  return new Promise((resolve) => {
118
171
  // Build args for pi command
119
172
  const args = ["--mode", "json", "--no-session", "-p"];
120
- if (agentConfig?.model) args.push("--model", agentConfig.model);
173
+ const model = task.modelOverride ?? agentConfig?.model;
174
+ if (model) args.push("--model", model);
121
175
 
122
176
  if (agentConfig?.tools?.length) {
123
177
  const builtinTools: string[] = [];
@@ -151,10 +205,21 @@ async function runAgent(
151
205
 
152
206
  args.push(task.task);
153
207
 
154
- const proc = spawn("pi", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
208
+ const envOverrides = config.work.env ?? {};
209
+ const env = Object.keys(envOverrides).length > 0
210
+ ? { ...process.env, ...envOverrides }
211
+ : undefined;
212
+
213
+ const proc = spawn("pi", args, {
214
+ cwd,
215
+ stdio: ["ignore", "pipe", "pipe"],
216
+ ...(env ? { env } : {}),
217
+ });
218
+ let gracefulShutdownRequested = false;
219
+ let discoveredWorkerName: string | null = null;
155
220
 
156
221
  let jsonlBuffer = "";
157
- const events: unknown[] = [];
222
+ const events: PiEvent[] = [];
158
223
 
159
224
  proc.stdout?.on("data", (data) => {
160
225
  jsonlBuffer += data.toString();
@@ -167,6 +232,17 @@ async function runAgent(
167
232
  events.push(event);
168
233
  updateProgress(progress, event, startTime);
169
234
  if (artifactPaths) appendJsonl(artifactPaths.jsonlPath, line);
235
+ if (task.taskId) {
236
+ updateLiveWorker(cwd, task.taskId, {
237
+ taskId: task.taskId,
238
+ agent: task.agent,
239
+ progress: {
240
+ ...progress,
241
+ recentTools: progress.recentTools.map(tool => ({ ...tool })),
242
+ },
243
+ startedAt: startTime,
244
+ });
245
+ }
170
246
  }
171
247
  }
172
248
  });
@@ -175,12 +251,13 @@ async function runAgent(
175
251
  proc.stderr?.on("data", (data) => { stderr += data.toString(); });
176
252
 
177
253
  proc.on("close", (code) => {
254
+ if (task.taskId) removeLiveWorker(cwd, task.taskId);
178
255
  progress.status = code === 0 ? "completed" : "failed";
179
256
  progress.durationMs = Date.now() - startTime;
180
257
  if (stderr && code !== 0) progress.error = stderr;
181
258
 
182
259
  // Get final output from events
183
- const fullOutput = getFinalOutput(events as any[]);
260
+ const fullOutput = getFinalOutput(events);
184
261
  const truncation = truncateOutput(fullOutput, maxOutput, artifactPaths?.outputPath);
185
262
 
186
263
  // Write output artifact (untruncated)
@@ -209,6 +286,8 @@ async function runAgent(
209
286
  truncated: truncation.truncated,
210
287
  progress,
211
288
  config: agentConfig,
289
+ taskId: task.taskId,
290
+ wasGracefullyShutdown: gracefulShutdownRequested,
212
291
  error: progress.error,
213
292
  artifactPaths: artifactPaths ? {
214
293
  input: artifactPaths.inputPath,
@@ -217,16 +296,66 @@ async function runAgent(
217
296
  metadata: artifactPaths.metadataPath,
218
297
  } : undefined,
219
298
  });
299
+
300
+ if (gracefulShutdownRequested && discoveredWorkerName && options.messengerDirs?.registry) {
301
+ try {
302
+ fs.unlinkSync(path.join(options.messengerDirs.registry, `${discoveredWorkerName}.json`));
303
+ } catch {}
304
+ }
220
305
  });
221
306
 
222
307
  // Handle abort signal
223
308
  if (options.signal) {
224
- const kill = () => {
225
- proc.kill("SIGTERM");
226
- setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
309
+ const gracefulShutdown = async () => {
310
+ gracefulShutdownRequested = true;
311
+
312
+ let messageSent = false;
313
+ discoveredWorkerName = discoverWorkerName(proc.pid, options.messengerDirs?.registry);
314
+ if (discoveredWorkerName && options.messengerDirs) {
315
+ try {
316
+ const inboxDir = path.join(options.messengerDirs.inbox, discoveredWorkerName);
317
+ if (fs.existsSync(inboxDir)) {
318
+ const msgFile = path.join(inboxDir, `${Date.now()}-shutdown.json`);
319
+ fs.writeFileSync(msgFile, JSON.stringify({
320
+ id: randomUUID(),
321
+ from: "crew-orchestrator",
322
+ to: discoveredWorkerName,
323
+ text: SHUTDOWN_MESSAGE,
324
+ timestamp: new Date().toISOString(),
325
+ replyTo: null,
326
+ }));
327
+ messageSent = true;
328
+ }
329
+ } catch {}
330
+ }
331
+
332
+ if (messageSent) {
333
+ const graceMs = config.work.shutdownGracePeriodMs ?? 30000;
334
+ const exitPromise = new Promise<void>(r => proc.once("exit", () => r()));
335
+ const exited = await raceTimeout(exitPromise, graceMs);
336
+ if (exited) return;
337
+ }
338
+
339
+ if (!proc.killed && proc.exitCode === null) {
340
+ proc.kill("SIGTERM");
341
+ const termPromise = new Promise<void>(r => proc.once("exit", () => r()));
342
+ const killed = await raceTimeout(termPromise, 5000);
343
+ if (killed) return;
344
+ } else {
345
+ return;
346
+ }
347
+
348
+ if (!proc.killed && proc.exitCode === null) {
349
+ proc.kill("SIGKILL");
350
+ }
227
351
  };
228
- if (options.signal.aborted) kill();
229
- else options.signal.addEventListener("abort", kill, { once: true });
352
+ if (options.signal.aborted) {
353
+ gracefulShutdown().catch(() => {});
354
+ } else {
355
+ options.signal.addEventListener("abort", () => {
356
+ gracefulShutdown().catch(() => {});
357
+ }, { once: true });
358
+ }
230
359
  }
231
360
  });
232
361
  }
@@ -8,7 +8,6 @@
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
11
- import type { MessengerState, Dirs } from "../../lib.js";
12
11
  import type { CrewParams } from "../types.js";
13
12
  import { result } from "../utils/result.js";
14
13
  import { spawnAgents } from "../agents.js";
@@ -108,8 +107,6 @@ function appendReviewToProgress(
108
107
 
109
108
  export async function execute(
110
109
  params: CrewParams,
111
- _state: MessengerState,
112
- _dirs: Dirs,
113
110
  ctx: ExtensionContext
114
111
  ) {
115
112
  const cwd = ctx.cwd ?? process.cwd();
@@ -185,7 +182,8 @@ export async function execute(
185
182
 
186
183
  const [plannerResult] = await spawnAgents([{
187
184
  agent: PLANNER_AGENT,
188
- task: plannerPrompt
185
+ task: plannerPrompt,
186
+ modelOverride: config.models?.planner,
189
187
  }], 1, cwd);
190
188
 
191
189
  if (plannerResult.exitCode !== 0) {
@@ -219,7 +217,8 @@ export async function execute(
219
217
 
220
218
  const [reviewResult] = await spawnAgents([{
221
219
  agent: "crew-reviewer",
222
- task: reviewPrompt
220
+ task: reviewPrompt,
221
+ modelOverride: config.models?.reviewer,
223
222
  }], 1, cwd);
224
223
 
225
224
  if (reviewResult.exitCode !== 0) {
@@ -7,22 +7,22 @@
7
7
 
8
8
  import { execSync } from "node:child_process";
9
9
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
10
- import type { MessengerState, Dirs } from "../../lib.js";
11
10
  import type { CrewParams } from "../types.js";
12
11
  import { result } from "../utils/result.js";
13
12
  import { spawnAgents } from "../agents.js";
14
13
  import { discoverCrewAgents } from "../utils/discover.js";
14
+ import { loadCrewConfig } from "../utils/config.js";
15
15
  import { parseVerdict, type ParsedReview } from "../utils/verdict.js";
16
16
  import * as store from "../store.js";
17
17
 
18
18
  export async function execute(
19
19
  params: CrewParams,
20
- _state: MessengerState,
21
- _dirs: Dirs,
22
20
  ctx: ExtensionContext
23
21
  ) {
24
22
  const cwd = ctx.cwd ?? process.cwd();
25
23
  const { target, type } = params;
24
+ const config = loadCrewConfig(store.getCrewDir(cwd));
25
+ const reviewerModel = config.models?.reviewer;
26
26
 
27
27
  if (!target) {
28
28
  return result("Error: target (task ID) required for review action.\n\nUsage: pi_messenger({ action: \"review\", target: \"task-1\" })", {
@@ -45,9 +45,9 @@ export async function execute(
45
45
  const reviewType = type ?? (target.startsWith("task-") ? "impl" : "plan");
46
46
 
47
47
  if (reviewType === "impl") {
48
- return reviewImplementation(cwd, target);
48
+ return reviewImplementation(cwd, target, reviewerModel);
49
49
  } else {
50
- return reviewPlan(cwd);
50
+ return reviewPlan(cwd, reviewerModel);
51
51
  }
52
52
  }
53
53
 
@@ -55,7 +55,7 @@ export async function execute(
55
55
  // Implementation Review
56
56
  // =============================================================================
57
57
 
58
- async function reviewImplementation(cwd: string, taskId: string) {
58
+ async function reviewImplementation(cwd: string, taskId: string, modelOverride?: string) {
59
59
  const task = store.getTask(cwd, taskId);
60
60
  if (!task) {
61
61
  return result(`Error: Task ${taskId} not found.`, {
@@ -120,7 +120,8 @@ Output your verdict as SHIP, NEEDS_WORK, or MAJOR_RETHINK with detailed feedback
120
120
  // Spawn reviewer
121
121
  const [reviewResult] = await spawnAgents([{
122
122
  agent: "crew-reviewer",
123
- task: prompt
123
+ task: prompt,
124
+ modelOverride,
124
125
  }], 1, cwd);
125
126
 
126
127
  if (reviewResult.exitCode !== 0) {
@@ -170,7 +171,7 @@ ${verdict.verdict === "SHIP" ? "✅ Ready to merge!" : verdict.verdict === "NEED
170
171
  // Plan Review
171
172
  // =============================================================================
172
173
 
173
- async function reviewPlan(cwd: string) {
174
+ async function reviewPlan(cwd: string, modelOverride?: string) {
174
175
  const plan = store.getPlan(cwd);
175
176
  if (!plan) {
176
177
  return result("Error: No plan found.", {
@@ -224,7 +225,8 @@ Output your verdict as SHIP (plan is solid), NEEDS_WORK (minor adjustments), or
224
225
  // Spawn reviewer
225
226
  const [reviewResult] = await spawnAgents([{
226
227
  agent: "crew-reviewer",
227
- task: prompt
228
+ task: prompt,
229
+ modelOverride,
228
230
  }], 1, cwd);
229
231
 
230
232
  if (reviewResult.exitCode !== 0) {
@@ -6,10 +6,10 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
9
- import type { MessengerState, Dirs } from "../../lib.js";
9
+ import type { Dirs } from "../../lib.js";
10
10
  import type { CrewParams, AppendEntryFn, Task } from "../types.js";
11
11
  import { result } from "../utils/result.js";
12
- import { spawnAgents } from "../agents.js";
12
+ import { resolveModel, spawnAgents } from "../agents.js";
13
13
  import { loadCrewConfig } from "../utils/config.js";
14
14
  import { discoverCrewAgents } from "../utils/discover.js";
15
15
  import * as store from "../store.js";
@@ -18,10 +18,10 @@ import { autonomousState, startAutonomous, stopAutonomous, addWaveResult } from
18
18
 
19
19
  export async function execute(
20
20
  params: CrewParams,
21
- _state: MessengerState,
22
- _dirs: Dirs,
21
+ dirs: Dirs,
23
22
  ctx: ExtensionContext,
24
- appendEntry: AppendEntryFn
23
+ appendEntry: AppendEntryFn,
24
+ signal?: AbortSignal
25
25
  ) {
26
26
  const cwd = ctx.cwd ?? process.cwd();
27
27
  const config = loadCrewConfig(getCrewDir(cwd));
@@ -87,15 +87,30 @@ export async function execute(
87
87
  }
88
88
 
89
89
  // Spawn workers
90
- const workerTasks = tasksToRun.map(task => ({
91
- agent: "crew-worker",
92
- task: buildWorkerPrompt(task, plan.prd, cwd)
93
- }));
90
+ const workerTasks = tasksToRun.map(task => {
91
+ const taskModel = resolveModel(
92
+ task.model,
93
+ params.model,
94
+ config.models?.worker,
95
+ undefined,
96
+ );
97
+
98
+ return {
99
+ agent: "crew-worker",
100
+ task: buildWorkerPrompt(task, plan.prd, cwd),
101
+ taskId: task.id,
102
+ modelOverride: taskModel,
103
+ };
104
+ });
94
105
 
95
106
  const workerResults = await spawnAgents(
96
107
  workerTasks,
97
108
  concurrency,
98
- cwd
109
+ cwd,
110
+ {
111
+ signal,
112
+ messengerDirs: { registry: dirs.registry, inbox: dirs.inbox },
113
+ }
99
114
  );
100
115
 
101
116
  // Process results
@@ -105,22 +120,33 @@ export async function execute(
105
120
 
106
121
  for (let i = 0; i < workerResults.length; i++) {
107
122
  const r = workerResults[i];
108
- const taskId = tasksToRun[i].id;
123
+ const taskId = r.taskId;
124
+ if (!taskId) {
125
+ failed.push(`unknown-result-${i}`);
126
+ continue;
127
+ }
109
128
  const task = store.getTask(cwd, taskId);
110
129
 
111
130
  if (r.exitCode === 0) {
112
- // Check if task was completed (worker should call task.done)
113
131
  if (task?.status === "done") {
114
132
  succeeded.push(taskId);
115
133
  } else if (task?.status === "blocked") {
116
134
  blocked.push(taskId);
135
+ } else if (r.wasGracefullyShutdown && task?.status === "in_progress") {
136
+ store.updateTask(cwd, taskId, { status: "todo", assigned_to: undefined });
117
137
  } else {
118
- // Worker finished but didn't complete - treat as failure
119
138
  failed.push(taskId);
120
139
  }
121
140
  } else {
122
- // Auto-block on failure if in autonomous mode
123
- if (autonomous && task?.status === "in_progress") {
141
+ if (r.wasGracefullyShutdown) {
142
+ if (task?.status === "done") {
143
+ succeeded.push(taskId);
144
+ } else if (task?.status === "blocked") {
145
+ blocked.push(taskId);
146
+ } else if (task?.status === "in_progress") {
147
+ store.updateTask(cwd, taskId, { status: "todo", assigned_to: undefined });
148
+ }
149
+ } else if (autonomous && task?.status === "in_progress") {
124
150
  store.blockTask(cwd, taskId, `Worker failed: ${r.error ?? "Unknown error"}`);
125
151
  blocked.push(taskId);
126
152
  } else {
@@ -142,37 +168,40 @@ export async function execute(
142
168
  timestamp: new Date().toISOString()
143
169
  });
144
170
 
145
- // Check if we should continue
146
- const nextReady = store.getReadyTasks(cwd);
147
- const allTasks = store.getTasks(cwd);
148
- const allDone = allTasks.every(t => t.status === "done");
149
- const allBlockedOrDone = allTasks.every(t => t.status === "done" || t.status === "blocked");
150
-
151
- if (allDone) {
152
- stopAutonomous("completed");
171
+ if (signal?.aborted) {
172
+ stopAutonomous("manual");
153
173
  appendEntry("crew-state", autonomousState);
154
- appendEntry("crew_wave_complete", {
155
- prd: plan.prd,
156
- status: "completed",
157
- totalWaves: currentWave,
158
- totalTasks: allTasks.length
159
- });
160
- } else if (allBlockedOrDone || nextReady.length === 0) {
161
- stopAutonomous("blocked");
162
- appendEntry("crew-state", autonomousState);
163
- appendEntry("crew_wave_blocked", {
164
- prd: plan.prd,
165
- status: "blocked",
166
- blockedTasks: allTasks.filter(t => t.status === "blocked").map(t => t.id)
167
- });
168
174
  } else {
169
- // Persist state for session recovery and signal continuation
170
- appendEntry("crew-state", autonomousState);
171
- appendEntry("crew_wave_continue", {
172
- prd: plan.prd,
173
- nextWave: autonomousState.waveNumber,
174
- readyTasks: nextReady.map(t => t.id)
175
- });
175
+ const nextReady = store.getReadyTasks(cwd);
176
+ const allTasks = store.getTasks(cwd);
177
+ const allDone = allTasks.every(t => t.status === "done");
178
+ const allBlockedOrDone = allTasks.every(t => t.status === "done" || t.status === "blocked");
179
+
180
+ if (allDone) {
181
+ stopAutonomous("completed");
182
+ appendEntry("crew-state", autonomousState);
183
+ appendEntry("crew_wave_complete", {
184
+ prd: plan.prd,
185
+ status: "completed",
186
+ totalWaves: currentWave,
187
+ totalTasks: allTasks.length
188
+ });
189
+ } else if (allBlockedOrDone || nextReady.length === 0) {
190
+ stopAutonomous("blocked");
191
+ appendEntry("crew-state", autonomousState);
192
+ appendEntry("crew_wave_blocked", {
193
+ prd: plan.prd,
194
+ status: "blocked",
195
+ blockedTasks: allTasks.filter(t => t.status === "blocked").map(t => t.id)
196
+ });
197
+ } else {
198
+ appendEntry("crew-state", autonomousState);
199
+ appendEntry("crew_wave_continue", {
200
+ prd: plan.prd,
201
+ nextWave: autonomousState.waveNumber,
202
+ readyTasks: nextReady.map(t => t.id)
203
+ });
204
+ }
176
205
  }
177
206
  }
178
207
 
@@ -191,6 +220,11 @@ export async function execute(
191
220
  const nextText = nextReady.length > 0
192
221
  ? `\n\n**Ready for next wave:** ${nextReady.map(t => t.id).join(", ")}`
193
222
  : "";
223
+ const continueText = autonomous && !signal?.aborted && nextReady.length > 0
224
+ ? "Autonomous mode: Continuing to next wave..."
225
+ : signal?.aborted && autonomous
226
+ ? "Autonomous mode stopped (cancelled)."
227
+ : "";
194
228
 
195
229
  const text = `# Work Wave ${currentWave}
196
230
 
@@ -199,7 +233,7 @@ export async function execute(
199
233
  **Progress:** ${progress}
200
234
  ${statusText}${nextText}
201
235
 
202
- ${autonomous && nextReady.length > 0 ? "Autonomous mode: Continuing to next wave..." : ""}`;
236
+ ${continueText}`;
203
237
 
204
238
  return result(text, {
205
239
  mode: "work",
package/crew/index.ts CHANGED
@@ -42,7 +42,8 @@ export async function executeCrewAction(
42
42
  deliverMessage: DeliverFn,
43
43
  updateStatus: UpdateStatusFn,
44
44
  appendEntry: AppendEntryFn,
45
- config?: CrewActionConfig
45
+ config?: CrewActionConfig,
46
+ signal?: AbortSignal
46
47
  ) {
47
48
  // Parse action: "task.show" → group="task", op="show"
48
49
  const dotIndex = action.indexOf('.');
@@ -177,7 +178,7 @@ export async function executeCrewAction(
177
178
  ensureCrewInstalled();
178
179
  try {
179
180
  const planHandler = await import("./handlers/plan.js");
180
- return planHandler.execute(params, state, dirs, ctx);
181
+ return planHandler.execute(params, ctx);
181
182
  } catch (e) {
182
183
  return result(`Error: plan handler failed: ${e instanceof Error ? e.message : 'unknown'}`,
183
184
  { mode: "plan", error: "handler_error" });
@@ -189,7 +190,7 @@ export async function executeCrewAction(
189
190
  ensureCrewInstalled();
190
191
  try {
191
192
  const workHandler = await import("./handlers/work.js");
192
- return workHandler.execute(params, state, dirs, ctx, appendEntry);
193
+ return workHandler.execute(params, dirs, ctx, appendEntry, signal);
193
194
  } catch (e) {
194
195
  return result(`Error: work handler failed: ${e instanceof Error ? e.message : 'unknown'}`,
195
196
  { mode: "work", error: "handler_error" });
@@ -201,7 +202,7 @@ export async function executeCrewAction(
201
202
  ensureCrewInstalled();
202
203
  try {
203
204
  const reviewHandler = await import("./handlers/review.js");
204
- return reviewHandler.execute(params, state, dirs, ctx);
205
+ return reviewHandler.execute(params, ctx);
205
206
  } catch (e) {
206
207
  return result(`Error: review handler failed: ${e instanceof Error ? e.message : 'unknown'}`,
207
208
  { mode: "review", error: "handler_error" });