@bastani/atomic 0.5.18 → 0.5.19

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 (53) hide show
  1. package/.agents/skills/workflow-creator/SKILL.md +110 -1
  2. package/.agents/skills/workflow-creator/references/workflow-inputs.md +10 -0
  3. package/.mcp.json +9 -0
  4. package/.opencode/opencode.json +5 -2
  5. package/README.md +394 -645
  6. package/assets/settings.schema.json +0 -20
  7. package/dist/sdk/components/attached-statusline.d.ts +13 -0
  8. package/dist/sdk/components/attached-statusline.d.ts.map +1 -0
  9. package/dist/sdk/components/header.d.ts.map +1 -1
  10. package/dist/sdk/components/session-graph-panel.d.ts.map +1 -1
  11. package/dist/sdk/components/statusline.d.ts +1 -3
  12. package/dist/sdk/components/statusline.d.ts.map +1 -1
  13. package/dist/sdk/providers/claude.d.ts +16 -5
  14. package/dist/sdk/providers/claude.d.ts.map +1 -1
  15. package/dist/sdk/runtime/executor.d.ts +63 -0
  16. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  17. package/dist/sdk/runtime/tmux.d.ts +0 -9
  18. package/dist/sdk/runtime/tmux.d.ts.map +1 -1
  19. package/dist/services/config/atomic-config.d.ts +1 -7
  20. package/dist/services/config/atomic-config.d.ts.map +1 -1
  21. package/dist/services/config/definitions.d.ts +0 -45
  22. package/dist/services/config/definitions.d.ts.map +1 -1
  23. package/dist/services/config/index.d.ts +1 -1
  24. package/dist/theme/colors.d.ts +33 -0
  25. package/dist/theme/colors.d.ts.map +1 -0
  26. package/package.json +3 -2
  27. package/src/cli.ts +16 -1
  28. package/src/commands/cli/chat/index.ts +1 -1
  29. package/src/commands/cli/footer.tsx +118 -0
  30. package/src/commands/cli/init/index.ts +6 -89
  31. package/src/commands/cli/workflow-command.test.ts +146 -0
  32. package/src/commands/cli/workflow.ts +43 -7
  33. package/src/completions/bash.ts +3 -8
  34. package/src/completions/fish.ts +1 -3
  35. package/src/completions/powershell.ts +1 -17
  36. package/src/completions/zsh.ts +0 -2
  37. package/src/scripts/bundle-configs.ts +0 -12
  38. package/src/sdk/components/attached-statusline.tsx +33 -0
  39. package/src/sdk/components/header.tsx +16 -2
  40. package/src/sdk/components/session-graph-panel.tsx +10 -51
  41. package/src/sdk/components/statusline.tsx +0 -17
  42. package/src/sdk/providers/claude.ts +179 -177
  43. package/src/sdk/runtime/executor-entry.ts +3 -1
  44. package/src/sdk/runtime/executor.test.ts +292 -1
  45. package/src/sdk/runtime/executor.ts +222 -1
  46. package/src/sdk/runtime/tmux.conf +35 -4
  47. package/src/sdk/runtime/tmux.ts +0 -22
  48. package/src/services/config/atomic-config.ts +1 -14
  49. package/src/services/config/definitions.ts +1 -102
  50. package/src/services/config/index.ts +1 -1
  51. package/src/services/config/settings.ts +2 -65
  52. package/src/services/system/skills.ts +2 -19
  53. package/src/commands/cli/init/scm.ts +0 -175
@@ -1,10 +1,17 @@
1
- import { test, expect, describe } from "bun:test";
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, writeFileSync, chmodSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import {
3
6
  renderMessagesToText,
4
7
  hasContent,
5
8
  escBash,
6
9
  escPwsh,
7
10
  watchCopilotSessionForHIL,
11
+ watchCopilotSessionForElicitation,
12
+ shouldOverrideCopilotCliPath,
13
+ discoverCopilotBinary,
14
+ applyContainerEnvDefaults,
8
15
  type CopilotHILSessionSurface,
9
16
  } from "./executor.ts";
10
17
  import type { SavedMessage } from "../types.ts";
@@ -524,3 +531,287 @@ describe("watchCopilotSessionForHIL", () => {
524
531
  expect(session.handlerCount("tool.execution_complete")).toBe(0);
525
532
  });
526
533
  });
