@bastani/atomic 0.5.12-4 → 0.5.12-5

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 (27) hide show
  1. package/.agents/skills/workflow-creator/SKILL.md +24 -17
  2. package/.agents/skills/workflow-creator/references/agent-sessions.md +67 -24
  3. package/.agents/skills/workflow-creator/references/computation-and-validation.md +5 -3
  4. package/.agents/skills/workflow-creator/references/control-flow.md +25 -11
  5. package/.agents/skills/workflow-creator/references/discovery-and-verification.md +3 -2
  6. package/.agents/skills/workflow-creator/references/failure-modes.md +35 -36
  7. package/.agents/skills/workflow-creator/references/getting-started.md +25 -12
  8. package/.agents/skills/workflow-creator/references/session-config.md +26 -5
  9. package/.agents/skills/workflow-creator/references/state-and-data-flow.md +3 -3
  10. package/.agents/skills/workflow-creator/references/workflow-inputs.md +52 -47
  11. package/README.md +63 -41
  12. package/package.json +2 -2
  13. package/src/sdk/components/workflow-picker-panel.tsx +15 -21
  14. package/src/sdk/define-workflow.test.ts +58 -0
  15. package/src/sdk/define-workflow.ts +48 -30
  16. package/src/sdk/providers/claude.ts +234 -233
  17. package/src/sdk/runtime/discovery.ts +1 -2
  18. package/src/sdk/runtime/executor.ts +6 -1
  19. package/src/sdk/types.ts +24 -19
  20. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +11 -30
  21. package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +7 -4
  22. package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +6 -2
  23. package/src/sdk/workflows/builtin/ralph/claude/index.ts +32 -38
  24. package/src/sdk/workflows/builtin/ralph/copilot/index.ts +5 -1
  25. package/src/sdk/workflows/builtin/ralph/opencode/index.ts +5 -1
  26. package/src/sdk/workflows/index.ts +2 -2
  27. package/src/sdk/workflow-inputs.ts +0 -54
@@ -14,9 +14,17 @@
14
14
  * - Per-round capture verification (6 rounds)
15
15
  * - Adaptive retry with C-u clear + retype
16
16
  * - Post-submit active-task detection
17
- * - Whitespace-collapsing normalization
17
+ * - File-based idle detection via session JSONL watching
18
18
  */
19
19
 
20
+ import {
21
+ listSessions,
22
+ getSessionMessages,
23
+ query as sdkQuery,
24
+ type SessionMessage,
25
+ type SDKUserMessage,
26
+ type Options as SDKOptions,
27
+ } from "@anthropic-ai/claude-agent-sdk";
20
28
  import {
21
29
  sendViaPasteBuffer,
22
30
  sendSpecialKey,
@@ -30,14 +38,15 @@ import {
30
38
  waitForPaneReady,
31
39
  attemptSubmitRounds,
32
40
  } from "../runtime/tmux.ts";
41
+ import { watch } from "node:fs/promises";
33
42
 
34
43
  // ---------------------------------------------------------------------------
35
44
  // Session tracking — ensures createClaudeSession is called before claudeQuery
36
45
  // ---------------------------------------------------------------------------
37
46
 
38
- /** Per-pane state for Claude sessions, used by transcript-based idle detection. */
47
+ /** Per-pane state for Claude sessions. */
39
48
  interface PaneState {
40
- /** Claude Code's own session ID (from the Agent SDK). Resolved lazily. */
49
+ /** Claude Code's own session ID. Resolved after the first query is sent. */
41
50
  claudeSessionId: string | undefined;
42
51
  /** Session IDs that existed before this pane's Claude instance started. */
43
52
  knownSessionIds: Set<string>;
@@ -104,14 +113,14 @@ export async function createClaudeSession(options: ClaudeSessionOptions): Promis
104
113
  } = options;
105
114
 
106
115
  // Snapshot existing Claude sessions BEFORE starting, so we can identify the
107
- // new session later for transcript-based idle detection.
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.
108
118
  let knownSessionIds = new Set<string>();
109
119
  try {
110
- const { listSessions } = await import("@anthropic-ai/claude-agent-sdk");
111
120
  const existing = await listSessions({ dir: process.cwd() });
112
121
  knownSessionIds = new Set(existing.map((s) => s.sessionId));
113
122
  } catch {
114
- // SDK unavailabletranscript-based detection will gracefully degrade
123
+ // No session directory yet all sessions will be "new"
115
124
  }
116
125
 
117
126
  const cmd = ["claude", ...chatFlags].join(" ");
@@ -131,166 +140,165 @@ export async function createClaudeSession(options: ClaudeSessionOptions): Promis
131
140
  );
