@fitlab-ai/agent-infra 0.8.0 → 0.8.2

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 (86) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/bin/cli.ts +4 -4
  4. package/dist/bin/cli.js +4 -4
  5. package/dist/lib/defaults.json +1 -0
  6. package/dist/lib/run/index.js +96 -5
  7. package/dist/lib/sandbox/capture.js +113 -4
  8. package/dist/lib/sandbox/commands/create.js +37 -4
  9. package/dist/lib/sandbox/commands/enter.js +7 -3
  10. package/dist/lib/sandbox/config.js +1 -1
  11. package/dist/lib/sandbox/credentials.js +42 -3
  12. package/dist/lib/sandbox/runtimes/base.dockerfile +13 -0
  13. package/dist/lib/sandbox/runtimes/node20.dockerfile +1 -1
  14. package/dist/lib/sandbox/runtimes/node22.dockerfile +1 -1
  15. package/dist/lib/sandbox/tools.js +9 -0
  16. package/dist/lib/server/adapters/feishu/index.js +5 -3
  17. package/dist/lib/server/adapters/feishu/transport.js +20 -2
  18. package/dist/lib/server/protocol.js +20 -4
  19. package/dist/lib/task/commands/log.js +36 -14
  20. package/dist/lib/task/commands/status.js +144 -45
  21. package/dist/lib/update.js +31 -0
  22. package/lib/defaults.json +1 -0
  23. package/lib/run/index.ts +119 -6
  24. package/lib/sandbox/capture.ts +136 -5
  25. package/lib/sandbox/commands/create.ts +42 -4
  26. package/lib/sandbox/commands/enter.ts +8 -2
  27. package/lib/sandbox/config.ts +1 -1
  28. package/lib/sandbox/credentials.ts +57 -3
  29. package/lib/sandbox/runtimes/base.dockerfile +13 -0
  30. package/lib/sandbox/runtimes/node20.dockerfile +1 -1
  31. package/lib/sandbox/runtimes/node22.dockerfile +1 -1
  32. package/lib/sandbox/tools.ts +9 -0
  33. package/lib/server/adapters/feishu/index.ts +5 -3
  34. package/lib/server/adapters/feishu/transport.ts +32 -3
  35. package/lib/server/protocol.ts +20 -4
  36. package/lib/task/commands/log.ts +36 -14
  37. package/lib/task/commands/status.ts +200 -58
  38. package/lib/update.ts +34 -0
  39. package/package.json +5 -4
  40. package/templates/.agents/README.en.md +3 -3
  41. package/templates/.agents/README.zh-CN.md +3 -3
  42. package/templates/.agents/rules/next-step-output.en.md +1 -1
  43. package/templates/.agents/rules/next-step-output.zh-CN.md +1 -1
  44. package/templates/.agents/rules/pr-sync.github.en.md +3 -3
  45. package/templates/.agents/rules/pr-sync.github.zh-CN.md +3 -3
  46. package/templates/.agents/rules/task-management.en.md +1 -1
  47. package/templates/.agents/rules/task-management.zh-CN.md +1 -1
  48. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  49. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  50. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
  51. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
  52. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +3 -3
  53. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +4 -4
  54. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
  55. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
  56. package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
  57. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
  58. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
  59. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
  60. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +11 -11
  61. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +11 -11
  62. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -4
  63. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -4
  64. package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +5 -5
  65. package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +5 -5
  66. package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
  67. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
  68. package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
  69. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
  70. package/templates/.agents/skills/review-code/reference/output-templates.en.md +16 -16
  71. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +12 -12
  72. package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -4
  73. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -4
  74. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +5 -5
  75. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +5 -5
  76. package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
  77. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
  78. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
  79. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
  80. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +11 -11
  81. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +11 -11
  82. package/templates/.agents/skills/review-plan/reference/report-template.en.md +4 -4
  83. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +4 -4
  84. package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +5 -5
  85. package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +5 -5
  86. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +26 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  <a href="https://www.npmjs.com/package/@fitlab-ai/agent-infra"><img src="https://img.shields.io/npm/v/@fitlab-ai/agent-infra" alt="npm version"></a>
17
17
  <a href="https://www.npmjs.com/package/@fitlab-ai/agent-infra"><img src="https://img.shields.io/npm/dm/@fitlab-ai/agent-infra" alt="npm downloads"></a>
18
18
  <a href="License.txt"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
19
- <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-brightgreen?logo=node.js" alt="Node.js >= 22"></a>
19
+ <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-%3E%3D22.9.0-brightgreen?logo=node.js" alt="Node.js >= 22.9.0"></a>
20
20
  <a href="https://github.com/fitlab-ai/agent-infra/releases"><img src="https://img.shields.io/github/v/release/fitlab-ai/agent-infra" alt="GitHub release"></a>
