@bastani/atomic 0.6.3-0 → 0.6.4-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/.agents/skills/ast-grep/SKILL.md +323 -0
- package/.agents/skills/ast-grep/references/rule_reference.md +297 -0
- package/.agents/skills/ripgrep/SKILL.md +382 -0
- package/.mcp.json +5 -6
- package/dist/commands/cli/claude-inflight-hook.d.ts +100 -0
- package/dist/commands/cli/claude-inflight-hook.d.ts.map +1 -0
- package/dist/commands/cli/claude-stop-hook.d.ts +2 -0
- package/dist/commands/cli/claude-stop-hook.d.ts.map +1 -1
- package/dist/lib/spawn.d.ts +1 -1
- package/dist/lib/spawn.d.ts.map +1 -1
- package/dist/sdk/providers/claude.d.ts +36 -0
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/sdk/providers/copilot.d.ts +17 -1
- package/dist/sdk/providers/copilot.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +49 -34
- package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +18 -16
- package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/batching.d.ts +43 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/batching.d.ts.map +1 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +30 -0
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +2 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +18 -16
- package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts.map +1 -1
- package/dist/services/config/additional-instructions.d.ts +67 -0
- package/dist/services/config/additional-instructions.d.ts.map +1 -0
- package/package.json +3 -1
- package/src/cli.ts +18 -1
- package/src/commands/cli/chat/index.ts +52 -2
- package/src/commands/cli/claude-inflight-hook.test.ts +598 -0
- package/src/commands/cli/claude-inflight-hook.ts +359 -0
- package/src/commands/cli/claude-stop-hook.ts +40 -4
- package/src/commands/cli/init/index.ts +9 -0
- package/src/lib/spawn.ts +6 -2
- package/src/sdk/providers/claude.ts +131 -0
- package/src/sdk/providers/copilot.ts +30 -1
- package/src/sdk/runtime/executor.ts +43 -2
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +318 -158
- package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +253 -129
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/batching.ts +65 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/ignore-by-default.d.ts +8 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +203 -12
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +248 -78
- package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +258 -146
- package/src/services/config/additional-instructions.ts +273 -0
- 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
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
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([
|
|
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 = "";
|