@fitlab-ai/agent-infra 0.8.2 → 0.8.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 (119) hide show
  1. package/dist/bin/cli.js +1 -1
  2. package/dist/lib/cp.js +1 -1
  3. package/dist/lib/decide.js +2 -2
  4. package/dist/lib/defaults.json +1 -0
  5. package/dist/lib/init.js +7 -7
  6. package/dist/lib/merge.js +1 -1
  7. package/dist/lib/prompt.js +1 -1
  8. package/dist/lib/run/index.js +7 -7
  9. package/dist/lib/run/prompt.js +1 -1
  10. package/dist/lib/sandbox/capture.js +5 -5
  11. package/dist/lib/sandbox/clipboard/bridge.js +48 -13
  12. package/dist/lib/sandbox/clipboard/index.js +5 -6
  13. package/dist/lib/sandbox/clipboard/linux.js +91 -0
  14. package/dist/lib/sandbox/clipboard/paths.js +1 -1
  15. package/dist/lib/sandbox/clipboard/win32.js +144 -0
  16. package/dist/lib/sandbox/commands/create.js +67 -65
  17. package/dist/lib/sandbox/commands/enter.js +9 -9
  18. package/dist/lib/sandbox/commands/list-running.js +2 -2
  19. package/dist/lib/sandbox/commands/ls.js +7 -7
  20. package/dist/lib/sandbox/commands/prune.js +8 -8
  21. package/dist/lib/sandbox/commands/rebuild.js +38 -50
  22. package/dist/lib/sandbox/commands/refresh.js +2 -2
  23. package/dist/lib/sandbox/commands/rm.js +11 -11
  24. package/dist/lib/sandbox/commands/show.js +4 -4
  25. package/dist/lib/sandbox/commands/start.js +4 -4
  26. package/dist/lib/sandbox/commands/vm.js +4 -4
  27. package/dist/lib/sandbox/config.js +10 -4
  28. package/dist/lib/sandbox/constants.js +4 -1
  29. package/dist/lib/sandbox/credentials.js +1 -1
  30. package/dist/lib/sandbox/dotfiles.js +1 -1
  31. package/dist/lib/sandbox/engine.js +3 -3
  32. package/dist/lib/sandbox/engines/index.js +5 -5
  33. package/dist/lib/sandbox/engines/wsl2-paths.js +1 -1
  34. package/dist/lib/sandbox/image-build.js +80 -0
  35. package/dist/lib/sandbox/image-prune.js +2 -2
  36. package/dist/lib/sandbox/index.js +10 -10
  37. package/dist/lib/sandbox/managed-fs.js +1 -1
  38. package/dist/lib/sandbox/readme-scaffold.js +1 -1
  39. package/dist/lib/sandbox/tools.js +2 -2
  40. package/dist/lib/server/adapters/feishu/index.js +10 -3
  41. package/dist/lib/server/adapters/feishu/renderer.js +88 -0
  42. package/dist/lib/server/adapters/feishu/transport.js +2 -13
  43. package/dist/lib/server/daemon.js +64 -43
  44. package/dist/lib/server/display.js +83 -0
  45. package/dist/lib/server/index.js +2 -2
  46. package/dist/lib/server/process-control.js +2 -2
  47. package/dist/lib/server/protocol.js +2 -2
  48. package/dist/lib/server/streamer.js +5 -4
  49. package/dist/lib/task/commands/cat.js +2 -2
  50. package/dist/lib/task/commands/decisions.js +4 -4
  51. package/dist/lib/task/commands/files.js +3 -3
  52. package/dist/lib/task/commands/grep.js +3 -3
  53. package/dist/lib/task/commands/issue-body.js +4 -4
  54. package/dist/lib/task/commands/log.js +5 -5
  55. package/dist/lib/task/commands/ls.js +3 -3
  56. package/dist/lib/task/commands/show.js +1 -1
  57. package/dist/lib/task/commands/status.js +71 -25
  58. package/dist/lib/task/index.js +9 -9
  59. package/dist/lib/task/resolve-ref.js +1 -1
  60. package/dist/lib/task/workflow-warnings.js +94 -0
  61. package/dist/lib/update.js +4 -4
  62. package/lib/defaults.json +1 -0
  63. package/lib/sandbox/clipboard/bridge.ts +50 -8
  64. package/lib/sandbox/clipboard/index.ts +5 -6
  65. package/lib/sandbox/clipboard/linux.ts +124 -0
  66. package/lib/sandbox/clipboard/win32.ts +173 -0
  67. package/lib/sandbox/commands/create.ts +84 -64
  68. package/lib/sandbox/commands/rebuild.ts +42 -54
  69. package/lib/sandbox/config.ts +13 -1
  70. package/lib/sandbox/constants.ts +4 -0
  71. package/lib/sandbox/image-build.ts +134 -0
  72. package/lib/server/adapters/_contract.ts +3 -0
  73. package/lib/server/adapters/feishu/index.ts +11 -3
  74. package/lib/server/adapters/feishu/renderer.ts +99 -0
  75. package/lib/server/adapters/feishu/transport.ts +5 -18
  76. package/lib/server/daemon.ts +74 -38
  77. package/lib/server/display.ts +136 -0
  78. package/lib/server/streamer.ts +5 -4
  79. package/lib/task/commands/log.ts +3 -3
  80. package/lib/task/commands/status.ts +102 -21
  81. package/lib/task/workflow-warnings.ts +121 -0
  82. package/package.json +2 -2
  83. package/templates/.agents/rules/create-issue.github.en.md +3 -3
  84. package/templates/.agents/rules/create-issue.github.zh-CN.md +3 -3
  85. package/templates/.agents/rules/issue-pr-commands.github.en.md +7 -2
  86. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +7 -2
  87. package/templates/.agents/rules/issue-sync.github.en.md +13 -0
  88. package/templates/.agents/rules/issue-sync.github.zh-CN.md +13 -0
  89. package/templates/.agents/rules/next-step-output.en.md +15 -1
  90. package/templates/.agents/rules/next-step-output.zh-CN.md +15 -1
  91. package/templates/.agents/rules/pr-sync.github.en.md +17 -2
  92. package/templates/.agents/rules/pr-sync.github.zh-CN.md +17 -2
  93. package/templates/.agents/rules/task-management.en.md +1 -1
  94. package/templates/.agents/rules/task-management.zh-CN.md +1 -1
  95. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +57 -0
  96. package/templates/.agents/scripts/validate-artifact.js +120 -0
  97. package/templates/.agents/scripts/workflow-warnings.js +290 -0
  98. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +118 -0
  99. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +118 -0
  100. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +49 -0
  101. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +49 -0
  102. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +48 -0
  103. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +48 -0
  104. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +60 -0
  105. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +87 -0
  106. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
  107. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -0
  108. package/templates/.agents/skills/create-task/SKILL.en.md +4 -1
  109. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -1
  110. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +7 -0
  111. package/templates/.agents/templates/task.en.md +7 -0
  112. package/templates/.agents/templates/task.zh-CN.md +7 -0
  113. package/templates/.claude/commands/complete-manual-validation.en.md +9 -0
  114. package/templates/.claude/commands/complete-manual-validation.zh-CN.md +9 -0
  115. package/templates/.gemini/commands/_project_/complete-manual-validation.en.toml +8 -0
  116. package/templates/.gemini/commands/_project_/complete-manual-validation.zh-CN.toml +8 -0
  117. package/templates/.github/workflows/metadata-sync.yml +5 -0
  118. package/templates/.opencode/commands/complete-manual-validation.en.md +11 -0
  119. package/templates/.opencode/commands/complete-manual-validation.zh-CN.md +11 -0
