@fitlab-ai/agent-infra 0.6.3 → 0.6.5

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 (40) hide show
  1. package/README.md +12 -2
  2. package/README.zh-CN.md +12 -2
  3. package/bin/cli.ts +18 -6
  4. package/dist/bin/cli.js +20 -6
  5. package/dist/lib/cp.js +127 -0
  6. package/dist/lib/sandbox/clipboard/bridge.js +30 -9
  7. package/dist/lib/sandbox/clipboard/darwin.js +7 -14
  8. package/dist/lib/sandbox/clipboard/index.js +12 -3
  9. package/dist/lib/sandbox/commands/create.js +11 -0
  10. package/dist/lib/sandbox/commands/enter.js +20 -3
  11. package/dist/lib/sandbox/commands/rebuild.js +11 -5
  12. package/dist/lib/sandbox/host-timezone.js +33 -0
  13. package/dist/lib/sandbox/index.js +4 -3
  14. package/dist/lib/sandbox/readme-scaffold.js +148 -0
  15. package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +2 -0
  16. package/dist/lib/sandbox/runtimes/base.dockerfile +24 -19
  17. package/lib/cp.ts +177 -0
  18. package/lib/sandbox/clipboard/bridge.ts +30 -10
  19. package/lib/sandbox/clipboard/darwin.ts +15 -14
  20. package/lib/sandbox/clipboard/index.ts +12 -3
  21. package/lib/sandbox/commands/create.ts +12 -0
  22. package/lib/sandbox/commands/enter.ts +41 -3
  23. package/lib/sandbox/commands/rebuild.ts +15 -5
  24. package/lib/sandbox/host-timezone.ts +42 -0
  25. package/lib/sandbox/index.ts +4 -3
  26. package/lib/sandbox/readme-scaffold.ts +177 -0
  27. package/lib/sandbox/runtimes/ai-tools.dockerfile +2 -0
  28. package/lib/sandbox/runtimes/base.dockerfile +24 -19
  29. package/package.json +7 -7
  30. package/templates/.agents/rules/create-issue.github.en.md +19 -1
  31. package/templates/.agents/rules/create-issue.github.zh-CN.md +19 -1
  32. package/templates/.agents/rules/milestone-inference.github.en.md +12 -0
  33. package/templates/.agents/rules/milestone-inference.github.zh-CN.md +12 -0
  34. package/templates/.agents/rules/testing-discipline.en.md +44 -0
  35. package/templates/.agents/rules/testing-discipline.zh-CN.md +44 -0
  36. package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
  37. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +2 -0
  38. package/templates/.agents/skills/create-task/config/verify.json +1 -0
  39. package/templates/.agents/skills/import-issue/SKILL.en.md +1 -1
  40. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
@@ -3,12 +3,13 @@ const USAGE = `Usage: ai sandbox <command> [options]
3
3
  Commands:
4
4
  create <branch> [base] Create a sandbox (VM + image + worktree + container)
5
5
  exec <branch> [cmd...] Enter sandbox or run a command
6
- refresh Sync host Claude Code credentials to all sandbox copies
7
6
  ls List sandboxes for the current project
8
- rm <branch> [--all] Remove a sandbox or all sandboxes
9
7
  prune [--dry-run] Remove orphaned per-branch state dirs
8
+ rebuild [--quiet] [--refresh]
9
+ Rebuild the sandbox image (--refresh pulls base + tools)
10
+ refresh Sync host Claude Code credentials to all sandbox copies
11
+ rm <branch> [--all] Remove a sandbox or all sandboxes
10
12
  vm status|start|stop Manage the sandbox VM (macOS) or check the backend (Windows)
11
- rebuild [--quiet] Rebuild the sandbox image
12
13
 
13
14
  Run 'ai sandbox <command> --help' for details.`;