534
+
535
+ // ---------------------------------------------------------------------------
536
+ // watchCopilotSessionForElicitation — HIL detection via elicitation events
537
+ // ---------------------------------------------------------------------------
538
+
539
+ describe("watchCopilotSessionForElicitation", () => {
540
+ test("fires onHIL(true) on elicitation.requested event", () => {
541
+ const session = makeMockCopilotSession();
542
+ const calls: boolean[] = [];
543
+ const unsubscribe = watchCopilotSessionForElicitation(session, (w) =>
544
+ calls.push(w),
545
+ );
546
+
547
+ session.dispatch("elicitation.requested", { requestId: "req-1" });
548
+ expect(calls).toEqual([true]);
549
+
550
+ unsubscribe();
551
+ });
552
+
553
+ test("fires onHIL(false) on elicitation.completed with matching requestId", () => {
554
+ const session = makeMockCopilotSession();
555
+ const calls: boolean[] = [];
556
+ const unsubscribe = watchCopilotSessionForElicitation(session, (w) =>
557
+ calls.push(w),
558
+ );
559
+
560
+ session.dispatch("elicitation.requested", { requestId: "req-1" });
561
+ expect(calls).toEqual([true]);
562
+
563
+ session.dispatch("elicitation.completed", { requestId: "req-1" });
564
+ expect(calls).toEqual([true, false]);
565
+
566
+ unsubscribe();
567
+ });
568
+
569
+ test("only fires onHIL(true) once and onHIL(false) only after last overlapping request completes", () => {
570
+ const session = makeMockCopilotSession();
571
+ const calls: boolean[] = [];
572
+ const unsubscribe = watchCopilotSessionForElicitation(session, (w) =>
573
+ calls.push(w),
574
+ );
575
+
576
+ session.dispatch("elicitation.requested", { requestId: "req-a" });
577
+ session.dispatch("elicitation.requested", { requestId: "req-b" });
578
+ // onHIL(true) fires exactly once on the first request
579
+ expect(calls).toEqual([true]);
580
+
581
+ session.dispatch("elicitation.completed", { requestId: "req-a" });
582
+ // req-b still active — must not fire onHIL(false) yet
583
+ expect(calls).toEqual([true]);
584
+
585
+ session.dispatch("elicitation.completed", { requestId: "req-b" });
586
+ expect(calls).toEqual([true, false]);
587
+
588
+ unsubscribe();
589
+ });
590
+
591
+ test("ignores elicitation.completed for an unknown requestId", () => {
592
+ const session = makeMockCopilotSession();
593
+ const calls: boolean[] = [];
594
+ const unsubscribe = watchCopilotSessionForElicitation(session, (w) =>
595
+ calls.push(w),
596
+ );
597
+
598
+ session.dispatch("elicitation.completed", { requestId: "req-unknown" });
599
+ expect(calls).toEqual([]);
600
+
601
+ unsubscribe();
602
+ });
603
+
604
+ test("ignores elicitation.requested payload without a requestId", () => {
605
+ const session = makeMockCopilotSession();
606
+ const calls: boolean[] = [];
607
+ const unsubscribe = watchCopilotSessionForElicitation(session, (w) =>
608
+ calls.push(w),
609
+ );
610
+
611
+ session.dispatch("elicitation.requested", {});
612
+ expect(calls).toEqual([]);
613
+
614
+ unsubscribe();
615
+ });
616
+
617
+ test("unsubscribe removes both elicitation listeners and subsequent events are not received", () => {
618
+ const session = makeMockCopilotSession();
619
+ const calls: boolean[] = [];
620
+ const unsubscribe = watchCopilotSessionForElicitation(session, (w) =>
621
+ calls.push(w),
622
+ );
623
+
624
+ expect(session.handlerCount("elicitation.requested")).toBe(1);
625
+ expect(session.handlerCount("elicitation.completed")).toBe(1);
626
+
627
+ unsubscribe();
628
+
629
+ expect(session.handlerCount("elicitation.requested")).toBe(0);
630
+ expect(session.handlerCount("elicitation.completed")).toBe(0);
631
+
632
+ // Events fired after unsubscribe must not reach the original handler
633
+ session.dispatch("elicitation.requested", { requestId: "req-post" });
634
+ session.dispatch("elicitation.completed", { requestId: "req-post" });
635
+ expect(calls).toEqual([]);
636
+ });
637
+
638
+ test("MCP-initiated elicitation (non-empty elicitationSource) triggers onHIL(true) and onHIL(false) same as agent-initiated", () => {
639
+ const session = makeMockCopilotSession();
640
+ const calls: boolean[] = [];
641
+ const unsubscribe = watchCopilotSessionForElicitation(session, (w) =>
642
+ calls.push(w),
643
+ );
644
+
645
+ // Simulate MCP-initiated elicitation with a non-empty elicitationSource
646
+ session.dispatch("elicitation.requested", {
647
+ requestId: "req-mcp-1",
648
+ elicitationSource: "mcp-server://my-tool",
649
+ message: "Please provide your API key",
650
+ });
651
+ expect(calls).toEqual([true]);
652
+
653
+ session.dispatch("elicitation.completed", {
654
+ requestId: "req-mcp-1",
655
+ action: "accept",
656
+ });
657
+ expect(calls).toEqual([true, false]);
658
+
659
+ unsubscribe();
660
+ });
661
+
662
+ test("calling unsubscribe twice does not throw", () => {
663
+ const session = makeMockCopilotSession();
664
+ const unsubscribe = watchCopilotSessionForElicitation(session, () => {});
665
+
666
+ unsubscribe();
667
+ // Second call must be safe — no throw, no error
668
+ expect(() => unsubscribe()).not.toThrow();
669
+ });
670
+
671
+ test("ask_user watcher and elicitation watcher on same session track HIL independently", () => {
672
+ const session = makeMockCopilotSession();
673
+ const hilCalls: boolean[] = [];
674
+ const elicitationCalls: boolean[] = [];
675
+
676
+ const unsubHIL = watchCopilotSessionForHIL(session, (w) =>
677
+ hilCalls.push(w),
678
+ );
679
+ const unsubElicitation = watchCopilotSessionForElicitation(session, (w) =>
680
+ elicitationCalls.push(w),
681
+ );
682
+
683
+ // Fire an ask_user event — only the HIL watcher should see it
684
+ session.dispatch("tool.execution_start", {
685
+ toolName: "ask_user",
686
+ toolCallId: "tc-concurrent",
687
+ });
688
+ expect(hilCalls).toEqual([true]);
689
+ expect(elicitationCalls).toEqual([]);
690
+
691
+ // Fire an elicitation event — only the elicitation watcher should see it
692
+ session.dispatch("elicitation.requested", {
693
+ requestId: "req-concurrent",
694
+ });
695
+ expect(hilCalls).toEqual([true]);
696
+ expect(elicitationCalls).toEqual([true]);
697
+
698
+ // Complete the ask_user — only HIL watcher fires false
699
+ session.dispatch("tool.execution_complete", { toolCallId: "tc-concurrent" });
700
+ expect(hilCalls).toEqual([true, false]);
701
+ expect(elicitationCalls).toEqual([true]);
702
+
703
+ // Complete the elicitation — only elicitation watcher fires false
704
+ session.dispatch("elicitation.completed", { requestId: "req-concurrent" });
705
+ expect(hilCalls).toEqual([true, false]);
706
+ expect(elicitationCalls).toEqual([true, false]);
707
+
708
+ unsubHIL();
709
+ unsubElicitation();
710
+ });
711
+ });
712
+
713
+ // ---------------------------------------------------------------------------
714
+ // Copilot CLI path discovery (Bun-without-node containers)
715
+ // ---------------------------------------------------------------------------
716
+
717
+ describe("discoverCopilotBinary / shouldOverrideCopilotCliPath", () => {
718
+ let sandbox: string;
719
+ let savedPath: string | undefined;
720
+ let savedCliPath: string | undefined;
721
+
722
+ beforeEach(() => {
723
+ sandbox = mkdtempSync(join(tmpdir(), "atomic-cli-probe-"));
724
+ savedPath = process.env.PATH;
725
+ savedCliPath = process.env.COPILOT_CLI_PATH;
726
+ delete process.env.COPILOT_CLI_PATH;
727
+ });
728
+
729
+ afterEach(() => {
730
+ if (savedPath === undefined) delete process.env.PATH;
731
+ else process.env.PATH = savedPath;
732
+ if (savedCliPath === undefined) delete process.env.COPILOT_CLI_PATH;
733
+ else process.env.COPILOT_CLI_PATH = savedCliPath;
734
+ rmSync(sandbox, { recursive: true, force: true });
735
+ });
736
+
737
+ function putExe(dir: string, name: string, contents = "#!/bin/sh\necho $0"): string {
738
+ const p = join(dir, name);
739
+ writeFileSync(p, contents);
740
+ chmodSync(p, 0o755);
741
+ return p;
742
+ }
743
+
744
+ test("finds an executable 'copilot' on PATH", () => {
745
+ const bin = putExe(sandbox, "copilot");
746
+ process.env.PATH = sandbox;
747
+ expect(discoverCopilotBinary()).toBe(bin);
748
+ });
749
+
750
+ test("returns undefined when no copilot on PATH", () => {
751
+ process.env.PATH = sandbox;
752
+ expect(discoverCopilotBinary()).toBeUndefined();
753
+ });
754
+
755
+ test("returns undefined when PATH is unset", () => {
756
+ delete process.env.PATH;
757
+ expect(discoverCopilotBinary()).toBeUndefined();
758
+ });
759
+
760
+ test("returns undefined when PATH is empty", () => {
761
+ process.env.PATH = "";
762
+ expect(discoverCopilotBinary()).toBeUndefined();
763
+ });
764
+
765
+ test("skips non-executable files named 'copilot' on Unix", () => {
766
+ if (process.platform === "win32") return;
767
+ const p = join(sandbox, "copilot");
768
+ writeFileSync(p, "not executable");
769
+ chmodSync(p, 0o644);
770
+ process.env.PATH = sandbox;
771
+ expect(discoverCopilotBinary()).toBeUndefined();
772
+ });
773
+
774
+ test("shouldOverrideCopilotCliPath: false when COPILOT_CLI_PATH is user-set", () => {
775
+ putExe(sandbox, "copilot");
776
+ process.env.PATH = sandbox;
777
+ process.env.COPILOT_CLI_PATH = "/somewhere/else/copilot";
778
+ expect(shouldOverrideCopilotCliPath()).toBe(false);
779
+ });
780
+
781
+ test("shouldOverrideCopilotCliPath: false when node is also on PATH (SDK default works)", () => {
782
+ putExe(sandbox, "copilot");
783
+ putExe(sandbox, "node");
784
+ process.env.PATH = sandbox;
785
+ expect(shouldOverrideCopilotCliPath()).toBe(false);
786
+ });
787
+
788
+ test("shouldOverrideCopilotCliPath: true when bun + copilot but no node", () => {
789
+ putExe(sandbox, "copilot");
790
+ // Sandboxing PATH to a dir without `node` is what makes this test
791
+ // deterministic regardless of the host's installed toolchain.
792
+ process.env.PATH = sandbox;
793
+ // We're running this test under Bun, so process.versions.bun is set
794
+ expect(!!process.versions.bun).toBe(true);
795
+ expect(shouldOverrideCopilotCliPath()).toBe(true);
796
+ });
797
+
798
+ test("shouldOverrideCopilotCliPath: false when PATH is unset", () => {
799
+ delete process.env.PATH;
800
+ expect(shouldOverrideCopilotCliPath()).toBe(false);
801
+ });
802
+
803
+ test("applyContainerEnvDefaults sets COPILOT_CLI_PATH when override is needed", () => {
804
+ const bin = putExe(sandbox, "copilot");
805
+ process.env.PATH = sandbox;
806
+ applyContainerEnvDefaults();
807
+ expect(process.env.COPILOT_CLI_PATH).toBe(bin);
808
+ });
809
+
810
+ test("applyContainerEnvDefaults does NOT overwrite user-set COPILOT_CLI_PATH", () => {
811
+ putExe(sandbox, "copilot");
812
+ process.env.PATH = sandbox;
813
+ process.env.COPILOT_CLI_PATH = "/custom/copilot";
814
+ applyContainerEnvDefaults();
815
+ expect(process.env.COPILOT_CLI_PATH).toBe("/custom/copilot");
816
+ });
817
+ });
@@ -17,6 +17,7 @@
17
17
  import { join, resolve } from "node:path";