@@ -7,21 +7,21 @@ import { parseArgs } from 'node:util';
7
7
  import * as p from '@clack/prompts';
8
8
  import pc from 'picocolors';
9
9
  import * as toml from 'smol-toml';
10
- import { loadConfig } from "../config.js";
11
- import { assertValidBranchName, containerName, containerNameCandidates, parsePositiveIntegerOption, sandboxBranchLabel, sandboxImageConfigLabel, sandboxLabel, shareBranchDir, shareCommonDir, shellConfigDir, worktreeDirCandidates } from "../constants.js";
12
- import { prepareDockerfile } from "../dockerfile.js";
13
- import { detectEngine, ensureDocker } from "../engine.js";
14
- import { commandForEngine, execEngine, run, runEngine, runOk, runOkEngine, runSafe, runSafeEngine, runVerboseEngine } from "../shell.js";
15
- import { resolveTaskBranch } from "../task-resolver.js";
16
- import { imageSignatureFields, resolveTools, toolConfigDirCandidates, toolNpmPackagesArg, toolShellInstallScriptBase64 } from "../tools.js";
17
- import { hostJoin, toEnginePath, volumeArg } from "../engines/wsl2-paths.js";
18
- import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from "../clipboard/paths.js";
19
- import { validateSelinuxDisableEnv } from "../engines/selinux.js";
20
- import { resolveBuildUid } from "../engines/native.js";
21
- import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
22
- import { ensureSandboxDiscoveryReadmes } from "../readme-scaffold.js";
23
- import { prepareClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
24
- import { detectHostTimezone } from "../host-timezone.js";
10
+ import { loadConfig } from '../config.js';
11
+ import { assertValidBranchName, containerName, containerNameCandidates, parsePositiveIntegerOption, sandboxBranchLabel, sandboxImageConfigLabel, sandboxImageRefreshLabel, sandboxLabel, shareBranchDir, shareCommonDir, shellConfigDir, worktreeDirCandidates } from '../constants.js';
12
+ import { prepareDockerfile } from '../dockerfile.js';
13
+ import { detectEngine, ensureDocker } from '../engine.js';
14
+ import { commandForEngine, execEngine, run, runEngine, runOk, runOkEngine, runSafe, runSafeEngine, runVerboseEngine } from '../shell.js';
15
+ import { resolveTaskBranch } from '../task-resolver.js';
16
+ import { resolveTools, toolConfigDirCandidates } from '../tools.js';
17
+ import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.js';
18
+ import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.js';
19
+ import { validateSelinuxDisableEnv } from '../engines/selinux.js';
20
+ import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.js';
21
+ import { ensureSandboxDiscoveryReadmes } from '../readme-scaffold.js';
22
+ import { prepareClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from '../credentials.js';
23
+ import { detectHostTimezone } from '../host-timezone.js';
24
+ import { buildImageSignature, buildSandboxImageArgs, isRefreshDisabled, isRefreshDue, parseImageLabels, parseRefreshTimestamp } from '../image-build.js';
25
25
  const OPENCODE_YOLO_PERMISSION = '{"*":"allow","read":"allow","bash":"allow","edit":"allow","webfetch":"allow","external_directory":"allow","doom_loop":"allow"}';
26
26
  const SANDBOX_ALIAS_BLOCK_BEGIN = '# >>> agent-infra managed aliases >>>';
27
27
  const SANDBOX_ALIAS_BLOCK_END = '# <<< agent-infra managed aliases <<<';
@@ -47,22 +47,13 @@ alias gy='gemini --yolo; tput ed'
47
47
  `;
48
48
  const CONTAINER_HOME = '/home/devuser';
49
49
  const CONTAINER_SHELL_CONFIG_MOUNT = `${CONTAINER_HOME}/.host-shell-config`;
50
- const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>]
50
+ const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh]
51
51
 
52
52
  Host aliases:
53
53
  ${'~'}/.agent-infra/aliases/sandbox.sh is auto-created on first run and exposed
54
54
  as ${CONTAINER_HOME}/.bash_aliases inside the sandbox container (the host
55
55
  shell-config directory is bind-mounted at ${CONTAINER_SHELL_CONFIG_MOUNT} and
56
56
  symlinked into $HOME).`;
57
- function buildSignature(preparedDockerfile, tools) {
58
- return createHash('sha256')
59
- .update(JSON.stringify({
60
- dockerfile: preparedDockerfile.signature,
61
- tools: imageSignatureFields(tools)
62
- }))
63
- .digest('hex')
64
- .slice(0, 12);
65
- }
66
57
  function resolveToolDirs(config, tools, branch) {
67
58
  return tools.map((tool) => {
68
59
  const candidates = toolConfigDirCandidates(tool, config.project, branch);
@@ -908,34 +899,25 @@ export function buildTmpfsRunArgs(containerMount, tmpfs) {
908
899
  const size = tmpfs.size ?? '512m';
909
900
  return ['--tmpfs', `${containerMount}:rw,size=${size}`];
910
901
  }
911
- export function buildImage(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, runVerboseFn = runVerboseEngine, env = process.env } = {}) {
902
+ export function buildImage(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, runVerboseFn = runVerboseEngine, env = process.env, refresh = false, lastRefresh } = {}) {
912
903
  const selectedEngine = engine ?? detectEngine({ engine: config.engine });
913
- const { uid: hostUid, gid: hostGid } = resolveBuildUid({
904
+ runVerboseFn(selectedEngine, 'docker', buildSandboxImageArgs(config, tools, dockerfilePath, imageSignature, {
914
905
  engine: selectedEngine,
915
906
  runFn,
916
907
  runSafeFn,
917
- env
918
- });
919
- runVerboseFn(selectedEngine, 'docker', [
920
- 'build',
921
- '-t',
922
- config.imageName,
923
- '--build-arg',
924
- `HOST_UID=${hostUid}`,
925
- '--build-arg',
926
- `HOST_GID=${hostGid}`,
927
- '--build-arg',
928
- `AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
929
- '--build-arg',
930
- `AI_TOOLS_SHELL_INSTALL_B64=${toolShellInstallScriptBase64(tools)}`,
931
- '--label',
932
- sandboxLabel(config),
933
- '--label',
934
- `${sandboxImageConfigLabel(config)}=${imageSignature}`,
935
- '-f',
936
- toEnginePath(selectedEngine, dockerfilePath),
937
- toEnginePath(selectedEngine, config.repoRoot)
938
- ], { cwd: config.repoRoot });
908
+ env,
909
+ refresh,
910
+ lastRefresh
911
+ }), { cwd: config.repoRoot });
912
+ }
913
+ function readImageLabels(config, engine) {
914
+ return parseImageLabels(runSafeEngine(engine, 'docker', [
915
+ 'image',
916
+ 'inspect',
917
+ '--format',
918
+ '{{ json .Config.Labels }}',
919
+ config.imageName
920
+ ]));
939
921
  }
940
922
  export async function create(args) {
941
923
  const { values, positionals } = parseArgs({
@@ -945,6 +927,7 @@ export async function create(args) {
945
927
  options: {
946
928
  cpu: { type: 'string' },
947
929
  memory: { type: 'string' },
930
+ 'no-refresh': { type: 'boolean' },
948
931
  help: { type: 'boolean', short: 'h' }
949
932
  }
950
933
  });
@@ -986,7 +969,7 @@ export async function create(args) {
986
969
  const shareBranch = shareBranchDir(effectiveConfig, branch);
987
970
  const preparedDockerfile = prepareDockerfile(effectiveConfig);
988
971
  const baseBranch = base ?? runSafe('git', ['-C', effectiveConfig.repoRoot, 'branch', '--show-current']);
989
- const expectedImageSignature = buildSignature(preparedDockerfile, tools);
972
+ const expectedImageSignature = buildImageSignature(preparedDockerfile, tools);
990
973
  const engine = detectEngine(effectiveConfig);
991
974
  p.intro(pc.cyan('AI Sandbox'));
992
975
  p.log.info(`Project: ${pc.bold(effectiveConfig.project)} | Branch: ${pc.bold(branch)} | Base: ${pc.bold(baseBranch || 'HEAD')}`);
@@ -1002,20 +985,41 @@ export async function create(args) {
1002
985
  });
1003
986
  p.log.success('Docker is ready');
1004
987
  const imageExists = runOkEngine(engine, 'docker', ['image', 'inspect', effectiveConfig.imageName]);
1005
- const currentImageSignature = imageExists
1006
- ? runSafeEngine(engine, 'docker', [
1007
- 'image',
1008
- 'inspect',
1009
- '--format',
1010
- `{{ index .Config.Labels "${sandboxImageConfigLabel(effectiveConfig)}" }}`,
1011
- effectiveConfig.imageName
1012
- ])
1013
- : '';
1014
- const needsImageBuild = !imageExists || currentImageSignature !== expectedImageSignature;
988
+ const imageLabels = imageExists ? readImageLabels(effectiveConfig, engine) : {};
989
+ const currentImageSignature = imageLabels[sandboxImageConfigLabel(effectiveConfig)] ?? '';
990
+ const currentLastRefresh = parseRefreshTimestamp(imageLabels[sandboxImageRefreshLabel(effectiveConfig)] ?? '');
991
+ const signatureStale = !imageExists || currentImageSignature !== expectedImageSignature;
992
+ const now = Date.now();
993
+ const refreshDue = imageExists
994
+ && !signatureStale
995
+ && !isRefreshDisabled(process.env, values['no-refresh'] ?? false)
996
+ && isRefreshDue(currentLastRefresh, now, effectiveConfig.refreshIntervalDays);
997
+ const needsImageBuild = signatureStale || refreshDue;
1015
998
  if (needsImageBuild) {
1016
- p.log.step(imageExists ? 'Rebuilding stale image...' : 'Building image for first use...');
1017
- buildImage(effectiveConfig, tools, preparedDockerfile.path, expectedImageSignature, { engine });
1018
- p.log.success(imageExists ? 'Image rebuilt' : 'Image built');
999
+ const buildRefresh = !imageExists || refreshDue;
1000
+ const buildLastRefresh = buildRefresh ? now : currentLastRefresh || 0;
1001
+ p.log.step(refreshDue
1002
+ ? 'Refreshing stale image...'
1003
+ : imageExists
1004
+ ? 'Rebuilding stale image...'
1005
+ : 'Building image for first use...');
1006
+ try {
1007
+ buildImage(effectiveConfig, tools, preparedDockerfile.path, expectedImageSignature, { engine, refresh: buildRefresh, lastRefresh: buildLastRefresh });
1008
+ p.log.success(refreshDue
1009
+ ? 'Image refreshed'
1010
+ : imageExists
1011
+ ? 'Image rebuilt'
1012
+ : 'Image built');
1013
+ }
1014
+ catch (error) {
1015
+ if (refreshDue && !signatureStale && imageExists) {
1016
+ p.log.warn('Scheduled sandbox image refresh failed; continuing with the existing image. ' +
1017
+ commandErrorMessage(error));
1018
+ }
1019
+ else {
1020
+ throw error;
1021
+ }
1022
+ }
1019
1023
  }
1020
1024
  else {
1021
1025
  p.log.step(`Using existing image ${effectiveConfig.imageName}`);
@@ -1231,8 +1235,6 @@ export async function create(args) {
1231
1235
  ...buildClipboardVolumeArgs(engine, effectiveConfig.home),
1232
1236
  '-v',
1233
1237
  volumeArg(engine, path.join(effectiveConfig.repoRoot, '.git'), `${toEnginePath(engine, effectiveConfig.repoRoot)}/.git`),
1234
- '-v',
1235
- volumeArg(engine, hostJoin(effectiveConfig.home, '.ssh'), '/home/devuser/.ssh', ':ro'),
1236
1238
  ...dotfilesMount,
1237
1239
  ...toolVolumes,
1238
1240
  ...tmpfsArgs,
@@ -1,12 +1,12 @@
1
- import { loadConfig } from "../config.js";
2
- import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from "../constants.js";
3
- import { detectEngine } from "../engine.js";
4
- import { formatCredentialWarnings, formatRemaining, hasClaudeProviderAuth, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
5
- import { runInteractiveEngine } from "../shell.js";
6
- import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
7
- import { runInteractiveWithClipboardBridge } from "../clipboard/bridge.js";
8
- import { detectHostTimezone } from "../host-timezone.js";
9
- import { fetchSandboxRows, resolveBranchArg, selectSandboxContainer, startSandboxContainer } from "./list-running.js";
1
+ import { loadConfig } from '../config.js';
2
+ import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from '../constants.js';
3
+ import { detectEngine } from '../engine.js';
4
+ import { formatCredentialWarnings, formatRemaining, hasClaudeProviderAuth, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from '../credentials.js';
5
+ import { runInteractiveEngine } from '../shell.js';
6
+ import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.js';
7
+ import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.js';
8
+ import { detectHostTimezone } from '../host-timezone.js';
9
+ import { fetchSandboxRows, resolveBranchArg, selectSandboxContainer, startSandboxContainer } from './list-running.js';
10
10
  const USAGE = `Usage: ai sandbox exec <branch | TASK-id | N | '#N'> [cmd...]
11
11
 
12
12
  N (bare) and '#N' both reference the same active task short id from
@@ -1,8 +1,8 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { runSafeEngine, runVerboseEngine } from "../shell.js";
5
- import { resolveTaskBranch } from "../task-resolver.js";
4
+ import { runSafeEngine, runVerboseEngine } from '../shell.js';
5
+ import { resolveTaskBranch } from '../task-resolver.js';
6
6
  export function containerListFormat() {
7
7
  return '{{.Names}}\t{{.Status}}\t{{.Labels}}';
8
8
  }
@@ -1,12 +1,12 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import pc from 'picocolors';
3
- import { loadConfig } from "../config.js";
4
- import { sandboxBranchLabel, sandboxLabel } from "../constants.js";
5
- import { detectEngine } from "../engine.js";
6
- import { formatTable } from "../../table.js";
7
- import { lookupShortIdByBranch } from "../../task/short-id.js";
8
- import { fetchSandboxRows } from "./list-running.js";
9
- export { containerListFormat, parseLabels } from "./list-running.js";
3
+ import { loadConfig } from '../config.js';
4
+ import { sandboxBranchLabel, sandboxLabel } from '../constants.js';
5
+ import { detectEngine } from '../engine.js';
6
+ import { formatTable } from '../../table.js';
7
+ import { lookupShortIdByBranch } from '../../task/short-id.js';
8
+ import { fetchSandboxRows } from './list-running.js';
9
+ export { containerListFormat, parseLabels } from './list-running.js';
10
10
  const USAGE = `Usage: ai sandbox ls
11
11
 
12
12
  Lists all containers for the current project. The '#' column is a
@@ -3,14 +3,14 @@ import path from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
4
  import * as p from '@clack/prompts';
5
5
  import pc from 'picocolors';
6
- import { loadConfig } from "../config.js";
7
- import { safeNameCandidates, sandboxBranchLabel, sandboxLabel } from "../constants.js";
8
- import { detectEngine } from "../engine.js";
9
- import { hostJoin } from "../engines/wsl2-paths.js";
10
- import { removeManagedDir, removeWorktreeDir } from "../managed-fs.js";
11
- import { parseLabels } from "./ls.js";
12
- import { runEngine, runSafe } from "../shell.js";
13
- import { resolveTools } from "../tools.js";
6
+ import { loadConfig } from '../config.js';
7
+ import { safeNameCandidates, sandboxBranchLabel, sandboxLabel } from '../constants.js';
8
+ import { detectEngine } from '../engine.js';
9
+ import { hostJoin } from '../engines/wsl2-paths.js';
10
+ import { removeManagedDir, removeWorktreeDir } from '../managed-fs.js';
11
+ import { parseLabels } from './ls.js';
12
+ import { runEngine, runSafe } from '../shell.js';
13
+ import { resolveTools } from '../tools.js';
14
14
  const USAGE = `Usage: ai sandbox prune [--dry-run]`;
15
15
  function listChildDirs(base) {
16
16
  if (!fs.existsSync(base)) {
@@ -1,58 +1,37 @@
1
1
  import { parseArgs } from 'node:util';
2
- import { createHash } from 'node:crypto';
3
2
  import * as p from '@clack/prompts';
4
3
  import pc from 'picocolors';
5
- import { loadConfig } from "../config.js";
6
- import { prepareDockerfile } from "../dockerfile.js";
7
- import { sandboxImageConfigLabel, sandboxLabel } from "../constants.js";
8
- import { detectEngine, ensureDocker } from "../engine.js";
9
- import { runEngine, runSafeEngine, runVerboseEngine } from "../shell.js";
10
- import { pruneSandboxDanglingImages } from "../image-prune.js";
11
- import { imageSignatureFields, resolveTools, toolNpmPackagesArg, toolShellInstallScriptBase64 } from "../tools.js";
12
- import { toEnginePath } from "../engines/wsl2-paths.js";
13
- import { resolveBuildUid } from "../engines/native.js";
4
+ import { loadConfig } from '../config.js';
5
+ import { prepareDockerfile } from '../dockerfile.js';
6
+ import { sandboxImageRefreshLabel } from '../constants.js';
7
+ import { detectEngine, ensureDocker } from '../engine.js';
8
+ import { runEngine, runOkEngine, runSafeEngine, runVerboseEngine } from '../shell.js';
9
+ import { pruneSandboxDanglingImages } from '../image-prune.js';
10
+ import { resolveTools } from '../tools.js';
11
+ import { buildImageSignature, buildSandboxImageArgs, parseImageLabels, parseRefreshTimestamp } from '../image-build.js';
14
12
  const USAGE = `Usage: ai sandbox rebuild [--quiet] [--refresh]`;
15
- function buildSignature(preparedDockerfile, tools) {
16
- return createHash('sha256')
17
- .update(JSON.stringify({
18
- dockerfile: preparedDockerfile.signature,
19
- tools: imageSignatureFields(tools)
20
- }))
21
- .digest('hex')
22
- .slice(0, 12);
23
- }
24
- export function buildArgs(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, env = process.env, refresh = false } = {}) {
25
- const selectedEngine = engine ?? detectEngine(config);
26
- const { uid: hostUid, gid: hostGid } = resolveBuildUid({
27
- engine: selectedEngine,
13
+ export function buildArgs(config, tools, dockerfilePath, imageSignature, { engine, runFn = runEngine, runSafeFn = runSafeEngine, env = process.env, refresh = false, lastRefresh } = {}) {
14
+ return buildSandboxImageArgs(config, tools, dockerfilePath, imageSignature, {
15
+ engine: engine ?? detectEngine(config),
28
16
  runFn,
29
17
  runSafeFn,
30
- env
18
+ env,
19
+ refresh,
20
+ lastRefresh
31
21
  });
32
- const args = [
33
- 'build',
34
- '-t',
35
- config.imageName,
36
- '--build-arg',
37
- `HOST_UID=${hostUid}`,
38
- '--build-arg',
39
- `HOST_GID=${hostGid}`,
40
- '--build-arg',
41
- `AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
42
- '--build-arg',
43
- `AI_TOOLS_SHELL_INSTALL_B64=${toolShellInstallScriptBase64(tools)}`,
44
- '--label',
45
- sandboxLabel(config),
46
- '--label',
47
- `${sandboxImageConfigLabel(config)}=${imageSignature}`,
48
- '-f',
49
- toEnginePath(selectedEngine, dockerfilePath),
50
- toEnginePath(selectedEngine, config.repoRoot)
51
- ];
52
- if (refresh) {
53
- args.splice(1, 0, '--no-cache', '--pull');
22
+ }
23
+ function readExistingLastRefresh(config, engine) {
24
+ if (!runOkEngine(engine, 'docker', ['image', 'inspect', config.imageName])) {
25
+ return 0;
54
26
  }
55
- return args;
27
+ const labels = parseImageLabels(runSafeEngine(engine, 'docker', [
28
+ 'image',
29
+ 'inspect',
30
+ '--format',
31
+ '{{ json .Config.Labels }}',
32
+ config.imageName
33
+ ]));
34
+ return parseRefreshTimestamp(labels[sandboxImageRefreshLabel(config)] ?? '');
56
35
  }
57
36
  export async function rebuild(args) {
58
37
  const { values } = parseArgs({
@@ -72,24 +51,33 @@ export async function rebuild(args) {
72
51
  const config = loadConfig();
73
52
  const tools = resolveTools(config);
74
53
  const preparedDockerfile = prepareDockerfile(config);
75
- const imageSignature = buildSignature(preparedDockerfile, tools);
54
+ const imageSignature = buildImageSignature(preparedDockerfile, tools);
76
55
  const quiet = values.quiet ?? false;
77
56
  const refresh = values.refresh ?? false;
78
57
  const engine = detectEngine(config);
79
58
  await ensureDocker(config, undefined);
59
+ const lastRefresh = refresh ? Date.now() : readExistingLastRefresh(config, engine);
80
60
  p.intro(pc.cyan('Rebuilding sandbox image'));
81
61
  try {
82
62
  if (quiet) {
83
63
  const spinner = p.spinner();
84
64
  spinner.start('Building image...');
85
- runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), {
65
+ runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, {
66
+ engine,
67
+ refresh,
68
+ lastRefresh
69
+ }), {
86
70
  cwd: config.repoRoot
87
71
  });
88
72
  spinner.stop(pc.green('Sandbox image rebuilt'));
89
73
  }
90
74
  else {
91
75
  p.log.step('Building image');
92
- runVerboseEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), { cwd: config.repoRoot });
76
+ runVerboseEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, {
77
+ engine,
78
+ refresh,
79
+ lastRefresh
80
+ }), { cwd: config.repoRoot });
93
81
  p.log.success(pc.green('Sandbox image rebuilt'));
94
82
  }
95
83
  pruneSandboxDanglingImages(config, engine);
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { parseArgs } from 'node:util';
3
- import { buildLockedGuidance, discoverProjects, formatCredentialWarnings, formatRemaining, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
4
- import { runProbe } from "../shell.js";
3
+ import { buildLockedGuidance, discoverProjects, formatCredentialWarnings, formatRemaining, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from '../credentials.js';
4
+ import { runProbe } from '../shell.js';
5
5
  const USAGE = 'Usage: ai sandbox refresh';
6
6
  export function probeClaudeStatus(spawnFn = runProbe) {
7
7
  const result = spawnFn('claude', ['/status'], {
@@ -3,21 +3,21 @@ import path from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
4
  import * as p from '@clack/prompts';
5
5
  import pc from 'picocolors';
6
- import { loadConfig } from "../config.js";
7
- import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel, shareBranchDir, shellConfigDirCandidates, worktreeDirCandidates } from "../constants.js";
8
- import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from "../engine.js";
9
- import { pruneSandboxDanglingImages } from "../image-prune.js";
10
- import { removeManagedDir, removeWorktreeDir } from "../managed-fs.js";
11
- import { runOk, runSafe, runSafeEngine } from "../shell.js";
12
- import { resolveTaskBranch } from "../task-resolver.js";
13
- import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from "../tools.js";
14
- import { fetchSandboxRows } from "./list-running.js";
15
- import { lookupShortIdByBranch } from "../../task/short-id.js";
6
+ import { loadConfig } from '../config.js';
7
+ import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel, shareBranchDir, shellConfigDirCandidates, worktreeDirCandidates } from '../constants.js';
8
+ import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.js';
9
+ import { pruneSandboxDanglingImages } from '../image-prune.js';
10
+ import { removeManagedDir, removeWorktreeDir } from '../managed-fs.js';
11
+ import { runOk, runSafe, runSafeEngine } from '../shell.js';
12
+ import { resolveTaskBranch } from '../task-resolver.js';
13
+ import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.js';
14
+ import { fetchSandboxRows } from './list-running.js';
15
+ import { lookupShortIdByBranch } from '../../task/short-id.js';
16
16
  const USAGE = `Usage:
17
17
  ai sandbox rm <branch> Remove one sandbox (branch | TASK-id | short id)
18
18
  ai sandbox rm --all [--dry-run] [--yes] Remove every sandbox not bound to an active task
19
19
  ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
20
- export { assertManagedPath } from "../managed-fs.js";
20
+ export { assertManagedPath } from '../managed-fs.js';
21
21
  function projectToolDirs(config, tools) {
22
22
  return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
23
23
  }
@@ -1,10 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import * as p from '@clack/prompts';
3
3
  import pc from 'picocolors';
4
- import { loadConfig } from "../config.js";
5
- import { assertValidBranchName, worktreeDirCandidates } from "../constants.js";
6
- import { resolveBranchArg } from "./list-running.js";
7
- import { resolveTools, toolConfigDirCandidates } from "../tools.js";
4
+ import { loadConfig } from '../config.js';
5
+ import { assertValidBranchName, worktreeDirCandidates } from '../constants.js';
6
+ import { resolveBranchArg } from './list-running.js';
7
+ import { resolveTools, toolConfigDirCandidates } from '../tools.js';
8
8
  const USAGE = `Usage: ai sandbox show <branch | TASK-id | N | '#N'>
9
9
 
10
10
  Shows one sandbox's worktree path and per-tool state paths (Claude Code,
@@ -1,7 +1,7 @@
1
- import { loadConfig } from "../config.js";
2
- import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from "../constants.js";
3
- import { detectEngine } from "../engine.js";
4
- import { fetchSandboxRows, resolveBranchArg, selectSandboxContainer, startSandboxContainer } from "./list-running.js";
1
+ import { loadConfig } from '../config.js';
2
+ import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from '../constants.js';
3
+ import { detectEngine } from '../engine.js';
4
+ import { fetchSandboxRows, resolveBranchArg, selectSandboxContainer, startSandboxContainer } from './list-running.js';
5
5
  const USAGE = `Usage: ai sandbox start <branch | TASK-id | N | '#N'>
6
6
 
7
7
  Start an existing sandbox container that has stopped (for example after the
@@ -1,10 +1,10 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import * as p from '@clack/prompts';
3
3
  import pc from 'picocolors';
4
- import { loadConfig } from "../config.js";
5
- import { parsePositiveIntegerOption } from "../constants.js";
6
- import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, startManagedVm, stopManagedVm } from "../engine.js";
7
- import { runOk, runSafe } from "../shell.js";
4
+ import { loadConfig } from '../config.js';
5
+ import { parsePositiveIntegerOption } from '../constants.js';
6
+ import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, startManagedVm, stopManagedVm } from '../engine.js';
7
+ import { runOk, runSafe } from '../shell.js';
8
8
  const USAGE = `Usage: ai sandbox vm <status|start|stop> [--cpu <n>] [--memory <n>]`;
9
9
  export function ensureManagedVm(engine) {
10
10
  if (engine === ENGINES.NATIVE) {
@@ -3,14 +3,15 @@ import path from 'node:path';
3
3
  import { homedir, platform } from 'node:os';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import pc from 'picocolors';
6
- import { validateSandboxEngine } from "./engine.js";
7
- import { hostJoin } from "./engines/wsl2-paths.js";
8
- import { findRuntimeEngineMismatches } from "./runtime-engines.js";
9
- import { parseCustomTools } from "./tools.js";
6
+ import { validateSandboxEngine } from './engine.js';
7
+ import { hostJoin } from './engines/wsl2-paths.js';
8
+ import { findRuntimeEngineMismatches } from './runtime-engines.js';
9
+ import { parseCustomTools } from './tools.js';
10
10
  const DEFAULTS = Object.freeze({
11
11
  engine: null,
12
12
  runtimes: ['node22'],
13
13
  tools: ['agent-infra', 'claude-code', 'codex', 'gemini-cli', 'opencode'],
14
+ refreshIntervalDays: 7,
14
15
  dockerfile: null,
15
16
  vm: {
16
17
  cpu: null,
@@ -32,11 +33,15 @@ function detectRepoRoot() {
32
33
  function asPositiveNumberOrNull(value) {
33
34
  return typeof value === 'number' ? value : null;
34
35
  }
36
+ function asNonNegativeIntegerOrDefault(value, fallback) {
37
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback;
38
+ }
35
39
  function cloneDefaults() {
36
40
  return {
37
41
  engine: DEFAULTS.engine,
38
42
  runtimes: [...DEFAULTS.runtimes],
39
43
  tools: [...DEFAULTS.tools],
44
+ refreshIntervalDays: DEFAULTS.refreshIntervalDays,
40
45
  dockerfile: DEFAULTS.dockerfile,
41
46
  vm: { ...DEFAULTS.vm }
42
47
  };
@@ -96,6 +101,7 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
96
101
  ? [...sandbox.tools]
97
102
  : defaults.tools,
98
103
  customTools,
104
+ refreshIntervalDays: asNonNegativeIntegerOrDefault(sandbox.refreshIntervalDays, defaults.refreshIntervalDays),
99
105
  dockerfile,
100
106
  vm: {
101
107
  cpu: asPositiveNumberOrNull(sandbox.vm?.cpu) ?? defaults.vm.cpu,
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import { execFileSync } from 'node:child_process';
3
- import { hostJoin } from "./engines/wsl2-paths.js";
3
+ import { hostJoin } from './engines/wsl2-paths.js';
4
4
  const validatedBranches = new Set();
5
5
  function dedupe(items) {
6
6
  return [...new Set(items)];
@@ -72,6 +72,9 @@ export function sandboxBranchLabel(config) {
72
72
  export function sandboxImageConfigLabel(config) {
73
73
  return `${sandboxLabel(config)}.image-config`;
74
74
  }
75
+ export function sandboxImageRefreshLabel(config) {
76
+ return `${sandboxLabel(config)}.last-refresh`;
77
+ }
75
78
  export function parsePositiveIntegerOption(value, optionName) {
76
79
  if (value === undefined || value === null) {
77
80
  return undefined;
@@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { hostJoin } from "./engines/wsl2-paths.js";
5
+ import { hostJoin } from './engines/wsl2-paths.js';
6
6
  const LOCKED_PATTERN = /errSecInteractionNotAllowed|User interaction is not allowed/i;
7
7
  const NOT_FOUND_PATTERN = /errSecItemNotFound|specified item could not be found/i;
8
8
  const REDACTION_PATTERNS = [
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { hostJoin } from "./engines/wsl2-paths.js";
3
+ import { hostJoin } from './engines/wsl2-paths.js';
4
4
  export function dotfilesCacheDir(home, project) {
5
5
  return hostJoin(home, '.agent-infra', '.cache', 'dotfiles-resolved', project);
6
6
  }
@@ -1,7 +1,7 @@
1
1
  import { platform } from 'node:os';
2
- import { detectHostResources } from "./constants.js";
3
- import { ADAPTERS, enginesForPlatform, getAdapter } from "./engines/index.js";
4
- import { run, runOk, runSafe, runVerbose } from "./shell.js";
2
+ import { detectHostResources } from './constants.js';
3
+ import { ADAPTERS, enginesForPlatform, getAdapter } from './engines/index.js';
4
+ import { run, runOk, runSafe, runVerbose } from './shell.js';
5
5
  export const ENGINES = Object.freeze({
6
6
  COLIMA: 'colima',
7
7
  ORBSTACK: 'orbstack',
@@ -1,8 +1,8 @@
1
- import { colimaAdapter } from "./colima.js";
2
- import { dockerDesktopAdapter } from "./docker-desktop.js";
3
- import { nativeAdapter } from "./native.js";
4
- import { orbstackAdapter } from "./orbstack.js";
5
- import { wsl2Adapter } from "./wsl2.js";
1
+ import { colimaAdapter } from './colima.js';
2
+ import { dockerDesktopAdapter } from './docker-desktop.js';
3
+ import { nativeAdapter } from './native.js';
4
+ import { orbstackAdapter } from './orbstack.js';
5
+ import { wsl2Adapter } from './wsl2.js';
6
6
  export const ADAPTERS = Object.freeze({
7
7
  colima: colimaAdapter,
8
8
  orbstack: orbstackAdapter,
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { selinuxLabelForMount } from "./selinux.js";
2
+ import { selinuxLabelForMount } from './selinux.js';
3
3
  const WINDOWS_DRIVE_PATH_PATTERN = /^([A-Za-z]):[\\/](.*)$/;
4
4
  const UNC_PATH_PATTERN = /^(?:\\\\|\/\/)[^\\/]+[\\/][^\\/]+/;
5
5
  export function hostJoin(basePath, ...segments) {