@fitlab-ai/agent-infra 0.6.4 → 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.
package/README.md CHANGED
@@ -202,9 +202,13 @@ This detects the packaged template version and renders all managed files. The sa
202
202
 
203
203
  The sandbox image also preinstalls `gh`. When `gh auth token` succeeds on the host, `ai sandbox create` injects the token into the container as `GH_TOKEN`, so `gh` commands work inside the sandbox without extra setup.
204
204
 
205
+ `ai sandbox rebuild` keeps Docker's build cache by default, so it quickly retags the sandbox image without refreshing every package. Use `ai sandbox rebuild --refresh` when you want to upgrade the image: it passes `--no-cache --pull` to Docker, pulls the current Ubuntu base image, and reruns the apt, tmux build, and global npm install layers. Claude Code updates are disabled inside the container, and OpenCode startup update checks are disabled; `--refresh` is the routine upgrade path for sandbox-managed tools. Manual `opencode upgrade` remains outside this guard. The default `python3` provided by the Ubuntu 24.04 sandbox base is Python 3.12, so scripts that hard-code Python 3.10 paths may need adjustment.
206
+
205
207
  `ai sandbox exec` also forwards a small terminal-detection whitelist (`TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LC_TERMINAL`, `LC_TERMINAL_VERSION`) into the container. This keeps interactive TUIs aligned with the host terminal for behaviors such as Claude Code's Shift+Enter newline support, without passing through the full host environment.
206
208
 
207
- On macOS, interactive `ai sandbox exec <branch>` sessions can bridge image paste into the sandbox. When you press `Ctrl+V` and the host clipboard currently holds an image, agent-infra reads the image from the host clipboard, writes a PNG under `~/.agent-infra/clipboard/`, and injects the container path as bracketed paste so Claude Code, Codex, Gemini CLI, and OpenCode can attach it. The host clipboard is only read, never rewritten. The bridge is best-effort: existing sandboxes must be rebuilt to receive the `/clipboard` mount, and if the optional pty dependency or clipboard probe is unavailable the session falls back to the normal interactive path.
209
+ On macOS, interactive `ai sandbox exec <branch>` sessions can bridge image paste into the sandbox. When you press `Ctrl+V` and the host clipboard currently holds an image, agent-infra reads the image from the host clipboard, writes a PNG under `~/.agent-infra/clipboard/`, and injects the container path as bracketed paste so Claude Code, Codex, Gemini CLI, and OpenCode can attach it. The host clipboard is only read, never rewritten. The bridge is best-effort: existing sandboxes must be rebuilt to receive the `/clipboard` mount, and if the optional pty dependency or clipboard probe is unavailable the session falls back to the normal interactive path. Set `AI_SANDBOX_NO_CLIPBOARD_BRIDGE=1` to skip the bridge and enter the normal interactive path directly when diagnosing mouse, scrolling, or other input issues.
210
+
211
+ When you run the sandbox from a remote Mac over SSH, use `ai cp <ssh-alias>` on the Mac in front of you to push the local clipboard image to that remote Mac first. Copy an image with Cmd+C, run `ai cp mini`, then return to the existing SSH session and press `Ctrl+V`; the sandbox bridge reads the remote Mac's NSPasteboard and injects the image as usual. This command handles PNG images only and uses non-interactive ssh/scp with key-based authentication. For now both the sender and the remote must be macOS—the remote NSPasteboard is written via `osascript`—but the remote-write step is the natural extension point for other platforms later.
208
212
 
209
213
  `ai sandbox exec` and `ai sandbox refresh` reconcile Claude Code credentials in both directions across the host credential store and every sandbox project copy under `~/.agent-infra/credentials/*`. When a long-running sandbox refreshes OAuth tokens first, the next entry or refresh command writes the freshest valid copy back to the host Keychain or `~/.claude/.credentials.json`; when the host is fresher, it updates the project copies. If every copy is stale, `ai sandbox refresh` probes `claude /status` and asks you to log in only when the probe cannot recover credentials.
210
214
 
@@ -230,6 +234,11 @@ remove only dirs without an active sandbox container.
230
234
  Existing sandboxes pick up these mounts after `ai sandbox rm <branch>` and
