@fitlab-ai/agent-infra 0.6.5 → 0.7.0

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 (181) hide show
  1. package/README.md +51 -25
  2. package/README.zh-CN.md +49 -23
  3. package/dist/lib/defaults.json +1 -0
  4. package/dist/lib/init.js +3 -0
  5. package/dist/lib/sandbox/commands/create.js +4 -2
  6. package/dist/lib/sandbox/commands/enter.js +15 -4
  7. package/dist/lib/sandbox/commands/list-running.js +108 -0
  8. package/dist/lib/sandbox/commands/ls.js +24 -45
  9. package/dist/lib/sandbox/commands/rebuild.js +4 -2
  10. package/dist/lib/sandbox/config.js +3 -0
  11. package/dist/lib/sandbox/index.js +2 -1
  12. package/dist/lib/sandbox/runtimes/ai-tools.dockerfile +10 -6
  13. package/dist/lib/sandbox/tools.js +213 -8
  14. package/dist/lib/update.js +12 -1
  15. package/lib/defaults.json +1 -0
  16. package/lib/init.ts +10 -0
  17. package/lib/sandbox/commands/create.ts +10 -2
  18. package/lib/sandbox/commands/enter.ts +14 -4
  19. package/lib/sandbox/commands/list-running.ts +135 -0
  20. package/lib/sandbox/commands/ls.ts +28 -49
  21. package/lib/sandbox/commands/rebuild.ts +9 -2
  22. package/lib/sandbox/config.ts +7 -0
  23. package/lib/sandbox/index.ts +2 -1
  24. package/lib/sandbox/runtimes/ai-tools.dockerfile +10 -6
  25. package/lib/sandbox/tools.ts +248 -9
  26. package/lib/update.ts +15 -1
  27. package/package.json +1 -1
  28. package/templates/.agents/QUICKSTART.en.md +1 -1
  29. package/templates/.agents/QUICKSTART.zh-CN.md +1 -1
  30. package/templates/.agents/README.en.md +79 -2
  31. package/templates/.agents/README.zh-CN.md +79 -2
  32. package/templates/.agents/rules/create-issue.en.md +1 -1
  33. package/templates/.agents/rules/create-issue.github.en.md +1 -1
  34. package/templates/.agents/rules/create-issue.github.zh-CN.md +1 -1
  35. package/templates/.agents/rules/create-issue.zh-CN.md +1 -1
  36. package/templates/.agents/rules/issue-sync.github.en.md +6 -5
  37. package/templates/.agents/rules/issue-sync.github.zh-CN.md +6 -5
  38. package/templates/.agents/rules/milestone-inference.github.en.md +2 -2
  39. package/templates/.agents/rules/milestone-inference.github.zh-CN.md +2 -2
  40. package/templates/.agents/rules/no-mid-flow-questions.en.md +57 -0
  41. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +57 -0
  42. package/templates/.agents/rules/pr-sync.github.en.md +4 -5
  43. package/templates/.agents/rules/pr-sync.github.zh-CN.md +4 -5
  44. package/templates/.agents/rules/task-management.en.md +9 -6
  45. package/templates/.agents/rules/task-management.zh-CN.md +9 -6
  46. package/templates/.agents/rules/testing-discipline.en.md +2 -2
  47. package/templates/.agents/rules/testing-discipline.zh-CN.md +2 -2
  48. package/templates/.agents/scripts/validate-artifact.js +1 -1
  49. package/templates/.agents/skills/analyze-task/SKILL.en.md +16 -4
  50. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +16 -4
  51. package/templates/.agents/skills/check-task/SKILL.en.md +43 -32
  52. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +42 -31
  53. package/templates/.agents/skills/code-task/SKILL.en.md +117 -0
  54. package/templates/.agents/skills/{implement-task → code-task}/SKILL.zh-CN.md +51 -24
  55. package/templates/.agents/skills/{implement-task → code-task}/config/verify.en.json +4 -4
  56. package/templates/.agents/skills/{implement-task → code-task}/config/verify.zh-CN.json +4 -4
  57. package/templates/.agents/skills/{implement-task → code-task}/reference/branch-management.zh-CN.md +2 -2
  58. package/templates/.agents/skills/{implement-task/reference/implementation-rules.en.md → code-task/reference/code-rules.en.md} +6 -6
  59. package/templates/.agents/skills/{implement-task/reference/implementation-rules.zh-CN.md → code-task/reference/code-rules.zh-CN.md} +3 -3
  60. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +69 -0
  61. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +69 -0
  62. package/templates/.agents/skills/{refine-task/reference/fix-workflow.en.md → code-task/reference/fix-mode.en.md} +12 -12
  63. package/templates/.agents/skills/{refine-task/reference/fix-workflow.zh-CN.md → code-task/reference/fix-mode.zh-CN.md} +8 -8
  64. package/templates/.agents/skills/code-task/reference/output-template.en.md +20 -0
  65. package/templates/.agents/skills/code-task/reference/output-template.zh-CN.md +20 -0
  66. package/templates/.agents/skills/{implement-task → code-task}/reference/report-template.en.md +4 -4
  67. package/templates/.agents/skills/{implement-task → code-task}/reference/report-template.zh-CN.md +3 -3
  68. package/templates/.agents/skills/code-task/scripts/detect-mode.js +370 -0
  69. package/templates/.agents/skills/commit/SKILL.en.md +2 -2
  70. package/templates/.agents/skills/commit/SKILL.zh-CN.md +2 -2
  71. package/templates/.agents/skills/commit/reference/task-status-update.en.md +10 -6
  72. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +10 -6
  73. package/templates/.agents/skills/complete-task/SKILL.en.md +5 -3
  74. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +5 -3
  75. package/templates/.agents/skills/create-pr/SKILL.en.md +17 -1
  76. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +17 -1
  77. package/templates/.agents/skills/import-codescan/SKILL.en.md +1 -1
  78. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  79. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -2
  80. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +2 -2
  81. package/templates/.agents/skills/import-issue/SKILL.en.md +3 -3
  82. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +3 -3
  83. package/templates/.agents/skills/plan-task/SKILL.en.md +4 -4
  84. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -4
  85. package/templates/.agents/skills/restore-task/SKILL.en.md +4 -3
  86. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +4 -3
  87. package/templates/.agents/skills/review-analysis/SKILL.en.md +76 -0
  88. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +102 -0
  89. package/templates/.agents/skills/review-analysis/config/verify.en.json +51 -0
  90. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +51 -0
  91. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +87 -0
  92. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +87 -0
  93. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +90 -0
  94. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +91 -0
  95. package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +47 -0
  96. package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +47 -0
  97. package/templates/.agents/skills/{review-task → review-code}/SKILL.en.md +11 -9
  98. package/templates/.agents/skills/{review-task → review-code}/SKILL.zh-CN.md +15 -9
  99. package/templates/.agents/skills/{review-task → review-code}/config/verify.en.json +7 -5
  100. package/templates/.agents/skills/{review-task → review-code}/config/verify.zh-CN.json +6 -4
  101. package/templates/.agents/skills/{review-task → review-code}/reference/output-templates.en.md +21 -17
  102. package/templates/.agents/skills/{review-task → review-code}/reference/output-templates.zh-CN.md +19 -15
  103. package/templates/.agents/skills/{review-task → review-code}/reference/report-template.en.md +5 -6
  104. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +91 -0
  105. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +48 -0
  106. package/templates/.agents/skills/{review-task → review-code}/reference/review-criteria.zh-CN.md +10 -4
  107. package/templates/.agents/skills/review-plan/SKILL.en.md +76 -0
  108. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +102 -0
  109. package/templates/.agents/skills/{refine-task → review-plan}/config/verify.en.json +14 -10
  110. package/templates/.agents/skills/{refine-task → review-plan}/config/verify.zh-CN.json +14 -10
  111. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +87 -0
  112. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +87 -0
  113. package/templates/.agents/skills/review-plan/reference/report-template.en.md +90 -0
  114. package/templates/.agents/skills/{review-task → review-plan}/reference/report-template.zh-CN.md +3 -3
  115. package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +47 -0
  116. package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +47 -0
  117. package/templates/.agents/skills/test/SKILL.en.md +2 -2
  118. package/templates/.agents/skills/test/SKILL.zh-CN.md +13 -31
  119. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +1 -0
  120. package/templates/.agents/templates/task.en.md +3 -3
  121. package/templates/.agents/templates/task.zh-CN.md +2 -2
  122. package/templates/.agents/workflows/bug-fix.en.yaml +126 -80
  123. package/templates/.agents/workflows/bug-fix.zh-CN.yaml +90 -44
  124. package/templates/.agents/workflows/feature-development.en.yaml +115 -70
  125. package/templates/.agents/workflows/feature-development.zh-CN.yaml +92 -47
  126. package/templates/.agents/workflows/refactoring.en.yaml +123 -78
  127. package/templates/.agents/workflows/refactoring.zh-CN.yaml +89 -44
  128. package/templates/.claude/commands/code-task.en.md +8 -0
  129. package/templates/.claude/commands/code-task.zh-CN.md +8 -0
  130. package/templates/.claude/commands/review-analysis.en.md +8 -0
  131. package/templates/.claude/commands/review-analysis.zh-CN.md +8 -0
  132. package/templates/.claude/commands/review-code.en.md +8 -0
  133. package/templates/.claude/commands/review-code.zh-CN.md +8 -0
  134. package/templates/.claude/commands/review-plan.en.md +8 -0
  135. package/templates/.claude/commands/review-plan.zh-CN.md +8 -0
  136. package/templates/.gemini/commands/_project_/archive-tasks.zh-CN.toml +1 -1
  137. package/templates/.gemini/commands/_project_/code-task.en.toml +8 -0
  138. package/templates/.gemini/commands/_project_/code-task.zh-CN.toml +8 -0
  139. package/templates/.gemini/commands/_project_/init-labels.zh-CN.toml +1 -1
  140. package/templates/.gemini/commands/_project_/init-milestones.zh-CN.toml +1 -1
  141. package/templates/.gemini/commands/_project_/review-analysis.en.toml +8 -0
  142. package/templates/.gemini/commands/_project_/review-analysis.zh-CN.toml +8 -0
  143. package/templates/.gemini/commands/_project_/review-code.en.toml +8 -0
  144. package/templates/.gemini/commands/_project_/review-code.zh-CN.toml +8 -0
  145. package/templates/.gemini/commands/_project_/review-plan.en.toml +8 -0
  146. package/templates/.gemini/commands/_project_/review-plan.zh-CN.toml +8 -0
  147. package/templates/.opencode/commands/code-task.en.md +11 -0
  148. package/templates/.opencode/commands/code-task.zh-CN.md +11 -0
  149. package/templates/.opencode/commands/review-analysis.en.md +11 -0
  150. package/templates/.opencode/commands/review-analysis.zh-CN.md +11 -0
  151. package/templates/.opencode/commands/review-code.en.md +11 -0
  152. package/templates/.opencode/commands/review-code.zh-CN.md +11 -0
  153. package/templates/.opencode/commands/review-plan.en.md +11 -0
  154. package/templates/.opencode/commands/review-plan.zh-CN.md +11 -0
  155. package/templates/.agents/skills/implement-task/SKILL.en.md +0 -173
  156. package/templates/.agents/skills/implement-task/reference/output-template.en.md +0 -20
  157. package/templates/.agents/skills/implement-task/reference/output-template.zh-CN.md +0 -20
  158. package/templates/.agents/skills/refine-task/SKILL.en.md +0 -153
  159. package/templates/.agents/skills/refine-task/SKILL.zh-CN.md +0 -153
  160. package/templates/.agents/skills/refine-task/reference/report-template.en.md +0 -64
  161. package/templates/.agents/skills/refine-task/reference/report-template.zh-CN.md +0 -64
  162. package/templates/.agents/skills/review-task/reference/review-criteria.en.md +0 -42
  163. package/templates/.claude/commands/implement-task.en.md +0 -8
  164. package/templates/.claude/commands/implement-task.zh-CN.md +0 -8
  165. package/templates/.claude/commands/refine-task.en.md +0 -8
  166. package/templates/.claude/commands/refine-task.zh-CN.md +0 -8
  167. package/templates/.claude/commands/review-task.en.md +0 -8
  168. package/templates/.claude/commands/review-task.zh-CN.md +0 -8
  169. package/templates/.gemini/commands/_project_/implement-task.en.toml +0 -8
  170. package/templates/.gemini/commands/_project_/implement-task.zh-CN.toml +0 -8
  171. package/templates/.gemini/commands/_project_/refine-task.en.toml +0 -8
  172. package/templates/.gemini/commands/_project_/refine-task.zh-CN.toml +0 -8
  173. package/templates/.gemini/commands/_project_/review-task.en.toml +0 -8
  174. package/templates/.gemini/commands/_project_/review-task.zh-CN.toml +0 -8
  175. package/templates/.opencode/commands/implement-task.en.md +0 -11
  176. package/templates/.opencode/commands/implement-task.zh-CN.md +0 -11
  177. package/templates/.opencode/commands/refine-task.en.md +0 -11
  178. package/templates/.opencode/commands/refine-task.zh-CN.md +0 -11
  179. package/templates/.opencode/commands/review-task.en.md +0 -11
  180. package/templates/.opencode/commands/review-task.zh-CN.md +0 -11
  181. /package/templates/.agents/skills/{implement-task → code-task}/reference/branch-management.en.md +0 -0
