@os-eco/overstory-cli 0.9.4 → 0.10.3

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.
Files changed (110) hide show
  1. package/README.md +47 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +54 -0
  26. package/src/commands/coordinator.test.ts +127 -0
  27. package/src/commands/coordinator.ts +203 -5
  28. package/src/commands/dashboard.test.ts +188 -0
  29. package/src/commands/dashboard.ts +13 -3
  30. package/src/commands/doctor.ts +3 -1
  31. package/src/commands/group.test.ts +94 -0
  32. package/src/commands/group.ts +49 -20
  33. package/src/commands/init.test.ts +8 -0
  34. package/src/commands/init.ts +8 -1
  35. package/src/commands/log.test.ts +56 -11
  36. package/src/commands/log.ts +134 -69
  37. package/src/commands/mail.test.ts +162 -0
  38. package/src/commands/mail.ts +64 -9
  39. package/src/commands/merge.test.ts +112 -1
  40. package/src/commands/merge.ts +17 -4
  41. package/src/commands/nudge.test.ts +351 -4
  42. package/src/commands/nudge.ts +356 -34
  43. package/src/commands/run.test.ts +43 -7
  44. package/src/commands/serve/build.test.ts +202 -0
  45. package/src/commands/serve/build.ts +206 -0
  46. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  47. package/src/commands/serve/coordinator-actions.ts +408 -0
  48. package/src/commands/serve/dev.test.ts +168 -0
  49. package/src/commands/serve/dev.ts +117 -0
  50. package/src/commands/serve/mail-actions.test.ts +312 -0
  51. package/src/commands/serve/mail-actions.ts +167 -0
  52. package/src/commands/serve/rest.test.ts +1323 -0
  53. package/src/commands/serve/rest.ts +708 -0
  54. package/src/commands/serve/static.ts +51 -0
  55. package/src/commands/serve/ws.test.ts +361 -0
  56. package/src/commands/serve/ws.ts +332 -0
  57. package/src/commands/serve.test.ts +459 -0
  58. package/src/commands/serve.ts +565 -0
  59. package/src/commands/sling.test.ts +73 -1
  60. package/src/commands/sling.ts +149 -64
  61. package/src/commands/status.test.ts +9 -0
  62. package/src/commands/status.ts +12 -4
  63. package/src/commands/stop.test.ts +174 -1
  64. package/src/commands/stop.ts +107 -8
  65. package/src/commands/watch.test.ts +43 -0
  66. package/src/commands/watch.ts +153 -28
  67. package/src/config.ts +23 -0
  68. package/src/doctor/consistency.test.ts +106 -0
  69. package/src/doctor/consistency.ts +48 -1
  70. package/src/doctor/serve.test.ts +95 -0
  71. package/src/doctor/serve.ts +86 -0
  72. package/src/doctor/types.ts +2 -1
  73. package/src/doctor/watchdog.ts +57 -1
  74. package/src/events/tailer.test.ts +234 -1
  75. package/src/events/tailer.ts +90 -0
  76. package/src/index.ts +53 -6
  77. package/src/json.ts +29 -0
  78. package/src/mail/client.ts +15 -2
  79. package/src/mail/store.test.ts +82 -0
  80. package/src/mail/store.ts +41 -4
  81. package/src/merge/lock.test.ts +149 -0
  82. package/src/merge/lock.ts +140 -0
  83. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  84. package/src/runtimes/claude.test.ts +791 -1
  85. package/src/runtimes/claude.ts +323 -1
  86. package/src/runtimes/connections.test.ts +141 -1
  87. package/src/runtimes/connections.ts +73 -4
  88. package/src/runtimes/headless-connection.test.ts +264 -0
  89. package/src/runtimes/headless-connection.ts +158 -0
  90. package/src/runtimes/types.ts +10 -0
  91. package/src/schema-consistency.test.ts +1 -0
  92. package/src/sessions/store.test.ts +390 -24
  93. package/src/sessions/store.ts +184 -19
  94. package/src/test-setup.test.ts +31 -0
  95. package/src/test-setup.ts +28 -0
  96. package/src/types.ts +56 -1
  97. package/src/utils/pid.test.ts +85 -1
  98. package/src/utils/pid.ts +86 -1
  99. package/src/utils/process-scan.test.ts +53 -0
  100. package/src/utils/process-scan.ts +76 -0
  101. package/src/watchdog/daemon.test.ts +1520 -411
  102. package/src/watchdog/daemon.ts +442 -83
  103. package/src/watchdog/health.test.ts +157 -0
  104. package/src/watchdog/health.ts +92 -25
  105. package/src/worktree/process.test.ts +71 -0
  106. package/src/worktree/process.ts +25 -5
  107. package/src/worktree/tmux.test.ts +3 -0
  108. package/src/worktree/tmux.ts +10 -3
  109. package/templates/CLAUDE.md.tmpl +19 -8
  110. package/templates/overlay.md.tmpl +3 -2
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { join } from "node:path";
14
14
  import { Command } from "commander";