132
141
  }
133
142
 
134
- // Try to resolve the Claude session ID eagerly. It may not exist yet if
135
- // Claude hasn't written its session file; we'll retry lazily in claudeQuery.
136
- let claudeSessionId: string | undefined;
137
- try {
138
- const { listSessions } = await import("@anthropic-ai/claude-agent-sdk");
139
- const current = await listSessions({ dir: process.cwd() });
140
- const newSession = current.find((s) => !knownSessionIds.has(s.sessionId));
141
- claudeSessionId = newSession?.sessionId;
142
- } catch {}
143
-
144
- initializedPanes.set(paneId, { claudeSessionId, knownSessionIds });
143
+ // Session ID is resolved lazily in claudeQuery Claude doesn't write its
144
+ // session file until it receives the first message.
145
+ initializedPanes.set(paneId, {
146
+ claudeSessionId: undefined,
147
+ knownSessionIds,
148
+ });
145
149
  }
146
150
 
147
- // ---------------------------------------------------------------------------
148
- // Transcript-based idle detection
149
- // ---------------------------------------------------------------------------
150
-
151
151
  /**
152
- * Check whether a SessionMessage represents a session_state_changed event
153
- * with state 'idle'. The `message` payload is `unknown` in the SDK type, so
154
- * we do runtime narrowing to handle both possible JSONL serialization shapes
155
- * (extra fields only, or full raw SDKMessage).
152
+ * Find a session ID that isn't in the known set.
153
+ * Returns `undefined` if no new session exists yet.
156
154
  */
157
- function isIdleStateInTranscript(msg: { type: string; message: unknown }): boolean {
158
- if (msg.type !== "system") return false;
159
- const m = msg.message;
160
- if (!m || typeof m !== "object") return false;
161
- const obj = m as Record<string, unknown>;
162
- return obj.subtype === "session_state_changed" && obj.state === "idle";
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
+ }
163
165
  }
164
166
 
165
167
  /**
166
- * Wait for the Claude session to become idle by polling its transcript.
168
+ * Watch for a new Claude session JSONL file to appear on disk.
167
169
  *
168
- * Reads session messages (with `includeSystemMessages: true`) and looks for
169
- * an `SDKSessionStateChangedMessage` with `state: 'idle'` that appears after
170
- * `transcriptBeforeCount` messages — i.e., a NEW idle event that fired after
171
- * our prompt was submitted.
170
+ * Uses the `fs/promises` `watch()` async iterator (backed by inotify/kqueue
171
+ * in Bun OS-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).
172
174
  *
173
- * This is the **authoritative** turn-over signal from Claude Code's runtime,
174
- * far more reliable than pane-capture heuristics which can false-positive on
175
- * transient prompt indicators between sub-agent dispatches.
176
- *
177
- * Returns `null` if the SDK is unavailable, signalling the caller to fall
178
- * back to pane-capture polling.
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.
179
178
  */
