@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
@@ -1,4 +1,5 @@
1
1
  import * as lark from '@larksuiteoapi/node-sdk';
2
+ import { stripVTControlCharacters } from 'node:util';
2
3
  function asRecord(value) {
3
4
  return typeof value === 'object' && value !== null ? value : {};
4
5
  }
@@ -40,6 +41,23 @@ export function normalizeMessage(event) {
40
41
  function resolveDomain(value) {
41
42
  return value === 'lark' || value === 'Lark' ? lark.Domain.Lark : lark.Domain.Feishu;
42
43
  }
44
+ export function cleanFeishuText(text) {
45
+ return stripVTControlCharacters(text).replace(/\r\n/g, '\n');
46
+ }
47
+ export function cardMessage(text) {
48
+ return { kind: 'interactive', title: 'agent-infra', text: cleanFeishuText(text) };
49
+ }
50
+ export function toFeishuCreateData(chatId, message) {
51
+ return {
52
+ receive_id: chatId,
53
+ msg_type: 'interactive',
54
+ content: JSON.stringify({
55
+ config: { wide_screen_mode: true },
56
+ header: { title: { tag: 'plain_text', content: message.title }, template: 'blue' },
57
+ elements: [{ tag: 'div', text: { tag: 'lark_md', content: message.text } }]
58
+ })
59
+ };
60
+ }
43
61
  // Build the real SDK-backed transport from adapter config. appId/appSecret come
44
62
  // from server config (appSecret only via server.local.json / env). The WSClient
45
63
  // long connection delivers events; the Client REST call sends replies.
@@ -84,10 +102,10 @@ export function createFeishuTransport(config) {
84
102
  // Ignore: the daemon is shutting down regardless.
85
103
  }
86
104
  },
87
- async send(chatId, text) {
105
+ async send(chatId, message) {
88
106
  await client.im.message.create({
89
107
  params: { receive_id_type: 'chat_id' },
90
- data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) }
108
+ data: toFeishuCreateData(chatId, message)
91
109
  });
92
110
  }
93
111
  };
