@bastani/atomic 0.5.20 → 0.5.21-1

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 (45) hide show
  1. package/.agents/skills/workflow-creator/SKILL.md +78 -8
  2. package/.agents/skills/workflow-creator/references/discovery-and-verification.md +75 -0
  3. package/dist/sdk/components/orchestrator-panel.d.ts +23 -1
  4. package/dist/sdk/components/orchestrator-panel.d.ts.map +1 -1
  5. package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -1
  6. package/dist/sdk/define-workflow.d.ts.map +1 -1
  7. package/dist/sdk/errors.d.ts +12 -0
  8. package/dist/sdk/errors.d.ts.map +1 -1
  9. package/dist/sdk/runtime/discovery.d.ts +55 -12
  10. package/dist/sdk/runtime/discovery.d.ts.map +1 -1
  11. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  12. package/dist/sdk/runtime/loader.d.ts.map +1 -1
  13. package/dist/sdk/runtime/status-writer.d.ts +101 -0
  14. package/dist/sdk/runtime/status-writer.d.ts.map +1 -0
  15. package/dist/sdk/runtime/version-compat.d.ts +28 -0
  16. package/dist/sdk/runtime/version-compat.d.ts.map +1 -0
  17. package/dist/sdk/types.d.ts +21 -0
  18. package/dist/sdk/types.d.ts.map +1 -1
  19. package/dist/sdk/workflows/index.d.ts +1 -1
  20. package/dist/sdk/workflows/index.d.ts.map +1 -1
  21. package/dist/version.d.ts +2 -0
  22. package/dist/version.d.ts.map +1 -0
  23. package/package.json +1 -1
  24. package/src/cli.ts +57 -3
  25. package/src/commands/cli/session.test.ts +43 -0
  26. package/src/commands/cli/session.ts +18 -8
  27. package/src/commands/cli/workflow-command.test.ts +10 -4
  28. package/src/commands/cli/workflow-inputs.test.ts +322 -0
  29. package/src/commands/cli/workflow-inputs.ts +219 -0
  30. package/src/commands/cli/workflow-status.test.ts +451 -0
  31. package/src/commands/cli/workflow-status.ts +330 -0
  32. package/src/commands/cli/workflow.test.ts +10 -3
  33. package/src/commands/cli/workflow.ts +57 -18
  34. package/src/sdk/components/orchestrator-panel.tsx +36 -1
  35. package/src/sdk/components/workflow-picker-panel.tsx +167 -18
  36. package/src/sdk/define-workflow.ts +1 -0
  37. package/src/sdk/errors.ts +20 -0
  38. package/src/sdk/runtime/discovery.ts +94 -20
  39. package/src/sdk/runtime/executor.ts +37 -0
  40. package/src/sdk/runtime/loader.ts +29 -2
  41. package/src/sdk/runtime/status-writer.test.ts +245 -0
  42. package/src/sdk/runtime/status-writer.ts +201 -0
  43. package/src/sdk/runtime/version-compat.ts +68 -0
  44. package/src/sdk/types.ts +21 -0
  45. package/src/sdk/workflows/index.ts +1 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * `atomic workflow status [<id>]` — query the current state of one or
