@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.
- package/.agents/skills/workflow-creator/SKILL.md +2 -2
- package/.agents/skills/workflow-creator/references/agent-sessions.md +21 -26
- package/.agents/skills/workflow-creator/references/discovery-and-verification.md +1 -1
- package/.agents/skills/workflow-creator/references/failure-modes.md +16 -9
- package/.agents/skills/workflow-creator/references/getting-started.md +0 -1
- package/.agents/skills/workflow-creator/references/session-config.md +5 -12
- package/.agents/skills/workflow-creator/references/workflow-inputs.md +2 -2
- package/.claude/agents/reviewer.md +2 -2
- package/.github/agents/reviewer.md +2 -2
- package/.opencode/agents/reviewer.md +2 -2
- package/dist/commands/cli/claude-stop-hook.d.ts +1 -0
- package/dist/commands/cli/claude-stop-hook.d.ts.map +1 -1
- package/dist/sdk/providers/claude.d.ts +9 -47
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- 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.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +0 -6
- package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/ralph/helpers/prompts.d.ts +4 -4
- package/dist/sdk/workflows/builtin/ralph/helpers/prompts.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +17 -1
- package/src/commands/cli/claude-ask-hook.test.ts +128 -0
- package/src/commands/cli/claude-ask-hook.ts +84 -0
- package/src/commands/cli/claude-stop-hook.ts +2 -1
- package/src/sdk/providers/claude.ts +126 -160
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +1 -6
- package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +1 -6
- package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +30 -47
- package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +0 -6
- package/src/sdk/workflows/builtin/ralph/copilot/index.ts +2 -2
- 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
|
|
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
|
|
98
|
+
function buildWorkflowHookCommand(subcommand: string, extraArgs: readonly string[] = []): string {
|
|
99
99
|
if (import.meta.dir.includes("node_modules")) {
|
|
100
|
-
return "atomic
|
|
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
|
|
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
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
`'${
|
|
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
|
-
*
|
|
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
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* `
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
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
|
|
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 =
|
|
417
|
+
const { hil: dir } = claudeHookDirs();
|
|
418
|
+
const target = join(dir, claudeSessionId);
|
|
464
419
|
|
|
465
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
|
|
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
|
-
//
|
|
500
|
-
//
|
|
501
|
-
|
|
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
|
|
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
|
|
910
|
-
//
|
|
911
|
-
//
|
|
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
|
|
915
|
-
|
|
916
|
-
|
|
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
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
//
|
|
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
|
|
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
|
}),
|