@@ -12,10 +12,26 @@ function taskSubcommand(subcommand) {
12
12
  export function commandHelp() {
13
13
  return [
14
14
  `agent-infra ${VERSION}`,
15
- 'Built-ins: /help, /ping, /version',
16
- 'Read: /sandbox ls|show|vm status, /task decisions|log|ls|show|status',
17
- 'Write: /sandbox create|start',
18
- 'Exec: /decide <task-ref> <HD-id> <decision>, /run create-task <description>, /run <skill> <task-ref> ...'
15
+ 'Built-ins:',
16
+ '/help',
17
+ '/ping',
18
+ '/version',
19
+ 'Read:',
20
+ '/sandbox ls',
21
+ '/sandbox show {ref}',
22
+ '/sandbox vm status',
23
+ '/task decisions {ref}',
24
+ '/task log {ref}',
25
+ '/task ls',
26
+ '/task show {ref}',
27
+ '/task status {ref}',
28
+ 'Write:',
29
+ '/sandbox create {ref}',
30
+ '/sandbox start {ref}',
31
+ 'Exec:',
32
+ '/decide {task-ref} {HD-id} {decision}',
33
+ '/run create-task {description}',
34
+ '/run {skill} {task-ref}'
19
35
  ].join('\n');
20
36
  }
21
37
  export function parseCommand(text) {
@@ -12,7 +12,7 @@ completion time (or '(in progress)' while still running).
12
12
  Columns: # (row) / STEP / AGENT / STARTED / DONE / NOTE
13
13
  A human-executed step shows AGENT as 'human' and, when it has no start marker,
14
14
  a '-' STARTED placeholder. Review-step NOTE also carries two human counts in
15
- the verdict list, right after blockers/major/minor: manual-verify (env-blocked)
15
+ the verdict list, right after blockers/major/minor: manual-validation
16
16
  and human-decision (current ledger stage total).
17
17
  `;
18
18
  const TABLE_HEADERS = ['#', 'STEP', 'AGENT', 'STARTED', 'DONE', 'NOTE'];
@@ -33,10 +33,6 @@ const STARTED_SUFFIX_RE = /\s*\[started\]\s*$/;
33
33
  // note these differ from the `.airc.json` long names claude-code/gemini-cli).
34
34
  // Any other executor token (a human name, possibly CJK) is treated as human.
35
35
  const KNOWN_AI_AGENTS = new Set(['claude', 'codex', 'gemini', 'opencode', 'cursor']);
36
- const ENV_BLOCKED_RE = /\(\+\s*(\d+)\s+env-blocked\)/i;
37
- // Same match plus any leading whitespace, so folding the count into the verdict
38
- // text drops the redundant `(+ n env-blocked)` fragment without leaving a gap.
39
- const ENV_BLOCKED_STRIP_RE = /\s*\(\+\s*\d+\s+env-blocked\)/i;
40
36
  const REVIEW_STAGE_PREFIXES = [
41
37
  { prefix: 'Review Analysis', stage: 'analysis' },
42
38
  { prefix: 'Review Plan', stage: 'plan' },
@@ -116,9 +112,31 @@ function isReviewStage(stage) {
116
112
  function reviewStageForStep(step) {
117
113
  return REVIEW_STAGE_PREFIXES.find(({ prefix }) => step.startsWith(prefix))?.stage;
118
114
  }
115
+ function splitArtifactSuffix(note) {
116
+ const arrow = note.indexOf(' → ');
117
+ return arrow === -1 ? { verdict: note, suffix: '' } : { verdict: note.slice(0, arrow), suffix: note.slice(arrow) };
118
+ }
119
+ function fieldNumber(field, label) {
120
+ const trimmed = field.trim();
121
+ const colon = trimmed.indexOf(':');
122
+ if (colon === -1)
123
+ return undefined;
124
+ if (trimmed.slice(0, colon).trim().toLowerCase() !== label)
125
+ return undefined;
126
+ const value = Number(trimmed.slice(colon + 1).trim());
127
+ return Number.isInteger(value) && value >= 0 ? value : undefined;
128
+ }
119
129
  function humanValidationCount(note) {
120
- const match = ENV_BLOCKED_RE.exec(note);
121
- return match ? Number(match[1]) : 0;
130
+ const { verdict } = splitArtifactSuffix(note);
131
+ for (const field of verdict.split(',')) {
132
+ const value = fieldNumber(field, 'manual-validation');
133
+ if (value !== undefined)
134
+ return value;
135
+ }
136
+ return 0;
137
+ }
138
+ function isHumanCountField(field) {
139
+ return fieldNumber(field, 'manual-validation') !== undefined || fieldNumber(field, 'human-decision') !== undefined;
122
140
  }
123
141
  // A step is human-executed when its agent token is not a known AI token. Take
124
142
  // the first whitespace-delimited token and drop any trailing parenthetical
@@ -129,13 +147,17 @@ function isHumanAgent(agent) {
129
147
  }
130
148
  // Fold the two human counts into a review row's verdict NOTE: comma-joined, right
131
149
  // after the blockers/major/minor list and before the ` → artifact` link, mirroring
132
- // the review count line. The raw `(+ n env-blocked)` fragment is dropped so the
133
- // env-blocked number is not shown twice (it becomes the manual-verify count).
134
- function foldHumanCounts(note, decisions, envBlocked) {
135
- const base = note.replace(ENV_BLOCKED_STRIP_RE, '');
136
- const group = `Manual-verify: ${envBlocked}, Human-decision: ${decisions}`;
137
- const arrow = base.indexOf(' → ');
138
- return arrow === -1 ? `${base}, ${group}` : `${base.slice(0, arrow)}, ${group}${base.slice(arrow)}`;
150
+ // the review count line. Review done notes already carry `Manual-validation` as a
151
+ // source field, so build the final verdict field list once instead of cleaning a
152
+ // previously rendered string.
153
+ function foldHumanCounts(note, decisions, manualValidation) {
154
+ const { verdict, suffix } = splitArtifactSuffix(note);
155
+ const fields = verdict
156
+ .split(',')
157
+ .map((field) => field.trim())
158
+ .filter((field) => field !== '' && !isHumanCountField(field));
159
+ const group = `Manual-validation: ${manualValidation}, Human-decision: ${decisions}`;
160
+ return `${[...fields, group].join(', ')}${suffix}`;
139
161
  }
140
162
  function log(args = []) {
141
163
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
@@ -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
@@ -7,6 +7,9 @@ import { isPathOwnedByDisabledTUI, resolveEnabledTUIs } from "./builtin-tuis.js"
7
7
  const defaults = JSON.parse(fs.readFileSync(new URL('./defaults.json', import.meta.url), 'utf8'));
8
8
  const CONFIG_DIR = '.agents';
9
9
  const CONFIG_PATH = path.join(CONFIG_DIR, '.airc.json');
10
+ const AGENT_INFRA_SANDBOX_TOOL = 'agent-infra';
11
+ const LEGACY_DEFAULT_SANDBOX_TOOLS = ['claude-code', 'codex', 'gemini-cli', 'opencode'];
12
+ const DEFAULT_SANDBOX_TOOLS = [AGENT_INFRA_SANDBOX_TOOL, ...LEGACY_DEFAULT_SANDBOX_TOOLS];
10
13
  // One-time migration of the legacy project-level PR switch to the three-state
11
14
  // `prFlow` preference. `true` (the old default / "PR flow on") maps to the
12
15
  // strong constraint `required`; `false` maps to `disabled`. A missing or
@@ -25,6 +28,24 @@ function migratePrFlow(config) {
25
28
  }
26
29
  return null;
27
30
  }
31
+ function isLegacyDefaultSandboxTools(value) {
32
+ if (!Array.isArray(value) || value.length !== LEGACY_DEFAULT_SANDBOX_TOOLS.length) {
33
+ return false;
34
+ }
35
+ const tools = new Set(value);
36
+ return LEGACY_DEFAULT_SANDBOX_TOOLS.every((tool) => tools.has(tool));
37
+ }
38
+ function migrateSandboxTools(config) {
39
+ const tools = config.sandbox?.tools;
40
+ if (!isLegacyDefaultSandboxTools(tools)) {
41
+ return false;
42
+ }
43
+ config.sandbox = {
44
+ ...config.sandbox,
45
+ tools: [...DEFAULT_SANDBOX_TOOLS]
46
+ };
47
+ return true;
48
+ }
28
49
  function isPathOwnedByOtherPlatform(relativePath, platformType) {
29
50
  const top = String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, '').split('/')[0] ?? '';
30
51
  if (!top.startsWith('.'))
@@ -153,6 +174,7 @@ async function cmdUpdate() {
153
174
  const taskAdded = !config.task;
154
175
  const labelsAdded = !config.labels;
155
176
  const prFlowMigrated = migratePrFlow(config);
177
+ const sandboxToolsMigrated = !sandboxAdded && migrateSandboxTools(config);
156
178
  let configChanged = changed;
157
179
  if (platformAdded) {
158
180
  config.platform = structuredClone(defaults.platform);
@@ -173,6 +195,9 @@ async function cmdUpdate() {
173
195
  if (prFlowMigrated) {
174
196
  configChanged = true;
175
197
  }
198
+ if (sandboxToolsMigrated) {
199
+ configChanged = true;
200
+ }
176
201
  if (configChanged) {
177
202
  console.log('');
178
203
  if (hasNewEntries) {
@@ -200,6 +225,9 @@ async function cmdUpdate() {
200
225
  if (prFlowMigrated) {
201
226
  info(`Migrated legacy requiresPullRequest to prFlow="${prFlowMigrated}" in ${CONFIG_PATH}.`);
202
227
  }
228
+ if (sandboxToolsMigrated) {
229
+ info(`Migrated default sandbox.tools to include ${AGENT_INFRA_SANDBOX_TOOL} in ${CONFIG_PATH}.`);
230
+ }
203
231
  }
204
232
  else {
205
233
  info(`File registry changed in ${CONFIG_PATH}.`);
@@ -219,6 +247,9 @@ async function cmdUpdate() {
219
247
  if (hasNewEntries && prFlowMigrated) {
220
248
  info(`Migrated legacy requiresPullRequest to prFlow="${prFlowMigrated}" in ${CONFIG_PATH}.`);
221
249
  }
250
+ if (hasNewEntries && sandboxToolsMigrated) {
251
+ info(`Migrated default sandbox.tools to include ${AGENT_INFRA_SANDBOX_TOOL} in ${CONFIG_PATH}.`);
252
+ }
222
253
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
223
254
  ok(`Updated ${CONFIG_PATH}`);
224
255
  }
package/lib/defaults.json CHANGED
@@ -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",
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
  }