@bastani/atomic 0.5.0-2 → 0.5.0-4

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 (39) hide show
  1. package/.atomic/workflows/hello/claude/index.ts +22 -25
  2. package/.atomic/workflows/hello/copilot/index.ts +41 -31
  3. package/.atomic/workflows/hello/opencode/index.ts +40 -40
  4. package/.atomic/workflows/hello-parallel/claude/index.ts +54 -54
  5. package/.atomic/workflows/hello-parallel/copilot/index.ts +89 -70
  6. package/.atomic/workflows/hello-parallel/opencode/index.ts +77 -77
  7. package/.atomic/workflows/ralph/claude/index.ts +128 -93
  8. package/.atomic/workflows/ralph/copilot/index.ts +212 -112
  9. package/.atomic/workflows/ralph/helpers/prompts.ts +45 -2
  10. package/.atomic/workflows/ralph/opencode/index.ts +174 -111
  11. package/README.md +62 -53
  12. package/package.json +3 -2
  13. package/src/commands/cli/chat/index.ts +28 -8
  14. package/src/commands/cli/init/index.ts +6 -4
  15. package/src/commands/cli/init/scm.ts +27 -10
  16. package/src/sdk/components/connectors.test.ts +45 -0
  17. package/src/sdk/components/layout.test.ts +321 -0
  18. package/src/sdk/components/layout.ts +51 -15
  19. package/src/sdk/components/orchestrator-panel-store.test.ts +156 -0
  20. package/src/sdk/components/orchestrator-panel-store.ts +24 -0
  21. package/src/sdk/components/orchestrator-panel.tsx +21 -0
  22. package/src/sdk/components/session-graph-panel.tsx +3 -9
  23. package/src/sdk/components/statusline.tsx +4 -6
  24. package/src/sdk/define-workflow.test.ts +71 -0
  25. package/src/sdk/define-workflow.ts +42 -39
  26. package/src/sdk/errors.ts +1 -1
  27. package/src/sdk/index.ts +4 -1
  28. package/src/sdk/providers/claude.ts +1 -1
  29. package/src/sdk/providers/copilot.ts +5 -3
  30. package/src/sdk/providers/opencode.ts +5 -3
  31. package/src/sdk/runtime/executor.ts +512 -301
  32. package/src/sdk/runtime/loader.ts +2 -2
  33. package/src/sdk/runtime/tmux.ts +31 -2
  34. package/src/sdk/types.ts +93 -20
  35. package/src/sdk/workflows.ts +7 -4
  36. package/src/services/config/definitions.ts +39 -2
  37. package/src/services/config/settings.ts +0 -6
  38. package/src/services/system/skills.ts +3 -7
  39. package/tsconfig.json +34 -0
@@ -558,4 +558,160 @@ describe("PanelStore", () => {
558
558
  expect(emitCount.value).toBe(7);
559
559
  });
560
560
  });