180
- async function waitForIdleViaTranscript(
181
- paneId: string,
182
- claudeSessionId: string,
183
- transcriptBeforeCount: number,
184
- deadline: number,
185
- pollIntervalMs: number,
186
- delivered: boolean,
187
- ): Promise<ClaudeQueryResult | null> {
188
- const sdk = await import("@anthropic-ai/claude-agent-sdk").catch(() => null);
189
- if (!sdk) return null;
190
-
191
- const dir = process.cwd();
192
-
193
- // Give Claude time to start processing before first poll
194
- await Bun.sleep(3_000);
195
-
196
- while (Date.now() < deadline) {
197
- try {
198
- const msgs = await sdk.getSessionMessages(claudeSessionId, {
199
- dir,
200
- includeSystemMessages: true,
201
- });
202
-
203
- // No new messages yet — prompt may not have been received
204
- if (msgs.length <= transcriptBeforeCount) {
205
- await Bun.sleep(pollIntervalMs);
206
- continue;
207
- }
179
+ async function waitForSessionFile(
180
+ knownSessionIds: Set<string>,
181
+ timeoutMs: number,
182
+ ): Promise<string> {
183
+ const cwd = process.cwd();
184
+ const sessionDir = resolveSessionDir(cwd);
185
+ const ac = new AbortController();
186
+ const timeout = setTimeout(() => ac.abort(), timeoutMs);
208
187
 
209
- // New messages exist. Scan backwards from the tail for an idle event
210
- // that appeared after our prompt was sent.
211
- for (let i = msgs.length - 1; i >= transcriptBeforeCount; i--) {
212
- const msg = msgs[i];
213
- if (msg && isIdleStateInTranscript(msg)) {
214
- const output = normalizeTmuxLines(capturePaneScrollback(paneId));
215
- return { output, delivered: true };
188
+ try {
189
+ return await Promise.race([
190
+ // fs.watch instant OS-native notification (inotify/kqueue in Bun)
191
+ (async (): Promise<string> => {
192
+ 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;
199
+ }
200
+ }
201
+ } catch (e: unknown) {
202
+ if (e instanceof Error && e.name === "AbortError") throw e;
203
+ // Directory doesn't exist yet — let polling handle it
216
204
  }
217
- }
218
- } catch {
219
- // SDK read error — signal caller to fall back to pane capture
220
- return null;
205
+ // Park this branch so polling can win the race
206
+ return new Promise<string>(() => {});
207
+ })(),
208
+
209
+ // Polling fallback — handles directory-not-yet-created case
210
+ (async (): Promise<string> => {
211
+ while (!ac.signal.aborted) {
212
+ const id = await findNewSessionId(knownSessionIds, cwd);
213
+ if (id) return id;
214
+ await Bun.sleep(500);
215
+ }
216
+ throw new DOMException("Aborted", "AbortError");
217
+ })(),
218
+ ]);
219
+ } catch (e: unknown) {
220
+ if (e instanceof DOMException && e.name === "AbortError") {
221
+ throw new Error(
222
+ "Timed out waiting for Claude to write its session file. " +
223
+ "Verify the `claude` command started successfully.",
224
+ );
221
225
  }
222
-
223
- await Bun.sleep(pollIntervalMs);
226
+ throw e;
227
+ } finally {
228
+ clearTimeout(timeout);
229
+ ac.abort();
224
230
  }
231
+ }
225
232
 
226
- // Timeout — return whatever the pane currently shows
227
- const output = capturePaneScrollback(paneId);
228
- return { output: normalizeTmuxLines(output || ""), delivered };
233
+ /**
234
+ * Resolve the session directory for a given cwd.
235
+ * Session files live at `~/.claude/projects/<encoded-cwd>/`.
236
+ */
237
+ function resolveSessionDir(cwd: string): string {
238
+ const encodedCwd = cwd.replace(/[^a-zA-Z0-9]/g, "-");
239
+ const home = process.env.HOME || process.env.USERPROFILE || "";
240
+ return `${home}/.claude/projects/${encodedCwd}`;
229
241
  }
230
242
 
243
+ // ---------------------------------------------------------------------------
244
+ // Helpers
245
+ // ---------------------------------------------------------------------------
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Idle detection via pane capture
249
+ // ---------------------------------------------------------------------------
250
+
231
251
  /**
232
- * Wait for the Claude session to become idle by polling pane capture.
252
+ * Wait for the Claude session to become idle by polling the tmux pane.
253
+ *
254
+ * Interactive Claude Code sessions don't write idle or result events to the
255
+ * JSONL session file (those only flow through the SDK streaming output for
256
+ * headless consumers). The pane prompt indicator is the only reliable idle
257
+ * signal for interactive sessions.
233
258
  *
234
- * Legacy fallback used when transcript-based detection is unavailable
235
- * (SDK error, session ID unknown). Uses the same hysteresis logic as before:
236
- * require `idleConfirmCount` consecutive idle detections to avoid
237
- * false-idle returns between sub-agent dispatches.
259
+ * Once idle is detected, assistant output is extracted from the session
260
+ * transcript via `getSessionMessages()` rather than scraping the pane
261
+ * the transcript has structured content blocks, not terminal escape codes.
262
+ *
263
+ * No timeout is imposed. The loop runs until the pane shows the idle prompt.
238
264
  */
239
- async function waitForIdleViaCapture(
265
+ async function waitForIdle(
240
266
  paneId: string,
267
+ claudeSessionId: string | undefined,
268
+ transcriptBeforeCount: number,
241
269
  beforeContent: string,
242
- deadline: number,
243
270
  pollIntervalMs: number,
244
- idleConfirmCount: number,
245
- delivered: boolean,
246
- ): Promise<ClaudeQueryResult> {
247
- let lastContent = "";
248
- let stableCount = 0;
249
- let consecutiveIdleCount = 0;
250
- const idleThreshold = Math.max(1, idleConfirmCount);
251
-
252
- // Give Claude time to start processing
271
+ ): Promise<SessionMessage[]> {
272
+ // Give Claude time to start processing before first poll
253
273
  await Bun.sleep(3_000);
254
274
 
255
- while (Date.now() < deadline) {
275
+ while (true) {
256
276
  const currentContent = normalizeTmuxLines(capturePaneScrollback(paneId));
257
277
 
258
278
  // Must have new content compared to before we sent
259
- if (currentContent === beforeContent) {
260
- consecutiveIdleCount = 0;
261
- await Bun.sleep(pollIntervalMs);
262
- continue;
263
- }
264
-
265
- // Use visible capture for state detection to avoid stale scrollback matches
266
- const visible = capturePaneVisible(paneId);
267
- if (paneLooksReady(visible) && !paneHasActiveTask(visible)) {
268
- consecutiveIdleCount++;
269
- if (consecutiveIdleCount >= idleThreshold) {
270
- return { output: currentContent, delivered };
271
- }
272
- // Not yet confirmed idle — wait and recheck
273
- await Bun.sleep(pollIntervalMs);
274
- continue;
275
- } else {
276
- consecutiveIdleCount = 0;
277
- }
278
-
279
- if (currentContent === lastContent) {
280
- stableCount++;
281
- if (stableCount >= 3) {
282
- return { output: currentContent, delivered };
279
+ if (currentContent !== beforeContent) {
280
+ const visible = capturePaneVisible(paneId);
281
+ if (paneLooksReady(visible) && !paneHasActiveTask(visible)) {
282
+ // Pane is idle — return transcript messages from this turn
283
+ if (claudeSessionId) {
284
+ try {
285
+ const msgs = await getSessionMessages(claudeSessionId, {
286
+ dir: process.cwd(),
287
+ includeSystemMessages: true,
288
+ });
289
+ if (msgs.length > transcriptBeforeCount) {
290
+ return msgs.slice(transcriptBeforeCount);
291
+ }
292
+ } catch {
293
+ // Transcript read failed — return empty
294
+ }
295
+ }
296
+ return [];
283
297
  }
284
- } else {
285
- stableCount = 0;
286
298
  }
287
299
 
288
- lastContent = currentContent;
289
300
  await Bun.sleep(pollIntervalMs);
290
301
  }
291
-
292
- // Timeout — return whatever we have
293
- return { output: lastContent || capturePaneScrollback(paneId), delivered };
294
302
  }
295
303
 
296
304
  // ---------------------------------------------------------------------------
@@ -302,8 +310,6 @@ export interface ClaudeQueryOptions {
302
310
  paneId: string;
303
311
  /** The prompt to send */
304
312
  prompt: string;
305
- /** Timeout in ms waiting for Claude to finish responding (default: 300s) */
306
- timeoutMs?: number;
307
313
  /** Polling interval in ms (default: 2000) */
308
314
  pollIntervalMs?: number;
309
315
  /** Number of C-m presses per submit round (default: 1 for Claude) */
@@ -312,20 +318,41 @@ export interface ClaudeQueryOptions {
312
318
  maxSubmitRounds?: number;
313
319
  /** Timeout in ms waiting for pane to be ready before sending (default: 30s) */
314
320
  readyTimeoutMs?: number;
315
- /**
316
- * Number of consecutive idle detections required before considering the
317
- * response complete (default: 2). Prevents false-idle returns between
318
- * sub-agent dispatches where the pane briefly shows the prompt indicator
319
- * without an active task.
320
- */
321
- idleConfirmCount?: number;
322
321
  }
323
322
 
324
- export interface ClaudeQueryResult {
325
- /** The full pane content after the response completed */
326
- output: string;
327
- /** Whether delivery was confirmed (text disappeared from input) */
328
- delivered: boolean;
323
+ /**
324
+ * Extract text content from assistant messages in a transcript slice.
325
+ *
326
+ * Walks messages from `afterIndex` forward, pulls `TextBlock.text` from each
327
+ * assistant message's content array, and joins them. The `message` payload is
328
+ * `unknown` in the SDK type so we do runtime narrowing.
329
+ *
330
+ * Exported so workflow authors can extract text from `SessionMessage[]`
331
+ * returned by `s.session.query()`.
332
+ */
333
+ export function extractAssistantText(
334
+ msgs: ReadonlyArray<{ type: string; message: unknown }>,
335
+ afterIndex: number,
336
+ ): string {
337
+ const parts: string[] = [];
338
+ for (let i = afterIndex; i < msgs.length; i++) {
339
+ const msg = msgs[i];
340
+ if (!msg || msg.type !== "assistant") continue;
341
+ const m = msg.message;
342
+ if (!m || typeof m !== "object") continue;
343
+ const content = (m as Record<string, unknown>).content;
344
+ if (!Array.isArray(content)) continue;
345
+ for (const block of content) {
346
+ if (
347
+ block &&
348
+ typeof block === "object" &&
349
+ (block as Record<string, unknown>).type === "text"
350
+ ) {
351
+ parts.push(String((block as Record<string, unknown>).text ?? ""));
352
+ }
353
+ }
354
+ }
355
+ return parts.join("\n");
329
356
  }
330
357
 
331
358
  /**
@@ -351,16 +378,14 @@ export interface ClaudeQueryResult {
351
378
  * ctx.log(result.output);
352
379
  * ```
353
380
  */
354
- export async function claudeQuery(options: ClaudeQueryOptions): Promise<ClaudeQueryResult> {
381
+ export async function claudeQuery(options: ClaudeQueryOptions): Promise<SessionMessage[]> {
355
382
  const {
356
383
  paneId,
357
384
  prompt,
358
- timeoutMs = 300_000,
359
385
  pollIntervalMs = 2_000,
360
386
  submitPresses = 1,
361
387
  maxSubmitRounds = 6,
362
388
  readyTimeoutMs = 30_000,
363
- idleConfirmCount = 2,
364
389
  } = options;
365
390
 
366
391
  const paneState = initializedPanes.get(paneId);
@@ -372,55 +397,32 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<ClaudeQu
372
397
  }
373
398
 
374
399
  const normalizedPrompt = normalizeTmuxCapture(prompt).slice(0, 100);
400
+ const dir = process.cwd();
401
+ let { claudeSessionId } = paneState;
375
402
 
376
- // Step 1: Wait for pane readiness before sending (deducted from response timeout)
377
- const waitElapsed = await waitForPaneReady(paneId, readyTimeoutMs);
378
- const responseTimeoutMs = Math.max(0, timeoutMs - waitElapsed);
379
-
380
- if (waitElapsed > timeoutMs * 0.5) {
381
- console.warn(
382
- `claudeQuery: readiness wait consumed ${Math.round(waitElapsed / 1000)}s ` +
383
- `of ${Math.round(timeoutMs / 1000)}s total timeout budget`,
384
- );
385
- }
386
-
387
- const beforeContent = normalizeTmuxLines(capturePaneScrollback(paneId));
388
-
389
- // ── Transcript snapshot (before sending) ──
390
- // Lazily resolve the Claude session ID if not yet known, then snapshot the
391
- // current transcript length. This lets us detect NEW idle events that fire
392
- // after our prompt is submitted.
393
- let claudeSessionId = paneState.claudeSessionId;
394
- let transcriptBeforeCount = -1;
395
-
396
- if (!claudeSessionId) {
397
- try {
398
- const { listSessions } = await import("@anthropic-ai/claude-agent-sdk");
399
- const sessions = await listSessions({ dir: process.cwd() });
400
- const newSession = sessions.find(
401
- (s) => !paneState.knownSessionIds.has(s.sessionId),
402
- );
403
- if (newSession) {
404
- claudeSessionId = newSession.sessionId;
405
- paneState.claudeSessionId = claudeSessionId;
406
- }
407
- } catch {}
408
- }
403
+ // Step 1: Wait for pane readiness before sending
404
+ await waitForPaneReady(paneId, readyTimeoutMs);
409
405
 
406
+ // ── Transcript snapshot (before send) ──
407
+ // Must be taken BEFORE sending so we get an accurate baseline. On the
408
+ // first query the session ID is unknown (Claude hasn't written its file
409
+ // yet), so transcriptBeforeCount stays 0 and we extract all messages.
410
+ let transcriptBeforeCount = 0;
410
411
  if (claudeSessionId) {
411
412
  try {
412
- const { getSessionMessages } = await import(
413
- "@anthropic-ai/claude-agent-sdk"
414
- );
415
413
  const msgs = await getSessionMessages(claudeSessionId, {
416
- dir: process.cwd(),
414
+ dir,
417
415
  includeSystemMessages: true,
418
416
  });
419
417
  transcriptBeforeCount = msgs.length;
420
- } catch {}
418
+ } catch {
419
+ // Best-effort — 0 means we scan all messages (correct, slightly less efficient)
420
+ }
421
421
  }
422
422
 
423
- // Step 2: Send text via paste buffer (atomic, avoids ARG_MAX)
423
+ const beforeContent = normalizeTmuxLines(capturePaneScrollback(paneId));
424
+
425
+ // Step 2: Send text via paste buffer (atomic, handles large prompts)
424
426
  sendViaPasteBuffer(paneId, prompt);
425
427
  await Bun.sleep(150);
426
428
 
@@ -438,7 +440,7 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<ClaudeQu
438
440
  await Bun.sleep(80);
439
441
  sendViaPasteBuffer(paneId, prompt);
440
442
  await Bun.sleep(120);
441
- delivered = await attemptSubmitRounds(paneId, normalizedPrompt, 4, submitPresses);
443
+ delivered = await attemptSubmitRounds(paneId, normalizedPrompt, maxSubmitRounds, submitPresses);
442
444
  }
443
445
  }
444
446
 
@@ -464,34 +466,31 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<ClaudeQu
464
466
  }
465
467
  }
466
468
 
467
- // Step 6: Wait for response completion
468
- const deadline = Date.now() + responseTimeoutMs;
469
-
470
- // ── Transcript-based idle detection (preferred) ──
471
- // Uses the Claude Agent SDK's session_state_changed message as the
472
- // authoritative turn-over signal. Falls back to pane capture if the
473
- // SDK is unavailable or the session ID couldn't be resolved.
474
- if (claudeSessionId && transcriptBeforeCount >= 0) {
475
- const transcriptResult = await waitForIdleViaTranscript(
476
- paneId,
477
- claudeSessionId,
478
- transcriptBeforeCount,
479
- deadline,
480
- pollIntervalMs,
481
- delivered,
482
- );
483
- if (transcriptResult) return transcriptResult;
484
- // null → SDK error; fall through to pane-capture
469
+ // ── Resolve session ID (after send, first query only) ──
470
+ // Claude doesn't write its session file until it receives the first message.
471
+ if (!claudeSessionId) {
472
+ try {
473
+ claudeSessionId = await waitForSessionFile(
474
+ paneState.knownSessionIds,
475
+ readyTimeoutMs,
476
+ );
477
+ paneState.claudeSessionId = claudeSessionId;
478
+ } catch {
479
+ // Session file not found — output will fall back to pane content
480
+ }
485
481
  }
486
482
 
487
- // ── Pane-capture fallback ──
488
- return waitForIdleViaCapture(
483
+ // Step 6: Wait for response completion via pane capture
484
+ //
485
+ // Interactive Claude Code sessions don't write idle/result events to the
486
+ // JSONL. The pane prompt indicator is the only reliable idle signal.
487
+ // Once idle, output is extracted from the transcript when available.
488
+ return waitForIdle(
489
489
  paneId,
490
+ claudeSessionId,
491
+ transcriptBeforeCount,
490
492
  beforeContent,
491
- deadline,
492
493
  pollIntervalMs,
493
- idleConfirmCount,
494
- delivered,
495
494
  );
496
495
  }
497
496
 
@@ -504,8 +503,6 @@ export async function claudeQuery(options: ClaudeQueryOptions): Promise<ClaudeQu
504
503
  * These become defaults for every `s.session.query()` call within that stage.
505
504
  */
506
505
  export interface ClaudeQueryDefaults {
507
- /** Timeout in ms waiting for Claude to finish responding (default: 300s) */
508
- timeoutMs?: number;
509
506
  /** Polling interval in ms (default: 2000) */
510
507
  pollIntervalMs?: number;
511
508
  /** Number of C-m presses per submit round (default: 1) */
@@ -514,13 +511,6 @@ export interface ClaudeQueryDefaults {
514
511
  maxSubmitRounds?: number;
515
512
  /** Timeout in ms waiting for pane to be ready before sending (default: 30s) */
516
513
  readyTimeoutMs?: number;
517
- /**
518
- * Number of consecutive idle detections required before considering the
519
- * response complete (default: 2). Increase for long-running multi-step
520
- * tasks (e.g., explorer stages with sub-agent dispatches) to avoid
521
- * false-idle returns between steps.
522
- */
523
- idleConfirmCount?: number;
524
514
  }
525
515
 
526
516
  /**
@@ -574,8 +564,8 @@ export class ClaudeSessionWrapper {
574
564
  /** Send a prompt to Claude and wait for the response. */
575
565
  async query(
576
566
  prompt: string,
577
- opts?: Partial<ClaudeQueryDefaults>,
578
- ): Promise<ClaudeQueryResult> {
567
+ opts?: Partial<ClaudeQueryDefaults & SDKOptions>,
568
+ ): Promise<SessionMessage[]> {
579
569
  return claudeQuery({
580
570
  paneId: this.paneId,
581
571
  prompt,
@@ -621,18 +611,29 @@ export class HeadlessClaudeSessionWrapper {
621
611
  }
622
612
 
623
613
  async query(
624
- prompt: string | AsyncIterable<import("@anthropic-ai/claude-agent-sdk").SDKUserMessage>,
625
- options?: import("@anthropic-ai/claude-agent-sdk").Options,
626
- ): Promise<ClaudeQueryResult> {
627
- const { query } = await import("@anthropic-ai/claude-agent-sdk");
628
- let output = "";
629
- for await (const msg of query({ prompt, options })) {
614
+ prompt: string | AsyncIterable<SDKUserMessage>,
615
+ options?: Partial<ClaudeQueryDefaults & SDKOptions>,
616
+ ): Promise<SessionMessage[]> {
617
+ // Strip query-defaults fields; the rest are SDK options
618
+ const {
619
+ pollIntervalMs: _a,
620
+ submitPresses: _b,
621
+ maxSubmitRounds: _c,
622
+ readyTimeoutMs: _d,
623
+ ...sdkOpts
624
+ } = options ?? {};
625
+
626
+ let sdkSessionId = "";
627
+ for await (const msg of sdkQuery({ prompt, options: sdkOpts })) {
630
628
  if (msg.type === "result") {
631
- // SDKResultSuccess has `result: string`, not `output`.
632
- output = String((msg as Record<string, unknown>).result ?? "");
629
+ sdkSessionId = String((msg as Record<string, unknown>).session_id ?? "");
633
630
  }
634
631
  }
635
- return { output, delivered: true };
632
+ // Read the transcript to return native SessionMessage[]
633
+ if (sdkSessionId) {
634
+ return getSessionMessages(sdkSessionId, { dir: process.cwd() });
635
+ }
636
+ return [];
636
637
  }
637
638
 
638
639
  async disconnect(): Promise<void> {}
@@ -13,7 +13,6 @@ import { readdir } from "node:fs/promises";
13
13
  import { homedir } from "node:os";
14
14
  import ignore from "ignore";
15
15
  import type { AgentType, WorkflowInput } from "../types.ts";
16
- import { normalizePickerInputs } from "../workflow-inputs.ts";
17
16
  import { WorkflowLoader } from "./loader.ts";
18
17
 
19
18
  export interface DiscoveredWorkflow {
@@ -312,7 +311,7 @@ export async function loadWorkflowsMetadata(
312
311
  return {
313
312
  ...wf,
314
313
  description: loaded.value.definition.description,
315
- inputs: normalizePickerInputs(loaded.value.definition.inputs),
314
+ inputs: loaded.value.definition.inputs,
316
315
  };
317
316
  }),
318
317
  );
@@ -80,7 +80,12 @@ const AGENT_CLI: Record<
80
80
  "--allow-dangerously-skip-permissions",
81
81
  "--dangerously-skip-permissions",
82
82
  ],
83
- envVars: {},
83
+ envVars: {
84
+ // Enables session_state_changed events in the session JSONL transcript,
85
+ // which the idle detection in claude.ts watches for to know when the
86
+ // agent has finished processing a prompt.
87
+ CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
88
+ },
84
89
  },
85
90
  };
86
91