@fitlab-ai/agent-infra 0.6.2 → 0.6.4

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 (90) 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 +218 -0
  6. package/dist/lib/sandbox/clipboard/darwin.js +66 -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 +15 -2
  12. package/dist/lib/sandbox/commands/enter.js +14 -3
  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/host-timezone.js +33 -0
  19. package/dist/lib/sandbox/index.js +7 -1
  20. package/dist/lib/sandbox/managed-fs.js +25 -0
  21. package/dist/lib/sandbox/runtimes/base.dockerfile +21 -16
  22. package/dist/lib/sandbox/tools.js +1 -1
  23. package/dist/lib/version.js +9 -2
  24. package/lib/sandbox/clipboard/bridge.ts +286 -0
  25. package/lib/sandbox/clipboard/darwin.ts +91 -0
  26. package/lib/sandbox/clipboard/index.ts +13 -0
  27. package/lib/sandbox/clipboard/keys.ts +78 -0
  28. package/lib/sandbox/clipboard/node-pty.d.ts +19 -0
  29. package/lib/sandbox/clipboard/node-pty.ts +34 -0
  30. package/lib/sandbox/clipboard/paths.ts +71 -0
  31. package/lib/sandbox/commands/create.ts +19 -2
  32. package/lib/sandbox/commands/enter.ts +15 -3
  33. package/lib/sandbox/commands/ls.ts +28 -4
  34. package/lib/sandbox/commands/prune.ts +211 -0
  35. package/lib/sandbox/commands/rm.ts +30 -32
  36. package/lib/sandbox/config.ts +2 -0
  37. package/lib/sandbox/constants.ts +9 -0
  38. package/lib/sandbox/host-timezone.ts +42 -0
  39. package/lib/sandbox/index.ts +7 -1
  40. package/lib/sandbox/managed-fs.ts +27 -0
  41. package/lib/sandbox/runtimes/base.dockerfile +21 -16
  42. package/lib/sandbox/tools.ts +1 -1
  43. package/lib/version.ts +11 -4
  44. package/package.json +10 -6
  45. package/templates/.agents/README.en.md +19 -0
  46. package/templates/.agents/README.zh-CN.md +19 -0
  47. package/templates/.agents/rules/create-issue.github.en.md +19 -1
  48. package/templates/.agents/rules/create-issue.github.zh-CN.md +19 -1
  49. package/templates/.agents/rules/milestone-inference.github.en.md +12 -0
  50. package/templates/.agents/rules/milestone-inference.github.zh-CN.md +12 -0
  51. package/templates/.agents/rules/testing-discipline.en.md +44 -0
  52. package/templates/.agents/rules/testing-discipline.zh-CN.md +44 -0
  53. package/templates/.agents/skills/analyze-task/SKILL.en.md +26 -0
  54. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +26 -0
  55. package/templates/.agents/skills/analyze-task/config/verify.en.json +51 -0
  56. package/templates/.agents/skills/analyze-task/config/{verify.json → verify.zh-CN.json} +6 -2
  57. package/templates/.agents/skills/complete-task/SKILL.en.md +15 -0
  58. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +15 -0
  59. package/templates/.agents/skills/complete-task/config/{verify.json → verify.en.json} +10 -0
  60. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +48 -0
  61. package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
  62. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +2 -0
  63. package/templates/.agents/skills/create-task/config/verify.json +1 -0
  64. package/templates/.agents/skills/implement-task/SKILL.en.md +14 -0
  65. package/templates/.agents/skills/implement-task/SKILL.zh-CN.md +14 -0
  66. package/templates/.agents/skills/implement-task/config/verify.en.json +51 -0
  67. package/templates/.agents/skills/implement-task/config/{verify.json → verify.zh-CN.json} +7 -2
  68. package/templates/.agents/skills/implement-task/reference/report-template.en.md +15 -0
  69. package/templates/.agents/skills/implement-task/reference/report-template.zh-CN.md +15 -0
  70. package/templates/.agents/skills/import-issue/SKILL.en.md +1 -1
  71. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  72. package/templates/.agents/skills/plan-task/SKILL.en.md +22 -0
  73. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +22 -0
  74. package/templates/.agents/skills/plan-task/config/verify.en.json +52 -0
  75. package/templates/.agents/skills/plan-task/config/{verify.json → verify.zh-CN.json} +6 -2
  76. package/templates/.agents/skills/post-release/SKILL.en.md +1 -0
  77. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +1 -0
  78. package/templates/.agents/skills/refine-task/SKILL.en.md +14 -0
  79. package/templates/.agents/skills/refine-task/SKILL.zh-CN.md +14 -0
  80. package/templates/.agents/skills/refine-task/config/verify.en.json +47 -0
  81. package/templates/.agents/skills/refine-task/config/{verify.json → verify.zh-CN.json} +7 -2
  82. package/templates/.agents/skills/refine-task/reference/report-template.en.md +15 -0
  83. package/templates/.agents/skills/refine-task/reference/report-template.zh-CN.md +15 -0
  84. package/templates/.agents/skills/review-task/SKILL.en.md +14 -0
  85. package/templates/.agents/skills/review-task/SKILL.zh-CN.md +14 -0
  86. package/templates/.agents/skills/review-task/config/verify.en.json +50 -0
  87. package/templates/.agents/skills/review-task/config/{verify.json → verify.zh-CN.json} +5 -2
  88. package/templates/.agents/skills/review-task/reference/report-template.en.md +15 -0
  89. package/templates/.agents/skills/review-task/reference/report-template.zh-CN.md +15 -0
  90. package/dist/package.json +0 -5
