@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.
- package/README.md +1 -0
- package/dist/lib/spawn.d.ts +102 -0
- package/dist/lib/spawn.d.ts.map +1 -0
- package/dist/sdk/providers/claude.d.ts.map +1 -1
- package/dist/sdk/runtime/attached-footer.d.ts +14 -0
- package/dist/sdk/runtime/attached-footer.d.ts.map +1 -1
- package/dist/sdk/runtime/tmux.d.ts +22 -11
- package/dist/sdk/runtime/tmux.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/commands/cli/chat/index.test.ts +60 -0
- package/src/commands/cli/chat/index.ts +11 -33
- package/src/commands/cli/footer.tsx +170 -54
- package/src/lib/spawn.test.ts +109 -0
- package/src/lib/spawn.ts +371 -33
- package/src/sdk/providers/claude.ts +17 -0
- package/src/sdk/runtime/attached-footer.ts +96 -7
- package/src/sdk/runtime/tmux.ts +102 -52
- package/src/services/system/auto-sync.ts +14 -8
package/src/sdk/runtime/tmux.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
529
|
-
|
|
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
|
|
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 ??
|
|
541
|
-
};
|
|
573
|
+
agent: parsed.agent ?? getEnv(name, "ATOMIC_AGENT") ?? undefined,
|
|
574
|
+
}];
|
|
542
575
|
});
|
|
576
|
+
}
|
|
543
577
|
|
|
544
|
-
|
|
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
|
|