@bastani/atomic 0.5.21 → 0.5.22-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/.claude/settings.json +0 -12
- package/dist/commands/cli/claude-stop-hook.d.ts +65 -0
- package/dist/commands/cli/claude-stop-hook.d.ts.map +1 -0
- package/dist/sdk/providers/claude.d.ts +132 -84
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/types.d.ts +4 -4
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/workflows/index.d.ts +1 -1
- package/dist/sdk/workflows/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/commands/cli/claude-stop-hook.test.ts +155 -24
- package/src/commands/cli/claude-stop-hook.ts +122 -16
- package/src/commands/cli/workflow.ts +10 -0
- package/src/sdk/providers/claude.ts +511 -290
- package/src/sdk/runtime/executor.test.ts +173 -27
- package/src/sdk/runtime/executor.ts +348 -102
- package/src/sdk/types.ts +2 -4
- package/src/sdk/workflows/index.ts +0 -1
|
@@ -2,22 +2,27 @@
|
|
|
2
2
|
* Tests for claudeStopHookCommand.
|
|
3
3
|
*
|
|
4
4
|
* Strategy: monkey-patch `Bun.stdin.text` to return preset strings so we can
|
|
5
|
-
* call the function directly without spawning subprocesses.
|
|
5
|
+
* call the function directly without spawning subprocesses. This is
|
|
6
6
|
* consistent with how other CLI-command tests in this directory work.
|
|
7
7
|
*
|
|
8
8
|
* Filesystem isolation: we use `crypto.randomUUID()` for unique session IDs
|
|
9
9
|
* and clean up in `afterEach` so test runs never collide with each other
|
|
10
|
-
* or with real marker files.
|
|
10
|
+
* or with real marker/queue/release files.
|
|
11
|
+
*
|
|
12
|
+
* The hook's default wait for a queued follow-up prompt is 15 minutes.
|
|
13
|
+
* Every test here passes a short `waitTimeoutMs` so the hook exits quickly
|
|
14
|
+
* when no queue entry is present — we are testing the branching logic,
|
|
15
|
+
* not the real-world wait budget.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
|
-
import { describe, test, expect, afterEach,
|
|
14
|
-
import { access, rm } from "node:fs/promises";
|
|
18
|
+
import { describe, test, expect, afterEach, spyOn } from "bun:test";
|
|
19
|
+
import { access, rm, writeFile, mkdir } from "node:fs/promises";
|
|
15
20
|
import { join } from "node:path";
|
|
16
|
-
import {
|
|
17
|
-
|
|
21
|
+
import { claudeStopHookCommand, claudeHookDirs } from "./claude-stop-hook.ts";
|
|
22
|
+
|
|
23
|
+
const { marker: markerDir, queue: queueDir, release: releaseDir } = claudeHookDirs();
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
const markerDir = join(homedir(), ".atomic", "claude-stop");
|
|
25
|
+
const SHORT_TIMEOUT_MS = 300;
|
|
21
26
|
|
|
22
27
|
/** Returns true when a file exists at `filePath`. */
|
|
23
28
|
async function fileExists(filePath: string): Promise<boolean> {
|
|
@@ -31,9 +36,6 @@ async function fileExists(filePath: string): Promise<boolean> {
|
|
|
31
36
|
|
|
32
37
|
/** Patch `Bun.stdin.text` for the duration of one test. */
|
|
33
38
|
function mockStdin(text: string): void {
|
|
34
|
-
// Bun.stdin is a readonly property on the global `Bun` object.
|
|
35
|
-
// We reach it through the prototype chain the same way other tests
|
|
36
|
-
// in this repo patch globals (e.g. process.stdout.write).
|
|
37
39
|
(Bun.stdin as { text: () => Promise<string> }).text = () =>
|
|
38
40
|
Promise.resolve(text);
|
|
39
41
|
}
|
|
@@ -45,10 +47,12 @@ function mockStdin(text: string): void {
|
|
|
45
47
|
const sessionIdsToClean: string[] = [];
|
|
46
48
|
|
|
47
49
|
afterEach(async () => {
|
|
48
|
-
// Remove any marker files created during the test.
|
|
49
50
|
for (const id of sessionIdsToClean) {
|
|
50
|
-
await
|
|
51
|
-
|
|
51
|
+
await Promise.all([
|
|
52
|
+
rm(join(markerDir, id), { force: true }),
|
|
53
|
+
rm(join(queueDir, id), { force: true }),
|
|
54
|
+
rm(join(releaseDir, id), { force: true }),
|
|
55
|
+
]);
|
|
52
56
|
}
|
|
53
57
|
sessionIdsToClean.length = 0;
|
|
54
58
|
});
|
|
@@ -65,37 +69,63 @@ describe("claudeStopHookCommand", () => {
|
|
|
65
69
|
|
|
66
70
|
mockStdin(JSON.stringify({ session_id: sessionId }));
|
|
67
71
|
|
|
68
|
-
const code = await claudeStopHookCommand();
|
|
72
|
+
const code = await claudeStopHookCommand({ waitTimeoutMs: SHORT_TIMEOUT_MS });
|
|
69
73
|
|
|
70
74
|
expect(code).toBe(0);
|
|
71
75
|
expect(await fileExists(join(markerDir, sessionId))).toBe(true);
|
|
76
|
+
// No .tmp file should ever be created — we write directly to final path.
|
|
72
77
|
expect(await fileExists(join(markerDir, `${sessionId}.tmp`))).toBe(false);
|
|
73
78
|
});
|
|
74
79
|
|
|
75
|
-
// 2. stop_hook_active: true
|
|
76
|
-
|
|
80
|
+
// 2. stop_hook_active: true still writes marker and polls the queue
|
|
81
|
+
//
|
|
82
|
+
// Claude Code sets `stopHookActive: true` on every Stop hook invocation
|
|
83
|
+
// after a prior `{decision:"block"}` response (see `src/query.ts` →
|
|
84
|
+
// `transition: { reason: 'stop_hook_blocking' }`). Multi-turn workflows
|
|
85
|
+
// therefore see `stop_hook_active=true` on every turn past the first. The
|
|
86
|
+
// hook must still write the marker so `waitForIdle` unblocks, and must
|
|
87
|
+
// still poll for queued follow-ups so the next `s.session.query(...)` can
|
|
88
|
+
// reach Claude.
|
|
89
|
+
test("stop_hook_active:true still writes marker and polls the queue", async () => {
|
|
77
90
|
const sessionId = crypto.randomUUID();
|
|
78
91
|
sessionIdsToClean.push(sessionId);
|
|
79
92
|
|
|
93
|
+
const queuedPrompt = "Third turn follow-up";
|
|
94
|
+
await mkdir(queueDir, { recursive: true });
|
|
95
|
+
await writeFile(join(queueDir, sessionId), queuedPrompt, "utf-8");
|
|
96
|
+
|
|
80
97
|
mockStdin(
|
|
81
98
|
JSON.stringify({ session_id: sessionId, stop_hook_active: true }),
|
|
82
99
|
);
|
|
83
100
|
|
|
84
|
-
const
|
|
101
|
+
const stdoutChunks: string[] = [];
|
|
102
|
+
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(
|
|
103
|
+
(chunk: unknown) => {
|
|
104
|
+
stdoutChunks.push(String(chunk));
|
|
105
|
+
return true;
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const code = await claudeStopHookCommand({ waitTimeoutMs: SHORT_TIMEOUT_MS });
|
|
110
|
+
|
|
111
|
+
stdoutSpy.mockRestore();
|
|
85
112
|
|
|
86
113
|
expect(code).toBe(0);
|
|
87
|
-
|
|
88
|
-
expect(await fileExists(join(markerDir,
|
|
114
|
+
// Marker must be written so waitForIdle unblocks on every turn.
|
|
115
|
+
expect(await fileExists(join(markerDir, sessionId))).toBe(true);
|
|
116
|
+
// Queue entry consumed and emitted as a block decision.
|
|
117
|
+
expect(await fileExists(join(queueDir, sessionId))).toBe(false);
|
|
118
|
+
const parsed: unknown = JSON.parse(stdoutChunks.join(""));
|
|
119
|
+
expect(parsed).toEqual({ decision: "block", reason: queuedPrompt });
|
|
89
120
|
});
|
|
90
121
|
|
|
91
122
|
// 3. Malformed JSON → returns 0, logs to console.error
|
|
92
123
|
test("malformed JSON returns 0 and logs an error", async () => {
|
|
93
124
|
mockStdin("not json {{{");
|
|
94
125
|
|
|
95
|
-
// Spy on console.error so the error doesn't bleed into test output.
|
|
96
126
|
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
97
127
|
|
|
98
|
-
const code = await claudeStopHookCommand();
|
|
128
|
+
const code = await claudeStopHookCommand({ waitTimeoutMs: SHORT_TIMEOUT_MS });
|
|
99
129
|
|
|
100
130
|
expect(code).toBe(0);
|
|
101
131
|
expect(errorSpy).toHaveBeenCalled();
|
|
@@ -109,7 +139,7 @@ describe("claudeStopHookCommand", () => {
|
|
|
109
139
|
|
|
110
140
|
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
111
141
|
|
|
112
|
-
const code = await claudeStopHookCommand();
|
|
142
|
+
const code = await claudeStopHookCommand({ waitTimeoutMs: SHORT_TIMEOUT_MS });
|
|
113
143
|
|
|
114
144
|
expect(code).toBe(0);
|
|
115
145
|
expect(errorSpy).toHaveBeenCalled();
|
|
@@ -131,10 +161,111 @@ describe("claudeStopHookCommand", () => {
|
|
|
131
161
|
}),
|
|
132
162
|
);
|
|
133
163
|
|
|
134
|
-
const code = await claudeStopHookCommand();
|
|
164
|
+
const code = await claudeStopHookCommand({ waitTimeoutMs: SHORT_TIMEOUT_MS });
|
|
135
165
|
|
|
136
166
|
expect(code).toBe(0);
|
|
137
167
|
expect(await fileExists(join(markerDir, sessionId))).toBe(true);
|
|
138
168
|
expect(await fileExists(join(markerDir, `${sessionId}.tmp`))).toBe(false);
|
|
139
169
|
});
|
|
170
|
+
|
|
171
|
+
// 6. Queue file present at entry → emit block+reason, consume queue
|
|
172
|
+
test("queued prompt is emitted as a block decision and the queue file is consumed", async () => {
|
|
173
|
+
const sessionId = crypto.randomUUID();
|
|
174
|
+
sessionIdsToClean.push(sessionId);
|
|
175
|
+
|
|
176
|
+
const queuedPrompt = "Now translate your previous greeting into pig latin.";
|
|
177
|
+
await mkdir(queueDir, { recursive: true });
|
|
178
|
+
await writeFile(join(queueDir, sessionId), queuedPrompt, "utf-8");
|
|
179
|
+
|
|
180
|
+
mockStdin(JSON.stringify({ session_id: sessionId }));
|
|
181
|
+
|
|
182
|
+
const stdoutChunks: string[] = [];
|
|
183
|
+
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(
|
|
184
|
+
(chunk: unknown) => {
|
|
185
|
+
stdoutChunks.push(String(chunk));
|
|
186
|
+
return true;
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const code = await claudeStopHookCommand({ waitTimeoutMs: SHORT_TIMEOUT_MS });
|
|
191
|
+
|
|
192
|
+
stdoutSpy.mockRestore();
|
|
193
|
+
|
|
194
|
+
expect(code).toBe(0);
|
|
195
|
+
// Marker still written, since the workflow's waitForIdle depends on it.
|
|
196
|
+
expect(await fileExists(join(markerDir, sessionId))).toBe(true);
|
|
197
|
+
// Queue entry consumed.
|
|
198
|
+
expect(await fileExists(join(queueDir, sessionId))).toBe(false);
|
|
199
|
+
// Block decision emitted with the queued prompt as `reason`.
|
|
200
|
+
const emitted = stdoutChunks.join("");
|
|
201
|
+
const parsed: unknown = JSON.parse(emitted);
|
|
202
|
+
expect(parsed).toEqual({ decision: "block", reason: queuedPrompt });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// 7. Queue file appears during wait → still consumed and emitted
|
|
206
|
+
test("queue file written during the wait is consumed and emitted", async () => {
|
|
207
|
+
const sessionId = crypto.randomUUID();
|
|
208
|
+
sessionIdsToClean.push(sessionId);
|
|
209
|
+
|
|
210
|
+
const queuedPrompt = "Follow-up written mid-wait";
|
|
211
|
+
|
|
212
|
+
mockStdin(JSON.stringify({ session_id: sessionId }));
|
|
213
|
+
|
|
214
|
+
const stdoutChunks: string[] = [];
|
|
215
|
+
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(
|
|
216
|
+
(chunk: unknown) => {
|
|
217
|
+
stdoutChunks.push(String(chunk));
|
|
218
|
+
return true;
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Kick the hook off, then write the queue file partway through its wait.
|
|
223
|
+
const hookPromise = claudeStopHookCommand({
|
|
224
|
+
waitTimeoutMs: 2_000,
|
|
225
|
+
pollIntervalMs: 25,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await Bun.sleep(120);
|
|
229
|
+
await mkdir(queueDir, { recursive: true });
|
|
230
|
+
await writeFile(join(queueDir, sessionId), queuedPrompt, "utf-8");
|
|
231
|
+
|
|
232
|
+
const code = await hookPromise;
|
|
233
|
+
stdoutSpy.mockRestore();
|
|
234
|
+
|
|
235
|
+
expect(code).toBe(0);
|
|
236
|
+
expect(await fileExists(join(queueDir, sessionId))).toBe(false);
|
|
237
|
+
const parsed: unknown = JSON.parse(stdoutChunks.join(""));
|
|
238
|
+
expect(parsed).toEqual({ decision: "block", reason: queuedPrompt });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// 8. Release file present → exit 0, no stdout, consume release
|
|
242
|
+
test("release file lets the hook exit promptly without a decision", async () => {
|
|
243
|
+
const sessionId = crypto.randomUUID();
|
|
244
|
+
sessionIdsToClean.push(sessionId);
|
|
245
|
+
|
|
246
|
+
await mkdir(releaseDir, { recursive: true });
|
|
247
|
+
await writeFile(join(releaseDir, sessionId), "", "utf-8");
|
|
248
|
+
|
|
249
|
+
mockStdin(JSON.stringify({ session_id: sessionId }));
|
|
250
|
+
|
|
251
|
+
const stdoutChunks: string[] = [];
|
|
252
|
+
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(
|
|
253
|
+
(chunk: unknown) => {
|
|
254
|
+
stdoutChunks.push(String(chunk));
|
|
255
|
+
return true;
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const code = await claudeStopHookCommand({ waitTimeoutMs: SHORT_TIMEOUT_MS });
|
|
260
|
+
|
|
261
|
+
stdoutSpy.mockRestore();
|
|
262
|
+
|
|
263
|
+
expect(code).toBe(0);
|
|
264
|
+
// Release consumed so it doesn't carry over.
|
|
265
|
+
expect(await fileExists(join(releaseDir, sessionId))).toBe(false);
|
|
266
|
+
// Marker still written.
|
|
267
|
+
expect(await fileExists(join(markerDir, sessionId))).toBe(true);
|
|
268
|
+
// No block decision emitted.
|
|
269
|
+
expect(stdoutChunks.join("")).toBe("");
|
|
270
|
+
});
|
|
140
271
|
});
|
|
@@ -2,9 +2,19 @@
|
|
|
2
2
|
* Claude Stop Hook command — internal handler for Claude Code's Stop hook.
|
|
3
3
|
*
|
|
4
4
|
* Claude invokes `atomic _claude-stop-hook` at the end of every turn,
|
|
5
|
-
* piping a JSON payload via stdin. This handler
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* piping a JSON payload via stdin. This handler has two jobs:
|
|
6
|
+
*
|
|
7
|
+
* 1. Write a per-session marker file that the workflow runtime watches via
|
|
8
|
+
* `fs.watch` to detect turn completion (replacing tmux-pane scraping).
|
|
9
|
+
*
|
|
10
|
+
* 2. Deliver follow-up prompts without tmux send-keys. After the marker is
|
|
11
|
+
* written, this process block-polls `~/.atomic/claude-queue/<session_id>`.
|
|
12
|
+
* If the workflow enqueues a prompt there, we read it, delete the queue
|
|
13
|
+
* entry, and emit `{"decision":"block","reason":<prompt>}` on stdout.
|
|
14
|
+
* Claude Code treats `reason` as the next user message and keeps the
|
|
15
|
+
* agent loop running on the same session — no TUI keystrokes required.
|
|
16
|
+
* If the workflow instead signals session end via
|
|
17
|
+
* `~/.atomic/claude-release/<session_id>`, we exit 0 and let Claude stop.
|
|
8
18
|
*
|
|
9
19
|
* Usage (configured in Claude's Stop hook):
|
|
10
20
|
* atomic _claude-stop-hook
|
|
@@ -19,6 +29,7 @@
|
|
|
19
29
|
*/
|
|
20
30
|
|
|
21
31
|
import fs from "node:fs/promises";
|
|
32
|
+
import { existsSync } from "node:fs";
|
|
22
33
|
import path from "node:path";
|
|
23
34
|
import os from "node:os";
|
|
24
35
|
|
|
@@ -43,6 +54,32 @@ function isClaudeStopHookPayload(value: unknown): value is ClaudeStopHookPayload
|
|
|
43
54
|
return true;
|
|
44
55
|
}
|
|
45
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Directory paths used by the Stop hook and the workflow runtime to exchange
|
|
59
|
+
* per-session signals.
|
|
60
|
+
*
|
|
61
|
+
* Exported so tests and `src/sdk/providers/claude.ts` share one source of truth.
|
|
62
|
+
*/
|
|
63
|
+
export function claudeHookDirs(): { marker: string; queue: string; release: string } {
|
|
64
|
+
const base = path.join(os.homedir(), ".atomic");
|
|
65
|
+
return {
|
|
66
|
+
marker: path.join(base, "claude-stop"),
|
|
67
|
+
queue: path.join(base, "claude-queue"),
|
|
68
|
+
release: path.join(base, "claude-release"),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Options for {@link claudeStopHookCommand}. Primarily used by tests to shrink the wait budget. */
|
|
73
|
+
export interface ClaudeStopHookOptions {
|
|
74
|
+
/** Maximum time the hook waits for a queued follow-up prompt before letting Claude stop. */
|
|
75
|
+
waitTimeoutMs?: number;
|
|
76
|
+
/** Polling interval for queue/release detection. */
|
|
77
|
+
pollIntervalMs?: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DEFAULT_WAIT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
81
|
+
const DEFAULT_POLL_INTERVAL_MS = 100;
|
|
82
|
+
|
|
46
83
|
/**
|
|
47
84
|
* Handler for the hidden `_claude-stop-hook` subcommand.
|
|
48
85
|
*
|
|
@@ -52,7 +89,12 @@ function isClaudeStopHookPayload(value: unknown): value is ClaudeStopHookPayload
|
|
|
52
89
|
* We always return 0 — a non-zero exit would surface as a hook error in
|
|
53
90
|
* Claude's transcript, which is not what we want.
|
|
54
91
|
*/
|
|
55
|
-
export async function claudeStopHookCommand(
|
|
92
|
+
export async function claudeStopHookCommand(
|
|
93
|
+
options: ClaudeStopHookOptions = {},
|
|
94
|
+
): Promise<number> {
|
|
95
|
+
const waitTimeoutMs = options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
|
|
96
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
97
|
+
|
|
56
98
|
// 1. Read stdin
|
|
57
99
|
const raw = await Bun.stdin.text();
|
|
58
100
|
|
|
@@ -70,21 +112,85 @@ export async function claudeStopHookCommand(): Promise<number> {
|
|
|
70
112
|
return 0;
|
|
71
113
|
}
|
|
72
114
|
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
115
|
+
// NOTE: we intentionally do NOT early-exit on `stop_hook_active === true`.
|
|
116
|
+
//
|
|
117
|
+
// Claude Code sets `stopHookActive: true` in its query state after any Stop
|
|
118
|
+
// hook returns a `{decision:"block"}` response, and that flag stays true for
|
|
119
|
+
// every subsequent Stop hook invocation in the same session (see
|
|
120
|
+
// `src/query.ts` → `transition: { reason: 'stop_hook_blocking' }`). In a
|
|
121
|
+
// multi-turn workflow, every follow-up turn after the first is therefore
|
|
122
|
+
// invoked with `stop_hook_active=true`. Returning early here would skip the
|
|
123
|
+
// marker write, leaving `waitForIdle` hanging until its 15-minute safety
|
|
124
|
+
// timeout, and would skip the queue poll so the workflow's next
|
|
125
|
+
// `s.session.query(...)` would never reach Claude.
|
|
126
|
+
//
|
|
127
|
+
// Our design doesn't need the generic loop guard: the hook only emits a
|
|
128
|
+
// `block` decision when the workflow runtime has written a prompt to the
|
|
129
|
+
// queue file. Infinite loops are bounded by the workflow (which either
|
|
130
|
+
// enqueues a finite number of prompts or writes a release marker on
|
|
131
|
+
// teardown via `clearClaudeSession`).
|
|
132
|
+
const dirs = claudeHookDirs();
|
|
133
|
+
await Promise.all([
|
|
134
|
+
fs.mkdir(dirs.marker, { recursive: true }),
|
|
135
|
+
fs.mkdir(dirs.queue, { recursive: true }),
|
|
136
|
+
fs.mkdir(dirs.release, { recursive: true }),
|
|
137
|
+
]);
|
|
77
138
|
|
|
78
|
-
// 4. Write the marker file
|
|
79
|
-
|
|
80
|
-
|
|
139
|
+
// 4. Write the marker file directly.
|
|
140
|
+
//
|
|
141
|
+
// We intentionally do NOT use a tmp+rename dance here. On Linux, inotify
|
|
142
|
+
// emits the rename event with `filename=<session_id>.tmp` (the source),
|
|
143
|
+
// which made `waitForIdle`'s `event.filename === session_id` filter miss
|
|
144
|
+
// the event entirely and hang forever. A direct write on a tiny payload is
|
|
145
|
+
// effectively atomic at the page-cache level and generates a single event
|
|
146
|
+
// whose filename matches the session id — which is all `waitForIdle` needs.
|
|
147
|
+
const markerPath = path.join(dirs.marker, payload.session_id);
|
|
148
|
+
await Bun.write(markerPath, raw);
|
|
81
149
|
|
|
82
|
-
|
|
83
|
-
|
|
150
|
+
// 5. Block-poll for either a queued follow-up prompt or a release signal.
|
|
151
|
+
//
|
|
152
|
+
// The workflow's `waitForIdle` has already been unblocked by the marker
|
|
153
|
+
// write above and is now returning control to the user's stage callback.
|
|
154
|
+
// One of three things happens next:
|
|
155
|
+
//
|
|
156
|
+
// a. The callback calls `s.session.query(next)`, which writes the next
|
|
157
|
+
// prompt to `~/.atomic/claude-queue/<session_id>`. We read it, delete
|
|
158
|
+
// the queue entry, and emit `{"decision":"block","reason":<prompt>}`
|
|
159
|
+
// on stdout. Claude Code feeds `reason` back as the next user message
|
|
160
|
+
// and keeps the turn loop running — no tmux keystrokes involved.
|
|
161
|
+
//
|
|
162
|
+
// b. The callback returns and the runtime writes a release marker at
|
|
163
|
+
// `~/.atomic/claude-release/<session_id>`. We exit 0 with no stdout
|
|
164
|
+
// payload and Claude stops as usual.
|
|
165
|
+
//
|
|
166
|
+
// c. Neither happens within `waitTimeoutMs`. We exit 0 on timeout as a
|
|
167
|
+
// safety net — Claude stops rather than hanging its Stop hook forever.
|
|
168
|
+
const queuePath = path.join(dirs.queue, payload.session_id);
|
|
169
|
+
const releasePath = path.join(dirs.release, payload.session_id);
|
|
84
170
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
171
|
+
const deadline = Date.now() + waitTimeoutMs;
|
|
172
|
+
while (Date.now() <= deadline) {
|
|
173
|
+
if (existsSync(releasePath)) {
|
|
174
|
+
try { await fs.unlink(releasePath); } catch { /* ENOENT is fine */ }
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
if (existsSync(queuePath)) {
|
|
178
|
+
let prompt: string;
|
|
179
|
+
try {
|
|
180
|
+
prompt = await fs.readFile(queuePath, "utf-8");
|
|
181
|
+
} catch {
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
try { await fs.unlink(queuePath); } catch { /* ENOENT is fine */ }
|
|
185
|
+
process.stdout.write(JSON.stringify({
|
|
186
|
+
decision: "block",
|
|
187
|
+
reason: prompt,
|
|
188
|
+
}));
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
await Bun.sleep(pollIntervalMs);
|
|
192
|
+
}
|
|
88
193
|
|
|
194
|
+
// Timeout — no queued prompt arrived. Let Claude stop normally.
|
|
89
195
|
return 0;
|
|
90
196
|
}
|
|
@@ -14,6 +14,9 @@ import { AGENT_CONFIG, type AgentKey } from "../../services/config/index.ts";
|
|
|
14
14
|
import { COLORS, createPainter, type PaletteKey } from "../../theme/colors.ts";
|
|
15
15
|
import { isCommandInstalled } from "../../services/system/detect.ts";
|
|
16
16
|
import { ensureTmuxInstalled, ensureBunInstalled } from "../../lib/spawn.ts";
|
|
17
|
+
import { ensureProjectSetup } from "./init/index.ts";
|
|
18
|
+
import { ensureAtomicGlobalAgentConfigs } from "../../services/config/atomic-global-config.ts";
|
|
19
|
+
import { getConfigRoot } from "../../services/config/config-path.ts";
|
|
17
20
|
import {
|
|
18
21
|
isTmuxInstalled,
|
|
19
22
|
discoverWorkflows,
|
|
@@ -264,6 +267,13 @@ export async function workflowCommand(options: {
|
|
|
264
267
|
const preflightCode = await runPrereqChecks(agent);
|
|
265
268
|
if (preflightCode !== 0) return preflightCode;
|
|
266
269
|
|
|
270
|
+
// ── Preflight: global config sync + project onboarding files ──
|
|
271
|
+
// Mirrors `atomic chat` so workflow runs see the same MCP configs,
|
|
272
|
+
// agent settings, and global agent folders the chat command auto-heals.
|
|
273
|
+
const projectRoot = cwd ?? process.cwd();
|
|
274
|
+
await ensureAtomicGlobalAgentConfigs(getConfigRoot());
|
|
275
|
+
await ensureProjectSetup(agent, projectRoot);
|
|
276
|
+
|
|
267
277
|
// ── Picker mode: -a <agent>, no -n ──
|
|
268
278
|
if (!options.name) {
|
|
269
279
|
return runPickerMode(agent, passthroughArgs, cwd, detach);
|