@bastani/atomic 0.5.18 → 0.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.agents/skills/workflow-creator/SKILL.md +110 -1
  2. package/.agents/skills/workflow-creator/references/workflow-inputs.md +10 -0
  3. package/.mcp.json +9 -0
  4. package/.opencode/opencode.json +5 -2
  5. package/README.md +394 -645
  6. package/assets/settings.schema.json +0 -20
  7. package/dist/sdk/components/attached-statusline.d.ts +13 -0
  8. package/dist/sdk/components/attached-statusline.d.ts.map +1 -0
  9. package/dist/sdk/components/header.d.ts.map +1 -1
  10. package/dist/sdk/components/session-graph-panel.d.ts.map +1 -1
  11. package/dist/sdk/components/statusline.d.ts +1 -3
  12. package/dist/sdk/components/statusline.d.ts.map +1 -1
  13. package/dist/sdk/providers/claude.d.ts +16 -5
  14. package/dist/sdk/providers/claude.d.ts.map +1 -1
  15. package/dist/sdk/runtime/executor.d.ts +63 -0
  16. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  17. package/dist/sdk/runtime/tmux.d.ts +0 -9
  18. package/dist/sdk/runtime/tmux.d.ts.map +1 -1
  19. package/dist/services/config/atomic-config.d.ts +1 -7
  20. package/dist/services/config/atomic-config.d.ts.map +1 -1
  21. package/dist/services/config/definitions.d.ts +0 -45
  22. package/dist/services/config/definitions.d.ts.map +1 -1
  23. package/dist/services/config/index.d.ts +1 -1
  24. package/dist/theme/colors.d.ts +33 -0
  25. package/dist/theme/colors.d.ts.map +1 -0
  26. package/package.json +3 -2
  27. package/src/cli.ts +16 -1
  28. package/src/commands/cli/chat/index.ts +1 -1
  29. package/src/commands/cli/footer.tsx +118 -0
  30. package/src/commands/cli/init/index.ts +6 -89
  31. package/src/commands/cli/workflow-command.test.ts +146 -0
  32. package/src/commands/cli/workflow.ts +43 -7
  33. package/src/completions/bash.ts +3 -8
  34. package/src/completions/fish.ts +1 -3
  35. package/src/completions/powershell.ts +1 -17
  36. package/src/completions/zsh.ts +0 -2
  37. package/src/scripts/bundle-configs.ts +0 -12
  38. package/src/sdk/components/attached-statusline.tsx +33 -0
  39. package/src/sdk/components/header.tsx +16 -2
  40. package/src/sdk/components/session-graph-panel.tsx +10 -51
  41. package/src/sdk/components/statusline.tsx +0 -17
  42. package/src/sdk/providers/claude.ts +179 -177
  43. package/src/sdk/runtime/executor-entry.ts +3 -1
  44. package/src/sdk/runtime/executor.test.ts +292 -1
  45. package/src/sdk/runtime/executor.ts +222 -1
  46. package/src/sdk/runtime/tmux.conf +35 -4
  47. package/src/sdk/runtime/tmux.ts +0 -22
  48. package/src/services/config/atomic-config.ts +1 -14
  49. package/src/services/config/definitions.ts +1 -102
  50. package/src/services/config/index.ts +1 -1
  51. package/src/services/config/settings.ts +2 -65
  52. package/src/services/system/skills.ts +2 -19
  53. 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
- // Attached mode: use tmux's native window list to show agent names
398
- // (the current window is highlighted automatically by tmux).
399
- // Graph mode: restore the minimal defaults.
400
- useEffect(() => {
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", "-g", "status-left", TMUX_DEFAULT_STATUS_LEFT]);
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 focusedNode={focused} attachMsg={attachMsg} />
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
- /** Claude Code's own session ID. Resolved after the first query is sent. */
50
- claudeSessionId: string | undefined;
51
- /** Session IDs that existed before this pane's Claude instance started. */
52
- knownSessionIds: Set<string>;
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
- * Start Claude Code in a tmux pane with configurable CLI flags.
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: undefined,
147
- knownSessionIds,
143
+ claudeSessionId: randomUUID(),
144
+ claudeStarted: false,
145
+ chatFlags,
146
+ readyTimeoutMs,
147
+ sessionDir,
148
148
  });
149
149
  }
150
150
 