3
+ * all running workflows so an orchestrating agent can decide whether
4
+ * to keep waiting, surface a HIL prompt to the user, or move on.
5
+ *
6
+ * Status sources, in priority order:
7
+ * 1. <sessionDir>/status.json — written by the orchestrator on every
8
+ * panel-store mutation. Provides per-stage detail and the
9
+ * derived overall state (in_progress | error | completed |
10
+ * needs_review).
11
+ * 2. tmux liveness fallback — when status.json is missing or stale
12
+ * we still report whether the tmux session is alive so
13
+ * script-driven workflows aren't blind during the brief window
14
+ * before the orchestrator first writes its snapshot.
15
+ */
16
+
17
+ import { join } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { COLORS, createPainter, type PaletteKey } from "../../theme/colors.ts";
20
+ import {
21
+ isTmuxInstalled as _isTmuxInstalled,
22
+ listSessions as _listSessions,
23
+ sessionExists as _sessionExists,
24
+ } from "../../sdk/workflows/index.ts";
25
+ import {
26
+ readSnapshot,
27
+ workflowRunIdFromTmuxName,
28
+ type WorkflowOverallStatus,
29
+ type WorkflowStatusSnapshot,
30
+ } from "../../sdk/runtime/status-writer.ts";
31
+ import type { TmuxSession } from "../../sdk/runtime/tmux.ts";
32
+
33
+ export type StatusFormat = "json" | "text";
34
+
35
+ /** A single workflow's resolved status, as returned to the caller. */
36
+ export interface WorkflowStatusReport {
37
+ /** Tmux session name (e.g. `atomic-wf-claude-ralph-a1b2c3d4`). */
38
+ id: string;
39
+ /** Workflow run id (the trailing 8-hex segment of the tmux name). */
40
+ workflowRunId: string;
41
+ /** Workflow name pulled from the snapshot. Empty when no snapshot exists. */
42
+ workflowName: string;
43
+ /** Agent backend (claude / copilot / opencode). */
44
+ agent: string;
45
+ overall: WorkflowOverallStatus;
46
+ /** True when the tmux session is currently alive on the atomic socket. */
47
+ alive: boolean;
48
+ /** ISO timestamp of the last snapshot, or null when none exists. */
49
+ updatedAt: string | null;
50
+ /** Sessions/stages, mirrored from the snapshot. Empty when no snapshot exists. */
51
+ sessions: WorkflowStatusSnapshot["sessions"];
52
+ /** Fatal-error message, if any. */
53
+ fatalError: string | null;
54
+ }
55
+
56
+ export interface StatusDeps {
57
+ isTmuxInstalled: () => boolean;
58
+ sessionExists: (name: string) => boolean;
59
+ listSessions: () => TmuxSession[];
60
+ /**
61
+ * Read a snapshot from disk. Defaults to the real reader; tests
62
+ * inject a fake to control the snapshot data without touching the
63
+ * filesystem.
64
+ */
65
+ readSnapshot: typeof readSnapshot;
66
+ /** Base directory for session dirs. Defaults to `~/.atomic/sessions`. */
67
+ sessionsBaseDir: string;
68
+ }
69
+
70
+ const defaultDeps: StatusDeps = {
71
+ isTmuxInstalled: _isTmuxInstalled,
72
+ sessionExists: _sessionExists,
73
+ listSessions: _listSessions,
74
+ readSnapshot,
75
+ sessionsBaseDir: join(homedir(), ".atomic", "sessions"),
76
+ };
77
+
78
+ /**
79
+ * Build a report for a single workflow. When the on-disk snapshot is
80
+ * missing we still emit a minimal report so callers can distinguish
81
+ * "workflow exists but hasn't written a snapshot yet" from "workflow
82
+ * doesn't exist at all" (the latter returns null upstream).
83
+ */
84
+ async function buildReport(
85
+ tmuxName: string,
86
+ alive: boolean,
87
+ deps: StatusDeps,
88
+ ): Promise<WorkflowStatusReport | null> {
89
+ const workflowRunId = workflowRunIdFromTmuxName(tmuxName);
90
+ if (!workflowRunId) return null;
91
+
92
+ const sessionDir = join(deps.sessionsBaseDir, workflowRunId);
93
+ const snapshot = await deps.readSnapshot(sessionDir);
94
+
95
+ if (!snapshot) {
96
+ return {
97
+ id: tmuxName,
98
+ workflowRunId,
99
+ workflowName: "",
100
+ agent: "",
101
+ // Without a snapshot we can only say it's still running (or
102
+ // already gone) — never that it succeeded or errored.
103
+ overall: alive ? "in_progress" : "error",
104
+ alive,
105
+ updatedAt: null,
106
+ sessions: [],
107
+ fatalError: alive ? null : "orchestrator exited before writing status",
108
+ };
109
+ }
110
+
111
+ // If the orchestrator has shut down but the snapshot still says
112
+ // in_progress, downgrade to error — the process died without
113
+ // writing a terminal state.
114
+ const overall: WorkflowOverallStatus =
115
+ !alive && snapshot.overall === "in_progress" ? "error" : snapshot.overall;
116
+
117
+ return {
118
+ id: tmuxName,
119
+ workflowRunId,
120
+ workflowName: snapshot.workflowName,
121
+ agent: snapshot.agent,
122
+ overall,
123
+ alive,
124
+ updatedAt: snapshot.updatedAt,
125
+ sessions: snapshot.sessions,
126
+ fatalError:
127
+ overall === "error" && snapshot.fatalError === null && !alive
128
+ ? "orchestrator exited unexpectedly"
129
+ : snapshot.fatalError,
130
+ };
131
+ }
132
+
133
+ export interface WorkflowStatusOptions {
134
+ /** Filter to a specific workflow by tmux session name. */
135
+ id?: string;
136
+ format?: StatusFormat;
137
+ }
138
+
139
+ /**
140
+ * Top-level command. Prints either a single report (when `id` is
141
+ * provided) or the list of all workflow sessions on the atomic
142
+ * socket. Returns 1 when a requested id can't be found, 0 otherwise.
143
+ */
144
+ export async function workflowStatusCommand(
145
+ options: WorkflowStatusOptions,
146
+ deps: StatusDeps = defaultDeps,
147
+ ): Promise<number> {
148
+ const format: StatusFormat = options.format ?? "json";
149
+
150
+ if (!deps.isTmuxInstalled()) {
151
+ return emit(format, { workflows: [] }, "no sessions running (tmux is not installed)");
152
+ }
153
+
154
+ const allSessions = deps.listSessions();
155
+ const workflowSessions = allSessions.filter((s) => s.type === "workflow");
156
+
157
+ // ── Single-workflow query ────────────────────────────────────────
158
+ if (options.id !== undefined) {
159
+ const target = workflowSessions.find((s) => s.name === options.id);
160
+ // Honour the requested id even when the tmux session is gone but
161
+ // the on-disk snapshot might still be readable (best-effort
162
+ // post-mortem). When neither exists we report not found.
163
+ if (!target) {
164
+ const fallbackRunId = workflowRunIdFromTmuxName(options.id);
165
+ if (fallbackRunId) {
166
+ const report = await buildReport(options.id, false, deps);
167
+ if (report && report.workflowName !== "") {
168
+ return emitReport(format, report);
169
+ }
170
+ }
171
+ return reportError(
172
+ format,
173
+ `Workflow '${options.id}' not found.`,
174
+ );
175
+ }
176
+ const report = await buildReport(target.name, true, deps);
177
+ if (!report) {
178
+ return reportError(format, `Could not parse workflow id '${options.id}'.`);
179
+ }
180
+ return emitReport(format, report);
181
+ }
182
+
183
+ // ── All-workflow listing ─────────────────────────────────────────
184
+ const reports: WorkflowStatusReport[] = [];
185
+ for (const s of workflowSessions) {
186
+ const r = await buildReport(s.name, true, deps);
187
+ if (r) reports.push(r);
188
+ }
189
+
190
+ return emit(
191
+ format,
192
+ { workflows: reports },
193
+ "no workflows running",
194
+ reports,
195
+ );
196
+ }
197
+
198
+ // ─── Output helpers ─────────────────────────────────────────────────
199
+
200
+ function emit(
201
+ format: StatusFormat,
202
+ jsonPayload: { workflows: WorkflowStatusReport[] },
203
+ emptyMessage: string,
204
+ reports?: WorkflowStatusReport[],
205
+ ): number {
206
+ if (format === "json") {
207
+ process.stdout.write(JSON.stringify(jsonPayload, null, 2) + "\n");
208
+ return 0;
209
+ }
210
+ const list = reports ?? jsonPayload.workflows;
211
+ if (list.length === 0) {
212
+ const paint = createPainter();
213
+ process.stdout.write(
214
+ "\n " + paint("text", emptyMessage, { bold: true }) + "\n\n",
215
+ );
216
+ return 0;
217
+ }
218
+ process.stdout.write(renderListText(list));
219
+ return 0;
220
+ }
221
+
222
+ function emitReport(format: StatusFormat, report: WorkflowStatusReport): number {
223
+ if (format === "json") {
224
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
225
+ } else {
226
+ process.stdout.write(renderReportText(report));
227
+ }
228
+ return 0;
229
+ }
230
+
231
+ function reportError(format: StatusFormat, message: string): number {
232
+ if (format === "json") {
233
+ process.stdout.write(JSON.stringify({ error: message }, null, 2) + "\n");
234
+ } else {
235
+ process.stderr.write(`${COLORS.red}Error: ${message}${COLORS.reset}\n`);
236
+ }
237
+ return 1;
238
+ }
239
+
240
+ const OVERALL_COLORS: Record<WorkflowOverallStatus, PaletteKey> = {
241
+ in_progress: "accent",
242
+ needs_review: "warning",
243
+ completed: "success",
244
+ error: "error",
245
+ };
246
+
247
+ const OVERALL_INDICATOR: Record<WorkflowOverallStatus, string> = {
248
+ in_progress: "●",
249
+ needs_review: "!",
250
+ completed: "✓",
251
+ error: "✗",
252
+ };
253
+
254
+ function renderListText(reports: WorkflowStatusReport[]): string {
255
+ const paint = createPainter();
256
+ const lines: string[] = [];
257
+ const noun = reports.length === 1 ? "workflow" : "workflows";
258
+ lines.push("");
259
+ lines.push(
260
+ " " +
261
+ paint("text", String(reports.length), { bold: true }) +
262
+ " " +
263
+ paint("dim", noun),
264
+ );
265
+ lines.push("");
266
+ for (const r of reports) {
267
+ const color = OVERALL_COLORS[r.overall];
268
+ const indicator = OVERALL_INDICATOR[r.overall];
269
+ const label =
270
+ r.workflowName !== "" ? r.workflowName : "(no snapshot)";
271
+ lines.push(
272
+ " " +
273
+ paint(color, indicator) +
274
+ " " +
275
+ paint("text", r.id, { bold: true }) +
276
+ " " +
277
+ paint(color, r.overall) +
278
+ " " +
279
+ paint("dim", label),
280
+ );
281
+ }
282
+ lines.push("");
283
+ return lines.join("\n") + "\n";
284
+ }
285
+
286
+ function renderReportText(report: WorkflowStatusReport): string {
287
+ const paint = createPainter();
288
+ const color = OVERALL_COLORS[report.overall];
289
+ const lines: string[] = [];
290
+ lines.push("");
291
+ lines.push(
292
+ " " +
293
+ paint(color, OVERALL_INDICATOR[report.overall]) +
294
+ " " +
295
+ paint("text", report.id, { bold: true }) +
296
+ " " +
297
+ paint(color, report.overall),
298
+ );
299
+ if (report.workflowName !== "") {
300
+ lines.push(
301
+ " " +
302
+ paint("dim", "workflow: ") +
303
+ paint("text", report.workflowName) +
304
+ paint("dim", " · ") +
305
+ paint("accent", report.agent),
306
+ );
307
+ }
308
+ if (report.fatalError) {
309
+ lines.push(" " + paint("error", `error: ${report.fatalError}`));
310
+ }
311
+ if (report.sessions.length > 0) {
312
+ lines.push("");
313
+ lines.push(" " + paint("dim", "stages:"));
314
+ for (const s of report.sessions) {
315
+ lines.push(
316
+ " " +
317
+ paint("text", s.name) +
318
+ " " +
319
+ paint("dim", s.status) +
320
+ (s.error ? " " + paint("error", s.error) : ""),
321
+ );
322
+ }
323
+ }
324
+ if (report.updatedAt) {
325
+ lines.push("");
326
+ lines.push(" " + paint("dim", `updated: ${report.updatedAt}`));
327
+ }
328
+ lines.push("");
329
+ return lines.join("\n") + "\n";
330
+ }
@@ -6,7 +6,10 @@ import {
6
6
  renderWorkflowList,
7
7
  } from "./workflow.ts";
