@fitlab-ai/agent-infra 0.6.2 → 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.
Files changed (75) hide show
  1. package/README.md +13 -3
  2. package/README.zh-CN.md +10 -3
  3. package/bin/cli.ts +6 -1
  4. package/dist/bin/cli.js +6 -1
  5. package/dist/lib/sandbox/clipboard/bridge.js +216 -0
  6. package/dist/lib/sandbox/clipboard/darwin.js +73 -0
  7. package/dist/lib/sandbox/clipboard/index.js +9 -0
  8. package/dist/lib/sandbox/clipboard/keys.js +58 -0
  9. package/dist/lib/sandbox/clipboard/node-pty.js +13 -0
  10. package/dist/lib/sandbox/clipboard/paths.js +59 -0
  11. package/dist/lib/sandbox/commands/create.js +11 -2
  12. package/dist/lib/sandbox/commands/enter.js +8 -2
  13. package/dist/lib/sandbox/commands/ls.js +19 -4
  14. package/dist/lib/sandbox/commands/prune.js +176 -0
  15. package/dist/lib/sandbox/commands/rm.js +27 -33
  16. package/dist/lib/sandbox/config.js +1 -0
  17. package/dist/lib/sandbox/constants.js +6 -0
  18. package/dist/lib/sandbox/index.js +7 -1
  19. package/dist/lib/sandbox/managed-fs.js +25 -0
  20. package/dist/lib/sandbox/tools.js +1 -1
  21. package/dist/lib/version.js +9 -2
  22. package/lib/sandbox/clipboard/bridge.ts +285 -0
  23. package/lib/sandbox/clipboard/darwin.ts +90 -0
  24. package/lib/sandbox/clipboard/index.ts +13 -0
  25. package/lib/sandbox/clipboard/keys.ts +78 -0
  26. package/lib/sandbox/clipboard/node-pty.d.ts +19 -0
  27. package/lib/sandbox/clipboard/node-pty.ts +34 -0
  28. package/lib/sandbox/clipboard/paths.ts +71 -0
  29. package/lib/sandbox/commands/create.ts +15 -2
  30. package/lib/sandbox/commands/enter.ts +8 -2
  31. package/lib/sandbox/commands/ls.ts +28 -4
  32. package/lib/sandbox/commands/prune.ts +211 -0
  33. package/lib/sandbox/commands/rm.ts +30 -32
  34. package/lib/sandbox/config.ts +2 -0
  35. package/lib/sandbox/constants.ts +9 -0
  36. package/lib/sandbox/index.ts +7 -1
  37. package/lib/sandbox/managed-fs.ts +27 -0
  38. package/lib/sandbox/tools.ts +1 -1
  39. package/lib/version.ts +11 -4
  40. package/package.json +5 -1
  41. package/templates/.agents/README.en.md +19 -0
  42. package/templates/.agents/README.zh-CN.md +19 -0
  43. package/templates/.agents/skills/analyze-task/SKILL.en.md +26 -0
  44. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +26 -0
  45. package/templates/.agents/skills/analyze-task/config/verify.en.json +51 -0
  46. package/templates/.agents/skills/analyze-task/config/{verify.json → verify.zh-CN.json} +6 -2
  47. package/templates/.agents/skills/complete-task/SKILL.en.md +15 -0
  48. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +15 -0
  49. package/templates/.agents/skills/complete-task/config/{verify.json → verify.en.json} +10 -0
  50. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +48 -0
  51. package/templates/.agents/skills/implement-task/SKILL.en.md +14 -0
  52. package/templates/.agents/skills/implement-task/SKILL.zh-CN.md +14 -0
  53. package/templates/.agents/skills/implement-task/config/verify.en.json +51 -0
  54. package/templates/.agents/skills/implement-task/config/{verify.json → verify.zh-CN.json} +7 -2
  55. package/templates/.agents/skills/implement-task/reference/report-template.en.md +15 -0
  56. package/templates/.agents/skills/implement-task/reference/report-template.zh-CN.md +15 -0
  57. package/templates/.agents/skills/plan-task/SKILL.en.md +22 -0
  58. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +22 -0
  59. package/templates/.agents/skills/plan-task/config/verify.en.json +52 -0
  60. package/templates/.agents/skills/plan-task/config/{verify.json → verify.zh-CN.json} +6 -2
  61. package/templates/.agents/skills/post-release/SKILL.en.md +1 -0
  62. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +1 -0
  63. package/templates/.agents/skills/refine-task/SKILL.en.md +14 -0
  64. package/templates/.agents/skills/refine-task/SKILL.zh-CN.md +14 -0
  65. package/templates/.agents/skills/refine-task/config/verify.en.json +47 -0
  66. package/templates/.agents/skills/refine-task/config/{verify.json → verify.zh-CN.json} +7 -2
  67. package/templates/.agents/skills/refine-task/reference/report-template.en.md +15 -0
  68. package/templates/.agents/skills/refine-task/reference/report-template.zh-CN.md +15 -0
  69. package/templates/.agents/skills/review-task/SKILL.en.md +14 -0
  70. package/templates/.agents/skills/review-task/SKILL.zh-CN.md +14 -0
  71. package/templates/.agents/skills/review-task/config/verify.en.json +50 -0
  72. package/templates/.agents/skills/review-task/config/{verify.json → verify.zh-CN.json} +5 -2
  73. package/templates/.agents/skills/review-task/reference/report-template.en.md +15 -0
  74. package/templates/.agents/skills/review-task/reference/report-template.zh-CN.md +15 -0
  75. package/dist/package.json +0 -5
