@bastani/atomic 0.6.0-0 → 0.6.1-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.
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { join } from "node:path";
10
+ import { requiredMuxBinaryCandidatesForPlatform } from "../../lib/spawn.ts";
10
11
  import { writeFileSync, unlinkSync } from "node:fs";
11
12
  import { tmpdir } from "node:os";
12
13
  import type { Subprocess } from "bun";
@@ -45,7 +46,9 @@ let resolvedMuxBinary: string | null | undefined; // undefined = not yet resolve
45
46
  /**
46
47
  * Resolve the terminal multiplexer binary for the current platform.
47
48
  *
48
- * On Windows, tries psmux → pmux tmux (psmux ships all three as aliases).
49
+ * On Windows, tries psmux → pmux. Do not accept arbitrary `tmux.exe` because
50
+ * that can be a non-native shim and would prevent the psmux installer from
51
+ * running.
49
52
  * On Unix/macOS, uses tmux directly.
50
53
  *
51
54
  * Returns the binary name (not the full path) or null if none is found.
@@ -59,19 +62,14 @@ export function getMuxBinary(): string | null {
59
62
  // so that callers who modify PATH (e.g. tests) get correct results.
60
63
  const pathOpt = { PATH: process.env.PATH ?? "" };
61
64
 
62
- if (process.platform === "win32") {
63
- for (const candidate of ["psmux", "pmux", "tmux"]) {
64
- if (Bun.which(candidate, pathOpt)) {
65
- resolvedMuxBinary = candidate;
66
- return resolvedMuxBinary;
67
- }
65
+ for (const candidate of requiredMuxBinaryCandidatesForPlatform()) {
66
+ if (Bun.which(candidate, pathOpt)) {
67
+ resolvedMuxBinary = candidate;
68
+ return resolvedMuxBinary;
68
69
  }
69
- resolvedMuxBinary = null;
70
- return null;
71
70
  }
72
71
 
73
- // Unix / macOS
74
- resolvedMuxBinary = Bun.which("tmux", pathOpt) ? "tmux" : null;
72
+ resolvedMuxBinary = null;
75
73
  return resolvedMuxBinary;
76
74
  }
77
75
 
@@ -220,26 +218,50 @@ export function createSession(
220
218
  return paneId || tmux(["list-panes", "-t", sessionName, "-F", "#{pane_id}"]).split("\n")[0]!;
221
219
  }
222
220
 
221
+ export function buildKillSessionOnPaneExitHooks(
222
+ sessionName: string,
223
+ paneId: string,
224
+ options: { guardPaneExited?: boolean } = {},
225
+ ): Array<{ event: string; command: string }> {
226
+ const killCommand = `kill-session -t ${sessionName}`;
227
+ const paneExitedCommand = options.guardPaneExited === false
228
+ ? killCommand
229
+ : `if -F '#{==:#{hook_pane},${paneId}}' '${killCommand}'`;
230
+ return [
231
+ { event: "pane-exited", command: paneExitedCommand },
232
+ { event: "after-kill-pane", command: killCommand },
233
+ ];
234
+ }
235
+
236
+ function supportsHookPaneFormat(binary = getMuxBinary()): boolean {
237
+ return binary !== "psmux" && binary !== "pmux";
238
+ }
239
+
223
240
  /**
224
- * Install a hook that kills the entire session when the given pane's
225
- * process exits. Used by chat sessions so the session is torn down
226
- * when the agent CLI exits — whether via `/exit`, a deliberate double
227
- * Ctrl+C, or a crash — without leaving the footer pane keeping the
228
- * session alive.
241
+ * Install hooks that kill the entire session when the agent pane goes away.
242
+ * Used by chat sessions so the session is torn down when the agent CLI exits
243
+ * — whether via `/exit`, a deliberate double Ctrl+C, a crash, or a direct
244
+ * pane close — without leaving the footer pane keeping the session alive.
229
245
  *
230
- * The hook is session-scoped (pane-scoped hooks don't fire because the
231
- * pane is already gone when `pane-exited` would run) and guarded with
232
- * `#{hook_pane}` so the footer pane's eventual exit cascade doesn't
233
- * re-trigger it. `kill-session` is idempotent in any case.
246
+ * tmux fires `pane-exited` when a pane process exits; psmux also supports
247
+ * that event, but does not currently populate tmux's `#{hook_pane}` format,
248
+ * so the psmux hook is session-scoped. A direct pane close/kill fires
249
+ * `after-kill-pane` instead. These session-scoped hooks are safe for chat
250
+ * sessions: they only have the agent pane plus its footer, and closing either
251
+ * should close the entire chat window.
234
252
  */
