@fitlab-ai/agent-infra 0.7.7 → 0.8.1

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 (142) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/bin/cli.ts +42 -15
  4. package/dist/bin/cli.js +46 -16
  5. package/dist/lib/decide.js +110 -0
  6. package/dist/lib/run/host.js +39 -0
  7. package/dist/lib/run/index.js +193 -0
  8. package/dist/lib/run/prompt.js +2 -0
  9. package/dist/lib/run/skills.js +29 -0
  10. package/dist/lib/run/tui.js +35 -0
  11. package/dist/lib/sandbox/capture.js +158 -0
  12. package/dist/lib/sandbox/commands/create.js +58 -9
  13. package/dist/lib/sandbox/commands/enter.js +7 -3
  14. package/dist/lib/sandbox/credentials.js +42 -3
  15. package/dist/lib/sandbox/runtimes/base.dockerfile +13 -0
  16. package/dist/lib/server/adapters/_contract.js +9 -0
  17. package/dist/lib/server/adapters/feishu/index.js +40 -0
  18. package/dist/lib/server/adapters/feishu/transport.js +95 -0
  19. package/dist/lib/server/auth.js +19 -0
  20. package/dist/lib/server/config.js +183 -0
  21. package/dist/lib/server/daemon.js +96 -0
  22. package/dist/lib/server/index.js +47 -0
  23. package/dist/lib/server/logger.js +34 -0
  24. package/dist/lib/server/plugin-loader.js +73 -0
  25. package/dist/lib/server/process-control.js +200 -0
  26. package/dist/lib/server/protocol.js +69 -0
  27. package/dist/lib/server/redact.js +12 -0
  28. package/dist/lib/server/runner.js +75 -0
  29. package/dist/lib/server/streamer.js +44 -0
  30. package/dist/lib/task/commands/decisions.js +258 -0
  31. package/dist/lib/task/commands/log.js +38 -41
  32. package/dist/lib/task/commands/status.js +144 -45
  33. package/dist/lib/task/index.js +9 -0
  34. package/dist/lib/task/ledger.js +66 -0
  35. package/dist/lib/task/sections.js +30 -1
  36. package/lib/decide.ts +124 -0
  37. package/lib/run/host.ts +47 -0
  38. package/lib/run/index.ts +237 -0
  39. package/lib/run/prompt.ts +1 -0
  40. package/lib/run/skills.ts +34 -0
  41. package/lib/run/tui.ts +40 -0
  42. package/lib/sandbox/capture.ts +215 -0
  43. package/lib/sandbox/commands/create.ts +64 -12
  44. package/lib/sandbox/commands/enter.ts +8 -2
  45. package/lib/sandbox/credentials.ts +57 -3
  46. package/lib/sandbox/runtimes/base.dockerfile +13 -0
  47. package/lib/server/adapters/_contract.ts +42 -0
  48. package/lib/server/adapters/feishu/index.ts +47 -0
  49. package/lib/server/adapters/feishu/transport.ts +128 -0
  50. package/lib/server/auth.ts +28 -0
  51. package/lib/server/config.ts +228 -0
  52. package/lib/server/daemon.ts +110 -0
  53. package/lib/server/index.ts +50 -0
  54. package/lib/server/logger.ts +48 -0
  55. package/lib/server/plugin-loader.ts +84 -0
  56. package/lib/server/process-control.ts +225 -0
  57. package/lib/server/protocol.ts +79 -0
  58. package/lib/server/redact.ts +12 -0
  59. package/lib/server/runner.ts +95 -0
  60. package/lib/server/server.schema.json +127 -0
  61. package/lib/server/streamer.ts +57 -0
  62. package/lib/task/commands/decisions.ts +272 -0
  63. package/lib/task/commands/log.ts +38 -39
  64. package/lib/task/commands/status.ts +200 -58
  65. package/lib/task/index.ts +9 -0
  66. package/lib/task/ledger.ts +73 -0
  67. package/lib/task/sections.ts +30 -1
  68. package/package.json +8 -3
  69. package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
  70. package/templates/.agents/rules/issue-fields.github.en.md +1 -1
  71. package/templates/.agents/rules/issue-fields.github.zh-CN.md +1 -1
  72. package/templates/.agents/rules/next-step-output.en.md +5 -1
  73. package/templates/.agents/rules/next-step-output.zh-CN.md +5 -1
  74. package/templates/.agents/rules/no-mid-flow-questions.en.md +7 -2
  75. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +7 -2
  76. package/templates/.agents/rules/pr-sync.github.en.md +3 -3
  77. package/templates/.agents/rules/pr-sync.github.zh-CN.md +3 -3
  78. package/templates/.agents/rules/review-handshake.en.md +5 -1
  79. package/templates/.agents/rules/review-handshake.zh-CN.md +5 -1
  80. package/templates/.agents/rules/task-management.en.md +2 -2
  81. package/templates/.agents/rules/task-management.zh-CN.md +2 -2
  82. package/templates/.agents/scripts/lib/post-review-commit.js +15 -18
  83. package/templates/.agents/scripts/validate-artifact.js +8 -0
  84. package/templates/.agents/skills/analyze-task/SKILL.en.md +15 -3
  85. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +15 -3
  86. package/templates/.agents/skills/analyze-task/config/verify.en.json +2 -1
  87. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +2 -1
  88. package/templates/.agents/skills/code-task/SKILL.en.md +2 -2
  89. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +3 -3
  90. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
  91. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
  92. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +3 -3
  93. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +4 -4
  94. package/templates/.agents/skills/complete-task/SKILL.en.md +1 -0
  95. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -0
  96. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -1
  97. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -1
  98. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
  99. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
  100. package/templates/.agents/skills/create-task/SKILL.en.md +1 -3
  101. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -3
  102. package/templates/.agents/skills/import-codescan/SKILL.en.md +0 -3
  103. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +0 -3
  104. package/templates/.agents/skills/import-dependabot/SKILL.en.md +0 -1
  105. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +0 -1
  106. package/templates/.agents/skills/import-issue/SKILL.en.md +1 -2
  107. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -2
  108. package/templates/.agents/skills/plan-task/SKILL.en.md +1 -2
  109. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +1 -2
  110. package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
  111. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
  112. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
  113. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
  114. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +11 -11
  115. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +11 -11
  116. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -4
  117. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -4
  118. package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +5 -5
  119. package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +5 -5
  120. package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
  121. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
  122. package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
  123. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
  124. package/templates/.agents/skills/review-code/reference/output-templates.en.md +16 -16
  125. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +12 -12
  126. package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -4
  127. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -4
  128. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +5 -5
  129. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +5 -5
  130. package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
  131. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
  132. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
  133. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
  134. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +11 -11
  135. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +11 -11
  136. package/templates/.agents/skills/review-plan/reference/report-template.en.md +4 -4
  137. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +4 -4
  138. package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +5 -5
  139. package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +5 -5
  140. package/templates/.agents/templates/task.en.md +3 -3
  141. package/templates/.agents/templates/task.zh-CN.md +3 -3
  142. package/templates/.github/workflows/metadata-sync.yml +0 -18