@@ -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
+ }
@@ -17,9 +17,9 @@ import {
17
17
  sandboxBranchLabel,
18
18
  sandboxImageConfigLabel,
19
19
  sandboxLabel,
20
- sanitizeBranchName,
21
20
  shareBranchDir,
22
21
  shareCommonDir,
22
+ shellConfigDir,
23
23
  worktreeDirCandidates
24
24
  } from '../constants.ts';
25
25
  import { prepareDockerfile } from '../dockerfile.ts';
@@ -39,6 +39,7 @@ import { resolveTaskBranch } from '../task-resolver.ts';
39
39
  import { resolveTools, toolConfigDirCandidates, toolNpmPackagesArg } from '../tools.ts';
40
40
  import type { SandboxTool } from '../tools.ts';
41
41
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.ts';
42
+ import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.ts';
42
43
  import { validateSelinuxDisableEnv } from '../engines/selinux.ts';
43
44
  import { resolveBuildUid } from '../engines/native.ts';
44
45
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
@@ -127,7 +128,17 @@ function resolveToolDirs(config: Pick<SandboxCreateConfig, 'project'>, tools: Sa
127
128
  }
128
129
 
129
130
  export function hostShellConfigDir(home: string, project: string, branch: string): string {
130
- return hostJoin(home, '.agent-infra', 'config', project, sanitizeBranchName(branch));
131
+ return shellConfigDir(
132
+ { shellConfigBase: hostJoin(home, '.agent-infra', 'config', project) },
133
+ branch
134
+ );
135
+ }
136
+
137
+ export function buildClipboardVolumeArgs(engine: string, home: string): string[] {
138
+ return [
139
+ '-v',
140
+ volumeArg(engine, clipboardHostDir(home), CONTAINER_CLIPBOARD_MOUNT, ':ro')
141
+ ];
131
142
  }
132
143
 
133
144
  function runtimeChecks(runtimes: string[]): RuntimeCheck[] {
@@ -1344,6 +1355,7 @@ export async function create(args: string[]): Promise<void> {
1344
1355
  fs.mkdirSync(workspaceDir, { recursive: true });
1345
1356
  fs.mkdirSync(shareCommon, { recursive: true });
1346
1357
  fs.mkdirSync(shareBranch, { recursive: true });
1358
+ fs.mkdirSync(clipboardHostDir(effectiveConfig.home), { recursive: true, mode: 0o700 });
1347
1359
 
1348
1360
  const dotfilesSnapshot = materializeDotfiles(
1349
1361
  effectiveConfig.dotfilesDir,
@@ -1372,6 +1384,7 @@ export async function create(args: string[]): Promise<void> {
1372
1384
  volumeArg(engine, shareCommon, '/share/common'),
1373
1385
  '-v',
1374
1386
  volumeArg(engine, shareBranch, '/share/branch'),
1387
+ ...buildClipboardVolumeArgs(engine, effectiveConfig.home),
1375
1388
  '-v',
1376
1389
  volumeArg(
1377
1390
  engine,
@@ -11,6 +11,7 @@ import {
11
11
  import { runInteractiveEngine, runSafeEngine } from '../shell.ts';
12
12
  import { resolveTaskBranch } from '../task-resolver.ts';
13
13
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
14
+ import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.ts';
14
15
 
15
16
  const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
16
17
  const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
@@ -65,7 +66,7 @@ export function formatCredentialSyncStatus(
65
66
  return null;
66
67
  }
67
68
 
68
- export function enter(args: string[]): number {
69
+ export async function enter(args: string[]): Promise<number> {
69
70
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
70
71
  process.stdout.write(`${USAGE}\n`);
71
72
  if (args.length === 0) {
@@ -108,7 +109,12 @@ export function enter(args: string[]): number {
108
109
  process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
109
110
  }
110
111
 
111
- return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH]);
112
+ return runInteractiveWithClipboardBridge({
113
+ engine,
114
+ dockerArgs: ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH],
115
+ container,
116
+ home: config.home
117
+ });
112
118
  }
113
119
 
114
120
  return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, ...cmd]);