@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.
@@ -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. This is
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, mock, spyOn } from "bun:test";
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 { homedir } from "node:os";
17
- import { claudeStopHookCommand } from "./claude-stop-hook.ts";
21
+ import { claudeStopHookCommand, claudeHookDirs } from "./claude-stop-hook.ts";
22
+
23
+ const { marker: markerDir, queue: queueDir, release: releaseDir } = claudeHookDirs();
18
24
 
19
- // Paths we'll need in every test.
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 rm(join(markerDir, id), { force: true });
51
- await rm(join(markerDir, `${id}.tmp`), { force: true });
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 no-op
76
- test("stop_hook_active:true is a no-op and returns 0", async () => {
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 code = await claudeStopHookCommand();
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
- expect(await fileExists(join(markerDir, sessionId))).toBe(false);
88
- expect(await fileExists(join(markerDir, `${sessionId}.tmp`))).toBe(false);
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 writes a marker file that
6
- * another part of the system watches via `fs.watch`, replacing tmux-pane-
7
- * scraping idle detection with a clean event-driven approach.
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(): Promise<number> {
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
- // 3. Guard against infinite Stop-hook loops
74
- if (payload.stop_hook_active === true) {
75
- return 0;
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 atomically
79
- const markerDir = path.join(os.homedir(), ".atomic", "claude-stop");
80
- await fs.mkdir(markerDir, { recursive: true });
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
- const tmpPath = path.join(markerDir, `${payload.session_id}.tmp`);
83
- const finalPath = path.join(markerDir, payload.session_id);
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
- // Write contents the watcher only cares that the file appears.
86
- await Bun.write(tmpPath, raw);
87
- await fs.rename(tmpPath, finalPath);
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);