18
18
  import { homedir } from "node:os";
19
19
  import { writeFile } from "node:fs/promises";
20
+ import { statSync, accessSync, constants as fsConstants } from "node:fs";
20
21
  import type {
21
22
  WorkflowDefinition,
22
23
  WorkflowContext,
@@ -55,6 +56,7 @@ import {
55
56
  import { OrchestratorPanel } from "./panel.tsx";
56
57
  import { GraphFrontierTracker } from "./graph-inference.ts";
57
58
  import { errorMessage } from "../errors.ts";
59
+ import { createPainter } from "../../theme/colors.ts";
58
60
 
59
61
  /** Maximum time (ms) to wait for an agent's server to become reachable. */
60
62
  const SERVER_WAIT_TIMEOUT_MS = 60_000;
@@ -130,6 +132,13 @@ export interface WorkflowRunOptions {
130
132
  workflowFile: string;
131
133
  /** Project root (defaults to cwd) */
132
134
  projectRoot?: string;
135
+ /**
136
+ * When true, create the tmux session and return immediately instead
137
+ * of attaching. The orchestrator keeps running in the background on
138
+ * the atomic tmux socket; users can attach later with
139
+ * `atomic workflow session connect <name>`.
140
+ */
141
+ detach?: boolean;
133
142
  }
134
143
 
135
144
  interface SessionResult {
@@ -182,6 +191,117 @@ async function getRandomPort(): Promise<number> {
182
191
  );
183
192
  }
184
193
 
194
+ /**
195
+ * Resolve a non-JS Copilot CLI binary on PATH.
196
+ *
197
+ * Under Bun, `@github/copilot-sdk` spawns its bundled JS entry via `node`
198
+ * (see `getNodeExecPath` in the SDK). If `node` isn't installed — common in
199
+ * minimal containers — the spawn fails silently with ENOENT and the SDK's
200
+ * write to the child's stdin surfaces as "Cannot call write after a stream
201
+ * was destroyed" from vscode-jsonrpc. Pointing the SDK at a standalone
202
+ * `copilot` binary (the npm-installed ELF executable) sidesteps the
203
+ * node-vs-bun problem because the SDK execs it directly when the path does
204
+ * not end in `.js`.
205
+ *
206
+ * Returns undefined if no suitable binary is found.
207
+ */
208
+ export function discoverCopilotBinary(): string | undefined {
209
+ const pathVar = process.env.PATH;
210
+ if (!pathVar) return undefined;
211
+ // Windows: only `copilot.exe` is probed. Bun's global install writes a
212
+ // real `.exe` shim, so this covers the Bun-container scenario this guard
213
+ // exists for. Pre-existing npm-installed shims (`copilot.cmd`/`.ps1`)
214
+ // aren't handled — the entire override is gated on `process.versions.bun`.
215
+ const exe = process.platform === "win32" ? "copilot.exe" : "copilot";
216
+ const sep = process.platform === "win32" ? ";" : ":";
217
+ for (const dir of pathVar.split(sep)) {
218
+ if (!dir) continue;
219
+ const candidate = join(dir, exe);
220
+ if (!isExecutableFile(candidate)) continue;
221
+ return candidate;
222
+ }
223
+ return undefined;
224
+ }
225
+
226
+ /**
227
+ * True when we need to override the SDK's default CLI path — i.e. running
228
+ * under Bun, the user hasn't set COPILOT_CLI_PATH, and `node` is not
229
+ * available to execute the SDK's bundled JS entry.
230
+ *
231
+ * Pure predicate on the current env; safe to call repeatedly.
232
+ */
233
+ export function shouldOverrideCopilotCliPath(): boolean {
234
+ if (!process.versions.bun) return false;
235
+ if (process.env.COPILOT_CLI_PATH) return false;
236
+ if (isNodeOnPath()) return false;
237
+ return discoverCopilotBinary() !== undefined;
238
+ }
239
+
240
+ function isExecutableFile(path: string): boolean {
241
+ try {
242
+ if (!statSync(path).isFile()) return false;
243
+ if (process.platform === "win32") return true;
244
+ accessSync(path, fsConstants.X_OK);
245
+ return true;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ function isNodeOnPath(): boolean {
252
+ const pathVar = process.env.PATH;
253
+ if (!pathVar) return false;
254
+ const exe = process.platform === "win32" ? "node.exe" : "node";
255
+ const sep = process.platform === "win32" ? ";" : ":";
256
+ for (const dir of pathVar.split(sep)) {
257
+ if (!dir) continue;
258
+ if (isExecutableFile(join(dir, exe))) return true;
259
+ }
260
+ return false;
261
+ }
262
+
263
+ /**
264
+ * Set safe env defaults for the orchestrator process before any SDK is
265
+ * loaded. Idempotent — subsequent calls no-op once `COPILOT_CLI_PATH`
266
+ * is set. Call as early as possible so headless Copilot subprocesses
267
+ * inherit the resolved env.
268
+ */
269
+ export function applyContainerEnvDefaults(): void {
270
+ if (!process.versions.bun) return;
271
+ if (process.env.COPILOT_CLI_PATH) return;
272
+ if (isNodeOnPath()) return;
273
+ const bin = discoverCopilotBinary();
274
+ if (bin) process.env.COPILOT_CLI_PATH = bin;
275
+ }
276
+
277
+ /** Percent of the agent window's vertical space allocated to the React footer pane. */
278
+ const FOOTER_PANE_PERCENT = 5;
279
+
280
+ /**
281
+ * Split the agent window so the top pane runs the agent CLI and the bottom
282
+ * pane runs the React footer (via `atomic _footer`). Allocating a percentage
283
+ * of the vertical layout — rather than an absolute cell count — lets tmux
284
+ * enforce its minimum-pane-size constraints and keeps the split readable on
285
+ * both tall and short terminals.
286
+ *
287
+ * Resolves the CLI entrypoint relative to this file (executor.ts lives at
288
+ * src/sdk/runtime/, so ../../cli.ts is the CLI). `process.argv[1]` points
289
+ * to executor-entry.ts here — a separate process that has no `_footer`
290
+ * subcommand — so we can't use it.
291
+ */
292
+ function spawnAttachedFooter(windowName: string, paneId: string): void {
293
+ const runtime = process.execPath;
294
+ if (!runtime) return;
295
+ const cliPath = join(import.meta.dir, "..", "..", "cli.ts");
296
+ const cmd = `"${escBash(runtime)}" "${escBash(cliPath)}" _footer --name "${escBash(windowName)}"`;
297
+ tmux.tmuxRun([
298
+ "split-window",
299
+ "-t", paneId,
300
+ "-v", "-l", `${FOOTER_PANE_PERCENT}%`, "-d",
301
+ cmd,
302
+ ]);
303
+ }
304
+
185
305
  function buildPaneCommand(
186
306
  agent: AgentType,
187
307
  port: number,
@@ -340,6 +460,7 @@ export async function executeWorkflow(
340
460
  inputs = {},
341
461
  workflowFile,
342
462
  projectRoot = process.cwd(),
463
+ detach = false,
343
464
  } = options;
344
465
 
345
466
  const workflowRunId = generateId();
@@ -395,6 +516,14 @@ export async function executeWorkflow(
395
516
  tmux.createSession(tmuxSessionName, shellCmd, "orchestrator");
396
517
  tmux.setSessionEnv(tmuxSessionName, "ATOMIC_AGENT", agent);
397
518
 
519
+ if (detach) {
520
+ // Session is already running detached on the atomic socket (tmux
521
+ // new-session -d). Print connection hints and return so the caller
522
+ // can exit cleanly without blocking on the orchestrator.
523
+ printDetachedBanner(tmuxSessionName);
524
+ return;
525
+ }
526
+
398
527
  if (tmux.isInsideAtomicSocket()) {
399
528
  // Already on the atomic server — just switch to the new session.
400
529
  tmux.switchClient(tmuxSessionName);
@@ -408,6 +537,25 @@ export async function executeWorkflow(
408
537
  }
409
538
  }
410
539
 
540
+ /**
541
+ * Print a short banner telling the user the workflow is running in the
542
+ * background and how to attach to it. Written to stdout so scripts can
543
+ * capture the session name with a simple redirect.
544
+ */
545
+ function printDetachedBanner(tmuxSessionName: string): void {
546
+ const paint = createPainter();
547
+ process.stdout.write(
548
+ "\n" +
549
+ " " + paint("success", "✓") + " " + paint("text", "workflow started in background", { bold: true }) + "\n" +
550
+ " " + paint("dim", "session: ") + paint("accent", tmuxSessionName) + "\n" +
551
+ "\n" +
552
+ " " + paint("dim", "attach: ") + paint("accent", `atomic workflow session connect ${tmuxSessionName}`) + "\n" +
553
+ " " + paint("dim", "list: ") + paint("accent", "atomic workflow session list") + "\n" +
554
+ " " + paint("dim", "kill: ") + paint("accent", `atomic workflow session kill ${tmuxSessionName}`) + "\n" +
555
+ "\n",
556
+ );
557
+ }
558
+
411
559
  /**
412
560
  * Small buffer (ms) subtracted from `Date.now()` when recording the Claude
413
561
  * session start timestamp. Protects against fast sequential runs where
@@ -566,6 +714,16 @@ export interface OpenCodeHILEvent {
566
714
  * Events for other sessions are silently ignored. The function returns when
567
715
  * the stream is exhausted (i.e. the server closes the connection).
568
716
  *
717
+ * NOTE: OpenCode does not emit any bus event for MCP-server-initiated
718
+ * elicitation requests — its MCP client never registers an
719
+ * `ElicitRequestSchema` handler, so such requests are auto-rejected by the
720
+ * MCP SDK at the protocol layer before reaching any OpenCode-level code.
721
+ * As a result, the workflow UI **cannot** mark an OpenCode session as
722
+ * "awaiting input" for MCP elicitation; this is an upstream limitation that
723
+ * Atomic cannot work around. If a future OpenCode release surfaces MCP
724
+ * elicitation as a bus event, extend the switch below (or add a sibling
725
+ * watcher) to map it onto `onHIL`.
726
+ *
569
727
  * Exported for unit testing.
570
728
  */
571
729
  export async function watchOpencodeStreamForHIL(
@@ -651,6 +809,50 @@ export function watchCopilotSessionForHIL(
651
809
  };
652
810
  }
653
811
 
812
+ /**
813
+ * Subscribe to a Copilot session's elicitation events to track HIL state for
814
+ * `session.ui.elicitation()`, `session.ui.select()`, `session.ui.input()`, and
815
+ * MCP-server-initiated elicitation requests:
816
+ *
817
+ * - `elicitation.requested` → `onHIL(true)` (set transitions empty→non-empty)
818
+ * - `elicitation.completed` → `onHIL(false)` (set transitions non-empty→empty)
819
+ *
820
+ * Overlapping elicitation requests are tracked by `requestId` so
821
+ * `onHIL(false)` only fires after the last in-flight request completes.
822
+ *
823
+ * Returns an unsubscribe function that removes both listeners.
824
+ *
825
+ * Exported for unit testing.
826
+ */
827
+ export function watchCopilotSessionForElicitation(
828
+ session: CopilotHILSessionSurface,
829
+ onHIL: (waiting: boolean) => void,
830
+ ): () => void {
831
+ const active = new Set<string>();
832
+ const unsubRequested = session.on("elicitation.requested", (event) => {
833
+ const data = event.data as { requestId?: string } | undefined;
834
+ if (data?.requestId) {
835
+ const wasEmpty = active.size === 0;
836
+ active.add(data.requestId);
837
+ if (wasEmpty) onHIL(true);
838
+ }
839
+ });
840
+ const unsubCompleted = session.on("elicitation.completed", (event) => {
841
+ const data = event.data as { requestId?: string } | undefined;
842
+ if (
843
+ data?.requestId &&
844
+ active.delete(data.requestId) &&
845
+ active.size === 0
846
+ ) {
847
+ onHIL(false);
848
+ }
849
+ });
850
+ return () => {
851
+ unsubRequested();
852
+ unsubCompleted();
853
+ };
854
+ }
855
+
654
856
  // ============================================================================
655
857
  // Shared transcript / message readers
656
858
  // ============================================================================
@@ -744,6 +946,7 @@ async function initProviderClientAndSession<A extends AgentType>(
744
946
  serverUrl: string,
745
947
  paneId: string,
746
948
  sessionId: string,
949
+ sessionDir: string,
747
950
  clientOpts: StageClientOptions<A>,
748
951
  sessionOpts: StageSessionOptions<A>,
749
952
  headless = false,
@@ -812,7 +1015,7 @@ async function initProviderClientAndSession<A extends AgentType>(
812
1015
  }
813
1016
  const claudeClientOpts = clientOpts as StageClientOptions<"claude">;
814
1017
  const claudeSessionOpts = sessionOpts as StageSessionOptions<"claude">;
815
- const client = new ClaudeClientWrapper(paneId, claudeClientOpts);
1018
+ const client = new ClaudeClientWrapper(paneId, claudeClientOpts, sessionDir);
816
1019
  await client.start();
817
1020
  const session = new ClaudeSessionWrapper(paneId, sessionId, claudeSessionOpts, onHIL);
818
1021
  return { client, session } as Result;
@@ -973,6 +1176,8 @@ function createSessionRunner(
973
1176
  );
974
1177
  shared.activeRegistry.set(name, { name, paneId, done: donePromise });
975
1178
 
1179
+ spawnAttachedFooter(name, paneId);
1180
+
976
1181
  serverUrl = await waitForServer(shared.agent, port, paneId);
977
1182
 
978
1183
  shared.panel.addSession(name, graphParents);
@@ -1093,6 +1298,7 @@ function createSessionRunner(
1093
1298
  serverUrl,
1094
1299
  paneId,
1095
1300
  sessionId,
1301
+ sessionDir,
1096
1302
  clientOpts,
1097
1303
  sessionOpts,
1098
1304
  isHeadless,
@@ -1114,6 +1320,7 @@ function createSessionRunner(
1114
1320
  // Unsubscribe fn for the Copilot HIL event listeners; invoked in the
1115
1321
  // `finally` block so the handlers are removed when the stage ends.
1116
1322
  let hilUnsubscribe: (() => void) | undefined;
1323
+ let copilotElicitationUnsubscribe: (() => void) | undefined;
1117
1324
 
1118
1325
  if (shared.agent === "copilot") {
1119
1326
  const copilotSession = providerSession as ProviderSession<"copilot">;
@@ -1127,6 +1334,19 @@ function createSessionRunner(
1127
1334
  // is registered, so we can detect HIL via the SDK's event stream and
1128
1335
  // still let the CLI render its native tmux-pane dialog.
1129
1336
  hilUnsubscribe = watchCopilotSessionForHIL(copilotSession, onHIL);
1337
+
1338
+ // Copilot elicitation HIL detection via native SDK events.
1339
+ //
1340
+ // `elicitation.requested` / `elicitation.completed` fire when the
1341
+ // agent calls `session.ui.elicitation()`, `session.ui.select()`,
1342
+ // `session.ui.input()`, or an MCP server issues an elicitation
1343
+ // request. These events are distinct from the `ask_user` tool and
1344
+ // require a separate watcher so the UI can show the "waiting for
1345
+ // response" indicator in all HIL scenarios.
1346
+ copilotElicitationUnsubscribe = watchCopilotSessionForElicitation(
1347
+ copilotSession,
1348
+ onHIL,
1349
+ );
1130
1350
  }
1131
1351
 
1132
1352
  // ── 12b. OpenCode: SSE event stream for HIL detection ──
@@ -1204,6 +1424,7 @@ function createSessionRunner(
1204
1424
  } finally {
1205
1425
  // ── 14a. Stop background HIL watcher (if any) ──
1206
1426
  hilUnsubscribe?.();
1427
+ copilotElicitationUnsubscribe?.();
1207
1428
 
1208
1429
  // ── 14b. Auto-cleanup provider resources ──
1209
1430
  await cleanupProvider(
@@ -29,10 +29,6 @@ set -g status-right-length 60
29
29
  set -g status-style "bg=#1e1e2e,fg=#cdd6f4"
30
30
  set -g status-right-style "fg=#6c7086"
31
31
 
32
- # Pane splitting
33
- bind - split-window -v -c "#{pane_current_path}"
34
- bind | split-window -h -c "#{pane_current_path}"
35
-
36
32
  # Pane resizing
37
33
  bind -r l resize-pane -R 5
38
34
  bind -r h resize-pane -L 5
@@ -71,3 +67,38 @@ bind-key -T copy-mode-vi Escape if-shell -F "#{selection_present}" "send-keys -X
71
67
  bind -T copy-mode-vi MouseDrag1Pane select-pane \; send-keys -X begin-selection \; send-keys -X scroll-exit-off
72
68
  bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel
73
69
  bind -T copy-mode-vi MouseDown1Pane send-keys -X clear-selection \; select-pane
70
+
71
+ # ── Workflow protection: unbind destructive defaults ──────────────
72
+ # Atomic manages the tmux session lifecycle programmatically. Users should
73
+ # interact with agents and navigate via Ctrl+G / Ctrl+\ / the graph panel.
74
+ # The prefix key (C-b) is kept only for copy-mode entry (C-b [).
75
+ #
76
+ # Note: In practice, C-b is largely unreachable inside Atomic sessions because
77
+ # the agent TUIs (Claude Code, OpenCode, Copilot CLI) run in raw/alternate
78
+ # screen mode and capture keyboard input — including Ctrl+b — before tmux ever
79
+ # sees it. This makes all prefix-based bindings effectively inaccessible during
80
+ # normal agent interaction. The unbinds below are a defense-in-depth measure
81
+ # for the edge case where a user exits an agent back to a bare shell prompt.
82
+ #
83
+ # Bindings intentionally kept (non-destructive or low-risk):
84
+ # C-b [ copy-mode entry (keyboard fallback for mouse scroll)
85
+ # C-b : command prompt (requires typing a full command — low accidental risk)
86
+ # C-b d detach (non-destructive; just disconnects the client)
87
+ # C-b c new window (non-destructive; doesn't affect existing windows)
88
+ # C-b - custom split vertical (replaces default C-b ")
89
+ # C-b | custom split horizontal (replaces default C-b %)
90
+
91
+ # Prevent window/pane destruction
92
+ unbind & # kill-window
93
+ unbind x # kill-pane
94
+
95
+ # Prevent renaming
96
+ unbind , # rename-window
97
+ unbind '$' # rename-session
98
+
99
+ # Prevent automatic window renaming (complements allow-rename off above)
100
+ setw -g automatic-rename off
101
+
102
+ # Pane splitting
103
+ bind - split-window -v -c "#{pane_current_path}"
104
+ bind | split-window -h -c "#{pane_current_path}"