@fitlab-ai/agent-infra 0.8.0 → 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 (63) hide show
  1. package/bin/cli.ts +1 -1
  2. package/dist/bin/cli.js +1 -1
  3. package/dist/lib/run/index.js +96 -5
  4. package/dist/lib/sandbox/capture.js +113 -4
  5. package/dist/lib/sandbox/commands/create.js +3 -3
  6. package/dist/lib/sandbox/commands/enter.js +7 -3
  7. package/dist/lib/sandbox/credentials.js +42 -3
  8. package/dist/lib/sandbox/runtimes/base.dockerfile +13 -0
  9. package/dist/lib/task/commands/log.js +36 -14
  10. package/dist/lib/task/commands/status.js +144 -45
  11. package/lib/run/index.ts +119 -6
  12. package/lib/sandbox/capture.ts +136 -5
  13. package/lib/sandbox/commands/create.ts +3 -3
  14. package/lib/sandbox/commands/enter.ts +8 -2
  15. package/lib/sandbox/credentials.ts +57 -3
  16. package/lib/sandbox/runtimes/base.dockerfile +13 -0
  17. package/lib/task/commands/log.ts +36 -14
  18. package/lib/task/commands/status.ts +200 -58
  19. package/package.json +4 -3
  20. package/templates/.agents/rules/next-step-output.en.md +1 -1
  21. package/templates/.agents/rules/next-step-output.zh-CN.md +1 -1
  22. package/templates/.agents/rules/pr-sync.github.en.md +3 -3
  23. package/templates/.agents/rules/pr-sync.github.zh-CN.md +3 -3
  24. package/templates/.agents/rules/task-management.en.md +1 -1
  25. package/templates/.agents/rules/task-management.zh-CN.md +1 -1
  26. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  27. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  28. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
  29. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
  30. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +3 -3
  31. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +4 -4
  32. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
  33. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
  34. package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
  35. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
  36. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
  37. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
  38. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +11 -11
  39. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +11 -11
  40. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -4
  41. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -4
  42. package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +5 -5
  43. package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +5 -5
  44. package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
  45. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
  46. package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
  47. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
  48. package/templates/.agents/skills/review-code/reference/output-templates.en.md +16 -16
  49. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +12 -12
  50. package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -4
  51. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -4
  52. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +5 -5
  53. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +5 -5
  54. package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
  55. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
  56. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
  57. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
  58. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +11 -11
  59. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +11 -11
  60. package/templates/.agents/skills/review-plan/reference/report-template.en.md +4 -4
  61. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +4 -4
  62. package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +5 -5
  63. package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +5 -5
@@ -1,23 +1,26 @@
1
1
  import fs from 'node:fs';
2
+ import path from 'node:path';
2
3
  import { execFileSync } from 'node:child_process';
4
+ import { commandForEngine } from "../../sandbox/shell.js";
3
5
  import { resolveTaskRef } from "../resolve-ref.js";
4
6
  import { enumerateArtifacts } from "../artifacts.js";
5
7
  import { parseTaskFrontmatter, extractTitle } from "../frontmatter.js";
6
8
  import { loadShortIdByTaskId } from "../short-id.js";
9
+ import { parseActivityLog, pairEntries } from "./log.js";
7
10
  const USAGE = `Usage: ai task status <N | #N | TASK-id>
8
11
 
9
- Prints an aggregated "health check" view for a task: header, metadata, an
10
- artifacts summary, git branch state, and best-effort GitHub issue/PR status.
12
+ Prints an aggregated "health check" view for a task: header, metadata,
13
+ artifacts, workflow/runtime execution state, and git branch state.
11
14
  <ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
12
15
 
13
- Git and Platform rows are best-effort: a failed git/gh call degrades that row to
14
- '-' without failing the command.
16
+ Git rows are best-effort: a failed git call degrades that row to '-' without
17
+ failing the command.
15
18
  `;
16
19
  const DASH = '-';
17
20
  function makeRunner(cwd) {
18
21
  return (file, args) => execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
19
22
  }
20
- // Run `run` and swallow any failure into null, so a single failing git/gh call
23
+ // Run `run` and swallow any failure into null, so a single failing git call
21
24
  // degrades only its own field instead of aborting the whole view.