@@ -0,0 +1,286 @@
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
+ // readImagePng returns null both for "no image on clipboard" and for
157
+ // unexpected read failures; both cases forward the original Ctrl+V so
158
+ // the container app handles it as a regular keystroke. The throw branch
159
+ // below only fires on truly unexpected exceptions (e.g. fs write
160
+ // errors writing to the host clipboard dir).
161
+ const png = adapter.readImagePng();
162
+ if (!png) {
163
+ target.write(match.raw);
164
+ return;
165
+ }
166
+ const filename = pngClipboardFilename(png);
167
+ writeClipboardPngAtomic(clipboardHostDir(home), filename, png);
168
+ pruneClipboardDir(clipboardHostDir(home));
169
+ target.write(buildBracketedPaste(containerClipboardPath(filename)));
170
+ } catch (error) {
171
+ target.write(match.raw);
172
+ if (!warnedPasteFailure) {
173
+ warnedPasteFailure = true;
174
+ writeStderr(`Warning: clipboard image paste failed; forwarded original Ctrl+V (${error instanceof Error ? error.message : 'unknown error'})\n`);
175
+ }
176
+ }
177
+ }
178
+
179
+ function clearFlushTimer(): void {
180
+ if (flushTimer) {
181
+ clearTimeout(flushTimer);
182
+ flushTimer = null;
183
+ }
184
+ }
185
+
186
+ try {
187
+ stdin.setRawMode?.(true);
188
+ stdin.resume();
189
+ stdin.on('data', onData);
190
+ stdout.on('resize', onResize);
191
+ process.on('SIGINT', onSigint);
192
+ process.on('SIGTERM', onSigterm);
193
+ child.onData((data) => stdout.write(data));
194
+
195
+ return exitCode(await onceExit(child, stdin));
196
+ } finally {
197
+ clearFlushTimer();
198
+ // The child pty is already exiting here; flushing buffered input is
199
+ // best-effort and must never block terminal/stdin cleanup below.
200
+ try {
201
+ for (const token of detector.feed(inputDecoder.end())) {
202
+ if (token.kind === 'text') {
203
+ child.write(token.raw);
204
+ } else {
205
+ handleCtrlV(token, child);
206
+ }
207
+ }
208
+ for (const token of detector.flush()) {
209
+ if (token.kind === 'text') {
210
+ child.write(token.raw);
211
+ }
212
+ }
213
+ } catch {
214
+ // Writing to an already-closed pty can throw; ignore on teardown.
215
+ }
216
+ stdin.off('data', onData);
217
+ stdout.off('resize', onResize);
218
+ process.off('SIGINT', onSigint);
219
+ process.off('SIGTERM', onSigterm);
220
+ stdin.setRawMode?.(false);
221
+ // Release stdin so the resumed TTY handle stops keeping the event loop
222
+ // alive; without this the CLI hangs after the sandbox exits until Ctrl+C.
223
+ stdin.pause?.();
224
+ restoreTerminal();
225
+ }
226
+ }
227
+
228
+ function onceExit(
229
+ child: PtyProcess,
230
+ stdin: NodeJS.ReadStream
231
+ ): Promise<{ exitCode: number; signal?: number | string }> {
232
+ return new Promise((resolve) => {
233
+ let settled = false;
234
+ const finish = (event: { exitCode: number; signal?: number | string }) => {
235
+ if (settled) {
236
+ return;
237
+ }
238
+ settled = true;
239
+ stdin.off('end', onStdinEnd);
240
+ stdin.off('close', onStdinEnd);
241
+ resolve(event);
242
+ };
243
+ const onStdinEnd = () => {
244
+ child.kill('SIGHUP');
245
+ finish({ exitCode: 0, signal: 'SIGHUP' });
246
+ };
247
+
248
+ child.onExit(finish);
249
+ stdin.once('end', onStdinEnd);
250
+ stdin.once('close', onStdinEnd);
251
+ });
252
+ }
253
+
254
+ function exitCode(event: { exitCode: number; signal?: number | string }): number {
255
+ if (event.signal !== undefined && event.signal !== null) {
256
+ return signalExitCode(event.signal);
257
+ }
258
+ if (event.exitCode !== undefined && event.exitCode !== null) {
259
+ return event.exitCode;
260
+ }
261
+ return 1;
262
+ }
263
+
264
+ function signalExitCode(signal: number | string): number {
265
+ if (typeof signal === 'number') {
266
+ return 128 + signal;
267
+ }
268
+ const signals: Record<string, number> = {
269
+ SIGHUP: 1,
270
+ SIGINT: 2,
271
+ SIGQUIT: 3,
272
+ SIGILL: 4,
273
+ SIGTRAP: 5,
274
+ SIGABRT: 6,
275
+ SIGBUS: 7,
276
+ SIGFPE: 8,
277
+ SIGKILL: 9,
278
+ SIGUSR1: 10,
279
+ SIGSEGV: 11,
280
+ SIGUSR2: 12,
281
+ SIGPIPE: 13,
282
+ SIGALRM: 14,
283
+ SIGTERM: 15
284
+ };
285
+ return 128 + (signals[signal] ?? 0);
286
+ }
@@ -0,0 +1,91 @@
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
+ // Quick "is osascript callable at all" probe used by available(). Not for
8
+ // clipboard work — clipboard work shares READ_IMAGE_TIMEOUT_MS below.
9
+ // Generous 2s budget: this is a once-per-session bridge enablement check;
10
+ // failing it just disables the clipboard bridge for that session, so we'd
11
+ // rather tolerate a cold osascript spawn than misreport "unavailable".
12
+ const OSASCRIPT_PROBE_TIMEOUT_MS = 2_000;
13
+ const READ_IMAGE_TIMEOUT_MS = 5_000;
14
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
15
+
16
+ type ExecFn = (cmd: string, args: string[], options?: ExecFileSyncOptions) => Buffer | string;
17
+
18
+ export type DarwinClipboardAdapter = {
19
+ available(): { ok: true } | { ok: false; reason: string };
20
+ // Returns PNG bytes when the clipboard holds (or can synthesize) an image,
21
+ // null otherwise. No separate hasImage(): a probing `clipboard info` call
22
+ // forces NSPasteboard to materialize TIFF/BMP/8BPS representations to
23
+ // report their sizes, which can take seconds when the clipboard holds a
24
+ // Retina screenshot. Letting AppleScript's `as «class PNGf»` either succeed
25
+ // (image present, possibly auto-converted from TIFF/JPEG/GIF) or error
26
+ // (nothing PNG-coercible) keeps the path O(PNG size) regardless of how
27
+ // many other representations the source declared.
28
+ readImagePng(): Buffer | null;
29
+ };
30
+
31
+ export function createDarwinClipboardAdapter({
32
+ execFn = execFileSync,
33
+ mkdtempFn = fs.mkdtempSync,
34
+ readFileFn = fs.readFileSync,
35
+ rmFn = fs.rmSync
36
+ }: {
37
+ execFn?: ExecFn;
38
+ mkdtempFn?: typeof fs.mkdtempSync;
39
+ readFileFn?: typeof fs.readFileSync;
40
+ rmFn?: typeof fs.rmSync;
41
+ } = {}): DarwinClipboardAdapter {
42
+ return {
43
+ available() {
44
+ try {
45
+ execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout: OSASCRIPT_PROBE_TIMEOUT_MS });
46
+ return { ok: true };
47
+ } catch {
48
+ return { ok: false, reason: 'macOS osascript is unavailable' };
49
+ }
50
+ },
51
+ readImagePng() {
52
+ const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
53
+ const outputPath = path.join(tmpDir, 'clipboard.png');
54
+ try {
55
+ try {
56
+ execFn('osascript', ['-e', pngWriteScript(outputPath)], {
57
+ encoding: 'utf8',
58
+ timeout: READ_IMAGE_TIMEOUT_MS
59
+ });
60
+ } catch {
61
+ execFn('pngpaste', [outputPath], {
62
+ encoding: 'utf8',
63
+ timeout: READ_IMAGE_TIMEOUT_MS
64
+ });
65
+ }
66
+ const png = Buffer.from(readFileFn(outputPath));
67
+ return isPng(png) ? png : null;
68
+ } catch {
69
+ return null;
70
+ } finally {
71
+ rmFn(tmpDir, { recursive: true, force: true });
72
+ }
73
+ }
74
+ };
75
+ }
76
+
77
+ function isPng(buffer: Buffer): boolean {
78
+ return buffer.length >= PNG_MAGIC.length && PNG_MAGIC.every((byte, index) => buffer[index] === byte);
79
+ }
80
+
81
+ function pngWriteScript(outputPath: string): string {
82
+ const escapedPath = outputPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
83
+ return [
84
+ 'set pngData to the clipboard as «class PNGf»',
85
+ `set outFile to POSIX file "${escapedPath}"`,
86
+ 'set fileRef to open for access outFile with write permission',
87
+ 'set eof fileRef to 0',
88
+ 'write pngData to fileRef',
89
+ 'close access fileRef'
90
+ ].join('\n');
91
+ }
@@ -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';
@@ -47,6 +48,7 @@ import {
47
48
  redactCommandError,
48
49
  validateClaudeCredentialsEnvOverride
49
50
  } from '../credentials.ts';