231
235
  `ai sandbox create <branch>`.
232
236
 
237
+ On first `ai sandbox create`, agent-infra writes a bilingual `README.md` into
238
+ `~/.agent-infra/share/<project>/common/` and each `branches/<branch>/`
239
+ directory to help you discover these channels. The READMEs are idempotent and
240
+ can be safely deleted; the scaffold only writes them when missing.
241
+
233
242
  ### User-level dotfiles channel
234
243
 
235
244
  `ai sandbox create` also mounts an optional read-only channel for host user preferences:
@@ -294,6 +303,7 @@ target.
294
303
  | `.config/opencode/*`, `.local/share/opencode/*` | OpenCode credentials and data use dedicated bind mounts. |
295
304
  | `.host-shell-config/*` | agent-infra managed shell and Git configuration. |
296
305
  | `.gitconfig`, `.gitignore_global`, `.stCommitMsg`, `.bash_aliases` | agent-infra symlinks these to `.host-shell-config/`, including `safe.directory` and GPG sync state. |
306
+ | `README.md` | agent-infra scaffolds a discoverability README at the dotfiles root on first create; the link hook ignores it so `$HOME/README.md` is not shadowed. |
297
307
 
298
308
  Other existing real directories, such as `~/.config/` or `~/.cache/`, are not
299
309
  replaced by top-level dotfiles. If a file conflicts with one of those
@@ -787,7 +797,7 @@ The generated `.agents/.airc.json` file is the central contract between the boot
787
797
  "project": "my-project",
788
798
  "org": "my-org",
789
799
  "language": "en",
