@fitlab-ai/agent-infra 0.6.2-alpha.1 → 0.6.3
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 +13 -3
- package/README.zh-CN.md +10 -3
- package/bin/cli.ts +6 -1
- package/dist/bin/cli.js +6 -1
- package/dist/lib/sandbox/clipboard/bridge.js +216 -0
- package/dist/lib/sandbox/clipboard/darwin.js +73 -0
- package/dist/lib/sandbox/clipboard/index.js +9 -0
- package/dist/lib/sandbox/clipboard/keys.js +58 -0
- package/dist/lib/sandbox/clipboard/node-pty.js +13 -0
- package/dist/lib/sandbox/clipboard/paths.js +59 -0
- package/dist/lib/sandbox/commands/create.js +38 -21
- package/dist/lib/sandbox/commands/enter.js +8 -2
- package/dist/lib/sandbox/commands/ls.js +19 -4
- package/dist/lib/sandbox/commands/prune.js +176 -0
- package/dist/lib/sandbox/commands/rm.js +27 -33
- package/dist/lib/sandbox/config.js +1 -0
- package/dist/lib/sandbox/constants.js +6 -0
- package/dist/lib/sandbox/credentials.js +43 -24
- package/dist/lib/sandbox/index.js +7 -1
- package/dist/lib/sandbox/managed-fs.js +25 -0
- package/dist/lib/sandbox/tools.js +1 -1
- package/dist/lib/version.js +9 -2
- package/lib/sandbox/clipboard/bridge.ts +285 -0
- package/lib/sandbox/clipboard/darwin.ts +90 -0
- package/lib/sandbox/clipboard/index.ts +13 -0
- package/lib/sandbox/clipboard/keys.ts +78 -0
- package/lib/sandbox/clipboard/node-pty.d.ts +19 -0
- package/lib/sandbox/clipboard/node-pty.ts +34 -0
- package/lib/sandbox/clipboard/paths.ts +71 -0
- package/lib/sandbox/commands/create.ts +44 -21
- package/lib/sandbox/commands/enter.ts +8 -2
- package/lib/sandbox/commands/ls.ts +28 -4
- package/lib/sandbox/commands/prune.ts +211 -0
- package/lib/sandbox/commands/rm.ts +30 -32
- package/lib/sandbox/config.ts +2 -0
- package/lib/sandbox/constants.ts +9 -0
- package/lib/sandbox/credentials.ts +49 -26
- package/lib/sandbox/index.ts +7 -1
- package/lib/sandbox/managed-fs.ts +27 -0
- package/lib/sandbox/tools.ts +1 -1
- package/lib/version.ts +11 -4
- package/package.json +5 -1
- package/templates/.agents/README.en.md +19 -0
- package/templates/.agents/README.zh-CN.md +19 -0
- package/templates/.agents/rules/create-issue.github.en.md +3 -3
- package/templates/.agents/rules/create-issue.github.zh-CN.md +3 -3
- package/templates/.agents/skills/analyze-task/SKILL.en.md +29 -0
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +29 -0
- package/templates/.agents/skills/analyze-task/config/verify.en.json +51 -0
- package/templates/.agents/skills/analyze-task/config/{verify.json → verify.zh-CN.json} +6 -2
- package/templates/.agents/skills/complete-task/SKILL.en.md +16 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +16 -0
- package/templates/.agents/skills/complete-task/config/{verify.json → verify.en.json} +10 -0
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +48 -0
- package/templates/.agents/skills/create-pr/config/verify.json +1 -0
- package/templates/.agents/skills/create-task/SKILL.en.md +3 -3
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +3 -3
- package/templates/.agents/skills/implement-task/SKILL.en.md +14 -0
- package/templates/.agents/skills/implement-task/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/implement-task/config/verify.en.json +51 -0
- package/templates/.agents/skills/implement-task/config/{verify.json → verify.zh-CN.json} +7 -2
- package/templates/.agents/skills/implement-task/reference/report-template.en.md +15 -0
- package/templates/.agents/skills/implement-task/reference/report-template.zh-CN.md +15 -0
- package/templates/.agents/skills/plan-task/SKILL.en.md +24 -0
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +24 -0
- package/templates/.agents/skills/plan-task/config/verify.en.json +52 -0
- package/templates/.agents/skills/plan-task/config/{verify.json → verify.zh-CN.json} +6 -2
- package/templates/.agents/skills/post-release/SKILL.en.md +1 -0
- package/templates/.agents/skills/post-release/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/refine-task/SKILL.en.md +14 -0
- package/templates/.agents/skills/refine-task/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/refine-task/config/verify.en.json +47 -0
- package/templates/.agents/skills/refine-task/config/{verify.json → verify.zh-CN.json} +7 -2
- package/templates/.agents/skills/refine-task/reference/report-template.en.md +15 -0
- package/templates/.agents/skills/refine-task/reference/report-template.zh-CN.md +15 -0
- package/templates/.agents/skills/review-task/SKILL.en.md +14 -0
- package/templates/.agents/skills/review-task/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/review-task/config/verify.en.json +50 -0
- package/templates/.agents/skills/review-task/config/{verify.json → verify.zh-CN.json} +5 -2
- package/templates/.agents/skills/review-task/reference/report-template.en.md +15 -0
- package/templates/.agents/skills/review-task/reference/report-template.zh-CN.md +15 -0
- package/dist/package.json +0 -5
|
@@ -6,6 +6,7 @@ Commands:
|
|
|
6
6
|
refresh Sync host Claude Code credentials to all sandbox copies
|
|
7
7
|
ls List sandboxes for the current project
|
|
8
8
|
rm <branch> [--all] Remove a sandbox or all sandboxes
|
|
9
|
+
prune [--dry-run] Remove orphaned per-branch state dirs
|
|
9
10
|
vm status|start|stop Manage the sandbox VM (macOS) or check the backend (Windows)
|
|
10
11
|
rebuild [--quiet] Rebuild the sandbox image
|
|
11
12
|
|
|
@@ -29,7 +30,7 @@ export async function runSandbox(args) {
|
|
|
29
30
|
}
|
|
30
31
|
case 'exec': {
|
|
31
32
|
const { enter } = await import("./commands/enter.js");
|
|
32
|
-
const exitCode = enter(rest);
|
|
33
|
+
const exitCode = await enter(rest);
|
|
33
34
|
if (typeof exitCode === 'number' && exitCode !== 0) {
|
|
34
35
|
process.exitCode = exitCode;
|
|
35
36
|
}
|
|
@@ -53,6 +54,11 @@ export async function runSandbox(args) {
|
|
|
53
54
|
await rm(rest);
|
|
54
55
|
break;
|
|
55
56
|
}
|
|
57
|
+
case 'prune': {
|
|
58
|
+
const { prune } = await import("./commands/prune.js");
|
|
59
|
+
await prune(rest);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
56
62
|
case 'vm': {
|
|
57
63
|
const { vm } = await import("./commands/vm.js");
|
|
58
64
|
await vm(rest);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { run } from "./shell.js";
|
|
4
|
+
export function assertManagedPath(root, target) {
|
|
5
|
+
const resolvedRoot = path.resolve(root);
|
|
6
|
+
const resolvedTarget = path.resolve(target);
|
|
7
|
+
const relative = path.relative(resolvedRoot, resolvedTarget);
|
|
8
|
+
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
throw new Error(`Refusing to remove path outside managed sandbox root: ${target}`);
|
|
12
|
+
}
|
|
13
|
+
export function removeManagedDir(root, dir) {
|
|
14
|
+
assertManagedPath(root, dir);
|
|
15
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
export function removeWorktreeDir(repoRoot, worktreeBase, dir) {
|
|
18
|
+
try {
|
|
19
|
+
run('git', ['-C', repoRoot, 'worktree', 'remove', dir, '--force']);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
removeManagedDir(worktreeBase, dir);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=managed-fs.js.map
|
|
@@ -5,7 +5,7 @@ function createBuiltinTools(home, project) {
|
|
|
5
5
|
'claude-code': {
|
|
6
6
|
id: 'claude-code',
|
|
7
7
|
name: 'Claude Code',
|
|
8
|
-
npmPackage: '@anthropic-ai/claude-code',
|
|
8
|
+
npmPackage: '@anthropic-ai/claude-code@stable',
|
|
9
9
|
sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'claude-code'),
|
|
10
10
|
containerMount: '/home/devuser/.claude',
|
|
11
11
|
versionCmd: 'claude --version',
|
package/dist/lib/version.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
const
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
const packageJsonUrl = [
|
|
3
|
+
new URL('../package.json', import.meta.url),
|
|
4
|
+
new URL('../../package.json', import.meta.url),
|
|
5
|
+
].find((url) => existsSync(url));
|
|
6
|
+
if (!packageJsonUrl) {
|
|
7
|
+
throw new Error('Unable to locate package.json for agent-infra version');
|
|
8
|
+
}
|
|
9
|
+
const { version } = JSON.parse(readFileSync(packageJsonUrl, 'utf8'));
|
|
3
10
|
const VERSION = `v${version}`;
|
|
4
11
|
export { VERSION };
|
|
5
12
|
//# sourceMappingURL=version.js.map
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
import { createClipboardAdapter, type ClipboardAdapter } from './index.ts';
|
|
3
|
+
import { buildBracketedPaste, CtrlVDetector, type CtrlVMatch } from './keys.ts';
|
|
4
|
+
import {
|
|
5
|
+
clipboardHostDir,
|
|
6
|
+
containerClipboardPath,
|
|
7
|
+
pngClipboardFilename,
|
|
8
|
+
pruneClipboardDir,
|
|
9
|
+
writeClipboardPngAtomic
|
|
10
|
+
} from './paths.ts';
|
|
11
|
+
import { commandForEngine, restoreTerminal, runInteractiveEngine, runOkEngine } from '../shell.ts';
|
|
12
|
+
import { loadNodePty, type NodePty, type PtyProcess } from './node-pty.ts';
|
|
13
|
+
|
|
14
|
+
type BridgeOptions = {
|
|
15
|
+
engine: string;
|
|
16
|
+
dockerArgs: string[];
|
|
17
|
+
container: string;
|
|
18
|
+
home: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
env?: NodeJS.ProcessEnv;
|
|
21
|
+
platformName?: NodeJS.Platform;
|
|
22
|
+
adapter?: ClipboardAdapter | null;
|
|
23
|
+
loadPty?: () => Promise<NodePty | null>;
|
|
24
|
+
runInteractive?: typeof runInteractiveEngine;
|
|
25
|
+
runOk?: typeof runOkEngine;
|
|
26
|
+
writeStderr?: (chunk: string) => unknown;
|
|
27
|
+
stdin?: NodeJS.ReadStream;
|
|
28
|
+
stdout?: NodeJS.WriteStream;
|
|
29
|
+
createDetector?: () => CtrlVDetector;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const FALLBACK_PREFIX = 'Warning: clipboard image paste bridge disabled';
|
|
33
|
+
const PARTIAL_ESCAPE_FLUSH_MS = 30;
|
|
34
|
+
|
|
35
|
+
export async function runInteractiveWithClipboardBridge(options: BridgeOptions): Promise<number> {
|
|
36
|
+
const {
|
|
37
|
+
engine,
|
|
38
|
+
dockerArgs,
|
|
39
|
+
container,
|
|
40
|
+
home,
|
|
41
|
+
cwd = process.cwd(),
|
|
42
|
+
env = process.env,
|
|
43
|
+
platformName = process.platform,
|
|
44
|
+
adapter = createClipboardAdapter({ platformName }),
|
|
45
|
+
loadPty = loadNodePty,
|
|
46
|
+
runInteractive = runInteractiveEngine,
|
|
47
|
+
runOk = runOkEngine,
|
|
48
|
+
writeStderr = (chunk) => process.stderr.write(chunk),
|
|
49
|
+
stdin = process.stdin,
|
|
50
|
+
stdout = process.stdout,
|
|
51
|
+
createDetector = () => new CtrlVDetector()
|
|
52
|
+
} = options;
|
|
53
|
+
|
|
54
|
+
function fallback(reason: string): number {
|
|
55
|
+
writeStderr(`${FALLBACK_PREFIX}: ${reason}\n`);
|
|
56
|
+
return runInteractive(engine, 'docker', dockerArgs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (platformName !== 'darwin') {
|
|
60
|
+
return runInteractive(engine, 'docker', dockerArgs);
|
|
61
|
+
}
|
|
62
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
63
|
+
return fallback('host stdin/stdout is not a TTY');
|
|
64
|
+
}
|
|
65
|
+
if (!adapter) {
|
|
66
|
+
return fallback('macOS clipboard adapter is unavailable');
|
|
67
|
+
}
|
|
68
|
+
const available = adapter.available();
|
|
69
|
+
if (!available.ok) {
|
|
70
|
+
return fallback(available.reason);
|
|
71
|
+
}
|
|
72
|
+
if (!runOk(engine, 'docker', ['exec', container, 'sh', '-c', '[ -d /clipboard ] && [ -r /clipboard ]'])) {
|
|
73
|
+
return fallback('container /clipboard mount is missing; rebuild the sandbox to enable image paste');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const pty = await loadPty();
|
|
77
|
+
if (!pty) {
|
|
78
|
+
return fallback('node-pty optional dependency is unavailable');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const command = commandForEngine(engine, 'docker', dockerArgs);
|
|
82
|
+
let child: PtyProcess;
|
|
83
|
+
try {
|
|
84
|
+
child = pty.spawn(command.cmd, command.args, {
|
|
85
|
+
name: env.TERM || 'xterm-256color',
|
|
86
|
+
cols: stdout.columns || 120,
|
|
87
|
+
rows: stdout.rows || 40,
|
|
88
|
+
cwd,
|
|
89
|
+
env
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const message = error instanceof Error ? error.message : 'unknown error';
|
|
93
|
+
return fallback(`node-pty spawn failed: ${message}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return runBridge({
|
|
97
|
+
child,
|
|
98
|
+
home,
|
|
99
|
+
adapter,
|
|
100
|
+
writeStderr,
|
|
101
|
+
stdin,
|
|
102
|
+
stdout,
|
|
103
|
+
detector: createDetector()
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function runBridge({
|
|
108
|
+
child,
|
|
109
|
+
home,
|
|
110
|
+
adapter,
|
|
111
|
+
writeStderr,
|
|
112
|
+
stdin,
|
|
113
|
+
stdout,
|
|
114
|
+
detector
|
|
115
|
+
}: {
|
|
116
|
+
child: PtyProcess;
|
|
117
|
+
home: string;
|
|
118
|
+
adapter: ClipboardAdapter;
|
|
119
|
+
writeStderr: (chunk: string) => unknown;
|
|
120
|
+
stdin: NodeJS.ReadStream;
|
|
121
|
+
stdout: NodeJS.WriteStream;
|
|
122
|
+
detector: CtrlVDetector;
|
|
123
|
+
}): Promise<number> {
|
|
124
|
+
let warnedPasteFailure = false;
|
|
125
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
126
|
+
const inputDecoder = new StringDecoder('utf8');
|
|
127
|
+
|
|
128
|
+
const onData = (chunk: Buffer) => {
|
|
129
|
+
clearFlushTimer();
|
|
130
|
+
for (const token of detector.feed(inputDecoder.write(chunk))) {
|
|
131
|
+
if (token.kind === 'text') {
|
|
132
|
+
child.write(token.raw);
|
|
133
|
+
} else {
|
|
134
|
+
handleCtrlV(token, child);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (detector.hasPending()) {
|
|
138
|
+
flushTimer = setTimeout(() => {
|
|
139
|
+
flushTimer = null;
|
|
140
|
+
for (const token of detector.flush()) {
|
|
141
|
+
if (token.kind === 'text') {
|
|
142
|
+
child.write(token.raw);
|
|
143
|
+
} else {
|
|
144
|
+
handleCtrlV(token, child);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}, PARTIAL_ESCAPE_FLUSH_MS);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const onResize = () => child.resize(stdout.columns || 120, stdout.rows || 40);
|
|
151
|
+
const onSigint = () => child.kill('SIGINT');
|
|
152
|
+
const onSigterm = () => child.kill('SIGTERM');
|
|
153
|
+
|
|
154
|
+
function handleCtrlV(match: CtrlVMatch, target: PtyProcess): void {
|
|
155
|
+
try {
|
|
156
|
+
if (!adapter.hasImage()) {
|
|
157
|
+
target.write(match.raw);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const png = adapter.readImagePng();
|
|
162
|
+
if (!png) {
|
|
163
|
+
throw new Error('clipboard image could not be read');
|
|
164
|
+
}
|
|
165
|
+
const filename = pngClipboardFilename(png);
|
|
166
|
+
writeClipboardPngAtomic(clipboardHostDir(home), filename, png);
|
|
167
|
+
pruneClipboardDir(clipboardHostDir(home));
|
|
168
|
+
target.write(buildBracketedPaste(containerClipboardPath(filename)));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
target.write(match.raw);
|
|
171
|
+
if (!warnedPasteFailure) {
|
|
172
|
+
warnedPasteFailure = true;
|
|
173
|
+
writeStderr(`Warning: clipboard image paste failed; forwarded original Ctrl+V (${error instanceof Error ? error.message : 'unknown error'})\n`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function clearFlushTimer(): void {
|
|
179
|
+
if (flushTimer) {
|
|
180
|
+
clearTimeout(flushTimer);
|
|
181
|
+
flushTimer = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
stdin.setRawMode?.(true);
|
|
187
|
+
stdin.resume();
|
|
188
|
+
stdin.on('data', onData);
|
|
189
|
+
stdout.on('resize', onResize);
|
|
190
|
+
process.on('SIGINT', onSigint);
|
|
191
|
+
process.on('SIGTERM', onSigterm);
|
|
192
|
+
child.onData((data) => stdout.write(data));
|
|
193
|
+
|
|
194
|
+
return exitCode(await onceExit(child, stdin));
|
|
195
|
+
} finally {
|
|
196
|
+
clearFlushTimer();
|
|
197
|
+
// The child pty is already exiting here; flushing buffered input is
|
|
198
|
+
// best-effort and must never block terminal/stdin cleanup below.
|
|
199
|
+
try {
|
|
200
|
+
for (const token of detector.feed(inputDecoder.end())) {
|
|
201
|
+
if (token.kind === 'text') {
|
|
202
|
+
child.write(token.raw);
|
|
203
|
+
} else {
|
|
204
|
+
handleCtrlV(token, child);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const token of detector.flush()) {
|
|
208
|
+
if (token.kind === 'text') {
|
|
209
|
+
child.write(token.raw);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Writing to an already-closed pty can throw; ignore on teardown.
|
|
214
|
+
}
|
|
215
|
+
stdin.off('data', onData);
|
|
216
|
+
stdout.off('resize', onResize);
|
|
217
|
+
process.off('SIGINT', onSigint);
|
|
218
|
+
process.off('SIGTERM', onSigterm);
|
|
219
|
+
stdin.setRawMode?.(false);
|
|
220
|
+
// Release stdin so the resumed TTY handle stops keeping the event loop
|
|
221
|
+
// alive; without this the CLI hangs after the sandbox exits until Ctrl+C.
|
|
222
|
+
stdin.pause?.();
|
|
223
|
+
restoreTerminal();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function onceExit(
|
|
228
|
+
child: PtyProcess,
|
|
229
|
+
stdin: NodeJS.ReadStream
|
|
230
|
+
): Promise<{ exitCode: number; signal?: number | string }> {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
let settled = false;
|
|
233
|
+
const finish = (event: { exitCode: number; signal?: number | string }) => {
|
|
234
|
+
if (settled) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
settled = true;
|
|
238
|
+
stdin.off('end', onStdinEnd);
|
|
239
|
+
stdin.off('close', onStdinEnd);
|
|
240
|
+
resolve(event);
|
|
241
|
+
};
|
|
242
|
+
const onStdinEnd = () => {
|
|
243
|
+
child.kill('SIGHUP');
|
|
244
|
+
finish({ exitCode: 0, signal: 'SIGHUP' });
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
child.onExit(finish);
|
|
248
|
+
stdin.once('end', onStdinEnd);
|
|
249
|
+
stdin.once('close', onStdinEnd);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function exitCode(event: { exitCode: number; signal?: number | string }): number {
|
|
254
|
+
if (event.signal !== undefined && event.signal !== null) {
|
|
255
|
+
return signalExitCode(event.signal);
|
|
256
|
+
}
|
|
257
|
+
if (event.exitCode !== undefined && event.exitCode !== null) {
|
|
258
|
+
return event.exitCode;
|
|
259
|
+
}
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function signalExitCode(signal: number | string): number {
|
|
264
|
+
if (typeof signal === 'number') {
|
|
265
|
+
return 128 + signal;
|
|
266
|
+
}
|
|
267
|
+
const signals: Record<string, number> = {
|
|
268
|
+
SIGHUP: 1,
|
|
269
|
+
SIGINT: 2,
|
|
270
|
+
SIGQUIT: 3,
|
|
271
|
+
SIGILL: 4,
|
|
272
|
+
SIGTRAP: 5,
|
|
273
|
+
SIGABRT: 6,
|
|
274
|
+
SIGBUS: 7,
|
|
275
|
+
SIGFPE: 8,
|
|
276
|
+
SIGKILL: 9,
|
|
277
|
+
SIGUSR1: 10,
|
|
278
|
+
SIGSEGV: 11,
|
|
279
|
+
SIGUSR2: 12,
|
|
280
|
+
SIGPIPE: 13,
|
|
281
|
+
SIGALRM: 14,
|
|
282
|
+
SIGTERM: 15
|
|
283
|
+
};
|
|
284
|
+
return 128 + (signals[signal] ?? 0);
|
|
285
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import type { ExecFileSyncOptions } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const HAS_IMAGE_TIMEOUT_MS = 500;
|
|
8
|
+
const READ_IMAGE_TIMEOUT_MS = 5_000;
|
|
9
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
10
|
+
|
|
11
|
+
type ExecFn = (cmd: string, args: string[], options?: ExecFileSyncOptions) => Buffer | string;
|
|
12
|
+
|
|
13
|
+
export type DarwinClipboardAdapter = {
|
|
14
|
+
available(): { ok: true } | { ok: false; reason: string };
|
|
15
|
+
hasImage(): boolean;
|
|
16
|
+
readImagePng(): Buffer | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function createDarwinClipboardAdapter({
|
|
20
|
+
execFn = execFileSync,
|
|
21
|
+
mkdtempFn = fs.mkdtempSync,
|
|
22
|
+
readFileFn = fs.readFileSync,
|
|
23
|
+
rmFn = fs.rmSync
|
|
24
|
+
}: {
|
|
25
|
+
execFn?: ExecFn;
|
|
26
|
+
mkdtempFn?: typeof fs.mkdtempSync;
|
|
27
|
+
readFileFn?: typeof fs.readFileSync;
|
|
28
|
+
rmFn?: typeof fs.rmSync;
|
|
29
|
+
} = {}): DarwinClipboardAdapter {
|
|
30
|
+
return {
|
|
31
|
+
available() {
|
|
32
|
+
try {
|
|
33
|
+
execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout: HAS_IMAGE_TIMEOUT_MS });
|
|
34
|
+
return { ok: true };
|
|
35
|
+
} catch {
|
|
36
|
+
return { ok: false, reason: 'macOS osascript is unavailable' };
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
hasImage() {
|
|
40
|
+
try {
|
|
41
|
+
const output = String(execFn('osascript', ['-e', 'clipboard info'], {
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
timeout: HAS_IMAGE_TIMEOUT_MS
|
|
44
|
+
}));
|
|
45
|
+
return /\b(PNGf|TIFF|JPEG|GIFf)\b/.test(output);
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
readImagePng() {
|
|
51
|
+
const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
|
|
52
|
+
const outputPath = path.join(tmpDir, 'clipboard.png');
|
|
53
|
+
try {
|
|
54
|
+
try {
|
|
55
|
+
execFn('osascript', ['-e', pngWriteScript(outputPath)], {
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
timeout: READ_IMAGE_TIMEOUT_MS
|
|
58
|
+
});
|
|
59
|
+
} catch {
|
|
60
|
+
execFn('pngpaste', [outputPath], {
|
|
61
|
+
encoding: 'utf8',
|
|
62
|
+
timeout: READ_IMAGE_TIMEOUT_MS
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const png = Buffer.from(readFileFn(outputPath));
|
|
66
|
+
return isPng(png) ? png : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
} finally {
|
|
70
|
+
rmFn(tmpDir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPng(buffer: Buffer): boolean {
|
|
77
|
+
return buffer.length >= PNG_MAGIC.length && PNG_MAGIC.every((byte, index) => buffer[index] === byte);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pngWriteScript(outputPath: string): string {
|
|
81
|
+
const escapedPath = outputPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
82
|
+
return [
|
|
83
|
+
'set pngData to the clipboard as «class PNGf»',
|
|
84
|
+
`set outFile to POSIX file "${escapedPath}"`,
|
|
85
|
+
'set fileRef to open for access outFile with write permission',
|
|
86
|
+
'set eof fileRef to 0',
|
|
87
|
+
'write pngData to fileRef',
|
|
88
|
+
'close access fileRef'
|
|
89
|
+
].join('\n');
|
|
90
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { platform } from 'node:os';
|
|
2
|
+
import { createDarwinClipboardAdapter, type DarwinClipboardAdapter } from './darwin.ts';
|
|
3
|
+
|
|
4
|
+
export type ClipboardAdapter = DarwinClipboardAdapter;
|
|
5
|
+
|
|
6
|
+
export function createClipboardAdapter({
|
|
7
|
+
platformName = platform()
|
|
8
|
+
}: { platformName?: NodeJS.Platform } = {}): ClipboardAdapter | null {
|
|
9
|
+
if (platformName !== 'darwin') {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return createDarwinClipboardAdapter();
|
|
13
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type CtrlVMatch = {
|
|
2
|
+
kind: 'ctrl-v';
|
|
3
|
+
raw: string;
|
|
4
|
+
label: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type KeyToken =
|
|
8
|
+
| { kind: 'text'; raw: string }
|
|
9
|
+
| CtrlVMatch;
|
|
10
|
+
|
|
11
|
+
const CTRL_V_SEQUENCES = [
|
|
12
|
+
{ raw: '\x16', label: 'ctrl-v 0x16' },
|
|
13
|
+
{ raw: '\x1b[118;5u', label: 'ctrl-v csi-u ESC[118;5u' },
|
|
14
|
+
{ raw: '\x1b[27;5;118~', label: 'ctrl-v modifyOtherKeys ESC[27;5;118~' }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export class CtrlVDetector {
|
|
18
|
+
#pending = '';
|
|
19
|
+
|
|
20
|
+
hasPending(): boolean {
|
|
21
|
+
return this.#pending.length > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
feed(raw: string): KeyToken[] {
|
|
25
|
+
let input = this.#pending + raw;
|
|
26
|
+
this.#pending = '';
|
|
27
|
+
const tokens: KeyToken[] = [];
|
|
28
|
+
|
|
29
|
+
while (input.length > 0) {
|
|
30
|
+
const match = CTRL_V_SEQUENCES.find((sequence) => input.startsWith(sequence.raw));
|
|
31
|
+
if (match) {
|
|
32
|
+
tokens.push({ kind: 'ctrl-v', raw: match.raw, label: match.label });
|
|
33
|
+
input = input.slice(match.raw.length);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const partial = CTRL_V_SEQUENCES.some((sequence) =>
|
|
38
|
+
sequence.raw.startsWith(input) && input.length < sequence.raw.length
|
|
39
|
+
);
|
|
40
|
+
if (partial) {
|
|
41
|
+
this.#pending = input;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const first = input.slice(0, 1);
|
|
46
|
+
tokens.push({ kind: 'text', raw: first });
|
|
47
|
+
input = input.slice(first.length);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return coalesceText(tokens);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
flush(): KeyToken[] {
|
|
54
|
+
if (!this.#pending) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const raw = this.#pending;
|
|
58
|
+
this.#pending = '';
|
|
59
|
+
return [{ kind: 'text', raw }];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function coalesceText(tokens: KeyToken[]): KeyToken[] {
|
|
64
|
+
const result: KeyToken[] = [];
|
|
65
|
+
for (const token of tokens) {
|
|
66
|
+
const previous = result.at(-1);
|
|
67
|
+
if (token.kind === 'text' && previous?.kind === 'text') {
|
|
68
|
+
previous.raw += token.raw;
|
|
69
|
+
} else {
|
|
70
|
+
result.push(token);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function buildBracketedPaste(text: string): string {
|
|
77
|
+
return `\x1b[200~${text}\x1b[201~`;
|
|
78
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
declare module '@lydell/node-pty' {
|
|
2
|
+
export function spawn(
|
|
3
|
+
file: string,
|
|
4
|
+
args: string[],
|
|
5
|
+
options: {
|
|
6
|
+
name: string;
|
|
7
|
+
cols: number;
|
|
8
|
+
rows: number;
|
|
9
|
+
cwd: string;
|
|
10
|
+
env: NodeJS.ProcessEnv;
|
|
11
|
+
}
|
|
12
|
+
): {
|
|
13
|
+
onData(callback: (data: string) => void): void;
|
|
14
|
+
onExit(callback: (event: { exitCode: number; signal?: number | string }) => void): void;
|
|
15
|
+
write(data: string): void;
|
|
16
|
+
resize(cols: number, rows: number): void;
|
|
17
|
+
kill(signal?: string): void;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
export type PtyProcess = {
|
|
4
|
+
onData(callback: (data: string) => void): void;
|
|
5
|
+
onExit(callback: (event: { exitCode: number; signal?: number | string }) => void): void;
|
|
6
|
+
write(data: string): void;
|
|
7
|
+
resize(cols: number, rows: number): void;
|
|
8
|
+
kill(signal?: string): void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type NodePty = {
|
|
12
|
+
spawn(
|
|
13
|
+
file: string,
|
|
14
|
+
args: string[],
|
|
15
|
+
options: {
|
|
16
|
+
name: string;
|
|
17
|
+
cols: number;
|
|
18
|
+
rows: number;
|
|
19
|
+
cwd: string;
|
|
20
|
+
env: NodeJS.ProcessEnv;
|
|
21
|
+
}
|
|
22
|
+
): PtyProcess;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function loadNodePty(): Promise<NodePty | null> {
|
|
26
|
+
try {
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const mod = require('@lydell/node-pty') as NodePty | { default?: NodePty };
|
|
29
|
+
const maybeDefault = mod as { default?: NodePty };
|
|
30
|
+
return maybeDefault.default ?? (mod as NodePty);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { hostJoin } from '../engines/wsl2-paths.ts';
|
|
5
|
+
|
|
6
|
+
export const CONTAINER_CLIPBOARD_MOUNT = '/clipboard';
|
|
7
|
+
const DEFAULT_KEEP = 20;
|
|
8
|
+
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
export function clipboardHostDir(home: string): string {
|
|
11
|
+
return hostJoin(home, '.agent-infra', 'clipboard');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function containerClipboardPath(filename: string): string {
|
|
15
|
+
return path.posix.join(CONTAINER_CLIPBOARD_MOUNT, filename);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function pngClipboardFilename(buffer: Buffer): string {
|
|
19
|
+
return `${crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 16)}.png`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeClipboardPngAtomic(dir: string, filename: string, buffer: Buffer): string {
|
|
23
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
24
|
+
try {
|
|
25
|
+
fs.chmodSync(dir, 0o700);
|
|
26
|
+
} catch {
|
|
27
|
+
// Best effort: existing directories may live on filesystems that ignore chmod.
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const target = path.join(dir, filename);
|
|
31
|
+
const tmp = path.join(dir, `.${filename}.${process.pid}.tmp`);
|
|
32
|
+
fs.writeFileSync(tmp, buffer);
|
|
33
|
+
fs.renameSync(tmp, target);
|
|
34
|
+
return target;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function pruneClipboardDir(
|
|
38
|
+
dir: string,
|
|
39
|
+
{ keep = DEFAULT_KEEP, maxAgeMs = DEFAULT_MAX_AGE_MS, now = Date.now() }:
|
|
40
|
+
{ keep?: number; maxAgeMs?: number; now?: number } = {}
|
|
41
|
+
): string[] {
|
|
42
|
+
if (!fs.existsSync(dir)) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const entries = fs.readdirSync(dir)
|
|
47
|
+
.filter((name) => name.endsWith('.png'))
|
|
48
|
+
.map((name) => {
|
|
49
|
+
const fullPath = path.join(dir, name);
|
|
50
|
+
const stat = fs.statSync(fullPath);
|
|
51
|
+
return { fullPath, mtimeMs: stat.mtimeMs };
|
|
52
|
+
})
|
|
53
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
54
|
+
|
|
55
|
+
const keepSet = new Set(entries.slice(0, keep).map((entry) => entry.fullPath));
|
|
56
|
+
const removed: string[] = [];
|
|
57
|
+
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
if (keepSet.has(entry.fullPath) && now - entry.mtimeMs <= maxAgeMs) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
fs.rmSync(entry.fullPath, { force: true });
|
|
64
|
+
removed.push(entry.fullPath);
|
|
65
|
+
} catch {
|
|
66
|
+
// Cleanup is opportunistic; a failed prune should not break paste.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return removed;
|
|
71
|
+
}
|