@bastani/atomic 0.5.18 → 0.5.19-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 +110 -1
- package/.agents/skills/workflow-creator/references/workflow-inputs.md +10 -0
- package/.mcp.json +9 -0
- package/.opencode/opencode.json +5 -2
- package/README.md +394 -645
- package/assets/settings.schema.json +0 -20
- package/dist/sdk/components/attached-statusline.d.ts +13 -0
- package/dist/sdk/components/attached-statusline.d.ts.map +1 -0
- package/dist/sdk/components/header.d.ts.map +1 -1
- package/dist/sdk/components/session-graph-panel.d.ts.map +1 -1
- package/dist/sdk/components/statusline.d.ts +1 -3
- package/dist/sdk/components/statusline.d.ts.map +1 -1
- package/dist/sdk/providers/claude.d.ts +16 -5
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts +63 -0
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/runtime/tmux.d.ts +0 -9
- package/dist/sdk/runtime/tmux.d.ts.map +1 -1
- package/dist/services/config/atomic-config.d.ts +1 -7
- package/dist/services/config/atomic-config.d.ts.map +1 -1
- package/dist/services/config/definitions.d.ts +0 -45
- package/dist/services/config/definitions.d.ts.map +1 -1
- package/dist/services/config/index.d.ts +1 -1
- package/dist/theme/colors.d.ts +33 -0
- package/dist/theme/colors.d.ts.map +1 -0
- package/package.json +3 -2
- package/src/cli.ts +16 -1
- package/src/commands/cli/chat/index.ts +1 -1
- package/src/commands/cli/footer.tsx +118 -0
- package/src/commands/cli/init/index.ts +6 -89
- package/src/commands/cli/workflow-command.test.ts +146 -0
- package/src/commands/cli/workflow.ts +43 -7
- package/src/completions/bash.ts +3 -8
- package/src/completions/fish.ts +1 -3
- package/src/completions/powershell.ts +1 -17
- package/src/completions/zsh.ts +0 -2
- package/src/scripts/bundle-configs.ts +0 -12
- package/src/sdk/components/attached-statusline.tsx +33 -0
- package/src/sdk/components/header.tsx +16 -2
- package/src/sdk/components/session-graph-panel.tsx +10 -51
- package/src/sdk/components/statusline.tsx +0 -17
- package/src/sdk/providers/claude.ts +179 -177
- package/src/sdk/runtime/executor-entry.ts +3 -1
- package/src/sdk/runtime/executor.test.ts +292 -1
- package/src/sdk/runtime/executor.ts +222 -1
- package/src/sdk/runtime/tmux.conf +35 -4
- package/src/sdk/runtime/tmux.ts +0 -22
- package/src/services/config/atomic-config.ts +1 -14
- package/src/services/config/definitions.ts +1 -102
- package/src/services/config/index.ts +1 -1
- package/src/services/config/settings.ts +2 -65
- package/src/services/system/skills.ts +2 -19
- package/src/commands/cli/init/scm.ts +0 -175
|
@@ -18,18 +18,7 @@ import {
|
|
|
18
18
|
useRef,
|
|
19
19
|
useContext,
|
|
20
20
|
} from "react";
|
|
21
|
-
import {
|
|
22
|
-
tmuxRun,
|
|
23
|
-
TMUX_DEFAULT_STATUS_LEFT,
|
|
24
|
-
TMUX_DEFAULT_STATUS_LEFT_LENGTH,
|
|
25
|
-
TMUX_DEFAULT_STATUS_RIGHT,
|
|
26
|
-
TMUX_DEFAULT_STATUS_RIGHT_LENGTH,
|
|
27
|
-
TMUX_ATTACHED_STATUS_RIGHT,
|
|
28
|
-
TMUX_ATTACHED_STATUS_RIGHT_LENGTH,
|
|
29
|
-
TMUX_ATTACHED_WINDOW_FMT,
|
|
30
|
-
TMUX_ATTACHED_WINDOW_STYLE,
|
|
31
|
-
TMUX_ATTACHED_WINDOW_CURRENT_STYLE,
|
|
32
|
-
} from "../runtime/tmux.ts";
|
|
21
|
+
import { tmuxRun } from "../runtime/tmux.ts";
|
|
33
22
|
import {
|
|
34
23
|
useStore,
|
|
35
24
|
useGraphTheme,
|
|
@@ -394,47 +383,17 @@ export function SessionGraphPanel() {
|
|
|
394
383
|
}, [tmuxSession, hasStartedAgent]);
|
|
395
384
|
|
|
396
385
|
// ── Tmux status bar sync ──────────────────────────────
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
if (store.viewMode === "attached") {
|
|
402
|
-
tmuxRun(["set", "-g", "status-left", " "]);
|
|
403
|
-
tmuxRun(["set", "-g", "status-left-length", "1"]);
|
|
404
|
-
tmuxRun(["set", "-g", "status-right", TMUX_ATTACHED_STATUS_RIGHT]);
|
|
405
|
-
tmuxRun(["set", "-g", "status-right-length", TMUX_ATTACHED_STATUS_RIGHT_LENGTH]);
|
|
406
|
-
tmuxRun(["set", "-g", "window-status-format", TMUX_ATTACHED_WINDOW_FMT]);
|
|
407
|
-
tmuxRun(["set", "-g", "window-status-current-format", TMUX_ATTACHED_WINDOW_FMT]);
|
|
408
|
-
tmuxRun(["set", "-g", "window-status-style", TMUX_ATTACHED_WINDOW_STYLE]);
|
|
409
|
-
tmuxRun(["set", "-g", "window-status-current-style", TMUX_ATTACHED_WINDOW_CURRENT_STYLE]);
|
|
410
|
-
tmuxRun(["set", "-g", "window-status-separator", ""]);
|
|
411
|
-
} else {
|
|
412
|
-
tmuxRun(["set", "-g", "status-left", TMUX_DEFAULT_STATUS_LEFT]);
|
|
413
|
-
tmuxRun(["set", "-g", "status-left-length", TMUX_DEFAULT_STATUS_LEFT_LENGTH]);
|
|
414
|
-
tmuxRun(["set", "-g", "status-right", TMUX_DEFAULT_STATUS_RIGHT]);
|
|
415
|
-
tmuxRun(["set", "-g", "status-right-length", TMUX_DEFAULT_STATUS_RIGHT_LENGTH]);
|
|
416
|
-
tmuxRun(["set", "-gu", "window-status-format"]);
|
|
417
|
-
tmuxRun(["set", "-gu", "window-status-current-format"]);
|
|
418
|
-
tmuxRun(["set", "-gu", "window-status-style"]);
|
|
419
|
-
tmuxRun(["set", "-gu", "window-status-current-style"]);
|
|
420
|
-
tmuxRun(["set", "-gu", "window-status-separator"]);
|
|
421
|
-
}
|
|
422
|
-
}, [store.viewMode]);
|
|
423
|
-
|
|
424
|
-
// Restore default tmux status bar on unmount
|
|
386
|
+
// The workflow owns its footer: the tmux status bar is hidden for this
|
|
387
|
+
// session so the React-rendered Statusline is the single source of truth
|
|
388
|
+
// in both graph and attached modes. Scoped via `-t <tmuxSession>` so other
|
|
389
|
+
// sessions on the atomic socket (e.g. chat) keep the tmux.conf defaults.
|
|
425
390
|
useEffect(() => {
|
|
391
|
+
const s = tmuxSession;
|
|
392
|
+
tmuxRun(["set", "-t", s, "status", "off"]);
|
|
426
393
|
return () => {
|
|
427
|
-
tmuxRun(["set", "-
|
|
428
|
-
tmuxRun(["set", "-g", "status-left-length", TMUX_DEFAULT_STATUS_LEFT_LENGTH]);
|
|
429
|
-
tmuxRun(["set", "-g", "status-right", TMUX_DEFAULT_STATUS_RIGHT]);
|
|
430
|
-
tmuxRun(["set", "-g", "status-right-length", TMUX_DEFAULT_STATUS_RIGHT_LENGTH]);
|
|
431
|
-
tmuxRun(["set", "-gu", "window-status-format"]);
|
|
432
|
-
tmuxRun(["set", "-gu", "window-status-current-format"]);
|
|
433
|
-
tmuxRun(["set", "-gu", "window-status-style"]);
|
|
434
|
-
tmuxRun(["set", "-gu", "window-status-current-style"]);
|
|
435
|
-
tmuxRun(["set", "-gu", "window-status-separator"]);
|
|
394
|
+
tmuxRun(["set", "-tu", s, "status"]);
|
|
436
395
|
};
|
|
437
|
-
}, []);
|
|
396
|
+
}, [tmuxSession]);
|
|
438
397
|
|
|
439
398
|
return (
|
|
440
399
|
<box width="100%" height="100%" flexDirection="column" backgroundColor={theme.background}>
|
|
@@ -499,7 +458,7 @@ export function SessionGraphPanel() {
|
|
|
499
458
|
{/* Compact agent switcher overlay */}
|
|
500
459
|
{switcherOpen ? <CompactSwitcher selectedIndex={switcherSel} /> : null}
|
|
501
460
|
|
|
502
|
-
<Statusline
|
|
461
|
+
<Statusline attachMsg={attachMsg} />
|
|
503
462
|
</box>
|
|
504
463
|
);
|
|
505
464
|
}
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/react */
|
|
2
2
|
|
|
3
3
|
import { useStore, useGraphTheme, useStoreVersion } from "./orchestrator-panel-contexts.ts";
|
|
4
|
-
import { statusIcon, statusColor } from "./status-helpers.ts";
|
|
5
|
-
import type { LayoutNode } from "./layout.ts";
|
|
6
4
|
|
|
7
5
|
export function Statusline({
|
|
8
|
-
focusedNode,
|
|
9
6
|
attachMsg,
|
|
10
7
|
}: {
|
|
11
|
-
focusedNode: LayoutNode | undefined;
|
|
12
8
|
attachMsg: string;
|
|
13
9
|
}) {
|
|
14
10
|
const store = useStore();
|
|
@@ -24,19 +20,6 @@ export function Statusline({
|
|
|
24
20
|
</text>
|
|
25
21
|
</box>
|
|
26
22
|
|
|
27
|
-
{/* Focused node info */}
|
|
28
|
-
{focusedNode ? (
|
|
29
|
-
<box backgroundColor="transparent" paddingLeft={1} alignItems="center">
|
|
30
|
-
<text>
|
|
31
|
-
<span fg={statusColor(focusedNode.status, theme)}>{statusIcon(focusedNode.status)} </span>
|
|
32
|
-
<span fg={theme.text}>{focusedNode.name}</span>
|
|
33
|
-
{focusedNode.error ? (
|
|
34
|
-
<span fg={theme.error}> {"\u00B7"} {focusedNode.error}</span>
|
|
35
|
-
) : null}
|
|
36
|
-
</text>
|
|
37
|
-
</box>
|
|
38
|
-
) : null}
|
|
39
|
-
|
|
40
23
|
{store.backgroundTaskCount > 0 ? (
|
|
41
24
|
<box backgroundColor="transparent" paddingLeft={1} alignItems="center">
|
|
42
25
|
<text>
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import {
|
|
21
|
-
listSessions,
|
|
22
21
|
getSessionMessages,
|
|
23
22
|
query as sdkQuery,
|
|
24
23
|
type SessionMessage,
|
|
@@ -39,6 +38,9 @@ import {
|
|
|
39
38
|
attemptSubmitRounds,
|
|
40
39
|
} from "../runtime/tmux.ts";
|
|
41
40
|
import { watch } from "node:fs/promises";
|
|
41
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
42
|
+
import { join } from "node:path";
|
|
43
|
+
import { randomUUID } from "node:crypto";
|
|
42
44
|
|
|
43
45
|
// ---------------------------------------------------------------------------
|
|
44
46
|
// Session tracking — ensures createClaudeSession is called before claudeQuery
|
|
@@ -46,10 +48,24 @@ import { watch } from "node:fs/promises";
|
|
|
46
48
|
|
|
47
49
|
/** Per-pane state for Claude sessions. */
|
|
48
50
|
interface PaneState {
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Claude Code's session ID. Pre-generated via `crypto.randomUUID()` in
|
|
53
|
+
* `createClaudeSession` and passed to `claude --session-id <UUID>` on the
|
|
54
|
+
* first query, so we know the JSONL filename without polling.
|
|
55
|
+
*/
|
|
56
|
+
claudeSessionId: string;
|
|
57
|
+
/** Whether the `claude` CLI has been spawned in this pane yet. */
|
|
58
|
+
claudeStarted: boolean;
|
|
59
|
+
/** CLI flags to pass to `claude` when it is spawned on the first query. */
|
|
60
|
+
chatFlags: string[];
|
|
61
|
+
/** Timeout in ms waiting for Claude TUI / JSONL file on first spawn. */
|
|
62
|
+
readyTimeoutMs: number;
|
|
63
|
+
/**
|
|
64
|
+
* Workflow session directory (`~/.atomic/sessions/<runId>/<name>-<sid>`).
|
|
65
|
+
* The first prompt is persisted here as `prompt.txt` so it appears in the
|
|
66
|
+
* session log alongside `messages.json`, `metadata.json`, etc.
|
|
67
|
+
*/
|
|
68
|
+
sessionDir: string;
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
const initializedPanes = new Map<string, PaneState>();
|
|
@@ -75,6 +91,11 @@ const DEFAULT_CHAT_FLAGS = [
|
|
|
75
91
|
export interface ClaudeSessionOptions {
|
|
76
92
|
/** tmux pane ID where Claude should be started */
|
|
77
93
|
paneId: string;
|
|
94
|
+
/**
|
|
95
|
+
* Workflow session directory. The first prompt is written here as
|
|
96
|
+
* `prompt.txt` and Claude is told to read from that path.
|
|
97
|
+
*/
|
|
98
|
+
sessionDir: string;
|
|
78
99
|
/** CLI flags to pass to the `claude` command (default: ["--allow-dangerously-skip-permissions", "--dangerously-skip-permissions"]) */
|
|
79
100
|
chatFlags?: string[];
|
|
80
101
|
/** Timeout in ms waiting for Claude TUI to be ready (default: 30s) */
|
|
@@ -82,11 +103,16 @@ export interface ClaudeSessionOptions {
|
|
|
82
103
|
}
|
|
83
104
|
|
|
84
105
|
/**
|
|
85
|
-
*
|
|
106
|
+
* Initialize per-pane Claude state. Does NOT spawn the `claude` CLI — the
|
|
107
|
+
* pane is left as a bare shell. The CLI is spawned lazily on the first
|
|
108
|
+
* `claudeQuery()` call, with the prompt baked into the spawn command:
|
|
109
|
+
*
|
|
110
|
+
* claude [chatFlags] --session-id <UUID> 'Read the prompt in <tmpfile>'
|
|
111
|
+
*
|
|
112
|
+
* Pre-generating the session UUID here lets the first query pass it to the
|
|
113
|
+
* CLI, so we know the JSONL filename up front and can skip discovery polling.
|
|
86
114
|
*
|
|
87
115
|
* Must be called before any `claudeQuery()` calls targeting the same pane.
|
|
88
|
-
* The pane should be a bare shell — `createClaudeSession` sends the `claude`
|
|
89
|
-
* command with the given flags and waits for the TUI to become ready.
|
|
90
116
|
*
|
|
91
117
|
* @example
|
|
92
118
|
* ```typescript
|
|
@@ -108,94 +134,89 @@ export interface ClaudeSessionOptions {
|
|
|
108
134
|
export async function createClaudeSession(options: ClaudeSessionOptions): Promise<void> {
|
|
109
135
|
const {
|
|
110
136
|
paneId,
|
|
137
|
+
sessionDir,
|
|
111
138
|
chatFlags = DEFAULT_CHAT_FLAGS,
|
|
112
139
|
readyTimeoutMs = 30_000,
|
|
113
140
|
} = options;
|
|
114
141
|
|
|
115
|
-
// Snapshot existing Claude sessions BEFORE starting, so we can identify the
|
|
116
|
-
// new session later by diffing against this set. The directory may not exist
|
|
117
|
-
// on first run — that's fine, the known set is just empty.
|
|
118
|
-
let knownSessionIds = new Set<string>();
|
|
119
|
-
try {
|
|
120
|
-
const existing = await listSessions({ dir: process.cwd() });
|
|
121
|
-
knownSessionIds = new Set(existing.map((s) => s.sessionId));
|
|
122
|
-
} catch {
|
|
123
|
-
// No session directory yet — all sessions will be "new"
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const cmd = ["claude", ...chatFlags].join(" ");
|
|
127
|
-
await sendKeysAndSubmit(paneId, cmd);
|
|
128
|
-
|
|
129
|
-
// Give the shell time to exec before polling for TUI readiness
|
|
130
|
-
await Bun.sleep(1_000);
|
|
131
|
-
await waitForPaneReady(paneId, readyTimeoutMs);
|
|
132
|
-
|
|
133
|
-
// Verify Claude TUI actually rendered — a bare shell or crash won't show
|
|
134
|
-
// the expected prompt/task indicators
|
|
135
|
-
const visible = capturePaneVisible(paneId);
|
|
136
|
-
if (!paneLooksReady(visible) && !paneHasActiveTask(visible)) {
|
|
137
|
-
throw new Error(
|
|
138
|
-
"createClaudeSession() timed out waiting for the Claude TUI to start. " +
|
|
139
|
-
"Verify the `claude` command is installed and the flags are valid.",
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Session ID is resolved lazily in claudeQuery — Claude doesn't write its
|
|
144
|
-
// session file until it receives the first message.
|
|
145
142
|
initializedPanes.set(paneId, {
|
|
146
|
-
claudeSessionId:
|
|
147
|
-
|
|
143
|
+
claudeSessionId: randomUUID(),
|
|
144
|
+
claudeStarted: false,
|
|
145
|
+
chatFlags,
|
|
146
|
+
readyTimeoutMs,
|
|
147
|
+
sessionDir,
|
|
148
148
|
});
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
152
|
+
* Spawn `claude` in the pane with the prompt baked in via the Read tool.
|
|
153
|
+
*
|
|
154
|
+
* The prompt is written to `${sessionDir}/prompt.txt` so it persists in the
|
|
155
|
+
* workflow's session log alongside `messages.json`, `metadata.json`, etc.
|
|
156
|
+
* The argv prompt is `Read the prompt in <path>`, so Claude's first action
|
|
157
|
+
* is a Read tool call against that file. This sidesteps shell-escaping and
|
|
158
|
+
* ARG_MAX entirely — the prompt bytes never traverse the shell parser or
|
|
159
|
+
* the kernel argv cap.
|
|
154
160
|
*/
|
|
155
|
-
async function
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
async function spawnClaudeWithPrompt(
|
|
162
|
+
paneId: string,
|
|
163
|
+
prompt: string,
|
|
164
|
+
chatFlags: string[],
|
|
165
|
+
sessionId: string,
|
|
166
|
+
sessionDir: string,
|
|
167
|
+
readyTimeoutMs: number,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
const promptFile = join(sessionDir, "prompt.txt");
|
|
170
|
+
writeFileSync(promptFile, prompt, "utf-8");
|
|
171
|
+
|
|
172
|
+
// sessionDir is the workflow's `${name}-${sessionId}` directory under
|
|
173
|
+
// ~/.atomic/sessions — slug-based, so single-quoting is sufficient on
|
|
174
|
+
// POSIX and PowerShell alike.
|
|
175
|
+
const argvPrompt = `'Read the prompt in ${promptFile}'`;
|
|
176
|
+
const cmd = [
|
|
177
|
+
"claude",
|
|
178
|
+
...chatFlags,
|
|
179
|
+
"--session-id",
|
|
180
|
+
sessionId,
|
|
181
|
+
argvPrompt,
|
|
182
|
+
].join(" ");
|
|
183
|
+
|
|
184
|
+
await sendKeysAndSubmit(paneId, cmd);
|
|
185
|
+
|
|
186
|
+
// SDK-native readiness signal: wait for Claude to create its JSONL file
|
|
187
|
+
// at the known UUID path. No pane scraping, no paneLooksReady check.
|
|
188
|
+
await waitForSessionFileAt(sessionId, readyTimeoutMs);
|
|
165
189
|
}
|
|
166
190
|
|
|
167
191
|
/**
|
|
168
|
-
*
|
|
192
|
+
* Wait for Claude's JSONL session file at a known UUID-named path to exist.
|
|
169
193
|
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* An `AbortController` coordinates the timeout and cleanup across both
|
|
176
|
-
* watchers — whichever detects the session first wins the `Promise.race`,
|
|
177
|
-
* and the abort signal tears down the other.
|
|
194
|
+
* Because we pass `--session-id <UUID>` to the spawn, the file's exact path
|
|
195
|
+
* is deterministic — we just need to wait for it to appear. Uses `fs.watch`
|
|
196
|
+
* for instant OS-native notification (inotify/kqueue in Bun) racing against
|
|
197
|
+
* a polling fallback that handles the case where the session directory
|
|
198
|
+
* doesn't exist yet on first run.
|
|
178
199
|
*/
|
|
179
|
-
async function
|
|
180
|
-
|
|
200
|
+
async function waitForSessionFileAt(
|
|
201
|
+
sessionId: string,
|
|
181
202
|
timeoutMs: number,
|
|
182
|
-
): Promise<
|
|
183
|
-
const
|
|
184
|
-
const
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
const sessionDir = resolveSessionDir(process.cwd());
|
|
205
|
+
const targetPath = `${sessionDir}/${sessionId}.jsonl`;
|
|
206
|
+
|
|
207
|
+
if (existsSync(targetPath)) return;
|
|
208
|
+
|
|
185
209
|
const ac = new AbortController();
|
|
186
210
|
const timeout = setTimeout(() => ac.abort(), timeoutMs);
|
|
187
211
|
|
|
188
212
|
try {
|
|
189
|
-
|
|
190
|
-
// fs.watch — instant OS-native notification
|
|
191
|
-
(async (): Promise<
|
|
213
|
+
await Promise.race([
|
|
214
|
+
// fs.watch — instant OS-native notification when Claude writes the file
|
|
215
|
+
(async (): Promise<void> => {
|
|
192
216
|
try {
|
|
193
|
-
for await (const event of watch(sessionDir, {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (event.filename?.endsWith(".jsonl")) {
|
|
197
|
-
const id = await findNewSessionId(knownSessionIds, cwd);
|
|
198
|
-
if (id) return id;
|
|
217
|
+
for await (const event of watch(sessionDir, { signal: ac.signal })) {
|
|
218
|
+
if (event.filename === `${sessionId}.jsonl` && existsSync(targetPath)) {
|
|
219
|
+
return;
|
|
199
220
|
}
|
|
200
221
|
}
|
|
201
222
|
} catch (e: unknown) {
|
|
@@ -203,14 +224,13 @@ async function waitForSessionFile(
|
|
|
203
224
|
// Directory doesn't exist yet — let polling handle it
|
|
204
225
|
}
|
|
205
226
|
// Park this branch so polling can win the race
|
|
206
|
-
return new Promise<
|
|
227
|
+
return new Promise<void>(() => {});
|
|
207
228
|
})(),
|
|
208
229
|
|
|
209
230
|
// Polling fallback — handles directory-not-yet-created case
|
|
210
|
-
(async (): Promise<
|
|
231
|
+
(async (): Promise<void> => {
|
|
211
232
|
while (!ac.signal.aborted) {
|
|
212
|
-
|
|
213
|
-
if (id) return id;
|
|
233
|
+
if (existsSync(targetPath)) return;
|
|
214
234
|
await Bun.sleep(500);
|
|
215
235
|
}
|
|
216
236
|
throw new DOMException("Aborted", "AbortError");
|
|
@@ -219,7 +239,7 @@ async function waitForSessionFile(
|
|
|
219
239
|
} catch (e: unknown) {
|
|
220
240
|
if (e instanceof DOMException && e.name === "AbortError") {
|
|
221
241
|
throw new Error(
|
|
222
|
-
|
|
242
|
+
`Timed out waiting for Claude session file at ${targetPath}. ` +
|
|
223
243
|
"Verify the `claude` command started successfully.",
|
|
224
244
|
);
|
|
225
245
|
}
|
|
@@ -322,32 +342,6 @@ export async function _runHILWatcher(
|
|
|
322
342
|
}
|
|
323
343
|
}
|
|
324
344
|
|
|
325
|
-
/**
|
|
326
|
-
* Watch the Claude session JSONL transcript for `AskUserQuestion` HIL events.
|
|
327
|
-
*
|
|
328
|
-
* Uses `fs/promises` `watch()` (inotify/kqueue in Bun) on the session file.
|
|
329
|
-
* On each write, re-reads messages via `getSessionMessages()` and calls
|
|
330
|
-
* `onHIL(true)` when an unresolved `AskUserQuestion` appears or
|
|
331
|
-
* `onHIL(false)` when it is resolved. Only fires on state transitions to
|
|
332
|
-
* avoid redundant callbacks.
|
|
333
|
-
*
|
|
334
|
-
* The loop exits when the provided `AbortSignal` is aborted (e.g. session
|
|
335
|
-
* becomes idle). Individual read errors are silently swallowed so a single
|
|
336
|
-
* corrupt write doesn't kill the watcher.
|
|
337
|
-
*/
|
|
338
|
-
async function watchTranscriptForHIL(
|
|
339
|
-
sessionId: string,
|
|
340
|
-
signal: AbortSignal,
|
|
341
|
-
onHIL: (waiting: boolean) => void,
|
|
342
|
-
): Promise<void> {
|
|
343
|
-
const jsonlPath = `${resolveSessionDir(process.cwd())}/${sessionId}.jsonl`;
|
|
344
|
-
await _runHILWatcher(
|
|
345
|
-
watch(jsonlPath, { signal }),
|
|
346
|
-
() => getSessionMessages(sessionId, { dir: process.cwd(), includeSystemMessages: true }),
|
|
347
|
-
onHIL,
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
345
|
// ---------------------------------------------------------------------------
|
|
352
346
|
// Helpers
|
|
353
347
|
// ---------------------------------------------------------------------------
|
|
@@ -545,19 +539,28 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<SessionM
|
|
|
545
539
|
);
|
|
546
540
|
}
|
|
547
541
|
|
|
548
|
-
const normalizedPrompt = normalizeTmuxCapture(prompt).slice(0, 100);
|
|
549
542
|
const dir = process.cwd();
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
543
|
+
const claudeSessionId = paneState.claudeSessionId;
|
|
544
|
+
|
|
545
|
+
// ── First query: spawn `claude --session-id <UUID> 'Read the prompt in <path>'`.
|
|
546
|
+
// The prompt is delivered via Claude's Read tool on its first turn — no
|
|
547
|
+
// paste-buffer, no submit retries. Subsequent queries fall through to the
|
|
548
|
+
// existing paste-buffer flow against the now-running TUI.
|
|
549
|
+
if (!paneState.claudeStarted) {
|
|
550
|
+
await spawnClaudeWithPrompt(
|
|
551
|
+
paneId,
|
|
552
|
+
prompt,
|
|
553
|
+
paneState.chatFlags,
|
|
554
|
+
claudeSessionId,
|
|
555
|
+
paneState.sessionDir,
|
|
556
|
+
paneState.readyTimeoutMs,
|
|
557
|
+
);
|
|
558
|
+
paneState.claudeStarted = true;
|
|
559
|
+
} else {
|
|
560
|
+
// ── Transcript snapshot (before send) ──
|
|
561
|
+
// Taken BEFORE sending so we get an accurate baseline for slicing the
|
|
562
|
+
// returned messages to just this turn.
|
|
563
|
+
let transcriptBeforeCount = 0;
|
|
561
564
|
try {
|
|
562
565
|
const msgs = await getSessionMessages(claudeSessionId, {
|
|
563
566
|
dir,
|
|
@@ -567,82 +570,77 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<SessionM
|
|
|
567
570
|
} catch {
|
|
568
571
|
// Best-effort — 0 means we scan all messages (correct, slightly less efficient)
|
|
569
572
|
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
const beforeContent = normalizeTmuxLines(capturePaneScrollback(paneId));
|
|
573
573
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
await Bun.sleep(150);
|
|
574
|
+
const beforeContent = normalizeTmuxLines(capturePaneScrollback(paneId));
|
|
575
|
+
const normalizedPrompt = normalizeTmuxCapture(prompt).slice(0, 100);
|
|
577
576
|
|
|
578
|
-
|
|
579
|
-
|
|
577
|
+
// Step 1: Wait for pane readiness before sending
|
|
578
|
+
await waitForPaneReady(paneId, readyTimeoutMs);
|
|
580
579
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const visibleNorm = normalizeTmuxCapture(visibleCapture);
|
|
580
|
+
// Step 2: Send text via paste buffer (atomic, handles large prompts)
|
|
581
|
+
sendViaPasteBuffer(paneId, prompt);
|
|
582
|
+
await Bun.sleep(150);
|
|
585
583
|
|
|
586
|
-
//
|
|
587
|
-
|
|
588
|
-
sendSpecialKey(paneId, "C-u");
|
|
589
|
-
await Bun.sleep(80);
|
|
590
|
-
sendViaPasteBuffer(paneId, prompt);
|
|
591
|
-
await Bun.sleep(120);
|
|
592
|
-
delivered = await attemptSubmitRounds(paneId, normalizedPrompt, maxSubmitRounds, submitPresses);
|
|
593
|
-
}
|
|
594
|
-
}
|
|
584
|
+
// Step 3: Submit with per-round capture verification
|
|
585
|
+
let delivered = await attemptSubmitRounds(paneId, normalizedPrompt, maxSubmitRounds, submitPresses);
|
|
595
586
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
587
|
+
// Step 4: Adaptive retry — clear line, re-type, re-submit
|
|
588
|
+
if (!delivered) {
|
|
589
|
+
const visibleCapture = capturePaneVisible(paneId);
|
|
590
|
+
const visibleNorm = normalizeTmuxCapture(visibleCapture);
|
|
591
|
+
|
|
592
|
+
// Only retry if text is still visible and pane is idle (not mid-task)
|
|
593
|
+
if (visibleNorm.includes(normalizedPrompt) && !paneHasActiveTask(visibleCapture) && paneLooksReady(visibleCapture)) {
|
|
594
|
+
sendSpecialKey(paneId, "C-u");
|
|
595
|
+
await Bun.sleep(80);
|
|
596
|
+
sendViaPasteBuffer(paneId, prompt);
|
|
597
|
+
await Bun.sleep(120);
|
|
598
|
+
delivered = await attemptSubmitRounds(paneId, normalizedPrompt, maxSubmitRounds, submitPresses);
|
|
599
|
+
}
|
|
608
600
|
}
|
|
609
601
|
|
|
610
|
-
//
|
|
602
|
+
// Step 5: Final fallback — double C-m nudge + post-submit verification
|
|
611
603
|
if (!delivered) {
|
|
612
604
|
sendSpecialKey(paneId, "C-m");
|
|
613
|
-
await Bun.sleep(
|
|
605
|
+
await Bun.sleep(120);
|
|
614
606
|
sendSpecialKey(paneId, "C-m");
|
|
615
|
-
|
|
616
|
-
}
|
|
607
|
+
await Bun.sleep(300);
|
|
617
608
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
609
|
+
const verifyCapture = capturePaneVisible(paneId);
|
|
610
|
+
if (paneHasActiveTask(verifyCapture)) {
|
|
611
|
+
delivered = true;
|
|
612
|
+
} else {
|
|
613
|
+
delivered = !normalizeTmuxCapture(verifyCapture).includes(normalizedPrompt);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// One more attempt if text is still stuck
|
|
617
|
+
if (!delivered) {
|
|
618
|
+
sendSpecialKey(paneId, "C-m");
|
|
619
|
+
await Bun.sleep(150);
|
|
620
|
+
sendSpecialKey(paneId, "C-m");
|
|
621
|
+
}
|
|
629
622
|
}
|
|
623
|
+
|
|
624
|
+
// Wait for response completion via pane idle + transcript read.
|
|
625
|
+
// HIL detection is integrated into waitForIdle.
|
|
626
|
+
return await waitForIdle(
|
|
627
|
+
paneId,
|
|
628
|
+
claudeSessionId,
|
|
629
|
+
transcriptBeforeCount,
|
|
630
|
+
beforeContent,
|
|
631
|
+
pollIntervalMs,
|
|
632
|
+
onHIL,
|
|
633
|
+
);
|
|
630
634
|
}
|
|
631
635
|
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
// JSONL. The pane prompt indicator is the only reliable idle signal.
|
|
636
|
-
// Once idle, output is extracted from the transcript when available.
|
|
637
|
-
//
|
|
638
|
-
// HIL detection is integrated into waitForIdle — when the pane looks idle
|
|
639
|
-
// but the transcript has an unresolved AskUserQuestion, the function
|
|
640
|
-
// calls onHIL(true) and keeps waiting instead of returning prematurely.
|
|
636
|
+
// First-query path: wait for Claude to finish the response. The prompt
|
|
637
|
+
// file lives in the workflow's session dir as `prompt.txt` and is kept
|
|
638
|
+
// as part of the session log — no cleanup needed.
|
|
641
639
|
return await waitForIdle(
|
|
642
640
|
paneId,
|
|
643
641
|
claudeSessionId,
|
|
644
|
-
|
|
645
|
-
|
|
642
|
+
0,
|
|
643
|
+
"",
|
|
646
644
|
pollIntervalMs,
|
|
647
645
|
onHIL,
|
|
648
646
|
);
|
|
@@ -674,19 +672,23 @@ export interface ClaudeQueryDefaults {
|
|
|
674
672
|
export class ClaudeClientWrapper {
|
|
675
673
|
readonly paneId: string;
|
|
676
674
|
private readonly opts: { chatFlags?: string[]; readyTimeoutMs?: number };
|
|
675
|
+
private readonly sessionDir: string;
|
|
677
676
|
|
|
678
677
|
constructor(
|
|
679
678
|
paneId: string,
|
|
680
679
|
opts: { chatFlags?: string[]; readyTimeoutMs?: number } = {},
|
|
680
|
+
sessionDir: string,
|
|
681
681
|
) {
|
|
682
682
|
this.paneId = paneId;
|
|
683
683
|
this.opts = opts;
|
|
684
|
+
this.sessionDir = sessionDir;
|
|
684
685
|
}
|
|
685
686
|
|
|
686
687
|
/** Start the Claude CLI in the tmux pane. Called by the runtime during init. */
|
|
687
688
|
async start(): Promise<void> {
|
|
688
689
|
await createClaudeSession({
|
|
689
690
|
paneId: this.paneId,
|
|
691
|
+
sessionDir: this.sessionDir,
|
|
690
692
|
chatFlags: this.opts.chatFlags,
|
|
691
693
|
readyTimeoutMs: this.opts.readyTimeoutMs,
|
|
692
694
|
});
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* library module that can be safely re-exported from the SDK barrel.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { runOrchestrator } from "./executor.ts";
|
|
11
|
+
import { runOrchestrator, applyContainerEnvDefaults } from "./executor.ts";
|
|
12
|
+
|
|
13
|
+
applyContainerEnvDefaults();
|
|
12
14
|
|
|
13
15
|
runOrchestrator().catch((err) => {
|
|
14
16
|
console.error("Fatal:", err);
|