8
8
  import type { WorkflowInput } from "../../sdk/workflows/index.ts";
9
- import type { DiscoveredWorkflow } from "../../sdk/workflows/index.ts";
9
+ import type {
10
+ DiscoveredWorkflow,
11
+ WorkflowWithMetadata,
12
+ } from "../../sdk/workflows/index.ts";
10
13
 
11
14
  // ─── Colour handling ────────────────────────────────────────────────────────
12
15
  // The renderer emits ANSI sequences when the host terminal claims truecolor
@@ -190,12 +193,16 @@ function wf(
190
193
  name: string,
191
194
  agent: DiscoveredWorkflow["agent"],
192
195
  source: DiscoveredWorkflow["source"],
193
- ): DiscoveredWorkflow {
196
+ status: WorkflowWithMetadata["status"] = { kind: "ok" },
197
+ ): WorkflowWithMetadata {
194
198
  return {
195
199
  name,
196
200
  agent,
197
201
  source,
198
202
  path: `/tmp/fake/${source}/${name}/${agent}/index.ts`,
203
+ description: "",
204
+ inputs: [],
205
+ status,
199
206
  };
200
207
  }
201
208
 
@@ -216,7 +223,7 @@ describe("renderWorkflowList", () => {
216
223
  });
217
224
 
