@bastani/atomic 0.5.0-3 → 0.5.0-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/.atomic/workflows/hello/claude/index.ts +22 -25
- package/.atomic/workflows/hello/copilot/index.ts +41 -31
- package/.atomic/workflows/hello/opencode/index.ts +40 -40
- package/.atomic/workflows/hello-parallel/claude/index.ts +54 -54
- package/.atomic/workflows/hello-parallel/copilot/index.ts +89 -70
- package/.atomic/workflows/hello-parallel/opencode/index.ts +77 -77
- package/.atomic/workflows/ralph/claude/index.ts +128 -93
- package/.atomic/workflows/ralph/copilot/index.ts +212 -112
- package/.atomic/workflows/ralph/helpers/prompts.ts +45 -2
- package/.atomic/workflows/ralph/opencode/index.ts +174 -111
- package/README.md +138 -59
- package/package.json +1 -1
- package/src/cli.ts +0 -2
- package/src/commands/cli/chat/index.ts +28 -8
- package/src/commands/cli/init/index.ts +7 -10
- package/src/commands/cli/init/scm.ts +27 -10
- package/src/sdk/components/connectors.test.ts +45 -0
- package/src/sdk/components/layout.test.ts +321 -0
- package/src/sdk/components/layout.ts +51 -15
- package/src/sdk/components/orchestrator-panel-contexts.ts +13 -4
- package/src/sdk/components/orchestrator-panel-store.test.ts +156 -0
- package/src/sdk/components/orchestrator-panel-store.ts +24 -0
- package/src/sdk/components/orchestrator-panel.tsx +21 -0
- package/src/sdk/components/session-graph-panel.tsx +8 -15
- package/src/sdk/components/statusline.tsx +4 -6
- package/src/sdk/define-workflow.test.ts +71 -0
- package/src/sdk/define-workflow.ts +42 -39
- package/src/sdk/errors.ts +1 -1
- package/src/sdk/index.ts +4 -1
- package/src/sdk/providers/claude.ts +1 -1
- package/src/sdk/providers/copilot.ts +5 -3
- package/src/sdk/providers/opencode.ts +5 -3
- package/src/sdk/runtime/executor.ts +512 -301
- package/src/sdk/runtime/loader.ts +2 -2
- package/src/sdk/runtime/tmux.ts +31 -2
- package/src/sdk/types.ts +93 -20
- package/src/sdk/workflows.ts +7 -4
- package/src/services/config/definitions.ts +39 -2
- package/src/services/config/settings.ts +0 -6
- package/src/services/system/skills.ts +3 -7
- package/.atomic/workflows/package-lock.json +0 -31
- package/.atomic/workflows/package.json +0 -8
|
@@ -6,15 +6,24 @@
|
|
|
6
6
|
* 2. It creates a tmux session with an orchestrator pane that runs
|
|
7
7
|
* `bun run executor.ts --run <args>`
|
|
8
8
|
* 3. The CLI then attaches to the tmux session (user sees it live)
|
|
9
|
-
* 4. The orchestrator pane
|
|
9
|
+
* 4. The orchestrator pane calls `definition.run(workflowCtx)` — the
|
|
10
|
+
* user's callback uses `ctx.session()` to spawn agent sessions
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { join, resolve } from "path";
|
|
13
14
|
import { homedir } from "os";
|
|
14
15
|
import { mkdir, writeFile, readFile } from "fs/promises";
|
|
15
16
|
import type {
|
|
16
|
-
WorkflowDefinition,
|
|
17
|
-
|
|
17
|
+
WorkflowDefinition,
|
|
18
|
+
WorkflowContext,
|
|
19
|
+
SessionContext,
|
|
20
|
+
SessionRunOptions,
|
|
21
|
+
SessionHandle,
|
|
22
|
+
SessionRef,
|
|
23
|
+
AgentType,
|
|
24
|
+
Transcript,
|
|
25
|
+
SavedMessage,
|
|
26
|
+
SaveTranscript,
|
|
18
27
|
} from "../types.ts";
|
|
19
28
|
import type { SessionEvent } from "@github/copilot-sdk";
|
|
20
29
|
import type { SessionPromptResponse } from "@opencode-ai/sdk/v2";
|
|
@@ -23,18 +32,48 @@ import * as tmux from "./tmux.ts";
|
|
|
23
32
|
import { getMuxBinary } from "./tmux.ts";
|
|
24
33
|
import { WorkflowLoader } from "./loader.ts";
|
|
25
34
|
import { clearClaudeSession } from "../providers/claude.ts";
|
|
26
|
-
import { OrchestratorPanel
|
|
35
|
+
import { OrchestratorPanel } from "./panel.tsx";
|
|
27
36
|
|
|
28
37
|
/** Maximum time (ms) to wait for an agent's server to become reachable. */
|
|
29
38
|
const SERVER_WAIT_TIMEOUT_MS = 60_000;
|
|
30
39
|
|
|
31
40
|
/** Agent CLI configuration for spawning in tmux panes. */
|
|
32
|
-
const AGENT_CLI: Record<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
const AGENT_CLI: Record<
|
|
42
|
+
AgentType,
|
|
43
|
+
{ cmd: string; chatFlags: string[]; envVars: Record<string, string> }
|
|
44
|
+
> = {
|
|
45
|
+
copilot: {
|
|
46
|
+
cmd: "copilot",
|
|
47
|
+
chatFlags: [
|
|
48
|
+
"--add-dir",
|
|
49
|
+
".",
|
|
50
|
+
"--yolo",
|
|
51
|
+
"--experimental",
|
|
52
|
+
"--no-auto-update",
|
|
53
|
+
],
|
|
54
|
+
envVars: {
|
|
55
|
+
COPILOT_ALLOW_ALL: "true",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
opencode: { cmd: "opencode", chatFlags: [], envVars: {} },
|
|
59
|
+
claude: {
|
|
60
|
+
cmd: "claude",
|
|
61
|
+
chatFlags: [
|
|
62
|
+
"--allow-dangerously-skip-permissions",
|
|
63
|
+
"--dangerously-skip-permissions",
|
|
64
|
+
],
|
|
65
|
+
envVars: {},
|
|
66
|
+
},
|
|
36
67
|
};
|
|
37
68
|
|
|
69
|
+
/** Thrown when the user aborts a running workflow via `q` or `Ctrl+C`. */
|
|
70
|
+
class WorkflowAbortError extends Error {
|
|
71
|
+
constructor() {
|
|
72
|
+
super("Workflow aborted by user");
|
|
73
|
+
this.name = "WorkflowAbortError";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
export interface WorkflowRunOptions {
|
|
39
78
|
/** The compiled workflow definition */
|
|
40
79
|
definition: WorkflowDefinition;
|
|
@@ -55,6 +94,18 @@ interface SessionResult {
|
|
|
55
94
|
paneId: string;
|
|
56
95
|
}
|
|
57
96
|
|
|
97
|
+
/** A session that has been spawned but may not have completed yet. */
|
|
98
|
+
interface ActiveSession {
|
|
99
|
+
name: string;
|
|
100
|
+
paneId: string;
|
|
101
|
+
/**
|
|
102
|
+
* Settles when the session finishes. Resolves on success, rejects with the
|
|
103
|
+
* callback's error on failure. Dependent sessions awaiting via `dependsOn`
|
|
104
|
+
* block on this promise.
|
|
105
|
+
*/
|
|
106
|
+
done: Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
|
|
58
109
|
function generateId(): string {
|
|
59
110
|
return crypto.randomUUID().slice(0, 8);
|
|
60
111
|
}
|
|
@@ -85,26 +136,50 @@ async function getRandomPort(): Promise<number> {
|
|
|
85
136
|
await Bun.sleep(50);
|
|
86
137
|
}
|
|
87
138
|
|
|
88
|
-
throw new Error(
|
|
139
|
+
throw new Error(
|
|
140
|
+
`Failed to acquire a random port after ${MAX_RETRIES} attempts (last: ${lastPort})`,
|
|
141
|
+
);
|
|
89
142
|
}
|
|
90
143
|
|
|
91
|
-
function buildPaneCommand(
|
|
92
|
-
|
|
144
|
+
function buildPaneCommand(
|
|
145
|
+
agent: AgentType,
|
|
146
|
+
port: number,
|
|
147
|
+
): { command: string; envVars: Record<string, string> } {
|
|
148
|
+
const { cmd, chatFlags, envVars } = AGENT_CLI[agent];
|
|
93
149
|
|
|
94
150
|
switch (agent) {
|
|
95
151
|
case "copilot":
|
|
96
|
-
return
|
|
152
|
+
return {
|
|
153
|
+
command: [cmd, "--ui-server", "--port", String(port), ...chatFlags].join(
|
|
154
|
+
" ",
|
|
155
|
+
),
|
|
156
|
+
envVars,
|
|
157
|
+
};
|
|
97
158
|
case "opencode":
|
|
98
|
-
return
|
|
159
|
+
return {
|
|
160
|
+
command: [cmd, "--port", String(port), ...chatFlags].join(" "),
|
|
161
|
+
envVars,
|
|
162
|
+
};
|
|
99
163
|
case "claude":
|
|
100
164
|
// Claude is started via createClaudeSession() in the workflow's run()
|
|
101
|
-
return
|
|
165
|
+
return {
|
|
166
|
+
command:
|
|
167
|
+
process.env.SHELL || (process.platform === "win32" ? "pwsh" : "sh"),
|
|
168
|
+
envVars,
|
|
169
|
+
};
|
|
102
170
|
default:
|
|
103
|
-
return
|
|
171
|
+
return {
|
|
172
|
+
command: [cmd, ...chatFlags].join(" "),
|
|
173
|
+
envVars,
|
|
174
|
+
};
|
|
104
175
|
}
|
|
105
176
|
}
|
|
106
177
|
|
|
107
|
-
async function waitForServer(
|
|
178
|
+
async function waitForServer(
|
|
179
|
+
agent: AgentType,
|
|
180
|
+
port: number,
|
|
181
|
+
paneId: string,
|
|
182
|
+
): Promise<string> {
|
|
108
183
|
if (agent === "claude") return "";
|
|
109
184
|
|
|
110
185
|
const serverUrl = `localhost:${port}`;
|
|
@@ -181,8 +256,16 @@ export function escPwsh(s: string): string {
|
|
|
181
256
|
* Creates a tmux session with the orchestrator as the initial pane,
|
|
182
257
|
* then attaches so the user sees everything live.
|
|
183
258
|
*/
|
|
184
|
-
export async function executeWorkflow(
|
|
185
|
-
|
|
259
|
+
export async function executeWorkflow(
|
|
260
|
+
options: WorkflowRunOptions,
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
const {
|
|
263
|
+
definition,
|
|
264
|
+
agent,
|
|
265
|
+
prompt,
|
|
266
|
+
workflowFile,
|
|
267
|
+
projectRoot = process.cwd(),
|
|
268
|
+
} = options;
|
|
186
269
|
|
|
187
270
|
const workflowRunId = generateId();
|
|
188
271
|
const tmuxSessionName = `atomic-wf-${definition.name}-${workflowRunId}`;
|
|
@@ -235,21 +318,16 @@ export async function executeWorkflow(options: WorkflowRunOptions): Promise<void
|
|
|
235
318
|
} else {
|
|
236
319
|
// Outside tmux: attach normally (blocks until session ends)
|
|
237
320
|
const muxBinary = getMuxBinary() ?? "tmux";
|
|
238
|
-
const attachProc = Bun.spawn(
|
|
239
|
-
|
|
240
|
-
|
|
321
|
+
const attachProc = Bun.spawn(
|
|
322
|
+
[muxBinary, "attach-session", "-t", tmuxSessionName],
|
|
323
|
+
{
|
|
324
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
325
|
+
},
|
|
326
|
+
);
|
|
241
327
|
await attachProc.exited;
|
|
242
328
|
}
|
|
243
329
|
}
|
|
244
330
|
|
|
245
|
-
/**
|
|
246
|
-
* Throw immediately if the abort signal has already been triggered.
|
|
247
|
-
* Consolidates the repeated abort-check pattern used throughout session execution.
|
|
248
|
-
*/
|
|
249
|
-
function throwIfAborted(signal?: AbortSignal): void {
|
|
250
|
-
if (signal?.aborted) throw new Error("Cancelled: a sibling session failed");
|
|
251
|
-
}
|
|
252
|
-
|
|
253
331
|
/**
|
|
254
332
|
* Small buffer (ms) subtracted from `Date.now()` when recording the Claude
|
|
255
333
|
* session start timestamp. Protects against fast sequential runs where
|
|
@@ -264,13 +342,27 @@ const CLAUDE_SESSION_TIMESTAMP_BUFFER_MS = 100;
|
|
|
264
342
|
|
|
265
343
|
/** Type guard for objects with a string `content` property (Copilot assistant.message data). */
|
|
266
344
|
export function hasContent(value: unknown): value is { content: string } {
|
|
267
|
-
return
|
|
345
|
+
return (
|
|
346
|
+
typeof value === "object" &&
|
|
347
|
+
value !== null &&
|
|
348
|
+
"content" in value &&
|
|
349
|
+
typeof (value as { content: unknown }).content === "string"
|
|
350
|
+
);
|
|
268
351
|
}
|
|
269
352
|
|
|
270
353
|
/** Type guard for Claude message objects whose `content` is an array of text blocks. */
|
|
271
|
-
export function isTextBlockArray(
|
|
272
|
-
|
|
273
|
-
|
|
354
|
+
export function isTextBlockArray(
|
|
355
|
+
value: unknown,
|
|
356
|
+
): value is Array<{ type: "text"; text: string }> {
|
|
357
|
+
return (
|
|
358
|
+
Array.isArray(value) &&
|
|
359
|
+
value.every(
|
|
360
|
+
(b) =>
|
|
361
|
+
typeof b === "object" &&
|
|
362
|
+
b !== null &&
|
|
363
|
+
b.type === "text" &&
|
|
364
|
+
typeof b.text === "string",
|
|
365
|
+
)
|
|
274
366
|
);
|
|
275
367
|
}
|
|
276
368
|
|
|
@@ -286,7 +378,10 @@ export function renderMessagesToText(messages: SavedMessage[]): string {
|
|
|
286
378
|
case "opencode": {
|
|
287
379
|
// Part is a discriminated union; filter to TextPart which has { type: "text", text: string }
|
|
288
380
|
return m.data.parts
|
|
289
|
-
.filter(
|
|
381
|
+
.filter(
|
|
382
|
+
(p): p is Extract<typeof p, { type: "text" }> =>
|
|
383
|
+
p.type === "text",
|
|
384
|
+
)
|
|
290
385
|
.map((p) => p.text)
|
|
291
386
|
.join("\n");
|
|
292
387
|
}
|
|
@@ -309,219 +404,322 @@ export function renderMessagesToText(messages: SavedMessage[]): string {
|
|
|
309
404
|
.join("\n\n");
|
|
310
405
|
}
|
|
311
406
|
|
|
312
|
-
|
|
313
|
-
|
|
407
|
+
/** Resolve a SessionRef (string or SessionHandle) to the session name. */
|
|
408
|
+
function resolveRef(ref: SessionRef): string {
|
|
409
|
+
return typeof ref === "string" ? ref : ref.name;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ============================================================================
|
|
413
|
+
// Session runner — implements ctx.session() lifecycle
|
|
414
|
+
// ============================================================================
|
|
415
|
+
|
|
416
|
+
/** Shared state passed to session runners by the orchestrator. */
|
|
417
|
+
interface SharedRunnerState {
|
|
314
418
|
tmuxSessionName: string;
|
|
315
419
|
sessionsBaseDir: string;
|
|
316
420
|
agent: AgentType;
|
|
317
421
|
prompt: string;
|
|
318
|
-
completedSessions: SessionResult[];
|
|
319
422
|
panel: OrchestratorPanel;
|
|
320
|
-
|
|
321
|
-
|
|
423
|
+
/** Sessions that have been spawned (for name uniqueness + cleanup). */
|
|
424
|
+
activeRegistry: Map<string, ActiveSession>;
|
|
425
|
+
/** Sessions that completed successfully (for transcript reads). */
|
|
426
|
+
completedRegistry: Map<string, SessionResult>;
|
|
322
427
|
}
|
|
323
428
|
|
|
324
429
|
/**
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
430
|
+
* Create a `ctx.session()` function bound to a parent name for graph edges.
|
|
431
|
+
* The returned function manages the full session lifecycle:
|
|
432
|
+
* spawn → run callback → flush saves → complete/error → cleanup.
|
|
328
433
|
*/
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
434
|
+
function createSessionRunner(
|
|
435
|
+
shared: SharedRunnerState,
|
|
436
|
+
parentName: string,
|
|
437
|
+
): <T = void>(
|
|
438
|
+
options: SessionRunOptions,
|
|
439
|
+
run: (ctx: SessionContext) => Promise<T>,
|
|
440
|
+
) => Promise<SessionHandle<T>> {
|
|
441
|
+
return async <T = void>(
|
|
442
|
+
options: SessionRunOptions,
|
|
443
|
+
run: (ctx: SessionContext) => Promise<T>,
|
|
444
|
+
): Promise<SessionHandle<T>> => {
|
|
445
|
+
const { name } = options;
|
|
446
|
+
const deps = options.dependsOn ?? [];
|
|
447
|
+
|
|
448
|
+
// ── 1. Validate name uniqueness (synchronous, before any await) ──
|
|
449
|
+
if (!name || name.trim() === "") {
|
|
450
|
+
throw new Error("Session name is required.");
|
|
451
|
+
}
|
|
452
|
+
if (shared.activeRegistry.has(name) || shared.completedRegistry.has(name)) {
|
|
453
|
+
throw new Error(`Duplicate session name: "${name}"`);
|
|
454
|
+
}
|
|
334
455
|
|
|
335
|
-
|
|
456
|
+
// ── 2. Validate dependsOn (synchronous, before any await) ──
|
|
457
|
+
if (deps.length > 0) {
|
|
458
|
+
if (deps.includes(name)) {
|
|
459
|
+
throw new Error(`Session "${name}" cannot depend on itself.`);
|
|
460
|
+
}
|
|
461
|
+
const unknown = deps.filter(
|
|
462
|
+
(d) =>
|
|
463
|
+
!shared.activeRegistry.has(d) && !shared.completedRegistry.has(d),
|
|
464
|
+
);
|
|
465
|
+
if (unknown.length > 0) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`Session "${name}" dependsOn unknown session(s): ${unknown.join(
|
|
468
|
+
", ",
|
|
469
|
+
)}. Dependencies must be spawned before the dependent session.`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
336
473
|
|
|
337
|
-
|
|
474
|
+
// ── 3. Create done promise so dependent sessions can await this one ──
|
|
475
|
+
let resolveDone!: () => void;
|
|
476
|
+
let rejectDone!: (err: unknown) => void;
|
|
477
|
+
const donePromise = new Promise<void>((resolve, reject) => {
|
|
478
|
+
resolveDone = resolve;
|
|
479
|
+
rejectDone = reject;
|
|
480
|
+
});
|
|
481
|
+
// Prevent "unhandled rejection" noise when no dependent awaits us.
|
|
482
|
+
donePromise.catch(() => {});
|
|
338
483
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
484
|
+
// ── 4. Register in active registry (synchronous) ──
|
|
485
|
+
// Placeholder paneId — filled in after tmux window creation.
|
|
486
|
+
shared.activeRegistry.set(name, { name, paneId: "", done: donePromise });
|
|
342
487
|
|
|
343
|
-
|
|
488
|
+
const sessionId = generateId();
|
|
489
|
+
let paneId = "";
|
|
490
|
+
// Graph parents: explicit deps if provided, otherwise the enclosing scope.
|
|
491
|
+
const graphParents = deps.length > 0 ? [...deps] : [parentName];
|
|
344
492
|
|
|
345
|
-
|
|
493
|
+
try {
|
|
494
|
+
// ── 5. Wait for dependsOn sessions to finish ──
|
|
495
|
+
// Active deps block; completed deps resolve immediately. If a dep
|
|
496
|
+
// rejects (its session failed), this throws and aborts the dependent.
|
|
497
|
+
if (deps.length > 0) {
|
|
498
|
+
await Promise.all(
|
|
499
|
+
deps.map((d) => {
|
|
500
|
+
const active = shared.activeRegistry.get(d);
|
|
501
|
+
if (active) return active.done;
|
|
502
|
+
return Promise.resolve();
|
|
503
|
+
}),
|
|
504
|
+
);
|
|
505
|
+
}
|
|
346
506
|
|
|
347
|
-
|
|
507
|
+
// ── 6. Allocate port ──
|
|
508
|
+
const port = await getRandomPort();
|
|
509
|
+
const { command: paneCmd, envVars: paneEnvVars } = buildPaneCommand(
|
|
510
|
+
shared.agent,
|
|
511
|
+
port,
|
|
512
|
+
);
|
|
348
513
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
514
|
+
// ── 7. Create tmux window ──
|
|
515
|
+
paneId = tmux.createWindow(
|
|
516
|
+
shared.tmuxSessionName,
|
|
517
|
+
name,
|
|
518
|
+
paneCmd,
|
|
519
|
+
undefined,
|
|
520
|
+
paneEnvVars,
|
|
521
|
+
);
|
|
522
|
+
shared.activeRegistry.set(name, { name, paneId, done: donePromise });
|
|
353
523
|
|
|
354
|
-
|
|
355
|
-
|
|
524
|
+
// ── 8. Wait for server readiness ──
|
|
525
|
+
const serverUrl = await waitForServer(shared.agent, port, paneId);
|
|
356
526
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (agent === "claude") {
|
|
362
|
-
const { listSessions } = await import("@anthropic-ai/claude-agent-sdk");
|
|
363
|
-
const existing = await listSessions({ dir: process.cwd() });
|
|
364
|
-
knownClaudeSessionIds = new Set(existing.map((s) => s.sessionId));
|
|
365
|
-
}
|
|
527
|
+
// ── 9. Create session directory ──
|
|
528
|
+
const sessionDirName = `${name}-${sessionId}`;
|
|
529
|
+
const sessionDir = join(shared.sessionsBaseDir, sessionDirName);
|
|
530
|
+
await ensureDir(sessionDir);
|
|
366
531
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const claudeSessionStartedAfter = agent === "claude"
|
|
370
|
-
? Date.now() - CLAUDE_SESSION_TIMESTAMP_BUFFER_MS
|
|
371
|
-
: 0;
|
|
372
|
-
|
|
373
|
-
async function wrapMessages(arg: SessionEvent[] | SessionPromptResponse | string): Promise<SavedMessage[]> {
|
|
374
|
-
if (typeof arg === "string") {
|
|
375
|
-
const { getSessionMessages, listSessions } = await import("@anthropic-ai/claude-agent-sdk");
|
|
376
|
-
const dir = process.cwd();
|
|
377
|
-
const sessions = await listSessions({ dir });
|
|
378
|
-
|
|
379
|
-
// Primary: filter to sessions not in the pre-run snapshot (new sessions only).
|
|
380
|
-
// Fallback: use timestamp if snapshot is unavailable.
|
|
381
|
-
const newSessions = knownClaudeSessionIds
|
|
382
|
-
? sessions.filter((s) => !knownClaudeSessionIds!.has(s.sessionId))
|
|
383
|
-
: sessions.filter((s) => s.lastModified >= claudeSessionStartedAfter);
|
|
384
|
-
|
|
385
|
-
const candidates = newSessions.sort((a, b) => b.lastModified - a.lastModified);
|
|
386
|
-
|
|
387
|
-
const candidate = candidates[0];
|
|
388
|
-
if (!candidate) {
|
|
389
|
-
throw new Error(`wrapMessages: no new Claude session found for ${dir}`);
|
|
390
|
-
}
|
|
532
|
+
const messagesPath = join(sessionDir, "messages.json");
|
|
533
|
+
const inboxPath = join(sessionDir, "inbox.md");
|
|
391
534
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
535
|
+
// ── 10. Add node to graph panel ──
|
|
536
|
+
shared.panel.addSession(name, graphParents);
|
|
395
537
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
538
|
+
// ── 11. Claude session snapshot (for identifying new sessions later) ──
|
|
539
|
+
let knownClaudeSessionIds: Set<string> | undefined;
|
|
540
|
+
if (shared.agent === "claude") {
|
|
541
|
+
const { listSessions } = await import("@anthropic-ai/claude-agent-sdk");
|
|
542
|
+
const existing = await listSessions({ dir: process.cwd() });
|
|
543
|
+
knownClaudeSessionIds = new Set(existing.map((s) => s.sessionId));
|
|
544
|
+
}
|
|
545
|
+
const claudeSessionStartedAfter =
|
|
546
|
+
shared.agent === "claude"
|
|
547
|
+
? Date.now() - CLAUDE_SESSION_TIMESTAMP_BUFFER_MS
|
|
548
|
+
: 0;
|
|
549
|
+
|
|
550
|
+
// ── Message wrapping (Claude/Copilot/OpenCode) ──
|
|
551
|
+
async function wrapMessages(
|
|
552
|
+
arg: SessionEvent[] | SessionPromptResponse | string,
|
|
553
|
+
): Promise<SavedMessage[]> {
|
|
554
|
+
if (typeof arg === "string") {
|
|
555
|
+
const { getSessionMessages, listSessions } = await import(
|
|
556
|
+
"@anthropic-ai/claude-agent-sdk"
|
|
557
|
+
);
|
|
558
|
+
const dir = process.cwd();
|
|
559
|
+
const sessions = await listSessions({ dir });
|
|
560
|
+
|
|
561
|
+
const newSessions = knownClaudeSessionIds
|
|
562
|
+
? sessions.filter(
|
|
563
|
+
(s) => !knownClaudeSessionIds!.has(s.sessionId),
|
|
564
|
+
)
|
|
565
|
+
: sessions.filter(
|
|
566
|
+
(s) => s.lastModified >= claudeSessionStartedAfter,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const candidates = newSessions.sort(
|
|
570
|
+
(a, b) => b.lastModified - a.lastModified,
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const candidate = candidates[0];
|
|
574
|
+
if (!candidate) {
|
|
575
|
+
throw new Error(
|
|
576
|
+
`wrapMessages: no new Claude session found for ${dir}`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
399
579
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
580
|
+
const msgs: SessionMessage[] = await getSessionMessages(
|
|
581
|
+
candidate.sessionId,
|
|
582
|
+
{ dir },
|
|
583
|
+
);
|
|
584
|
+
return msgs.map((m) => ({ provider: "claude" as const, data: m }));
|
|
585
|
+
}
|
|
406
586
|
|
|
407
|
-
|
|
408
|
-
|
|
587
|
+
if (!Array.isArray(arg) && "info" in arg && "parts" in arg) {
|
|
588
|
+
return [
|
|
589
|
+
{
|
|
590
|
+
provider: "opencode" as const,
|
|
591
|
+
data: arg as SessionPromptResponse,
|
|
592
|
+
},
|
|
593
|
+
];
|
|
594
|
+
}
|
|
409
595
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
})();
|
|
419
|
-
pendingSaves.push(p);
|
|
420
|
-
return p;
|
|
421
|
-
}) as SaveTranscript;
|
|
422
|
-
|
|
423
|
-
const ctx: SessionContext = {
|
|
424
|
-
serverUrl,
|
|
425
|
-
userPrompt: prompt,
|
|
426
|
-
agent,
|
|
427
|
-
sessionDir,
|
|
428
|
-
paneId,
|
|
429
|
-
sessionId,
|
|
430
|
-
save,
|
|
431
|
-
transcript: async (name: string): Promise<Transcript> => {
|
|
432
|
-
if (siblingNames.has(name)) {
|
|
433
|
-
throw new Error(
|
|
434
|
-
`Cannot read transcript for "${name}" \u2014 it is running in parallel. ` +
|
|
435
|
-
`Only sessions from prior steps are available.`
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
|
-
const prev = completedSessions.find((s) => s.name === name);
|
|
439
|
-
if (!prev) {
|
|
440
|
-
throw new Error(
|
|
441
|
-
`No transcript for "${name}". Available: ${completedSessions.map((s) => s.name).join(", ") || "(none)"}`
|
|
442
|
-
);
|
|
596
|
+
if (Array.isArray(arg)) {
|
|
597
|
+
return (arg as SessionEvent[]).map((m) => ({
|
|
598
|
+
provider: "copilot" as const,
|
|
599
|
+
data: m,
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return [];
|
|
443
604
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
605
|
+
|
|
606
|
+
// ── Save function ──
|
|
607
|
+
const pendingSaves: Promise<void>[] = [];
|
|
608
|
+
|
|
609
|
+
const save: SaveTranscript = ((
|
|
610
|
+
arg: SessionEvent[] | SessionPromptResponse | string,
|
|
611
|
+
) => {
|
|
612
|
+
const p = (async () => {
|
|
613
|
+
const wrapped = await wrapMessages(arg);
|
|
614
|
+
await Bun.write(messagesPath, JSON.stringify(wrapped, null, 2));
|
|
615
|
+
const text = renderMessagesToText(wrapped);
|
|
616
|
+
await Bun.write(inboxPath, text);
|
|
617
|
+
})();
|
|
618
|
+
pendingSaves.push(p);
|
|
619
|
+
return p;
|
|
620
|
+
}) as SaveTranscript;
|
|
621
|
+
|
|
622
|
+
// ── Transcript/messages access (reads only from completedRegistry) ──
|
|
623
|
+
const transcriptFn = async (ref: SessionRef): Promise<Transcript> => {
|
|
624
|
+
const refName = resolveRef(ref);
|
|
625
|
+
const prev = shared.completedRegistry.get(refName);
|
|
626
|
+
if (!prev) {
|
|
627
|
+
const available =
|
|
628
|
+
[...shared.completedRegistry.keys()].join(", ") || "(none)";
|
|
629
|
+
throw new Error(
|
|
630
|
+
`No transcript for "${refName}". Available: ${available}`,
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
const filePath = join(prev.sessionDir, "inbox.md");
|
|
634
|
+
const content = await readFile(filePath, "utf-8");
|
|
635
|
+
return { path: filePath, content };
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const getMessagesFn = async (
|
|
639
|
+
ref: SessionRef,
|
|
640
|
+
): Promise<SavedMessage[]> => {
|
|
641
|
+
const refName = resolveRef(ref);
|
|
642
|
+
const prev = shared.completedRegistry.get(refName);
|
|
643
|
+
if (!prev) {
|
|
644
|
+
const available =
|
|
645
|
+
[...shared.completedRegistry.keys()].join(", ") || "(none)";
|
|
646
|
+
throw new Error(
|
|
647
|
+
`No messages for "${refName}". Available: ${available}`,
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const filePath = join(prev.sessionDir, "messages.json");
|
|
651
|
+
const raw = await readFile(filePath, "utf-8");
|
|
652
|
+
return JSON.parse(raw) as SavedMessage[];
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
// ── 12. Construct SessionContext ──
|
|
656
|
+
const ctx: SessionContext = {
|
|
657
|
+
serverUrl,
|
|
658
|
+
userPrompt: shared.prompt,
|
|
659
|
+
agent: shared.agent,
|
|
660
|
+
sessionDir,
|
|
661
|
+
paneId,
|
|
662
|
+
sessionId,
|
|
663
|
+
save,
|
|
664
|
+
transcript: transcriptFn,
|
|
665
|
+
getMessages: getMessagesFn,
|
|
666
|
+
session: createSessionRunner(shared, name),
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
// ── Write session metadata ──
|
|
670
|
+
await writeFile(
|
|
671
|
+
join(sessionDir, "metadata.json"),
|
|
672
|
+
JSON.stringify(
|
|
673
|
+
{
|
|
674
|
+
name,
|
|
675
|
+
description: options.description ?? "",
|
|
676
|
+
agent: shared.agent,
|
|
677
|
+
paneId,
|
|
678
|
+
serverUrl,
|
|
679
|
+
port,
|
|
680
|
+
startedAt: new Date().toISOString(),
|
|
681
|
+
},
|
|
682
|
+
null,
|
|
683
|
+
2,
|
|
684
|
+
),
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
// ── 13. Run user callback ──
|
|
688
|
+
let callbackResult: T;
|
|
689
|
+
try {
|
|
690
|
+
callbackResult = await run(ctx);
|
|
691
|
+
if (pendingSaves.length > 0) await Promise.all(pendingSaves);
|
|
692
|
+
} catch (error) {
|
|
693
|
+
const message =
|
|
694
|
+
error instanceof Error ? error.message : String(error);
|
|
695
|
+
await writeFile(join(sessionDir, "error.txt"), message).catch(
|
|
696
|
+
() => {},
|
|
453
697
|
);
|
|
698
|
+
shared.panel.sessionError(name, message);
|
|
699
|
+
throw error;
|
|
454
700
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
701
|
+
|
|
702
|
+
// ── 14. Mark session complete ──
|
|
703
|
+
shared.panel.sessionSuccess(name);
|
|
704
|
+
const result: SessionResult = { name, sessionId, sessionDir, paneId };
|
|
705
|
+
shared.completedRegistry.set(name, result);
|
|
706
|
+
shared.activeRegistry.delete(name);
|
|
707
|
+
resolveDone();
|
|
708
|
+
|
|
709
|
+
return { name, id: sessionId, result: callbackResult! };
|
|
710
|
+
} catch (error) {
|
|
711
|
+
// Ensure the done promise settles and the active entry is cleared so
|
|
712
|
+
// dependents fail fast instead of hanging forever on a ghost dep.
|
|
713
|
+
shared.activeRegistry.delete(name);
|
|
714
|
+
rejectDone(error);
|
|
715
|
+
throw error;
|
|
716
|
+
} finally {
|
|
717
|
+
// ── 15. Cleanup (Claude session state) ──
|
|
718
|
+
if (shared.agent === "claude" && paneId) {
|
|
719
|
+
clearClaudeSession(paneId);
|
|
460
720
|
}
|
|
461
|
-
|
|
462
|
-
const raw = await readFile(filePath, "utf-8");
|
|
463
|
-
return JSON.parse(raw) as SavedMessage[];
|
|
464
|
-
},
|
|
721
|
+
}
|
|
465
722
|
};
|
|
466
|
-
|
|
467
|
-
await writeFile(
|
|
468
|
-
join(sessionDir, "metadata.json"),
|
|
469
|
-
JSON.stringify({
|
|
470
|
-
name: sessionDef.name,
|
|
471
|
-
description: sessionDef.description ?? "",
|
|
472
|
-
agent, paneId, serverUrl, port,
|
|
473
|
-
startedAt: new Date().toISOString(),
|
|
474
|
-
}, null, 2)
|
|
475
|
-
);
|
|
476
|
-
|
|
477
|
-
try {
|
|
478
|
-
await sessionDef.run(ctx);
|
|
479
|
-
if (pendingSaves.length > 0) await Promise.all(pendingSaves);
|
|
480
|
-
} catch (error) {
|
|
481
|
-
const isCancelled = signal?.aborted;
|
|
482
|
-
const message = isCancelled
|
|
483
|
-
? "Cancelled: a sibling session failed"
|
|
484
|
-
: (error instanceof Error ? error.message : String(error));
|
|
485
|
-
await writeFile(join(sessionDir, "error.txt"), message).catch(() => {});
|
|
486
|
-
panel.sessionError(sessionDef.name, message);
|
|
487
|
-
throw error;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
panel.sessionSuccess(sessionDef.name);
|
|
491
|
-
return { name: sessionDef.name, sessionId, sessionDir, paneId };
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/** Like Promise.all() but aborts on first rejection and calls a cleanup callback. */
|
|
495
|
-
async function promiseAllFailFast<T>(
|
|
496
|
-
promises: Promise<T>[],
|
|
497
|
-
controller: AbortController,
|
|
498
|
-
onFirstFailure: () => void,
|
|
499
|
-
): Promise<T[]> {
|
|
500
|
-
if (promises.length === 0) return Promise.resolve([]);
|
|
501
|
-
|
|
502
|
-
return new Promise<T[]>((resolve, reject) => {
|
|
503
|
-
const results = Array.from<T>({ length: promises.length });
|
|
504
|
-
let remaining = promises.length;
|
|
505
|
-
let rejected = false;
|
|
506
|
-
|
|
507
|
-
promises.forEach((promise, index) => {
|
|
508
|
-
promise.then(
|
|
509
|
-
(value) => {
|
|
510
|
-
if (rejected) return;
|
|
511
|
-
results[index] = value;
|
|
512
|
-
remaining--;
|
|
513
|
-
if (remaining === 0) resolve(results);
|
|
514
|
-
},
|
|
515
|
-
(error) => {
|
|
516
|
-
if (rejected) return;
|
|
517
|
-
rejected = true;
|
|
518
|
-
controller.abort();
|
|
519
|
-
onFirstFailure();
|
|
520
|
-
reject(error);
|
|
521
|
-
},
|
|
522
|
-
);
|
|
523
|
-
});
|
|
524
|
-
});
|
|
525
723
|
}
|
|
526
724
|
|
|
527
725
|
// ============================================================================
|
|
@@ -530,8 +728,12 @@ async function promiseAllFailFast<T>(
|
|
|
530
728
|
|
|
531
729
|
async function runOrchestrator(): Promise<void> {
|
|
532
730
|
const requiredEnvVars = [
|
|
533
|
-
"ATOMIC_WF_ID",
|
|
534
|
-
"
|
|
731
|
+
"ATOMIC_WF_ID",
|
|
732
|
+
"ATOMIC_WF_TMUX",
|
|
733
|
+
"ATOMIC_WF_AGENT",
|
|
734
|
+
"ATOMIC_WF_PROMPT",
|
|
735
|
+
"ATOMIC_WF_FILE",
|
|
736
|
+
"ATOMIC_WF_CWD",
|
|
535
737
|
] as const;
|
|
536
738
|
for (const key of requiredEnvVars) {
|
|
537
739
|
if (!process.env[key]) {
|
|
@@ -542,7 +744,9 @@ async function runOrchestrator(): Promise<void> {
|
|
|
542
744
|
const workflowRunId = process.env.ATOMIC_WF_ID!;
|
|
543
745
|
const tmuxSessionName = process.env.ATOMIC_WF_TMUX!;
|
|
544
746
|
const agent = process.env.ATOMIC_WF_AGENT! as AgentType;
|
|
545
|
-
const prompt = Buffer.from(process.env.ATOMIC_WF_PROMPT!, "base64").toString(
|
|
747
|
+
const prompt = Buffer.from(process.env.ATOMIC_WF_PROMPT!, "base64").toString(
|
|
748
|
+
"utf-8",
|
|
749
|
+
);
|
|
546
750
|
const workflowFile = process.env.ATOMIC_WF_FILE!;
|
|
547
751
|
const cwd = process.env.ATOMIC_WF_CWD!;
|
|
548
752
|
|
|
@@ -551,7 +755,9 @@ async function runOrchestrator(): Promise<void> {
|
|
|
551
755
|
const sessionsBaseDir = join(getSessionsBaseDir(), workflowRunId);
|
|
552
756
|
await ensureDir(sessionsBaseDir);
|
|
553
757
|
|
|
554
|
-
const panel = await OrchestratorPanel.create({
|
|
758
|
+
const panel = await OrchestratorPanel.create({
|
|
759
|
+
tmuxSession: tmuxSessionName,
|
|
760
|
+
});
|
|
555
761
|
|
|
556
762
|
// Idempotent shutdown guard
|
|
557
763
|
let shutdownCalled = false;
|
|
@@ -559,7 +765,9 @@ async function runOrchestrator(): Promise<void> {
|
|
|
559
765
|
if (shutdownCalled) return;
|
|
560
766
|
shutdownCalled = true;
|
|
561
767
|
panel.destroy();
|
|
562
|
-
try {
|
|
768
|
+
try {
|
|
769
|
+
tmux.killSession(tmuxSessionName);
|
|
770
|
+
} catch {}
|
|
563
771
|
process.exitCode = exitCode;
|
|
564
772
|
};
|
|
565
773
|
|
|
@@ -568,6 +776,17 @@ async function runOrchestrator(): Promise<void> {
|
|
|
568
776
|
const signalHandler = () => shutdown(1);
|
|
569
777
|
process.on("SIGINT", signalHandler);
|
|
570
778
|
|
|
779
|
+
// Shared state for all session runners
|
|
780
|
+
const shared: SharedRunnerState = {
|
|
781
|
+
tmuxSessionName,
|
|
782
|
+
sessionsBaseDir,
|
|
783
|
+
agent,
|
|
784
|
+
prompt,
|
|
785
|
+
panel,
|
|
786
|
+
activeRegistry: new Map(),
|
|
787
|
+
completedRegistry: new Map(),
|
|
788
|
+
};
|
|
789
|
+
|
|
571
790
|
try {
|
|
572
791
|
const plan: WorkflowLoader.Plan = {
|
|
573
792
|
name: workflowFile.split("/").at(-3) ?? "unknown",
|
|
@@ -590,94 +809,86 @@ async function runOrchestrator(): Promise<void> {
|
|
|
590
809
|
|
|
591
810
|
await writeFile(
|
|
592
811
|
join(sessionsBaseDir, "metadata.json"),
|
|
593
|
-
JSON.stringify(
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
812
|
+
JSON.stringify(
|
|
813
|
+
{
|
|
814
|
+
workflowName: definition.name,
|
|
815
|
+
agent,
|
|
816
|
+
prompt,
|
|
817
|
+
projectRoot: cwd,
|
|
818
|
+
startedAt: new Date().toISOString(),
|
|
819
|
+
},
|
|
820
|
+
null,
|
|
821
|
+
2,
|
|
822
|
+
),
|
|
600
823
|
);
|
|
601
824
|
|
|
602
|
-
//
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
panelSessions.push({ name: s.name, parents: prevStepNames });
|
|
608
|
-
}
|
|
609
|
-
prevStepNames = step.map((s) => s.name);
|
|
610
|
-
}
|
|
825
|
+
// Initialize panel with just the orchestrator node (sessions added dynamically)
|
|
826
|
+
panel.showWorkflowInfo(definition.name, agent, [], prompt);
|
|
827
|
+
|
|
828
|
+
// Build the WorkflowContext — top-level context for the .run() callback
|
|
829
|
+
const sessionRunner = createSessionRunner(shared, "orchestrator");
|
|
611
830
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
completedSessions.push(result);
|
|
626
|
-
if (agent === "claude") clearClaudeSession(result.paneId);
|
|
627
|
-
} catch (error) {
|
|
628
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
629
|
-
panel.showFatalError(message);
|
|
630
|
-
await panel.waitForExit();
|
|
631
|
-
shutdown(1);
|
|
632
|
-
return;
|
|
831
|
+
const workflowCtx: WorkflowContext = {
|
|
832
|
+
userPrompt: prompt,
|
|
833
|
+
agent,
|
|
834
|
+
session: sessionRunner,
|
|
835
|
+
transcript: async (ref: SessionRef): Promise<Transcript> => {
|
|
836
|
+
const refName = resolveRef(ref);
|
|
837
|
+
const prev = shared.completedRegistry.get(refName);
|
|
838
|
+
if (!prev) {
|
|
839
|
+
const available =
|
|
840
|
+
[...shared.completedRegistry.keys()].join(", ") || "(none)";
|
|
841
|
+
throw new Error(
|
|
842
|
+
`No transcript for "${refName}". Available: ${available}`,
|
|
843
|
+
);
|
|
633
844
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
siblingNames: mySiblings,
|
|
648
|
-
});
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
try {
|
|
652
|
-
const results = await promiseAllFailFast(promises, controller, () => {
|
|
653
|
-
for (const s of step) tmux.killWindow(tmuxSessionName, s.name);
|
|
654
|
-
});
|
|
655
|
-
completedSessions.push(...results);
|
|
656
|
-
if (agent === "claude") {
|
|
657
|
-
for (const r of results) clearClaudeSession(r.paneId);
|
|
658
|
-
}
|
|
659
|
-
} catch (error) {
|
|
660
|
-
// Wait for all cancelled siblings to settle
|
|
661
|
-
await Promise.allSettled(promises);
|
|
662
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
663
|
-
panel.showFatalError(message);
|
|
664
|
-
await panel.waitForExit();
|
|
665
|
-
shutdown(1);
|
|
666
|
-
return;
|
|
845
|
+
const filePath = join(prev.sessionDir, "inbox.md");
|
|
846
|
+
const content = await readFile(filePath, "utf-8");
|
|
847
|
+
return { path: filePath, content };
|
|
848
|
+
},
|
|
849
|
+
getMessages: async (ref: SessionRef): Promise<SavedMessage[]> => {
|
|
850
|
+
const refName = resolveRef(ref);
|
|
851
|
+
const prev = shared.completedRegistry.get(refName);
|
|
852
|
+
if (!prev) {
|
|
853
|
+
const available =
|
|
854
|
+
[...shared.completedRegistry.keys()].join(", ") || "(none)";
|
|
855
|
+
throw new Error(
|
|
856
|
+
`No messages for "${refName}". Available: ${available}`,
|
|
857
|
+
);
|
|
667
858
|
}
|
|
668
|
-
|
|
669
|
-
|
|
859
|
+
const filePath = join(prev.sessionDir, "messages.json");
|
|
860
|
+
const raw = await readFile(filePath, "utf-8");
|
|
861
|
+
return JSON.parse(raw) as SavedMessage[];
|
|
862
|
+
},
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
// Run the workflow, racing against user abort (q / Ctrl+C)
|
|
866
|
+
const abortPromise = panel.waitForAbort().then(() => {
|
|
867
|
+
throw new WorkflowAbortError();
|
|
868
|
+
});
|
|
869
|
+
await Promise.race([definition.run(workflowCtx), abortPromise]);
|
|
670
870
|
|
|
671
871
|
panel.showCompletion(definition.name, sessionsBaseDir);
|
|
672
872
|
await panel.waitForExit();
|
|
673
873
|
shutdown(0);
|
|
674
874
|
} catch (error) {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
875
|
+
// Kill any active session tmux windows that didn't complete
|
|
876
|
+
for (const [, active] of shared.activeRegistry) {
|
|
877
|
+
try {
|
|
878
|
+
tmux.killWindow(tmuxSessionName, active.name);
|
|
879
|
+
} catch {}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (error instanceof WorkflowAbortError) {
|
|
883
|
+
shutdown(0);
|
|
884
|
+
} else {
|
|
885
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
886
|
+
try {
|
|
887
|
+
panel.showFatalError(message);
|
|
888
|
+
await panel.waitForExit();
|
|
889
|
+
} catch {}
|
|
890
|
+
shutdown(1);
|
|
891
|
+
}
|
|
681
892
|
} finally {
|
|
682
893
|
process.off("SIGINT", signalHandler);
|
|
683
894
|
}
|