14
15
  export async function runSandbox(args) {
@@ -0,0 +1,148 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { shareBranchDir, shareCommonDir } from "./constants.js";
4
+ const DOTFILES_README = `# User-level dotfiles channel
5
+
6
+ This directory is mounted **read-only** into every sandbox container at
7
+ \`/dotfiles\`. On entry, \`sandbox-dotfiles-link\` mirrors every file here as a
8
+ symlink under \`$HOME\` (e.g. \`.tmux.conf\` -> \`/home/devuser/.tmux.conf\`),
9
+ overriding image defaults so your editor, shell, and tool preferences follow
10
+ you across \`ai sandbox destroy + create\`.
11
+
12
+ See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#user-level-dotfiles-channel
13
+
14
+ Common usage - drop files or symlinks here:
15
+
16
+ \`\`\`sh
17
+ # Real files
18
+ echo "set -g mouse on" > ~/.agent-infra/dotfiles/.tmux.conf
19
+
20
+ # Symlinks to live host paths
21
+ ln -s ~/.tmux.conf ~/.agent-infra/dotfiles/.tmux.conf
22
+ ln -s ~/.config/lazygit ~/.agent-infra/dotfiles/.config/lazygit
23
+ \`\`\`
24
+
25
+ > Do **not** put secrets here. Use the dedicated SSH / credential mounts.
26
+
27
+ If you delete this file, the next \`ai sandbox create\` will re-create it
28
+ verbatim. To stop seeing it, edit or empty the file in place - the scaffold
29
+ only writes \`README.md\` when it is missing, never when it already exists.
30
+
31
+ ---
32
+
33
+ # 用户级 dotfiles 通道
34
+
35
+ 该目录被以**只读**方式挂载到每个 sandbox 容器的 \`/dotfiles\`。容器启动时,
36
+ \`sandbox-dotfiles-link\` 会把这里的每个文件 \`ln -sfn\` 到 \`$HOME\` 对应路径
37
+ (例如 \`.tmux.conf -> /home/devuser/.tmux.conf\`),覆盖镜像默认值,让你的编辑器、
38
+ shell、工具偏好跨 \`ai sandbox destroy + create\` 持久存在。
39
+
40
+ 参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#用户级-dotfiles-通道
41
+
42
+ 常见用法:把文件或符号链接放进来:
43
+
44
+ \`\`\`sh
45
+ # 直接放文件
46
+ echo "set -g mouse on" > ~/.agent-infra/dotfiles/.tmux.conf
47
+
48
+ # 用符号链接指向 host 实际文件
49
+ ln -s ~/.tmux.conf ~/.agent-infra/dotfiles/.tmux.conf
50
+ ln -s ~/.config/lazygit ~/.agent-infra/dotfiles/.config/lazygit
51
+ \`\`\`
52
+
53
+ > **不要**在此放任何凭证。SSH / 凭证请使用专用挂载通道。
54
+
55
+ 如果你删除该文件,下一次 \`ai sandbox create\` 会原样重新生成。如果你不想再
56
+ 看到它,**就地编辑或清空内容**即可:scaffold 仅在 \`README.md\` **缺失**时
57
+ 写入,文件存在(哪怕被清空)就不会被重写。
58
+ `;
59
+ const SHARE_COMMON_README = `# /share/common - host <-> sandbox shared scratch (cross-branch)
60
+
61
+ This directory is mounted **read-write** into every sandbox container of this
62
+ project at \`/share/common\`, regardless of branch. Drop files here to share
63
+ between host and any sandbox without polluting the git worktree.
64
+
65
+ See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#host-sandbox-file-exchange
66
+
67
+ This file is safe to delete; the next \`ai sandbox create\` will re-create it.
68
+
69
+ ---
70
+
71
+ # /share/common - 宿主 <-> sandbox 共享暂存(跨分支)
72
+
73
+ 该目录被以**读写**方式挂载到本项目所有 sandbox 容器的 \`/share/common\`,
74
+ 跨分支可见。可用来在宿主和任意 sandbox 之间传文件,无需弄脏 git worktree。
75
+
76
+ 参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#宿主-沙箱文件交换
77
+
78
+ 该文件可以安全删除;下一次 \`ai sandbox create\` 会重新生成。
79
+ `;
80
+ const SHARE_BRANCH_README = `# /share/branch - host <-> sandbox shared scratch (branch-exclusive)
81
+
82
+ This directory is mounted **read-write** into the sandbox container of this
83
+ project's current branch at \`/share/branch\`. Files here are exclusive to this
84
+ branch's sandbox and do not leak across branches.
85
+
86
+ See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#host-sandbox-file-exchange
87
+
88
+ This file is safe to delete; the next \`ai sandbox create\` will re-create it.
89
+
90
+ ---
91
+
92
+ # /share/branch - 宿主 <-> sandbox 共享暂存(分支独占)
93
+
94
+ 该目录被以**读写**方式挂载到本项目当前分支 sandbox 容器的 \`/share/branch\`,
95
+ 仅当前分支可见,不会跨分支泄漏。
96
+
97
+ 参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#宿主-沙箱文件交换
98
+
99
+ 该文件可以安全删除;下一次 \`ai sandbox create\` 会重新生成。
100
+ `;
101
+ function errorDetail(error) {
102
+ return error instanceof Error ? error.message : 'unknown error';
103
+ }
104
+ function errorCode(error) {
105
+ return typeof error === 'object' && error !== null && 'code' in error
106
+ ? String(error.code)
107
+ : '';
108
+ }
109
+ function ensureFile(target, content, options) {
110
+ const writeStderr = options.writeStderr ?? ((chunk) => process.stderr.write(chunk));
111
+ const fsModule = options.fsModule ?? fs;
112
+ const result = { created: false, path: target };
113
+ try {
114
+ fsModule.mkdirSync(path.dirname(target), { recursive: true });
115
+ }
116
+ catch (error) {
117
+ writeStderr(`sandbox-readme-scaffold: skipping ${target} (${errorDetail(error)})\n`);
118
+ return result;
119
+ }
120
+ try {
121
+ fsModule.writeFileSync(target, content, { encoding: 'utf8', flag: 'wx' });
122
+ result.created = true;
123
+ }
124
+ catch (error) {
125
+ if (errorCode(error) === 'EEXIST') {
126
+ return result;
127
+ }
128
+ writeStderr(`sandbox-readme-scaffold: skipping ${target} (${errorDetail(error)})\n`);
129
+ }
130
+ return result;
131
+ }
132
+ export function ensureDotfilesReadme(dotfilesDir, options = {}) {
133
+ return ensureFile(path.join(dotfilesDir, 'README.md'), DOTFILES_README, options);
134
+ }
135
+ export function ensureShareCommonReadme(config, options = {}) {
136
+ return ensureFile(path.join(shareCommonDir(config), 'README.md'), SHARE_COMMON_README, options);
137
+ }
138
+ export function ensureShareBranchReadme(config, branch, options = {}) {
139
+ return ensureFile(path.join(shareBranchDir(config, branch), 'README.md'), SHARE_BRANCH_README, options);
140
+ }
141
+ export function ensureSandboxDiscoveryReadmes(config, branch, options = {}) {
142
+ return [
143
+ ensureDotfilesReadme(config.dotfilesDir, options),
144
+ ensureShareCommonReadme(config, options),
145
+ ensureShareBranchReadme(config, branch, options)
146
+ ];
147
+ }
148
+ //# sourceMappingURL=readme-scaffold.js.map
@@ -1,4 +1,6 @@
1
1
  USER devuser
2
+ ENV DISABLE_UPDATES=1
3
+ ENV OPENCODE_DISABLE_AUTOUPDATE=1
2
4
  ENV NPM_CONFIG_PREFIX=/home/devuser/.npm-global
3
5
  ENV PATH="/home/devuser/.npm-global/bin:${PATH}"
4
6
 
@@ -1,9 +1,8 @@
1
- FROM ubuntu:22.04
1
+ FROM ubuntu:24.04
2
2
 
3
3
  LABEL description="AI coding sandbox"
4
4
 
5
5
  ENV DEBIAN_FRONTEND=noninteractive
6
- ENV TZ=Asia/Shanghai
7
6
 
8
7
  ARG HOST_UID=1000
9
8
  ARG HOST_GID=1000
@@ -22,14 +21,14 @@ RUN apt-get update && apt-get install -y \
22
21
  build-essential ca-certificates gnupg lsb-release \
23
22
  libevent-core-2.1-7 libncursesw6 libtinfo6 \
24
23
  pkg-config bison libevent-dev libncurses-dev \
25
- locales \
24
+ locales tzdata \
26
25
  && locale-gen en_US.UTF-8 \
27
26
  && (curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
28
27
  | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg) \
29
28
  && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
30
29
  > /etc/apt/sources.list.d/github-cli.list \
31
30
  && apt-get update && apt-get install -y gh \
32
- && TMUX_VERSION=3.6a \
31
+ && TMUX_VERSION=3.6b \
33
32
  && wget -qO /tmp/tmux.tar.gz \
34
33
  "https://github.com/tmux/tmux/releases/download/${TMUX_VERSION}/tmux-${TMUX_VERSION}.tar.gz" \
35
34
  && tar xzf /tmp/tmux.tar.gz -C /tmp \
@@ -45,13 +44,13 @@ RUN apt-get update && apt-get install -y \
45
44
  && rm -rf /var/lib/apt/lists/*
46
45
 
47
46
  # Enable extended keys in CSI u format so Shift+Enter and other modified
48
- # keys are forwarded through tmux. Preserve terminal-detection variables
47
+ # keys are forwarded through tmux. Preserve terminal/timezone variables
49
48
  # injected at `docker exec` time when new tmux sessions are created.
50
49
  RUN printf '%s\n' \
51
50
  'set -g extended-keys always' \
52
51
  'set -g extended-keys-format csi-u' \
53
52
  "set -as terminal-features 'xterm*:extkeys'" \
54
- "set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION'" \
53
+ "set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION TZ'" \
55
54
  'set -g mouse on' \
56
55
  'set -g status-interval 1' \
57
56
  'set -g status-right-length 80' \
@@ -127,7 +126,7 @@ find . -type f -print | while IFS= read -r rel; do
127
126
  .config/opencode|.config/opencode/*|\
128
127
  .local/share/opencode|.local/share/opencode/*|\
129
128
  .host-shell-config|.host-shell-config/*|\
130
- .gitconfig|.gitignore_global|.stCommitMsg|.bash_aliases)
129
+ .gitconfig|.gitignore_global|.stCommitMsg|.bash_aliases|README.md)
131
130
  continue ;;
132
131
  esac
133
132
 
@@ -146,7 +145,7 @@ RUN cat > /usr/local/bin/sandbox-tmux-entry <<'SCRIPT' && chmod +x /usr/local/bi
146
145
  #!/bin/sh
147
146
  set -eu
148
147
 
149
- sandbox-dotfiles-link >/dev/null || true
148
+ sandbox-dotfiles-link >/dev/null 2>&1 || true
150
149
 
151
150
  SESSION=work
152
151
 
@@ -154,20 +153,26 @@ if ! command -v tmux >/dev/null 2>&1; then
154
153
  exec bash
155
154
  fi
156
155
 
157
- if ! tmux has-session -t "$SESSION" 2>/dev/null; then
158
- exec tmux new-session -s "$SESSION"
159
- fi
160
-
161
- tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | \
162
- while read -r name attached; do
163
- [ "$name" = "$SESSION" ] && continue
156
+ # Drop stale grouped sessions left by older entry-script versions (the windows
157
+ # live on $SESSION, so killing the group members only removes view entries).
158
+ tmux list-sessions -F '#{session_name}' 2>/dev/null | while IFS= read -r name; do
164
159
  case "$name" in
165
- ''|*[!0-9]*) continue ;;
160
+ "$SESSION"-*) tmux kill-session -t "$name" 2>/dev/null || true ;;
166
161
  esac
167
- [ "$attached" = "0" ] && tmux kill-session -t "$name" 2>/dev/null || true
168
- done
162
+ done
163
+
164
+ # Reuse the single $SESSION; -d detaches any pre-existing client so the new
165
+ # one becomes the sole owner of window-size, eliminating size races.
166
+ if tmux has-session -t "$SESSION" 2>/dev/null; then
167
+ # Push the per-exec TZ into the running session's env so new
168
+ # windows/panes pick up the host timezone without a session kill.
169
+ if [ -n "${TZ:-}" ]; then
170
+ tmux set-environment -t "$SESSION" TZ "$TZ" 2>/dev/null || true
171
+ fi
172
+ exec tmux attach -d -t "$SESSION"
173
+ fi
169
174
 
170
- exec tmux new-session -t "$SESSION"
175
+ exec tmux new-session -s "$SESSION"
171
176
  SCRIPT
172
177
 
173
178
  ENV LANG=en_US.UTF-8
package/lib/cp.ts ADDED
@@ -0,0 +1,177 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import { platform as currentPlatform, tmpdir as defaultTmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { parseArgs } from 'node:util';
7
+ import { createClipboardAdapter, type ClipboardAdapter } from './sandbox/clipboard/index.ts';
8
+
9
+ const USAGE = 'Usage: ai cp <ssh-alias>\n\nCopy the local clipboard image (PNG) to a remote macOS NSPasteboard over ssh/scp.\n';
10
+ const COMMAND_TIMEOUT_MS = 30_000;
11
+
12
+ export type SpawnResult = {
13
+ status: number | null;
14
+ stdout: string;
15
+ stderr: string;
16
+ error?: Error;
17
+ };
18
+
19
+ type SpawnFn = (cmd: string, args: string[], input?: string) => SpawnResult;
20
+ type CreateAdapterFn = (options?: { platformName?: NodeJS.Platform }) => ClipboardAdapter | null;
21
+ type MkDTempFn = (prefix: string) => string;
22
+ type WriteFileFn = (file: string, data: Buffer) => void;
23
+ type RmFn = (target: string, options: { recursive: boolean; force: boolean }) => void;
24
+
25
+ export type CpDeps = {
26
+ platform?: NodeJS.Platform;
27
+ createAdapter?: CreateAdapterFn;
28
+ spawnFn?: SpawnFn;
29
+ randomId?: () => string;
30
+ mkdtempFn?: MkDTempFn;
31
+ writeFileFn?: WriteFileFn;
32
+ rmFn?: RmFn;
33
+ tmpdir?: () => string;
34
+ writeStdout?: (chunk: string) => unknown;
35
+ writeStderr?: (chunk: string) => unknown;
36
+ };
37
+
38
+ export function runCommand(cmd: string, args: string[], input?: string): SpawnResult {
39
+ const result = spawnSync(cmd, args, {
40
+ input,
41
+ encoding: 'utf8',
42
+ stdio: ['pipe', 'pipe', 'pipe'],
43
+ timeout: COMMAND_TIMEOUT_MS
44
+ });
45
+
46
+ return {
47
+ status: result.status,
48
+ stdout: result.stdout ?? '',
49
+ stderr: result.stderr ?? '',
50
+ error: result.error
51
+ };
52
+ }
53
+
54
+ export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number> {
55
+ const {
56
+ platform = currentPlatform(),
57
+ createAdapter = createClipboardAdapter,
58
+ spawnFn = runCommand,
59
+ randomId = randomUUID,
60
+ mkdtempFn = fs.mkdtempSync,
61
+ writeFileFn = fs.writeFileSync,
62
+ rmFn = fs.rmSync,
63
+ tmpdir = defaultTmpdir,
64
+ writeStdout = (chunk: string) => process.stdout.write(chunk),
65
+ writeStderr = (chunk: string) => process.stderr.write(chunk)
66
+ } = deps;
67
+
68
+ if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
69
+ writeStdout(USAGE);
70
+ return 0;
71
+ }
72
+
73
+ let positionals: string[];
74
+ try {
75
+ ({ positionals } = parseArgs({ args, allowPositionals: true, strict: true }));
76
+ } catch {
77
+ writeStderr(USAGE);
78
+ return 1;
79
+ }
80
+
81
+ const alias = positionals[0];
82
+ if (!alias || positionals.length !== 1) {
83
+ writeStderr(USAGE);
84
+ return 1;
85
+ }
86
+ if (alias.startsWith('-')) {
87
+ writeStderr(`invalid ssh alias '${alias}': must not start with '-'\n`);
88
+ return 1;
89
+ }
90
+
91
+ if (platform !== 'darwin') {
92
+ writeStderr(`ai cp currently supports macOS senders only (got ${platform})\n`);
93
+ return 1;
94
+ }
95
+
96
+ const adapter = createAdapter({ platformName: platform });
97
+ const png = adapter?.readImagePng() ?? null;
98
+ if (png === null) {
99
+ writeStderr('no image on clipboard\n');
100
+ return 1;
101
+ }
102
+
103
+ let uploaded = false;
104
+ let localTmpDir: string | null = null;
105
+ let remotePath: string | null = null;
106
+
107
+ try {
108
+ localTmpDir = mkdtempFn(path.join(tmpdir(), 'agent-infra-cp-'));
109
+ const localPng = path.join(localTmpDir, 'clipboard.png');
110
+ writeFileFn(localPng, png);
111
+
112
+ remotePath = `/tmp/agent-infra-cp-${randomId()}.png`;
113
+ const upload = spawnFn('scp', [
114
+ '-o',
115
+ 'BatchMode=yes',
116
+ '-o',
117
+ 'ConnectTimeout=10',
118
+ localPng,
119
+ `${alias}:${remotePath}`
120
+ ]);
121
+ if (upload.status !== 0) {
122
+ writeStderr(`failed to upload image to ${alias}:\n${commandDetail(upload)}\n`);
123
+ return 1;
124
+ }
125
+ uploaded = true;
126
+
127
+ // Remote write currently targets macOS only: it pipes an AppleScript to the
128
+ // remote `osascript` to set its NSPasteboard. This is the extension point for
129
+ // other remote platforms later (e.g. dispatch on remote OS to wl-copy/xclip
130
+ // on Linux); a non-macOS remote fails here with a clear non-zero error today.
131
+ const setRemote = spawnFn('ssh', [
132
+ '-o',
133
+ 'BatchMode=yes',
134
+ '-o',
135
+ 'ConnectTimeout=10',
136
+ alias,
137
+ 'osascript',
138
+ '-'
139
+ ], remoteSetScript(remotePath));
140
+ if (setRemote.status !== 0) {
141
+ writeStderr(`failed to set remote clipboard on ${alias}:\n${commandDetail(setRemote)}\n`);
142
+ return 1;
143
+ }
144
+
145
+ writeStdout(`copied clipboard image to ${alias}\n`);
146
+ return 0;
147
+ } finally {
148
+ if (uploaded && remotePath) {
149
+ spawnFn('ssh', [
150
+ '-o',
151
+ 'BatchMode=yes',
152
+ '-o',
153
+ 'ConnectTimeout=10',
154
+ alias,
155
+ 'rm',
156
+ '-f',
157
+ remotePath
158
+ ]);
159
+ }
160
+ if (localTmpDir) {
161
+ rmFn(localTmpDir, { recursive: true, force: true });
162
+ }
163
+ }
164
+ }
165
+
166
+ function commandDetail(result: SpawnResult): string {
167
+ const detail = result.stderr || result.error?.message || result.stdout || 'unknown error';
168
+ return detail.trimEnd();
169
+ }
170
+
171
+ function remoteSetScript(remotePath: string): string {
172
+ const escapedPath = remotePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
173
+ return [
174
+ `set theFile to POSIX file "${escapedPath}"`,
175
+ 'set the clipboard to (read theFile as «class PNGf»)'
176
+ ].join('\n');
177
+ }
@@ -1,4 +1,5 @@
1
1
  import { StringDecoder } from 'node:string_decoder';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import { createClipboardAdapter, type ClipboardAdapter } from './index.ts';
3
4
  import { buildBracketedPaste, CtrlVDetector, type CtrlVMatch } from './keys.ts';
4
5
  import {
@@ -32,6 +33,26 @@ type BridgeOptions = {
32
33
  const FALLBACK_PREFIX = 'Warning: clipboard image paste bridge disabled';
33
34
  const PARTIAL_ESCAPE_FLUSH_MS = 30;
34
35
 
36
+ // Node's stdin.setRawMode(true) uses libuv's RAW mode, which (unlike the
37
+ // cfmakeraw that `docker exec -it` applies on the non-bridge path) keeps ONLCR
38
+ // set on the shared host TTY. With ONLCR on, the kernel rewrites the bare \n
39
+ // that tmux emits after homing the cursor inside the right pane into \r\n,
40
+ // snapping the cursor to column 1 so the following erase/redraw wipes the left
41
+ // pane. Clearing OPOST brings the host TTY in line with the non-bridge path.
42
+ // Best-effort: setRawMode(false) on teardown restores the original termios, and
43
+ // a missing/failed stty only reinstates the redraw glitch.
44
+ function disableOutputPostProcessing(stdin: NodeJS.ReadStream): void {
45
+ const candidate = (stdin as { fd?: unknown }).fd;
46
+ if (typeof candidate !== 'number') {
47
+ return;
48
+ }
49
+ try {
50
+ spawnSync('stty', ['-opost'], { stdio: [candidate, 'ignore', 'ignore'] });
51
+ } catch {
52
+ // stty unavailable or fd is not a tty; leave the terminal as-is.
53
+ }
54
+ }
55
+
35
56
  export async function runInteractiveWithClipboardBridge(options: BridgeOptions): Promise<number> {
36
57
  const {
37
58
  engine,
@@ -56,14 +77,11 @@ export async function runInteractiveWithClipboardBridge(options: BridgeOptions):
56
77
  return runInteractive(engine, 'docker', dockerArgs);
57
78
  }
58
79
 
59
- if (platformName !== 'darwin') {
60
- return runInteractive(engine, 'docker', dockerArgs);
61
- }
62
80
  if (!stdin.isTTY || !stdout.isTTY) {
63
81
  return fallback('host stdin/stdout is not a TTY');
64
82
  }
65
83
  if (!adapter) {
66
- return fallback('macOS clipboard adapter is unavailable');
84
+ return fallback('no clipboard adapter available on this platform');
67
85
  }
68
86
  const available = adapter.available();
69
87
  if (!available.ok) {
@@ -153,14 +171,15 @@ async function runBridge({
153
171
 
154
172
  function handleCtrlV(match: CtrlVMatch, target: PtyProcess): void {
155
173
  try {
156
- if (!adapter.hasImage()) {
157
- target.write(match.raw);
158
- return;
159
- }
160
-
174
+ // readImagePng returns null both for "no image on clipboard" and for
175
+ // unexpected read failures; both cases forward the original Ctrl+V so
176
+ // the container app handles it as a regular keystroke. The throw branch
177
+ // below only fires on truly unexpected exceptions (e.g. fs write
178
+ // errors writing to the host clipboard dir).
161
179
  const png = adapter.readImagePng();
162
180
  if (!png) {
163
- throw new Error('clipboard image could not be read');
181
+ target.write(match.raw);
182
+ return;
164
183
  }
165
184
  const filename = pngClipboardFilename(png);
166
185
  writeClipboardPngAtomic(clipboardHostDir(home), filename, png);
@@ -184,6 +203,7 @@ async function runBridge({
184
203
 
185
204
  try {
186
205
  stdin.setRawMode?.(true);
206
+ disableOutputPostProcessing(stdin);
187
207
  stdin.resume();
188
208
  stdin.on('data', onData);
189
209
  stdout.on('resize', onResize);
@@ -4,7 +4,12 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import type { ExecFileSyncOptions } from 'node:child_process';
6
6
 
7
- const HAS_IMAGE_TIMEOUT_MS = 500;
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;
8
13
  const READ_IMAGE_TIMEOUT_MS = 5_000;
9
14
  const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
10
15
 
@@ -12,7 +17,14 @@ type ExecFn = (cmd: string, args: string[], options?: ExecFileSyncOptions) => Bu
12
17
 
13
18
  export type DarwinClipboardAdapter = {
14
19
  available(): { ok: true } | { ok: false; reason: string };
15
- hasImage(): boolean;
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.
16
28
  readImagePng(): Buffer | null;
17
29
  };
18
30
 
@@ -30,23 +42,12 @@ export function createDarwinClipboardAdapter({
30
42
  return {
31
43
  available() {
32
44
  try {
33
- execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout: HAS_IMAGE_TIMEOUT_MS });
45
+ execFn('osascript', ['-e', 'return "ok"'], { encoding: 'utf8', timeout: OSASCRIPT_PROBE_TIMEOUT_MS });
34
46
  return { ok: true };
35
47
  } catch {
36
48
  return { ok: false, reason: 'macOS osascript is unavailable' };
37
49
  }
38
50
  },
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
51
  readImagePng() {
51
52
  const tmpDir = mkdtempFn(path.join(os.tmpdir(), 'agent-infra-clipboard-'));
52
53
  const outputPath = path.join(tmpDir, 'clipboard.png');
@@ -6,8 +6,17 @@ export type ClipboardAdapter = DarwinClipboardAdapter;
6
6
  export function createClipboardAdapter({
7
7
  platformName = platform()
8
8
  }: { platformName?: NodeJS.Platform } = {}): ClipboardAdapter | null {
9
- if (platformName !== 'darwin') {
10
- return null;
9
+ switch (platformName) {
10
+ case 'darwin':
11
+ return createDarwinClipboardAdapter();
12
+ case 'linux':
13
+ // Future work: dispatch based on $WAYLAND_DISPLAY (wl-paste) or $DISPLAY (xclip);
14
+ // see Issue #386 follow-up. Returning null disables the bridge for now.
15
+ return null;
16
+ case 'win32':
17
+ // Future work: native Win32 clipboard reader. Returning null disables the bridge.
18
+ return null;
19
+ default:
20
+ return null;
11
21
  }
12
- return createDarwinClipboardAdapter();
13
22
  }
@@ -43,11 +43,13 @@ import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.
43
43
  import { validateSelinuxDisableEnv } from '../engines/selinux.ts';
44
44
  import { resolveBuildUid } from '../engines/native.ts';
45
45
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
46
+ import { ensureSandboxDiscoveryReadmes } from '../readme-scaffold.ts';
46
47
  import {
47
48
  prepareClaudeCredentials,
48
49
  redactCommandError,
49
50
  validateClaudeCredentialsEnvOverride
50
51
  } from '../credentials.ts';
52
+ import { detectHostTimezone } from '../host-timezone.ts';
51
53
 
52
54
  const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
53
55
  const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
@@ -1286,6 +1288,13 @@ export async function create(args: string[]): Promise<void> {
1286
1288
  message(`Created default sandbox aliases at ${aliasesFile.path}`);
1287
1289
  }
1288
1290
 
1291
+ const readmeResults = ensureSandboxDiscoveryReadmes(effectiveConfig, branch);
1292
+ for (const { created, path: readmePath } of readmeResults) {
1293
+ if (created) {
1294
+ message(`Created discovery README at ${readmePath}`);
1295
+ }
1296
+ }
1297
+
1289
1298
  const gitconfigPath = path.join(effectiveConfig.home, '.gitconfig');
1290
1299
  const gitconfigContent = fs.existsSync(gitconfigPath)
1291
1300
  ? fs.readFileSync(gitconfigPath, 'utf8')
@@ -1364,6 +1373,8 @@ export async function create(args: string[]): Promise<void> {
1364
1373
  const dotfilesMount = dotfilesSnapshot
1365
1374
  ? buildDotfilesVolumeArgs(engine, dotfilesSnapshot.cacheDir)
1366
1375
  : [];
1376
+ const hostTz = detectHostTimezone();
1377
+ const tzFlags = hostTz ? ['-e', `TZ=${hostTz}`] : [];
1367
1378
 
1368
1379
  runEngineTaskCommand(engine, 'docker', [
1369
1380
  'run',
@@ -1398,6 +1409,7 @@ export async function create(args: string[]): Promise<void> {
1398
1409
  ...liveMountVolumes,
1399
1410
  ...shellConfigVolumes,
1400
1411
  ...envFile.dockerArgs,
1412
+ ...tzFlags,
1401
1413
  '-w',
1402
1414
  '/workspace',
1403
1415
  effectiveConfig.imageName