561
+
562
+ // ── addSession ──────────────────────────────────────────────────────────────
563
+
564
+ describe("addSession", () => {
565
+ test("appends a new session to the sessions array", () => {
566
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
567
+ const beforeCount = store.sessions.length;
568
+
569
+ store.addSession({
570
+ name: "dynamic-1",
571
+ status: "running",
572
+ parents: ["orchestrator"],
573
+ startedAt: Date.now(),
574
+ endedAt: null,
575
+ });
576
+
577
+ expect(store.sessions.length).toBe(beforeCount + 1);
578
+ expect(store.sessions.at(-1)!.name).toBe("dynamic-1");
579
+ expect(store.sessions.at(-1)!.status).toBe("running");
580
+ });
581
+
582
+ test("increments version on add", () => {
583
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
584
+ const v = store.version;
585
+
586
+ store.addSession({
587
+ name: "dynamic-2",
588
+ status: "running",
589
+ parents: ["orchestrator"],
590
+ startedAt: Date.now(),
591
+ endedAt: null,
592
+ });
593
+
594
+ expect(store.version).toBe(v + 1);
595
+ });
596
+
597
+ test("notifies subscribers on add", () => {
598
+ const listener = mock(() => {});
599
+ store.subscribe(listener);
600
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
601
+ listener.mockClear();
602
+
603
+ store.addSession({
604
+ name: "dynamic-3",
605
+ status: "running",
606
+ parents: ["orchestrator"],
607
+ startedAt: Date.now(),
608
+ endedAt: null,
609
+ });
610
+
611
+ expect(listener).toHaveBeenCalledTimes(1);
612
+ });
613
+
614
+ test("dynamically added session can be started/completed", () => {
615
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
616
+
617
+ store.addSession({
618
+ name: "dynamic-4",
619
+ status: "running",
620
+ parents: ["orchestrator"],
621
+ startedAt: Date.now(),
622
+ endedAt: null,
623
+ });
624
+
625
+ store.completeSession("dynamic-4");
626
+ const session = store.sessions.find((s) => s.name === "dynamic-4");
627
+ expect(session!.status).toBe("complete");
628
+ expect(session!.endedAt).not.toBeNull();
629
+ });
630
+
631
+ test("stores session with non-existent parent reference", () => {
632
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
633
+
634
+ store.addSession({
635
+ name: "orphan",
636
+ status: "running",
637
+ parents: ["nonexistent"],
638
+ startedAt: Date.now(),
639
+ endedAt: null,
640
+ });
641
+
642
+ const session = store.sessions.find((s) => s.name === "orphan");
643
+ expect(session).toBeDefined();
644
+ expect(session!.parents).toEqual(["nonexistent"]);
645
+ });
646
+
647
+ test("stores session with empty parents array", () => {
648
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
649
+
650
+ store.addSession({
651
+ name: "no-parent",
652
+ status: "running",
653
+ parents: [],
654
+ startedAt: Date.now(),
655
+ endedAt: null,
656
+ });
657
+
658
+ const session = store.sessions.find((s) => s.name === "no-parent");
659
+ expect(session).toBeDefined();
660
+ expect(session!.parents).toEqual([]);
661
+ });
662
+
663
+ test("nested child added after parent via addSession", () => {
664
+ store.setWorkflowInfo("wf", "copilot", [], "prompt");
665
+
666
+ store.addSession({
667
+ name: "step-1",
668
+ status: "running",
669
+ parents: ["orchestrator"],
670
+ startedAt: Date.now(),
671
+ endedAt: null,
672
+ });
673
+
674
+ store.addSession({
675
+ name: "step-1-child",
676
+ status: "running",
677
+ parents: ["step-1"],
678
+ startedAt: Date.now(),
679
+ endedAt: null,
680
+ });
681
+
682
+ const child = store.sessions.find((s) => s.name === "step-1-child");
683
+ expect(child).toBeDefined();
684
+ expect(child!.parents).toEqual(["step-1"]);
685
+ });
686
+ });
687
+
688
+ // ── setWorkflowInfo edge cases ─────────────────────────────────────────────
689
+
690
+ describe("setWorkflowInfo edge cases", () => {
691
+ test("session with inter-session parent reference preserves parents", () => {
692
+ store.setWorkflowInfo("wf", "copilot", [
693
+ { name: "step-1", parents: [] },
694
+ { name: "step-2", parents: ["step-1"] },
695
+ ], "prompt");
696
+
697
+ const step1 = store.sessions.find((s) => s.name === "step-1");
698
+ const step2 = store.sessions.find((s) => s.name === "step-2");
699
+ // step-1 with empty parents gets ["orchestrator"]
700
+ expect(step1!.parents).toEqual(["orchestrator"]);
701
+ // step-2 with explicit parent keeps it
702
+ expect(step2!.parents).toEqual(["step-1"]);
703
+ });
704
+
705
+ test("deeply nested pre-defined sessions preserve hierarchy", () => {
706
+ store.setWorkflowInfo("wf", "copilot", [
707
+ { name: "s1", parents: [] },
708
+ { name: "s2", parents: ["s1"] },
709
+ { name: "s3", parents: ["s2"] },
710
+ ], "prompt");
711
+
712
+ expect(store.sessions.find((s) => s.name === "s1")!.parents).toEqual(["orchestrator"]);
713
+ expect(store.sessions.find((s) => s.name === "s2")!.parents).toEqual(["s1"]);
714
+ expect(store.sessions.find((s) => s.name === "s3")!.parents).toEqual(["s2"]);
715
+ });
716
+ });
561
717
  });