151
151
  /**
152
- * Find a session ID that isn't in the known set.
153
- * Returns `undefined` if no new session exists yet.
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 findNewSessionId(
156
- knownSessionIds: Set<string>,
157
- cwd: string,
158
- ): Promise<string | undefined> {
159
- try {
160
- const sessions = await listSessions({ dir: cwd });
161
- return sessions.find((s) => !knownSessionIds.has(s.sessionId))?.sessionId;
162
- } catch {
163
- return undefined;
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
- * Watch for a new Claude session JSONL file to appear on disk.
192
+ * Wait for Claude's JSONL session file at a known UUID-named path to exist.
169
193
  *
170
- * Uses the `fs/promises` `watch()` async iterator (backed by inotify/kqueue
171
- * in BunOS-native, no polling) for instant notification when Claude writes
172
- * its session file. A `Bun.sleep`-based polling loop runs concurrently to
173
- * handle the case where the session directory doesn't exist yet (first run).
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 deterministicwe 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 waitForSessionFile(
180
- knownSessionIds: Set<string>,
200
+ async function waitForSessionFileAt(
201
+ sessionId: string,
181
202
  timeoutMs: number,
182
- ): Promise<string> {
183
- const cwd = process.cwd();
184
- const sessionDir = resolveSessionDir(cwd);
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
- return await Promise.race([
190
- // fs.watch — instant OS-native notification (inotify/kqueue in Bun)
191
- (async (): Promise<string> => {
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
- signal: ac.signal,
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<string>(() => {});
227
+ return new Promise<void>(() => {});
207
228
  })(),
208
229
 
209
230
  // Polling fallback — handles directory-not-yet-created case
210
- (async (): Promise<string> => {
231
+ (async (): Promise<void> => {
211
232
  while (!ac.signal.aborted) {
212
- const id = await findNewSessionId(knownSessionIds, cwd);
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
- "Timed out waiting for Claude to write its session file. " +
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
- let { claudeSessionId } = paneState;
551
-
552
- // Step 1: Wait for pane readiness before sending
553
- await waitForPaneReady(paneId, readyTimeoutMs);
554
-
555
- // ── Transcript snapshot (before send) ──
556
- // Must be taken BEFORE sending so we get an accurate baseline. On the
557
- // first query the session ID is unknown (Claude hasn't written its file
558
- // yet), so transcriptBeforeCount stays 0 and we extract all messages.
559
- let transcriptBeforeCount = 0;
560
- if (claudeSessionId) {
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
- // Step 2: Send text via paste buffer (atomic, handles large prompts)
575
- sendViaPasteBuffer(paneId, prompt);
576
- await Bun.sleep(150);
574
+ const beforeContent = normalizeTmuxLines(capturePaneScrollback(paneId));
575
+ const normalizedPrompt = normalizeTmuxCapture(prompt).slice(0, 100);
577
576
 
578
- // Step 3: Submit with per-round capture verification
579
- let delivered = await attemptSubmitRounds(paneId, normalizedPrompt, maxSubmitRounds, submitPresses);
577
+ // Step 1: Wait for pane readiness before sending
578
+ await waitForPaneReady(paneId, readyTimeoutMs);
580
579
 
581
- // Step 4: Adaptive retry clear line, re-type, re-submit
582
- if (!delivered) {
583
- const visibleCapture = capturePaneVisible(paneId);
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
- // Only retry if text is still visible and pane is idle (not mid-task)
587
- if (visibleNorm.includes(normalizedPrompt) && !paneHasActiveTask(visibleCapture) && paneLooksReady(visibleCapture)) {
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
- // Step 5: Final fallbackdouble C-m nudge + post-submit verification
597
- if (!delivered) {
598
- sendSpecialKey(paneId, "C-m");
599
- await Bun.sleep(120);
600
- sendSpecialKey(paneId, "C-m");
601
- await Bun.sleep(300);
602
-
603
- const verifyCapture = capturePaneVisible(paneId);
604
- if (paneHasActiveTask(verifyCapture)) {
605
- delivered = true;
606
- } else {
607
- delivered = !normalizeTmuxCapture(verifyCapture).includes(normalizedPrompt);
587
+ // Step 4: Adaptive retryclear 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
- // One more attempt if text is still stuck
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(150);
605
+ await Bun.sleep(120);
614
606
  sendSpecialKey(paneId, "C-m");
615
- }
616
- }
607
+ await Bun.sleep(300);
617
608
 
618
- // ── Resolve session ID (after send, first query only) ──
619
- // Claude doesn't write its session file until it receives the first message.
620
- if (!claudeSessionId) {
621
- try {
622
- claudeSessionId = await waitForSessionFile(
623
- paneState.knownSessionIds,
624
- readyTimeoutMs,
625
- );
626
- paneState.claudeSessionId = claudeSessionId;
627
- } catch {
628
- // Session file not found — output will fall back to pane content
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
- // Step 6: Wait for response completion via pane capture
633
- //
634
- // Interactive Claude Code sessions don't write idle/result events to the
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
- transcriptBeforeCount,
645
- beforeContent,
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);