@@ -0,0 +1,237 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadServerConfig } from '../server/config.ts';
4
+ import { resolveTaskBranch } from '../sandbox/task-resolver.ts';
5
+ import { createRunId, runInSandbox, type SandboxRunMetadata } from '../sandbox/capture.ts';
6
+ import { loadShortIdByTaskId, normalizeShortIdInput } from '../task/short-id.ts';
7
+ import { buildTuiCommand, renderPrompt, selectTui } from './tui.ts';
8
+ import { getSkillRunSpec } from './skills.ts';
9
+ import { runHostCommand, type RunProcessResult } from './host.ts';
10
+
11
+ export type ParsedRunArgs = {
12
+ skill: string;
13
+ taskRef: string | null;
14
+ args: string[];
15
+ tui: string | null;
16
+ };
17
+
18
+ export type SandboxRunRequest = {
19
+ taskRef: string;
20
+ branch: string;
21
+ command: string[];
22
+ runId?: string;
23
+ };
24
+
25
+ export type SandboxRunResult = RunProcessResult & {
26
+ run?: SandboxRunMetadata;
27
+ };
28
+
29
+ export type RunSkillOptions = {
30
+ command?: Record<string, unknown>;
31
+ repoRoot?: string;
32
+ runHost?: (command: string[]) => Promise<RunProcessResult>;
33
+ runSandbox?: (request: SandboxRunRequest) => Promise<SandboxRunResult>;
34
+ writeStdout?: (chunk: string) => void;
35
+ writeStderr?: (chunk: string) => void;
36
+ };
37
+
38
+ const USAGE = `Usage: ai run <skill> [task-ref] [args...] [--tui <name>]
39
+
40
+ Task skills are scheduled inside the sandbox tmux session; ai run returns once
41
+ the tmux window is created.
42
+
43
+ Examples:
44
+ ai run create-task "describe the task" --tui codex
45
+ ai run code-task #7 --tui codex`;
46
+
47
+ function extractTui(args: string[]): { rest: string[]; tui: string | null } {
48
+ const rest: string[] = [];
49
+ let tui: string | null = null;
50
+ for (let i = 0; i < args.length; i += 1) {
51
+ const arg = args[i] as string;
52
+ if (arg === '--tui') {
53
+ const value = args[i + 1];
54
+ if (!value) throw new Error('--tui requires a value');
55
+ tui = value;
56
+ i += 1;
57
+ continue;
58
+ }
59
+ rest.push(arg);
60
+ }
61
+ return { rest, tui };
62
+ }
63
+
64
+ export function parseRunArgs(args: string[]): ParsedRunArgs {
65
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
66
+ throw new Error(USAGE);
67
+ }
68
+ const { rest, tui } = extractTui(args);
69
+ const [skill = '', maybeTaskRef, ...remaining] = rest;
70
+ const spec = getSkillRunSpec(skill);
71
+ if (!spec) throw new Error(`Unknown skill '${skill}'`);
72
+ if (spec.kind === 'create') {
73
+ const createArgs = rest.slice(1);
74
+ if (createArgs.length === 0) throw new Error('create-task requires a description');
75
+ return { skill, taskRef: null, args: createArgs, tui };
76
+ }
77
+ if (!maybeTaskRef) throw new Error(`${skill} requires a task-ref`);
78
+ return { skill, taskRef: maybeTaskRef, args: remaining, tui };
79
+ }
80
+
81
+ function assertAllowedByConfig(skill: string, commandConfig: Record<string, unknown>): void {
82
+ const allowed = commandConfig.allowedSkills;
83
+ if (!Array.isArray(allowed)) return;
84
+ if (!allowed.every((entry) => typeof entry === 'string')) {
85
+ throw new Error('command.allowedSkills must be an array of skill names');
86
+ }
87
+ if (!allowed.includes(skill)) {
88
+ throw new Error(`Skill '${skill}' is not allowed by command.allowedSkills`);
89
+ }
90
+ }
91
+
92
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
93
+
94
+ function readShortIdLength(repoRoot: string): number {
95
+ try {
96
+ const cfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
97
+ const value = cfg?.task?.shortIdLength;
98
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 1) return value;
99
+ } catch {
100
+ // Use the project default when config is absent in lightweight tests.
101
+ }
102
+ return 2;
103
+ }
104
+
105
+ function readActiveShortIdRegistry(repoRoot: string): Record<string, string> {
106
+ const registryPath = path.join(repoRoot, '.agents', 'workspace', 'active', '.short-ids.json');
107
+ try {
108
+ const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
109
+ return data && typeof data === 'object' && data.ids && typeof data.ids === 'object' ? data.ids : {};
110
+ } catch {
111
+ return {};
112
+ }
113
+ }
114
+
115
+ type ActiveTaskIdentity = {
116
+ taskId: string;
117
+ taskDir: string;
118
+ taskRef: string;
119
+ };
120
+
121
+ function resolveActiveTaskIdentity(taskRef: string, repoRoot: string): ActiveTaskIdentity | null {
122
+ let taskId: string | null = null;
123
+ let resolvedTaskRef = taskRef;
124
+
125
+ if (TASK_ID_RE.test(taskRef)) {
126
+ taskId = taskRef;
127
+ resolvedTaskRef = loadShortIdByTaskId(repoRoot).get(taskId) ?? taskRef;
128
+ } else {
129
+ const normalized = normalizeShortIdInput(taskRef, { shortIdLength: readShortIdLength(repoRoot) });
130
+ if (normalized.kind !== 'shortId') return null;
131
+ resolvedTaskRef = normalized.value;
132
+ taskId = readActiveShortIdRegistry(repoRoot)[normalized.value.slice(1)] ?? null;
133
+ }
134
+
135
+ if (!taskId) return null;
136
+ const taskDir = path.join(repoRoot, '.agents', 'workspace', 'active', taskId);
137
+ if (!fs.existsSync(path.join(taskDir, 'task.md'))) return null;
138
+ return { taskId, taskDir, taskRef: resolvedTaskRef };
139
+ }
140
+
141
+ function formatLocalTimestamp(date: Date = new Date()): string {
142
+ const pad = (n: number): string => String(n).padStart(2, '0');
143
+ const year = date.getFullYear();
144
+ const month = pad(date.getMonth() + 1);
145
+ const day = pad(date.getDate());
146
+ const hour = pad(date.getHours());
147
+ const minute = pad(date.getMinutes());
148
+ const second = pad(date.getSeconds());
149
+ const offsetMinutes = -date.getTimezoneOffset();
150
+ const sign = offsetMinutes >= 0 ? '+' : '-';
151
+ const offsetHour = pad(Math.floor(Math.abs(offsetMinutes) / 60));
152
+ const offsetMinute = pad(Math.abs(offsetMinutes) % 60);
153
+ return `${year}-${month}-${day} ${hour}:${minute}:${second}${sign}${offsetHour}:${offsetMinute}`;
154
+ }
155
+
156
+ function writeRunRecord(params: {
157
+ identity: ActiveTaskIdentity;
158
+ run: SandboxRunMetadata;
159
+ branch: string;
160
+ command: string[];
161
+ }): void {
162
+ const runsDir = path.join(params.identity.taskDir, 'runs');
163
+ fs.mkdirSync(runsDir, { recursive: true });
164
+ const record = {
165
+ version: 1,
166
+ run_id: params.run.runId,
167
+ task_id: params.identity.taskId,
168
+ task_ref: params.identity.taskRef,
169
+ branch: params.branch,
170
+ engine: params.run.engine,
171
+ container: params.run.container,
172
+ run_dir: params.run.runDir,
173
+ status_file: `${params.run.runDir}/status`,
174
+ log_file: `${params.run.runDir}/output.log`,
175
+ created_at: formatLocalTimestamp(),
176
+ command: params.command
177
+ };
178
+ fs.writeFileSync(
179
+ path.join(runsDir, `${params.run.runId}.json`),
180
+ `${JSON.stringify(record, null, 2)}\n`,
181
+ 'utf8'
182
+ );
183
+ }
184
+
185
+ export async function runSkill(args: string[], options: RunSkillOptions = {}): Promise<number> {
186
+ const parsed = parseRunArgs(args);
187
+ const config = options.command ? null : loadServerConfig({ rootDir: options.repoRoot });
188
+ const commandConfig = options.command ?? config?.command ?? {};
189
+ assertAllowedByConfig(parsed.skill, commandConfig);
190
+ const tui = selectTui(parsed.skill, { cliTui: parsed.tui, command: commandConfig });
191
+ const promptArgs = parsed.taskRef === null ? parsed.args : [parsed.taskRef, ...parsed.args];
192
+ const prompt = renderPrompt({ tui, skill: parsed.skill, args: promptArgs });
193
+ const [file, argv] = buildTuiCommand(tui, prompt);
194
+ const command = [file, ...argv];
195
+
196
+ if (parsed.taskRef === null) {
197
+ const result = await (options.runHost ?? runHostCommand)(command);
198
+ return result.exitCode ?? (result.signal ? 1 : 0);
199
+ }
200
+
201
+ const repoRoot = options.repoRoot ?? config?.repoRoot ?? process.cwd();
202
+ const branch = resolveTaskBranch(parsed.taskRef, repoRoot);
203
+ const identity = resolveActiveTaskIdentity(parsed.taskRef, repoRoot);
204
+ const runId = identity ? createRunId() : undefined;
205
+ const writeStdout = options.writeStdout ?? ((chunk: string) => process.stdout.write(chunk));
206
+ const writeStderr = options.writeStderr ?? ((chunk: string) => process.stderr.write(chunk));
207
+ const runSandbox =
208
+ options.runSandbox ??
209
+ ((request: SandboxRunRequest) => runInSandbox(request));
210
+ const result = await runSandbox({ taskRef: parsed.taskRef, branch, command, runId });
211
+ if (result.stdout) {
212
+ writeStdout(result.stdout);
213
+ }
214
+ if (result.stderr) {
215
+ writeStderr(result.stderr);
216
+ }
217
+ if ((result.exitCode ?? (result.signal ? 1 : 0)) === 0 && identity && result.run) {
218
+ writeRunRecord({ identity, run: result.run, branch, command });
219
+ }
220
+ return result.exitCode ?? (result.signal ? 1 : 0);
221
+ }
222
+
223
+ export async function cmdRun(args: string[]): Promise<void> {
224
+ try {
225
+ const code = await runSkill(args);
226
+ process.exitCode = code;
227
+ } catch (error) {
228
+ const message = error instanceof Error ? error.message : String(error);
229
+ if (message.startsWith('Usage:')) {
230
+ process.stdout.write(`${message}\n`);
231
+ process.exitCode = args.length === 0 ? 1 : 0;
232
+ } else {
233
+ process.stderr.write(`Error: ${message}\n`);
234
+ process.exitCode = 1;
235
+ }
236
+ }
237
+ }
@@ -0,0 +1 @@
1
+ export { renderPrompt } from './tui.ts';
@@ -0,0 +1,34 @@
1
+ export type SkillRunSpec =
2
+ | { kind: 'task'; skill: string; role: 'exec'; requiresSandbox: true }
3
+ | { kind: 'create'; skill: 'create-task'; role: 'exec'; requiresSandbox: false };
4
+
5
+ const TASK_SKILLS = new Set([
6
+ 'analyze-task',
7
+ 'block-task',
8
+ 'cancel-task',
9
+ 'code-task',
10
+ 'commit',
11
+ 'complete-task',
12
+ 'create-pr',
13
+ 'plan-task',
14
+ 'review-analysis',
15
+ 'review-code',
16
+ 'review-plan',
17
+ 'test',
18
+ 'test-integration',
19
+ 'watch-pr'
20
+ ]);
21
+
22
+ export function getSkillRunSpec(skill: string): SkillRunSpec | null {
23
+ if (skill === 'create-task') {
24
+ return { kind: 'create', skill: 'create-task', role: 'exec', requiresSandbox: false };
25
+ }
26
+ if (TASK_SKILLS.has(skill)) {
27
+ return { kind: 'task', skill, role: 'exec', requiresSandbox: true };
28
+ }
29
+ return null;
30
+ }
31
+
32
+ export function allowedSkillNames(): string[] {
33
+ return ['create-task', ...TASK_SKILLS].sort();
34
+ }
package/lib/run/tui.ts ADDED
@@ -0,0 +1,40 @@
1
+ export type TuiName = 'claude' | 'codex' | 'gemini' | 'opencode';
2
+
3
+ const TUI_NAMES = new Set(['claude', 'codex', 'gemini', 'opencode']);
4
+
5
+ export type CommandConfig = {
6
+ defaultTui?: unknown;
7
+ skillTuiDefaults?: unknown;
8
+ };
9
+
10
+ function isTuiName(value: unknown): value is TuiName {
11
+ return typeof value === 'string' && TUI_NAMES.has(value);
12
+ }
13
+
14
+ export function selectTui(
15
+ skill: string,
16
+ options: { cliTui?: string | null; command?: CommandConfig }
17
+ ): TuiName {
18
+ if (isTuiName(options.cliTui)) return options.cliTui;
19
+ const defaults = options.command?.skillTuiDefaults;
20
+ if (defaults && typeof defaults === 'object' && !Array.isArray(defaults)) {
21
+ const value = (defaults as Record<string, unknown>)[skill];
22
+ if (isTuiName(value)) return value;
23
+ }
24
+ if (isTuiName(options.command?.defaultTui)) return options.command.defaultTui;
25
+ return 'codex';
26
+ }
27
+
28
+ export function renderPrompt(params: { tui: TuiName; skill: string; args: string[] }): string {
29
+ const suffix = [params.skill, ...params.args].join(' ').trim();
30
+ if (params.tui === 'codex') return `$${suffix}`;
31
+ if (params.tui === 'gemini') return `/agent-infra:${suffix}`;
32
+ return `/${suffix}`;
33
+ }
34
+
35
+ export function buildTuiCommand(tui: TuiName, prompt: string): [string, string[]] {
36
+ if (tui === 'claude') return ['claude', ['--dangerously-skip-permissions', '--print', prompt]];
37
+ if (tui === 'gemini') return ['gemini', ['--approval-mode', 'yolo', '--prompt', prompt]];
38
+ if (tui === 'opencode') return ['opencode', ['run', '--dangerously-skip-permissions', prompt]];
39
+ return ['codex', ['exec', '--dangerously-bypass-approvals-and-sandbox', prompt]];
40
+ }
@@ -0,0 +1,215 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { loadConfig } from './config.ts';
3
+ import { containerNameCandidates, sandboxBranchLabel, sandboxLabel } from './constants.ts';
4
+ import { detectEngine } from './engine.ts';
5
+ import { hostTimezoneEnvFlags, terminalEnvFlags } from './commands/enter.ts';
6
+ import {
7
+ fetchSandboxRows,
8
+ selectSandboxContainer,
9
+ startSandboxContainer,
10
+ type SandboxRow
11
+ } from './commands/list-running.ts';
12
+
13
+ export type SandboxCaptureRequest = {
14
+ taskRef: string;
15
+ branch: string;
16
+ command: string[];
17
+ timeoutMs?: number;
18
+ };
19
+
20
+ export type SandboxCaptureResult = {
21
+ exitCode: number | null;
22
+ signal: NodeJS.Signals | null;
23
+ stdout: string;
24
+ stderr: string;
25
+ run?: SandboxRunMetadata;
26
+ };
27
+
28
+ export type SandboxRunMetadata = {
29
+ runId: string;
30
+ engine: string;
31
+ container: string;
32
+ runDir: string;
33
+ };
34
+
35
+ export type SandboxCaptureOptions = {
36
+ engine?: string;
37
+ repoRoot?: string;
38
+ runId?: string;
39
+ containerCandidates?: string[];
40
+ rows?: SandboxRow[];
41
+ startContainer?: (name: string) => void;
42
+ spawn?: (file: string, args: string[]) => Promise<SandboxCaptureResult>;
43
+ };
44
+
45
+ async function spawnCapture(
46
+ file: string,
47
+ args: string[]
48
+ ): Promise<SandboxCaptureResult> {
49
+ return new Promise((resolve, reject) => {
50
+ const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'] });
51
+ let stdout = '';
52
+ let stderr = '';
53
+ let settled = false;
54
+ const rejectOnce = (error: unknown): void => {
55
+ if (settled) return;
56
+ settled = true;
57
+ reject(error);
58
+ };
59
+ const resolveOnce = (result: SandboxCaptureResult): void => {
60
+ if (settled) return;
61
+ settled = true;
62
+ resolve(result);
63
+ };
64
+ child.stdout.setEncoding('utf8');
65
+ child.stderr.setEncoding('utf8');
66
+ child.stdout.on('data', (chunk) => {
67
+ stdout += chunk;
68
+ });
69
+ child.stderr.on('data', (chunk) => {
70
+ stderr += chunk;
71
+ });
72
+ child.on('error', rejectOnce);
73
+ child.on('close', (exitCode, signal) => {
74
+ resolveOnce({ exitCode, signal, stdout, stderr });
75
+ });
76
+ });
77
+ }
78
+
79
+ function shellQuote(value: string): string {
80
+ return `'${value.replace(/'/g, `'\\''`)}'`;
81
+ }
82
+
83
+ export function createRunId(date: Date = new Date()): string {
84
+ return `run-${date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '').replace('T', '-').replace('Z', '')}-${process.pid}`;
85
+ }
86
+
87
+ function buildRunScript(params: {
88
+ command: string[];
89
+ runDir: string;
90
+ runId: string;
91
+ }): string {
92
+ const command = params.command.map(shellQuote).join(' ');
93
+ const runDir = shellQuote(params.runDir);
94
+ const runId = shellQuote(params.runId);
95
+ return `#!/bin/sh
96
+ set -u
97
+ cd /workspace
98
+ date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/started_at
99
+ printf '%s\\n' running > ${runDir}/status
100
+ ${command}
101
+ code=$?
102
+ printf '%s\\n' "$code" > ${runDir}/exit_code
103
+ date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/finished_at
104
+ if [ "$code" -eq 0 ]; then
105
+ printf '%s\\n' completed > ${runDir}/status
106
+ else
107
+ printf '%s\\n' failed > ${runDir}/status
108
+ fi
109
+ printf '\\n[agent-infra] run %s finished with exit code %s\\n' ${runId} "$code"
110
+ exec bash -l
111
+ `;
112
+ }
113
+
114
+ function buildTmuxLauncher(params: {
115
+ request: SandboxCaptureRequest;
116
+ runId: string;
117
+ }): string {
118
+ const session = 'work';
119
+ const window = `ai-${params.runId.replace(/^run-/, '').slice(0, 18)}`;
120
+ const runRoot = '/tmp/agent-infra-runs';
121
+ const runDir = `${runRoot}/${params.runId}`;
122
+ const runScript = buildRunScript({ command: params.request.command, runDir, runId: params.runId });
123
+ const runScriptBase64 = Buffer.from(runScript, 'utf8').toString('base64');
124
+ const paneCommand = `cd /workspace && ${shellQuote(`${runDir}/run.sh`)}`;
125
+
126
+ return `set -eu
127
+ session=${shellQuote(session)}
128
+ window=${shellQuote(window)}
129
+ run_id=${shellQuote(params.runId)}
130
+ run_dir=${shellQuote(runDir)}
131
+ task_ref=${shellQuote(params.request.taskRef)}
132
+ branch=${shellQuote(params.request.branch)}
133
+
134
+ sandbox-dotfiles-link >/dev/null 2>&1 || true
135
+ mkdir -p "$run_dir"
136
+ printf '%s\\n' pending > "$run_dir/status"
137
+ printf '%s\\n' "$task_ref" > "$run_dir/task_ref"
138
+ printf '%s\\n' "$branch" > "$run_dir/branch"
139
+ printf '%s\\n' ${shellQuote(params.request.command.join(' '))} > "$run_dir/command"
140
+ printf '%s' ${shellQuote(runScriptBase64)} | base64 -d > "$run_dir/run.sh"
141
+ chmod +x "$run_dir/run.sh"
142
+
143
+ if ! command -v tmux >/dev/null 2>&1; then
144
+ printf '%s\\n' "tmux is not installed in this sandbox" >&2
145
+ exit 127
146
+ fi
147
+
148
+ if ! tmux has-session -t "$session" 2>/dev/null; then
149
+ tmux new-session -d -s "$session" -n shell
150
+ fi
151
+
152
+ if [ -n "\${TZ:-}" ]; then
153
+ tmux set-environment -t "$session" TZ "$TZ" 2>/dev/null || true
154
+ fi
155
+
156
+ pane=$(tmux new-window -d -P -F '#{pane_id}' -t "$session" -n "$window")
157
+ printf '%s\\n' "$session" > "$run_dir/session"
158
+ printf '%s\\n' "$window" > "$run_dir/window"
159
+ printf '%s\\n' "$pane" > "$run_dir/pane"
160
+ tmux pipe-pane -o -t "$pane" "cat > $run_dir/output.log"
161
+ tmux send-keys -t "$pane" ${shellQuote(paneCommand)} Enter
162
+
163
+ cat <<EOF
164
+ Started sandbox run $run_id in tmux session '$session', window '$window', pane '$pane'.
165
+ Attach with: ai sandbox enter $task_ref
166
+ Status file: $run_dir/status
167
+ Output log: $run_dir/output.log
168
+ EOF
169
+ `;
170
+ }
171
+
172
+ export async function runInSandbox(
173
+ request: SandboxCaptureRequest,
174
+ options: SandboxCaptureOptions = {}
175
+ ): Promise<SandboxCaptureResult> {
176
+ const config = options.engine ? null : loadConfig();
177
+ const engine = options.engine ?? detectEngine(config!);
178
+ const rows =
179
+ options.rows ??
180
+ (() => {
181
+ const fetched = fetchSandboxRows(engine, sandboxLabel(config!), sandboxBranchLabel(config!));
182
+ return [...fetched.running, ...fetched.nonRunning];
183
+ })();
184
+ const candidates = options.containerCandidates ?? containerNameCandidates(config!, request.branch);
185
+ const found = selectSandboxContainer(rows, candidates);
186
+ if (!found) {
187
+ throw new Error(
188
+ `Sandbox for ${request.branch} not found. Create it first with ai sandbox create ${request.taskRef}.`
189
+ );
190
+ }
191
+ if (!found.running) {
192
+ (options.startContainer ?? ((name: string) => startSandboxContainer(engine, name)))(found.name);
193
+ }
194
+ const runId = options.runId ?? createRunId();
195
+ const runDir = `/tmp/agent-infra-runs/${runId}`;
196
+ const dockerArgs = [
197
+ 'exec',
198
+ ...terminalEnvFlags(),
199
+ ...hostTimezoneEnvFlags(),
200
+ found.name,
201
+ 'bash',
202
+ '-lc',
203
+ buildTmuxLauncher({ request, runId })
204
+ ];
205
+ const result = await (options.spawn ?? spawnCapture)('docker', dockerArgs);
206
+ return {
207
+ ...result,
208
+ run: {
209
+ runId,
210
+ engine,
211
+ container: found.name,
212
+ runDir
213
+ }
214
+ };
215
+ }
@@ -740,6 +740,63 @@ function readHostJsonSafe(filePath: string): JsonObject | null {
740
740
  }
741
741
  }
742
742
 
743
+ const CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS = [
744
+ 'model',
745
+ 'fallbackModel',
746
+ 'availableModels',
747
+ 'modelOverrides',
748
+ 'enforceAvailableModels',
749
+ 'advisorModel',
750
+ 'apiKeyHelper',
751
+ 'effortLevel'
752
+ ];
753
+
754
+ function isJsonObjectRecord(value: unknown): value is JsonObject {
755
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
756
+ }
757
+
758
+ function mergeMissingStringEnvFields(target: JsonObject, source: JsonObject): boolean {
759
+ if (!isJsonObjectRecord(source.env)) {
760
+ return false;
761
+ }
762
+ if (Object.hasOwn(target, 'env') && !isJsonObjectRecord(target.env)) {
763
+ return false;
764
+ }
765
+
766
+ let targetEnv = target.env as JsonObject | undefined;
767
+ let changed = false;
768
+ for (const [key, value] of Object.entries(source.env)) {
769
+ if (typeof value !== 'string' || value === '') {
770
+ continue;
771
+ }
772
+ if (!targetEnv) {
773
+ targetEnv = {};
774
+ target.env = targetEnv;
775
+ }
776
+ if (!Object.hasOwn(targetEnv, key)) {
777
+ targetEnv[key] = value;
778
+ changed = true;
779
+ }
780
+ }
781
+ return changed;
782
+ }
783
+
784
+ function mergeMissingTopLevelSettings(target: JsonObject, source: JsonObject): boolean {
785
+ let changed = false;
786
+ for (const key of CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS) {
787
+ if (!Object.hasOwn(source, key) || Object.hasOwn(target, key)) {
788
+ continue;
789
+ }
790
+ const value = source[key];
791
+ if (value === null || value === undefined || value === '') {
792
+ continue;
793
+ }
794
+ target[key] = value;
795
+ changed = true;
796
+ }
797
+ return changed;
798
+ }
799
+
743
800
  export function ensureClaudeOnboarding(toolDir: string, hostHomeDir?: string): void {
744
801
  const claudeJsonPath = path.join(toolDir, '.claude.json');
745
802
  let data: JsonObject & {
@@ -810,7 +867,7 @@ export function ensureClaudeOnboarding(toolDir: string, hostHomeDir?: string): v
810
867
 
811
868
  export function ensureClaudeSettings(toolDir: string, hostHomeDir?: string): void {
812
869
  const settingsPath = path.join(toolDir, 'settings.json');
813
- let data: JsonObject & { skipDangerousModePermissionPrompt?: boolean; effortLevel?: string } = {};
870
+ let data: JsonObject & { skipDangerousModePermissionPrompt?: boolean } = {};
814
871
  if (fs.existsSync(settingsPath)) {
815
872
  try {
816
873
  data = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as typeof data;
@@ -825,14 +882,9 @@ export function ensureClaudeSettings(toolDir: string, hostHomeDir?: string): voi
825
882
  }
826
883
  if (hostHomeDir) {
827
884
  const hostSettings = readHostJsonSafe(path.join(hostHomeDir, '.claude', 'settings.json'));
828
- if (
829
- hostSettings
830
- && typeof hostSettings.effortLevel === 'string'
831
- && hostSettings.effortLevel !== ''
832
- && !Object.hasOwn(data, 'effortLevel')
833
- ) {
834
- data.effortLevel = hostSettings.effortLevel;
835
- changed = true;
885
+ if (hostSettings) {
886
+ changed = mergeMissingStringEnvFields(data, hostSettings) || changed;
887
+ changed = mergeMissingTopLevelSettings(data, hostSettings) || changed;
836
888
  }
837
889
  }
838
890
  if (changed) {
@@ -1386,9 +1438,9 @@ export async function create(args: string[]): Promise<void> {
1386
1438
  if (claudeCodeEntry) {
1387
1439
  ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
1388
1440
  ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
1389
- // prepareClaudeCredentials wrote the shared credentials file
1390
- // before this point. If credentials were missing, the
1391
- // claude-code entry was removed from effectiveResolvedTools.
1441
+ // prepareClaudeCredentials wrote OAuth credentials or confirmed
1442
+ // provider/API settings are enough for Claude Code. If no usable
1443
+ // auth was present, the claude-code entry was removed above.
1392
1444
  }
1393
1445
  const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
1394
1446
  if (codexEntry) {
@@ -9,6 +9,7 @@ import { detectEngine } from '../engine.ts';
9
9
  import {
10
10
  formatCredentialWarnings,
11
11
  formatRemaining,
12
+ hasClaudeProviderAuth,
12
13
  reconcileClaudeCredentials,
13
14
  redactCommandError,
14
15
  validateClaudeCredentialsEnvOverride
@@ -93,8 +94,12 @@ export function runSandboxInteractive(params: {
93
94
 
94
95
  export function formatCredentialSyncStatus(
95
96
  result: ReturnType<typeof reconcileClaudeCredentials>,
96
- isTTY = process.stderr.isTTY
97
+ isTTY = process.stderr.isTTY,
98
+ providerAuthAvailable = false
97
99
  ): string | null {
100
+ if (providerAuthAvailable && (result.status === 'STALE_ACCESS' || result.status === 'MISSING')) {
101
+ return null;
102
+ }
98
103
  if (result.status === 'STALE_ACCESS') {
99
104
  return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
100
105
  }
@@ -158,8 +163,9 @@ export async function enter(args: string[]): Promise<number> {
158
163
  if (config.tools.includes('claude-code')) {
159
164
  try {
160
165
  // Scan all projects so a refresh from a neighbouring sandbox can still flow back to the host.
166
+ const providerAuthAvailable = hasClaudeProviderAuth(config.home);
161
167
  const result = reconcileClaudeCredentials(config.home);
162
- const message = formatCredentialSyncStatus(result);
168
+ const message = formatCredentialSyncStatus(result, process.stderr.isTTY, providerAuthAvailable);
163
169
  if (message) {
164
170
  process.stderr.write(message);
165
171
  }