@@ -15,6 +15,7 @@ export class PanelStore {
15
15
  fatalError: string | null = null;
16
16
  completionReached = false;
17
17
  exitResolve: (() => void) | null = null;
18
+ abortResolve: (() => void) | null = null;
18
19
 
19
20
  private listeners = new Set<Listener>();
20
21
 
@@ -81,6 +82,11 @@ export class PanelStore {
81
82
  this.emit();
82
83
  }
83
84
 
85
+ addSession(session: SessionData): void {
86
+ this.sessions.push(session);
87
+ this.emit();
88
+ }
89
+
84
90
  setCompletion(workflowName: string, transcriptsPath: string): void {
85
91
  this.completionInfo = { workflowName, transcriptsPath };
86
92
  const orch = this.sessions.find((s) => s.name === "orchestrator");
@@ -111,6 +117,24 @@ export class PanelStore {
111
117
  }
112
118
  }
113
119
 
120
+ /** Safely invoke abortResolve at most once to signal mid-execution quit. */
121
+ resolveAbort(): void {
122
+ if (this.abortResolve) {
123
+ const resolve = this.abortResolve;
124
+ this.abortResolve = null;
125
+ resolve();
126
+ }
127
+ }
128
+
129
+ /** Quit the workflow — routes to the correct handler based on current phase. */
130
+ requestQuit(): void {
131
+ if (this.completionReached) {
132
+ this.resolveExit();
133
+ } else {
134
+ this.resolveAbort();
135
+ }
136
+ }
137
+
114
138
  markCompletionReached(): void {
115
139
  this.completionReached = true;
116
140
  this.emit();
@@ -111,6 +111,17 @@ export class OrchestratorPanel {
111
111
  this.store.failSession(name, message);
112
112
  }
113
113
 
114
+ /** Dynamically add a new session node to the graph UI. */
115
+ addSession(name: string, parents: string[]): void {
116
+ this.store.addSession({
117
+ name,
118
+ status: "running",
119
+ parents,
120
+ startedAt: Date.now(),
121
+ endedAt: null,
122
+ });
123
+ }
124
+
114
125
  /** Show the workflow-complete banner with a link to saved transcripts. */
115
126
  showCompletion(workflowName: string, transcriptsPath: string): void {
116
127
  this.store.setCompletion(workflowName, transcriptsPath);
@@ -132,6 +143,16 @@ export class OrchestratorPanel {
132
143
  });
133
144
  }
134
145
 
146
+ /**
147
+ * Returns a promise that resolves when the user requests a mid-execution quit
148
+ * (via `q` or `Ctrl+C`). Race this against the workflow run.
149
+ */
150
+ waitForAbort(): Promise<void> {
151
+ return new Promise<void>((resolve) => {
152
+ this.store.abortResolve = resolve;
153
+ });
154
+ }
155
+
135
156
  /** Tear down the terminal renderer and release resources. Idempotent. */
136
157
  destroy(): void {
137
158
  if (this.destroyed) return;
@@ -180,15 +180,9 @@ export function SessionGraphPanel() {
180
180
 
181
181
  // Keyboard handling
182
182
  useKeyboard((key) => {
183
- // Ctrl+C always exits
184
- if (key.ctrl && key.name === "c") {
185
- store.resolveExit();
186
- return;
187
- }
188
-
189
- // After completion: only q exits (Enter is for attach, Escape is too easy to hit)
190
- if (store.completionReached && key.name === "q") {
191
- store.resolveExit();
183
+ // Ctrl+C or q: quit the workflow (abort if running, exit if completed)
184
+ if ((key.ctrl && key.name === "c") || key.name === "q") {
185
+ store.requestQuit();
192
186
  return;
193
187
  }
194
188
 
@@ -1,6 +1,6 @@
1
1
  /** @jsxImportSource @opentui/react */
2
2
 
3
- import { useStore, useGraphTheme } from "./orchestrator-panel-contexts.ts";
3
+ import { useGraphTheme } from "./orchestrator-panel-contexts.ts";
4
4
  import { statusIcon, statusColor, statusLabel } from "./status-helpers.ts";
5
5
  import type { LayoutNode } from "./layout.ts";
6
6
 
@@ -11,11 +11,9 @@ export function Statusline({
11
11
  focusedNode: LayoutNode | undefined;
12
12
  attachMsg: string;
13
13
  }) {
14
- const store = useStore();
15
14
  const theme = useGraphTheme();
16
15
  const ni = focusedNode ? statusIcon(focusedNode.status) : "";
17
16
  const nc = focusedNode ? statusColor(focusedNode.status, theme) : theme.textDim;
18
- const canExit = store.completionReached;
19
17
 
20
18
  return (
21
19
  <box height={1} flexDirection="row" backgroundColor={theme.backgroundElement}>
@@ -52,9 +50,9 @@ export function Statusline({
52
50
  <span fg={theme.textDim}> {"\u00B7"} </span>
53
51
  <span fg={theme.text}>{"\u21B5"}</span>
54
52
  <span fg={theme.textMuted}> attach</span>
55
- {canExit ? <span fg={theme.textDim}> {"\u00B7"} </span> : null}
56
- {canExit ? <span fg={theme.text}>q</span> : null}
57
- {canExit ? <span fg={theme.textMuted}> quit</span> : null}
53
+ <span fg={theme.textDim}> {"\u00B7"} </span>
54
+ <span fg={theme.text}>q</span>
55
+ <span fg={theme.textMuted}> quit</span>
58
56
  </text>
59
57
  )}
60
58
  </box>
@@ -0,0 +1,71 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { defineWorkflow, WorkflowBuilder } from "./define-workflow.ts";
3
+
4
+ describe("defineWorkflow", () => {
5
+ test("returns a WorkflowBuilder", () => {
6
+ const builder = defineWorkflow({ name: "test" });
7
+ expect(builder).toBeInstanceOf(WorkflowBuilder);
8
+ expect(builder.__brand).toBe("WorkflowBuilder");
9
+ });
10
+
11
+ test("throws on empty name", () => {
12
+ expect(() => defineWorkflow({ name: "" })).toThrow("Workflow name is required");
13
+ });
14
+
15
+ test("throws on whitespace-only name", () => {
16
+ expect(() => defineWorkflow({ name: " " })).toThrow("Workflow name is required");
17
+ });
18
+ });
19
+
20
+ describe("WorkflowBuilder.run()", () => {
21
+ test("accepts a function and returns this for chaining", () => {
22
+ const builder = defineWorkflow({ name: "test" });
23
+ const result = builder.run(async () => {});
24
+ expect(result).toBe(builder);
25
+ });
26
+
27
+ test("throws if called twice", () => {
28
+ const builder = defineWorkflow({ name: "test" }).run(async () => {});
29
+ expect(() => builder.run(async () => {})).toThrow("run() can only be called once");
30
+ });
31
+
32
+ test("throws if argument is not a function", () => {
33
+ const builder = defineWorkflow({ name: "test" });
34
+ expect(() => builder.run("not a function" as never)).toThrow("run() requires a function");
35
+ });
36
+ });
37
+
38
+ describe("WorkflowBuilder.compile()", () => {
39
+ test("produces a WorkflowDefinition with correct brand", () => {
40
+ const def = defineWorkflow({ name: "test" })
41
+ .run(async () => {})
42
+ .compile();
43
+ expect(def.__brand).toBe("WorkflowDefinition");
44
+ });
45
+
46
+ test("preserves name and description", () => {
47
+ const def = defineWorkflow({ name: "my-wf", description: "A description" })
48
+ .run(async () => {})
49
+ .compile();
50
+ expect(def.name).toBe("my-wf");
51
+ expect(def.description).toBe("A description");
52
+ });
53
+
54
+ test("defaults description to empty string", () => {
55
+ const def = defineWorkflow({ name: "test" })
56
+ .run(async () => {})
57
+ .compile();
58
+ expect(def.description).toBe("");
59
+ });
60
+
61
+ test("stores the run function", () => {
62
+ const fn = async () => {};
63
+ const def = defineWorkflow({ name: "test" }).run(fn).compile();
64
+ expect(def.run).toBe(fn);
65
+ });
66
+
67
+ test("throws if no run callback was provided", () => {
68
+ const builder = defineWorkflow({ name: "test" });
69
+ expect(() => builder.compile()).toThrow("has no run callback");
70
+ });
71
+ });
@@ -1,75 +1,71 @@
1
1
  /**
2
- * Workflow Builder — chainable DSL for defining multi-session workflows.
2
+ * Workflow Builder — defines a workflow with a single `.run()` entry point.
3
3
  *
4
4
  * Usage:
5
- * defineWorkflow({ name: "ralph", description: "..." })
6
- * .session({ name: "research", run: async (ctx) => { ... } })
7
- * .session({ name: "plan", run: async (ctx) => { ... } })
5
+ * defineWorkflow({ name: "my-workflow", description: "..." })
6
+ * .run(async (ctx) => {
7
+ * await ctx.session({ name: "research" }, async (s) => { ... });
8
+ * await ctx.session({ name: "plan" }, async (s) => { ... });
9
+ * })
8
10
  * .compile()
9
11
  */
10
12
 
11
- import type { WorkflowOptions, SessionOptions, WorkflowDefinition } from "./types.ts";
13
+ import type { WorkflowOptions, WorkflowContext, WorkflowDefinition } from "./types.ts";
12
14
 
13
15
  /**
14
- * Chainable workflow builder. Records session definitions in order,
15
- * then .compile() seals them into a WorkflowDefinition.
16
+ * Chainable workflow builder. Records the run callback,
17
+ * then .compile() seals it into a WorkflowDefinition.
16
18
  */
17
19
  export class WorkflowBuilder {
18
20
  /** @internal Brand for detection across package boundaries */
19
21
  readonly __brand = "WorkflowBuilder" as const;
20
22
  private readonly options: WorkflowOptions;
21
- private readonly stepDefs: SessionOptions[][] = [];
22
- private readonly namesSeen = new Set<string>();
23
+ private runFn: ((ctx: WorkflowContext) => Promise<void>) | null = null;
23
24
 
24
25
  constructor(options: WorkflowOptions) {
25
26
  this.options = options;
26
27
  }
27
28
 
28
29
  /**
29
- * Add a session (or parallel group of sessions) to the workflow.
30
+ * Set the workflow's entry point.
30
31
  *
31
- * Pass a single SessionOptions for sequential execution.
32
- * Pass an array of SessionOptions for parallel execution —
33
- * all sessions in the array run concurrently, and the next
34
- * .session() call waits for the entire group to complete.
32
+ * The callback receives a {@link WorkflowContext} with `session()` for
33
+ * spawning agent sessions, and `transcript()` / `getMessages()` for
34
+ * reading completed session outputs. Use native TypeScript control flow
35
+ * (loops, conditionals, `Promise.all()`) for orchestration.
35
36
  */
36
- session(opts: SessionOptions | SessionOptions[]): this {
37
- const step = Array.isArray(opts) ? opts : [opts];
38
- if (step.length === 0) {
39
- throw new Error("session() requires at least one SessionOptions.");
37
+ run(fn: (ctx: WorkflowContext) => Promise<void>): this {
38
+ if (this.runFn) {
39
+ throw new Error("run() can only be called once per workflow.");
40
40
  }
41
- for (const s of step) {
42
- if (!s.name || s.name.trim() === "") {
43
- throw new Error("Session name is required.");
44
- }
45
- if (typeof s.run !== "function") {
46
- throw new Error(`Session "${s.name}": run must be a function, got ${typeof s.run}.`);
47
- }
48
- if (this.namesSeen.has(s.name)) {
49
- throw new Error(`Duplicate session name: "${s.name}"`);
50
- }
51
- this.namesSeen.add(s.name);
41
+ if (typeof fn !== "function") {
42
+ throw new Error(`run() requires a function, got ${typeof fn}.`);
52
43
  }
53
- this.stepDefs.push(step);
44
+ this.runFn = fn;
54
45
  return this;
55
46
  }
56
47
 
57
48
  /**
58
49
  * Compile the workflow into a sealed WorkflowDefinition.
59
50
  *
60
- * After calling compile(), no more sessions can be added.
61
- * The returned object is consumed by the Atomic CLI runtime.
51
+ * After calling compile(), the returned object is consumed by the
52
+ * Atomic CLI runtime.
62
53
  */
63
54
  compile(): WorkflowDefinition {
64
- if (this.stepDefs.length === 0) {
65
- throw new Error(`Workflow "${this.options.name}" has no sessions. Add at least one .session() call.`);
55
+ if (!this.runFn) {
56
+ throw new Error(
57
+ `Workflow "${this.options.name}" has no run callback. ` +
58
+ `Add a .run(async (ctx) => { ... }) call before .compile().`,
59
+ );
66
60
  }
67
61
 
62
+ const runFn = this.runFn;
63
+
68
64
  return {
69
65
  __brand: "WorkflowDefinition" as const,
70
66
  name: this.options.name,
71
67
  description: this.options.description ?? "",
72
- steps: Object.freeze(this.stepDefs.map((step) => Object.freeze([...step]))),
68
+ run: runFn,
73
69
  };
74
70
  }
75
71
  }
@@ -82,11 +78,18 @@ export class WorkflowBuilder {
82
78
  * import { defineWorkflow } from "@bastani/atomic/workflows";
83
79
  *
84
80
  * export default defineWorkflow({
85
- * name: "ralph",
86
- * description: "Research, plan, implement",
81
+ * name: "hello",
82
+ * description: "Two-session demo",
87
83
  * })
88
- * .session({ name: "research", run: async (ctx) => { ... } })
89
- * .session({ name: "plan", run: async (ctx) => { ... } })
84
+ * .run(async (ctx) => {
85
+ * const describe = await ctx.session({ name: "describe" }, async (s) => {
86
+ * // ... agent SDK code using s.serverUrl, s.paneId, s.save() ...
87
+ * });
88
+ * await ctx.session({ name: "summarize" }, async (s) => {
89
+ * const research = await s.transcript(describe);
90
+ * // ...
91
+ * });
92
+ * })
90
93
  * .compile();
91
94
  * ```
92
95
  */
package/src/sdk/errors.ts CHANGED
@@ -20,7 +20,7 @@ export class WorkflowNotCompiledError extends Error {
20
20
  `Workflow at ${path} was defined but not compiled.\n` +
21
21
  ` Add .compile() at the end of your defineWorkflow() chain:\n\n` +
22
22
  ` export default defineWorkflow({ ... })\n` +
23
- ` .session({ ... })\n` +
23
+ ` .run(async (ctx) => { ... })\n` +
24
24
  ` .compile();`,
25
25
  );
26
26
  this.name = "WorkflowNotCompiledError";
package/src/sdk/index.ts CHANGED
@@ -19,7 +19,10 @@ export type {
19
19
  SavedMessage,
20
20
  SaveTranscript,
21
21
  SessionContext,
22
- SessionOptions,
22
+ SessionRef,
23
+ SessionHandle,
24
+ SessionRunOptions,
25
+ WorkflowContext,
23
26
  WorkflowOptions,
24
27
  WorkflowDefinition,
25
28
  } from "./types.ts";
@@ -307,7 +307,7 @@ export function validateClaudeWorkflow(source: string): ClaudeValidationWarning[
307
307
  rule: "claude/create-session",
308
308
  message:
309
309
  "Could not verify that createClaudeSession is called before claudeQuery(). " +
310
- "Call createClaudeSession({ paneId: ctx.paneId }) to start the Claude CLI before sending queries.",
310
+ "Call createClaudeSession({ paneId: s.paneId }) to start the Claude CLI before sending queries.",
311
311
  });
312
312
  }
313
313
  }
@@ -2,7 +2,7 @@
2
2
  * Copilot workflow source validation.
3
3
  *
4
4
  * Checks that Copilot workflow source files follow required patterns:
5
- * - `cliUrl` is wired to `ctx.serverUrl` (or destructured `serverUrl`)
5
+ * - `cliUrl` is wired to the session context's `serverUrl`
6
6
  * - `setForegroundSessionId` is called after creating a session
7
7
  */
8
8
 
@@ -18,11 +18,13 @@ export function validateCopilotWorkflow(source: string): CopilotValidationWarnin
18
18
  const warnings: CopilotValidationWarning[] = [];
19
19
 
20
20
  if (/\bCopilotClient\b/.test(source)) {
21
- if (!/cliUrl\s*:\s*(?:ctx\.serverUrl|serverUrl)/.test(source)) {
21
+ // Accept any identifier before .serverUrl (e.g., s.serverUrl, ctx.serverUrl)
22
+ // or a destructured `serverUrl` variable
23
+ if (!/cliUrl\s*:\s*(?:\w+\.serverUrl|serverUrl)/.test(source)) {
22
24
  warnings.push({
23
25
  rule: "copilot/cli-url",
24
26
  message:
25
- "Could not verify that CopilotClient is created with { cliUrl: ctx.serverUrl }. " +
27
+ "Could not verify that CopilotClient is created with { cliUrl: s.serverUrl }. " +
26
28
  "This is required to connect to the workflow's agent pane.",
27
29
  });
28
30
  }
@@ -2,7 +2,7 @@
2
2
  * OpenCode workflow source validation.
3
3
  *
4
4
  * Checks that OpenCode workflow source files follow required patterns:
5
- * - `baseUrl` is wired to `ctx.serverUrl` (or destructured `serverUrl`)
5
+ * - `baseUrl` is wired to the session context's `serverUrl`
6
6
  * - `tui.selectSession` is called after creating a session
7
7
  */
8
8
 
@@ -18,11 +18,13 @@ export function validateOpenCodeWorkflow(source: string): OpenCodeValidationWarn
18
18
  const warnings: OpenCodeValidationWarning[] = [];
19
19
 
20
20
  if (/\bcreateOpencodeClient\b/.test(source)) {
21
- if (!/baseUrl\s*:\s*(?:ctx\.serverUrl|serverUrl)/.test(source)) {
21
+ // Accept any identifier before .serverUrl (e.g., s.serverUrl, ctx.serverUrl)
22
+ // or a destructured `serverUrl` variable
23
+ if (!/baseUrl\s*:\s*(?:\w+\.serverUrl|serverUrl)/.test(source)) {
22
24
  warnings.push({
23
25
  rule: "opencode/base-url",
24
26
  message:
25
- "Could not verify that createOpencodeClient is called with { baseUrl: ctx.serverUrl }. " +
27
+ "Could not verify that createOpencodeClient is called with { baseUrl: s.serverUrl }. " +
26
28
  "This is required to connect to the workflow's agent pane.",
27
29
  });
28
30
  }