218
225
  test("groups entries by source → provider and sorts names", () => {
219
- const workflows: DiscoveredWorkflow[] = [
226
+ const workflows: WorkflowWithMetadata[] = [
220
227
  wf("zebra", "claude", "local"),
221
228
  wf("apple", "claude", "local"),
222
229
  wf("middle", "opencode", "local"),
@@ -27,6 +27,7 @@ import type {
27
27
  AgentType,
28
28
  DiscoveredWorkflow,
29
29
  WorkflowInput,
30
+ WorkflowMetadataStatus,
30
31
  WorkflowWithMetadata,
31
32
  } from "../../sdk/workflows/index.ts";
32
33
  import { WorkflowPickerPanel } from "../../sdk/components/workflow-picker-panel.tsx";
@@ -227,8 +228,11 @@ export async function workflowCommand(options: {
227
228
  options.agent as AgentType | undefined,
228
229
  { merge: false },
229
230
  );
230
- // Filter out workflows that fail to load (type errors, missing
231
- // .compile(), etc.) so the list only shows workflows ready to run.
231
+ // Keep workflows that failed to load in the list — their status is
232
+ // surfaced inline as a "needs update" or "broken" tag so the user
233
+ // can see that a workflow still exists on disk even after an SDK
234
+ // bump invalidated it. Silent filtering was the original cause of
235
+ // the "workflow vanished after upgrade" report.
232
236
  const workflows = await loadWorkflowsMetadata(discovered);
233
237
  process.stdout.write(renderWorkflowList(workflows));
234
238
  return 0;
@@ -477,9 +481,13 @@ async function runNamedMode(
477
481
  ` ~/.atomic/workflows/${name}/${agent}/index.ts ${COLORS.dim}(global)${COLORS.reset}`,
478
482
  );
479
483
 
480
- const available = await loadWorkflowsMetadata(
481
- await discoverWorkflows(cwd, agent),
482
- );
484
+ // Only suggest runnable alternatives — broken/incompatible workflows
485
+ // are visible via `atomic workflow -l` where their status is surfaced;
486
+ // listing them here would mask the real problem (the name the user
487
+ // typed does not exist) behind a dead-end suggestion.
488
+ const available = (
489
+ await loadWorkflowsMetadata(await discoverWorkflows(cwd, agent))
490
+ ).filter((w) => w.status.kind === "ok");
483
491
  if (available.length > 0) {
484
492
  console.error(`\nAvailable ${agent} workflows:`);
485
493
  for (const wf of available) {
@@ -608,6 +616,29 @@ const SOURCE_COLORS: Record<DiscoveredWorkflow["source"], PaletteKey> = {
608
616
  builtin: "info", // sky — ships with atomic, foundational
609
617
  };
610
618
 
619
+ /**
620
+ * Per-row status badge shown in `atomic workflow -l` output. `ok` rows
621
+ * render with no badge (the list is already dense; flagging only
622
+ * non-ok rows keeps the happy path untouched). Incompatible rows
623
+ * include the required version so the user can compare at a glance;
624
+ * error rows stay terse and defer detail to `atomic workflow -n
625
+ * <name>` which surfaces the structured loader message.
626
+ */
627
+ function renderStatusBadge(
628
+ paint: ReturnType<typeof createPainter>,
629
+ status: WorkflowMetadataStatus,
630
+ ): string {
631
+ if (status.kind === "ok") return "";
632
+ if (status.kind === "incompatible") {
633
+ return (
634
+ " " +
635
+ paint("warning", "⚠ needs v" + status.requiredVersion) +
636
+ paint("dim", ` (installed v${status.currentVersion})`)
637
+ );
638
+ }
639
+ return " " + paint("error", "✗ broken");
640
+ }
641
+
611
642
  /**
612
643
  * Render `atomic workflow --list` output as a printable string.
613
644
  *
@@ -635,7 +666,7 @@ const SOURCE_COLORS: Record<DiscoveredWorkflow["source"], PaletteKey> = {
635
666
  * Exported for testing — the pure-function shape makes coverage for the
636
667
  * renderer trivial without spinning up a full CLI invocation.
637
668
  */
638
- export function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
669
+ export function renderWorkflowList(workflows: WorkflowWithMetadata[]): string {
639
670
  const paint = createPainter();
640
671
  const lines: string[] = [];
641
672
 
@@ -653,9 +684,11 @@ export function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
653
684
  return lines.join("\n") + "\n";
654
685
  }
655
686
 
656
- // Group by source → agent → sorted names. This gives the renderer O(1)
657
- // lookups at both nesting levels and keeps the output deterministic.
658
- type ByAgent = Map<AgentType, string[]>;
687
+ // Group by source → agent → sorted entries. Entries carry the full
688
+ // metadata (name + status) so the row renderer can append a status
689
+ // badge to non-ok rows without another lookup.
690
+ type EntrySummary = { name: string; status: WorkflowMetadataStatus };
691
+ type ByAgent = Map<AgentType, EntrySummary[]>;
659
692
  const bySource = new Map<DiscoveredWorkflow["source"], ByAgent>();
660
693
  for (const wf of workflows) {
661
694
  let byAgent = bySource.get(wf.source);
@@ -663,13 +696,13 @@ export function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
663
696
  byAgent = new Map();
664
697
  bySource.set(wf.source, byAgent);
665
698
  }
666
- const names = byAgent.get(wf.agent) ?? [];
667
- names.push(wf.name);
668
- byAgent.set(wf.agent, names);
699
+ const entries = byAgent.get(wf.agent) ?? [];
700
+ entries.push({ name: wf.name, status: wf.status });
701
+ byAgent.set(wf.agent, entries);
669
702
  }
670
703
  for (const byAgent of bySource.values()) {
671
- for (const names of byAgent.values()) {
672
- names.sort((a, b) => a.localeCompare(b));
704
+ for (const entries of byAgent.values()) {
705
+ entries.sort((a, b) => a.name.localeCompare(b.name));
673
706
  }
674
707
  }
675
708
 
@@ -702,8 +735,8 @@ export function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
702
735
  );
703
736
 
704
737
  for (const agent of AGENT_ORDER) {
705
- const names = byAgent.get(agent);
706
- if (!names || names.length === 0) continue;
738
+ const entries = byAgent.get(agent);
739
+ if (!entries || entries.length === 0) continue;
707
740
 
708
741
  // Provider heading: bold accent blue — a clearly different layer from
709
742
  // both the semantic source heading above and the neutral entries below.
@@ -712,8 +745,14 @@ export function renderWorkflowList(workflows: DiscoveredWorkflow[]): string {
712
745
  " " + paint("accent", AGENT_DISPLAY_NAMES[agent], { bold: true }),
713
746
  );
714
747
 
715
- for (const name of names) {
716
- lines.push(" " + paint("text", name));
748
+ for (const entry of entries) {
749
+ // Dim the name on non-ok rows so the eye lands on the status
750
+ // badge rather than the workflow name — the badge is where the
751
+ // actionable info lives, and the name is already unrunnable.
752
+ const nameCol: PaletteKey = entry.status.kind === "ok" ? "text" : "dim";
753
+ lines.push(
754
+ " " + paint(nameCol, entry.name) + renderStatusBadge(paint, entry.status),
755
+ );
717
756
  }
718
757
  }
719
758
  }
@@ -11,7 +11,7 @@ import { deriveGraphTheme } from "./graph-theme.ts";
11
11
  import type { GraphTheme } from "./graph-theme.ts";
12
12
  import { PanelStore } from "./orchestrator-panel-store.ts";
13
13
  import { StoreContext, ThemeContext, TmuxSessionContext } from "./orchestrator-panel-contexts.ts";
14
- import type { PanelSession, PanelOptions } from "./orchestrator-panel-types.ts";
14
+ import type { PanelSession, PanelOptions, SessionData } from "./orchestrator-panel-types.ts";
15
15
  import { SessionGraphPanel } from "./session-graph-panel.tsx";
16
16
  import { ErrorBoundary } from "./error-boundary.tsx";
17
17
 
@@ -179,4 +179,39 @@ export class OrchestratorPanel {
179
179
  this.renderer.destroy();
180
180
  } catch {}
181
181
  }
182
+
183
+ /**
184
+ * Subscribe to store mutations. Returned function unsubscribes.
185
+ *
186
+ * Used by the orchestrator process to mirror the in-memory panel
187
+ * state to a `status.json` file on disk so out-of-process consumers
188
+ * (e.g. `atomic workflow status`) can read the live workflow state.
189
+ */
190
+ subscribe(fn: () => void): () => void {
191
+ return this.store.subscribe(fn);
192
+ }
193
+
194
+ /**
195
+ * Read-only snapshot of the fields needed by the on-disk status
196
+ * writer. Defined here (not in PanelStore) because the store keeps
197
+ * full mutable references; this projection drops the renderer-only
198
+ * promise resolvers and version counter.
199
+ */
200
+ getSnapshot(): {
201
+ workflowName: string;
202
+ agent: string;
203
+ prompt: string;
204
+ fatalError: string | null;
205
+ completionReached: boolean;
206
+ sessions: readonly SessionData[];
207
+ } {
208
+ return {
209
+ workflowName: this.store.workflowName,
210
+ agent: this.store.agent,
211
+ prompt: this.store.prompt,
212
+ fatalError: this.store.fatalError,
213
+ completionReached: this.store.completionReached,
214
+ sessions: this.store.sessions,
215
+ };
216
+ }
182
217
  }