@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
|
@@ -4,25 +4,43 @@
|
|
|
4
4
|
* tmux window. The executor splits each agent window after creation and
|
|
5
5
|
* runs `atomic _footer --name <window-name>` in the bottom pane.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* where no signal arrives (process gets reparented to init/unknown).
|
|
7
|
+
* The footer is rendered through OpenTUI's headless renderer and repainted
|
|
8
|
+
* into the footer pane. A normal CLI renderer must not be used here:
|
|
9
|
+
* terminal capability probes from a footer pane can be answered by the
|
|
10
|
+
* attached client and routed by tmux into the active agent pane as input.
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
import {
|
|
14
|
+
getBaseAttributes,
|
|
15
|
+
TextAttributes,
|
|
16
|
+
type CapturedFrame,
|
|
17
|
+
type CapturedSpan,
|
|
18
|
+
} from "@opentui/core";
|
|
19
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
20
|
+
import { act } from "react";
|
|
17
21
|
import { resolveTheme } from "../../sdk/runtime/theme.ts";
|
|
18
22
|
import {
|
|
19
23
|
deriveGraphTheme,
|
|
20
|
-
type GraphTheme,
|
|
21
24
|
} from "../../sdk/components/graph-theme.ts";
|
|
22
25
|
import { AttachedStatusline } from "../../sdk/components/attached-statusline.tsx";
|
|
23
26
|
import type { AgentType } from "../../sdk/types.ts";
|
|
24
27
|
|
|
25
28
|
const PARENT_WATCHDOG_MS = 2000;
|
|
29
|
+
const FOOTER_RENDER_INTERVAL_MS = 250;
|
|
30
|
+
const FOOTER_RENDER_ROWS = 1;
|
|
31
|
+
const CLEAR_LINE = "\r\x1b[2K";
|
|
32
|
+
|
|
33
|
+
type FooterOutput = {
|
|
34
|
+
columns?: number;
|
|
35
|
+
write(chunk: string): unknown;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type FooterRendererOptions = {
|
|
39
|
+
name: string;
|
|
40
|
+
agentType?: AgentType;
|
|
41
|
+
stdout?: FooterOutput;
|
|
42
|
+
onReady?: () => void;
|
|
43
|
+
};
|
|
26
44
|
|
|
27
45
|
/**
|
|
28
46
|
* Snapshot the parent PID at module load. `process.ppid` is cached in both
|
|
@@ -59,74 +77,172 @@ const EXIT_SIGNALS: NodeJS.Signals[] =
|
|
|
59
77
|
? ["SIGTERM", "SIGINT", "SIGBREAK", "SIGHUP"]
|
|
60
78
|
: ["SIGHUP", "SIGTERM", "SIGINT", "SIGPIPE"];
|
|
61
79
|
|
|
62
|
-
|
|
80
|
+
const ANSI_RESET = "\x1b[0m";
|
|
81
|
+
|
|
82
|
+
function ansiColor(kind: 38 | 48, spanColor: CapturedSpan["fg"]): string {
|
|
83
|
+
const [r, g, b] = spanColor.toInts();
|
|
84
|
+
return `${kind};2;${r};${g};${b}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sanitizeText(text: string): string {
|
|
88
|
+
return text.replace(/[\x00-\x1f\x7f]/g, " ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function spanToAnsi(span: CapturedSpan): string {
|
|
92
|
+
const attrs = getBaseAttributes(span.attributes);
|
|
93
|
+
const codes = [
|
|
94
|
+
ansiColor(38, span.fg),
|
|
95
|
+
ansiColor(48, span.bg),
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
if ((attrs & TextAttributes.BOLD) !== 0) codes.unshift("1");
|
|
99
|
+
if ((attrs & TextAttributes.DIM) !== 0) codes.unshift("2");
|
|
100
|
+
if ((attrs & TextAttributes.ITALIC) !== 0) codes.unshift("3");
|
|
101
|
+
if ((attrs & TextAttributes.UNDERLINE) !== 0) codes.unshift("4");
|
|
102
|
+
if ((attrs & TextAttributes.INVERSE) !== 0) codes.unshift("7");
|
|
103
|
+
|
|
104
|
+
return `\x1b[${codes.join(";")}m${sanitizeText(span.text)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function frameToAnsi(frame: CapturedFrame): string {
|
|
108
|
+
const lines = frame.lines.map((line) =>
|
|
109
|
+
line.spans.map(spanToAnsi).join("")
|
|
110
|
+
);
|
|
111
|
+
return `${lines.join("\n")}${ANSI_RESET}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function createFooterTestRenderer({
|
|
63
115
|
name,
|
|
64
|
-
theme,
|
|
65
116
|
agentType,
|
|
117
|
+
width = process.stdout.columns ?? 80,
|
|
66
118
|
}: {
|
|
67
119
|
name: string;
|
|
68
|
-
theme: GraphTheme;
|
|
69
120
|
agentType?: AgentType;
|
|
121
|
+
width?: number;
|
|
70
122
|
}) {
|
|
71
|
-
const
|
|
123
|
+
const theme = deriveGraphTheme(resolveTheme(null));
|
|
124
|
+
return await testRender(
|
|
125
|
+
<AttachedStatusline name={name} theme={theme} agentType={agentType} />,
|
|
126
|
+
{
|
|
127
|
+
width: Math.max(width, 1),
|
|
128
|
+
height: FOOTER_RENDER_ROWS,
|
|
129
|
+
exitOnCtrlC: false,
|
|
130
|
+
exitSignals: [],
|
|
131
|
+
clearOnShutdown: false,
|
|
132
|
+
useMouse: false,
|
|
133
|
+
useKittyKeyboard: null,
|
|
134
|
+
openConsoleOnError: false,
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function renderFooterSetupFrame(
|
|
140
|
+
testSetup: Awaited<ReturnType<typeof createFooterTestRenderer>>,
|
|
141
|
+
): Promise<string> {
|
|
142
|
+
await act(async () => {
|
|
143
|
+
await testSetup.renderOnce();
|
|
144
|
+
});
|
|
145
|
+
return frameToAnsi(testSetup.captureSpans());
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function renderFooterFrame({
|
|
149
|
+
name,
|
|
150
|
+
agentType,
|
|
151
|
+
width = process.stdout.columns ?? 80,
|
|
152
|
+
}: {
|
|
153
|
+
name: string;
|
|
154
|
+
agentType?: AgentType;
|
|
155
|
+
width?: number;
|
|
156
|
+
}): Promise<string> {
|
|
157
|
+
const testSetup = await createFooterTestRenderer({ name, agentType, width });
|
|
158
|
+
try {
|
|
159
|
+
return await renderFooterSetupFrame(testSetup);
|
|
160
|
+
} finally {
|
|
161
|
+
act(() => {
|
|
162
|
+
testSetup.renderer.destroy();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function runFooterRenderer({
|
|
168
|
+
name,
|
|
169
|
+
agentType,
|
|
170
|
+
stdout = process.stdout,
|
|
171
|
+
onReady,
|
|
172
|
+
}: FooterRendererOptions): Promise<void> {
|
|
173
|
+
const testSetup = await createFooterTestRenderer({
|
|
174
|
+
name,
|
|
175
|
+
agentType,
|
|
176
|
+
width: stdout.columns,
|
|
177
|
+
});
|
|
178
|
+
let currentWidth = Math.max(stdout.columns ?? 80, 1);
|
|
179
|
+
let lastFrame = "";
|
|
180
|
+
let renderInFlight = false;
|
|
181
|
+
let tornDown = false;
|
|
182
|
+
let teardown!: () => void;
|
|
183
|
+
|
|
184
|
+
const render = async () => {
|
|
185
|
+
if (tornDown || renderInFlight) return;
|
|
186
|
+
renderInFlight = true;
|
|
187
|
+
try {
|
|
188
|
+
const nextWidth = Math.max(stdout.columns ?? 80, 1);
|
|
189
|
+
if (nextWidth !== currentWidth) {
|
|
190
|
+
currentWidth = nextWidth;
|
|
191
|
+
testSetup.resize(currentWidth, FOOTER_RENDER_ROWS);
|
|
192
|
+
}
|
|
72
193
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
194
|
+
const frame = await renderFooterSetupFrame(testSetup);
|
|
195
|
+
if (!tornDown && frame !== lastFrame) {
|
|
196
|
+
stdout.write(`${CLEAR_LINE}${frame}`);
|
|
197
|
+
lastFrame = frame;
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
renderInFlight = false;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
await render();
|
|
205
|
+
|
|
206
|
+
await new Promise<void>((resolve) => {
|
|
207
|
+
teardown = () => {
|
|
76
208
|
if (tornDown) return;
|
|
77
209
|
tornDown = true;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
} catch {
|
|
81
|
-
// renderer may already be mid-destroy; the pty is likely gone
|
|
210
|
+
for (const sig of EXIT_SIGNALS) {
|
|
211
|
+
process.off(sig, teardown);
|
|
82
212
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
213
|
+
process.off("SIGWINCH", requestRender);
|
|
214
|
+
clearInterval(renderTick);
|
|
215
|
+
clearInterval(watchdog);
|
|
216
|
+
act(() => {
|
|
217
|
+
testSetup.renderer.destroy();
|
|
218
|
+
});
|
|
219
|
+
resolve();
|
|
87
220
|
};
|
|
221
|
+
|
|
222
|
+
const requestRender = () => {
|
|
223
|
+
void render();
|
|
224
|
+
};
|
|
225
|
+
|
|
88
226
|
for (const sig of EXIT_SIGNALS) {
|
|
89
227
|
process.on(sig, teardown);
|
|
90
228
|
}
|
|
229
|
+
process.on("SIGWINCH", requestRender);
|
|
91
230
|
|
|
231
|
+
const renderTick = setInterval(() => {
|
|
232
|
+
void render();
|
|
233
|
+
}, FOOTER_RENDER_INTERVAL_MS);
|
|
92
234
|
const watchdog = setInterval(() => {
|
|
93
235
|
if (!originalParentAlive()) teardown();
|
|
94
236
|
}, PARENT_WATCHDOG_MS);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return () => {
|
|
98
|
-
for (const sig of EXIT_SIGNALS) {
|
|
99
|
-
process.off(sig, teardown);
|
|
100
|
-
}
|
|
101
|
-
clearInterval(watchdog);
|
|
102
|
-
};
|
|
103
|
-
}, [renderer]);
|
|
104
|
-
|
|
105
|
-
return (
|
|
106
|
-
<box
|
|
107
|
-
width="100%"
|
|
108
|
-
height="100%"
|
|
109
|
-
flexDirection="column"
|
|
110
|
-
justifyContent="flex-end"
|
|
111
|
-
backgroundColor={theme.background}
|
|
112
|
-
>
|
|
113
|
-
<AttachedStatusline name={name} theme={theme} agentType={agentType} />
|
|
114
|
-
</box>
|
|
115
|
-
);
|
|
237
|
+
onReady?.();
|
|
238
|
+
});
|
|
116
239
|
}
|
|
117
240
|
|
|
118
241
|
export async function footerCommand(
|
|
119
242
|
name: string,
|
|
120
243
|
agentType?: AgentType,
|
|
244
|
+
options: Omit<FooterRendererOptions, "name" | "agentType"> = {},
|
|
121
245
|
): Promise<number> {
|
|
122
|
-
|
|
123
|
-
exitOnCtrlC: false,
|
|
124
|
-
});
|
|
125
|
-
const theme = deriveGraphTheme(resolveTheme(renderer.themeMode));
|
|
126
|
-
createRoot(renderer).render(
|
|
127
|
-
<FooterShell name={name} theme={theme} agentType={agentType} />,
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
await new Promise<void>(() => {});
|
|
246
|
+
await runFooterRenderer({ name, agentType, ...options });
|
|
131
247
|
return 0;
|
|
132
248
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
hasRequiredMuxBinary,
|
|
7
|
+
isMuxBinaryRequiredForPlatform,
|
|
8
|
+
prependPath,
|
|
9
|
+
psmuxReleaseAssetSuffix,
|
|
10
|
+
requiredMuxBinaryCandidatesForPlatform,
|
|
11
|
+
resolveCommandFromCurrentPath,
|
|
12
|
+
runCommand,
|
|
13
|
+
} from "./spawn.ts";
|
|
14
|
+
|
|
15
|
+
describe("spawn PATH helpers", () => {
|
|
16
|
+
let originalPath: string | undefined;
|
|
17
|
+
let tempDir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
originalPath = process.env.PATH;
|
|
21
|
+
tempDir = mkdtempSync(join(tmpdir(), "atomic-spawn-"));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
process.env.PATH = originalPath;
|
|
26
|
+
rmSync(tempDir, { force: true, recursive: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("resolves commands added to PATH during the current process", () => {
|
|
30
|
+
const commandName = process.platform === "win32"
|
|
31
|
+
? "atomic-spawn-test.cmd"
|
|
32
|
+
: "atomic-spawn-test";
|
|
33
|
+
const commandPath = join(tempDir, commandName);
|
|
34
|
+
const body = process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n";
|
|
35
|
+
|
|
36
|
+
writeFileSync(commandPath, body);
|
|
37
|
+
chmodSync(commandPath, 0o755);
|
|
38
|
+
|
|
39
|
+
process.env.PATH = originalPath ?? "";
|
|
40
|
+
prependPath(tempDir);
|
|
41
|
+
|
|
42
|
+
expect(resolveCommandFromCurrentPath("atomic-spawn-test")).toBe(commandPath);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("requires native psmux binaries on Windows", () => {
|
|
46
|
+
expect(requiredMuxBinaryCandidatesForPlatform("win32")).toEqual([
|
|
47
|
+
"psmux",
|
|
48
|
+
"pmux",
|
|
49
|
+
]);
|
|
50
|
+
expect(isMuxBinaryRequiredForPlatform("psmux", "win32")).toBe(true);
|
|
51
|
+
expect(isMuxBinaryRequiredForPlatform("pmux", "win32")).toBe(true);
|
|
52
|
+
expect(isMuxBinaryRequiredForPlatform("tmux", "win32")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("requires tmux on Unix-like platforms", () => {
|
|
56
|
+
expect(requiredMuxBinaryCandidatesForPlatform("linux")).toEqual(["tmux"]);
|
|
57
|
+
expect(requiredMuxBinaryCandidatesForPlatform("darwin")).toEqual(["tmux"]);
|
|
58
|
+
expect(isMuxBinaryRequiredForPlatform("tmux", "linux")).toBe(true);
|
|
59
|
+
expect(isMuxBinaryRequiredForPlatform("psmux", "linux")).toBe(false);
|
|
60
|
+
expect(isMuxBinaryRequiredForPlatform("pmux", "darwin")).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("maps supported Windows architectures to psmux release assets", () => {
|
|
64
|
+
expect(psmuxReleaseAssetSuffix("x64")).toBe("windows-x64.zip");
|
|
65
|
+
expect(psmuxReleaseAssetSuffix("ia32")).toBe("windows-x86.zip");
|
|
66
|
+
expect(psmuxReleaseAssetSuffix("arm64")).toBe("windows-arm64.zip");
|
|
67
|
+
expect(psmuxReleaseAssetSuffix("arm")).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("uses platform requirement when checking PATH", () => {
|
|
71
|
+
const commandPath = join(tempDir, "tmux");
|
|
72
|
+
|
|
73
|
+
writeFileSync(commandPath, "#!/bin/sh\n");
|
|
74
|
+
chmodSync(commandPath, 0o755);
|
|
75
|
+
|
|
76
|
+
process.env.PATH = tempDir;
|
|
77
|
+
|
|
78
|
+
expect(hasRequiredMuxBinary()).toBe(process.platform !== "win32");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("does not add duplicate PATH entries", () => {
|
|
82
|
+
process.env.PATH = originalPath ?? "";
|
|
83
|
+
|
|
84
|
+
prependPath(tempDir);
|
|
85
|
+
prependPath(tempDir);
|
|
86
|
+
|
|
87
|
+
const delimiter = process.platform === "win32" ? ";" : ":";
|
|
88
|
+
const entries = (process.env.PATH ?? "").split(delimiter);
|
|
89
|
+
expect(entries.filter((entry) => entry === tempDir)).toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("runCommand keeps stdout and stderr separate", async () => {
|
|
93
|
+
const scriptPath = join(tempDir, "streams.ts");
|
|
94
|
+
writeFileSync(
|
|
95
|
+
scriptPath,
|
|
96
|
+
"await Bun.write(Bun.stderr, 'warning\\n'); await Bun.write(Bun.stdout, 'value\\n');\n",
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const result = await runCommand([
|
|
100
|
+
process.execPath,
|
|
101
|
+
scriptPath,
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
expect(result.success).toBe(true);
|
|
105
|
+
expect(result.details).toBe("warning");
|
|
106
|
+
expect(result.stderr).toBe("warning");
|
|
107
|
+
expect(result.stdout).toBe("value");
|
|
108
|
+
});
|
|
109
|
+
});
|