@bastani/atomic 0.5.18 → 0.5.19-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/workflow-creator/SKILL.md +110 -1
- package/.agents/skills/workflow-creator/references/workflow-inputs.md +10 -0
- package/.mcp.json +9 -0
- package/.opencode/opencode.json +5 -2
- package/README.md +394 -645
- package/assets/settings.schema.json +0 -20
- package/dist/sdk/components/attached-statusline.d.ts +13 -0
- package/dist/sdk/components/attached-statusline.d.ts.map +1 -0
- package/dist/sdk/components/header.d.ts.map +1 -1
- package/dist/sdk/components/session-graph-panel.d.ts.map +1 -1
- package/dist/sdk/components/statusline.d.ts +1 -3
- package/dist/sdk/components/statusline.d.ts.map +1 -1
- package/dist/sdk/providers/claude.d.ts +16 -5
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/sdk/runtime/executor.d.ts +63 -0
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/runtime/tmux.d.ts +0 -9
- package/dist/sdk/runtime/tmux.d.ts.map +1 -1
- package/dist/services/config/atomic-config.d.ts +1 -7
- package/dist/services/config/atomic-config.d.ts.map +1 -1
- package/dist/services/config/definitions.d.ts +0 -45
- package/dist/services/config/definitions.d.ts.map +1 -1
- package/dist/services/config/index.d.ts +1 -1
- package/dist/theme/colors.d.ts +33 -0
- package/dist/theme/colors.d.ts.map +1 -0
- package/package.json +3 -2
- package/src/cli.ts +16 -1
- package/src/commands/cli/chat/index.ts +1 -1
- package/src/commands/cli/footer.tsx +118 -0
- package/src/commands/cli/init/index.ts +6 -89
- package/src/commands/cli/workflow-command.test.ts +146 -0
- package/src/commands/cli/workflow.ts +43 -7
- package/src/completions/bash.ts +3 -8
- package/src/completions/fish.ts +1 -3
- package/src/completions/powershell.ts +1 -17
- package/src/completions/zsh.ts +0 -2
- package/src/scripts/bundle-configs.ts +0 -12
- package/src/sdk/components/attached-statusline.tsx +33 -0
- package/src/sdk/components/header.tsx +16 -2
- package/src/sdk/components/session-graph-panel.tsx +10 -51
- package/src/sdk/components/statusline.tsx +0 -17
- package/src/sdk/providers/claude.ts +179 -177
- package/src/sdk/runtime/executor-entry.ts +3 -1
- package/src/sdk/runtime/executor.test.ts +292 -1
- package/src/sdk/runtime/executor.ts +222 -1
- package/src/sdk/runtime/tmux.conf +35 -4
- package/src/sdk/runtime/tmux.ts +0 -22
- package/src/services/config/atomic-config.ts +1 -14
- package/src/services/config/definitions.ts +1 -102
- package/src/services/config/index.ts +1 -1
- package/src/services/config/settings.ts +2 -65
- package/src/services/system/skills.ts +2 -19
- 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}"
|