21
21
  <a href="https://codecov.io/gh/fitlab-ai/agent-infra"><img src="https://codecov.io/gh/fitlab-ai/agent-infra/graph/badge.svg" alt="codecov"></a>
22
22
  <a href="CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome"></a>
package/README.zh-CN.md CHANGED
@@ -16,7 +16,7 @@
16
16
  <a href="https://www.npmjs.com/package/@fitlab-ai/agent-infra"><img src="https://img.shields.io/npm/v/@fitlab-ai/agent-infra" alt="npm version"></a>
17
17
  <a href="https://www.npmjs.com/package/@fitlab-ai/agent-infra"><img src="https://img.shields.io/npm/dm/@fitlab-ai/agent-infra" alt="npm downloads"></a>
18
18
  <a href="License.txt"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT"></a>
19
- <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-brightgreen?logo=node.js" alt="Node.js >= 22"></a>
19
+ <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-%3E%3D22.9.0-brightgreen?logo=node.js" alt="Node.js >= 22.9.0"></a>
20
20
  <a href="https://github.com/fitlab-ai/agent-infra/releases"><img src="https://img.shields.io/github/v/release/fitlab-ai/agent-infra" alt="GitHub release"></a>
21
21
  <a href="https://codecov.io/gh/fitlab-ai/agent-infra"><img src="https://codecov.io/gh/fitlab-ai/agent-infra/graph/badge.svg" alt="codecov"></a>
22
22
  <a href="CONTRIBUTING.zh-CN.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome"></a>
package/bin/cli.ts CHANGED
@@ -2,10 +2,10 @@
2
2
  import { VERSION } from '../lib/version.ts';
3
3
 
4
4
  // Node.js version check
5
- const [major = 0] = process.versions.node.split('.').map((part) => parseInt(part, 10));
6
- if (major < 22) {
5
+ const [major = 0, minor = 0] = process.versions.node.split('.').map((part) => parseInt(part, 10));
6
+ if (major < 22 || (major === 22 && minor < 9)) {
7
7
  process.stderr.write(
8
- `agent-infra requires Node.js >= 22 (current: ${process.version})\n`
8
+ `agent-infra requires Node.js >= 22.9.0 (current: ${process.version})\n`
9
9
  );
10
10
  process.exit(1);
11
11
  }
@@ -20,7 +20,7 @@ Commands:
20
20
  help Show this help message
21
21
  init Initialize a new project with update-agent-infra seed command
22
22
  merge Merge tasks from another workspace directory (active/blocked/completed/archive)
23
- run Run a whitelisted lifecycle skill through a non-interactive TUI
23
+ run Schedule a lifecycle skill in the task sandbox tmux session
24
24
  sandbox, s Manage Docker-based AI sandboxes
25
25
  server Run the local AI collaboration daemon (start/stop/status/logs)
26
26
  task, t Read-only views over .agents/workspace tasks (cat / files / grep / log / ls / show / status)
package/dist/bin/cli.js CHANGED
@@ -9,9 +9,9 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  };
10
10
  import { VERSION } from "../lib/version.js";
11
11
  // Node.js version check
12
- const [major = 0] = process.versions.node.split('.').map((part) => parseInt(part, 10));
13
- if (major < 22) {
14
- process.stderr.write(`agent-infra requires Node.js >= 22 (current: ${process.version})\n`);
12
+ const [major = 0, minor = 0] = process.versions.node.split('.').map((part) => parseInt(part, 10));
13
+ if (major < 22 || (major === 22 && minor < 9)) {
14
+ process.stderr.write(`agent-infra requires Node.js >= 22.9.0 (current: ${process.version})\n`);
15
15
  process.exit(1);
16
16
  }
17
17
  const USAGE = `agent-infra ${VERSION} - bootstrap AI collaboration infrastructure
@@ -24,7 +24,7 @@ Commands:
24
24
  help Show this help message
25
25
  init Initialize a new project with update-agent-infra seed command
26
26
  merge Merge tasks from another workspace directory (active/blocked/completed/archive)
27
- run Run a whitelisted lifecycle skill through a non-interactive TUI
27
+ run Schedule a lifecycle skill in the task sandbox tmux session
28
28
  sandbox, s Manage Docker-based AI sandboxes
29
29
  server Run the local AI collaboration daemon (start/stop/status/logs)
30
30
  task, t Read-only views over .agents/workspace tasks (cat / files / grep / log / ls / show / status)
@@ -8,6 +8,7 @@
8
8
  "node22"
9
9
  ],
10
10
  "tools": [
11
+ "agent-infra",
11
12
  "claude-code",
12
13
  "codex",
13
14
  "gemini-cli",
@@ -1,11 +1,17 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { loadServerConfig } from "../server/config.js";
2
4
  import { resolveTaskBranch } from "../sandbox/task-resolver.js";
3
- import { runInSandbox } from "../sandbox/capture.js";
5
+ import { createRunId, runInSandbox } from "../sandbox/capture.js";
6
+ import { loadShortIdByTaskId, normalizeShortIdInput } from "../task/short-id.js";
4
7
  import { buildTuiCommand, renderPrompt, selectTui } from "./tui.js";
5
8
  import { getSkillRunSpec } from "./skills.js";
6
9
  import { runHostCommand } from "./host.js";
7
10
  const USAGE = `Usage: ai run <skill> [task-ref] [args...] [--tui <name>]
8
11
 
12
+ Task skills are scheduled inside the sandbox tmux session; ai run returns once
13
+ the tmux window is created.
14
+
9
15
  Examples:
10
16
  ai run create-task "describe the task" --tui codex
11
17
  ai run code-task #7 --tui codex`;
@@ -56,6 +62,83 @@ function assertAllowedByConfig(skill, commandConfig) {
56
62
  throw new Error(`Skill '${skill}' is not allowed by command.allowedSkills`);
57
63
  }
58
64
  }