22
25
  function tryRun(run, file, args) {
23
26
  try {
@@ -37,9 +40,7 @@ const METADATA_KEYS = [
37
40
  'branch',
38
41
  'assigned_to',
39
42
  'created_at',
40
- 'updated_at',
41
- 'issue_number',
42
- 'pr_status'
43
+ 'updated_at'
43
44
  ];
44
45
  function collectMetadata(fm) {
45
46
  return METADATA_KEYS.map((key) => [key, fm[key] ? fm[key] : DASH]);
@@ -136,39 +137,123 @@ function collectGit(frontmatterBranch, run) {
136
137
  }
137
138
  return { current, frontmatter, match, exists, uncommitted, aheadBehind };
138
139
  }
139
- function collectPlatform(fm, run) {
140
- let issue = DASH;
141
- if (fm.issue_number && /^\d+$/.test(fm.issue_number)) {
142
- const out = tryRun(run, 'gh', ['issue', 'view', fm.issue_number, '--json', 'state,labels']);
143
- if (out !== null) {
144
- try {
145
- const data = JSON.parse(out);
146
- const labels = Array.isArray(data.labels)
147
- ? data.labels.map((label) => label.name).join(', ')
148
- : '';
149
- issue = labels ? `${data.state} [${labels}]` : `${data.state}`;
150
- }
151
- catch {
152
- issue = DASH;
140
+ const STALE_MS = 60 * 60 * 1000;
141
+ function parseActivityTime(value) {
142
+ const epoch = Date.parse(value.replace(' ', 'T'));
143
+ return Number.isFinite(epoch) ? epoch : Number.NaN;
144
+ }
145
+ function collectWorkflow(content, now = new Date()) {
146
+ const parsed = parseActivityLog(content);
147
+ if (!parsed.sectionFound || parsed.entries.length === 0) {
148
+ return { state: 'unknown', step: DASH, agent: DASH, startedAt: DASH, doneAt: DASH, stale: DASH };
149
+ }
150
+ const rows = pairEntries(parsed.entries);
151
+ const latest = rows.at(-1);
152
+ if (!latest) {
153
+ return { state: 'unknown', step: DASH, agent: DASH, startedAt: DASH, doneAt: DASH, stale: DASH };
154
+ }
155
+ const inProgress = latest.started !== '' && latest.done === '';
156
+ const state = inProgress ? 'in-progress' : latest.done ? 'idle' : 'unknown';
157
+ let stale = DASH;
158
+ if (inProgress) {
159
+ const started = parseActivityTime(latest.started);
160
+ stale = Number.isFinite(started) ? (now.getTime() - started > STALE_MS ? 'yes' : 'no') : 'unknown';
161
+ }
162
+ return {
163
+ state,
164
+ step: latest.step || DASH,
165
+ agent: latest.agent || DASH,
166
+ startedAt: latest.started || DASH,
167
+ doneAt: latest.done || DASH,
168
+ stale
169
+ };
170
+ }
171
+ function latestRunRecord(taskDir) {
172
+ const runsDir = path.join(taskDir, 'runs');
173
+ if (!fs.existsSync(runsDir))
174
+ return null;
175
+ const candidates = [];
176
+ for (const entry of fs.readdirSync(runsDir)) {
177
+ if (!entry.endsWith('.json'))
178
+ continue;
179
+ const filePath = path.join(runsDir, entry);
180
+ const stat = fs.statSync(filePath);
181
+ if (stat.isFile())
182
+ candidates.push({ path: filePath, mtimeMs: stat.mtimeMs });
183
+ }
184
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
185
+ for (const candidate of candidates) {
186
+ try {
187
+ const data = JSON.parse(fs.readFileSync(candidate.path, 'utf8'));
188
+ if (typeof data?.run_id === 'string' &&
189
+ typeof data?.engine === 'string' &&
190
+ typeof data?.container === 'string' &&
191
+ typeof data?.run_dir === 'string') {
192
+ return {
193
+ run_id: data.run_id,
194
+ engine: data.engine,
195
+ container: data.container,
196
+ run_dir: data.run_dir,
197
+ status_file: typeof data.status_file === 'string' ? data.status_file : `${data.run_dir}/status`,
198
+ log_file: typeof data.log_file === 'string' ? data.log_file : `${data.run_dir}/output.log`
199
+ };
153
200
  }
154
201
  }
202
+ catch {
203
+ // Ignore malformed local records and try the next newest file.
204
+ }
155
205
  }
156
- let pr = DASH;
157
- if (fm.pr_status === 'created' && fm.pr_number && /^\d+$/.test(fm.pr_number)) {
158
- const out = tryRun(run, 'gh', ['pr', 'view', fm.pr_number, '--json', 'state,statusCheckRollup']);
159
- if (out !== null) {
160
- try {
161
- const data = JSON.parse(out);
162
- const rollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [];
163
- const passed = rollup.filter((check) => check.conclusion === 'SUCCESS' || check.state === 'SUCCESS').length;
164
- pr = rollup.length > 0 ? `${data.state}, checks: ${passed}/${rollup.length}` : `${data.state}`;
165
- }
166
- catch {
167
- pr = DASH;
206
+ return null;
207
+ }
208
+ function readRuntimeFile(record, filePath, run) {
209
+ const command = commandForEngine(record.engine, 'docker', ['exec', record.container, 'cat', filePath]);
210
+ const output = tryRun(run, command.cmd, command.args);
211
+ return output === null ? null : output.trim();
212
+ }
213
+ function runtimeValue(record, name, run) {
214
+ const output = readRuntimeFile(record, `${record.run_dir}/${name}`, run);
215
+ return output ? output : DASH;
216
+ }
217
+ function collectRuntime(taskDir, workflow, run) {
218
+ const record = latestRunRecord(taskDir);
219
+ if (!record) {
220
+ return workflow.state === 'in-progress'
221
+ ? {
222
+ mode: 'unmanaged',
223
+ status: 'inferred-from-workflow',
224
+ run: DASH,
225
+ tmux: DASH,
226
+ startedAt: DASH,
227
+ finishedAt: DASH,
228
+ exitCode: DASH,
229
+ log: DASH
168
230
  }
169
- }
231
+ : {
232
+ mode: 'none',
233
+ status: DASH,
234
+ run: DASH,
235
+ tmux: DASH,
236
+ startedAt: DASH,
237
+ finishedAt: DASH,
238
+ exitCode: DASH,
239
+ log: DASH
240
+ };
170
241
  }
171
- return { issue, pr };
242
+ const status = readRuntimeFile(record, record.status_file, run)?.trim() || 'unknown';
243
+ const session = runtimeValue(record, 'session', run);
244
+ const window = runtimeValue(record, 'window', run);
245
+ const pane = runtimeValue(record, 'pane', run);
246
+ const tmux = session !== DASH && window !== DASH && pane !== DASH ? `${session}:${window}:${pane}` : DASH;
247
+ return {
248
+ mode: 'managed-tmux',
249
+ status,
250
+ run: record.run_id,
251
+ tmux,
252
+ startedAt: runtimeValue(record, 'started_at', run),
253
+ finishedAt: runtimeValue(record, 'finished_at', run),
254
+ exitCode: runtimeValue(record, 'exit_code', run),
255
+ log: record.log_file
256
+ };
172
257
  }
173
258
  // Indent each label/value pair by two spaces and pad labels to a common width so
174
259
  // every section reads as an aligned "key value" block.
@@ -189,6 +274,24 @@ function renderStatus(model) {
189
274
  else {
190
275
  lines.push(...renderPairs(model.artifacts.groups.map((group) => [group.stage, group.files.join(', ')])));
191
276
  }
277
+ lines.push('', 'Workflow', ...renderPairs([
278
+ ['state', model.workflow.state],
279
+ ['step', model.workflow.step],
280
+ ['agent', model.workflow.agent],
281
+ ['started_at', model.workflow.startedAt],
282
+ ['done_at', model.workflow.doneAt],
283
+ ['stale', model.workflow.stale]
284
+ ]));
285
+ lines.push('', 'Runtime', ...renderPairs([
286
+ ['mode', model.runtime.mode],
287
+ ['status', model.runtime.status],
288
+ ['run', model.runtime.run],
289
+ ['tmux', model.runtime.tmux],
290
+ ['started_at', model.runtime.startedAt],
291
+ ['finished_at', model.runtime.finishedAt],
292
+ ['exit_code', model.runtime.exitCode],
293
+ ['log', model.runtime.log]
294
+ ]));
192
295
  lines.push('', 'Git', ...renderPairs([
193
296
  ['current', model.git.current],
194
297
  ['frontmatter', model.git.frontmatter],
@@ -197,11 +300,6 @@ function renderStatus(model) {
197
300
  ['uncommitted', model.git.uncommitted],
198
301
  ['ahead/behind', model.git.aheadBehind]
199
302
  ]));
200
- const issueLabel = model.issueNumber ? `issue #${model.issueNumber}` : 'issue';
201
- lines.push('', 'Platform', ...renderPairs([
202
- [issueLabel, model.platform.issue],
203
- ['pr', model.platform.pr]
204
- ]));
205
303
  return lines;
206
304
  }
207
305
  function status(args = []) {
@@ -221,19 +319,20 @@ function status(args = []) {
221
319
  const fm = parseTaskFrontmatter(content);
222
320
  const run = makeRunner(resolved.repoRoot);
223
321
  const artifacts = enumerateArtifacts(resolved.taskDir);
322
+ const workflow = collectWorkflow(content);
224
323
  const model = {
225
324
  taskId: resolved.taskId,
226
325
  shortId: loadShortIdByTaskId(resolved.repoRoot).get(resolved.taskId) ?? DASH,
227
326
  title: extractTitle(content),
228
- issueNumber: fm.issue_number && /^\d+$/.test(fm.issue_number) ? fm.issue_number : '',
229
327
  metadata: collectMetadata(fm),
230
328
  artifacts: { count: artifacts.length, groups: groupArtifacts(artifacts) },
231
- git: collectGit(fm.branch ?? '', run),
232
- platform: collectPlatform(fm, run)
329
+ workflow,
330
+ runtime: collectRuntime(resolved.taskDir, workflow, run),
331
+ git: collectGit(fm.branch ?? '', run)
233
332
  };
234
333
  for (const line of renderStatus(model)) {
235
334
  process.stdout.write(`${line}\n`);
236
335
  }
237
336
  }
238
- export { status, makeRunner, collectMetadata, groupArtifacts, collectGit, collectPlatform, renderStatus, METADATA_KEYS };
337
+ export { status, makeRunner, collectMetadata, groupArtifacts, collectGit, collectWorkflow, collectRuntime, renderStatus, METADATA_KEYS };
239
338
  //# sourceMappingURL=status.js.map
package/lib/run/index.ts CHANGED
@@ -1,6 +1,9 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { loadServerConfig } from '../server/config.ts';
2
4
  import { resolveTaskBranch } from '../sandbox/task-resolver.ts';
3
- import { runInSandbox } from '../sandbox/capture.ts';
5
+ import { createRunId, runInSandbox, type SandboxRunMetadata } from '../sandbox/capture.ts';
6
+ import { loadShortIdByTaskId, normalizeShortIdInput } from '../task/short-id.ts';
4
7
  import { buildTuiCommand, renderPrompt, selectTui } from './tui.ts';
5
8
  import { getSkillRunSpec } from './skills.ts';
6
9
  import { runHostCommand, type RunProcessResult } from './host.ts';
@@ -16,19 +19,27 @@ export type SandboxRunRequest = {
16
19
  taskRef: string;
17
20
  branch: string;
18
21
  command: string[];
22
+ runId?: string;
23
+ };
24
+
25
+ export type SandboxRunResult = RunProcessResult & {
26
+ run?: SandboxRunMetadata;
19
27
  };
20
28
 
21
29
  export type RunSkillOptions = {
22
30
  command?: Record<string, unknown>;
23
31
  repoRoot?: string;
24
32
  runHost?: (command: string[]) => Promise<RunProcessResult>;
25
- runSandbox?: (request: SandboxRunRequest) => Promise<RunProcessResult>;
33
+ runSandbox?: (request: SandboxRunRequest) => Promise<SandboxRunResult>;
26
34
  writeStdout?: (chunk: string) => void;
27
35
  writeStderr?: (chunk: string) => void;
28
36
  };
29
37
 
30
38
  const USAGE = `Usage: ai run <skill> [task-ref] [args...] [--tui <name>]
31
39
 
40
+ Task skills are scheduled inside the sandbox tmux session; ai run returns once
41
+ the tmux window is created.
42
+
32
43
  Examples:
33
44
  ai run create-task "describe the task" --tui codex
34
45
  ai run code-task #7 --tui codex`;
@@ -78,6 +89,99 @@ function assertAllowedByConfig(skill: string, commandConfig: Record<string, unkn
78
89
  }
79
90
  }
80
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
+
81
185
  export async function runSkill(args: string[], options: RunSkillOptions = {}): Promise<number> {
82
186
  const parsed = parseRunArgs(args);
83
187
  const config = options.command ? null : loadServerConfig({ rootDir: options.repoRoot });
@@ -96,13 +200,22 @@ export async function runSkill(args: string[], options: RunSkillOptions = {}): P
96
200
 
97
201
  const repoRoot = options.repoRoot ?? config?.repoRoot ?? process.cwd();
98
202
  const branch = resolveTaskBranch(parsed.taskRef, repoRoot);
99
- const runSandbox = options.runSandbox ?? ((request: SandboxRunRequest) => runInSandbox(request));
100
- const result = await runSandbox({ taskRef: parsed.taskRef, branch, command });
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 });
101
211
  if (result.stdout) {
102
- (options.writeStdout ?? ((chunk: string) => process.stdout.write(chunk)))(result.stdout);
212
+ writeStdout(result.stdout);
103
213
  }
104
214
  if (result.stderr) {
105
- (options.writeStderr ?? ((chunk: string) => process.stderr.write(chunk)))(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 });
106
219
  }
107
220
  return result.exitCode ?? (result.signal ? 1 : 0);
108
221
  }
@@ -22,22 +22,45 @@ export type SandboxCaptureResult = {
22
22
  signal: NodeJS.Signals | null;
23
23
  stdout: string;
24
24
  stderr: string;
25
+ run?: SandboxRunMetadata;
26
+ };
27
+
28
+ export type SandboxRunMetadata = {
29
+ runId: string;
30
+ engine: string;
31
+ container: string;
32
+ runDir: string;
25
33
  };
26
34
 
27
35
  export type SandboxCaptureOptions = {
28
36
  engine?: string;
29
37
  repoRoot?: string;
38
+ runId?: string;
30
39
  containerCandidates?: string[];
31
40
  rows?: SandboxRow[];
32
41
  startContainer?: (name: string) => void;
33
42
  spawn?: (file: string, args: string[]) => Promise<SandboxCaptureResult>;
34
43
  };
35
44
 
36
- async function spawnCapture(file: string, args: string[]): Promise<SandboxCaptureResult> {
45
+ async function spawnCapture(
46
+ file: string,
47
+ args: string[]
48
+ ): Promise<SandboxCaptureResult> {
37
49
  return new Promise((resolve, reject) => {
38
50
  const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'] });
39
51
  let stdout = '';
40
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
+ };
41
64
  child.stdout.setEncoding('utf8');
42
65
  child.stderr.setEncoding('utf8');
43
66
  child.stdout.on('data', (chunk) => {
@@ -46,11 +69,106 @@ async function spawnCapture(file: string, args: string[]): Promise<SandboxCaptur
46
69
  child.stderr.on('data', (chunk) => {
47
70
  stderr += chunk;
48
71
  });
49
- child.on('error', reject);
50
- child.on('close', (exitCode, signal) => resolve({ exitCode, signal, stdout, stderr }));
72
+ child.on('error', rejectOnce);
73
+ child.on('close', (exitCode, signal) => {
74
+ resolveOnce({ exitCode, signal, stdout, stderr });
75
+ });
51
76
  });
52
77
  }
53
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
+
54
172
  export async function runInSandbox(
55
173
  request: SandboxCaptureRequest,
56
174
  options: SandboxCaptureOptions = {}
@@ -73,12 +191,25 @@ export async function runInSandbox(
73
191
  if (!found.running) {
74
192
  (options.startContainer ?? ((name: string) => startSandboxContainer(engine, name)))(found.name);
75
193
  }
194
+ const runId = options.runId ?? createRunId();
195
+ const runDir = `/tmp/agent-infra-runs/${runId}`;
76
196
  const dockerArgs = [
77
197
  'exec',
78
198
  ...terminalEnvFlags(),
79
199
  ...hostTimezoneEnvFlags(),
80
200
  found.name,
81
- ...request.command
201
+ 'bash',
202
+ '-lc',
203
+ buildTmuxLauncher({ request, runId })
82
204
  ];
83
- return (options.spawn ?? spawnCapture)('docker', dockerArgs);
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
+ };
84
215
  }
@@ -1438,9 +1438,9 @@ export async function create(args: string[]): Promise<void> {
1438
1438
  if (claudeCodeEntry) {
1439
1439
  ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
1440
1440
  ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
1441
- // prepareClaudeCredentials wrote the shared credentials file
1442
- // before this point. If credentials were missing, the
1443
- // 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.
1444
1444
  }
1445
1445
  const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
1446
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
  }