@bastani/atomic 0.6.3 → 0.6.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.
Files changed (49) hide show
  1. package/.agents/skills/ast-grep/SKILL.md +323 -0
  2. package/.agents/skills/ast-grep/references/rule_reference.md +297 -0
  3. package/.agents/skills/ripgrep/SKILL.md +382 -0
  4. package/.mcp.json +5 -6
  5. package/dist/commands/cli/claude-inflight-hook.d.ts +100 -0
  6. package/dist/commands/cli/claude-inflight-hook.d.ts.map +1 -0
  7. package/dist/commands/cli/claude-stop-hook.d.ts +2 -0
  8. package/dist/commands/cli/claude-stop-hook.d.ts.map +1 -1
  9. package/dist/lib/spawn.d.ts +1 -1
  10. package/dist/lib/spawn.d.ts.map +1 -1
  11. package/dist/sdk/providers/claude.d.ts +36 -0
  12. package/dist/sdk/providers/claude.d.ts.map +1 -1
  13. package/dist/sdk/providers/copilot.d.ts +17 -1
  14. package/dist/sdk/providers/copilot.d.ts.map +1 -1
  15. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  16. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +49 -34
  17. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -1
  18. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +18 -16
  19. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts.map +1 -1
  20. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/batching.d.ts +43 -0
  21. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/batching.d.ts.map +1 -0
  22. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +30 -0
  23. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts.map +1 -1
  24. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +2 -1
  25. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts.map +1 -1
  26. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +18 -16
  27. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts.map +1 -1
  28. package/dist/services/config/additional-instructions.d.ts +67 -0
  29. package/dist/services/config/additional-instructions.d.ts.map +1 -0
  30. package/package.json +3 -1
  31. package/src/cli.ts +18 -1
  32. package/src/commands/cli/chat/index.ts +52 -2
  33. package/src/commands/cli/claude-inflight-hook.test.ts +598 -0
  34. package/src/commands/cli/claude-inflight-hook.ts +359 -0
  35. package/src/commands/cli/claude-stop-hook.ts +40 -4
  36. package/src/commands/cli/init/index.ts +9 -0
  37. package/src/lib/spawn.ts +6 -2
  38. package/src/sdk/providers/claude.ts +131 -0
  39. package/src/sdk/providers/copilot.ts +30 -1
  40. package/src/sdk/runtime/executor.ts +43 -2
  41. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +318 -158
  42. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +253 -129
  43. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/batching.ts +65 -0
  44. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/ignore-by-default.d.ts +8 -0
  45. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +203 -12
  46. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +248 -78
  47. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +258 -146
  48. package/src/services/config/additional-instructions.ts +273 -0
  49. package/src/services/system/auto-sync.ts +10 -1
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Claude In-Flight Hook command — internal handler for the workflow's
3
+ * `SubagentStart` / `SubagentStop` / `TeammateIdle` hooks.
4
+ *
5
+ * Invoked as:
6
+ * atomic _claude-inflight-hook start (SubagentStart)
7
+ * atomic _claude-inflight-hook stop (SubagentStop)
8
+ * atomic _claude-inflight-hook wait (TeammateIdle)
9
+ *
10
+ * `start` / `stop` maintain a directory of one marker file per in-flight
11
+ * subagent under `~/.atomic/claude-inflight/<root_session_id>/<agent_id>`.
12
+ * `<root_session_id>` is the stage's top-level Claude session — for nested
13
+ * subagents (a subagent spawning its own subagent) we resolve the root by
14
+ * looking up the parent session's mapping, so all descendants of a stage
15
+ * funnel into the same marker dir.
16
+ *
17
+ * `wait` is the focused completion-signal handler used for `TeammateIdle`:
18
+ * read `session_id` from the payload, await `waitForInflightDrained`, exit
19
+ * 0. We don't reuse the Stop hook handler here because Stop also writes
20
+ * `~/.atomic/claude-stop/<session_id>` (which the runtime's `waitForIdle`
21
+ * watches) and polls queue/release — those are tied to the stage's root
22
+ * session, and TeammateIdle's `session_id` may be a teammate's session
23
+ * that the runtime never enqueues to or releases.
24
+ *
25
+ * Two consumers gate on the in-flight dir being empty:
26
+ *
27
+ * 1. `claudeStopHookCommand` — won't consume the `claude-release` marker
28
+ * until in-flight is empty, so Claude itself doesn't exit while
29
+ * backgrounded subagents are still running.
30
+ *
31
+ * 2. `clearClaudeSession` — calls `waitForInflightDrained` before tearing
32
+ * down the pane, so the executor doesn't advance to the next stage
33
+ * while the previous stage's subagents still hold FDs/PTYs on the
34
+ * atomic tmux server.
35
+ *
36
+ * Always exits 0 — a non-zero exit would surface as a hook error in
37
+ * Claude's transcript, and silent miss + stale-sweep recovery is preferable
38
+ * to red noise on every workflow run.
39
+ */
40
+
41
+ import fs from "node:fs/promises";
42
+ import path from "node:path";
43
+ import { claudeHookDirs } from "./claude-stop-hook.ts";
44
+
45
+ /**
46
+ * Shape of the JSON payload Claude pipes to the Subagent / TeammateIdle
47
+ * lifecycle hooks via stdin.
48
+ *
49
+ * SubagentStart / SubagentStop → `agent_id` (uuid), `agent_type`
50
+ * TeammateIdle → `session_id` only (used by `wait` mode)
51
+ *
52
+ * `session_id` is the parent Claude session that triggered the event — for
53
+ * a top-level subagent, that's the stage's root; for a nested subagent,
54
+ * that's the spawning agent's session, and we look up its root via
55
+ * `inflightRoots`.
56
+ *
57
+ * Extra fields are ignored; missing ones cause a graceful exit-0 no-op.
58
+ */
59
+ export interface ClaudeInflightHookPayload {
60
+ session_id: string;
61
+ hook_event_name?: string;
62
+ agent_id?: string;
63
+ agent_type?: string;
64
+ cwd?: string;
65
+ }
66
+
67
+ export type ClaudeInflightHookMode = "start" | "stop" | "wait";
68
+
69
+ function isClaudeInflightHookPayload(
70
+ value: unknown,
71
+ ): value is ClaudeInflightHookPayload {
72
+ if (typeof value !== "object" || value === null) return false;
73
+ const obj = value as Record<string, unknown>;
74
+ if (typeof obj["session_id"] !== "string") return false;
75
+ return true;
76
+ }
77
+
78
+ /**
79
+ * Default TTL for stale-marker sweeps. A marker older than this is treated
80
+ * as orphaned (subagent crashed without firing SubagentStop) and removed.
81
+ *
82
+ * 2 hours is conservative: real subagents researching docs or running tests
83
+ * can exceed 30 minutes in ralph-style workflows, but no legitimate hook
84
+ * lifecycle should leave a marker on disk longer than ~2 h. Override at the
85
+ * environment via `ATOMIC_INFLIGHT_STALE_MS` for shorter runs in tests.
86
+ */
87
+ const DEFAULT_INFLIGHT_STALE_MS = 2 * 60 * 60 * 1000;
88
+
89
+ function staleMs(): number {
90
+ const raw = process.env["ATOMIC_INFLIGHT_STALE_MS"];
91
+ if (!raw) return DEFAULT_INFLIGHT_STALE_MS;
92
+ const parsed = Number.parseInt(raw, 10);
93
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_INFLIGHT_STALE_MS;
94
+ }
95
+
96
+ /** Pick the unique id this event represents — `agent_id` for Subagent events. */
97
+ function extractId(payload: ClaudeInflightHookPayload): string | null {
98
+ if (typeof payload.agent_id === "string" && payload.agent_id.length > 0) {
99
+ return payload.agent_id;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /** Path to the roots-mapping file for a given session. */
105
+ function rootsMapPath(sessionId: string): string {
106
+ return path.join(claudeHookDirs().inflightRoots, sessionId);
107
+ }
108
+
109
+ /** Path to the marker dir for a given root session. */
110
+ function markerDirFor(rootSessionId: string): string {
111
+ return path.join(claudeHookDirs().inflight, rootSessionId);
112
+ }
113
+
114
+ /** Path to a single marker file under a root session. */
115
+ function markerPathFor(rootSessionId: string, id: string): string {
116
+ return path.join(markerDirFor(rootSessionId), id);
117
+ }
118
+
119
+ /**
120
+ * Resolve the stage root for an event's `session_id`. If the parent has a
121
+ * roots-mapping entry (it was itself spawned as a subagent), follow it.
122
+ * Otherwise the parent IS the root.
123
+ */
124
+ async function resolveRoot(parentSessionId: string): Promise<string> {
125
+ try {
126
+ const mapped = await fs.readFile(rootsMapPath(parentSessionId), "utf-8");
127
+ const trimmed = mapped.trim();
128
+ if (trimmed.length > 0) return trimmed;
129
+ } catch {
130
+ // ENOENT (parent has no mapping → it is the root) or any read error
131
+ // falls through to the default.
132
+ }
133
+ return parentSessionId;
134
+ }
135
+
136
+ /**
137
+ * Best-effort marker payload — gives the stale sweep something to read for
138
+ * `ts`, and the Stop hook a way to log who got reaped.
139
+ */
140
+ function markerBody(
141
+ payload: ClaudeInflightHookPayload,
142
+ rootSessionId: string,
143
+ ): string {
144
+ return JSON.stringify({
145
+ kind: payload.hook_event_name ?? null,
146
+ parent_session_id: payload.session_id,
147
+ root: rootSessionId,
148
+ agent_type: payload.agent_type ?? null,
149
+ ts: Date.now(),
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Handler for the hidden `_claude-inflight-hook` subcommand.
155
+ *
156
+ * Always returns 0 — silently swallows all errors. A buggy tracker must
157
+ * never kill stages.
158
+ */
159
+ export async function claudeInflightHookCommand(
160
+ mode: ClaudeInflightHookMode,
161
+ ): Promise<number> {
162
+ let raw: string;
163
+ try {
164
+ raw = await Bun.stdin.text();
165
+ } catch {
166
+ return 0;
167
+ }
168
+
169
+ let payload: ClaudeInflightHookPayload;
170
+ try {
171
+ const parsed: unknown = JSON.parse(raw);
172
+ if (!isClaudeInflightHookPayload(parsed)) {
173
+ return 0;
174
+ }
175
+ payload = parsed;
176
+ } catch {
177
+ return 0;
178
+ }
179
+
180
+ // `wait` is the TeammateIdle path — gate on the root session's in-flight
181
+ // dir draining and exit. No marker write, no queue/release polling: the
182
+ // event's `session_id` may be a teammate's session that the runtime never
183
+ // enqueues to or releases, so reusing the Stop hook would risk hanging
184
+ // for the whole 24-day timeout.
185
+ if (mode === "wait") {
186
+ try {
187
+ const root = await resolveRoot(payload.session_id);
188
+ await waitForInflightDrained(root);
189
+ } catch {
190
+ // Best-effort — the wait swallows internal errors and resolves on
191
+ // timeout. A throw here would only happen on a path bug.
192
+ }
193
+ return 0;
194
+ }
195
+
196
+ const id = extractId(payload);
197
+ if (!id) return 0;
198
+
199
+ try {
200
+ const dirs = claudeHookDirs();
201
+ await fs.mkdir(dirs.inflight, { recursive: true });
202
+ await fs.mkdir(dirs.inflightRoots, { recursive: true });
203
+
204
+ const root = await resolveRoot(payload.session_id);
205
+
206
+ if (mode === "start") {
207
+ // Record the new agent's mapping so its own future descendants can
208
+ // resolve the same root via `resolveRoot`.
209
+ try {
210
+ await Bun.write(rootsMapPath(id), root);
211
+ } catch {
212
+ // Best-effort: a missing mapping just means a nested subagent of
213
+ // this id would mark under its immediate parent instead of the
214
+ // ultimate root. Stale sweep cleans up either way.
215
+ }
216
+
217
+ await fs.mkdir(markerDirFor(root), { recursive: true });
218
+ await Bun.write(markerPathFor(root, id), markerBody(payload, root));
219
+ } else {
220
+ // mode === "stop"
221
+ try {
222
+ await fs.unlink(markerPathFor(root, id));
223
+ } catch (e: unknown) {
224
+ const code = (e as NodeJS.ErrnoException | null)?.code;
225
+ if (code !== "ENOENT") {
226
+ // Ignore other errors silently — exit 0 is the contract.
227
+ }
228
+ }
229
+ }
230
+ } catch {
231
+ // Catch-all: any FS or path failure → silent exit 0.
232
+ }
233
+
234
+ return 0;
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Helpers exported for the Stop hook and clearClaudeSession
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /** True when the per-root marker dir is missing or contains no marker files. */
242
+ export async function inflightDirIsEmpty(rootSessionId: string): Promise<boolean> {
243
+ try {
244
+ const entries = await fs.readdir(markerDirFor(rootSessionId));
245
+ return entries.length === 0;
246
+ } catch (e: unknown) {
247
+ const code = (e as NodeJS.ErrnoException | null)?.code;
248
+ if (code === "ENOENT") return true;
249
+ // On any other error, assume non-empty so we wait — wedging the wait is
250
+ // worse than letting it advance with leaked FDs only when the FS is
251
+ // genuinely broken.
252
+ return false;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Remove markers older than `thresholdMs` (default `ATOMIC_INFLIGHT_STALE_MS`
258
+ * or 2 h). Returns the number of markers reaped. Used by the Stop hook and
259
+ * `waitForInflightDrained` to recover from subagents that crashed without
260
+ * firing `SubagentStop`.
261
+ */
262
+ export async function sweepStaleInflight(
263
+ rootSessionId: string,
264
+ thresholdMs?: number,
265
+ ): Promise<number> {
266
+ const dir = markerDirFor(rootSessionId);
267
+ const cutoff = Date.now() - (thresholdMs ?? staleMs());
268
+ let reaped = 0;
269
+ let entries: string[];
270
+ try {
271
+ entries = await fs.readdir(dir);
272
+ } catch {
273
+ return 0;
274
+ }
275
+ for (const entry of entries) {
276
+ const file = path.join(dir, entry);
277
+ try {
278
+ const stat = await fs.stat(file);
279
+ if (stat.mtimeMs < cutoff) {
280
+ await fs.unlink(file);
281
+ reaped += 1;
282
+ }
283
+ } catch {
284
+ // ignore
285
+ }
286
+ }
287
+ return reaped;
288
+ }
289
+
290
+ export interface WaitForInflightOptions {
291
+ /** How long to wait before giving up. Default 30 minutes. */
292
+ timeoutMs?: number;
293
+ /** Poll cadence. Default 100 ms. */
294
+ pollIntervalMs?: number;
295
+ /** Stale-marker TTL. Default `ATOMIC_INFLIGHT_STALE_MS` or 2 h. */
296
+ staleMs?: number;
297
+ }
298
+
299
+ const DEFAULT_DRAIN_TIMEOUT_MS = 30 * 60 * 1000;
300
+ const DEFAULT_DRAIN_POLL_MS = 100;
301
+
302
+ /**
303
+ * Resolve when the per-root marker dir is empty. Sweeps stale markers on
304
+ * every tick. Resolves silently on timeout — the caller can't usefully
305
+ * recover, so wedging vs. leaking is the only trade.
306
+ */
307
+ export async function waitForInflightDrained(
308
+ rootSessionId: string,
309
+ options: WaitForInflightOptions = {},
310
+ ): Promise<void> {
311
+ const timeoutMs = options.timeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
312
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_DRAIN_POLL_MS;
313
+ const deadline = Date.now() + timeoutMs;
314
+
315
+ while (true) {
316
+ if (await inflightDirIsEmpty(rootSessionId)) return;
317
+ if (Date.now() >= deadline) return;
318
+ await sweepStaleInflight(rootSessionId, options.staleMs);
319
+ if (await inflightDirIsEmpty(rootSessionId)) return;
320
+ if (Date.now() >= deadline) return;
321
+ await new Promise<void>((r) => setTimeout(r, pollIntervalMs));
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Remove the per-root marker dir and any roots-mapping entries that point
327
+ * at this root. Called by `clearClaudeSession` on stage teardown so
328
+ * leftovers cannot bleed into a future session that reuses the same id
329
+ * (UUID collision is astronomically unlikely, but stale-sweep + cleanup
330
+ * costs nothing).
331
+ */
332
+ export async function clearInflightTracking(rootSessionId: string): Promise<void> {
333
+ try {
334
+ await fs.rm(markerDirFor(rootSessionId), { recursive: true, force: true });
335
+ } catch {
336
+ // ignore
337
+ }
338
+ // Sweep roots-mapping entries that point at this root.
339
+ const dirs = claudeHookDirs();
340
+ let entries: string[];
341
+ try {
342
+ entries = await fs.readdir(dirs.inflightRoots);
343
+ } catch {
344
+ return;
345
+ }
346
+ await Promise.all(
347
+ entries.map(async (entry) => {
348
+ const file = path.join(dirs.inflightRoots, entry);
349
+ try {
350
+ const value = (await fs.readFile(file, "utf-8")).trim();
351
+ if (value === rootSessionId || entry === rootSessionId) {
352
+ await fs.unlink(file);
353
+ }
354
+ } catch {
355
+ // ignore
356
+ }
357
+ }),
358
+ );
359
+ }
@@ -33,6 +33,10 @@ import { watch as watchDir } from "node:fs/promises";
33
33
  import { existsSync } from "node:fs";
34
34
  import path from "node:path";
35
35
  import os from "node:os";
36
+ import {
37
+ inflightDirIsEmpty,
38
+ sweepStaleInflight,
39
+ } from "./claude-inflight-hook.ts";
36
40
 
37
41
  /** Shape of the JSON payload Claude pipes to the Stop hook via stdin. */
38
42
  export interface ClaudeStopHookPayload {
@@ -68,8 +72,11 @@ export function claudeHookDirs(): {
68
72
  hil: string;
69
73
  pid: string;
70
74
  ready: string;
75
+ inflight: string;
76
+ inflightRoots: string;
71
77
  } {
72
78
  const base = path.join(os.homedir(), ".atomic");
79
+ const inflightBase = path.join(base, "claude-inflight");
73
80
  return {
74
81
  marker: path.join(base, "claude-stop"),
75
82
  queue: path.join(base, "claude-queue"),
@@ -85,6 +92,19 @@ export function claudeHookDirs(): {
85
92
  // watches this directory to detect readiness — positive signal, unlike
86
93
  // racing the JSONL writer.
87
94
  ready: path.join(base, "claude-ready"),
95
+ // Per-root-session marker dirs (`<inflight>/<root_session_id>/<id>`)
96
+ // populated by the SubagentStart/SubagentStop and TaskCreated/
97
+ // TaskCompleted hooks. Both `clearClaudeSession` and the Stop hook gate
98
+ // on this dir being empty before letting the stage advance, so a stage
99
+ // never tears down while it still has live subagents/tasks holding FDs.
100
+ inflight: inflightBase,
101
+ // `<inflight>/.session-roots/<session_id>` → root_session_id mapping.
102
+ // SubagentStart writes a mapping for every spawned agent so that nested
103
+ // subagents (a subagent spawning its own subagent) can resolve which
104
+ // stage's root they belong to and write their marker under the right
105
+ // root. Lives alongside `inflight/` rather than under it so a `readdir`
106
+ // of `<inflight>/<root>/` only returns id markers.
107
+ inflightRoots: path.join(inflightBase, ".session-roots"),
88
108
  };
89
109
  }
90
110
 
@@ -292,10 +312,12 @@ export async function claudeStopHookCommand(
292
312
  type Hit = { kind: "release" } | { kind: "queue"; prompt: string };
293
313
 
294
314
  const check = async (): Promise<Hit | null> => {
295
- if (existsSync(releasePath)) {
296
- try { await fs.unlink(releasePath); } catch { /* ENOENT is fine */ }
297
- return { kind: "release" };
298
- }
315
+ // Queue takes priority over release: if the runtime enqueued a follow-up
316
+ // prompt, we want to deliver it and let Claude run another turn. The
317
+ // workflow only writes a release marker when it's actually torn down, so
318
+ // a queue + release race only happens at session end — and in that case
319
+ // the queue prompt was authored before teardown, so honoring it first is
320
+ // correct.
299
321
  if (existsSync(queuePath)) {
300
322
  let prompt: string;
301
323
  try {
@@ -307,6 +329,20 @@ export async function claudeStopHookCommand(
307
329
  try { await fs.unlink(queuePath); } catch { /* ENOENT is fine */ }
308
330
  return { kind: "queue", prompt };
309
331
  }
332
+ if (existsSync(releasePath)) {
333
+ // Don't consume the release marker until in-flight subagents/tasks
334
+ // have drained. Reaping the marker prematurely would let Claude exit
335
+ // while backgrounded children still hold FDs/PTYs on the atomic tmux
336
+ // server, which is the failure mode the inflight tracking exists to
337
+ // prevent. Stale-sweep first so a crashed subagent that never fired
338
+ // SubagentStop doesn't wedge the wait forever.
339
+ await sweepStaleInflight(payload.session_id);
340
+ if (!(await inflightDirIsEmpty(payload.session_id))) {
341
+ return null;
342
+ }
343
+ try { await fs.unlink(releasePath); } catch { /* ENOENT is fine */ }
344
+ return { kind: "release" };
345
+ }
310
346
  return null;
311
347
  };
312
348
 
@@ -9,6 +9,7 @@
9
9
  import type { AgentKey } from "../../../services/config/index.ts";
10
10
  import { getConfigRoot } from "../../../services/config/config-path.ts";
11
11
  import { syncScmMcpServers } from "../../../services/config/scm-sync.ts";
12
+ import { reconcileOpencodeInstructions } from "../../../services/config/additional-instructions.ts";
12
13
  import { applyManagedOnboardingFiles } from "./onboarding.ts";
13
14
 
14
15
  /**
@@ -29,4 +30,12 @@ export async function ensureProjectSetup(
29
30
  const configRoot = getConfigRoot();
30
31
  await applyManagedOnboardingFiles(agentKey, projectRoot, configRoot);
31
32
  await syncScmMcpServers(projectRoot);
33
+
34
+ // OpenCode is the only provider whose CLI/SDK has no flag or env-var
35
+ // path for additional instructions — it consumes them via its project
36
+ // config. Reconcile only when targeting OpenCode so we don't touch
37
+ // `.opencode/opencode.json` from unrelated chat sessions.
38
+ if (agentKey === "opencode") {
39
+ await reconcileOpencodeInstructions(projectRoot);
40
+ }
32
41
  }
package/src/lib/spawn.ts CHANGED
@@ -414,9 +414,13 @@ export async function upgradeGlobalPackages(pkgs: string[]): Promise<void> {
414
414
  }
415
415
  }
416
416
 
417
- /** Upgrade @playwright/cli and @llamaindex/liteparse globally in one pass. */
417
+ /** Upgrade @playwright/cli, @llamaindex/liteparse, and @ast-grep/cli globally in one pass. */
418
418
  export async function upgradeGlobalToolPackages(): Promise<void> {
419
- return upgradeGlobalPackages(["@playwright/cli", "@llamaindex/liteparse"]);
419
+ return upgradeGlobalPackages([
420
+ "@playwright/cli",
421
+ "@llamaindex/liteparse",
422
+ "@ast-grep/cli",
423
+ ]);
420
424
  }
421
425
 
422
426
  /**
@@ -32,6 +32,11 @@ import { join } from "node:path";
32
32
  import { randomUUID } from "node:crypto";
33
33
  import os from "node:os";
34
34
  import { claudeHookDirs } from "../../commands/cli/claude-stop-hook.ts";
35
+ import {
36
+ clearInflightTracking,
37
+ waitForInflightDrained,
38
+ } from "../../commands/cli/claude-inflight-hook.ts";
39
+ import { resolveAdditionalInstructionsContent } from "../../services/config/additional-instructions.ts";
35
40
 
36
41
  // ---------------------------------------------------------------------------
37
42
  // Session tracking — ensures createClaudeSession is called before claudeQuery
@@ -59,6 +64,19 @@ const initializedPanes = new Map<string, PaneState>();
59
64
  * waiting out the hook's safety timeout.
60
65
  *
61
66
  * Called by the runtime when a Claude stage is being torn down. Idempotent.
67
+ *
68
+ * After writing the release marker, this waits for the per-session in-flight
69
+ * marker dir (`~/.atomic/claude-inflight/<session_id>/`) to drain. The
70
+ * marker dir is populated by the SubagentStart/Stop and TaskCreated/Completed
71
+ * hooks registered in {@link WORKFLOW_HOOK_SETTINGS}. This wait is the
72
+ * synchronization barrier that prevents the executor from advancing to the
73
+ * next stage while the previous stage's backgrounded subagents/tasks still
74
+ * hold FDs/PTYs on the atomic tmux server — the failure mode that surfaced
75
+ * intermittently as `tmux respawn-pane: fork failed: Device not configured`.
76
+ *
77
+ * The wait has its own bounded timeout (default 30 minutes) so a wedged
78
+ * subagent can't permanently block the workflow; the in-hook stale-sweep
79
+ * (~2 hours TTL) is the ultimate safety net.
62
80
  */
63
81
  export async function clearClaudeSession(paneId: string): Promise<void> {
64
82
  const state = initializedPanes.get(paneId);
@@ -69,6 +87,15 @@ export async function clearClaudeSession(paneId: string): Promise<void> {
69
87
  // Best-effort — if release fails the hook will still exit on its
70
88
  // own safety timeout.
71
89
  }
90
+ // Wait for in-flight subagents/tasks to finish before letting the
91
+ // executor advance. Resolves immediately when the dir is empty/missing
92
+ // (the common case, including any stage that didn't spawn subagents).
93
+ try {
94
+ await waitForInflightDrained(state.claudeSessionId);
95
+ } catch {
96
+ // Best-effort — the wait swallows internal errors and resolves on
97
+ // timeout. A throw here would only happen on a path bug.
98
+ }
72
99
  try {
73
100
  await unlinkAtomicPidFile(state.claudeSessionId);
74
101
  } catch {
@@ -82,6 +109,12 @@ export async function clearClaudeSession(paneId: string): Promise<void> {
82
109
  // a fresh one under its own UUID and clears any prior leftover in
83
110
  // `claudeQuery` before respawn.
84
111
  }
112
+ try {
113
+ await clearInflightTracking(state.claudeSessionId);
114
+ } catch {
115
+ // Best-effort — leftover marker files are reaped by the next session's
116
+ // stale-sweep, and the .session-roots/ entries are tiny.
117
+ }
85
118
  }
86
119
  initializedPanes.delete(paneId);
87
120
  }
@@ -188,6 +221,20 @@ const READY_HOOK_TIMEOUT_MS = 2_147_483_000;
188
221
  * catch path — see `src/services/tools/toolExecution.ts` in the CLI
189
222
  * source), so registering the same command on both guarantees the
190
223
  * marker clears regardless of which completion path the tool takes.
224
+ * - `SubagentStart` / `SubagentStop`: maintain a per-root-session marker
225
+ * dir under `~/.atomic/claude-inflight/<root>/` so the Stop hook and
226
+ * `clearClaudeSession` can both gate on subagent completion before
227
+ * letting the stage advance. Without this gate, a stage that spawned
228
+ * `run_in_background: true` subagents would tear down its pane while
229
+ * children still hold FDs/PTYs on the atomic tmux server, intermittently
230
+ * surfacing as `tmux respawn-pane: fork failed: Device not configured`
231
+ * when the next stage tried to spawn.
232
+ * - `TeammateIdle`: same gating applied at agent-team teammate idle.
233
+ * Unlike Stop, this fires when a teammate (potentially a different
234
+ * `session_id` from the stage's root) goes idle, so we route it to a
235
+ * focused `_claude-inflight-hook wait` mode that only awaits in-flight
236
+ * drain — no claude-stop marker write (that would confuse `waitForIdle`)
237
+ * and no queue/release polling (those are keyed on the stage's root).
191
238
  *
192
239
  * Built once at module load. Contains no single quotes (JSON syntax doesn't
193
240
  * produce them and paths rarely do), so POSIX single-quoting at the spawn
@@ -250,6 +297,45 @@ const WORKFLOW_HOOK_SETTINGS = JSON.stringify({
250
297
  ],
251
298
  },
252
299
  ],
300
+ // SubagentStart/SubagentStop fire per Agent-tool dispatch (no matcher)
301
+ // and route to a single subcommand that touches/removes one marker file
302
+ // per `agent_id`. The handler is bulletproof — any error exits 0
303
+ // silently — so a hook failure can't kill the stage.
304
+ SubagentStart: [
305
+ {
306
+ hooks: [
307
+ {
308
+ type: "command",
309
+ command: buildWorkflowHookCommand("_claude-inflight-hook", ["start"]),
310
+ },
311
+ ],
312
+ },
313
+ ],
314
+ SubagentStop: [
315
+ {
316
+ hooks: [
317
+ {
318
+ type: "command",
319
+ command: buildWorkflowHookCommand("_claude-inflight-hook", ["stop"]),
320
+ },
321
+ ],
322
+ },
323
+ ],
324
+ // TeammateIdle gets a focused `wait` mode (gates on in-flight drain,
325
+ // nothing else) — see the WORKFLOW_HOOK_SETTINGS docstring for why this
326
+ // doesn't reuse the Stop hook handler. Timeout matches Stop's so the
327
+ // wait can run for as long as the workflow holds onto teammates.
328
+ TeammateIdle: [
329
+ {
330
+ hooks: [
331
+ {
332
+ type: "command",
333
+ command: buildWorkflowHookCommand("_claude-inflight-hook", ["wait"]),
334
+ timeout: STOP_HOOK_TIMEOUT_SECONDS,
335
+ },
336
+ ],
337
+ },
338
+ ],
253
339
  },
254
340
  });
255
341
 
@@ -1037,6 +1123,36 @@ export function mergeDisallowedTools(
1037
1123
  return merged;
1038
1124
  }
1039
1125
 
1126
+ /**
1127
+ * Fold the atomic-managed additional instructions into a caller's
1128
+ * `systemPrompt` value. Behavior, in order of precedence:
1129
+ *
1130
+ * - **No caller value** → return a `claude_code` preset with our content
1131
+ * in `append`. Preserves the SDK's full Claude Code persona.
1132
+ * - **Caller passed a preset object** → concatenate our content onto the
1133
+ * existing `append` (newline-separated when both are present).
1134
+ * - **Caller passed a custom string or array** → leave it alone. The
1135
+ * caller has explicitly opted into a custom prompt, and silently
1136
+ * prepending the persona-style preset text would break that contract.
1137
+ *
1138
+ * Exported for unit testing.
1139
+ */
1140
+ export function mergeSystemPromptAppend(
1141
+ existing: SDKOptions["systemPrompt"],
1142
+ extra: string,
1143
+ ): SDKOptions["systemPrompt"] {
1144
+ if (!extra) return existing;
1145
+ if (existing === undefined) {
1146
+ return { type: "preset", preset: "claude_code", append: extra };
1147
+ }
1148
+ if (typeof existing === "object" && !Array.isArray(existing) && existing.type === "preset") {
1149
+ const prevAppend = existing.append ?? "";
1150
+ const merged = prevAppend ? `${prevAppend}\n\n${extra}` : extra;
1151
+ return { ...existing, append: merged };
1152
+ }
1153
+ return existing;
1154
+ }
1155
+
1040
1156
  /**
1041
1157
  * Synthetic client wrapper for Claude stages.
1042
1158
  * Auto-starts the Claude CLI in the tmux pane during `start()`.
@@ -1193,6 +1309,13 @@ export function resolveHeadlessClaudeBin(): string {
1193
1309
  */
1194
1310
  export class HeadlessClaudeSessionWrapper {
1195
1311
  readonly paneId = "";
1312
+ /**
1313
+ * Project root the workflow is operating against. Used to resolve
1314
+ * project-scoped config (e.g. `additional-instructions`) against the
1315
+ * workflow's actual root rather than `process.cwd()`, which can drift
1316
+ * when workflows are invoked programmatically or from a subdirectory.
1317
+ */
1318
+ private readonly _projectRoot: string;
1196
1319
  /**
1197
1320
  * The Claude session UUID of the most recently completed `query()`. Exposed
1198
1321
  * via `s.sessionId` so workflows can pass it to `s.save(s.sessionId)` and
@@ -1200,6 +1323,10 @@ export class HeadlessClaudeSessionWrapper {
1200
1323
  * Claude stages run in parallel (each call gets its own SDK-assigned UUID).
1201
1324
  */
1202
1325
  private _lastSessionId: string = "";
1326
+
1327
+ constructor(projectRoot: string) {
1328
+ this._projectRoot = projectRoot;
1329
+ }
1203
1330
  /**
1204
1331
  * Validated structured output captured from the most recent `query()`'s
1205
1332
  * `result` message. Populated only when callers pass
@@ -1226,6 +1353,7 @@ export class HeadlessClaudeSessionWrapper {
1226
1353
  // agent can call it and the SDK query will sit blocked forever since no
1227
1354
  // human is attached to answer.
1228
1355
  const sdkOpts = options ?? {};
1356
+ const additional = await resolveAdditionalInstructionsContent(this._projectRoot);
1229
1357
  const headlessSdkOpts: Partial<SDKOptions> = {
1230
1358
  ...sdkOpts,
1231
1359
  pathToClaudeCodeExecutable:
@@ -1233,6 +1361,9 @@ export class HeadlessClaudeSessionWrapper {
1233
1361
  disallowedTools: mergeDisallowedTools(sdkOpts.disallowedTools, [
1234
1362
  "AskUserQuestion",
1235
1363
  ]),
1364
+ ...(additional
1365
+ ? { systemPrompt: mergeSystemPromptAppend(sdkOpts.systemPrompt, additional) }
1366
+ : {}),
1236
1367
  };
1237
1368
 
1238
1369
  let sdkSessionId = "";