@bastani/atomic 0.5.22 → 0.5.23-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.
Files changed (33) hide show
  1. package/.agents/skills/workflow-creator/SKILL.md +2 -2
  2. package/.agents/skills/workflow-creator/references/agent-sessions.md +21 -26
  3. package/.agents/skills/workflow-creator/references/discovery-and-verification.md +1 -1
  4. package/.agents/skills/workflow-creator/references/failure-modes.md +16 -9
  5. package/.agents/skills/workflow-creator/references/getting-started.md +0 -1
  6. package/.agents/skills/workflow-creator/references/session-config.md +5 -12
  7. package/.agents/skills/workflow-creator/references/workflow-inputs.md +2 -2
  8. package/.claude/agents/reviewer.md +2 -2
  9. package/.github/agents/reviewer.md +2 -2
  10. package/.opencode/agents/reviewer.md +2 -2
  11. package/dist/commands/cli/claude-stop-hook.d.ts +1 -0
  12. package/dist/commands/cli/claude-stop-hook.d.ts.map +1 -1
  13. package/dist/sdk/providers/claude.d.ts +9 -47
  14. package/dist/sdk/providers/claude.d.ts.map +1 -1
  15. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -1
  16. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts.map +1 -1
  17. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +0 -6
  18. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts.map +1 -1
  19. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts.map +1 -1
  20. package/dist/sdk/workflows/builtin/ralph/helpers/prompts.d.ts +4 -4
  21. package/dist/sdk/workflows/builtin/ralph/helpers/prompts.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/cli.ts +17 -1
  24. package/src/commands/cli/claude-ask-hook.test.ts +128 -0
  25. package/src/commands/cli/claude-ask-hook.ts +84 -0
  26. package/src/commands/cli/claude-stop-hook.ts +2 -1
  27. package/src/sdk/providers/claude.ts +126 -160
  28. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +1 -6
  29. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +1 -6
  30. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +30 -47
  31. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +0 -6
  32. package/src/sdk/workflows/builtin/ralph/copilot/index.ts +2 -2
  33. package/src/sdk/workflows/builtin/ralph/helpers/prompts.ts +7 -7
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Tests for claudeAskHookCommand.
3
+ *
4
+ * Strategy mirrors claude-stop-hook.test.ts: monkey-patch `Bun.stdin.text`
5
+ * so we can call the function directly without spawning subprocesses, and
6
+ * use unique session IDs with `afterEach` cleanup to avoid cross-test
7
+ * contamination.
8
+ */
9
+
10
+ import { describe, test, expect, afterEach } from "bun:test";
11
+ import { access, rm, writeFile, mkdir } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import {
14
+ claudeAskHookCommand,
15
+ } from "./claude-ask-hook.ts";
16
+ import { claudeHookDirs } from "./claude-stop-hook.ts";
17
+
18
+ const { hil: hilDir } = claudeHookDirs();
19
+
20
+ async function fileExists(filePath: string): Promise<boolean> {
21
+ try {
22
+ await access(filePath);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ function mockStdin(text: string): void {
30
+ (Bun.stdin as { text: () => Promise<string> }).text = () =>
31
+ Promise.resolve(text);
32
+ }
33
+
34
+ const sessionIdsToClean: string[] = [];
35
+
36
+ afterEach(async () => {
37
+ for (const id of sessionIdsToClean) {
38
+ await rm(join(hilDir, id), { force: true });
39
+ }
40
+ sessionIdsToClean.length = 0;
41
+ });
42
+
43
+ describe("claudeAskHookCommand", () => {
44
+ test("enter mode writes the marker file and returns 0", async () => {
45
+ const sessionId = crypto.randomUUID();
46
+ sessionIdsToClean.push(sessionId);
47
+
48
+ mockStdin(JSON.stringify({
49
+ session_id: sessionId,
50
+ hook_event_name: "PreToolUse",
51
+ tool_name: "AskUserQuestion",
52
+ }));
53
+
54
+ const code = await claudeAskHookCommand("enter");
55
+
56
+ expect(code).toBe(0);
57
+ expect(await fileExists(join(hilDir, sessionId))).toBe(true);
58
+ });
59
+
60
+ test("exit mode removes an existing marker and returns 0", async () => {
61
+ const sessionId = crypto.randomUUID();
62
+ sessionIdsToClean.push(sessionId);
63
+
64
+ await mkdir(hilDir, { recursive: true });
65
+ await writeFile(join(hilDir, sessionId), "stale");
66
+
67
+ mockStdin(JSON.stringify({
68
+ session_id: sessionId,
69
+ hook_event_name: "PostToolUse",
70
+ tool_name: "AskUserQuestion",
71
+ }));
72
+
73
+ const code = await claudeAskHookCommand("exit");
74
+
75
+ expect(code).toBe(0);
76
+ expect(await fileExists(join(hilDir, sessionId))).toBe(false);
77
+ });
78
+
79
+ test("exit mode with no existing marker is a no-op and returns 0", async () => {
80
+ const sessionId = crypto.randomUUID();
81
+ sessionIdsToClean.push(sessionId);
82
+
83
+ mockStdin(JSON.stringify({ session_id: sessionId }));
84
+
85
+ const code = await claudeAskHookCommand("exit");
86
+
87
+ expect(code).toBe(0);
88
+ expect(await fileExists(join(hilDir, sessionId))).toBe(false);
89
+ });
90
+
91
+ test("malformed JSON returns 0 and does not write a marker", async () => {
92
+ const sessionId = crypto.randomUUID();
93
+ sessionIdsToClean.push(sessionId);
94
+
95
+ mockStdin("not json {");
96
+
97
+ const code = await claudeAskHookCommand("enter");
98
+
99
+ expect(code).toBe(0);
100
+ expect(await fileExists(join(hilDir, sessionId))).toBe(false);
101
+ });
102
+
103
+ test("missing session_id returns 0 and does not write a marker", async () => {
104
+ mockStdin(JSON.stringify({ hook_event_name: "PreToolUse" }));
105
+
106
+ const code = await claudeAskHookCommand("enter");
107
+
108
+ expect(code).toBe(0);
109
+ });
110
+
111
+ test("enter mode tolerates extra payload fields", async () => {
112
+ const sessionId = crypto.randomUUID();
113
+ sessionIdsToClean.push(sessionId);
114
+
115
+ mockStdin(JSON.stringify({
116
+ session_id: sessionId,
117
+ hook_event_name: "PreToolUse",
118
+ tool_name: "AskUserQuestion",
119
+ cwd: "/some/path",
120
+ extraneous_field: 42,
121
+ }));
122
+
123
+ const code = await claudeAskHookCommand("enter");
124
+
125
+ expect(code).toBe(0);
126
+ expect(await fileExists(join(hilDir, sessionId))).toBe(true);
127
+ });
128
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Claude AskUserQuestion Hook command — internal handler for PreToolUse /
3
+ * PostToolUse / PostToolUseFailure hooks scoped to the `AskUserQuestion`
4
+ * built-in tool.
5
+ *
6
+ * Invoked as:
7
+ * atomic _claude-ask-hook enter (PreToolUse)
8
+ * atomic _claude-ask-hook exit (PostToolUse + PostToolUseFailure)
9
+ *
10
+ * Writes or removes `~/.atomic/claude-hil/<session_id>`. The workflow runtime
11
+ * (`src/sdk/providers/claude.ts`) `fs.watch`es that directory and fires
12
+ * `onHIL(true|false)` on create/unlink, driving the blue "awaiting_input"
13
+ * pulse on the node card.
14
+ *
15
+ * Returns exit 0 on every path — a non-zero exit would surface as a hook
16
+ * error in Claude's transcript, which is worse than a silently-missed HIL
17
+ * signal (the `onHIL?.(false)` safety call in `claudeQuery`'s finally block
18
+ * recovers UI state in either case).
19
+ */
20
+
21
+ import fs from "node:fs/promises";
22
+ import path from "node:path";
23
+ import { claudeHookDirs } from "./claude-stop-hook.ts";
24
+
25
+ /** Shape of the JSON payload Claude pipes to the PreToolUse/PostToolUse hook via stdin. */
26
+ export interface ClaudeAskHookPayload {
27
+ session_id: string;
28
+ hook_event_name?: string;
29
+ tool_name?: string;
30
+ cwd?: string;
31
+ }
32
+
33
+ export type ClaudeAskHookMode = "enter" | "exit";
34
+
35
+ function isClaudeAskHookPayload(value: unknown): value is ClaudeAskHookPayload {
36
+ if (typeof value !== "object" || value === null) return false;
37
+ const obj = value as Record<string, unknown>;
38
+ return typeof obj["session_id"] === "string";
39
+ }
40
+
41
+ /**
42
+ * Handler for the hidden `_claude-ask-hook` subcommand.
43
+ *
44
+ * Always returns 0 so a hook failure never shows up as a red "hook error"
45
+ * in Claude's transcript.
46
+ */
47
+ export async function claudeAskHookCommand(mode: ClaudeAskHookMode): Promise<number> {
48
+ const raw = await Bun.stdin.text();
49
+
50
+ let payload: ClaudeAskHookPayload;
51
+ try {
52
+ const parsed: unknown = JSON.parse(raw);
53
+ if (!isClaudeAskHookPayload(parsed)) {
54
+ console.error("[claude-ask-hook] Invalid payload: missing or malformed 'session_id'");
55
+ return 0;
56
+ }
57
+ payload = parsed;
58
+ } catch {
59
+ console.error("[claude-ask-hook] Failed to parse stdin as JSON");
60
+ return 0;
61
+ }
62
+
63
+ const { hil } = claudeHookDirs();
64
+ await fs.mkdir(hil, { recursive: true });
65
+ const markerPath = path.join(hil, payload.session_id);
66
+
67
+ if (mode === "enter") {
68
+ // Direct write (Bun.write is a single open+write, not tmp+rename) — keeps
69
+ // the inotify sequence to one IN_CREATE event per enter, simplifying the
70
+ // watcher's state machine. See claude-stop-hook.ts for the same rationale.
71
+ await Bun.write(markerPath, raw);
72
+ } else {
73
+ try {
74
+ await fs.unlink(markerPath);
75
+ } catch (e: unknown) {
76
+ const code = (e as NodeJS.ErrnoException | null)?.code;
77
+ if (code !== "ENOENT") {
78
+ console.error(`[claude-ask-hook] Failed to unlink marker: ${String(e)}`);
79
+ }
80
+ }
81
+ }
82
+
83
+ return 0;
84
+ }
@@ -60,12 +60,13 @@ function isClaudeStopHookPayload(value: unknown): value is ClaudeStopHookPayload
60
60
  *
61
61
  * Exported so tests and `src/sdk/providers/claude.ts` share one source of truth.
62
62
  */
63
- export function claudeHookDirs(): { marker: string; queue: string; release: string } {
63
+ export function claudeHookDirs(): { marker: string; queue: string; release: string; hil: string } {
64
64
  const base = path.join(os.homedir(), ".atomic");
65
65
  return {
66
66
  marker: path.join(base, "claude-stop"),
67
67
  queue: path.join(base, "claude-queue"),
68
68
  release: path.join(base, "claude-release"),
69
+ hil: path.join(base, "claude-hil"),
69
70
  };
70
71
  }
71
72
 
@@ -82,7 +82,7 @@ const DEFAULT_CHAT_FLAGS = [
82
82
  ];
83
83
 
84
84
  /**
85
- * Build the shell command Claude Code runs from the injected Stop hook.
85
+ * Build the shell command Claude Code runs from an injected workflow hook.
86
86
  *
87
87
  * - **Published install** (`import.meta.dir` under `node_modules`): resolve
88
88
  * `atomic` via the user's PATH. That's the binary they installed, and
@@ -95,33 +95,84 @@ const DEFAULT_CHAT_FLAGS = [
95
95
  * The dev-detection heuristic (`node_modules` in `import.meta.dir`) is the
96
96
  * same one used by `src/services/system/auto-sync.ts:50`.
97
97
  */
98
- function buildWorkflowStopHookCommand(): string {
98
+ function buildWorkflowHookCommand(subcommand: string, extraArgs: readonly string[] = []): string {
99
99
  if (import.meta.dir.includes("node_modules")) {
100
- return "atomic _claude-stop-hook";
100
+ return ["atomic", subcommand, ...extraArgs].join(" ");
101
101
  }
102
102
  const runtime = process.execPath;
103
103
  const cliPath = join(import.meta.dir, "..", "..", "cli.ts");
104
- return `"${escBash(runtime)}" "${escBash(cliPath)}" _claude-stop-hook`;
104
+ return [
105
+ `"${escBash(runtime)}"`,
106
+ `"${escBash(cliPath)}"`,
107
+ subcommand,
108
+ ...extraArgs,
109
+ ].join(" ");
105
110
  }
106
111
 
107
112
  /**
108
113
  * Inline settings injected via `claude --settings <json>` on every workflow
109
- * spawn. Registers the workflow Stop hook that delivers follow-up prompts
110
- * without relying on `.claude/settings.json` — so the hook fires only for
111
- * workflow-spawned Claude sessions, not when a user runs `claude` manually.
114
+ * spawn. Registers the workflow-owned hooks without relying on
115
+ * `.claude/settings.json` — so the hooks fire only for workflow-spawned
116
+ * Claude sessions, not when a user runs `claude` manually.
117
+ *
118
+ * Registered hooks:
119
+ * - `Stop`: deliver queued follow-up prompts via `{decision:"block"}` and
120
+ * write an idle-marker file that `waitForIdle` watches.
121
+ * - `PreToolUse` matched on `AskUserQuestion`: write
122
+ * `~/.atomic/claude-hil/<session_id>` so `watchHILMarker` can fire
123
+ * `onHIL(true)` — the node card flips to the blue "awaiting_input" pulse.
124
+ * - `PostToolUse` / `PostToolUseFailure` matched on `AskUserQuestion`:
125
+ * remove the HIL marker. Claude Code fires exactly one of these per
126
+ * tool invocation (PostToolUse on success, PostToolUseFailure in the
127
+ * catch path — see `src/services/tools/toolExecution.ts` in the CLI
128
+ * source), so registering the same command on both guarantees the
129
+ * marker clears regardless of which completion path the tool takes.
112
130
  *
113
131
  * Built once at module load. Contains no single quotes (JSON syntax doesn't
114
132
  * produce them and paths rarely do), so POSIX single-quoting at the spawn
115
133
  * site is sufficient shell escaping.
116
134
  */
117
- const WORKFLOW_STOP_HOOK_SETTINGS = JSON.stringify({
135
+ const WORKFLOW_HOOK_SETTINGS = JSON.stringify({
118
136
  hooks: {
119
137
  Stop: [
120
138
  {
121
139
  hooks: [
122
140
  {
123
141
  type: "command",
124
- command: buildWorkflowStopHookCommand(),
142
+ command: buildWorkflowHookCommand("_claude-stop-hook"),
143
+ },
144
+ ],
145
+ },
146
+ ],
147
+ PreToolUse: [
148
+ {
149
+ matcher: "AskUserQuestion",
150
+ hooks: [
151
+ {
152
+ type: "command",
153
+ command: buildWorkflowHookCommand("_claude-ask-hook", ["enter"]),
154
+ },
155
+ ],
156
+ },
157
+ ],
158
+ PostToolUse: [
159
+ {
160
+ matcher: "AskUserQuestion",
161
+ hooks: [
162
+ {
163
+ type: "command",
164
+ command: buildWorkflowHookCommand("_claude-ask-hook", ["exit"]),
165
+ },
166
+ ],
167
+ },
168
+ ],
169
+ PostToolUseFailure: [
170
+ {
171
+ matcher: "AskUserQuestion",
172
+ hooks: [
173
+ {
174
+ type: "command",
175
+ command: buildWorkflowHookCommand("_claude-ask-hook", ["exit"]),
125
176
  },
126
177
  ],
127
178
  },
@@ -226,7 +277,7 @@ async function spawnClaudeWithPrompt(
226
277
  // last-wins semantics shadow any user-provided --settings, making this
227
278
  // non-overridable by `.atomic/settings.json` chatFlags overrides.
228
279
  "--settings",
229
- `'${WORKFLOW_STOP_HOOK_SETTINGS}'`,
280
+ `'${WORKFLOW_HOOK_SETTINGS}'`,
230
281
  "--session-id",
231
282
  sessionId,
232
283
  argvPrompt,
@@ -315,49 +366,6 @@ function resolveSessionDir(cwd: string): string {
315
366
  // HIL detection helpers
316
367
  // ---------------------------------------------------------------------------
317
368
 
318
- /**
319
- * Returns true if the most recent assistant message contains an
320
- * `AskUserQuestion` tool_use block that has not yet been resolved
321
- * by a corresponding `tool_result` in a subsequent user message.
322
- *
323
- * Pure function — no side effects, safe to call from a watch loop.
324
- *
325
- * Exported as `_hasUnresolvedHILTool` for unit testing.
326
- */
327
- export function _hasUnresolvedHILTool(messages: SessionMessage[]): boolean {
328
- const resolvedIds = new Set<string>();
329
-
330
- for (const msg of messages) {
331
- if (msg.type !== "user") continue;
332
- const content = (msg.message as { content: unknown })?.content;
333
- if (!Array.isArray(content)) continue;
334
- for (const block of content) {
335
- if (block.type === "tool_result" && block.tool_use_id) {
336
- resolvedIds.add(block.tool_use_id);
337
- }
338
- }
339
- }
340
-
341
- for (const msg of [...messages].reverse()) {
342
- if (msg.type !== "assistant") continue;
343
- const content = (msg.message as { content: unknown })?.content;
344
- if (!Array.isArray(content)) continue;
345
- for (const block of content) {
346
- if (
347
- block.type === "tool_use" &&
348
- block.name === "AskUserQuestion" &&
349
- block.id &&
350
- !resolvedIds.has(block.id)
351
- ) {
352
- return true;
353
- }
354
- }
355
- break;
356
- }
357
-
358
- return false;
359
- }
360
-
361
369
  /**
362
370
  * Returns true when the most recent assistant message in the transcript
363
371
  * ended with `stop_reason: "tool_use"` — i.e. the agent stopped the current
@@ -389,123 +397,62 @@ export function _isMidAgentLoop(messages: SessionMessage[]): boolean {
389
397
  }
390
398
 
391
399
  /**
392
- * Core HIL watcher loop pure logic, dependency-injected for testability.
400
+ * Watch `~/.atomic/claude-hil/` for this session's marker file and fire
401
+ * `onHIL(true|false)` on create/unlink. Returns when `signal` is aborted.
393
402
  *
394
- * Iterates an async iterable of "file change" events (each event triggers a
395
- * transcript read via `readMessages`). Calls `onHIL(true)` when
396
- * `_hasUnresolvedHILTool` first returns true, `onHIL(false)` when it returns
397
- * false after having been true. The `wasHIL` guard prevents redundant
398
- * callbacks on repeated events with the same HIL state. Read errors from
399
- * `readMessages` are swallowed so a single corrupt JSONL write doesn't kill
400
- * the watcher.
401
- *
402
- * Exported as `_runHILWatcher` for unit testing (event source and message
403
- * reader are injected rather than hard-coded to `fs.watch` / `getSessionMessages`).
404
- */
405
- export async function _runHILWatcher(
406
- events: AsyncIterable<unknown>,
407
- readMessages: () => Promise<SessionMessage[]>,
408
- onHIL: (waiting: boolean) => void,
409
- ): Promise<void> {
410
- let wasHIL = false;
411
-
412
- for await (const _event of events) {
413
- try {
414
- const msgs = await readMessages();
415
- const isHIL = _hasUnresolvedHILTool(msgs);
416
- if (isHIL !== wasHIL) {
417
- onHIL(isHIL);
418
- wasHIL = isHIL;
419
- }
420
- } catch {
421
- // Transcript read failed — skip this event, try again on next write
422
- }
423
- }
424
- }
425
-
426
- /**
427
- * Path helpers for the transcript JSONL written by Claude Code.
428
- * @internal Exported for tests.
429
- */
430
- export function transcriptDir(): string {
431
- return resolveSessionDir(process.cwd());
432
- }
433
-
434
- /** @internal Exported for tests. */
435
- export function transcriptPath(claudeSessionId: string): string {
436
- return join(transcriptDir(), `${claudeSessionId}.jsonl`);
437
- }
438
-
439
- /**
440
- * Watch this session's transcript JSONL and call `onHIL` on every HIL-state
441
- * transition — independently of the Stop hook.
442
- *
443
- * Why not piggyback on the Stop hook? `AskUserQuestion` is a deferred tool
444
- * (`shouldDefer: true`, see Claude Code's
445
- * `src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx`). While the question
446
- * is pending, Claude's agent loop blocks on the tool with
447
- * `needsFollowUp === true`, so `handleStopHooks` never runs
448
- * (`src/query.ts`: `if (!needsFollowUp)`). A watcher tied to the Stop-hook
449
- * marker would sleep through the entire HIL window and only wake up after
450
- * the user has already answered.
451
- *
452
- * Watches the parent session directory rather than the file itself so the
453
- * attach is safe before Claude has created the JSONL on first query. Events
454
- * are filtered by `<sessionId>.jsonl`. Returns when `signal` is aborted.
403
+ * The marker is written by the `_claude-ask-hook enter` subcommand from
404
+ * Claude Code's `PreToolUse` hook (matched on `AskUserQuestion`) and removed
405
+ * by `_claude-ask-hook exit` from `PostToolUse` / `PostToolUseFailure`. That
406
+ * makes the signal deterministic and independent of Claude Code's batched
407
+ * JSONL flush timing, which used to hide the HIL window entirely when
408
+ * tool_use and tool_result landed in the same file write.
455
409
  *
456
410
  * @internal Exported for tests.
457
411
  */
458
- export async function watchTranscriptForHIL(
412
+ export async function watchHILMarker(
459
413
  claudeSessionId: string,
460
414
  onHIL: (waiting: boolean) => void,
461
415
  signal: AbortSignal,
462
416
  ): Promise<void> {
463
- const dir = transcriptDir();
417
+ const { hil: dir } = claudeHookDirs();
418
+ const target = join(dir, claudeSessionId);
464
419
 
465
- const readMessages = async (): Promise<SessionMessage[]> => {
466
- try {
467
- return await getSessionMessages(claudeSessionId, {
468
- dir: process.cwd(),
469
- includeSystemMessages: true,
470
- });
471
- } catch {
472
- return [];
473
- }
474
- };
420
+ await mkdir(dir, { recursive: true });
475
421
 
476
422
  let wasHIL = false;
477
- const check = async (): Promise<void> => {
478
- const msgs = await readMessages();
479
- const isHIL = _hasUnresolvedHILTool(msgs);
423
+ const emit = (isHIL: boolean): void => {
480
424
  if (isHIL !== wasHIL) {
481
425
  onHIL(isHIL);
482
426
  wasHIL = isHIL;
483
427
  }
484
428
  };
485
429
 
486
- await mkdir(dir, { recursive: true });
487
-
488
- // Attach the watcher BEFORE the initial check so any events that arrive
489
- // during the check are buffered by the iterator instead of being lost.
430
+ // Attach the watcher BEFORE the initial existsSync so any event that fires
431
+ // during the check is buffered by the iterator instead of being dropped.
490
432
  const watcher = watch(dir, { signal });
491
433
 
492
- // Initial check: closes the race where the JSONL already contains an
493
- // unresolved AskUserQuestion by the time this watcher attaches (resumed
494
- // session, slow attach, etc.).
495
- await check();
434
+ // Polling fallback: Bun/inotify can drop events under heavy fs load, which
435
+ // would leave the UI stuck on (or off) the blue "awaiting_input" pulse.
436
+ // A cheap periodic existsSync guarantees eventual consistency. `emit` is
437
+ // guarded by `wasHIL` so the interval is idempotent w.r.t. the watcher.
438
+ const poll = setInterval(() => emit(existsSync(target)), 250);
439
+
440
+ // Initial existsSync: handles resumed sessions whose PreToolUse marker was
441
+ // already on disk before the watcher attached.
442
+ if (existsSync(target)) emit(true);
496
443
 
497
444
  try {
498
445
  for await (const _event of watcher) {
499
- // We intentionally don't filter by `_event.filename`. On Linux, writes
500
- // can deliver events with unrelated or `.tmp` basenames, and Bun's
501
- // fs.watch behavior varies across OSes; `getSessionMessages` is keyed
502
- // by `claudeSessionId` so a cheap re-read is authoritative.
503
- await check();
446
+ // Don't trust event.filename Bun/Linux deliver inconsistent basenames
447
+ // across OSes and write patterns. Disk existence is authoritative.
448
+ emit(existsSync(target));
504
449
  }
505
450
  } catch (e: unknown) {
506
451
  if (!(e instanceof Error && e.name === "AbortError")) {
507
452
  throw e;
508
453
  }
454
+ } finally {
455
+ clearInterval(poll);
509
456
  }
510
457
  }
511
458
 
@@ -596,6 +543,24 @@ async function clearStaleQueue(claudeSessionId: string): Promise<void> {
596
543
  }
597
544
  }
598
545
 
546
+ /**
547
+ * Remove a stale HIL marker left over from a prior turn (e.g. the ask-hook
548
+ * process was SIGKILL'd between PreToolUse and PostToolUse). Without this,
549
+ * `watchHILMarker`'s initial `existsSync` would spuriously fire `onHIL(true)`
550
+ * at the start of a fresh turn. Ignores ENOENT.
551
+ */
552
+ async function clearStaleHILMarker(claudeSessionId: string): Promise<void> {
553
+ const { hil } = claudeHookDirs();
554
+ await mkdir(hil, { recursive: true });
555
+ try {
556
+ await unlink(join(hil, claudeSessionId));
557
+ } catch (e: unknown) {
558
+ if (!(e instanceof Error && "code" in e && (e as NodeJS.ErrnoException).code === "ENOENT")) {
559
+ throw e;
560
+ }
561
+ }
562
+ }
563
+
599
564
  /**
600
565
  * Write the next prompt to the session queue file. The currently-running
601
566
  * Stop hook process (blocked on poll from the previous turn) picks it up,
@@ -631,7 +596,7 @@ export async function releaseClaudeSession(claudeSessionId: string): Promise<voi
631
596
  * tmux pane glyphs, which vary between Claude Code versions.
632
597
  *
633
598
  * This function is strictly about *idle detection*. HIL is detected separately
634
- * by {@link watchTranscriptForHIL}; the Stop hook does not fire while
599
+ * by {@link watchHILMarker}; the Stop hook does not fire while
635
600
  * `AskUserQuestion` is pending (the agent loop blocks on deferred tools), so
636
601
  * mixing the two would silently miss the HIL window.
637
602
  *
@@ -862,9 +827,12 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<SessionM
862
827
  const claudeSessionId = paneState.claudeSessionId;
863
828
 
864
829
  // Clear stale marker AND stale queue entry before submitting so the
865
- // Stop-hook for the previous turn (if any) cannot race this one.
830
+ // Stop-hook for the previous turn (if any) cannot race this one. The HIL
831
+ // marker is cleared too so a crashed ask-hook process from turn N-1 can't
832
+ // make `watchHILMarker`'s initial existsSync spuriously fire onHIL(true).
866
833
  await clearStaleMarker(claudeSessionId);
867
834
  await clearStaleQueue(claudeSessionId);
835
+ await clearStaleHILMarker(claudeSessionId);
868
836
 
869
837
  let transcriptBeforeCount = 0;
870
838
  let spawnPromptFile: string | undefined;
@@ -906,29 +874,27 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<SessionM
906
874
  paneState.claudeStarted = true;
907
875
  }
908
876
 
909
- // HIL detection runs in parallel with idle detection. The Stop hook
910
- // (which drives waitForIdle) doesn't fire while `AskUserQuestion` is
911
- // pending, so we watch the transcript JSONL directly for HIL transitions.
877
+ // HIL detection runs in parallel with idle detection. The
878
+ // PreToolUse/PostToolUse/PostToolUseFailure hooks on `AskUserQuestion`
879
+ // write/remove `~/.atomic/claude-hil/<session_id>`; we watch that dir
880
+ // for create/unlink events so HIL state is deterministic and immune to
881
+ // Claude Code's batched JSONL flush timing.
912
882
  const hilAc = new AbortController();
913
883
  if (onHIL) {
914
- void watchTranscriptForHIL(claudeSessionId, onHIL, hilAc.signal).catch(
915
- () => {
916
- // Best-effort — never fail the query over HIL detection.
917
- },
918
- );
884
+ void watchHILMarker(claudeSessionId, onHIL, hilAc.signal).catch(() => {
885
+ // Best-effort — never fail the query over HIL detection.
886
+ });
919
887
  }
920
888
 
921
889
  try {
922
890
  return await waitForIdle(claudeSessionId, transcriptBeforeCount);
923
891
  } finally {
924
892
  hilAc.abort();
925
- // Safety: waitForIdle only returns at true turn-idle (no unresolved
926
- // AskUserQuestion by Claude's own `!needsFollowUp` gate). If the
927
- // transcript watcher missed the final tool_result flush due to
928
- // Claude's batched JSONL writes, the UI could be stuck on
929
- // awaiting_input. `resumeSession` in the panel store is idempotent
930
- // (no-op when the session isn't in awaiting_input), so this is
931
- // always safe.
893
+ // Safety: waitForIdle only returns at true turn-idle. If the ask-hook
894
+ // process crashed mid-turn and left the marker on disk, the UI could
895
+ // be stuck on awaiting_input. `resumeSession` in the panel store is
896
+ // idempotent (no-op when the session isn't in awaiting_input), so
897
+ // this is always safe.
932
898
  onHIL?.(false);
933
899
  }
934
900
  } finally {
@@ -189,7 +189,7 @@ export default defineWorkflow({
189
189
  {},
190
190
  async (s) => {
191
191
  const result = await s.session.query(
192
- buildHistoryLocatorPrompt({ question: prompt, root }),
192
+ buildHistoryLocatorPrompt({ question: prompt }),
193
193
  { agent: "codebase-research-locator", ...SUBAGENT_OPTS },
194
194
  );
195
195
  s.save(s.sessionId);
@@ -210,7 +210,6 @@ export default defineWorkflow({
210
210
  buildHistoryAnalyzerPrompt({
211
211
  question: prompt,
212
212
  locatorOutput: historyLocator.result,
213
- root,
214
213
  }),
215
214
  { agent: "codebase-research-analyzer", ...SUBAGENT_OPTS },
216
215
  );
@@ -264,7 +263,6 @@ export default defineWorkflow({
264
263
  buildLocatorPrompt({
265
264
  question: prompt,
266
265
  partition,
267
- root,
268
266
  scoutOverview,
269
267
  index: i,
270
268
  total: explorerCount,
@@ -288,7 +286,6 @@ export default defineWorkflow({
288
286
  buildPatternFinderPrompt({
289
287
  question: prompt,
290
288
  partition,
291
- root,
292
289
  scoutOverview,
293
290
  index: i,
294
291
  total: explorerCount,
@@ -320,7 +317,6 @@ export default defineWorkflow({
320
317
  question: prompt,
321
318
  partition,
322
319
  locatorOutput,
323
- root,
324
320
  scoutOverview,
325
321
  index: i,
326
322
  total: explorerCount,
@@ -345,7 +341,6 @@ export default defineWorkflow({
345
341
  question: prompt,
346
342
  partition,
347
343
  locatorOutput,
348
- root,
349
344
  index: i,
350
345
  total: explorerCount,
351
346
  }),