15
+ import { isStopHookPersistentCapability } from "../agents/capabilities.ts";
15
16
  import { updateIdentity } from "../agents/identity.ts";
16
17
  import { loadConfig } from "../config.ts";
17
18
  import { ValidationError } from "../errors.ts";
@@ -66,8 +67,12 @@ function updateLastActivity(projectRoot: string, agentName: string): void {
66
67
  const session = store.getByName(agentName);
67
68
  if (session) {
68
69
  store.updateLastActivity(agentName);
69
- if (session.state === "booting" || session.state === "zombie") {
70
- store.updateState(agentName, "working");
70
+ // Tool-use observed: try booting working. Matrix-guarded so a
71
+ // zombie classification (set by watchdog) is NOT silently revived
72
+ // here — that revival was a contributor to the schizophrenic
73
+ // state=zombie + tool-use-active symptom in overstory-a993.
74
+ if (session.state === "booting") {
75
+ store.tryTransitionState(agentName, "working");
71
76
  }
72
77
  }
73
78
  } finally {
@@ -79,63 +84,144 @@ function updateLastActivity(projectRoot: string, agentName: string): void {
79
84
  }
80
85
 
81
86
  /**
82
- * Agent capabilities that run as persistent interactive sessions.
83
- * The Stop hook fires every turn for these agents (not just at session end),
84
- * so they must NOT auto-transition to 'completed' on session-end events.
87
+ * Maximum retry attempts for the session-end transition.
88
+ *
89
+ * The Stop hook is the only signal that turns sessions.db state from
90
+ * "working" to "completed" for headless legacy paths and tmux sessions.
91
+ * If it loses that signal due to a transient SQLite contention error
92
+ * (e.g. "database is locked" while the watchdog ticks against the same
93
+ * file), the row stays in "working" forever and the watchdog later
94
+ * promotes it to "zombie". Retrying with exponential backoff lets brief
95
+ * lock contention resolve before we give up. (overstory-e74b)
85
96
  */
86
- const PERSISTENT_CAPABILITIES = new Set(["coordinator", "orchestrator", "monitor"]);
97
+ const TRANSITION_MAX_ATTEMPTS = 5;
98
+ const TRANSITION_BACKOFF_BASE_MS = 50;
87
99
 
88
100
  /**
89
- * Transition agent state to 'completed' in the SessionStore.
90
- * Called when session-end event fires.
91
- *
92
- * Skips the transition for persistent agent types (coordinator, orchestrator, monitor)
93
- * whose Stop hook fires every turn, not just at true session end.
101
+ * One attempt at the session-end state transition.
94
102
  *
95
- * Non-fatal: silently ignores errors to avoid breaking hook execution.
103
+ * Throws on transient failures (e.g. SQLite "database is locked") so the
104
+ * caller can retry. The body is the original logic from
105
+ * `transitionToCompleted`.
96
106
  */
97
- function transitionToCompleted(projectRoot: string, agentName: string): void {
107
+ function transitionToCompletedOnce(projectRoot: string, agentName: string): void {
108
+ const overstoryDir = join(projectRoot, ".overstory");
109
+ const { store } = openSessionStore(overstoryDir);
98
110
  try {
99
- const overstoryDir = join(projectRoot, ".overstory");
100
- const { store } = openSessionStore(overstoryDir);
101
- try {
102
- const session = store.getByName(agentName);
103
- if (session && PERSISTENT_CAPABILITIES.has(session.capability)) {
104
- // Check if a persistent top-level agent self-exited by verifying the run
105
- // is already completed.
106
- // If `ov run complete` was called before session-end, the run status is 'completed'
107
- // and we should transition the persistent session to completed too.
108
- if (
109
- (session.capability === "coordinator" || session.capability === "orchestrator") &&
110
- session.runId
111
- ) {
112
- const runStore = createRunStore(join(overstoryDir, "sessions.db"));
113
- try {
114
- const run = runStore.getRun(session.runId);
115
- if (run && run.status === "completed") {
116
- // Self-exit: the persistent agent called ov run complete before session ended
117
- store.updateState(agentName, "completed");
118
- store.updateLastActivity(agentName);
119
- return;
120
- }
121
- } finally {
122
- runStore.close();
111
+ const session = store.getByName(agentName);
112
+ if (session && isStopHookPersistentCapability(session.capability)) {
113
+ // Check if a persistent top-level agent self-exited by verifying the run
114
+ // is already completed.
115
+ // If `ov run complete` was called before session-end, the run status is 'completed'
116
+ // and we should transition the persistent session to completed too.
117
+ if (
118
+ (session.capability === "coordinator" || session.capability === "orchestrator") &&
119
+ session.runId
120
+ ) {
121
+ const runStore = createRunStore(join(overstoryDir, "sessions.db"));
122
+ try {
123
+ const run = runStore.getRun(session.runId);
124
+ if (run && run.status === "completed") {
125
+ // Self-exit: the persistent agent called ov run complete before session ended
126
+ store.updateState(agentName, "completed");
127
+ store.updateLastActivity(agentName);
128
+ return;
123
129
  }
130
+ } finally {
131
+ runStore.close();
124
132
  }
125
- // Normal persistent agent: only update activity, don't mark completed
126
- store.updateLastActivity(agentName);
127
- return;
128
133
  }
129
- store.updateState(agentName, "completed");
134
+ // Normal persistent agent: only update activity, don't mark completed
130
135
  store.updateLastActivity(agentName);
136
+ return;
137
+ }
138
+ store.updateState(agentName, "completed");
139
+ store.updateLastActivity(agentName);
140
+ } finally {
141
+ store.close();
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Best-effort: log a session-end hook failure to events.db so it surfaces in
147
+ * `ov errors` and trace timelines. Swallows secondary errors (events.db may
148
+ * also be locked when the primary write failed).
149
+ */
150
+ async function logHookFailure(
151
+ projectRoot: string,
152
+ agentName: string,
153
+ hookName: string,
154
+ error: unknown,
155
+ attempts: number,
156
+ ): Promise<void> {
157
+ try {
158
+ const eventsDbPath = join(projectRoot, ".overstory", "events.db");
159
+ const eventStore = createEventStore(eventsDbPath);
160
+ try {
161
+ eventStore.insert({
162
+ runId: null,
163
+ agentName,
164
+ sessionId: null,
165
+ eventType: "error",
166
+ toolName: null,
167
+ toolArgs: null,
168
+ toolDurationMs: null,
169
+ level: "error",
170
+ data: JSON.stringify({
171
+ hook: hookName,
172
+ attempts,
173
+ message: error instanceof Error ? error.message : String(error),
174
+ }),
175
+ });
131
176
  } finally {
132
- store.close();
177
+ eventStore.close();
133
178
  }
134
179
  } catch {
135
- // Non-fatal: don't break logging if session update fails
180
+ // Non-fatal: events.db may also be unavailable when the primary write failed.
136
181
  }
137
182
  }
138
183
 
184
+ /**
185
+ * Transition agent state to 'completed' in the SessionStore.
186
+ * Called when session-end event fires.
187
+ *
188
+ * Retries on transient SQLite contention with exponential backoff
189
+ * (50/100/200/400/800ms). On persistent failure, records an `error` event
190
+ * to events.db so the missed signal shows up in observability tooling and
191
+ * the watchdog's stale-but-tmux-dead fallback can recognize it.
192
+ * (overstory-e74b)
193
+ *
194
+ * Skips the transition for capabilities in `STOP_HOOK_PERSISTENT_CAPABILITIES`
195
+ * (coordinator, orchestrator, monitor, lead) whose Stop hook fires every model
196
+ * turn rather than once at true session end. See
197
+ * `src/agents/capabilities.ts` for the full rationale and consumer list.
198
+ *
199
+ * Non-fatal: silently ignores errors to avoid breaking hook execution.
200
+ */
201
+ async function transitionToCompleted(projectRoot: string, agentName: string): Promise<void> {
202
+ let lastError: unknown;
203
+ for (let attempt = 0; attempt < TRANSITION_MAX_ATTEMPTS; attempt++) {
204
+ try {
205
+ transitionToCompletedOnce(projectRoot, agentName);
206
+ return;
207
+ } catch (err) {
208
+ lastError = err;
209
+ if (attempt < TRANSITION_MAX_ATTEMPTS - 1) {
210
+ await Bun.sleep(TRANSITION_BACKOFF_BASE_MS * 2 ** attempt);
211
+ }
212
+ }
213
+ }
214
+
215
+ // All retries failed — surface the missed signal via events.db.
216
+ await logHookFailure(
217
+ projectRoot,
218
+ agentName,
219
+ "session-end:transitionToCompleted",
220
+ lastError,
221
+ TRANSITION_MAX_ATTEMPTS,
222
+ );
223
+ }
224
+
139
225
  /**
140
226
  * Look up an agent's session record.
141
227
  * Returns null if not found.
@@ -629,8 +715,9 @@ async function runLog(opts: {
629
715
  }
630
716
  case "session-end":
631
717
  logger.info("session.end", { agentName: opts.agent });
632
- // Transition agent state to completed
633
- transitionToCompleted(config.project.root, opts.agent);
718
+ // Transition agent state to completed (with retry/backoff and
719
+ // events.db fallback on persistent failure — overstory-e74b).
720
+ await transitionToCompleted(config.project.root, opts.agent);
634
721
  // Look up agent session for identity update and metrics recording
635
722
  {
636
723
  const agentSession = getAgentSession(config.project.root, opts.agent);
@@ -647,28 +734,6 @@ async function runLog(opts: {
647
734
  // Non-fatal: identity may not exist for this agent
648
735
  }
649
736
 
650
- // Auto-nudge coordinator when a lead completes so it wakes up
651
- // to process merge_ready / worker_done messages without waiting
652
- // for user input (see decision mx-728f8d).
653
- if (agentSession?.capability === "lead") {
654
- try {
655
- const nudgesDir = join(config.project.root, ".overstory", "pending-nudges");
656
- const { mkdir } = await import("node:fs/promises");
657
- await mkdir(nudgesDir, { recursive: true });
658
- const markerPath = join(nudgesDir, "coordinator.json");
659
- const marker = {
660
- from: opts.agent,
661
- reason: "lead_completed",
662
- subject: `Lead ${opts.agent} completed — check mail for merge_ready/worker_done`,
663
- messageId: `auto-nudge-${opts.agent}-${Date.now()}`,
664
- createdAt: new Date().toISOString(),
665
- };
666
- await Bun.write(markerPath, `${JSON.stringify(marker, null, "\t")}\n`);
667
- } catch {
668
- // Non-fatal: nudge failure should not break session-end
669
- }
670
- }
671
-
672
737
  // Record session metrics (with optional token data from transcript)
673
738
  if (agentSession) {
674
739
  // NOTE: We intentionally do NOT auto-complete the run here for coordinator agents.
@@ -730,7 +795,7 @@ async function runLog(opts: {
730
795
 
731
796
  // Auto-record expertise via mulch learn + record (post-session).
732
797
  // Skip persistent agents whose Stop hook fires every turn.
733
- if (!PERSISTENT_CAPABILITIES.has(agentSession.capability)) {
798
+ if (!isStopHookPersistentCapability(agentSession.capability)) {
734
799
  try {
735
800
  const mulchClient = createMulchClient(config.project.root);
736
801
  const mailDbPath = join(config.project.root, ".overstory", "mail.db");
@@ -751,7 +816,7 @@ async function runLog(opts: {
751
816
 
752
817
  // Append outcomes to applied mulch records (outcome feedback loop).
753
818
  // Reads applied-records.json written by sling.ts at spawn time.
754
- if (!PERSISTENT_CAPABILITIES.has(agentSession.capability)) {
819
+ if (!isStopHookPersistentCapability(agentSession.capability)) {
755
820
  try {
756
821
  const mulchClient = createMulchClient(config.project.root);
757
822
  await appendOutcomeToAppliedRecords({
@@ -118,6 +118,54 @@ describe("mailCommand", () => {
118
118
  expect(output).toContain("Explore API");
119
119
  expect(output).toContain("Total: 2 messages");
120
120
  });
121
+
122
+ test("--type filters by message type", async () => {
123
+ // Add a typed message to the seeded inbox
124
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
125
+ const client = createMailClient(store);
126
+ client.send({
127
+ from: "lead-x",
128
+ to: "coordinator",
129
+ subject: "merge_ready: t1",
130
+ body: "ready to merge",
131
+ type: "merge_ready",
132
+ });
133
+ client.close();
134
+
135
+ await mailCommand(["list", "--type", "merge_ready"]);
136
+ expect(output).toContain("merge_ready: t1");
137
+ expect(output).not.toContain("Build task");
138
+ expect(output).not.toContain("Explore API");
139
+ expect(output).toContain("Total: 1 message");
140
+ });
141
+
142
+ test("--type combined with --from filters by both", async () => {
143
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
144
+ const client = createMailClient(store);
145
+ client.send({
146
+ from: "lead-x",
147
+ to: "coordinator",
148
+ subject: "merge_ready: t1",
149
+ body: "ready",
150
+ type: "merge_ready",
151
+ });
152
+ client.send({
153
+ from: "lead-y",
154
+ to: "coordinator",
155
+ subject: "merge_ready: t2",
156
+ body: "ready",
157
+ type: "merge_ready",
158
+ });
159
+ client.close();
160
+
161
+ await mailCommand(["list", "--from", "lead-x", "--type", "merge_ready"]);
162
+ expect(output).toContain("merge_ready: t1");
163
+ expect(output).not.toContain("merge_ready: t2");
164
+ });
165
+
166
+ test("--type rejects invalid type with ValidationError", async () => {
167
+ await expect(mailCommand(["list", "--type", "bogus"])).rejects.toThrow(/Invalid --type/);
168
+ });
121
169
  });
122
170
 
123
171
  describe("reply", () => {
@@ -1274,6 +1322,120 @@ describe("mailCommand", () => {
1274
1322
  expect(stderrOutput).toBe("");
1275
1323
  });
1276
1324
  });
1325
+
1326
+ describe("terminal-state recipient rejection (overstory-f5be)", () => {
1327
+ async function seedRecipient(name: string, state: "working" | "completed" | "zombie") {
1328
+ const { createSessionStore } = await import("../sessions/store.ts");
1329
+ const sessionsDbPath = join(tempDir, ".overstory", "sessions.db");
1330
+ const sessionStore = createSessionStore(sessionsDbPath);
1331
+ sessionStore.upsert({
1332
+ id: `session-${name}`,
1333
+ agentName: name,
1334
+ capability: "builder",
1335
+ worktreePath: `/worktrees/${name}`,
1336
+ branchName: name,
1337
+ taskId: "bead-x",
1338
+ tmuxSession: `overstory-test-${name}`,
1339
+ state,
1340
+ pid: 99999,
1341
+ parentAgent: "orchestrator",
1342
+ depth: 1,
1343
+ runId: "run-001",
1344
+ startedAt: new Date().toISOString(),
1345
+ lastActivity: new Date().toISOString(),
1346
+ escalationLevel: 0,
1347
+ stalledSince: null,
1348
+ transcriptPath: null,
1349
+ });
1350
+ sessionStore.close();
1351
+ }
1352
+
1353
+ test("rejects send to recipient in completed state", async () => {
1354
+ await seedRecipient("dead-builder", "completed");
1355
+
1356
+ let caught: unknown;
1357
+ try {
1358
+ await mailCommand([
1359
+ "send",
1360
+ "--to",
1361
+ "dead-builder",
1362
+ "--subject",
1363
+ "Hello",
1364
+ "--body",
1365
+ "Are you there?",
1366
+ ]);
1367
+ } catch (err) {
1368
+ caught = err;
1369
+ }
1370
+
1371
+ expect(caught).toBeDefined();
1372
+ expect((caught as Error).name).toBe("MailError");
1373
+ expect((caught as Error).message).toContain("dead-builder");
1374
+ expect((caught as Error).message).toContain("completed");
1375
+
1376
+ // Confirm no message was inserted
1377
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
1378
+ const client = createMailClient(store);
1379
+ const messages = client.list({ to: "dead-builder" });
1380
+ expect(messages.length).toBe(0);
1381
+ client.close();
1382
+ });
1383
+
1384
+ test("rejects send to recipient in zombie state", async () => {
1385
+ await seedRecipient("crashed-builder", "zombie");
1386
+
1387
+ let caught: unknown;
1388
+ try {
1389
+ await mailCommand([
1390
+ "send",
1391
+ "--to",
1392
+ "crashed-builder",
1393
+ "--subject",
1394
+ "Status?",
1395
+ "--body",
1396
+ "Ping",
1397
+ ]);
1398
+ } catch (err) {
1399
+ caught = err;
1400
+ }
1401
+
1402
+ expect(caught).toBeDefined();
1403
+ expect((caught as Error).name).toBe("MailError");
1404
+ expect((caught as Error).message).toContain("zombie");
1405
+ });
1406
+
1407
+ test("allows send when recipient has no session row (e.g. orchestrator)", async () => {
1408
+ // No session seeded for "orchestrator" — the existing beforeEach
1409
+ // only inserts mail rows, not session rows.
1410
+ await mailCommand([
1411
+ "send",
1412
+ "--to",
1413
+ "orchestrator",
1414
+ "--subject",
1415
+ "Hello",
1416
+ "--body",
1417
+ "Top-level role",
1418
+ ]);
1419
+
1420
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
1421
+ const client = createMailClient(store);
1422
+ const messages = client.list({ to: "orchestrator" });
1423
+ expect(messages.length).toBeGreaterThanOrEqual(1);
1424
+ client.close();
1425
+ });
1426
+
1427
+ test("allows send to active (working) recipient", async () => {
1428
+ await seedRecipient("live-builder", "working");
1429
+
1430
+ await mailCommand(["send", "--to", "live-builder", "--subject", "Hello", "--body", "Active"]);
1431
+
1432
+ const store = createMailStore(join(tempDir, ".overstory", "mail.db"));
1433
+ const client = createMailClient(store);
1434
+ const messages = client.list({ to: "live-builder" });
1435
+ expect(messages.length).toBe(1);
1436
+ client.close();
1437
+ });
1438
+ });
1277
1439
  });
1278
1440
 
1279
1441
  describe("shouldAutoNudge", () => {
@@ -7,9 +7,9 @@
7
7
  */
8
8
 
9
9
  import { join } from "node:path";
10
- import { Command } from "commander";
10
+ import { Command, CommanderError } from "commander";
11
11
  import { resolveProjectRoot } from "../config.ts";
12
- import { ValidationError } from "../errors.ts";
12
+ import { MailError, ValidationError } from "../errors.ts";
13
13
  import { createEventStore } from "../events/store.ts";
14
14
  import { jsonOutput } from "../json.ts";
15
15
  import { accent, printHint, printSuccess } from "../logging/color.ts";
@@ -253,6 +253,7 @@ interface ListOpts {
253
253
  to?: string;
254
254
  agent?: string;
255
255
  unread?: boolean;
256
+ type?: string;
256
257
  json?: boolean;
257
258
  }
258
259
 
@@ -405,6 +406,30 @@ async function handleSend(opts: SendOpts, cwd: string): Promise<void> {
405
406
  }
406
407
  }
407
408
 
409
+ // Reject sends to agents in a terminal state (completed/zombie).
410
+ // `installMailInjectors` reaps the per-agent dispatch loop the moment a
411
+ // session lands in a terminal state (serve.ts:378), so any mail addressed
412
+ // after that point would sit unread forever with no way to surface it.
413
+ // Sessions with no row at all (orchestrator, coordinator, operator roles)
414
+ // fall through — we only know about agents tracked in SessionStore.
415
+ // Group addresses already skip terminal agents via `getActive()`.
416
+ {
417
+ const overstoryDir = join(cwd, ".overstory");
418
+ const { store: sessionStore } = openSessionStore(overstoryDir);
419
+ try {
420
+ const recipient = sessionStore.getByName(to);
421
+ if (recipient && (recipient.state === "completed" || recipient.state === "zombie")) {
422
+ throw new MailError(
423
+ `Recipient "${to}" is in terminal state (${recipient.state}); message not sent. ` +
424
+ `The agent is no longer running, so this message would never be delivered.`,
425
+ { agentName: to },
426
+ );
427
+ }
428
+ } finally {
429
+ sessionStore.close();
430
+ }
431
+ }
432
+
408
433
  // Single-recipient message (existing logic)
409
434
  const client = openClient(cwd);
410
435
  try {
@@ -603,9 +628,20 @@ function handleList(opts: ListOpts, cwd: string): void {
603
628
  const unread = opts.unread ? true : undefined;
604
629
  const json = opts.json ?? false;
605
630
 
631
+ let type: MailMessageType | undefined;
632
+ if (opts.type !== undefined) {
633
+ if (!MAIL_MESSAGE_TYPES.includes(opts.type as MailMessageType)) {
634
+ throw new ValidationError(
635
+ `Invalid --type "${opts.type}". Must be one of: ${MAIL_MESSAGE_TYPES.join(", ")}`,
636
+ { field: "type", value: opts.type },
637
+ );
638
+ }
639
+ type = opts.type as MailMessageType;
640
+ }
641
+
606
642
  const client = openClient(cwd);
607
643
  try {
608
- const messages = client.list({ from, to, unread });
644
+ const messages = client.list({ from, to, unread, type });
609
645
 
610
646
  if (json) {
611
647
  jsonOutput("mail list", { messages });
@@ -732,8 +768,8 @@ export async function mailCommand(args: string[]): Promise<void> {
732
768
 
733
769
  program
734
770
  .command("check")
735
- .description("Check inbox (unread messages)")
736
- .option("--agent <name>", "Agent name")
771
+ .description("Check inbox for one agent and mark unread as read (per-agent scope)")
772
+ .option("--agent <name>", "Agent name (default: orchestrator)")
737
773
  .option("--inject", "Inject format for hook context")
738
774
  .option("--json", "Output as JSON")
739
775
  .option("--debounce <ms>", "Debounce interval in milliseconds")
@@ -744,11 +780,12 @@ export async function mailCommand(args: string[]): Promise<void> {
744
780
 
745
781
  program
746
782
  .command("list")
747
- .description("List messages with filters")
783
+ .description("List messages with filters (system-wide unless --to/--agent given)")
748
784
  .option("--from <name>", "Filter by sender")
749
- .option("--to <name>", "Filter by recipient")
785
+ .option("--to <name>", "Filter by recipient (scopes to one agent)")
750
786
  .option("--agent <name>", "Alias for --to (filter by recipient)")
751
- .option("--unread", "Show only unread messages")
787
+ .option("--unread", "Show only unread messages (does NOT mark them read)")
788
+ .option("--type <type>", "Filter by message type")
752
789
  .option("--json", "Output as JSON")
753
790
  .exitOverride()
754
791
  .action((opts: ListOpts) => {
@@ -789,5 +826,23 @@ export async function mailCommand(args: string[]): Promise<void> {
789
826
  handlePurge(opts, root);
790
827
  });
791
828
 
792
- await program.parseAsync(["node", "overstory-mail", ...args]);
829
+ try {
830
+ await program.parseAsync(["node", "overstory-mail", ...args]);
831
+ } catch (err) {
832
+ // `exitOverride()` turns Commander's help paths into thrown
833
+ // CommanderErrors after the help text was already written to stdout.
834
+ // Swallow both the explicit `--help` path (commander.helpDisplayed,
835
+ // exitCode 0) and the missing-subcommand path (commander.help,
836
+ // exitCode 1) — the user got what they asked for.
837
+ if (
838
+ err instanceof CommanderError &&
839
+ (err.code === "commander.helpDisplayed" || err.code === "commander.help")
840
+ ) {
841
+ if (err.exitCode !== 0) {
842
+ process.exitCode = err.exitCode;
843
+ }
844
+ return;
845
+ }
846
+ throw err;
847
+ }
793
848
  }