@bastani/atomic 0.5.12-3 → 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.
- package/.agents/skills/workflow-creator/SKILL.md +24 -17
- package/.agents/skills/workflow-creator/references/agent-sessions.md +67 -24
- package/.agents/skills/workflow-creator/references/computation-and-validation.md +5 -3
- package/.agents/skills/workflow-creator/references/control-flow.md +25 -11
- package/.agents/skills/workflow-creator/references/discovery-and-verification.md +3 -2
- package/.agents/skills/workflow-creator/references/failure-modes.md +35 -36
- package/.agents/skills/workflow-creator/references/getting-started.md +25 -12
- package/.agents/skills/workflow-creator/references/session-config.md +26 -5
- package/.agents/skills/workflow-creator/references/state-and-data-flow.md +3 -3
- package/.agents/skills/workflow-creator/references/workflow-inputs.md +52 -47
- package/README.md +63 -41
- package/package.json +2 -4
- package/src/commands/cli/workflow.ts +1 -1
- package/src/sdk/components/workflow-picker-panel.tsx +109 -47
- package/src/sdk/define-workflow.test.ts +58 -0
- package/src/sdk/define-workflow.ts +48 -30
- package/src/sdk/providers/claude.ts +234 -233
- package/src/sdk/runtime/discovery.ts +2 -3
- package/src/sdk/runtime/executor.ts +6 -1
- package/src/sdk/types.ts +24 -19
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +11 -30
- package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +7 -4
- package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +6 -2
- package/src/sdk/workflows/builtin/ralph/claude/index.ts +32 -38
- package/src/sdk/workflows/builtin/ralph/copilot/index.ts +5 -1
- package/src/sdk/workflows/builtin/ralph/opencode/index.ts +5 -1
- package/src/sdk/workflows/index.ts +2 -2
|
@@ -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
|
-
* -
|
|
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
|
|
47
|
+
/** Per-pane state for Claude sessions. */
|
|
39
48
|
interface PaneState {
|
|
40
|
-
/** Claude Code's own session ID
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
*
|
|
153
|
-
*
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
*
|
|
168
|
+
* Watch for a new Claude session JSONL file to appear on disk.
|
|
167
169
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
)
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
226
|
+
throw e;
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(timeout);
|
|
229
|
+
ac.abort();
|
|
224
230
|
}
|
|
231
|
+
}
|
|
225
232
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
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 (
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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<
|
|
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
|
|
377
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
//
|
|
488
|
-
|
|
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<
|
|
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<
|
|
625
|
-
options?:
|
|
626
|
-
): Promise<
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
632
|
-
output = String((msg as Record<string, unknown>).result ?? "");
|
|
629
|
+
sdkSessionId = String((msg as Record<string, unknown>).session_id ?? "");
|
|
633
630
|
}
|
|
634
631
|
}
|
|
635
|
-
|
|
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> {}
|
|
@@ -288,12 +288,12 @@ export async function findWorkflow(
|
|
|
288
288
|
export interface WorkflowWithMetadata extends DiscoveredWorkflow {
|
|
289
289
|
/** Workflow description, empty string when none was declared. */
|
|
290
290
|
description: string;
|
|
291
|
-
/**
|
|
291
|
+
/** Picker-ready input schema; free-form workflows materialize a prompt field. */
|
|
292
292
|
inputs: readonly WorkflowInput[];
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
/**
|
|
296
|
-
* Load metadata (description + inputs) for a batch of discovered workflows.
|
|
296
|
+
* Load metadata (description + picker-ready inputs) for a batch of discovered workflows.
|
|
297
297
|
*
|
|
298
298
|
* Workflows that fail to import are **skipped silently** so one broken
|
|
299
299
|
* entry can never prevent the picker from rendering. Callers that need
|
|
@@ -320,4 +320,3 @@ export async function loadWorkflowsMetadata(
|
|
|
320
320
|
);
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
-
|
|
@@ -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
|
|