@@ -7,7 +7,7 @@ import { prepareDockerfile } from "../dockerfile.js";
7
7
  import { sandboxImageConfigLabel, sandboxLabel } from "../constants.js";
8
8
  import { detectEngine, ensureDocker } from "../engine.js";
9
9
  import { runEngine, runOkEngine, runSafeEngine, runVerboseEngine } from "../shell.js";
10
- import { resolveTools, toolNpmPackagesArg } from "../tools.js";
10
+ import { imageSignatureFields, resolveTools, toolNpmPackagesArg, toolShellInstallScriptBase64 } from "../tools.js";
11
11
  import { toEnginePath } from "../engines/wsl2-paths.js";
12
12
  import { resolveBuildUid } from "../engines/native.js";
13
13
  const USAGE = `Usage: ai sandbox rebuild [--quiet] [--refresh]`;
@@ -15,7 +15,7 @@ function buildSignature(preparedDockerfile, tools) {
15
15
  return createHash('sha256')
16
16
  .update(JSON.stringify({
17
17
  dockerfile: preparedDockerfile.signature,
18
- tools: tools.map((tool) => tool.npmPackage)
18
+ tools: imageSignatureFields(tools)
19
19
  }))
20
20
  .digest('hex')
21
21
  .slice(0, 12);
@@ -38,6 +38,8 @@ export function buildArgs(config, tools, dockerfilePath, imageSignature, { engin
38
38
  `HOST_GID=${hostGid}`,
39
39
  '--build-arg',
40
40
  `AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
41
+ '--build-arg',
42
+ `AI_TOOLS_SHELL_INSTALL_B64=${toolShellInstallScriptBase64(tools)}`,
41
43
  '--label',
42
44
  sandboxLabel(config),
43
45
  '--label',
@@ -6,6 +6,7 @@ import pc from 'picocolors';
6
6
  import { validateSandboxEngine } from "./engine.js";
7
7
  import { hostJoin } from "./engines/wsl2-paths.js";
8
8
  import { findRuntimeEngineMismatches } from "./runtime-engines.js";
9
+ import { parseCustomTools } from "./tools.js";
9
10
  const DEFAULTS = Object.freeze({
10
11
  engine: null,
11
12
  runtimes: ['node22'],
@@ -76,6 +77,7 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
76
77
  ' Update "sandbox.runtimes" in .agents/.airc.json (e.g. "node22"), or relax "engines.node".\n'));
77
78
  }
78
79
  }
80
+ const customTools = parseCustomTools(sandbox.customTools, { home });
79
81
  return {
80
82
  repoRoot,
81
83
  configPath,
@@ -93,6 +95,7 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
93
95
  tools: Array.isArray(sandbox.tools) && sandbox.tools.length > 0
94
96
  ? [...sandbox.tools]
95
97
  : defaults.tools,
98
+ customTools,
96
99
  dockerfile,
97
100
  vm: {
98
101
  cpu: asPositiveNumberOrNull(sandbox.vm?.cpu) ?? defaults.vm.cpu,
@@ -2,7 +2,8 @@ const USAGE = `Usage: ai sandbox <command> [options]
2
2
 
3
3
  Commands:
4
4
  create <branch> [base] Create a sandbox (VM + image + worktree + container)
5
- exec <branch> [cmd...] Enter sandbox or run a command
5
+ exec <branch | '#N'> [cmd...]
6
+ Enter sandbox or run a command (use leftmost '#' column from 'ls')
6
7
  ls List sandboxes for the current project
7
8
  prune [--dry-run] Remove orphaned per-branch state dirs
8
9
  rebuild [--quiet] [--refresh]
@@ -4,16 +4,20 @@ ENV OPENCODE_DISABLE_AUTOUPDATE=1
4
4
  ENV NPM_CONFIG_PREFIX=/home/devuser/.npm-global
5
5
  ENV PATH="/home/devuser/.npm-global/bin:${PATH}"
6
6
 
7
- ARG AI_TOOL_PACKAGES
8
- RUN if [ -z "${AI_TOOL_PACKAGES}" ]; then \
9
- echo "AI_TOOL_PACKAGES build arg is required"; \
10
- exit 1; \
11
- fi && \
12
- set -e && \
7
+ ARG AI_TOOL_PACKAGES=
8
+ RUN set -e && \
13
9
  for pkg in ${AI_TOOL_PACKAGES}; do \
14
10
  npm install -g "$pkg"; \
15
11
  done
16
12
 
13
+ ARG AI_TOOLS_SHELL_INSTALL_B64=
14
+ RUN if [ -n "${AI_TOOLS_SHELL_INSTALL_B64}" ]; then \
15
+ set -e && \
16
+ echo "${AI_TOOLS_SHELL_INSTALL_B64}" | base64 -d > /tmp/ai-tools-install.sh && \
17
+ bash /tmp/ai-tools-install.sh && \
18
+ rm /tmp/ai-tools-install.sh; \
19
+ fi
20
+
17
21
  RUN npm install -g pyright
18
22
 
19
23
  RUN mkdir -p /home/devuser/.local/share /home/devuser/.local/state
@@ -1,11 +1,12 @@
1
1
  import { safeNameCandidates, sanitizeBranchName } from "./constants.js";
2
2
  import { hostJoin } from "./engines/wsl2-paths.js";
3
+ const TOOL_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
3
4
  function createBuiltinTools(home, project) {
4
5
  return {
5
6
  'claude-code': {
6
7
  id: 'claude-code',
7
8
  name: 'Claude Code',
8
- npmPackage: '@anthropic-ai/claude-code@stable',
9
+ install: { type: 'npm', cmd: '@anthropic-ai/claude-code@stable' },
9
10
  sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'claude-code'),
10
11
  containerMount: '/home/devuser/.claude',
11
12
  versionCmd: 'claude --version',
@@ -35,7 +36,7 @@ function createBuiltinTools(home, project) {
35
36
  codex: {
36
37
  id: 'codex',
37
38
  name: 'Codex',
38
- npmPackage: '@openai/codex',
39
+ install: { type: 'npm', cmd: '@openai/codex' },
39
40
  sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'codex'),
40
41
  containerMount: '/home/devuser/.codex',
41
42
  versionCmd: 'codex --version',
@@ -50,7 +51,7 @@ function createBuiltinTools(home, project) {
50
51
  opencode: {
51
52
  id: 'opencode',
52
53
  name: 'OpenCode',
53
- npmPackage: 'opencode-ai',
54
+ install: { type: 'npm', cmd: 'opencode-ai' },
54
55
  sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'opencode'),
55
56
  containerMount: '/home/devuser/.local/share/opencode',
56
57
  versionCmd: 'opencode version',
@@ -69,7 +70,7 @@ function createBuiltinTools(home, project) {
69
70
  'gemini-cli': {
70
71
  id: 'gemini-cli',
71
72
  name: 'Gemini CLI',
72
- npmPackage: '@google/gemini-cli',
73
+ install: { type: 'npm', cmd: '@google/gemini-cli' },
73
74
  sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'gemini-cli'),
74
75
  containerMount: '/home/devuser/.gemini',
75
76
  versionCmd: 'gemini --version',
@@ -84,15 +85,200 @@ function createBuiltinTools(home, project) {
84
85
  }
85
86
  };
86
87
  }
88
+ export function builtinToolIds() {
89
+ return Object.keys(createBuiltinTools('', ''));
90
+ }
87
91
  function validateTool(tool) {
88
- if (!tool.npmPackage || !tool.containerMount.startsWith('/')) {
89
- throw new Error(`Invalid sandbox tool descriptor: ${tool.id}`);
92
+ if (!tool.id || !TOOL_ID_PATTERN.test(tool.id)) {
93
+ throw new Error(`Invalid sandbox tool id: ${String(tool.id)}`);
94
+ }
95
+ if (!tool.install || (tool.install.type !== 'npm' && tool.install.type !== 'shell')) {
96
+ throw new Error(`Sandbox tool ${tool.id} has invalid install.type`);
97
+ }
98
+ if (!tool.install.cmd) {
99
+ throw new Error(`Sandbox tool ${tool.id} has empty install.cmd`);
100
+ }
101
+ if (!tool.containerMount || !tool.containerMount.startsWith('/')) {
102
+ throw new Error(`Sandbox tool ${tool.id} containerMount must be an absolute path`);
103
+ }
104
+ }
105
+ function isPlainObject(value) {
106
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
107
+ }
108
+ function asString(value, field, context) {
109
+ if (typeof value !== 'string') {
110
+ throw new Error(`${context}: field "${field}" must be a string`);
111
+ }
112
+ return value;
113
+ }
114
+ function asOptionalNonEmptyString(value, field, context) {
115
+ if (value === undefined) {
116
+ return undefined;
117
+ }
118
+ if (typeof value !== 'string') {
119
+ throw new Error(`${context}: field "${field}" must be a string when provided`);
120
+ }
121
+ if (value.length === 0) {
122
+ throw new Error(`${context}: field "${field}" must be non-empty when provided`);
123
+ }
124
+ return value;
125
+ }
126
+ function asStringRecord(value, field, context) {
127
+ if (value === undefined) {
128
+ return undefined;
129
+ }
130
+ if (!isPlainObject(value)) {
131
+ throw new Error(`${context}: field "${field}" must be an object when provided`);
132
+ }
133
+ const out = {};
134
+ for (const [key, val] of Object.entries(value)) {
135
+ if (typeof val !== 'string') {
136
+ throw new Error(`${context}: field "${field}.${key}" must be a string`);
137
+ }
138
+ out[key] = val;
139
+ }
140
+ return out;
141
+ }
142
+ function asStringArray(value, field, context) {
143
+ if (value === undefined) {
144
+ return undefined;
145
+ }
146
+ if (!Array.isArray(value)) {
147
+ throw new Error(`${context}: field "${field}" must be an array when provided`);
148
+ }
149
+ return value.map((item, index) => {
150
+ if (typeof item !== 'string') {
151
+ throw new Error(`${context}: field "${field}[${index}]" must be a string`);
152
+ }
153
+ return item;
154
+ });
155
+ }
156
+ function parseInstall(value, context) {
157
+ if (!isPlainObject(value)) {
158
+ throw new Error(`${context}: field "install" must be an object`);
159
+ }
160
+ const type = value.type;
161
+ if (type !== 'npm' && type !== 'shell') {
162
+ throw new Error(`${context}: field "install.type" must be "npm" or "shell"`);
163
+ }
164
+ const cmd = asString(value.cmd, 'install.cmd', context);
165
+ if (!cmd) {
166
+ throw new Error(`${context}: field "install.cmd" must be non-empty`);
167
+ }
168
+ return { type, cmd };
169
+ }
170
+ function parseHostPreSeedFiles(value, context) {
171
+ if (value === undefined) {
172
+ return undefined;
173
+ }
174
+ if (!Array.isArray(value)) {
175
+ throw new Error(`${context}: field "hostPreSeedFiles" must be an array when provided`);
90
176
  }
177
+ return value.map((item, index) => {
178
+ if (!isPlainObject(item)) {
179
+ throw new Error(`${context}: field "hostPreSeedFiles[${index}]" must be an object`);
180
+ }
181
+ return {
182
+ hostPath: asString(item.hostPath, `hostPreSeedFiles[${index}].hostPath`, context),
183
+ sandboxName: asString(item.sandboxName, `hostPreSeedFiles[${index}].sandboxName`, context)
184
+ };
185
+ });
186
+ }
187
+ function parseHostPreSeedDirs(value, context) {
188
+ if (value === undefined) {
189
+ return undefined;
190
+ }
191
+ if (!Array.isArray(value)) {
192
+ throw new Error(`${context}: field "hostPreSeedDirs" must be an array when provided`);
193
+ }
194
+ return value.map((item, index) => {
195
+ if (!isPlainObject(item)) {
196
+ throw new Error(`${context}: field "hostPreSeedDirs[${index}]" must be an object`);
197
+ }
198
+ return {
199
+ hostDir: asString(item.hostDir, `hostPreSeedDirs[${index}].hostDir`, context),
200
+ sandboxSubdir: asString(item.sandboxSubdir, `hostPreSeedDirs[${index}].sandboxSubdir`, context)
201
+ };
202
+ });
203
+ }
204
+ function parseHostLiveMounts(value, context) {
205
+ if (value === undefined) {
206
+ return undefined;
207
+ }
208
+ if (!Array.isArray(value)) {
209
+ throw new Error(`${context}: field "hostLiveMounts" must be an array when provided`);
210
+ }
211
+ return value.map((item, index) => {
212
+ if (!isPlainObject(item)) {
213
+ throw new Error(`${context}: field "hostLiveMounts[${index}]" must be an object`);
214
+ }
215
+ return {
216
+ hostPath: asString(item.hostPath, `hostLiveMounts[${index}].hostPath`, context),
217
+ containerSubpath: asString(item.containerSubpath, `hostLiveMounts[${index}].containerSubpath`, context)
218
+ };
219
+ });
220
+ }
221
+ export function parseCustomTool(entry, index, options) {
222
+ const context = `customTools[${index}]`;
223
+ if (!isPlainObject(entry)) {
224
+ throw new Error(`${context} must be an object`);
225
+ }
226
+ const id = asString(entry.id, 'id', context);
227
+ if (!TOOL_ID_PATTERN.test(id)) {
228
+ throw new Error(`${context}: field "id" must match ${TOOL_ID_PATTERN.source}`);
229
+ }
230
+ const containerMount = asOptionalNonEmptyString(entry.containerMount, 'containerMount', context)
231
+ ?? `/home/devuser/.${id}`;
232
+ if (!containerMount.startsWith('/')) {
233
+ throw new Error(`${context}: field "containerMount" must be an absolute path`);
234
+ }
235
+ const tool = {
236
+ id,
237
+ name: asOptionalNonEmptyString(entry.name, 'name', context) ?? id,
238
+ install: parseInstall(entry.install, context),
239
+ sandboxBase: hostJoin(options.home, '.agent-infra', 'sandboxes', id),
240
+ containerMount,
241
+ versionCmd: asOptionalNonEmptyString(entry.versionCmd, 'versionCmd', context) ?? `which ${id}`,
242
+ setupHint: asOptionalNonEmptyString(entry.setupHint, 'setupHint', context)
243
+ ?? `Run \`${id}\` inside the container to set up.`,
244
+ envVars: asStringRecord(entry.envVars, 'envVars', context),
245
+ hostPreSeedFiles: parseHostPreSeedFiles(entry.hostPreSeedFiles, context),
246
+ hostPreSeedDirs: parseHostPreSeedDirs(entry.hostPreSeedDirs, context),
247
+ pathRewriteFiles: asStringArray(entry.pathRewriteFiles, 'pathRewriteFiles', context),
248
+ hostLiveMounts: parseHostLiveMounts(entry.hostLiveMounts, context),
249
+ postSetupCmds: asStringArray(entry.postSetupCmds, 'postSetupCmds', context)
250
+ };
251
+ validateTool(tool);
252
+ return tool;
253
+ }
254
+ export function parseCustomTools(value, options) {
255
+ if (value === undefined || value === null) {
256
+ return [];
257
+ }
258
+ if (!Array.isArray(value)) {
259
+ throw new Error('sandbox: "customTools" must be an array');
260
+ }
261
+ return value.map((entry, index) => parseCustomTool(entry, index, options));
91
262
  }
92
263
  export function resolveTools(config) {
93
264
  const builtins = createBuiltinTools(config.home, config.project);
265
+ const customs = config.customTools ?? [];
266
+ const seen = new Set();
267
+ for (const tool of customs) {
268
+ if (builtins[tool.id]) {
269
+ throw new Error(`Custom sandbox tool id "${tool.id}" collides with a built-in tool`);
270
+ }
271
+ if (seen.has(tool.id)) {
272
+ throw new Error(`Duplicate sandbox tool id "${tool.id}" in customTools`);
273
+ }
274
+ seen.add(tool.id);
275
+ }
276
+ const merged = { ...builtins };
277
+ for (const tool of customs) {
278
+ merged[tool.id] = tool;
279
+ }
94
280
  return config.tools.map((id) => {
95
- const tool = builtins[id];
281
+ const tool = merged[id];
96
282
  if (!tool) {
97
283
  throw new Error(`Unknown sandbox tool: ${id}`);
98
284
  }
@@ -110,6 +296,25 @@ export function toolProjectDirCandidates(tool, project) {
110
296
  return [hostJoin(tool.sandboxBase, project)];
111
297
  }
112
298
  export function toolNpmPackagesArg(tools) {
113
- return tools.map((tool) => tool.npmPackage).join(' ');
299
+ return tools
300
+ .filter((tool) => tool.install.type === 'npm')
301
+ .map((tool) => tool.install.cmd)
302
+ .join(' ');
303
+ }
304
+ export function toolShellInstallScript(tools) {
305
+ const blocks = tools
306
+ .filter((tool) => tool.install.type === 'shell')
307
+ .map((tool) => `# install: ${tool.id}\n${tool.install.cmd}`);
308
+ if (blocks.length === 0) {
309
+ return '';
310
+ }
311
+ return ['#!/bin/bash', 'set -e', '', ...blocks, ''].join('\n');
312
+ }
313
+ export function toolShellInstallScriptBase64(tools) {
314
+ const script = toolShellInstallScript(tools);
315
+ return script ? Buffer.from(script, 'utf8').toString('base64') : '';
316
+ }
317
+ export function imageSignatureFields(tools) {
318
+ return tools.map((tool) => ({ id: tool.id, install: tool.install }));
114
319
  }
115
320
  //# sourceMappingURL=tools.js.map
@@ -121,6 +121,7 @@ async function cmdUpdate() {
121
121
  const platformAdded = !config.platform;
122
122
  const sandboxAdded = !config.sandbox;
123
123
  const labelsAdded = !config.labels;
124
+ const requiresPullRequestAdded = config.requiresPullRequest === undefined;
124
125
  let configChanged = changed;
125
126
  if (platformAdded) {
126
127
  config.platform = structuredClone(defaults.platform);
@@ -134,6 +135,10 @@ async function cmdUpdate() {
134
135
  config.labels = structuredClone(defaults.labels);
135
136
  configChanged = true;
136
137
  }
138
+ if (requiresPullRequestAdded) {
139
+ config.requiresPullRequest = defaults.requiresPullRequest;
140
+ configChanged = true;
141
+ }
137
142
  if (configChanged) {
138
143
  console.log('');
139
144
  if (hasNewEntries) {
@@ -145,7 +150,7 @@ async function cmdUpdate() {
145
150
  ok(` merged: ${entry}`);
146
151
  }
147
152
  }
148
- else if (platformAdded || sandboxAdded || labelsAdded) {
153
+ else if (platformAdded || sandboxAdded || labelsAdded || requiresPullRequestAdded) {
149
154
  if (platformAdded) {
150
155
  info(`Default platform config added to ${CONFIG_PATH}.`);
151
156
  }
@@ -155,6 +160,9 @@ async function cmdUpdate() {
155
160
  if (labelsAdded) {
156
161
  info(`Default labels.in config added to ${CONFIG_PATH}.`);
157
162
  }
163
+ if (requiresPullRequestAdded) {
164
+ info(`Default requiresPullRequest=${defaults.requiresPullRequest} added to ${CONFIG_PATH}.`);
165
+ }
158
166
  }
159
167
  else {
160
168
  info(`File registry changed in ${CONFIG_PATH}.`);
@@ -168,6 +176,9 @@ async function cmdUpdate() {
168
176
  if (hasNewEntries && platformAdded) {
169
177
  info(`Default platform config added to ${CONFIG_PATH}.`);
170
178
  }
179
+ if (hasNewEntries && requiresPullRequestAdded) {
180
+ info(`Default requiresPullRequest=${defaults.requiresPullRequest} added to ${CONFIG_PATH}.`);
181
+ }
171
182
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
172
183
  ok(`Updated ${CONFIG_PATH}`);
173
184
  }
package/lib/defaults.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "platform": {
3
3
  "type": "github"
4
4
  },
5
+ "requiresPullRequest": true,
5
6
  "sandbox": {
6
7
  "engine": null,
7
8
  "runtimes": [
package/lib/init.ts CHANGED
@@ -24,6 +24,7 @@ type Defaults = {
24
24
  files: FileRegistry;
25
25
  sandbox: Record<string, unknown>;
26
26
  labels: Record<string, unknown>;
27
+ requiresPullRequest: boolean;
27
28
  };
28
29
 
29
30
  type AgentConfig = {
@@ -31,6 +32,7 @@ type AgentConfig = {
31
32
  org: string;
32
33
  language: string;
33
34
  platform: { type: string };
35
+ requiresPullRequest: boolean;
34
36
  templateVersion: string;
35
37
  sandbox: Record<string, unknown>;
36
38
  labels: Record<string, unknown>;
@@ -207,6 +209,13 @@ async function cmdInit(): Promise<void> {
207
209
  );
208
210
  }
209
211
 
212
+ const requiresPRChoice = await select(
213
+ 'Require Pull Request flow?',
214
+ ['yes', 'no'],
215
+ 'yes'
216
+ );
217
+ const requiresPullRequest = requiresPRChoice !== 'no';
218
+
210
219
  const templateSources = parseLocalSources(await prompt(
211
220
  'Template sources (optional, comma-separated local paths, e.g. ~/my-templates; Enter to skip)',
212
221
  ''
@@ -280,6 +289,7 @@ async function cmdInit(): Promise<void> {
280
289
  org: orgName,
281
290
  language,
282
291
  platform: { type: platformType },
292
+ requiresPullRequest,
283
293
  templateVersion: VERSION,
284
294
  sandbox: structuredClone(defaults.sandbox),
285
295
  labels: structuredClone(defaults.labels),
@@ -36,7 +36,13 @@ import {
36
36
  runVerboseEngine
37
37
  } from '../shell.ts';
38
38
  import { resolveTaskBranch } from '../task-resolver.ts';
39
- import { resolveTools, toolConfigDirCandidates, toolNpmPackagesArg } from '../tools.ts';
39
+ import {
40
+ imageSignatureFields,
41
+ resolveTools,
42
+ toolConfigDirCandidates,
43
+ toolNpmPackagesArg,
44
+ toolShellInstallScriptBase64
45
+ } from '../tools.ts';
40
46
  import type { SandboxTool } from '../tools.ts';
41
47
  import { hostJoin, toEnginePath, volumeArg } from '../engines/wsl2-paths.ts';
42
48
  import { clipboardHostDir, CONTAINER_CLIPBOARD_MOUNT } from '../clipboard/paths.ts';
@@ -113,7 +119,7 @@ function buildSignature(preparedDockerfile: PreparedDockerfile, tools: SandboxTo
113
119
  return createHash('sha256')
114
120
  .update(JSON.stringify({
115
121
  dockerfile: preparedDockerfile.signature,
116
- tools: tools.map((tool) => tool.npmPackage)
122
+ tools: imageSignatureFields(tools)
117
123
  }))
118
124
  .digest('hex')
119
125
  .slice(0, 12);
@@ -1063,6 +1069,8 @@ export function buildImage(
1063
1069
  `HOST_GID=${hostGid}`,
1064
1070
  '--build-arg',
1065
1071
  `AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
1072
+ '--build-arg',
1073
+ `AI_TOOLS_SHELL_INSTALL_B64=${toolShellInstallScriptBase64(tools)}`,
1066
1074
  '--label',
1067
1075
  sandboxLabel(config),
1068
1076
  '--label',
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from '../config.ts';
2
- import { assertValidBranchName, containerNameCandidates } from '../constants.ts';
2
+ import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from '../constants.ts';
3
3
  import { detectEngine } from '../engine.ts';
4
4
  import {
5
5
  formatCredentialWarnings,
@@ -13,8 +13,12 @@ import { resolveTaskBranch } from '../task-resolver.ts';
13
13
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
14
14
  import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.ts';
15
15
  import { detectHostTimezone } from '../host-timezone.ts';
16
+ import { fetchSandboxRows, isTaskShortRef, resolveTaskShortRef } from './list-running.ts';
16
17
 
17
- const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
18
+ const USAGE = `Usage: ai sandbox exec <branch | TASK-id | '#N'> [cmd...]
19
+
20
+ '#N' references the N-th running sandbox in 'ai sandbox ls' order (1-based).
21
+ Quote it as '#N' to avoid shell '#' comment handling.`;
18
22
  const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
19
23
 
20
24
  // Terminal-detection variables that interactive TUIs (e.g. claude-code)
@@ -115,8 +119,14 @@ export async function enter(args: string[]): Promise<number> {
115
119
  const config = loadConfig();
116
120
  validateClaudeCredentialsEnvOverride();
117
121
  const engine = detectEngine(config);
118
- const [branchOrTaskId = '', ...cmd] = args;
119
- const branch = resolveTaskBranch(branchOrTaskId, config.repoRoot);
122
+ const [firstArg = '', ...cmd] = args;
123
+ let branch: string;
124
+ if (isTaskShortRef(firstArg)) {
125
+ const { running } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
126
+ branch = resolveTaskShortRef(firstArg, { running });
127
+ } else {
128
+ branch = resolveTaskBranch(firstArg, config.repoRoot);
129
+ }
120
130
  assertValidBranchName(branch);
121
131
  const running = runSafeEngine(engine, 'docker', ['ps', '--format', '{{.Names}}']).split('\n');
122
132
  const container = containerNameCandidates(config, branch).find((name) => running.includes(name));
@@ -0,0 +1,135 @@
1
+ import { runSafeEngine } from '../shell.ts';
2
+
3
+ export type SandboxRow = {
4
+ name: string;
5
+ status: string;
6
+ branch: string;
7
+ running: boolean;
8
+ index: number | null;
9
+ };
10
+
11
+ export function containerListFormat(): string {
12
+ return '{{.Names}}\t{{.Status}}\t{{.Labels}}';
13
+ }
14
+
15
+ export function parseLabels(csv: string): Record<string, string> {
16
+ if (!csv) {
17
+ return {};
18
+ }
19
+
20
+ const labels: Record<string, string> = {};
21
+ for (const pair of csv.split(',')) {
22
+ if (!pair) {
23
+ continue;
24
+ }
25
+ const eq = pair.indexOf('=');
26
+ if (eq < 0) {
27
+ continue;
28
+ }
29
+ labels[pair.slice(0, eq)] = pair.slice(eq + 1);
30
+ }
31
+ return labels;
32
+ }
33
+
34
+ export function parseSandboxRows(rawOutput: string, branchKey: string): SandboxRow[] {
35
+ if (!rawOutput) {
36
+ return [];
37
+ }
38
+ return rawOutput.split('\n').map((line) => {
39
+ const [name = '', status = '', labelsCsv = ''] = line.split('\t');
40
+ const branch = parseLabels(labelsCsv)[branchKey] ?? '';
41
+ return {
42
+ name,
43
+ status,
44
+ branch,
45
+ running: status.startsWith('Up '),
46
+ index: null
47
+ };
48
+ });
49
+ }
50
+
51
+ export function sortAndIndexSandboxRows(rows: SandboxRow[]): {
52
+ running: SandboxRow[];
53
+ nonRunning: SandboxRow[];
54
+ } {
55
+ const byName = (a: SandboxRow, b: SandboxRow): number => {
56
+ if (a.name < b.name) return -1;
57
+ if (a.name > b.name) return 1;
58
+ return 0;
59
+ };
60
+ const running = rows.filter((row) => row.running).sort(byName).map((row, i) => ({
61
+ ...row,
62
+ index: i + 1
63
+ }));
64
+ const nonRunning = rows.filter((row) => !row.running).sort(byName).map((row) => ({
65
+ ...row,
66
+ index: null
67
+ }));
68
+ return { running, nonRunning };
69
+ }
70
+
71
+ export function fetchSandboxRows(
72
+ engine: string,
73
+ label: string,
74
+ branchKey: string
75
+ ): { running: SandboxRow[]; nonRunning: SandboxRow[] } {
76
+ const raw = runSafeEngine(engine, 'docker', [
77
+ 'ps',
78
+ '-a',
79
+ '--filter',
80
+ `label=${label}`,
81
+ '--format',
82
+ containerListFormat()
83
+ ]);
84
+ return sortAndIndexSandboxRows(parseSandboxRows(raw, branchKey));
85
+ }
86
+
87
+ /**
88
+ * Returns true iff `arg` is a syntactically valid task short reference ('#N').
89
+ * Zero IO. Callers MUST use this as the gate before constructing any context
90
+ * for resolveTaskShortRef — that way non-matching arguments (e.g. '#abc',
91
+ * '#1.5', '#') never trigger sandbox list IO.
92
+ */
93
+ export function isTaskShortRef(arg: string): boolean {
94
+ return /^#\d+$/.test(arg);
95
+ }
96
+
97
+ /**
98
+ * Resolve a task short reference ('#N') to a branch name.
99
+ *
100
+ * Current implementation: treats the digits as a 1-based index into the
101
+ * supplied running-sandbox list (ls view order). This is the *only*
102
+ * resolution path until the global task-short-id registry lands in a
103
+ * follow-up task; do NOT read task.md or scan .agents/workspace/ from this
104
+ * helper here.
105
+ *
106
+ * Precondition: callers MUST gate on isTaskShortRef(arg) === true before
107
+ * constructing ctx and calling this function. Throws when arg is a valid
108
+ * short ref but cannot be resolved (out of range, no running sandboxes,
109
+ * etc.); the caller surfaces the error to the user.
110
+ */
111
+ export function resolveTaskShortRef(
112
+ arg: string,
113
+ ctx: { running: SandboxRow[] }
114
+ ): string {
115
+ const n = Number(arg.slice(1));
116
+ if (n < 1) {
117
+ throw new Error(`Invalid sandbox index '${arg}': must be >= 1`);
118
+ }
119
+ const { running } = ctx;
120
+ if (running.length === 0) {
121
+ throw new Error(`No running sandbox to reference with '${arg}'`);
122
+ }
123
+ if (n > running.length) {
124
+ throw new Error(
125
+ `No running sandbox at index '${arg}' (only ${running.length} running)`
126
+ );
127
+ }
128
+ const row = running[n - 1]!;
129
+ if (!row.branch) {
130
+ throw new Error(
131
+ `Cannot resolve branch for sandbox '${arg}' (container '${row.name}' missing branch label)`
132
+ );
133
+ }
134
+ return row.branch;
135
+ }