235
253
  export function killSessionOnPaneExit(sessionName: string, paneId: string): void {
236
- const guard = `if -F '#{==:#{hook_pane},${paneId}}' 'kill-session -t ${sessionName}'`;
237
- tmuxRun([
238
- "set-hook",
239
- "-t", sessionName,
240
- "pane-exited",
241
- guard,
242
- ]);
254
+ const hooks = buildKillSessionOnPaneExitHooks(sessionName, paneId, {
255
+ guardPaneExited: supportsHookPaneFormat(),
256
+ });
257
+ for (const hook of hooks) {
258
+ tmuxRun([
259
+ "set-hook",
260
+ "-t", sessionName,
261
+ hook.event,
262
+ hook.command,
263
+ ]);
264
+ }
243
265
  }
244
266
 
245
267
  /**
@@ -441,6 +463,14 @@ export function setSessionEnv(sessionName: string, key: string, value: string):
441
463
  tmuxRun(["set-environment", "-t", sessionName, key, value]);
442
464
  }
443
465
 
466
+ export function parseSessionEnvValue(stdout: string, key: string): string | null {
467
+ const prefix = `${key}=`;
468
+ const line = stdout
469
+ .split(/\r?\n/)
470
+ .find((entry) => entry.startsWith(prefix));
471
+ return line ? line.slice(prefix.length) : null;
472
+ }
473
+
444
474
  /**
445
475
  * Read a session-level environment variable.
446
476
  * Returns `null` when the session doesn't exist or the variable isn't set.
@@ -448,9 +478,10 @@ export function setSessionEnv(sessionName: string, key: string, value: string):
448
478
  export function getSessionEnv(sessionName: string, key: string): string | null {
449
479
  const result = tmuxRun(["show-environment", "-t", sessionName, key]);
450
480
  if (!result.ok) return null;
451
- // Output format: "KEY=VALUE"
452
- const eq = result.stdout.indexOf("=");
453
- return eq >= 0 ? result.stdout.slice(eq + 1) : null;
481
+ // tmux returns "KEY=VALUE" for a requested key. psmux can append its own
482
+ // PSMUX_* metadata or return all environment lines, so only accept an exact
483
+ // key match and ignore every unrelated line.
484
+ return parseSessionEnvValue(result.stdout, key);
454
485
  }
455
486
 
456
487
  /** Session type derived from the session name prefix. */
@@ -510,38 +541,58 @@ export interface TmuxSession {
510
541
  agent?: string;
511
542
  }
512
543
 
