@bastani/atomic 0.5.5 → 0.5.6-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.
Files changed (37) hide show
  1. package/README.md +60 -34
  2. package/dist/sdk/components/compact-switcher.d.ts +10 -0
  3. package/dist/sdk/components/compact-switcher.d.ts.map +1 -0
  4. package/dist/sdk/components/orchestrator-panel-store.d.ts +21 -1
  5. package/dist/sdk/components/orchestrator-panel-store.d.ts.map +1 -1
  6. package/dist/sdk/components/orchestrator-panel-types.d.ts +1 -0
  7. package/dist/sdk/components/orchestrator-panel-types.d.ts.map +1 -1
  8. package/dist/sdk/components/session-graph-panel.d.ts.map +1 -1
  9. package/dist/sdk/components/statusline.d.ts.map +1 -1
  10. package/dist/sdk/runtime/executor.d.ts +3 -2
  11. package/dist/sdk/runtime/executor.d.ts.map +1 -1
  12. package/dist/sdk/runtime/tmux.d.ts +82 -2
  13. package/dist/sdk/runtime/tmux.d.ts.map +1 -1
  14. package/dist/sdk/workflows/index.d.ts +2 -2
  15. package/dist/sdk/workflows/index.d.ts.map +1 -1
  16. package/package.json +1 -1
  17. package/src/cli.ts +150 -27
  18. package/src/commands/cli/chat/index.ts +25 -14
  19. package/src/commands/cli/completions.ts +24 -0
  20. package/src/commands/cli/session.test.ts +491 -0
  21. package/src/commands/cli/session.ts +265 -0
  22. package/src/commands/cli/workflow.ts +1 -1
  23. package/src/completions/bash.ts +107 -0
  24. package/src/completions/fish.ts +126 -0
  25. package/src/completions/index.ts +7 -0
  26. package/src/completions/powershell.ts +184 -0
  27. package/src/completions/zsh.ts +144 -0
  28. package/src/sdk/components/compact-switcher.tsx +73 -0
  29. package/src/sdk/components/orchestrator-panel-store.test.ts +124 -0
  30. package/src/sdk/components/orchestrator-panel-store.ts +36 -1
  31. package/src/sdk/components/orchestrator-panel-types.ts +2 -0
  32. package/src/sdk/components/session-graph-panel.tsx +138 -10
  33. package/src/sdk/components/statusline.tsx +13 -8
  34. package/src/sdk/runtime/executor.ts +18 -27
  35. package/src/sdk/runtime/tmux.conf +18 -0
  36. package/src/sdk/runtime/tmux.ts +198 -24
  37. package/src/sdk/workflows/index.ts +7 -1
@@ -18,7 +18,14 @@ import {
18
18
  useRef,
19
19
  useContext,
20
20
  } from "react";