65
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
66
+ function readShortIdLength(repoRoot) {
67
+ try {
68
+ const cfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
69
+ const value = cfg?.task?.shortIdLength;
70
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 1)
71
+ return value;
72
+ }
73
+ catch {
74
+ // Use the project default when config is absent in lightweight tests.
75
+ }
76
+ return 2;
77
+ }
78
+ function readActiveShortIdRegistry(repoRoot) {
79
+ const registryPath = path.join(repoRoot, '.agents', 'workspace', 'active', '.short-ids.json');
80
+ try {
81
+ const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
82
+ return data && typeof data === 'object' && data.ids && typeof data.ids === 'object' ? data.ids : {};
83
+ }
84
+ catch {
85
+ return {};
86
+ }
87
+ }
88
+ function resolveActiveTaskIdentity(taskRef, repoRoot) {
89
+ let taskId = null;
90
+ let resolvedTaskRef = taskRef;
91
+ if (TASK_ID_RE.test(taskRef)) {
92
+ taskId = taskRef;
93
+ resolvedTaskRef = loadShortIdByTaskId(repoRoot).get(taskId) ?? taskRef;
94
+ }
95
+ else {
96
+ const normalized = normalizeShortIdInput(taskRef, { shortIdLength: readShortIdLength(repoRoot) });
97
+ if (normalized.kind !== 'shortId')
98
+ return null;
99
+ resolvedTaskRef = normalized.value;
100
+ taskId = readActiveShortIdRegistry(repoRoot)[normalized.value.slice(1)] ?? null;
101
+ }
102
+ if (!taskId)
103
+ return null;
104
+ const taskDir = path.join(repoRoot, '.agents', 'workspace', 'active', taskId);
105
+ if (!fs.existsSync(path.join(taskDir, 'task.md')))
106
+ return null;
107
+ return { taskId, taskDir, taskRef: resolvedTaskRef };
108
+ }
109
+ function formatLocalTimestamp(date = new Date()) {
110
+ const pad = (n) => String(n).padStart(2, '0');
111
+ const year = date.getFullYear();
112
+ const month = pad(date.getMonth() + 1);
113
+ const day = pad(date.getDate());
114
+ const hour = pad(date.getHours());
115
+ const minute = pad(date.getMinutes());
116
+ const second = pad(date.getSeconds());
117
+ const offsetMinutes = -date.getTimezoneOffset();
118
+ const sign = offsetMinutes >= 0 ? '+' : '-';
119
+ const offsetHour = pad(Math.floor(Math.abs(offsetMinutes) / 60));
120
+ const offsetMinute = pad(Math.abs(offsetMinutes) % 60);
121
+ return `${year}-${month}-${day} ${hour}:${minute}:${second}${sign}${offsetHour}:${offsetMinute}`;
122
+ }
123
+ function writeRunRecord(params) {
124
+ const runsDir = path.join(params.identity.taskDir, 'runs');
125
+ fs.mkdirSync(runsDir, { recursive: true });
126
+ const record = {
127
+ version: 1,
128
+ run_id: params.run.runId,
129
+ task_id: params.identity.taskId,
130
+ task_ref: params.identity.taskRef,
131
+ branch: params.branch,
132
+ engine: params.run.engine,
133
+ container: params.run.container,
134
+ run_dir: params.run.runDir,
135
+ status_file: `${params.run.runDir}/status`,
136
+ log_file: `${params.run.runDir}/output.log`,
137
+ created_at: formatLocalTimestamp(),
138
+ command: params.command
139
+ };
140
+ fs.writeFileSync(path.join(runsDir, `${params.run.runId}.json`), `${JSON.stringify(record, null, 2)}\n`, 'utf8');
141
+ }
59
142
  export async function runSkill(args, options = {}) {
60
143
  const parsed = parseRunArgs(args);
61
144
  const config = options.command ? null : loadServerConfig({ rootDir: options.repoRoot });
@@ -72,13 +155,21 @@ export async function runSkill(args, options = {}) {
72
155
  }
73
156
  const repoRoot = options.repoRoot ?? config?.repoRoot ?? process.cwd();
74
157
  const branch = resolveTaskBranch(parsed.taskRef, repoRoot);
75
- const runSandbox = options.runSandbox ?? ((request) => runInSandbox(request));
76
- const result = await runSandbox({ taskRef: parsed.taskRef, branch, command });
158
+ const identity = resolveActiveTaskIdentity(parsed.taskRef, repoRoot);
159
+ const runId = identity ? createRunId() : undefined;
160
+ const writeStdout = options.writeStdout ?? ((chunk) => process.stdout.write(chunk));
161
+ const writeStderr = options.writeStderr ?? ((chunk) => process.stderr.write(chunk));
162
+ const runSandbox = options.runSandbox ??
163
+ ((request) => runInSandbox(request));
164
+ const result = await runSandbox({ taskRef: parsed.taskRef, branch, command, runId });
77
165
  if (result.stdout) {
78
- (options.writeStdout ?? ((chunk) => process.stdout.write(chunk)))(result.stdout);
166
+ writeStdout(result.stdout);
79
167
  }
80
168
  if (result.stderr) {
81
- (options.writeStderr ?? ((chunk) => process.stderr.write(chunk)))(result.stderr);
169
+ writeStderr(result.stderr);
170
+ }
171
+ if ((result.exitCode ?? (result.signal ? 1 : 0)) === 0 && identity && result.run) {
172
+ writeRunRecord({ identity, run: result.run, branch, command });
82
173
  }
83
174
  return result.exitCode ?? (result.signal ? 1 : 0);
84
175
  }