51
+ import { detectHostTimezone } from '../host-timezone.ts';
50
52
 
51
53
  const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
52
54
  const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
@@ -127,7 +129,17 @@ function resolveToolDirs(config: Pick<SandboxCreateConfig, 'project'>, tools: Sa
127
129
  }
128
130
 
129
131
  export function hostShellConfigDir(home: string, project: string, branch: string): string {
130
- return hostJoin(home, '.agent-infra', 'config', project, sanitizeBranchName(branch));
132
+ return shellConfigDir(
133
+ { shellConfigBase: hostJoin(home, '.agent-infra', 'config', project) },
134
+ branch
135
+ );
136
+ }
137
+
138
+ export function buildClipboardVolumeArgs(engine: string, home: string): string[] {
139
+ return [
140
+ '-v',
141
+ volumeArg(engine, clipboardHostDir(home), CONTAINER_CLIPBOARD_MOUNT, ':ro')
142
+ ];
131
143
  }
132
144
 
133
145
  function runtimeChecks(runtimes: string[]): RuntimeCheck[] {
@@ -1344,6 +1356,7 @@ export async function create(args: string[]): Promise<void> {
1344
1356
  fs.mkdirSync(workspaceDir, { recursive: true });
1345
1357
  fs.mkdirSync(shareCommon, { recursive: true });
1346
1358
  fs.mkdirSync(shareBranch, { recursive: true });
1359
+ fs.mkdirSync(clipboardHostDir(effectiveConfig.home), { recursive: true, mode: 0o700 });
1347
1360
 
1348
1361
  const dotfilesSnapshot = materializeDotfiles(
1349
1362
  effectiveConfig.dotfilesDir,
@@ -1352,6 +1365,8 @@ export async function create(args: string[]): Promise<void> {
1352
1365
  const dotfilesMount = dotfilesSnapshot
1353
1366
  ? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
1354
1367
  : [];
1368
+ const hostTz = detectHostTimezone();
1369
+ const tzFlags = hostTz ? ['-e', `TZ=${hostTz}`] : [];
1355
1370
 
1356
1371
  runEngineTaskCommand(engine, 'docker', [
1357
1372
  'run',
@@ -1372,6 +1387,7 @@ export async function create(args: string[]): Promise<void> {
1372
1387
  volumeArg(engine, shareCommon, '/share/common'),
1373
1388
  '-v',
1374
1389
  volumeArg(engine, shareBranch, '/share/branch'),
1390
+ ...buildClipboardVolumeArgs(engine, effectiveConfig.home),
1375
1391
  '-v',
1376
1392
  volumeArg(
1377
1393
  engine,
@@ -1385,6 +1401,7 @@ export async function create(args: string[]): Promise<void> {
1385
1401
  ...liveMountVolumes,
1386
1402
  ...shellConfigVolumes,
1387
1403
  ...envFile.dockerArgs,
1404
+ ...tzFlags,
1388
1405
  '-w',
1389
1406
  '/workspace',
1390
1407
  effectiveConfig.imageName