21
- import { tmuxRun } from "../runtime/tmux.ts";
21
+ import {
22
+ tmuxRun,
23
+ escapeTmuxFormat,
24
+ TMUX_DEFAULT_STATUS_LEFT,
25
+ TMUX_DEFAULT_STATUS_LEFT_LENGTH,
26
+ TMUX_DEFAULT_STATUS_RIGHT,
27
+ TMUX_DEFAULT_STATUS_RIGHT_LENGTH,
28
+ } from "../runtime/tmux.ts";
22
29
  import {
23
30
  useStore,
24
31
  useGraphTheme,
@@ -32,6 +39,7 @@ import { NodeCard } from "./node-card.tsx";
32
39
  import { Edge } from "./edge.tsx";
33
40
  import { Header } from "./header.tsx";
34
41
  import { Statusline } from "./statusline.tsx";
42
+ import { CompactSwitcher } from "./compact-switcher.tsx";
35
43
 
36
44
  /** Interval (ms) between pulse animation frames — ~60fps feel. */
37
45
  const PULSE_INTERVAL_MS = 60;
@@ -75,6 +83,10 @@ export function SessionGraphPanel() {
75
83
  const focusedIdRef = useRef(focusedId);
76
84
  focusedIdRef.current = focusedId;
77
85
 
86
+ // Compact switcher state
87
+ const [switcherOpen, setSwitcherOpen] = useState(false);
88
+ const [switcherSel, setSwitcherSel] = useState(0);
89
+
78
90
  // Update focus when sessions first appear
79
91
  useEffect(() => {
80
92
  if (store.sessions.length > 0 && !layout.map[focusedId]) {
@@ -119,15 +131,40 @@ export function SessionGraphPanel() {
119
131
  const session = store.sessions.find((s) => s.name === id);
120
132
  if (!session || session.status === "pending") return;
121
133
 
134
+ // Orchestrator = the graph view itself
135
+ if (id === "orchestrator") {
136
+ store.setViewMode("graph");
137
+ return;
138
+ }
139
+
122
140
  if (attachTimerRef.current) clearTimeout(attachTimerRef.current);
123
141
  setAttachMsg(`\u2192 ${n.name}`);
124
142
  attachTimerRef.current = setTimeout(() => setAttachMsg(""), ATTACH_MSG_DISPLAY_MS);
125
143
 
144
+ setFocusedId(id);
145
+ store.setViewMode("attached", id);
126
146
  tmuxRun(["switch-client", "-t", `${tmuxSession}:${n.name}`]);
127
147
  },
128
148
  [layout.map, tmuxSession],
129
149
  );
130
150
 
151
+ const returnToGraph = useCallback(() => {
152
+ store.setViewMode("graph");
153
+ }, []);
154
+
155
+ const openSwitcher = useCallback(() => {
156
+ // Pre-select the current agent or focused node
157
+ const currentId = store.viewMode === "attached" ? store.activeAgentId : focusedIdRef.current;
158
+ const idx = store.sessions.findIndex((s) => s.name === currentId);
159
+ setSwitcherSel(Math.max(0, idx));
160
+ setSwitcherOpen(true);
161
+ }, []);
162
+
163
+ const closeSwitcher = useCallback(() => {
164
+ setSwitcherOpen(false);
165
+ setSwitcherSel(0);
166
+ }, []);
167
+
131
168
  // Spatial navigation
132
169
  const navigate = useCallback(
133
170
  (dir: "left" | "right" | "up" | "down") => {
@@ -168,18 +205,54 @@ export function SessionGraphPanel() {
168
205
  [layout.map, nodeList],
169
206
  );
170
207
 
171
- // gg double-tap tracking
208
+ // gg double-tap tracking (graph mode only)
172
209
  const lastKeyRef = useRef({ key: "", time: 0 });
173
210
 
174
- // Keyboard handling
211
+ // Keyboard handling — with Ctrl+G return-to-graph and auto-reset
175
212
  useKeyboard((key) => {
176
- // Ctrl+C or q: quit the workflow (abort if running, exit if completed)
213
+ // ── Switcher open: intercept all keys ──
214
+ if (switcherOpen) {
215
+ if (key.name === "escape") {
216
+ closeSwitcher();
217
+ return;
218
+ }
219
+ if (key.name === "up" || key.name === "k") {
220
+ setSwitcherSel((s) => Math.max(0, s - 1));
221
+ return;
222
+ }
223
+ if (key.name === "down" || key.name === "j") {
224
+ setSwitcherSel((s) => Math.min(store.sessions.length - 1, s + 1));
225
+ return;
226
+ }
227
+ if (key.name === "return") {
228
+ const agent = store.sessions[switcherSel];
229
+ closeSwitcher();
230
+ if (agent) doAttach(agent.name);
231
+ return;
232
+ }
233
+ return; // Swallow all other keys while switcher is open
234
+ }
235
+
236
+ // ── Global: Ctrl+C or q quits ──
177
237
  if ((key.ctrl && key.name === "c") || key.name === "q") {
178
238
  store.requestQuit();
179
239
  return;
180
240
  }
181
241
 
182
- // Arrow keys + hjkl navigation
242
+ // ── Auto-reset: receiving keys while "attached" means user returned to the orchestrator window ──
243
+ if (store.viewMode === "attached") {
244
+ returnToGraph();
245
+ // Fall through to process the key in graph mode
246
+ }
247
+
248
+ // ── / opens agent switcher ──
249
+ if (key.sequence === "/") {
250
+ openSwitcher();
251
+ return;
252
+ }
253
+
254
+ // ── Graph view navigation ──
255
+ // Arrow keys + hjkl
183
256
  if (key.name === "left" || key.name === "h") {
184
257
  navigate("left");
185
258
  return;
@@ -196,11 +269,6 @@ export function SessionGraphPanel() {
196
269
  navigate("down");
197
270
  return;
198
271
  }
199
- if (key.name === "tab") {
200
- navigate(key.shift ? "left" : "right");
201
- return;
202
- }
203
-
204
272
  // Enter: attach to focused node's tmux window
205
273
  if (key.name === "return") {
206
274
  doAttach(focusedIdRef.current);
@@ -285,6 +353,63 @@ export function SessionGraphPanel() {
285
353
  }
286
354
  }, [focusedId, focused, termW, termH, padX, padY, viewportH, layout.rowH]);
287
355
 
356
+ // ── Detect return to graph via Ctrl+G ─────────────────
357
+ // Ctrl+G is bound at the tmux level (select-window -t :0), so tmux
358
+ // swallows the key and the React app never receives it. Poll the
359
+ // active window index while attached; when window 0 becomes active
360
+ // again we know the user returned and can reset viewMode immediately.
361
+ useEffect(() => {
362
+ if (store.viewMode !== "attached") return;
363
+
364
+ const check = () => {
365
+ const result = tmuxRun([
366
+ "display-message", "-t", tmuxSession, "-p", "#{window_index}",
367
+ ]);
368
+ if (result.ok && result.stdout.trim() === "0") {
369
+ store.setViewMode("graph");
370
+ }
371
+ };
372
+
373
+ const id = setInterval(check, 300);
374
+ return () => clearInterval(id);
375
+ }, [store.viewMode, tmuxSession]);
376
+
377
+ // ── Tmux status bar sync ──────────────────────────────
378
+ // When attached, the orchestrator panel is hidden (user views the agent's
379
+ // tmux window). Mirror the status line hints into tmux's own status bar
380
+ // so navigation keys remain discoverable.
381
+ const subagentCount = store.getSubagents().length;
382
+ const activeAgentIdx = store.getActiveAgentIndex();
383
+
384
+ useEffect(() => {
385
+ if (store.viewMode === "attached" && store.activeAgentId) {
386
+ const safeName = escapeTmuxFormat(store.activeAgentId);
387
+ const left = `#[bg=#6c7086,fg=#1e1e2e,bold] ATTACHED #[default] #[fg=#7f849c]\u203a #[fg=#cdd6f4]${safeName} #[fg=#7f849c]${activeAgentIdx + 1}/${subagentCount}`;
388
+ const right = `#[fg=#7f849c]Graph: #[fg=#cdd6f4]ctrl+g #[fg=#7f849c]| Next: #[fg=#cdd6f4]ctrl+\\ `;
389
+
390
+ tmuxRun(["set", "-g", "status-left", left]);
391
+ tmuxRun(["set", "-g", "status-left-length", "50"]);
392
+ tmuxRun(["set", "-g", "status-right", right]);
393
+ tmuxRun(["set", "-g", "status-right-length", "40"]);
394
+ } else {
395
+ // Graph mode: restore defaults (constants from tmux.ts match tmux.conf)
396
+ tmuxRun(["set", "-g", "status-left", TMUX_DEFAULT_STATUS_LEFT]);
397
+ tmuxRun(["set", "-g", "status-left-length", TMUX_DEFAULT_STATUS_LEFT_LENGTH]);
398
+ tmuxRun(["set", "-g", "status-right", TMUX_DEFAULT_STATUS_RIGHT]);
399
+ tmuxRun(["set", "-g", "status-right-length", TMUX_DEFAULT_STATUS_RIGHT_LENGTH]);
400
+ }
401
+ }, [store.viewMode, store.activeAgentId, activeAgentIdx, subagentCount]);
402
+
403
+ // Restore default tmux status bar on unmount
404
+ useEffect(() => {
405
+ return () => {
406
+ tmuxRun(["set", "-g", "status-left", TMUX_DEFAULT_STATUS_LEFT]);
407
+ tmuxRun(["set", "-g", "status-left-length", TMUX_DEFAULT_STATUS_LEFT_LENGTH]);
408
+ tmuxRun(["set", "-g", "status-right", TMUX_DEFAULT_STATUS_RIGHT]);
409
+ tmuxRun(["set", "-g", "status-right-length", TMUX_DEFAULT_STATUS_RIGHT_LENGTH]);
410
+ };
411
+ }, []);
412
+
288
413
  return (
289
414
  <box width="100%" height="100%" flexDirection="column" backgroundColor={theme.background}>
290
415
  <Header />
@@ -345,6 +470,9 @@ export function SessionGraphPanel() {
345
470
  </box>
346
471
  </scrollbox>
347
472
 
473
+ {/* Compact agent switcher overlay */}
474
+ {switcherOpen ? <CompactSwitcher selectedIndex={switcherSel} /> : null}
475
+
348
476
  <Statusline focusedNode={focused} attachMsg={attachMsg} />
349
477
  </box>
350
478
  );
@@ -1,7 +1,7 @@
1
1
  /** @jsxImportSource @opentui/react */
2
2
 
3
- import { useGraphTheme } from "./orchestrator-panel-contexts.ts";
4
- import { statusIcon, statusColor, statusLabel } from "./status-helpers.ts";
3
+ import { useStore, useGraphTheme, useStoreVersion } from "./orchestrator-panel-contexts.ts";
4
+ import { statusIcon, statusColor } from "./status-helpers.ts";
5
5
  import type { LayoutNode } from "./layout.ts";
6
6
 
7
7
  export function Statusline({
@@ -11,24 +11,25 @@ export function Statusline({
11
11
  focusedNode: LayoutNode | undefined;
12
12
  attachMsg: string;
13
13
  }) {
14
+ const store = useStore();
14
15
  const theme = useGraphTheme();
15
- const ni = focusedNode ? statusIcon(focusedNode.status) : "";
16
- const nc = focusedNode ? statusColor(focusedNode.status, theme) : theme.textDim;
16
+ useStoreVersion(store);
17
17
 
18
18
  return (
19
19
  <box height={1} flexDirection="row" backgroundColor={theme.backgroundElement}>
20
+ {/* Mode badge — always GRAPH since this bar is only visible in the orchestrator window */}
20
21
  <box backgroundColor={theme.primary} paddingLeft={1} paddingRight={1} alignItems="center">
21
22
  <text fg={theme.backgroundElement}>
22
23
  <strong>GRAPH</strong>
23
24
  </text>
24
25
  </box>
25
26
 
27
+ {/* Focused node info */}
26
28
  {focusedNode ? (
27
- <box backgroundColor="transparent" paddingLeft={1} paddingRight={1} alignItems="center">
29
+ <box backgroundColor="transparent" paddingLeft={1} alignItems="center">
28
30
  <text>
29
- <span fg={nc}>{ni} </span>
31
+ <span fg={statusColor(focusedNode.status, theme)}>{statusIcon(focusedNode.status)} </span>
30
32
  <span fg={theme.text}>{focusedNode.name}</span>
31
- <span fg={theme.textMuted}> {"\u00B7"} {statusLabel(focusedNode.status)}</span>
32
33
  {focusedNode.error ? (
33
34
  <span fg={theme.error}> {"\u00B7"} {focusedNode.error}</span>
34
35
  ) : null}
@@ -38,6 +39,7 @@ export function Statusline({
38
39
 
39
40
  <box flexGrow={1} />
40
41
 
42
+ {/* Navigation hints — always graph-mode (tmux status bar handles attached-mode hints) */}
41
43
  <box paddingRight={2} alignItems="center">
42
44
  {attachMsg ? (
43
45
  <text fg={theme.text}>
@@ -45,12 +47,15 @@ export function Statusline({
45
47
  </text>
46
48
  ) : (
47
49
  <text>
48
- <span fg={theme.text}>{"\u2191"} {"\u2193"} {"\u2190"} {"\u2192"}</span>
50
+ <span fg={theme.text}>{"\u2191\u2193\u2190\u2192"}</span>
49
51
  <span fg={theme.textMuted}> navigate</span>
50
52
  <span fg={theme.textDim}> {"\u00B7"} </span>
51
53
  <span fg={theme.text}>{"\u21B5"}</span>
52
54
  <span fg={theme.textMuted}> attach</span>
53
55
  <span fg={theme.textDim}> {"\u00B7"} </span>
56
+ <span fg={theme.text}>/</span>
57
+ <span fg={theme.textMuted}> agents</span>
58
+ <span fg={theme.textDim}> {"\u00B7"} </span>
54
59
  <span fg={theme.text}>q</span>
55
60
  <span fg={theme.textMuted}> quit</span>
56
61
  </text>
@@ -39,7 +39,7 @@ import type { SessionEvent } from "@github/copilot-sdk";
39
39
  import type { SessionPromptResponse } from "@opencode-ai/sdk/v2";
40
40
  import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
41
41
  import * as tmux from "./tmux.ts";
42
- import { spawnMuxAttach, SOCKET_NAME } from "./tmux.ts";
42
+ import { spawnMuxAttach } from "./tmux.ts";
43
43
  import { WorkflowLoader } from "./loader.ts";
44
44
  import {
45
45
  clearClaudeSession,
@@ -303,8 +303,9 @@ export function parseInputsEnv(raw: string | undefined): Record<string, string>
303
303
  /**
304
304
  * Called by `atomic workflow -n <name> -a <agent> <prompt>`.
305
305
  *
306
- * Creates a tmux session with the orchestrator as the initial pane,
307
- * then attaches so the user sees everything live.
306
+ * Always creates a tmux session in the atomic socket with the
307
+ * orchestrator as the initial pane, then attaches so the user sees
308
+ * everything live — even when invoked from inside another tmux session.
308
309
  */
309
310
  export async function executeWorkflow(
310
311
  options: WorkflowRunOptions,
@@ -318,7 +319,7 @@ export async function executeWorkflow(
318
319
  } = options;
319
320
 
320
321
  const workflowRunId = generateId();
321
- const tmuxSessionName = `atomic-wf-${definition.name}-${workflowRunId}`;
322
+ const tmuxSessionName = `atomic-wf-${agent}-${definition.name}-${workflowRunId}`;
322
323
  const sessionsBaseDir = join(getSessionsBaseDir(), workflowRunId);
323
324
  await ensureDir(sessionsBaseDir);
324
325
 
@@ -364,30 +365,20 @@ export async function executeWorkflow(
364
365
 
365
366
  await writeFile(launcherPath, launcherScript, { mode: 0o755 });
366
367
 
367
- console.log(`[atomic] Session: ${tmuxSessionName} (FYI all atomic sessions run on tmux -L ${SOCKET_NAME})`);
368
-
369
- // Attach or spawn depending on whether we're already inside tmux
370
- if (tmux.isInsideTmux()) {
371
- // Inside tmux: create the session with just a shell (agent windows live here),
372
- // then run the orchestrator directly in the user's current pane.
373
- const defaultShell = process.env.SHELL || (isWin ? "pwsh" : "sh");
374
- tmux.createSession(tmuxSessionName, defaultShell, "orchestrator");
375
-
376
- const launcherCmd = isWin
377
- ? ["pwsh", "-NoProfile", "-File", launcherPath]
378
- : ["bash", launcherPath];
379
- const proc = Bun.spawn(launcherCmd, {
380
- stdio: ["inherit", "inherit", "inherit"],
381
- cwd: projectRoot,
382
- });
383
- await proc.exited;
368
+ const shellCmd = isWin
369
+ ? `pwsh -NoProfile -File "${escPwsh(launcherPath)}"`
370
+ : `bash "${escBash(launcherPath)}"`;
371
+ tmux.createSession(tmuxSessionName, shellCmd, "orchestrator");
372
+ tmux.setSessionEnv(tmuxSessionName, "ATOMIC_AGENT", agent);
373
+
374
+ if (tmux.isInsideAtomicSocket()) {
375
+ // Already on the atomic server — just switch to the new session.
376
+ tmux.switchClient(tmuxSessionName);
377
+ } else if (tmux.isInsideTmux()) {
378
+ // Inside a different tmux server — detach and replace the client
379
+ // with an attach to the atomic socket (no nesting).
380
+ tmux.detachAndAttachAtomic(tmuxSessionName);
384
381
  } else {
385
- // Outside tmux: create session with the orchestrator and attach to it
386
- const shellCmd = isWin
387
- ? `pwsh -NoProfile -File "${escPwsh(launcherPath)}"`
388
- : `bash "${escBash(launcherPath)}"`;
389
- tmux.createSession(tmuxSessionName, shellCmd, "orchestrator");
390
-
391
382
  const attachProc = spawnMuxAttach(tmuxSessionName);
392
383
  await attachProc.exited;
393
384
  }
@@ -21,6 +21,8 @@ set -g focus-events on
21
21
  setw -g aggressive-resize on
22
22
 
23
23
  # Status bar — minimal
24
+ # These defaults are mirrored by TMUX_DEFAULT_STATUS_* constants in tmux.ts.
25
+ # Keep both in sync when changing.
24
26
  set -g status-left " "
25
27
  set -g status-right " #{session_name} | %H:%M "
26
28
  set -g status-right-length 60
@@ -43,6 +45,22 @@ bind-key -T copy-mode-vi v send-keys -X begin-selection
43
45
  bind-key -T copy-mode-vi C-v send-keys -X rectangle-toggle
44
46
  bind-key -T copy-mode-vi y send-keys -X copy-selection-and-cancel
45
47
 
48
+ # ── Atomic workflow navigation (prefix-free) ──────────────
49
+ # These bindings use `bind -n` (root table) so they work without a prefix key.
50
+ # They only apply inside Atomic's isolated tmux server (-L atomic) and do NOT
51
+ # affect the user's regular tmux or shell sessions.
52
+ #
53
+ # NOTE: Ctrl+\ overrides the default SIGQUIT signal. Agent CLIs running in
54
+ # these panes will not receive SIGQUIT via Ctrl+\. Use `kill -QUIT <pid>` if
55
+ # needed. The integrated agents (Claude Code, OpenCode, Copilot CLI) use
56
+ # Ctrl+C for interrupts and are not affected.
57
+
58
+ # Ctrl+G: jump straight back to the graph (window 0) from any agent window
59
+ bind -n C-g select-window -t :0
60
+
61
+ # Ctrl+\: cycle to next agent window from anywhere
62
+ bind -n C-\\ next-window
63
+
46
64
  # Escape exits copy-mode (clear selection first if one exists, otherwise cancel)
47
65
  bind-key -T copy-mode-vi Escape if-shell -F "#{selection_present}" "send-keys -X clear-selection" "send-keys -X cancel"
48
66
 
@@ -30,6 +30,29 @@ export type TmuxResult =
30
30
  // Core tmux primitives
31
31
  // ---------------------------------------------------------------------------
32
32
 
33
+ // ---------------------------------------------------------------------------
34
+ // Default status-bar values — must match tmux.conf.
35
+ // Centralised here so restore logic in session-graph-panel stays in sync.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export const TMUX_DEFAULT_STATUS_LEFT = " ";
39
+ export const TMUX_DEFAULT_STATUS_LEFT_LENGTH = "10";
40
+ export const TMUX_DEFAULT_STATUS_RIGHT = " #{session_name} | %H:%M ";
41
+ export const TMUX_DEFAULT_STATUS_RIGHT_LENGTH = "60";
42
+
43
+ /**
44
+ * Escape a string for safe interpolation into tmux format strings.
45
+ * Replaces `#` with `##` to prevent tmux from interpreting `#[...]`
46
+ * as style directives or `#(...)` as shell command expansions.
47
+ */
48
+ export function escapeTmuxFormat(value: string): string {
49
+ return value.replace(/#/g, "##");
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Core tmux primitives
54
+ // ---------------------------------------------------------------------------
55
+
33
56
  /** Cached resolved multiplexer binary path. Resolved once on first use. */
34
57
  let resolvedMuxBinary: string | null | undefined; // undefined = not yet resolved
35
58
 
@@ -45,9 +68,14 @@ let resolvedMuxBinary: string | null | undefined; // undefined = not yet resolve
45
68
  export function getMuxBinary(): string | null {
46
69
  if (resolvedMuxBinary !== undefined) return resolvedMuxBinary;
47
70
 
71
+ // Bun.which() reads PATH from the original process environment at startup
72
+ // and ignores runtime mutations to process.env.PATH. Pass PATH explicitly
73
+ // so that callers who modify PATH (e.g. tests) get correct results.
74
+ const pathOpt = { PATH: process.env.PATH ?? "" };
75
+
48
76
  if (process.platform === "win32") {
49
77
  for (const candidate of ["psmux", "pmux", "tmux"]) {
50
- if (Bun.which(candidate)) {
78
+ if (Bun.which(candidate, pathOpt)) {
51
79
  resolvedMuxBinary = candidate;
52
80
  return resolvedMuxBinary;
53
81
  }
@@ -57,7 +85,7 @@ export function getMuxBinary(): string | null {
57
85
  }
58
86
 
59
87
  // Unix / macOS
60
- resolvedMuxBinary = Bun.which("tmux") ? "tmux" : null;
88
+ resolvedMuxBinary = Bun.which("tmux", pathOpt) ? "tmux" : null;
61
89
  return resolvedMuxBinary;
62
90
  }
63
91
 
@@ -83,6 +111,22 @@ export function isInsideTmux(): boolean {
83
111
  return process.env.TMUX !== undefined || process.env.PSMUX !== undefined;
84
112
  }
85
113
 
114
+ /**
115
+ * Check if we're inside the atomic tmux socket specifically.
116
+ *
117
+ * The `TMUX` env var has the format `<socket_path>,<pid>,<index>`.
118
+ * On Unix this looks like `/tmp/tmux-1000/atomic,12345,0` when the
119
+ * socket name is "atomic".
120
+ */
121
+ export function isInsideAtomicSocket(): boolean {
122
+ const tmuxEnv = process.env.TMUX ?? process.env.PSMUX ?? "";
123
+ // Socket path is everything before the first comma.
124
+ const socketPath = tmuxEnv.split(",")[0] ?? "";
125
+ // The socket name is the last path segment.
126
+ const socketName = socketPath.split("/").pop() ?? "";
127
+ return socketName === SOCKET_NAME;
128
+ }
129
+
86
130
  /**
87
131
  * Run a tmux command and return a result object.
88
132
  * Prefers this over the throwing `tmux()` for cases where callers
@@ -176,6 +220,9 @@ export function createSession(
176
220
  }
177
221
  args.push(initialCommand);
178
222
  const paneId = tmux(args);
223
+ // Reload config into the running server so keybindings are always current
224
+ // (tmux only loads -f on first server start; source-file updates a running server).
225
+ tmuxRun(["source-file", CONFIG_PATH]);
179
226
  return paneId || tmux(["list-panes", "-t", sessionName, "-F", "#{pane_id}"]).split("\n")[0]!;
180
227
  }
181
228
 
@@ -229,36 +276,17 @@ export function createPane(sessionName: string, command: string): string {
229
276
  // Keystroke sending
230
277
  // ---------------------------------------------------------------------------
231
278
 
232
- /**
233
- * Maximum bytes per `send-keys -l` invocation.
234
- *
235
- * tmux passes the text as a single command-line argument to the child
236
- * process. On Linux the per-argument limit (`MAX_ARG_STRLEN`) is 128 KB;
237
- * on macOS the total `ARG_MAX` is ~1 MB but shared across all args.
238
- * We stay well under both limits with 50 KB chunks.
239
- */
240
- const SEND_KEYS_CHUNK_SIZE = 50_000;
241
-
242
279
  /**
243
280
  * Send literal text to a tmux pane using `-l` flag (no special key interpretation).
244
281
  * Uses `--` to prevent text starting with `-` from being parsed as flags.
245
282
  *
246
- * Long texts are chunked to avoid OS `ARG_MAX` / `MAX_ARG_STRLEN` limits
247
- * that cause `tmux send-keys` to fail with "command too long".
283
+ * For large text payloads, prefer {@link sendViaPasteBuffer} which bypasses
284
+ * tmux's ~16 KB internal message buffer limit.
248
285
  */
249
286
  export function sendLiteralText(paneId: string, text: string): void {
250
287
  // Replace newlines with spaces to avoid premature submission
251
288
  const normalized = text.replace(/[\r\n]+/g, " ");
252
-
253
- if (normalized.length <= SEND_KEYS_CHUNK_SIZE) {
254
- tmuxExec(["send-keys", "-t", paneId, "-l", "--", normalized]);
255
- return;
256
- }
257
-
258
- for (let offset = 0; offset < normalized.length; offset += SEND_KEYS_CHUNK_SIZE) {
259
- const chunk = normalized.slice(offset, offset + SEND_KEYS_CHUNK_SIZE);
260
- tmuxExec(["send-keys", "-t", paneId, "-l", "--", chunk]);
261
- }
289
+ tmuxExec(["send-keys", "-t", paneId, "-l", "--", normalized]);
262
290
  }
263
291
 
264
292
  /**
@@ -398,6 +426,118 @@ export function sessionExists(sessionName: string): boolean {
398
426
  return result.ok;
399
427
  }
400
428
 
429
+ /**
430
+ * Set a session-level environment variable.
431
+ * Uses `tmux set-environment -t <session>` so the value is scoped to
432
+ * the individual session, not the global server environment.
433
+ */
434
+ export function setSessionEnv(sessionName: string, key: string, value: string): void {
435
+ tmuxRun(["set-environment", "-t", sessionName, key, value]);
436
+ }
437
+
438
+ /**
439
+ * Read a session-level environment variable.
440
+ * Returns `null` when the session doesn't exist or the variable isn't set.
441
+ */
442
+ export function getSessionEnv(sessionName: string, key: string): string | null {
443
+ const result = tmuxRun(["show-environment", "-t", sessionName, key]);
444
+ if (!result.ok) return null;
445
+ // Output format: "KEY=VALUE"
446
+ const eq = result.stdout.indexOf("=");
447
+ return eq >= 0 ? result.stdout.slice(eq + 1) : null;
448
+ }
449
+
450
+ /** Session type derived from the session name prefix. */
451
+ export type SessionType = "chat" | "workflow";
452
+
453
+ /**
454
+ * Parse a session name into its type and agent.
455
+ *
456
+ * Naming conventions:
457
+ * Chat: atomic-chat-<agent>-<id>
458
+ * Workflow: atomic-wf-<agent>-<name>-<id>
459
+ *
460
+ * Agent names are a known, hyphen-free set (claude, copilot, opencode)
461
+ * so parsing is unambiguous even when the workflow name contains hyphens.
462
+ */
463
+ export function parseSessionName(name: string): { type?: SessionType; agent?: string } {
464
+ const KNOWN_AGENTS = new Set(["claude", "copilot", "opencode"]);
465
+
466
+ if (name.startsWith("atomic-chat-")) {
467
+ // atomic-chat-<agent>-<id>
468
+ const rest = name.slice("atomic-chat-".length);
469
+ const dash = rest.indexOf("-");
470
+ const candidate = dash >= 0 ? rest.slice(0, dash) : rest;
471
+ if (KNOWN_AGENTS.has(candidate)) {
472
+ return { type: "chat", agent: candidate };
473
+ }
474
+ return { type: "chat" };
475
+ }
476
+
477
+ if (name.startsWith("atomic-wf-")) {
478
+ // atomic-wf-<agent>-<name>-<id>
479
+ const rest = name.slice("atomic-wf-".length);
480
+ const dash = rest.indexOf("-");
481
+ const candidate = dash >= 0 ? rest.slice(0, dash) : rest;
482
+ if (KNOWN_AGENTS.has(candidate)) {
483
+ return { type: "workflow", agent: candidate };
484
+ }
485
+ return { type: "workflow" };
486
+ }
487
+
488
+ return {};
489
+ }
490
+
491
+ /** A single tmux session on the atomic socket. */
492
+ export interface TmuxSession {
493
+ /** Session name (e.g. "atomic-chat-claude-a1b2c3d4") */
494
+ name: string;
495
+ /** Number of windows in the session */
496
+ windows: number;
497
+ /** ISO 8601 creation timestamp */
498
+ created: string;
499
+ /** Whether a client is currently attached */
500
+ attached: boolean;
501
+ /** Session type derived from the name prefix */
502
+ type?: SessionType;
503
+ /** Agent backend that owns this session (e.g. "claude", "copilot", "opencode") */
504
+ agent?: string;
505
+ }
506
+
507
+ /**
508
+ * List all sessions on the atomic tmux socket.
509
+ *
510
+ * Uses a custom format string so output is machine-parseable regardless of
511
+ * locale. Returns an empty array when the server isn't running or has no
512
+ * sessions (tmux exits non-zero in both cases).
513
+ */
514
+ export function listSessions(): TmuxSession[] {
515
+ const fmt = "#{session_name}\t#{session_windows}\t#{session_created}\t#{session_attached}";
516
+ const result = tmuxRun(["list-sessions", "-F", fmt]);
517
+ if (!result.ok) return [];
518
+
519
+ const sessions = result.stdout
520
+ .split("\n")
521
+ .filter((line) => line.trim() !== "")
522
+ .map((line) => {
523
+ const [name, windowsStr, createdStr, attachedStr] = line.split("\t");
524
+ const epochSec = Number(createdStr);
525
+ const parsed = parseSessionName(name!);
526
+ return {
527
+ name: name!,
528
+ windows: Number(windowsStr) || 1,
529
+ created: Number.isFinite(epochSec) && epochSec > 0
530
+ ? new Date(epochSec * 1000).toISOString()
531
+ : createdStr!,
532
+ attached: attachedStr === "1",
533
+ type: parsed.type,
534
+ agent: parsed.agent ?? getSessionEnv(name!, "ATOMIC_AGENT") ?? undefined,
535
+ };
536
+ });
537
+
538
+ return sessions;
539
+ }
540
+
401
541
  /** Build the full argument list for an attach-session command. */
402
542
  function buildAttachArgs(sessionName: string): string[] {
403
543
  const binary = getMuxBinary();
@@ -450,6 +590,10 @@ export function switchClient(sessionName: string): void {
450
590
  */
451
591
  export function getCurrentSession(): string | null {
452
592
  if (!isInsideTmux()) return null;
593
+ // Only query the atomic server if we're actually inside the atomic socket.
594
+ // Otherwise, display-message picks an arbitrary session on the atomic
595
+ // server that has nothing to do with our terminal.
596
+ if (!isInsideAtomicSocket()) return null;
453
597
  const result = tmuxRun(["display-message", "-p", "#{session_name}"]);
454
598
  if (!result.ok) return null;
455
599
  return result.stdout || null;
@@ -470,6 +614,36 @@ export function attachOrSwitch(sessionName: string): void {
470
614
  }
471
615
  }
472
616
 
617
+ /**
618
+ * Detach from the user's current tmux session and replace the client
619
+ * with an attach to a session on the atomic socket.
620
+ *
621
+ * Uses `detach-client -E` so the user's terminal seamlessly transitions
622
+ * from their tmux session to the atomic session — no nesting.
623
+ * Their original tmux session stays alive; they can re-attach with
624
+ * `tmux attach` after leaving the atomic session.
625
+ *
626
+ * Only call when {@link isInsideTmux} returns `true`.
627
+ */
628
+ export function detachAndAttachAtomic(sessionName: string): void {
629
+ const binary = getMuxBinary();
630
+ if (!binary) {
631
+ throw new Error("No terminal multiplexer (tmux/psmux) found on PATH");
632
+ }
633
+ // Build the shell command that will run on the freed terminal.
634
+ const attachArgs = buildAttachArgs(sessionName);
635
+ const attachCmd = attachArgs
636
+ .map((a) => `"${a.replace(/[\\"$`!]/g, "\\$&")}"`)
637
+ .join(" ");
638
+
639
+ // Target the user's current tmux server (no -L flag) and replace
640
+ // the client process with an attach to the atomic socket.
641
+ Bun.spawnSync({
642
+ cmd: [binary, "detach-client", "-E", attachCmd],
643
+ stdio: ["inherit", "inherit", "inherit"],
644
+ });
645
+ }
646
+
473
647
  /**
474
648
  * Select (switch to) a window within the current tmux session.
475
649
  */