@@ -9,6 +9,19 @@ async function spawnCapture(file, args) {
9
9
  const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'] });
10
10
  let stdout = '';
11
11
  let stderr = '';
12
+ let settled = false;
13
+ const rejectOnce = (error) => {
14
+ if (settled)
15
+ return;
16
+ settled = true;
17
+ reject(error);
18
+ };
19
+ const resolveOnce = (result) => {
20
+ if (settled)
21
+ return;
22
+ settled = true;
23
+ resolve(result);
24
+ };
12
25
  child.stdout.setEncoding('utf8');
13
26
  child.stderr.setEncoding('utf8');
14
27
  child.stdout.on('data', (chunk) => {
@@ -17,10 +30,93 @@ async function spawnCapture(file, args) {
17
30
  child.stderr.on('data', (chunk) => {
18
31
  stderr += chunk;
19
32
  });
20
- child.on('error', reject);
21
- child.on('close', (exitCode, signal) => resolve({ exitCode, signal, stdout, stderr }));
33
+ child.on('error', rejectOnce);
34
+ child.on('close', (exitCode, signal) => {
35
+ resolveOnce({ exitCode, signal, stdout, stderr });
36
+ });
22
37
  });
23
38
  }
39
+ function shellQuote(value) {
40
+ return `'${value.replace(/'/g, `'\\''`)}'`;
41
+ }
42
+ export function createRunId(date = new Date()) {
43
+ return `run-${date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '').replace('T', '-').replace('Z', '')}-${process.pid}`;
44
+ }
45
+ function buildRunScript(params) {
46
+ const command = params.command.map(shellQuote).join(' ');
47
+ const runDir = shellQuote(params.runDir);
48
+ const runId = shellQuote(params.runId);
49
+ return `#!/bin/sh
50
+ set -u
51
+ cd /workspace
52
+ date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/started_at
53
+ printf '%s\\n' running > ${runDir}/status
54
+ ${command}
55
+ code=$?
56
+ printf '%s\\n' "$code" > ${runDir}/exit_code
57
+ date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/finished_at
58
+ if [ "$code" -eq 0 ]; then
59
+ printf '%s\\n' completed > ${runDir}/status
60
+ else
61
+ printf '%s\\n' failed > ${runDir}/status
62
+ fi
63
+ printf '\\n[agent-infra] run %s finished with exit code %s\\n' ${runId} "$code"
64
+ exec bash -l
65
+ `;
66
+ }
67
+ function buildTmuxLauncher(params) {
68
+ const session = 'work';
69
+ const window = `ai-${params.runId.replace(/^run-/, '').slice(0, 18)}`;
70
+ const runRoot = '/tmp/agent-infra-runs';
71
+ const runDir = `${runRoot}/${params.runId}`;
72
+ const runScript = buildRunScript({ command: params.request.command, runDir, runId: params.runId });
73
+ const runScriptBase64 = Buffer.from(runScript, 'utf8').toString('base64');
74
+ const paneCommand = `cd /workspace && ${shellQuote(`${runDir}/run.sh`)}`;
75
+ return `set -eu
76
+ session=${shellQuote(session)}
77
+ window=${shellQuote(window)}
78
+ run_id=${shellQuote(params.runId)}
79
+ run_dir=${shellQuote(runDir)}
80
+ task_ref=${shellQuote(params.request.taskRef)}
81
+ branch=${shellQuote(params.request.branch)}
82
+
83
+ sandbox-dotfiles-link >/dev/null 2>&1 || true
84
+ mkdir -p "$run_dir"
85
+ printf '%s\\n' pending > "$run_dir/status"
86
+ printf '%s\\n' "$task_ref" > "$run_dir/task_ref"
87
+ printf '%s\\n' "$branch" > "$run_dir/branch"
88
+ printf '%s\\n' ${shellQuote(params.request.command.join(' '))} > "$run_dir/command"
89
+ printf '%s' ${shellQuote(runScriptBase64)} | base64 -d > "$run_dir/run.sh"
90
+ chmod +x "$run_dir/run.sh"
91
+
92
+ if ! command -v tmux >/dev/null 2>&1; then
93
+ printf '%s\\n' "tmux is not installed in this sandbox" >&2
94
+ exit 127
95
+ fi
96
+
97
+ if ! tmux has-session -t "$session" 2>/dev/null; then
98
+ tmux new-session -d -s "$session" -n shell
99
+ fi
100
+
101
+ if [ -n "\${TZ:-}" ]; then
102
+ tmux set-environment -t "$session" TZ "$TZ" 2>/dev/null || true
103
+ fi
104
+
105
+ pane=$(tmux new-window -d -P -F '#{pane_id}' -t "$session" -n "$window")
106
+ printf '%s\\n' "$session" > "$run_dir/session"
107
+ printf '%s\\n' "$window" > "$run_dir/window"
108
+ printf '%s\\n' "$pane" > "$run_dir/pane"
109
+ tmux pipe-pane -o -t "$pane" "cat > $run_dir/output.log"
110
+ tmux send-keys -t "$pane" ${shellQuote(paneCommand)} Enter
111
+
112
+ cat <<EOF
113
+ Started sandbox run $run_id in tmux session '$session', window '$window', pane '$pane'.
114
+ Attach with: ai sandbox enter $task_ref
115
+ Status file: $run_dir/status
116
+ Output log: $run_dir/output.log
117
+ EOF
118
+ `;
119
+ }
24
120
  export async function runInSandbox(request, options = {}) {
25
121
  const config = options.engine ? null : loadConfig();
26
122
  const engine = options.engine ?? detectEngine(config);
@@ -37,13 +133,26 @@ export async function runInSandbox(request, options = {}) {
37
133
  if (!found.running) {
38
134
  (options.startContainer ?? ((name) => startSandboxContainer(engine, name)))(found.name);
39
135
  }
136
+ const runId = options.runId ?? createRunId();
137
+ const runDir = `/tmp/agent-infra-runs/${runId}`;
40
138
  const dockerArgs = [
41
139
  'exec',
42
140
  ...terminalEnvFlags(),
43
141
  ...hostTimezoneEnvFlags(),
44
142
  found.name,
45
- ...request.command
143
+ 'bash',
144
+ '-lc',
145
+ buildTmuxLauncher({ request, runId })
46
146
  ];
47
- return (options.spawn ?? spawnCapture)('docker', dockerArgs);
147
+ const result = await (options.spawn ?? spawnCapture)('docker', dockerArgs);
148
+ return {
149
+ ...result,
150
+ run: {
151
+ runId,
152
+ engine,
153
+ container: found.name,
154
+ runDir
155
+ }
156
+ };
48
157
  }
49
158
  //# sourceMappingURL=capture.js.map
@@ -678,6 +678,38 @@ function resolveHostCatalogPath(value, hostHomeDir) {
678
678
  return null;
679
679
  }
680
680
  }
681
+ const CODEX_DISABLED_FEATURE_FLAGS = ['apps', 'enable_mcp_apps'];
682
+ function removeCodexMcpServers(sandboxParsed) {
683
+ if (!Object.hasOwn(sandboxParsed, 'mcp_servers')) {
684
+ return false;
685
+ }
686
+ delete sandboxParsed.mcp_servers;
687
+ return true;
688
+ }
689
+ function inheritDisabledCodexFeatureFlags(sandboxParsed, hostParsed) {
690
+ if (!isJsonObjectRecord(hostParsed.features)) {
691
+ return false;
692
+ }
693
+ if (Object.hasOwn(sandboxParsed, 'features') && !isJsonObjectRecord(sandboxParsed.features)) {
694
+ return false;
695
+ }
696
+ let sandboxFeatures = sandboxParsed.features;
697
+ let changed = false;
698
+ for (const key of CODEX_DISABLED_FEATURE_FLAGS) {
699
+ if (hostParsed.features[key] !== false) {
700
+ continue;
701
+ }
702
+ if (!sandboxFeatures) {
703
+ sandboxFeatures = {};
704
+ sandboxParsed.features = sandboxFeatures;
705
+ }
706
+ if (sandboxFeatures[key] !== false) {
707
+ sandboxFeatures[key] = false;
708
+ changed = true;
709
+ }
710
+ }
711
+ return changed;
712
+ }
681
713
  export function ensureCodexModelInheritance(toolDir, hostHomeDir, containerCodexDir = '/home/devuser/.codex') {
682
714
  if (!hostHomeDir) {
683
715
  return;
@@ -704,12 +736,13 @@ export function ensureCodexModelInheritance(toolDir, hostHomeDir, containerCodex
704
736
  return;
705
737
  }
706
738
  }
739
+ let changed = removeCodexMcpServers(sandboxParsed);
740
+ changed = inheritDisabledCodexFeatureFlags(sandboxParsed, hostParsed) || changed;
707
741
  const inheritSpecs = [
708
742
  ['model', 'string'],
709
743
  ['model_reasoning_effort', 'string'],
710
744
  ['model_auto_compact_token_limit', 'number']
711
745
  ];
712
- let changed = false;
713
746
  for (const [key, type] of inheritSpecs) {
714
747
  if (Object.hasOwn(sandboxParsed, key)) {
715
748
  continue;
@@ -1113,9 +1146,9 @@ export async function create(args) {
1113
1146
  if (claudeCodeEntry) {
1114
1147
  ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
1115
1148
  ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
1116
- // prepareClaudeCredentials wrote the shared credentials file
1117
- // before this point. If credentials were missing, the
1118
- // claude-code entry was removed from effectiveResolvedTools.
1149
+ // prepareClaudeCredentials wrote OAuth credentials or confirmed
1150
+ // provider/API settings are enough for Claude Code. If no usable
1151
+ // auth was present, the claude-code entry was removed above.
1119
1152
  }
1120
1153
  const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
1121
1154
  if (codexEntry) {
@@ -1,7 +1,7 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from "../constants.js";
3
3
  import { detectEngine } from "../engine.js";
4
- import { formatCredentialWarnings, formatRemaining, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
4
+ import { formatCredentialWarnings, formatRemaining, hasClaudeProviderAuth, reconcileClaudeCredentials, redactCommandError, validateClaudeCredentialsEnvOverride } from "../credentials.js";
5
5
  import { runInteractiveEngine } from "../shell.js";
6
6
  import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
7
7
  import { runInteractiveWithClipboardBridge } from "../clipboard/bridge.js";
@@ -50,7 +50,10 @@ export function runSandboxInteractive(params) {
50
50
  }
51
51
  return runBridge({ engine, dockerArgs, container, home });
52
52
  }
53
- export function formatCredentialSyncStatus(result, isTTY = process.stderr.isTTY) {
53
+ export function formatCredentialSyncStatus(result, isTTY = process.stderr.isTTY, providerAuthAvailable = false) {
54
+ if (providerAuthAvailable && (result.status === 'STALE_ACCESS' || result.status === 'MISSING')) {
55
+ return null;
56
+ }
54
57
  if (result.status === 'STALE_ACCESS') {
55
58
  return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
56
59
  }
@@ -100,8 +103,9 @@ export async function enter(args) {
100
103
  if (config.tools.includes('claude-code')) {
101
104
  try {
102
105
  // Scan all projects so a refresh from a neighbouring sandbox can still flow back to the host.
106
+ const providerAuthAvailable = hasClaudeProviderAuth(config.home);
103
107
  const result = reconcileClaudeCredentials(config.home);
104
- const message = formatCredentialSyncStatus(result);
108
+ const message = formatCredentialSyncStatus(result, process.stderr.isTTY, providerAuthAvailable);
105
109
  if (message) {
106
110
  process.stderr.write(message);
107
111
  }
@@ -10,7 +10,7 @@ import { parseCustomTools } from "./tools.js";
10
10
  const DEFAULTS = Object.freeze({
11
11
  engine: null,
12
12
  runtimes: ['node22'],
13
- tools: ['claude-code', 'codex', 'gemini-cli', 'opencode'],
13
+ tools: ['agent-infra', 'claude-code', 'codex', 'gemini-cli', 'opencode'],
14
14
  dockerfile: null,
15
15
  vm: {
16
16
  cpu: null,
@@ -11,6 +11,12 @@ const REDACTION_PATTERNS = [
11
11
  { pattern: /gh[psoru]_[A-Za-z0-9]{30,}/g, replacement: '[REDACTED github token]' },
12
12
  { pattern: /Bearer\s+[A-Za-z0-9._~+/=-]{20,}/gi, replacement: 'Bearer [REDACTED]' }
13
13
  ];
14
+ function isRecord(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+ function hasNonEmptyString(value) {
18
+ return typeof value === 'string' && value.trim().length > 0;
19
+ }
14
20
  function errorMessage(error) {
15
21
  return error instanceof Error ? error.message : 'unknown error';
16
22
  }
@@ -102,6 +108,28 @@ export function validateClaudeCredentialsEnvOverride(env = process.env) {
102
108
  throw new Error('Invalid AGENT_INFRA_CLAUDE_CREDENTIALS_FILE value. Expected an absolute file path.');
103
109
  }
104
110
  }
111
+ export function hasClaudeProviderAuthInSettings(settings) {
112
+ if (!isRecord(settings)) {
113
+ return false;
114
+ }
115
+ const env = isRecord(settings.env) ? settings.env : {};
116
+ return hasNonEmptyString(env.ANTHROPIC_AUTH_TOKEN)
117
+ || hasNonEmptyString(env.ANTHROPIC_API_KEY)
118
+ || hasNonEmptyString(settings.apiKeyHelper);
119
+ }
120
+ export function hasClaudeProviderAuth(home, options = {}) {
121
+ const { readFn = (targetPath) => fs.readFileSync(targetPath, 'utf8'), existsFn = fs.existsSync } = options;
122
+ const settingsPath = path.join(home, '.claude', 'settings.json');
123
+ if (!existsFn(settingsPath)) {
124
+ return false;
125
+ }
126
+ try {
127
+ return hasClaudeProviderAuthInSettings(JSON.parse(readFn(settingsPath)));
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ }
105
133
  // Reconcile treats the freshest valid endpoint as authoritative so sandbox
106
134
  // token rotations can flow back to the host credential store.
107
135
  function validateClaudeCredentialsBlob(raw, blob = null) {
@@ -395,11 +423,19 @@ export function formatRemaining(expiresAt) {
395
423
  const minutes = totalMinutes % 60;
396
424
  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
397
425
  }
398
- export function prepareClaudeCredentials(home, project, resolvedTools, extractFn = extractClaudeCredentialsBlob, writeFn = writeClaudeCredentialsFile, inspectFn = inspectClaudeKeychainStatus) {
426
+ export function prepareClaudeCredentials(home, project, resolvedTools, extractFn = extractClaudeCredentialsBlob, writeFn = writeClaudeCredentialsFile, inspectFn = inspectClaudeKeychainStatus, providerAuthFn = hasClaudeProviderAuth) {
399
427
  const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
400
428
  if (!claudeCodeEntry) {
401
429
  return { status: 'NOT_APPLICABLE' };
402
430
  }
431
+ const providerAuthAvailable = () => {
432
+ try {
433
+ return providerAuthFn(home);
434
+ }
435
+ catch {
436
+ return false;
437
+ }
438
+ };
403
439
  let blob = null;
404
440
  const hasCustomInspectFn = inspectFn !== inspectClaudeKeychainStatus;
405
441
  const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
@@ -410,7 +446,7 @@ export function prepareClaudeCredentials(home, project, resolvedTools, extractFn
410
446
  blob = inspection.blob;
411
447
  break;
412
448
  case 'MISSING':
413
- return { status: 'SKIPPED' };
449
+ return providerAuthAvailable() ? { status: 'OK' } : { status: 'SKIPPED' };
414
450
  case 'KEYCHAIN_LOCKED':
415
451
  throw new Error([
416
452
  'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
@@ -418,6 +454,9 @@ export function prepareClaudeCredentials(home, project, resolvedTools, extractFn
418
454
  buildLockedGuidance()
419
455
  ].join('\n'));
420
456
  case 'STALE_ACCESS':
457
+ if (providerAuthAvailable()) {
458
+ return { status: 'OK' };
459
+ }
421
460
  throw new Error([
422
461
  'Claude Code credentials on host are invalid or expired.',
423
462
  '',
@@ -447,7 +486,7 @@ export function prepareClaudeCredentials(home, project, resolvedTools, extractFn
447
486
  else {
448
487
  blob = extractFn(home);
449
488
  if (!blob) {
450
- return { status: 'SKIPPED' };
489
+ return providerAuthAvailable() ? { status: 'OK' } : { status: 'SKIPPED' };
451
490
  }
452
491
  }
453
492
  writeFn(home, project, blob);
@@ -61,6 +61,19 @@ RUN cat > /usr/local/bin/cc-token-status <<'SCRIPT' && chmod +x /usr/local/bin/c
61
61
  #!/bin/sh
62
62
  set -eu
63
63
 
64
+ SETTINGS_FILE="/home/devuser/.claude/settings.json"
65
+ if [ -r "$SETTINGS_FILE" ] && jq -e '
66
+ def has_nonempty($v):
67
+ if ($v | type) == "string" then ($v | gsub("^\\s+|\\s+$"; "") | length) > 0 else false end;
68
+ (if type == "object" then . else {} end) as $settings
69
+ | (if ($settings.env | type) == "object" then $settings.env else {} end) as $env
70
+ | has_nonempty($env.ANTHROPIC_AUTH_TOKEN)
71
+ or has_nonempty($env.ANTHROPIC_API_KEY)
72
+ or has_nonempty($settings.apiKeyHelper)
73
+ ' "$SETTINGS_FILE" >/dev/null 2>&1; then
74
+ exit 0
75
+ fi
76
+
64
77
  CRED_FILE="/home/devuser/.claude/.credentials.json"
65
78
  [ -r "$CRED_FILE" ] || exit 0
66
79
 
@@ -1,3 +1,3 @@
1
- RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
1
+ RUN bash -o pipefail -c 'curl -fsSL --retry 5 --retry-delay 2 --retry-all-errors https://deb.nodesource.com/setup_20.x | bash -' && \
2
2
  apt-get install -y nodejs && \
3
3
  rm -rf /var/lib/apt/lists/*
@@ -1,3 +1,3 @@
1
- RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
1
+ RUN bash -o pipefail -c 'curl -fsSL --retry 5 --retry-delay 2 --retry-all-errors https://deb.nodesource.com/setup_22.x | bash -' && \
2
2
  apt-get install -y nodejs && \
3
3
  rm -rf /var/lib/apt/lists/*
@@ -88,6 +88,15 @@ function createBuiltinTools(home, project) {
88
88
  { hostPath: hostJoin(home, '.gemini', 'settings.json'), sandboxName: 'settings.json' },
89
89
  { hostPath: hostJoin(home, '.gemini', 'google_accounts.json'), sandboxName: 'google_accounts.json' }
90
90
  ]
91
+ },
92
+ 'agent-infra': {
93
+ id: 'agent-infra',
94
+ name: 'agent-infra CLI',
95
+ install: { type: 'npm', cmd: '@fitlab-ai/agent-infra@latest' },
96
+ sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'agent-infra'),
97
+ containerMount: '/home/devuser/.agent-infra-cli',
98
+ versionCmd: 'ai version --raw',
99
+ setupHint: 'Provides the ai and agent-infra CLI commands inside the sandbox.'
91
100
  }
92
101
  };
93
102
  }
@@ -1,4 +1,4 @@
1
- import { createFeishuTransport, normalizeMessage } from "./transport.js";
1
+ import { cardMessage, createFeishuTransport, normalizeMessage } from "./transport.js";
2
2
  // Assemble the feishu adapter. The transport is injectable so unit tests can
3
3
  // drive start/dispatch/reply/sendMessage against a fake transport without the
4
4
  // real SDK; production uses the default SDK-backed transport.
@@ -18,7 +18,9 @@ export function createFeishuAdapter(config, transport = createFeishuTransport(co
18
18
  text: normalized.text,
19
19
  messageId: normalized.messageId,
20
20
  raw: normalized.raw,
21
- reply: (text) => transport.send(normalized.chatId, text)
21
+ reply: async (text) => {
22
+ await transport.send(normalized.chatId, cardMessage(text));
23
+ }
22
24
  };
23
25
  await adapterCtx.dispatch(message);
24
26
  }
@@ -31,7 +33,7 @@ export function createFeishuAdapter(config, transport = createFeishuTransport(co
31
33
  await transport.stop();
32
34
  },
33
35
  async sendMessage(target, text) {
34
- await transport.send(target.chatId, text);
36
+ await transport.send(target.chatId, cardMessage(text));
35
37
  }
36
38
  };
37
39
  }