@bastani/atomic 0.5.19 → 0.5.20
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 +12 -0
- package/.mcp.json +3 -1
- package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -1
- package/dist/sdk/providers/claude.d.ts +49 -0
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/services/config/definitions.d.ts +1 -1
- package/dist/services/config/definitions.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +12 -1
- package/src/commands/cli/chat/index.ts +9 -1
- package/src/commands/cli/claude-stop-hook.test.ts +140 -0
- package/src/commands/cli/claude-stop-hook.ts +90 -0
- package/src/commands/cli/init/index.ts +1 -1
- package/src/sdk/components/workflow-picker-panel.tsx +23 -16
- package/src/sdk/providers/claude.ts +141 -69
- package/src/services/config/atomic-global-config.ts +2 -2
- package/src/services/config/definitions.ts +1 -1
package/.claude/settings.json
CHANGED
|
@@ -21,5 +21,17 @@
|
|
|
21
21
|
"kotlin-lsp@claude-plugins-official": true,
|
|
22
22
|
"swift-lsp@claude-plugins-official": true,
|
|
23
23
|
"lua-lsp@claude-plugins-official": true
|
|
24
|
+
},
|
|
25
|
+
"hooks": {
|
|
26
|
+
"Stop": [
|
|
27
|
+
{
|
|
28
|
+
"hooks": [
|
|
29
|
+
{
|
|
30
|
+
"type": "command",
|
|
31
|
+
"command": "atomic _claude-stop-hook"
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
]
|
|
24
36
|
}
|
|
25
37
|
}
|
package/.mcp.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"workflow-picker-panel.d.ts","sourceRoot":"","sources":["../../../src/sdk/components/workflow-picker-panel.tsx"],"names":[],"mappings":"AAAA,sCAAsC;AACtC;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAEL,KAAK,WAAW,EAIjB,MAAM,eAAe,CAAC;AAQvB,OAAO,EAAgB,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACvE,OAAO,KAAK,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AASpE,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;IACxB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,EAAE,MAAM,EAAE,OAAO,GAAG,WAAW,CAuBlF;AAeD,KAAK,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;AAG7C,qEAAqE;AACrE,MAAM,WAAW,oBAAoB;IACnC,kDAAkD;IAClD,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,mFAAmF;IACnF,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAChC;AA6BD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAsBvE;AAID,UAAU,SAAS;IACjB,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,KAAK,OAAO,GACR;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,CAAC;AAExC;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,oBAAoB,EAAE,GAChC,oBAAoB,EAAE,CASxB;AAED,wBAAgB,YAAY,CAC1B,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,oBAAoB,EAAE,GAChC,SAAS,EAAE,CAkCb;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,SAAS,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,EAAE,CAexE;AAID,wBAAgB,YAAY,CAAC,KAAK,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAIzE;
|
|
1
|
+
{"version":3,"file":"workflow-picker-panel.d.ts","sourceRoot":"","sources":["../../../src/sdk/components/workflow-picker-panel.tsx"],"names":[],"mappings":"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"}
|
|
@@ -90,6 +90,55 @@ export declare function _hasUnresolvedHILTool(messages: SessionMessage[]): boole
|
|
|
90
90
|
* reader are injected rather than hard-coded to `fs.watch` / `getSessionMessages`).
|
|
91
91
|
*/
|
|
92
92
|
export declare function _runHILWatcher(events: AsyncIterable<unknown>, readMessages: () => Promise<SessionMessage[]>, onHIL: (waiting: boolean) => void): Promise<void>;
|
|
93
|
+
/**
|
|
94
|
+
* Path of the directory where the claude-stop-hook writes marker files.
|
|
95
|
+
* Each Claude turn creates `~/.atomic/claude-stop/<session_id>` atomically
|
|
96
|
+
* via rename, which triggers the `fs.watch` event in `waitForIdle`.
|
|
97
|
+
*
|
|
98
|
+
* @internal Exported for unit tests.
|
|
99
|
+
*/
|
|
100
|
+
export declare function markerDir(): string;
|
|
101
|
+
/**
|
|
102
|
+
* Return the marker file path for a given Claude session ID.
|
|
103
|
+
*
|
|
104
|
+
* @internal Exported for unit tests.
|
|
105
|
+
*/
|
|
106
|
+
export declare function markerPath(claudeSessionId: string): string;
|
|
107
|
+
/**
|
|
108
|
+
* Wait for the Claude session to become idle using `fs.watch` on the
|
|
109
|
+
* `~/.atomic/claude-stop/` marker directory.
|
|
110
|
+
*
|
|
111
|
+
* When Claude finishes a turn, the `atomic _claude-stop-hook` Stop hook writes
|
|
112
|
+
* `~/.atomic/claude-stop/<session_id>` atomically (tmp file + rename). The
|
|
113
|
+
* rename triggers an OS-native `fs.watch` event on the parent directory —
|
|
114
|
+
* far more reliable than polling tmux pane glyphs, which vary between Claude
|
|
115
|
+
* Code versions.
|
|
116
|
+
*
|
|
117
|
+
* Algorithm:
|
|
118
|
+
* 1. Watch the marker directory for events whose `filename` matches
|
|
119
|
+
* `claudeSessionId`.
|
|
120
|
+
* 2. On a matching event, read the session transcript via
|
|
121
|
+
* `getSessionMessages` and test `_hasUnresolvedHILTool`.
|
|
122
|
+
* - If unresolved HIL: call `onHIL(true)`, unlink the marker (so the next
|
|
123
|
+
* turn's hook can fire again), and continue watching.
|
|
124
|
+
* - If no unresolved HIL after a prior HIL: call `onHIL(false)`.
|
|
125
|
+
* - If truly idle: slice messages from `transcriptBeforeCount` and return.
|
|
126
|
+
* 3. Clean up the `fs.watch` watcher on any exit path via AbortController.
|
|
127
|
+
*
|
|
128
|
+
* The function signature is intentionally identical to the previous polling
|
|
129
|
+
* implementation so all callers remain unchanged.
|
|
130
|
+
*
|
|
131
|
+
* @param paneId - tmux pane (kept in signature for caller compat; not used here)
|
|
132
|
+
* @param claudeSessionId - Claude's session UUID (used to identify marker file)
|
|
133
|
+
* @param transcriptBeforeCount - number of messages in transcript before this turn
|
|
134
|
+
* @param beforeContent - (unused) pane content before send; kept for compat
|
|
135
|
+
* @param pollIntervalMs - (unused) kept for compat; watch is event-driven
|
|
136
|
+
* @param onHIL - optional callback for HIL state changes
|
|
137
|
+
*/
|
|
138
|
+
/**
|
|
139
|
+
* @internal Exported for unit tests.
|
|
140
|
+
*/
|
|
141
|
+
export declare function waitForIdle(_paneId: string, claudeSessionId: string | undefined, transcriptBeforeCount: number, _beforeContent: string, _pollIntervalMs: number, onHIL?: (waiting: boolean) => void): Promise<SessionMessage[]>;
|
|
93
142
|
export interface ClaudeQueryOptions {
|
|
94
143
|
/** tmux pane ID where Claude is running */
|
|
95
144
|
paneId: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../../src/sdk/providers/claude.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAGL,KAAK,cAAc,EACnB,KAAK,cAAc,EACnB,KAAK,OAAO,IAAI,UAAU,EAC3B,MAAM,gCAAgC,CAAC;
|
|
1
|
+
{"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../../src/sdk/providers/claude.ts"],"names":[],"mappings":"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"}
|
|
@@ -16,7 +16,7 @@ export interface AgentConfig {
|
|
|
16
16
|
install_url: string;
|
|
17
17
|
/** Paths to exclude when copying (relative to folder) */
|
|
18
18
|
exclude: string[];
|
|
19
|
-
/** Project files
|
|
19
|
+
/** Project files applied during `atomic chat` preflight for provider onboarding */
|
|
20
20
|
onboarding_files: Array<{
|
|
21
21
|
source: string;
|
|
22
22
|
destination: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"definitions.d.ts","sourceRoot":"","sources":["../../../src/services/config/definitions.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,WAAW,WAAW;IAC1B,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,kEAAkE;IAClE,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,qFAAqF;IACrF,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,0CAA0C;IAC1C,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,yDAAyD;IACzD,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,
|
|
1
|
+
{"version":3,"file":"definitions.d.ts","sourceRoot":"","sources":["../../../src/services/config/definitions.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,MAAM,WAAW,WAAW;IAC1B,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,mCAAmC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,kEAAkE;IAClE,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,qFAAqF;IACrF,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,0CAA0C;IAC1C,MAAM,EAAE,MAAM,CAAC;IACf,wCAAwC;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,yDAAyD;IACzD,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,mFAAmF;IACnF,gBAAgB,EAAE,KAAK,CAAC;QACtB,MAAM,EAAE,MAAM,CAAC;QACf,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,OAAO,CAAC;KAChB,CAAC,CAAC;CACJ;AAED,QAAA,MAAM,UAAU,4CAA6C,CAAC;AAC9D,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,CAAC,CAAC;AAEnD,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,QAAQ,EAAE,WAAW,CA2DtD,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,WAAW,iBAAiB;IAChC,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,IAAI,QAAQ,CAEzD;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,QAAQ,GAAG,WAAW,CAEzD;AAED,wBAAgB,YAAY,IAAI,QAAQ,EAAE,CAEzC"}
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -283,6 +283,16 @@ Examples:
|
|
|
283
283
|
process.exit(exitCode);
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
// ── Internal: Claude Stop hook handler ────────────────────────────────
|
|
287
|
+
program
|
|
288
|
+
.command("_claude-stop-hook", { hidden: true })
|
|
289
|
+
.description("Internal: Claude Code Stop hook handler — writes a marker file for idle detection")
|
|
290
|
+
.action(async () => {
|
|
291
|
+
const { claudeStopHookCommand } = await import("./commands/cli/claude-stop-hook.ts");
|
|
292
|
+
const exitCode = await claudeStopHookCommand();
|
|
293
|
+
process.exit(exitCode);
|
|
294
|
+
});
|
|
295
|
+
|
|
286
296
|
// ── Completions command ────────────────────────────────────────────────
|
|
287
297
|
program
|
|
288
298
|
.command("completions")
|
|
@@ -333,7 +343,8 @@ async function main(): Promise<void> {
|
|
|
333
343
|
argv.includes("--help") ||
|
|
334
344
|
argv.includes("-h") ||
|
|
335
345
|
argv[0] === "completions" ||
|
|
336
|
-
argv[0] === "_footer"
|
|
346
|
+
argv[0] === "_footer" ||
|
|
347
|
+
argv[0] === "_claude-stop-hook";
|
|
337
348
|
|
|
338
349
|
if (!isInfoCommand) {
|
|
339
350
|
const { autoSyncIfStale } = await import("./services/system/auto-sync.ts");
|
|
@@ -190,7 +190,15 @@ export async function chatCommand(options: ChatCommandOptions = {}): Promise<num
|
|
|
190
190
|
const args = await buildAgentArgs(agentType, passthroughArgs, projectRoot);
|
|
191
191
|
const cmd = [config.cmd, ...args];
|
|
192
192
|
const overrides = await getProviderOverrides(agentType, projectRoot);
|
|
193
|
-
|
|
193
|
+
// ATOMIC_AGENT must be baked into the launcher env so the agent CLI
|
|
194
|
+
// (and anything it spawns) can read it. `setSessionEnv` below only
|
|
195
|
+
// affects processes spawned *after* the initial command, so it cannot
|
|
196
|
+
// populate the env of the agent CLI that `new-session` kicks off.
|
|
197
|
+
const envVars = {
|
|
198
|
+
...config.env_vars,
|
|
199
|
+
...overrides.envVars,
|
|
200
|
+
ATOMIC_AGENT: agentType,
|
|
201
|
+
};
|
|
194
202
|
|
|
195
203
|
// ── No TTY: tmux attach requires a real terminal ──
|
|
196
204
|
if (!process.stdin.isTTY) {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for claudeStopHookCommand.
|
|
3
|
+
*
|
|
4
|
+
* Strategy: monkey-patch `Bun.stdin.text` to return preset strings so we can
|
|
5
|
+
* call the function directly without spawning subprocesses. This is
|
|
6
|
+
* consistent with how other CLI-command tests in this directory work.
|
|
7
|
+
*
|
|
8
|
+
* Filesystem isolation: we use `crypto.randomUUID()` for unique session IDs
|
|
9
|
+
* and clean up in `afterEach` so test runs never collide with each other
|
|
10
|
+
* or with real marker files.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, test, expect, afterEach, mock, spyOn } from "bun:test";
|
|
14
|
+
import { access, rm } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { claudeStopHookCommand } from "./claude-stop-hook.ts";
|
|
18
|
+
|
|
19
|
+
// Paths we'll need in every test.
|
|
20
|
+
const markerDir = join(homedir(), ".atomic", "claude-stop");
|
|
21
|
+
|
|
22
|
+
/** Returns true when a file exists at `filePath`. */
|
|
23
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
24
|
+
try {
|
|
25
|
+
await access(filePath);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Patch `Bun.stdin.text` for the duration of one test. */
|
|
33
|
+
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
|
+
(Bun.stdin as { text: () => Promise<string> }).text = () =>
|
|
38
|
+
Promise.resolve(text);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Cleanup
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
const sessionIdsToClean: string[] = [];
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
// Remove any marker files created during the test.
|
|
49
|
+
for (const id of sessionIdsToClean) {
|
|
50
|
+
await rm(join(markerDir, id), { force: true });
|
|
51
|
+
await rm(join(markerDir, `${id}.tmp`), { force: true });
|
|
52
|
+
}
|
|
53
|
+
sessionIdsToClean.length = 0;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Tests
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
describe("claudeStopHookCommand", () => {
|
|
61
|
+
// 1. Valid payload → writes marker file
|
|
62
|
+
test("valid payload writes marker file and returns 0", async () => {
|
|
63
|
+
const sessionId = crypto.randomUUID();
|
|
64
|
+
sessionIdsToClean.push(sessionId);
|
|
65
|
+
|
|
66
|
+
mockStdin(JSON.stringify({ session_id: sessionId }));
|
|
67
|
+
|
|
68
|
+
const code = await claudeStopHookCommand();
|
|
69
|
+
|
|
70
|
+
expect(code).toBe(0);
|
|
71
|
+
expect(await fileExists(join(markerDir, sessionId))).toBe(true);
|
|
72
|
+
expect(await fileExists(join(markerDir, `${sessionId}.tmp`))).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// 2. stop_hook_active: true → no-op
|
|
76
|
+
test("stop_hook_active:true is a no-op and returns 0", async () => {
|
|
77
|
+
const sessionId = crypto.randomUUID();
|
|
78
|
+
sessionIdsToClean.push(sessionId);
|
|
79
|
+
|
|
80
|
+
mockStdin(
|
|
81
|
+
JSON.stringify({ session_id: sessionId, stop_hook_active: true }),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const code = await claudeStopHookCommand();
|
|
85
|
+
|
|
86
|
+
expect(code).toBe(0);
|
|
87
|
+
expect(await fileExists(join(markerDir, sessionId))).toBe(false);
|
|
88
|
+
expect(await fileExists(join(markerDir, `${sessionId}.tmp`))).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 3. Malformed JSON → returns 0, logs to console.error
|
|
92
|
+
test("malformed JSON returns 0 and logs an error", async () => {
|
|
93
|
+
mockStdin("not json {{{");
|
|
94
|
+
|
|
95
|
+
// Spy on console.error so the error doesn't bleed into test output.
|
|
96
|
+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
97
|
+
|
|
98
|
+
const code = await claudeStopHookCommand();
|
|
99
|
+
|
|
100
|
+
expect(code).toBe(0);
|
|
101
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
102
|
+
|
|
103
|
+
errorSpy.mockRestore();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// 4. Missing session_id → returns 0, logs to console.error
|
|
107
|
+
test("missing session_id returns 0 and logs an error", async () => {
|
|
108
|
+
mockStdin(JSON.stringify({}));
|
|
109
|
+
|
|
110
|
+
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
111
|
+
|
|
112
|
+
const code = await claudeStopHookCommand();
|
|
113
|
+
|
|
114
|
+
expect(code).toBe(0);
|
|
115
|
+
expect(errorSpy).toHaveBeenCalled();
|
|
116
|
+
|
|
117
|
+
errorSpy.mockRestore();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 5. Extra payload fields are tolerated
|
|
121
|
+
test("valid payload with optional fields writes marker and returns 0", async () => {
|
|
122
|
+
const sessionId = crypto.randomUUID();
|
|
123
|
+
sessionIdsToClean.push(sessionId);
|
|
124
|
+
|
|
125
|
+
mockStdin(
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
session_id: sessionId,
|
|
128
|
+
transcript_path: "/tmp/transcript.json",
|
|
129
|
+
cwd: "/home/user/project",
|
|
130
|
+
stop_hook_active: false,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const code = await claudeStopHookCommand();
|
|
135
|
+
|
|
136
|
+
expect(code).toBe(0);
|
|
137
|
+
expect(await fileExists(join(markerDir, sessionId))).toBe(true);
|
|
138
|
+
expect(await fileExists(join(markerDir, `${sessionId}.tmp`))).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Stop Hook command — internal handler for Claude Code's Stop hook.
|
|
3
|
+
*
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* Usage (configured in Claude's Stop hook):
|
|
10
|
+
* atomic _claude-stop-hook
|
|
11
|
+
*
|
|
12
|
+
* Payload (JSON via stdin):
|
|
13
|
+
* {
|
|
14
|
+
* "session_id": "abc123",
|
|
15
|
+
* "transcript_path": "/path/to/transcript",
|
|
16
|
+
* "cwd": "/path/to/cwd",
|
|
17
|
+
* "stop_hook_active": false
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs/promises";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
|
|
25
|
+
/** Shape of the JSON payload Claude pipes to the Stop hook via stdin. */
|
|
26
|
+
export interface ClaudeStopHookPayload {
|
|
27
|
+
session_id: string;
|
|
28
|
+
transcript_path?: string;
|
|
29
|
+
cwd?: string;
|
|
30
|
+
stop_hook_active?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Type guard to verify that a parsed value conforms to ClaudeStopHookPayload.
|
|
35
|
+
*/
|
|
36
|
+
function isClaudeStopHookPayload(value: unknown): value is ClaudeStopHookPayload {
|
|
37
|
+
if (typeof value !== "object" || value === null) return false;
|
|
38
|
+
const obj = value as Record<string, unknown>;
|
|
39
|
+
if (typeof obj["session_id"] !== "string") return false;
|
|
40
|
+
if (obj["transcript_path"] !== undefined && typeof obj["transcript_path"] !== "string") return false;
|
|
41
|
+
if (obj["cwd"] !== undefined && typeof obj["cwd"] !== "string") return false;
|
|
42
|
+
if (obj["stop_hook_active"] !== undefined && typeof obj["stop_hook_active"] !== "boolean") return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Handler for the hidden `_claude-stop-hook` subcommand.
|
|
48
|
+
*
|
|
49
|
+
* Returns an exit code (0 on success or benign failure). The caller
|
|
50
|
+
* in src/cli.ts does `process.exit(exitCode)`, so we just return the code.
|
|
51
|
+
*
|
|
52
|
+
* We always return 0 — a non-zero exit would surface as a hook error in
|
|
53
|
+
* Claude's transcript, which is not what we want.
|
|
54
|
+
*/
|
|
55
|
+
export async function claudeStopHookCommand(): Promise<number> {
|
|
56
|
+
// 1. Read stdin
|
|
57
|
+
const raw = await Bun.stdin.text();
|
|
58
|
+
|
|
59
|
+
// 2. Parse JSON
|
|
60
|
+
let payload: ClaudeStopHookPayload;
|
|
61
|
+
try {
|
|
62
|
+
const parsed: unknown = JSON.parse(raw);
|
|
63
|
+
if (!isClaudeStopHookPayload(parsed)) {
|
|
64
|
+
console.error("[claude-stop-hook] Invalid payload: missing or malformed 'session_id'");
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
payload = parsed;
|
|
68
|
+
} catch {
|
|
69
|
+
console.error("[claude-stop-hook] Failed to parse stdin as JSON");
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 3. Guard against infinite Stop-hook loops
|
|
74
|
+
if (payload.stop_hook_active === true) {
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 4. Write the marker file atomically
|
|
79
|
+
const markerDir = path.join(os.homedir(), ".atomic", "claude-stop");
|
|
80
|
+
await fs.mkdir(markerDir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
const tmpPath = path.join(markerDir, `${payload.session_id}.tmp`);
|
|
83
|
+
const finalPath = path.join(markerDir, payload.session_id);
|
|
84
|
+
|
|
85
|
+
// Write contents — the watcher only cares that the file appears.
|
|
86
|
+
await Bun.write(tmpPath, raw);
|
|
87
|
+
await fs.rename(tmpPath, finalPath);
|
|
88
|
+
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
@@ -572,6 +572,21 @@ function TextAreaContent({
|
|
|
572
572
|
const onInputRef = useLatest(onInput);
|
|
573
573
|
const lastTextRef = useRef(value);
|
|
574
574
|
|
|
575
|
+
// Read plainText on the next microtask so the textarea has applied the
|
|
576
|
+
// keystroke/paste before we observe its content, then fire onInput if
|
|
577
|
+
// it changed.
|
|
578
|
+
const flushPending = useCallback(() => {
|
|
579
|
+
queueMicrotask(() => {
|
|
580
|
+
const inst = instanceRef.current;
|
|
581
|
+
if (!inst) return;
|
|
582
|
+
const current = inst.plainText;
|
|
583
|
+
if (current !== lastTextRef.current) {
|
|
584
|
+
lastTextRef.current = current;
|
|
585
|
+
onInputRef.current(current);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
}, []);
|
|
589
|
+
|
|
575
590
|
const refCallback = useCallback((instance: TextareaRenderable | null) => {
|
|
576
591
|
instanceRef.current = instance;
|
|
577
592
|
}, []);
|
|
@@ -585,23 +600,14 @@ function TextAreaContent({
|
|
|
585
600
|
}
|
|
586
601
|
}, [value]);
|
|
587
602
|
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
603
|
+
// flushPending fires on each keystroke via useKeyboard; onPaste handles
|
|
604
|
+
// bracketed pastes (which don't fire keydown). The native Zig edit
|
|
605
|
+
// buffer's "content-changed" event is unreliable for propagating to the
|
|
606
|
+
// JS _contentChangeListener in certain installed environments, so we
|
|
607
|
+
// hook into useKeyboard (which fires before the textarea processes the
|
|
608
|
+
// key) and defer the read with queueMicrotask so the textarea has
|
|
593
609
|
// processed the keystroke by the time we read plainText.
|
|
594
|
-
useKeyboard(
|
|
595
|
-
queueMicrotask(() => {
|
|
596
|
-
const inst = instanceRef.current;
|
|
597
|
-
if (!inst) return;
|
|
598
|
-
const current = inst.plainText;
|
|
599
|
-
if (current !== lastTextRef.current) {
|
|
600
|
-
lastTextRef.current = current;
|
|
601
|
-
onInputRef.current(current);
|
|
602
|
-
}
|
|
603
|
-
});
|
|
604
|
-
}, []));
|
|
610
|
+
useKeyboard(flushPending);
|
|
605
611
|
|
|
606
612
|
return (
|
|
607
613
|
<textarea
|
|
@@ -616,6 +622,7 @@ function TextAreaContent({
|
|
|
616
622
|
placeholderColor={theme.textDim}
|
|
617
623
|
wrapMode="word"
|
|
618
624
|
flexGrow={1}
|
|
625
|
+
onPaste={flushPending}
|
|
619
626
|
/>
|
|
620
627
|
);
|
|
621
628
|
}
|
|
@@ -37,10 +37,11 @@ import {
|
|
|
37
37
|
waitForPaneReady,
|
|
38
38
|
attemptSubmitRounds,
|
|
39
39
|
} from "../runtime/tmux.ts";
|
|
40
|
-
import { watch } from "node:fs/promises";
|
|
40
|
+
import { watch, unlink, mkdir } from "node:fs/promises";
|
|
41
41
|
import { existsSync, writeFileSync } from "node:fs";
|
|
42
42
|
import { join } from "node:path";
|
|
43
43
|
import { randomUUID } from "node:crypto";
|
|
44
|
+
import os from "node:os";
|
|
44
45
|
|
|
45
46
|
// ---------------------------------------------------------------------------
|
|
46
47
|
// Session tracking — ensures createClaudeSession is called before claudeQuery
|
|
@@ -346,95 +347,161 @@ export async function _runHILWatcher(
|
|
|
346
347
|
// Helpers
|
|
347
348
|
// ---------------------------------------------------------------------------
|
|
348
349
|
|
|
350
|
+
/**
|
|
351
|
+
* Path of the directory where the claude-stop-hook writes marker files.
|
|
352
|
+
* Each Claude turn creates `~/.atomic/claude-stop/<session_id>` atomically
|
|
353
|
+
* via rename, which triggers the `fs.watch` event in `waitForIdle`.
|
|
354
|
+
*
|
|
355
|
+
* @internal Exported for unit tests.
|
|
356
|
+
*/
|
|
357
|
+
export function markerDir(): string {
|
|
358
|
+
return join(os.homedir(), ".atomic", "claude-stop");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Return the marker file path for a given Claude session ID.
|
|
363
|
+
*
|
|
364
|
+
* @internal Exported for unit tests.
|
|
365
|
+
*/
|
|
366
|
+
export function markerPath(claudeSessionId: string): string {
|
|
367
|
+
return join(markerDir(), claudeSessionId);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Ensure the marker directory exists and remove any stale marker left from a
|
|
372
|
+
* previous turn of this session. Call this BEFORE submitting the prompt so
|
|
373
|
+
* the subsequent `waitForIdle` watch loop doesn't fire on a stale file.
|
|
374
|
+
*
|
|
375
|
+
* Ignores ENOENT on `unlink` — the file simply doesn't exist yet.
|
|
376
|
+
*/
|
|
377
|
+
async function clearStaleMarker(claudeSessionId: string): Promise<void> {
|
|
378
|
+
await mkdir(markerDir(), { recursive: true });
|
|
379
|
+
try {
|
|
380
|
+
await unlink(markerPath(claudeSessionId));
|
|
381
|
+
} catch (e: unknown) {
|
|
382
|
+
// ENOENT is expected — ignore it; rethrow anything else
|
|
383
|
+
if (!(e instanceof Error && "code" in e && (e as NodeJS.ErrnoException).code === "ENOENT")) {
|
|
384
|
+
throw e;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
349
389
|
// ---------------------------------------------------------------------------
|
|
350
|
-
// Idle detection via
|
|
390
|
+
// Idle detection via marker file watch
|
|
351
391
|
// ---------------------------------------------------------------------------
|
|
352
392
|
|
|
353
393
|
/**
|
|
354
|
-
* Wait for the Claude session to become idle
|
|
394
|
+
* Wait for the Claude session to become idle using `fs.watch` on the
|
|
395
|
+
* `~/.atomic/claude-stop/` marker directory.
|
|
396
|
+
*
|
|
397
|
+
* When Claude finishes a turn, the `atomic _claude-stop-hook` Stop hook writes
|
|
398
|
+
* `~/.atomic/claude-stop/<session_id>` atomically (tmp file + rename). The
|
|
399
|
+
* rename triggers an OS-native `fs.watch` event on the parent directory —
|
|
400
|
+
* far more reliable than polling tmux pane glyphs, which vary between Claude
|
|
401
|
+
* Code versions.
|
|
355
402
|
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
403
|
+
* Algorithm:
|
|
404
|
+
* 1. Watch the marker directory for events whose `filename` matches
|
|
405
|
+
* `claudeSessionId`.
|
|
406
|
+
* 2. On a matching event, read the session transcript via
|
|
407
|
+
* `getSessionMessages` and test `_hasUnresolvedHILTool`.
|
|
408
|
+
* - If unresolved HIL: call `onHIL(true)`, unlink the marker (so the next
|
|
409
|
+
* turn's hook can fire again), and continue watching.
|
|
410
|
+
* - If no unresolved HIL after a prior HIL: call `onHIL(false)`.
|
|
411
|
+
* - If truly idle: slice messages from `transcriptBeforeCount` and return.
|
|
412
|
+
* 3. Clean up the `fs.watch` watcher on any exit path via AbortController.
|
|
360
413
|
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* the transcript has structured content blocks, not terminal escape codes.
|
|
414
|
+
* The function signature is intentionally identical to the previous polling
|
|
415
|
+
* implementation so all callers remain unchanged.
|
|
364
416
|
*
|
|
365
|
-
*
|
|
417
|
+
* @param paneId - tmux pane (kept in signature for caller compat; not used here)
|
|
418
|
+
* @param claudeSessionId - Claude's session UUID (used to identify marker file)
|
|
419
|
+
* @param transcriptBeforeCount - number of messages in transcript before this turn
|
|
420
|
+
* @param beforeContent - (unused) pane content before send; kept for compat
|
|
421
|
+
* @param pollIntervalMs - (unused) kept for compat; watch is event-driven
|
|
422
|
+
* @param onHIL - optional callback for HIL state changes
|
|
366
423
|
*/
|
|
367
|
-
|
|
368
|
-
|
|
424
|
+
/**
|
|
425
|
+
* @internal Exported for unit tests.
|
|
426
|
+
*/
|
|
427
|
+
export async function waitForIdle(
|
|
428
|
+
_paneId: string,
|
|
369
429
|
claudeSessionId: string | undefined,
|
|
370
430
|
transcriptBeforeCount: number,
|
|
371
|
-
|
|
372
|
-
|
|
431
|
+
_beforeContent: string,
|
|
432
|
+
_pollIntervalMs: number,
|
|
373
433
|
onHIL?: (waiting: boolean) => void,
|
|
374
434
|
): Promise<SessionMessage[]> {
|
|
375
|
-
//
|
|
376
|
-
|
|
435
|
+
// Without a session ID we cannot watch the marker directory — return empty.
|
|
436
|
+
if (!claudeSessionId) {
|
|
437
|
+
return [];
|
|
438
|
+
}
|
|
377
439
|
|
|
378
|
-
|
|
440
|
+
const dir = markerDir();
|
|
441
|
+
const sessionId = claudeSessionId;
|
|
442
|
+
const ac = new AbortController();
|
|
379
443
|
|
|
380
|
-
|
|
381
|
-
const currentContent = normalizeTmuxLines(capturePaneScrollback(paneId));
|
|
382
|
-
|
|
383
|
-
// Must have new content compared to before we sent
|
|
384
|
-
if (currentContent !== beforeContent) {
|
|
385
|
-
const visible = capturePaneVisible(paneId);
|
|
386
|
-
if (paneLooksReady(visible) && !paneHasActiveTask(visible)) {
|
|
387
|
-
// Pane looks idle — but it might be waiting for user input (HIL).
|
|
388
|
-
// Check the transcript for an unresolved AskUserQuestion before
|
|
389
|
-
// treating this as a true completion.
|
|
390
|
-
if (claudeSessionId) {
|
|
391
|
-
try {
|
|
392
|
-
const msgs = await getSessionMessages(claudeSessionId, {
|
|
393
|
-
dir: process.cwd(),
|
|
394
|
-
includeSystemMessages: true,
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
if (_hasUnresolvedHILTool(msgs)) {
|
|
398
|
-
// Agent is blocked on user input — signal HIL and keep waiting
|
|
399
|
-
if (!hilActive && onHIL) {
|
|
400
|
-
onHIL(true);
|
|
401
|
-
hilActive = true;
|
|
402
|
-
}
|
|
403
|
-
await Bun.sleep(pollIntervalMs);
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
444
|
+
let hilActive = false;
|
|
406
445
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
446
|
+
try {
|
|
447
|
+
for await (const event of watch(dir, { signal: ac.signal })) {
|
|
448
|
+
// Filter: only care about events for our session's marker file
|
|
449
|
+
if (event.filename !== sessionId) continue;
|
|
450
|
+
|
|
451
|
+
// Marker appeared — read transcript
|
|
452
|
+
let msgs: SessionMessage[];
|
|
453
|
+
try {
|
|
454
|
+
msgs = await getSessionMessages(sessionId, {
|
|
455
|
+
dir: process.cwd(),
|
|
456
|
+
includeSystemMessages: true,
|
|
457
|
+
});
|
|
458
|
+
} catch {
|
|
459
|
+
// Transcript read failed — wait for the next marker event
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
416
462
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
// Transcript read failed — return empty
|
|
423
|
-
}
|
|
463
|
+
if (_hasUnresolvedHILTool(msgs)) {
|
|
464
|
+
// Agent is blocked on user input (HIL).
|
|
465
|
+
if (!hilActive) {
|
|
466
|
+
onHIL?.(true);
|
|
467
|
+
hilActive = true;
|
|
424
468
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
hilActive = false;
|
|
469
|
+
// Remove the marker so the Stop hook can write a new one after the
|
|
470
|
+
// user responds and Claude finishes its next turn.
|
|
471
|
+
try {
|
|
472
|
+
await unlink(markerPath(sessionId));
|
|
473
|
+
} catch {
|
|
474
|
+
// ENOENT is fine — ignore
|
|
432
475
|
}
|
|
476
|
+
// Continue watching for the next marker event
|
|
477
|
+
continue;
|
|
433
478
|
}
|
|
434
|
-
}
|
|
435
479
|
|
|
436
|
-
|
|
480
|
+
// No unresolved HIL — if we were in HIL state, signal resolution.
|
|
481
|
+
if (hilActive) {
|
|
482
|
+
onHIL?.(false);
|
|
483
|
+
hilActive = false;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Truly idle — return transcript messages produced during this turn.
|
|
487
|
+
const result = msgs.length > transcriptBeforeCount
|
|
488
|
+
? msgs.slice(transcriptBeforeCount)
|
|
489
|
+
: [];
|
|
490
|
+
|
|
491
|
+
ac.abort();
|
|
492
|
+
return result;
|
|
493
|
+
}
|
|
494
|
+
} catch (e: unknown) {
|
|
495
|
+
// AbortError is expected when we call ac.abort() to stop watching.
|
|
496
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
497
|
+
// Normal exit — return value was already set and returned above.
|
|
498
|
+
// If we somehow reach here without returning, fall through to [].
|
|
499
|
+
} else {
|
|
500
|
+
throw e;
|
|
501
|
+
}
|
|
437
502
|
}
|
|
503
|
+
|
|
504
|
+
return [];
|
|
438
505
|
}
|
|
439
506
|
|
|
440
507
|
// ---------------------------------------------------------------------------
|
|
@@ -542,6 +609,11 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<SessionM
|
|
|
542
609
|
const dir = process.cwd();
|
|
543
610
|
const claudeSessionId = paneState.claudeSessionId;
|
|
544
611
|
|
|
612
|
+
// ── Clear any stale marker left from a previous turn before submitting. ──
|
|
613
|
+
// This ensures `waitForIdle`'s watch loop doesn't fire on the marker written
|
|
614
|
+
// by the Stop hook at the end of the LAST turn instead of the current one.
|
|
615
|
+
await clearStaleMarker(claudeSessionId);
|
|
616
|
+
|
|
545
617
|
// ── First query: spawn `claude --session-id <UUID> 'Read the prompt in <path>'`.
|
|
546
618
|
// The prompt is delivered via Claude's Read tool on its first turn — no
|
|
547
619
|
// paste-buffer, no submit retries. Subsequent queries fall through to the
|
|
@@ -330,8 +330,8 @@ export async function hasAtomicGlobalAgentConfigs(
|
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
/**
|
|
333
|
-
* Verify-and-repair entrypoint for user-facing commands (`atomic
|
|
334
|
-
*
|
|
333
|
+
* Verify-and-repair entrypoint for user-facing commands (`atomic chat`).
|
|
334
|
+
* If every bundled agent file is present at its
|
|
335
335
|
* destination, returns immediately without touching disk. Otherwise
|
|
336
336
|
* runs a merge re-sync, which fills the missing files from the local
|
|
337
337
|
* config data dir while leaving user-added files alone.
|
|
@@ -17,7 +17,7 @@ export interface AgentConfig {
|
|
|
17
17
|
install_url: string;
|
|
18
18
|
/** Paths to exclude when copying (relative to folder) */
|
|
19
19
|
exclude: string[];
|
|
20
|
-
/** Project files
|
|
20
|
+
/** Project files applied during `atomic chat` preflight for provider onboarding */
|
|
21
21
|
onboarding_files: Array<{
|
|
22
22
|
source: string;
|
|
23
23
|
destination: string;
|