790
- "templateVersion": "v0.6.4",
800
+ "templateVersion": "v0.6.5",
791
801
  "templates": {
792
802
  "sources": [
793
803
  { "type": "local", "path": "~/private-templates" }
package/README.zh-CN.md CHANGED
@@ -202,9 +202,13 @@ CLI 会收集项目元数据,向所有支持的 AI TUI 安装 `update-agent-in
202
202
 
203
203
  沙箱镜像也会预装 `gh`。如果宿主机上的 `gh auth token` 能成功返回 token,`ai sandbox create` 会把它以 `GH_TOKEN` 环境变量注入容器,让你在沙箱里直接使用 `gh`,无需额外登录配置。
204
204
 
205
+ `ai sandbox rebuild` 默认保留 Docker build cache,因此会快速重打沙箱镜像,不会刷新每个软件包。需要升级镜像时使用 `ai sandbox rebuild --refresh`:它会向 Docker 传入 `--no-cache --pull`,重新拉取当前 Ubuntu 基础镜像,并重跑 apt、tmux 编译和全局 npm 安装层。容器内 Claude Code 更新已关闭,OpenCode 启动时更新检查也已关闭;`--refresh` 是沙箱托管工具的常规升级入口。手动 `opencode upgrade` 不受该保护覆盖。Ubuntu 24.04 沙箱基础镜像提供的默认 `python3` 是 Python 3.12,因此硬编码 Python 3.10 路径的脚本可能需要调整。
206
+
205
207
  `ai sandbox exec` 也会向容器透传一小组终端检测白名单变量(`TERM_PROGRAM`、`TERM_PROGRAM_VERSION`、`LC_TERMINAL`、`LC_TERMINAL_VERSION`)。这样可以让交互式 TUI 保持与宿主终端一致的行为,例如 Claude Code 的 `Shift+Enter` 换行支持,同时避免把整个宿主环境灌入容器。
206
208
 
207
- 在 macOS 上,交互式 `ai sandbox exec <branch>` 会尽力桥接宿主图片粘贴。当你按下 `Ctrl+V` 且宿主剪贴板当前是图片时,agent-infra 会从宿主剪贴板读取图片,将 PNG 写到 `~/.agent-infra/clipboard/`,再以 bracketed paste 注入容器内路径,让 Claude Code、Codex、Gemini CLI 和 OpenCode 按图片附件处理。宿主剪贴板只读,不会被改写。该能力会自动降级:已有沙箱需要重建后才有 `/clipboard` 挂载;如果可选 pty 依赖或剪贴板探测不可用,会回退到原本的交互进入方式。
209
+ 在 macOS 上,交互式 `ai sandbox exec <branch>` 会尽力桥接宿主图片粘贴。当你按下 `Ctrl+V` 且宿主剪贴板当前是图片时,agent-infra 会从宿主剪贴板读取图片,将 PNG 写到 `~/.agent-infra/clipboard/`,再以 bracketed paste 注入容器内路径,让 Claude Code、Codex、Gemini CLI 和 OpenCode 按图片附件处理。宿主剪贴板只读,不会被改写。该能力会自动降级:已有沙箱需要重建后才有 `/clipboard` 挂载;如果可选 pty 依赖或剪贴板探测不可用,会回退到原本的交互进入方式。排查鼠标、滚动或其他输入异常时,可以设置 `AI_SANDBOX_NO_CLIPBOARD_BRIDGE=1` 跳过桥接,直接进入原本的交互路径。
210
+
211
+ 当你通过 SSH 在远端 Mac 上运行沙箱时,可先在手边这台 Mac 上执行 `ai cp <ssh-alias>`,把本机剪贴板图片推送到远端 Mac。典型流程是:Cmd+C 复制图片,运行 `ai cp mini`,回到已有 SSH session 后按 `Ctrl+V`;沙箱桥会读取远端 Mac 的 NSPasteboard,并按原路径注入图片。该命令只处理 PNG 图片,并使用基于 ssh key 的非交互 ssh/scp。目前发送端与远端都需为 macOS(远端通过 `osascript` 写入 NSPasteboard),后续可扩展支持其他远端平台。
208
212
 
209
213
  `ai sandbox exec` 和 `ai sandbox refresh` 会在宿主机凭证存储与 `~/.agent-infra/credentials/*` 下的所有沙箱项目副本之间做双向 reconcile。长时间运行的沙箱如果先刷新了 OAuth token,下一次进入或刷新命令会把最新有效副本回写到宿主 Keychain 或 `~/.claude/.credentials.json`;宿主机更新时也会继续覆盖项目副本。如果所有副本都已失效,`ai sandbox refresh` 会尝试 `claude /status` 探活,只有探活无法恢复时才提示重新登录。
210
214
 
@@ -220,6 +224,11 @@ CLI 会收集项目元数据,向所有支持的 AI TUI 安装 `update-agent-in
220
224
  可先用 `ai sandbox prune --dry-run` 查看旧版本或异常中断遗留的孤儿 per-branch 状态目录,再用 `ai sandbox prune` 只删除没有活跃 sandbox 容器对应的目录。
221
225
  已有沙箱需要执行 `ai sandbox rm <branch>` 后再执行 `ai sandbox create <branch>`,才能加载新的挂载点。
222
226
 
227
+ 首次执行 `ai sandbox create` 时,agent-infra 会在
228
+ `~/.agent-infra/share/<project>/common/` 以及每个 `branches/<branch>/`
229
+ 目录下写入一份中英双语 `README.md`,帮助你发现这些通道。README 是幂等的,
230
+ 可以安全删除;scaffold 仅在文件缺失时写入。
231
+
223
232
  #### 用户级 dotfiles 通道
224
233
 
225
234
  `ai sandbox create` 还会自动挂载一条可选的只读通道,用于把宿主机用户级偏好带进沙箱:
@@ -270,6 +279,7 @@ dotfiles 树解引用到
270
279
  | `.config/opencode/*`, `.local/share/opencode/*` | OpenCode 凭证和数据使用专用 bind mount。 |
271
280
  | `.host-shell-config/*` | agent-infra 管理的 shell 和 Git 配置。 |
272
281
  | `.gitconfig`, `.gitignore_global`, `.stCommitMsg`, `.bash_aliases` | agent-infra 将这些路径软链到 `.host-shell-config/`,包含 `safe.directory` 和 GPG 同步状态。 |
282
+ | `README.md` | agent-infra 会在 dotfiles 根目录 scaffold 一份发现性 README;link hook 会忽略它,避免遮蔽 `$HOME/README.md`。 |
273
283
 
274
284
  其他已经存在的真实目录(如 `~/.config/`、`~/.cache/`)不会被顶层 dotfile 替换。如果某个文件与这类目录冲突,钩子会打印警告并跳过:
275
285
 
@@ -760,7 +770,7 @@ import-issue #42 从 GitHub Issue 导入任务
760
770
  "project": "my-project",
761
771
  "org": "my-org",
762
772
  "language": "en",
763
- "templateVersion": "v0.6.4",
773
+ "templateVersion": "v0.6.5",
764
774
  "templates": {
765
775
  "sources": [
766
776
  { "type": "local", "path": "~/private-templates" }
package/bin/cli.ts CHANGED
@@ -13,12 +13,13 @@ if (major < 22) {
13
13
  const USAGE = `agent-infra ${VERSION} - bootstrap AI collaboration infrastructure
14
14
 
15
15
  Usage:
16
- agent-infra init Initialize a new project with update-agent-infra seed command
17
- agent-infra merge Merge tasks from another workspace directory (active/blocked/completed/archive)
18
- agent-infra update Update seed files and sync file registry for an existing project
19
- agent-infra sandbox Manage Docker-based AI sandboxes
20
- agent-infra version Show version
21
- agent-infra help Show this help message
16
+ agent-infra cp <ssh-alias> Copy local clipboard image to a remote macOS NSPasteboard
17
+ agent-infra help Show this help message
18
+ agent-infra init Initialize a new project with update-agent-infra seed command
19
+ agent-infra merge Merge tasks from another workspace directory (active/blocked/completed/archive)
20
+ agent-infra sandbox Manage Docker-based AI sandboxes
21
+ agent-infra update Update seed files and sync file registry for an existing project
22
+ agent-infra version Show version
22
23
 
23
24
  Shorthand: ai (e.g. ai init)
24
25
 
@@ -96,6 +97,17 @@ switch (command) {
96
97
  });
97
98
  break;
98
99
  }
100
+ case 'cp': {
101
+ const imported = await importCommand('../lib/cp.ts');
102
+ if (!imported) break;
103
+ const { cmdCp } = imported;
104
+ const code = await cmdCp(process.argv.slice(3)).catch((e: unknown) => {
105
+ process.stderr.write(`Error: ${errorMessage(e)}\n`);
106
+ return 1;
107
+ });
108
+ if (code) process.exitCode = code;
109
+ break;
110
+ }
99
111
  case 'version': {
100
112
  if (process.argv[3] === '--raw') {
101
113
  console.log(VERSION);
package/dist/bin/cli.js CHANGED
@@ -17,12 +17,13 @@ if (major < 22) {
17
17
  const USAGE = `agent-infra ${VERSION} - bootstrap AI collaboration infrastructure
18
18
 
19
19
  Usage:
20
- agent-infra init Initialize a new project with update-agent-infra seed command
21
- agent-infra merge Merge tasks from another workspace directory (active/blocked/completed/archive)
22
- agent-infra update Update seed files and sync file registry for an existing project
23
- agent-infra sandbox Manage Docker-based AI sandboxes
24
- agent-infra version Show version
25
- agent-infra help Show this help message
20
+ agent-infra cp <ssh-alias> Copy local clipboard image to a remote macOS NSPasteboard
21
+ agent-infra help Show this help message
22
+ agent-infra init Initialize a new project with update-agent-infra seed command
23
+ agent-infra merge Merge tasks from another workspace directory (active/blocked/completed/archive)
24
+ agent-infra sandbox Manage Docker-based AI sandboxes
25
+ agent-infra update Update seed files and sync file registry for an existing project
26
+ agent-infra version Show version
26
27
 
27
28
  Shorthand: ai (e.g. ai init)
28
29
 
@@ -99,6 +100,19 @@ switch (command) {
99
100
  });
100
101
  break;
101
102
  }
103
+ case 'cp': {
104
+ const imported = await importCommand('../lib/cp.ts');
105
+ if (!imported)
106
+ break;
107
+ const { cmdCp } = imported;
108
+ const code = await cmdCp(process.argv.slice(3)).catch((e) => {
109
+ process.stderr.write(`Error: ${errorMessage(e)}\n`);
110
+ return 1;
111
+ });
112
+ if (code)
113
+ process.exitCode = code;
114
+ break;
115
+ }
102
116
  case 'version': {
103
117
  if (process.argv[3] === '--raw') {
104
118
  console.log(VERSION);
package/dist/lib/cp.js ADDED
@@ -0,0 +1,127 @@
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 } from "./sandbox/clipboard/index.js";
8
+ const USAGE = 'Usage: ai cp <ssh-alias>\n\nCopy the local clipboard image (PNG) to a remote macOS NSPasteboard over ssh/scp.\n';
9
+ const COMMAND_TIMEOUT_MS = 30_000;
10
+ export function runCommand(cmd, args, input) {
11
+ const result = spawnSync(cmd, args, {
12
+ input,
13
+ encoding: 'utf8',
14
+ stdio: ['pipe', 'pipe', 'pipe'],
15
+ timeout: COMMAND_TIMEOUT_MS
16
+ });
17
+ return {
18
+ status: result.status,
19
+ stdout: result.stdout ?? '',
20
+ stderr: result.stderr ?? '',
21
+ error: result.error
22
+ };
23
+ }
24
+ export async function cmdCp(args, deps = {}) {
25
+ const { platform = currentPlatform(), createAdapter = createClipboardAdapter, spawnFn = runCommand, randomId = randomUUID, mkdtempFn = fs.mkdtempSync, writeFileFn = fs.writeFileSync, rmFn = fs.rmSync, tmpdir = defaultTmpdir, writeStdout = (chunk) => process.stdout.write(chunk), writeStderr = (chunk) => process.stderr.write(chunk) } = deps;
26
+ if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
27
+ writeStdout(USAGE);
28
+ return 0;
29
+ }
30
+ let positionals;
31
+ try {
32
+ ({ positionals } = parseArgs({ args, allowPositionals: true, strict: true }));
33
+ }
34
+ catch {
35
+ writeStderr(USAGE);
36
+ return 1;
37
+ }
38
+ const alias = positionals[0];
39
+ if (!alias || positionals.length !== 1) {
40
+ writeStderr(USAGE);
41
+ return 1;
42
+ }
43
+ if (alias.startsWith('-')) {
44
+ writeStderr(`invalid ssh alias '${alias}': must not start with '-'\n`);
45
+ return 1;
46
+ }
47
+ if (platform !== 'darwin') {
48
+ writeStderr(`ai cp currently supports macOS senders only (got ${platform})\n`);
49
+ return 1;
50
+ }
51
+ const adapter = createAdapter({ platformName: platform });
52
+ const png = adapter?.readImagePng() ?? null;
53
+ if (png === null) {
54
+ writeStderr('no image on clipboard\n');
55
+ return 1;
56
+ }
57
+ let uploaded = false;
58
+ let localTmpDir = null;
59
+ let remotePath = null;
60
+ try {
61
+ localTmpDir = mkdtempFn(path.join(tmpdir(), 'agent-infra-cp-'));
62
+ const localPng = path.join(localTmpDir, 'clipboard.png');
63
+ writeFileFn(localPng, png);
64
+ remotePath = `/tmp/agent-infra-cp-${randomId()}.png`;
65
+ const upload = spawnFn('scp', [
66
+ '-o',
67
+ 'BatchMode=yes',
68
+ '-o',
69
+ 'ConnectTimeout=10',
70
+ localPng,
71
+ `${alias}:${remotePath}`
72
+ ]);
73
+ if (upload.status !== 0) {
74
+ writeStderr(`failed to upload image to ${alias}:\n${commandDetail(upload)}\n`);
75
+ return 1;
76
+ }
77
+ uploaded = true;
78
+ // Remote write currently targets macOS only: it pipes an AppleScript to the
79
+ // remote `osascript` to set its NSPasteboard. This is the extension point for
80
+ // other remote platforms later (e.g. dispatch on remote OS to wl-copy/xclip
81
+ // on Linux); a non-macOS remote fails here with a clear non-zero error today.
82
+ const setRemote = spawnFn('ssh', [
83
+ '-o',
84
+ 'BatchMode=yes',
85
+ '-o',
86
+ 'ConnectTimeout=10',
87
+ alias,
88
+ 'osascript',
89
+ '-'
90
+ ], remoteSetScript(remotePath));
91
+ if (setRemote.status !== 0) {
92
+ writeStderr(`failed to set remote clipboard on ${alias}:\n${commandDetail(setRemote)}\n`);
93
+ return 1;
94
+ }
95
+ writeStdout(`copied clipboard image to ${alias}\n`);
96
+ return 0;
97
+ }
98
+ finally {
99
+ if (uploaded && remotePath) {
100
+ spawnFn('ssh', [
101
+ '-o',
102
+ 'BatchMode=yes',
103
+ '-o',
104
+ 'ConnectTimeout=10',
105
+ alias,
106
+ 'rm',
107
+ '-f',
108
+ remotePath
109
+ ]);
110
+ }
111
+ if (localTmpDir) {
112
+ rmFn(localTmpDir, { recursive: true, force: true });
113
+ }
114
+ }
115
+ }
116
+ function commandDetail(result) {
117
+ const detail = result.stderr || result.error?.message || result.stdout || 'unknown error';
118
+ return detail.trimEnd();
119
+ }
120
+ function remoteSetScript(remotePath) {
121
+ const escapedPath = remotePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
122
+ return [
123
+ `set theFile to POSIX file "${escapedPath}"`,
124
+ 'set the clipboard to (read theFile as «class PNGf»)'
125
+ ].join('\n');
126
+ }
127
+ //# sourceMappingURL=cp.js.map
@@ -1,4 +1,5 @@
1
1
  import { StringDecoder } from 'node:string_decoder';
2
+ import { spawnSync } from 'node:child_process';
2
3
  import { createClipboardAdapter } from "./index.js";
3
4
  import { buildBracketedPaste, CtrlVDetector } from "./keys.js";
4
5
  import { clipboardHostDir, containerClipboardPath, pngClipboardFilename, pruneClipboardDir, writeClipboardPngAtomic } from "./paths.js";
@@ -6,20 +7,37 @@ import { commandForEngine, restoreTerminal, runInteractiveEngine, runOkEngine }
6
7
  import { loadNodePty } from "./node-pty.js";
7
8
  const FALLBACK_PREFIX = 'Warning: clipboard image paste bridge disabled';
8
9
  const PARTIAL_ESCAPE_FLUSH_MS = 30;
10
+ // Node's stdin.setRawMode(true) uses libuv's RAW mode, which (unlike the
11
+ // cfmakeraw that `docker exec -it` applies on the non-bridge path) keeps ONLCR
12
+ // set on the shared host TTY. With ONLCR on, the kernel rewrites the bare \n
13
+ // that tmux emits after homing the cursor inside the right pane into \r\n,
14
+ // snapping the cursor to column 1 so the following erase/redraw wipes the left
15
+ // pane. Clearing OPOST brings the host TTY in line with the non-bridge path.
16
+ // Best-effort: setRawMode(false) on teardown restores the original termios, and
17
+ // a missing/failed stty only reinstates the redraw glitch.
18
+ function disableOutputPostProcessing(stdin) {
19
+ const candidate = stdin.fd;
20
+ if (typeof candidate !== 'number') {
21
+ return;
22
+ }
23
+ try {
24
+ spawnSync('stty', ['-opost'], { stdio: [candidate, 'ignore', 'ignore'] });
25
+ }
26
+ catch {
27
+ // stty unavailable or fd is not a tty; leave the terminal as-is.
28
+ }
29
+ }
9
30
  export async function runInteractiveWithClipboardBridge(options) {
10
31
  const { engine, dockerArgs, container, home, cwd = process.cwd(), env = process.env, platformName = process.platform, adapter = createClipboardAdapter({ platformName }), loadPty = loadNodePty, runInteractive = runInteractiveEngine, runOk = runOkEngine, writeStderr = (chunk) => process.stderr.write(chunk), stdin = process.stdin, stdout = process.stdout, createDetector = () => new CtrlVDetector() } = options;
11
32
  function fallback(reason) {
12
33
  writeStderr(`${FALLBACK_PREFIX}: ${reason}\n`);
13
34
  return runInteractive(engine, 'docker', dockerArgs);
14
35
  }
15
- if (platformName !== 'darwin') {
16
- return runInteractive(engine, 'docker', dockerArgs);
17
- }
18
36
  if (!stdin.isTTY || !stdout.isTTY) {
19
37
  return fallback('host stdin/stdout is not a TTY');
20
38
  }
21
39
  if (!adapter) {
22
- return fallback('macOS clipboard adapter is unavailable');
40
+ return fallback('no clipboard adapter available on this platform');
23
41
  }
24
42
  const available = adapter.available();
25
43
  if (!available.ok) {
@@ -121,6 +139,7 @@ async function runBridge({ child, home, adapter, writeStderr, stdin, stdout, det
121
139
  }
122
140
  try {
123
141
  stdin.setRawMode?.(true);
142
+ disableOutputPostProcessing(stdin);
124
143
  stdin.resume();
125
144
  stdin.on('data', onData);
126
145
  stdout.on('resize', onResize);
@@ -1,9 +1,18 @@
1
1
  import { platform } from 'node:os';
2
2
  import { createDarwinClipboardAdapter } from "./darwin.js";
3
3
  export function createClipboardAdapter({ platformName = platform() } = {}) {
4
- if (platformName !== 'darwin') {
5
- return null;
4
+ switch (platformName) {
5
+ case 'darwin':
6
+ return createDarwinClipboardAdapter();
7
+ case 'linux':
8
+ // Future work: dispatch based on $WAYLAND_DISPLAY (wl-paste) or $DISPLAY (xclip);
9
+ // see Issue #386 follow-up. Returning null disables the bridge for now.
10
+ return null;
11
+ case 'win32':
12
+ // Future work: native Win32 clipboard reader. Returning null disables the bridge.
13
+ return null;
14
+ default:
15
+ return null;
6
16
  }
7
- return createDarwinClipboardAdapter();
8
17
  }
9
18
  //# sourceMappingURL=index.js.map
@@ -19,6 +19,7 @@ import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from "../clipboard/paths.
19
19
  import { validateSelinuxDisableEnv } from "../engines/selinux.js";
20
20
  import { resolveBuildUid } from "../engines/native.js";
21
21
  import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
22
+ import { ensureSandboxDiscoveryReadmes } from "../readme-scaffold.js";
22
23
  import { prepareClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
23
24
  import { detectHostTimezone } from "../host-timezone.js";
24
25
  const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
@@ -981,6 +982,12 @@ export async function create(args) {
981
982
  if (aliasesFile.created) {
982
983
  message(`Created default sandbox aliases at ${aliasesFile.path}`);
983
984
  }
985
+ const readmeResults = ensureSandboxDiscoveryReadmes(effectiveConfig, branch);
986
+ for (const { created, path: readmePath } of readmeResults) {
987
+ if (created) {
988
+ message(`Created discovery README at ${readmePath}`);
989
+ }
990
+ }
984
991
  const gitconfigPath = path.join(effectiveConfig.home, '.gitconfig');
985
992
  const gitconfigContent = fs.existsSync(gitconfigPath)
986
993
  ? fs.readFileSync(gitconfigPath, 'utf8')
@@ -34,6 +34,17 @@ export function hostTimezoneEnvFlags(detect = detectHostTimezone) {
34
34
  const tz = detect();
35
35
  return tz ? ['-e', `TZ=${tz}`] : [];
36
36
  }
37
+ export function clipboardBridgeDisabled(env = process.env) {
38
+ const value = (env.AI_SANDBOX_NO_CLIPBOARD_BRIDGE ?? '').trim().toLowerCase();
39
+ return value === '1' || value === 'true' || value === 'yes';
40
+ }
41
+ export function runSandboxInteractive(params) {
42
+ const { engine, dockerArgs, container, home, env = process.env, runBridge = runInteractiveWithClipboardBridge, runInteractive = runInteractiveEngine } = params;
43
+ if (clipboardBridgeDisabled(env)) {
44
+ return runInteractive(engine, 'docker', dockerArgs);
45
+ }
46
+ return runBridge({ engine, dockerArgs, container, home });
47
+ }
37
48
  export function formatCredentialSyncStatus(result, isTTY = process.stderr.isTTY) {
38
49
  if (result.status === 'STALE_ACCESS') {
39
50
  return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
@@ -97,9 +108,10 @@ export async function enter(args) {
97
108
  catch (error) {
98
109
  process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
99
110
  }
100
- return runInteractiveWithClipboardBridge({
111
+ const dockerArgs = ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH];
112
+ return runSandboxInteractive({
101
113
  engine,
102
- dockerArgs: ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH],
114
+ dockerArgs,
103
115
  container,
104
116
  home: config.home
105
117
  });
@@ -10,7 +10,7 @@ import { runEngine, runOkEngine, runSafeEngine, runVerboseEngine } from "../shel
10
10
  import { resolveTools, toolNpmPackagesArg } from "../tools.js";
11
11
  import { toEnginePath } from "../engines/wsl2-paths.js";
12
12
  import { resolveBuildUid } from "../engines/native.js";
13
- const USAGE = `Usage: ai sandbox rebuild [--quiet]`;
13
+ const USAGE = `Usage: ai sandbox rebuild [--quiet] [--refresh]`;
14
14
  function buildSignature(preparedDockerfile, tools) {
15
15
  return createHash('sha256')
16
16
  .update(JSON.stringify({
@@ -20,7 +20,7 @@ function buildSignature(preparedDockerfile, tools) {
20
20
  .digest('hex')
21
21
  .slice(0, 12);
22
22
  }
23
- export function buildArgs(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, env = process.env } = {}) {
23
+ export function buildArgs(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, env = process.env, refresh = false } = {}) {
24
24
  const selectedEngine = engine ?? detectEngine(config);
25
25
  const { uid: hostUid, gid: hostGid } = resolveBuildUid({
26
26
  engine: selectedEngine,
@@ -28,7 +28,7 @@ export function buildArgs(config, tools, dockerfilePath, imageSignature, { engin
28
28
  runSafeFn,
29
29
  env
30
30
  });
31
- return [
31
+ const args = [
32
32
  'build',
33
33
  '-t',
34
34
  config.imageName,
@@ -46,6 +46,10 @@ export function buildArgs(config, tools, dockerfilePath, imageSignature, { engin
46
46
  toEnginePath(selectedEngine, dockerfilePath),
47
47
  toEnginePath(selectedEngine, config.repoRoot)
48
48
  ];
49
+ if (refresh) {
50
+ args.splice(1, 0, '--no-cache', '--pull');
51
+ }
52
+ return args;
49
53
  }
50
54
  function removeImageIfPresent(imageName, engine) {
51
55
  if (runOkEngine(engine, 'docker', ['image', 'inspect', imageName])) {
@@ -58,6 +62,7 @@ export async function rebuild(args) {
58
62
  allowPositionals: true,
59
63
  strict: true,
60
64
  options: {
65
+ refresh: { type: 'boolean' },
61
66
  quiet: { type: 'boolean', short: 'q' },
62
67
  help: { type: 'boolean', short: 'h' }
63
68
  }
@@ -71,6 +76,7 @@ export async function rebuild(args) {
71
76
  const preparedDockerfile = prepareDockerfile(config);
72
77
  const imageSignature = buildSignature(preparedDockerfile, tools);
73
78
  const quiet = values.quiet ?? false;
79
+ const refresh = values.refresh ?? false;
74
80
  const engine = detectEngine(config);
75
81
  await ensureDocker(config, undefined);
76
82
  p.intro(pc.cyan('Rebuilding sandbox image'));
@@ -81,7 +87,7 @@ export async function rebuild(args) {
81
87
  removeImageIfPresent(config.imageName, engine);
82
88
  spinner.stop('Old image removed');
83
89
  spinner.start('Building image...');
84
- runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine }), {
90
+ runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), {
85
91
  cwd: config.repoRoot
86
92
  });
87
93
  spinner.stop(pc.green('Sandbox image rebuilt'));
@@ -90,7 +96,7 @@ export async function rebuild(args) {
90
96
  p.log.step(`Removing old image ${config.imageName}`);
91
97
  removeImageIfPresent(config.imageName, engine);
92
98
  p.log.step('Building image');
93
- runVerboseEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine }), { cwd: config.repoRoot });
99
+ runVerboseEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), { cwd: config.repoRoot });
94
100
  p.log.success(pc.green('Sandbox image rebuilt'));
95
101
  }
96
102
  }
@@ -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) {