513
- /**
514
- * List all sessions on the atomic tmux socket.
515
- *
516
- * Uses a custom format string so output is machine-parseable regardless of
517
- * locale. Returns an empty array when the server isn't running or has no
518
- * sessions (tmux exits non-zero in both cases).
519
- */
520
- export function listSessions(): TmuxSession[] {
521
- const fmt = "#{session_name}\t#{session_windows}\t#{session_created}\t#{session_attached}";
522
- const result = tmuxRun(["list-sessions", "-F", fmt]);
523
- if (!result.ok) return [];
544
+ const SESSION_LIST_DELIMITER = "__ATOMIC_SESSION_FIELD__";
545
+
546
+ function isAtomicManagedSessionName(name: string): boolean {
547
+ return name.startsWith("atomic-");
548
+ }
524
549
 
525
- const sessions = result.stdout
550
+ export function parseListSessionsOutput(
551
+ stdout: string,
552
+ getEnv: (sessionName: string, key: string) => string | null = getSessionEnv,
553
+ ): TmuxSession[] {
554
+ return stdout
526
555
  .split("\n")
527
556
  .filter((line) => line.trim() !== "")
528
- .map((line) => {
529
- const [name, windowsStr, createdStr, attachedStr] = line.split("\t");
557
+ .filter((line) => line.includes(SESSION_LIST_DELIMITER))
558
+ .flatMap((line) => {
559
+ const [name, windowsStr, createdStr, attachedStr] = line.split(SESSION_LIST_DELIMITER);
560
+ if (!name || !windowsStr || !createdStr || attachedStr === undefined) return [];
561
+ if (!isAtomicManagedSessionName(name)) return [];
562
+
530
563
  const epochSec = Number(createdStr);
531
- const parsed = parseSessionName(name!);
532
- return {
533
- name: name!,
564
+ const parsed = parseSessionName(name);
565
+ return [{
566
+ name,
534
567
  windows: Number(windowsStr) || 1,
535
568
  created: Number.isFinite(epochSec) && epochSec > 0
536
569
  ? new Date(epochSec * 1000).toISOString()
537
- : createdStr!,
570
+ : createdStr,
538
571
  attached: attachedStr === "1",
539
572
  type: parsed.type,
540
- agent: parsed.agent ?? getSessionEnv(name!, "ATOMIC_AGENT") ?? undefined,
541
- };
573
+ agent: parsed.agent ?? getEnv(name, "ATOMIC_AGENT") ?? undefined,
574
+ }];
542
575
  });
576
+ }
543
577
 
544
- return sessions;
578
+ /**
579
+ * List all sessions on the atomic tmux socket.
580
+ *
581
+ * Uses a custom format string so output is machine-parseable regardless of
582
+ * locale. Returns an empty array when the server isn't running or has no
583
+ * sessions (tmux exits non-zero in both cases).
584
+ */
585
+ export function listSessions(): TmuxSession[] {
586
+ const fmt = [
587
+ "#{session_name}",
588
+ "#{session_windows}",
589
+ "#{session_created}",
590
+ "#{session_attached}",
591
+ ].join(SESSION_LIST_DELIMITER);
592
+ const result = tmuxRun(["list-sessions", "-F", fmt]);
593
+ if (!result.ok) return [];
594
+
595
+ return parseListSessionsOutput(result.stdout);
545
596
  }
546
597
 
547
598
  /** Build the full argument list for an attach-session command. */
@@ -679,4 +730,3 @@ export function normalizeTmuxLines(text: string): string {
679
730
  .join("\n")
680
731
  .trim();
681
732
  }
682
-
@@ -29,6 +29,7 @@ import { join } from "node:path";
29
29
  import { homedir } from "node:os";
30
30
  import { VERSION } from "../../version.ts";
31
31
  import {
32
+ hasRequiredMuxBinary,
32
33
  ensureTmuxInstalled,
33
34
  upgradeGlobalToolPackages,
34
35
  } from "../../lib/spawn.ts";
@@ -77,7 +78,8 @@ async function silentStep(fn: () => Promise<unknown>): Promise<boolean> {
77
78
  /**
78
79
  * Sync tooling deps, bundled agents, and global skills if the marker
79
80
  * doesn't match the bundled VERSION. No-op in dev checkouts and when the
80
- * marker already matches the current version.
81
+ * marker already matches the current version and the platform-native
82
+ * multiplexer is present.
81
83
  *
82
84
  * Runs entirely silently — no spinner, no progress bar, no banner. The
83
85
  * only loading UI lives in the bootstrap installers (install.sh / install.ps1).
@@ -91,17 +93,21 @@ export async function autoSyncIfStale(): Promise<void> {
91
93
  stored = (await marker.text()).trim();
92
94
  }
93
95
 
94
- if (stored === VERSION) return;
96
+ if (stored === VERSION && hasRequiredMuxBinary()) return;
97
+
98
+ const steps = stored === VERSION
99
+ ? [silentStep(() => ensureTmuxInstalled({ quiet: true }))]
100
+ : [
101
+ silentStep(() => ensureTmuxInstalled({ quiet: true })),
102
+ silentStep(installGlobalAgents),
103
+ silentStep(upgradeGlobalToolPackages),
104
+ silentStep(installGlobalSkills),
105
+ ];
95
106
 
96
107
  // All steps run in parallel and silently. Failures are swallowed so the
97
108
  // CLI can proceed. The marker is only written when every step succeeds;
98
109
  // on partial failure the next launch retries (all steps are idempotent).
99
- const results = await Promise.all([
100
- silentStep(() => ensureTmuxInstalled({ quiet: true })),
101
- silentStep(installGlobalAgents),
102
- silentStep(upgradeGlobalToolPackages),
103
- silentStep(installGlobalSkills),
104
- ]);
110
+ const results = await Promise.all(steps);
105
111
 
106
112
  const allOk = results.every(Boolean);
107
113