@fitlab-ai/agent-infra 0.7.0 → 0.7.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 (156) hide show
  1. package/bin/cli.ts +12 -1
  2. package/dist/bin/cli.js +13 -1
  3. package/dist/lib/builtin-tuis.js +45 -0
  4. package/dist/lib/defaults.json +3 -0
  5. package/dist/lib/init.js +62 -23
  6. package/dist/lib/prompt.js +49 -1
  7. package/dist/lib/sandbox/commands/create.js +10 -2
  8. package/dist/lib/sandbox/commands/enter.js +8 -7
  9. package/dist/lib/sandbox/commands/list-running.js +62 -28
  10. package/dist/lib/sandbox/commands/ls.js +20 -22
  11. package/dist/lib/sandbox/commands/rebuild.js +3 -11
  12. package/dist/lib/sandbox/commands/rm.js +2 -0
  13. package/dist/lib/sandbox/image-prune.js +18 -0
  14. package/dist/lib/sandbox/index.js +7 -3
  15. package/dist/lib/sandbox/task-resolver.js +18 -0
  16. package/dist/lib/sandbox/tools.js +1 -1
  17. package/dist/lib/table.js +29 -0
  18. package/dist/lib/task/commands/ls.js +122 -0
  19. package/dist/lib/task/commands/show.js +135 -0
  20. package/dist/lib/task/frontmatter.js +32 -0
  21. package/dist/lib/task/index.js +41 -0
  22. package/dist/lib/task/short-id.js +80 -0
  23. package/dist/lib/update.js +59 -18
  24. package/lib/builtin-tuis.ts +55 -0
  25. package/lib/defaults.json +3 -0
  26. package/lib/init.ts +87 -35
  27. package/lib/prompt.ts +54 -1
  28. package/lib/sandbox/commands/create.ts +11 -2
  29. package/lib/sandbox/commands/enter.ts +8 -7
  30. package/lib/sandbox/commands/list-running.ts +70 -31
  31. package/lib/sandbox/commands/ls.ts +25 -25
  32. package/lib/sandbox/commands/rebuild.ts +3 -12
  33. package/lib/sandbox/commands/rm.ts +3 -0
  34. package/lib/sandbox/image-prune.ts +23 -0
  35. package/lib/sandbox/index.ts +7 -3
  36. package/lib/sandbox/task-resolver.ts +23 -1
  37. package/lib/sandbox/tools.ts +1 -1
  38. package/lib/table.ts +32 -0
  39. package/lib/task/commands/ls.ts +138 -0
  40. package/lib/task/commands/show.ts +139 -0
  41. package/lib/task/frontmatter.ts +30 -0
  42. package/lib/task/index.ts +44 -0
  43. package/lib/task/short-id.ts +97 -0
  44. package/lib/update.ts +71 -30
  45. package/package.json +1 -1
  46. package/templates/.agents/README.en.md +32 -0
  47. package/templates/.agents/README.zh-CN.md +32 -0
  48. package/templates/.agents/hooks/auto-resume.sh +87 -0
  49. package/templates/.agents/rules/create-issue.github.en.md +1 -1
  50. package/templates/.agents/rules/create-issue.github.zh-CN.md +1 -1
  51. package/templates/.agents/rules/milestone-inference.github.en.md +4 -1
  52. package/templates/.agents/rules/milestone-inference.github.zh-CN.md +4 -1
  53. package/templates/.agents/rules/next-step-output.en.md +59 -0
  54. package/templates/.agents/rules/next-step-output.zh-CN.md +59 -0
  55. package/templates/.agents/rules/task-short-id.en.md +133 -0
  56. package/templates/.agents/rules/task-short-id.zh-CN.md +105 -0
  57. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +17 -0
  58. package/templates/.agents/scripts/task-short-id.js +556 -0
  59. package/templates/.agents/skills/analyze-task/SKILL.en.md +13 -11
  60. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +13 -12
  61. package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -1
  62. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -1
  63. package/templates/.agents/skills/block-task/SKILL.en.md +17 -5
  64. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +17 -6
  65. package/templates/.agents/skills/block-task/config/verify.json +1 -1
  66. package/templates/.agents/skills/cancel-task/SKILL.en.md +17 -5
  67. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +17 -6
  68. package/templates/.agents/skills/cancel-task/config/verify.json +1 -1
  69. package/templates/.agents/skills/check-task/SKILL.en.md +15 -9
  70. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +15 -10
  71. package/templates/.agents/skills/close-codescan/SKILL.en.md +16 -5
  72. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +16 -5
  73. package/templates/.agents/skills/close-dependabot/SKILL.en.md +16 -5
  74. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +16 -5
  75. package/templates/.agents/skills/code-task/SKILL.en.md +13 -5
  76. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +14 -6
  77. package/templates/.agents/skills/code-task/config/verify.en.json +2 -1
  78. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +2 -1
  79. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +10 -5
  80. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +10 -5
  81. package/templates/.agents/skills/code-task/reference/output-template.en.md +3 -3
  82. package/templates/.agents/skills/code-task/reference/output-template.zh-CN.md +3 -3
  83. package/templates/.agents/skills/code-task/reference/report-template.en.md +8 -0
  84. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +8 -0
  85. package/templates/.agents/skills/commit/SKILL.en.md +5 -1
  86. package/templates/.agents/skills/commit/SKILL.zh-CN.md +5 -1
  87. package/templates/.agents/skills/commit/reference/task-status-update.en.md +9 -9
  88. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +9 -9
  89. package/templates/.agents/skills/complete-task/SKILL.en.md +17 -1
  90. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +17 -2
  91. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -1
  92. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -1
  93. package/templates/.agents/skills/create-pr/SKILL.en.md +9 -5
  94. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +9 -5
  95. package/templates/.agents/skills/create-pr/config/verify.json +2 -1
  96. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
  97. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
  98. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +3 -3
  99. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +3 -3
  100. package/templates/.agents/skills/create-task/SKILL.en.md +29 -15
  101. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +29 -16
  102. package/templates/.agents/skills/create-task/config/verify.json +1 -1
  103. package/templates/.agents/skills/import-codescan/SKILL.en.md +20 -6
  104. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +20 -6
  105. package/templates/.agents/skills/import-codescan/config/verify.json +1 -1
  106. package/templates/.agents/skills/import-dependabot/SKILL.en.md +20 -6
  107. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +20 -6
  108. package/templates/.agents/skills/import-dependabot/config/verify.json +1 -1
  109. package/templates/.agents/skills/import-issue/SKILL.en.md +19 -5
  110. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +19 -5
  111. package/templates/.agents/skills/plan-task/SKILL.en.md +13 -11
  112. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +13 -12
  113. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  114. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  115. package/templates/.agents/skills/restore-task/SKILL.en.md +12 -0
  116. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +12 -1
  117. package/templates/.agents/skills/review-analysis/SKILL.en.md +7 -1
  118. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +7 -2
  119. package/templates/.agents/skills/review-analysis/config/verify.en.json +3 -2
  120. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +3 -2
  121. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +15 -15
  122. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +15 -15
  123. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +7 -1
  124. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +7 -1
  125. package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +2 -0
  126. package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +2 -0
  127. package/templates/.agents/skills/review-code/SKILL.en.md +8 -1
  128. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +8 -2
  129. package/templates/.agents/skills/review-code/config/verify.en.json +3 -2
  130. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +3 -2
  131. package/templates/.agents/skills/review-code/reference/output-templates.en.md +9 -9
  132. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +9 -9
  133. package/templates/.agents/skills/review-code/reference/report-template.en.md +7 -1
  134. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +7 -1
  135. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +2 -0
  136. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +2 -0
  137. package/templates/.agents/skills/review-plan/SKILL.en.md +7 -1
  138. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +7 -2
  139. package/templates/.agents/skills/review-plan/config/verify.en.json +3 -2
  140. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +3 -2
  141. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +15 -15
  142. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +15 -15
  143. package/templates/.agents/skills/review-plan/reference/report-template.en.md +7 -1
  144. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +7 -1
  145. package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +2 -0
  146. package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +2 -0
  147. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +1 -0
  148. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -0
  149. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +112 -21
  150. package/templates/.agents/workflows/bug-fix.en.yaml +1 -1
  151. package/templates/.agents/workflows/bug-fix.zh-CN.yaml +1 -1
  152. package/templates/.agents/workflows/feature-development.en.yaml +1 -1
  153. package/templates/.agents/workflows/feature-development.zh-CN.yaml +1 -1
  154. package/templates/.agents/workflows/refactoring.en.yaml +1 -1
  155. package/templates/.agents/workflows/refactoring.zh-CN.yaml +1 -1
  156. package/templates/.claude/settings.json +11 -0
@@ -0,0 +1,55 @@
1
+ const BUILTIN_TUI_IDS = ['claude-code', 'codex', 'gemini-cli', 'opencode'] as const;
2
+ type BuiltinTUIId = (typeof BUILTIN_TUI_IDS)[number];
3
+
4
+ const BUILTIN_TUI_DISPLAY: Record<BuiltinTUIId, string> = {
5
+ 'claude-code': 'Claude Code',
6
+ 'codex': 'Codex',
7
+ 'gemini-cli': 'Gemini CLI',
8
+ 'opencode': 'OpenCode'
9
+ };
10
+
11
+ const BUILTIN_TUI_OWNED_PATH_PREFIXES: Record<BuiltinTUIId, string[]> = {
12
+ 'claude-code': ['.claude/'],
13
+ 'codex': ['.codex/'],
14
+ 'gemini-cli': ['.gemini/'],
15
+ 'opencode': ['.opencode/']
16
+ };
17
+
18
+ function isBuiltinTUIId(value: unknown): value is BuiltinTUIId {
19
+ return typeof value === 'string' && (BUILTIN_TUI_IDS as readonly string[]).includes(value);
20
+ }
21
+
22
+ function resolveEnabledTUIs(value: unknown): Set<BuiltinTUIId> {
23
+ // Missing field / null / non-array → full set (backward compat for legacy
24
+ // .airc.json predating the `tuis` field).
25
+ if (!Array.isArray(value)) return new Set(BUILTIN_TUI_IDS);
26
+ // Empty array is a meaningful, user-set value: "no built-in TUI managed".
27
+ // This supports the customTUI-only project layout.
28
+ const set = new Set<BuiltinTUIId>();
29
+ for (const v of value) {
30
+ if (isBuiltinTUIId(v)) set.add(v);
31
+ }
32
+ return set;
33
+ }
34
+
35
+ function isPathOwnedByDisabledTUI(rel: string, enabled: Set<BuiltinTUIId>): boolean {
36
+ const normalized = String(rel || '').replace(/\\/g, '/').replace(/^\.\//, '');
37
+ for (const tui of BUILTIN_TUI_IDS) {
38
+ if (enabled.has(tui)) continue;
39
+ for (const prefix of BUILTIN_TUI_OWNED_PATH_PREFIXES[tui]) {
40
+ const trimmed = prefix.replace(/\/$/, '');
41
+ if (normalized === trimmed || normalized.startsWith(prefix)) return true;
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+
47
+ export {
48
+ BUILTIN_TUI_IDS,
49
+ BUILTIN_TUI_DISPLAY,
50
+ BUILTIN_TUI_OWNED_PATH_PREFIXES,
51
+ isBuiltinTUIId,
52
+ resolveEnabledTUIs,
53
+ isPathOwnedByDisabledTUI
54
+ };
55
+ export type { BuiltinTUIId };
package/lib/defaults.json CHANGED
@@ -21,6 +21,9 @@
21
21
  "disk": null
22
22
  }
23
23
  },
24
+ "task": {
25
+ "shortIdLength": 2
26
+ },
24
27
  "labels": {
25
28
  "in": {}
26
29
  },
package/lib/init.ts CHANGED
@@ -3,11 +3,18 @@ import path from 'node:path';
3
3
  import { execSync } from 'node:child_process';
4
4
  import { platform } from 'node:os';
5
5
  import { info, ok, err } from './log.ts';
6
- import { prompt, select, closePrompt } from './prompt.ts';
6
+ import { prompt, select, multiSelect, closePrompt } from './prompt.ts';
7
7
  import { resolveTemplateDir } from './paths.ts';
8
8
  import { renderFile, copySkillDir, KNOWN_PLATFORMS } from './render.ts';
9
9
  import { enginesForPlatform } from './sandbox/engines/index.ts';
10
10
  import { VERSION } from './version.ts';
11
+ import {
12
+ BUILTIN_TUI_IDS,
13
+ BUILTIN_TUI_DISPLAY,
14
+ isPathOwnedByDisabledTUI,
15
+ resolveEnabledTUIs
16
+ } from './builtin-tuis.ts';
17
+ import type { BuiltinTUIId } from './builtin-tuis.ts';
11
18
 
12
19
  type FileRegistry = {
13
20
  managed: string[];
@@ -23,6 +30,7 @@ type SourceEntry = {
23
30
  type Defaults = {
24
31
  files: FileRegistry;
25
32
  sandbox: Record<string, unknown>;
33
+ task: { shortIdLength: number };
26
34
  labels: Record<string, unknown>;
27
35
  requiresPullRequest: boolean;
28
36
  };
@@ -35,8 +43,10 @@ type AgentConfig = {
35
43
  requiresPullRequest: boolean;
36
44
  templateVersion: string;
37
45
  sandbox: Record<string, unknown>;
46
+ task: { shortIdLength: number };
38
47
  labels: Record<string, unknown>;
39
48
  files: FileRegistry;
49
+ tuis: string[];
40
50
  templates?: { sources: SourceEntry[] };
41
51
  skills?: { sources: SourceEntry[] };
42
52
  };
@@ -60,10 +70,15 @@ function isPathOwnedByOtherPlatform(relativePath: string, platformType: string):
60
70
  return candidate !== platformType;
61
71
  }
62
72
 
63
- function buildDefaultFiles(platformType: string): FileRegistry {
73
+ function buildDefaultFiles(platformType: string, enabledTUIs: Set<BuiltinTUIId>): FileRegistry {
74
+ const ownedByDisabled = (entry: string) => isPathOwnedByDisabledTUI(entry, enabledTUIs);
64
75
  return {
65
- managed: (defaults.files.managed || []).filter((entry) => !isPathOwnedByOtherPlatform(entry, platformType)),
66
- merged: (defaults.files.merged || []).filter((entry) => !isPathOwnedByOtherPlatform(entry, platformType)),
76
+ managed: (defaults.files.managed || []).filter(
77
+ (entry) => !isPathOwnedByOtherPlatform(entry, platformType) && !ownedByDisabled(entry)
78
+ ),
79
+ merged: (defaults.files.merged || []).filter(
80
+ (entry) => !isPathOwnedByOtherPlatform(entry, platformType) && !ownedByDisabled(entry)
81
+ ),
67
82
  ejected: structuredClone(defaults.files.ejected || [])
68
83
  };
69
84
  }
@@ -216,6 +231,20 @@ async function cmdInit(): Promise<void> {
216
231
  );
217
232
  const requiresPullRequest = requiresPRChoice !== 'no';
218
233
 
234
+ let enabledTUIs: string[];
235
+ try {
236
+ enabledTUIs = await multiSelect(
237
+ 'Built-in TUI command files to install/manage',
238
+ BUILTIN_TUI_IDS.map((id) => ({ id, label: BUILTIN_TUI_DISPLAY[id] }))
239
+ );
240
+ } catch (e) {
241
+ err(e instanceof Error ? e.message : String(e));
242
+ closePrompt();
243
+ process.exitCode = 1;
244
+ return;
245
+ }
246
+ const enabledTUISet = resolveEnabledTUIs(enabledTUIs);
247
+
219
248
  const templateSources = parseLocalSources(await prompt(
220
249
  'Template sources (optional, comma-separated local paths, e.g. ~/my-templates; Enter to skip)',
221
250
  ''
@@ -259,29 +288,35 @@ async function cmdInit(): Promise<void> {
259
288
  );
260
289
  ok('Installed .agents/skills/update-agent-infra/');
261
290
 
262
- // install Claude command
263
- renderFile(
264
- path.join(templateDir, '.claude', 'commands', claudeSrc),
265
- path.join('.claude', 'commands', 'update-agent-infra.md'),
266
- replacements
267
- );
268
- ok('Installed .claude/commands/update-agent-infra.md');
291
+ // install Claude command (only if enabled)
292
+ if (enabledTUISet.has('claude-code')) {
293
+ renderFile(
294
+ path.join(templateDir, '.claude', 'commands', claudeSrc),
295
+ path.join('.claude', 'commands', 'update-agent-infra.md'),
296
+ replacements
297
+ );
298
+ ok('Installed .claude/commands/update-agent-infra.md');
299
+ }
269
300
 
270
- // install Gemini command
271
- renderFile(
272
- path.join(templateDir, '.gemini', 'commands', '_project_', geminiSrc),
273
- path.join('.gemini', 'commands', project, 'update-agent-infra.toml'),
274
- replacements
275
- );
276
- ok(`Installed .gemini/commands/${project}/update-agent-infra.toml`);
301
+ // install Gemini command (only if enabled)
302
+ if (enabledTUISet.has('gemini-cli')) {
303
+ renderFile(
304
+ path.join(templateDir, '.gemini', 'commands', '_project_', geminiSrc),
305
+ path.join('.gemini', 'commands', project, 'update-agent-infra.toml'),
306
+ replacements
307
+ );
308
+ ok(`Installed .gemini/commands/${project}/update-agent-infra.toml`);
309
+ }
277
310
 
278
- // install OpenCode command
279
- renderFile(
280
- path.join(templateDir, '.opencode', 'commands', opencodeSrc),
281
- path.join('.opencode', 'commands', 'update-agent-infra.md'),
282
- replacements
283
- );
284
- ok('Installed .opencode/commands/update-agent-infra.md');
311
+ // install OpenCode command (only if enabled)
312
+ if (enabledTUISet.has('opencode')) {
313
+ renderFile(
314
+ path.join(templateDir, '.opencode', 'commands', opencodeSrc),
315
+ path.join('.opencode', 'commands', 'update-agent-infra.md'),
316
+ replacements
317
+ );
318
+ ok('Installed .opencode/commands/update-agent-infra.md');
319
+ }
285
320
 
286
321
  // generate .agents/.airc.json
287
322
  const config: AgentConfig = {
@@ -292,8 +327,10 @@ async function cmdInit(): Promise<void> {
292
327
  requiresPullRequest,
293
328
  templateVersion: VERSION,
294
329
  sandbox: structuredClone(defaults.sandbox),
330
+ task: structuredClone(defaults.task),
295
331
  labels: structuredClone(defaults.labels),
296
- files: buildDefaultFiles(platformType)
332
+ files: buildDefaultFiles(platformType, enabledTUISet),
333
+ tuis: enabledTUIs
297
334
  };
298
335
 
299
336
  if (sandboxEngine) {
@@ -320,15 +357,30 @@ async function cmdInit(): Promise<void> {
320
357
  console.log('');
321
358
  ok('Project initialized successfully!');
322
359
  console.log('');
323
- console.log(' Next step: open this project in any AI TUI and run:');
324
- console.log('');
325
- console.log(' Claude Code / OpenCode: /update-agent-infra');
326
- console.log(` Gemini CLI: /${project}:update-agent-infra`);
327
- console.log(' Codex CLI: $update-agent-infra');
328
- console.log('');
329
- console.log(' This will render all templates and set up the full');
330
- console.log(' AI collaboration infrastructure.');
331
- console.log('');
360
+ if (enabledTUISet.size === 0) {
361
+ console.log(' No built-in TUI selected.');
362
+ console.log(` Configure "customTUIs" in ${configPath} before running update-agent-infra.`);
363
+ console.log('');
364
+ } else {
365
+ console.log(' Next step: open this project in any AI TUI and run:');
366
+ console.log('');
367
+ const claudeOrOpencode: string[] = [];
368
+ if (enabledTUISet.has('claude-code')) claudeOrOpencode.push('Claude Code');
369
+ if (enabledTUISet.has('opencode')) claudeOrOpencode.push('OpenCode');
370
+ if (claudeOrOpencode.length > 0) {
371
+ console.log(` ${claudeOrOpencode.join(' / ')}: /update-agent-infra`);
372
+ }
373
+ if (enabledTUISet.has('gemini-cli')) {
374
+ console.log(` Gemini CLI: /${project}:update-agent-infra`);
375
+ }
376
+ if (enabledTUISet.has('codex')) {
377
+ console.log(' Codex CLI: $update-agent-infra');
378
+ }
379
+ console.log('');
380
+ console.log(' This will render all templates and set up the full');
381
+ console.log(' AI collaboration infrastructure.');
382
+ console.log('');
383
+ }
332
384
  }
333
385
 
334
386
  export { cmdInit };
package/lib/prompt.ts CHANGED
@@ -86,6 +86,59 @@ async function select(question: string, choices: string[], defaultValue?: string
86
86
  return trimmed;
87
87
  }
88
88
 
89
+ async function multiSelect(
90
+ question: string,
91
+ choices: { id: string; label: string }[]
92
+ ): Promise<string[]> {
93
+ process.stdout.write(` ${question}:\n`);
94
+ const idWidth = Math.max(...choices.map((c) => c.id.length));
95
+ choices.forEach((c, i) => {
96
+ process.stdout.write(` ${i + 1}) ${c.id.padEnd(idWidth)} (${c.label})\n`);
97
+ });
98
+ ask('Enter comma-separated numbers or ids to keep, or "none" to select nothing [default: all]: ');
99
+
100
+ setupInterface();
101
+
102
+ const line = await nextLine();
103
+ // Strictly distinguish bare Enter (null/empty string) from whitespace input.
104
+ if (line === null || line === '') return choices.map((c) => c.id);
105
+ // Explicit empty selection: "none" means deliberately zero built-in choices.
106
+ if (line.trim().toLowerCase() === 'none') return [];
107
+
108
+ const tokens = line.split(',').map((t) => t.trim());
109
+ if (tokens.some((t) => t === '')) {
110
+ throw new Error(`Invalid selection input: "${line}" (empty token)`);
111
+ }
112
+
113
+ const idSet = new Set(choices.map((c) => c.id));
114
+ const seenIds = new Set<string>();
115
+ for (const t of tokens) {
116
+ let resolvedId: string | undefined;
117
+ if (/^[0-9]+$/.test(t)) {
118
+ const n = Number.parseInt(t, 10);
119
+ if (n < 1 || n > choices.length) {
120
+ throw new Error(`Selection out of range: "${t}" (expected 1..${choices.length})`);
121
+ }
122
+ resolvedId = choices[n - 1]!.id;
123
+ } else if (idSet.has(t)) {
124
+ resolvedId = t;
125
+ } else {
126
+ throw new Error(`Unknown TUI selection token: "${t}"`);
127
+ }
128
+ if (seenIds.has(resolvedId)) {
129
+ throw new Error(`Duplicate selection: "${t}" resolves to already-selected "${resolvedId}"`);
130
+ }
131
+ seenIds.add(resolvedId);
132
+ }
133
+
134
+ // Normalize to prompt order: users can type tokens in any order, but the
135
+ // persisted array follows the canonical choices order to keep .airc.json
136
+ // diffs stable. An empty result here is impossible (tokens.length > 0 and
137
+ // every token resolves to an id), so no separate empty guard is needed —
138
+ // explicit "none" handled above.
139
+ return choices.map((c) => c.id).filter((id) => seenIds.has(id));
140
+ }
141
+
89
142
  function closePrompt(): void {
90
143
  if (_rl) {
91
144
  _rl.close();
@@ -94,4 +147,4 @@ function closePrompt(): void {
94
147
  }
95
148
  }
96
149
 
97
- export { prompt, select, closePrompt };
150
+ export { prompt, select, multiSelect, closePrompt };
@@ -868,13 +868,22 @@ export function ensureCodexModelInheritance(toolDir: string, hostHomeDir?: strin
868
868
  }
869
869
  }
870
870
 
871
+ const inheritSpecs: Array<readonly [string, 'string' | 'number']> = [
872
+ ['model', 'string'],
873
+ ['model_reasoning_effort', 'string'],
874
+ ['model_auto_compact_token_limit', 'number']
875
+ ];
876
+
871
877
  let changed = false;
872
- for (const key of ['model', 'model_reasoning_effort']) {
878
+ for (const [key, type] of inheritSpecs) {
873
879
  if (Object.hasOwn(sandboxParsed, key)) {
874
880
  continue;
875
881
  }
876
882
  const value = hostParsed[key];
877
- if (typeof value !== 'string' || value === '') {
883
+ if (type === 'string' && (typeof value !== 'string' || value === '')) {
884
+ continue;
885
+ }
886
+ if (type === 'number' && (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)) {
878
887
  continue;
879
888
  }
880
889
  sandboxParsed[key] = value;
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from '../config.ts';
2
- import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel } from '../constants.ts';
2
+ import { assertValidBranchName, containerNameCandidates } from '../constants.ts';
3
3
  import { detectEngine } from '../engine.ts';
4
4
  import {
5
5
  formatCredentialWarnings,
@@ -13,12 +13,14 @@ import { resolveTaskBranch } from '../task-resolver.ts';
13
13
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
14
14
  import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.ts';
15
15
  import { detectHostTimezone } from '../host-timezone.ts';
16
- import { fetchSandboxRows, isTaskShortRef, resolveTaskShortRef } from './list-running.ts';
16
+ import { isTaskShortRef, resolveTaskShortRef } from './list-running.ts';
17
17
 
18
- const USAGE = `Usage: ai sandbox exec <branch | TASK-id | '#N'> [cmd...]
18
+ const USAGE = `Usage: ai sandbox exec <branch | TASK-id | N | '#N'> [cmd...]
19
19
 
20
- '#N' references the N-th running sandbox in 'ai sandbox ls' order (1-based).
21
- Quote it as '#N' to avoid shell '#' comment handling.`;
20
+ N (bare) and '#N' both reference the same active task short id from
21
+ .agents/workspace/active/.short-ids.json. They resolve only via that
22
+ registry — they do not reference a container's row position in
23
+ 'ai sandbox ls' output.`;
22
24
  const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
23
25
 
24
26
  // Terminal-detection variables that interactive TUIs (e.g. claude-code)
@@ -122,8 +124,7 @@ export async function enter(args: string[]): Promise<number> {
122
124
  const [firstArg = '', ...cmd] = args;
123
125
  let branch: string;
124
126
  if (isTaskShortRef(firstArg)) {
125
- const { running } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
126
- branch = resolveTaskShortRef(firstArg, { running });
127
+ branch = resolveTaskShortRef(firstArg, { repoRoot: config.repoRoot });
127
128
  } else {
128
129
  branch = resolveTaskBranch(firstArg, config.repoRoot);
129
130
  }
@@ -1,3 +1,6 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
1
4
  import { runSafeEngine } from '../shell.ts';
2
5
 
3
6
  export type SandboxRow = {
@@ -85,51 +88,87 @@ export function fetchSandboxRows(
85
88
  }
86
89
 
87
90
  /**
88
- * Returns true iff `arg` is a syntactically valid task short reference ('#N').
91
+ * Returns true iff `arg` is a syntactically valid task short reference.
92
+ * Accepts both bare numeric ('11') and '#'-prefixed ('#11') forms.
89
93
  * Zero IO. Callers MUST use this as the gate before constructing any context
90
94
  * for resolveTaskShortRef — that way non-matching arguments (e.g. '#abc',
91
95
  * '#1.5', '#') never trigger sandbox list IO.
92
96
  */
93
97
  export function isTaskShortRef(arg: string): boolean {
94
- return /^#\d+$/.test(arg);
98
+ return /^#?\d+$/.test(arg);
95
99
  }
96
100
 
101
+ type RegistryLookup =
102
+ | { status: 'miss' }
103
+ | { status: 'hit'; branch: string };
104
+
97
105
  /**
98
- * Resolve a task short reference ('#N') to a branch name.
99
- *
100
- * Current implementation: treats the digits as a 1-based index into the
101
- * supplied running-sandbox list (ls view order). This is the *only*
102
- * resolution path until the global task-short-id registry lands in a
103
- * follow-up task; do NOT read task.md or scan .agents/workspace/ from this
104
- * helper here.
106
+ * Try to resolve a short ref against the global task-short-id registry.
105
107
  *
106
- * Precondition: callers MUST gate on isTaskShortRef(arg) === true before
107
- * constructing ctx and calling this function. Throws when arg is a valid
108
- * short ref but cannot be resolved (out of range, no running sandboxes,
109
- * etc.); the caller surfaces the error to the user.
108
+ * Tri-state semantics (review-code Round 1 M-1 fix):
109
+ * - 'miss' → script reports no entry (or registry script missing). Caller may fall back.
110
+ * - 'hit' → registry resolved to a task id and branch is found in task.md.
111
+ * - throws → registry hit but task.md is missing or branch metadata is unparseable;
112
+ * surfacing this error is critical — never silently fall back to running index.
110
113
  */
111
- export function resolveTaskShortRef(
112
- arg: string,
113
- ctx: { running: SandboxRow[] }
114
- ): string {
115
- const n = Number(arg.slice(1));
116
- if (n < 1) {
117
- throw new Error(`Invalid sandbox index '${arg}': must be >= 1`);
118
- }
119
- const { running } = ctx;
120
- if (running.length === 0) {
121
- throw new Error(`No running sandbox to reference with '${arg}'`);
122
- }
123
- if (n > running.length) {
114
+ function tryResolveFromRegistry(arg: string, repoRoot: string): RegistryLookup {
115
+ const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
116
+ if (!fs.existsSync(scriptPath)) return { status: 'miss' };
117
+ // Strip leading '#' when forwarding bare-numeric input through the script's CLI.
118
+ // (Script accepts both forms, but this avoids shell quoting confusion in error
119
+ // messages echoed back to the user.)
120
+ const result = spawnSync('node', [scriptPath, 'resolve', arg], { encoding: 'utf8', cwd: repoRoot });
121
+ if (result.status !== 0) return { status: 'miss' };
122
+ const taskId = (result.stdout || '').trim();
123
+ if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) {
124
124
  throw new Error(
125
- `No running sandbox at index '${arg}' (only ${running.length} running)`
125
+ `Registry returned malformed task id for '${arg}': ${JSON.stringify(taskId)}`
126
126
  );
127
127
  }
128
- const row = running[n - 1]!;
129
- if (!row.branch) {
128
+ for (const sub of ['active', 'completed', 'blocked', 'archive']) {
129
+ const taskMdPath = path.join(repoRoot, '.agents', 'workspace', sub, taskId, 'task.md');
130
+ if (!fs.existsSync(taskMdPath)) continue;
131
+ const content = fs.readFileSync(taskMdPath, 'utf8');
132
+ const fm = content.match(/^branch:\s*(.+)$/m);
133
+ if (fm?.[1]?.trim()) {
134
+ return { status: 'hit', branch: fm[1].trim().replace(/^(["'])(.*)\1$/, '$2') };
135
+ }
136
+ const ctx = content.match(/^- \*\*(?:分支|Branch)\*\*:[ \t]*`?([^`\n]+)`?$/m);
137
+ if (ctx?.[1]?.trim()) {
138
+ return { status: 'hit', branch: ctx[1].trim().replace(/^(["'])(.*)\1$/, '$2') };
139
+ }
130
140
  throw new Error(
131
- `Cannot resolve branch for sandbox '${arg}' (container '${row.name}' missing branch label)`
141
+ `Short ref '${arg}' resolved to task ${taskId} but task.md has no branch field`
132
142
  );
133
143
  }
134
- return row.branch;
144
+ throw new Error(
145
+ `Short ref '${arg}' resolved to task ${taskId} but task.md was not found under any workspace dir`
146
+ );
147
+ }
148
+
149
+ /**
150
+ /**
151
+ * Resolve a task short reference (bare 'N' or '#N') to a branch name for the
152
+ * sandbox entrypoint.
153
+ *
154
+ * Resolution: registry-only. Look up the short id in the global task-short-id
155
+ * registry under repoRoot; if hit, read the branch from the matching task.md.
156
+ * On miss (registry empty or short id absent), throw with an actionable
157
+ * message instead of falling back to a container's row position in
158
+ * 'ai sandbox ls' output — that fallback would make the same syntax mean
159
+ * different things depending on `docker ps` state.
160
+ *
161
+ * Precondition: callers MUST gate on isTaskShortRef(arg) === true.
162
+ */
163
+ export function resolveTaskShortRef(
164
+ arg: string,
165
+ ctx: { repoRoot: string }
166
+ ): string {
167
+ const lookup = tryResolveFromRegistry(arg, ctx.repoRoot);
168
+ if (lookup.status === 'hit') return lookup.branch;
169
+ throw new Error(
170
+ `short ref '${arg}' is not in the active task registry. ` +
171
+ `'#N' and bare N resolve only via the registry (not by row position in 'ai sandbox ls'); ` +
172
+ `use a task short id (e.g. 'ai sandbox exec 11'), a TASK-id, or a branch name.`
173
+ );
135
174
  }
@@ -6,40 +6,35 @@ import { loadConfig } from '../config.ts';
6
6
  import { sandboxBranchLabel, sandboxLabel } from '../constants.ts';
7
7
  import { detectEngine } from '../engine.ts';
8
8
  import { resolveTools, toolProjectDirCandidates } from '../tools.ts';
9
+ import { formatTable } from '../../table.ts';
10
+ import { lookupShortIdByBranch } from '../../task/short-id.ts';
9
11
  import { fetchSandboxRows } from './list-running.ts';
10
12
 
11
13
  export { containerListFormat, parseLabels } from './list-running.ts';
12
14
 
13
15
  const USAGE = `Usage: ai sandbox ls
14
16
 
15
- Lists all containers for the current project. The leftmost '#' column
16
- numbers running sandboxes; use it as "ai sandbox exec '#N'" to enter one.
17
- Quote '#N' to avoid shell '#' comment handling.`;
17
+ Lists all containers for the current project. The '#' column is a
18
+ display-only row number; the 'SHORT' column shows the active task short
19
+ id bound to each container's branch (via
20
+ .agents/workspace/active/.short-ids.json), or '-' if no active task is
21
+ bound. Pass the SHORT value to "ai sandbox exec" (e.g. 'ai sandbox exec 11').`;
18
22
 
19
- const CONTAINER_TABLE_HEADERS = ['#', 'NAMES', 'STATUS', 'BRANCH'] as const;
23
+ const CONTAINER_TABLE_HEADERS = ['#', 'SHORT', 'NAMES', 'STATUS', 'BRANCH'] as const;
20
24
 
21
25
  type ContainerTableRow = {
22
- index: string;
26
+ row: string;
27
+ shortId: string;
23
28
  name: string;
24
29
  status: string;
25
30
  branch: string;
26
31
  };
27
32
 
28
33
  export function formatContainerTable(rows: ContainerTableRow[]): string[] {
29
- const columns = rows.map((row) => [row.index, row.name, row.status, row.branch] as const);
30
- const widths = [
31
- Math.max(CONTAINER_TABLE_HEADERS[0].length, ...rows.map((row) => row.index.length)),
32
- Math.max(CONTAINER_TABLE_HEADERS[1].length, ...rows.map((row) => row.name.length)),
33
- Math.max(CONTAINER_TABLE_HEADERS[2].length, ...rows.map((row) => row.status.length)),
34
- Math.max(CONTAINER_TABLE_HEADERS[3].length, ...rows.map((row) => row.branch.length))
35
- ] as const;
36
- const renderRow = (values: readonly [string, string, string, string]): string =>
37
- `${values[0].padEnd(widths[0])} ${values[1].padEnd(widths[1])} ${values[2].padEnd(widths[2])} ${values[3]}`.trimEnd();
38
-
39
- return [
40
- renderRow(CONTAINER_TABLE_HEADERS),
41
- ...columns.map((column) => renderRow(column))
42
- ];
34
+ return formatTable(
35
+ CONTAINER_TABLE_HEADERS,
36
+ rows.map((r) => [r.row, r.shortId, r.name, r.status, r.branch])
37
+ );
43
38
  }
44
39
 
45
40
  function listChildren(dir: string): string[] {
@@ -69,15 +64,20 @@ export function ls(args: string[] = []): void {
69
64
  if (ordered.length === 0) {
70
65
  p.log.warn(' No sandbox containers');
71
66
  } else {
72
- const tableRows: ContainerTableRow[] = ordered.map((row) => ({
73
- index: row.index === null ? '' : String(row.index),
74
- name: row.name,
75
- status: row.status,
76
- branch: row.branch
77
- }));
67
+ const tableRows: ContainerTableRow[] = ordered.map((container, i) => {
68
+ const shortId = container.branch ? lookupShortIdByBranch(container.branch, config.repoRoot) : null;
69
+ return {
70
+ row: String(i + 1),
71
+ shortId: shortId ?? '-',
72
+ name: container.name,
73
+ status: container.status,
74
+ branch: container.branch
75
+ };
76
+ });
78
77
  for (const line of formatContainerTable(tableRows)) {
79
78
  process.stdout.write(` ${line}\n`);
80
79
  }
80
+ process.stdout.write(` Total: ${ordered.length} containers\n`);
81
81
  }
82
82
 
83
83
  p.log.step('Worktrees');
@@ -7,7 +7,8 @@ import type { SandboxConfig } from '../config.ts';
7
7
  import { prepareDockerfile } from '../dockerfile.ts';
8
8
  import { sandboxImageConfigLabel, sandboxLabel } from '../constants.ts';
9
9
  import { detectEngine, ensureDocker } from '../engine.ts';
10
- import { runEngine, runOkEngine, runSafeEngine, runVerboseEngine } from '../shell.ts';
10
+ import { runEngine, runSafeEngine, runVerboseEngine } from '../shell.ts';
11
+ import { pruneSandboxDanglingImages } from '../image-prune.ts';
11
12
  import {
12
13
  imageSignatureFields,
13
14
  resolveTools,
@@ -89,12 +90,6 @@ export function buildArgs(
89
90
  return args;
90
91
  }
91
92
 
92
- function removeImageIfPresent(imageName: string, engine: string): void {
93
- if (runOkEngine(engine, 'docker', ['image', 'inspect', imageName])) {
94
- runEngine(engine, 'docker', ['rmi', imageName]);
95
- }
96
- }
97
-
98
93
  export async function rebuild(args: string[]): Promise<void> {
99
94
  const { values } = parseArgs({
100
95
  args,
@@ -126,17 +121,12 @@ export async function rebuild(args: string[]): Promise<void> {
126
121
  try {
127
122
  if (quiet) {
128
123
  const spinner = p.spinner();
129
- spinner.start(`Removing old image ${config.imageName}...`);
130
- removeImageIfPresent(config.imageName, engine);
131
- spinner.stop('Old image removed');
132
124
  spinner.start('Building image...');
133
125
  runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), {
134
126
  cwd: config.repoRoot
135
127
  });
136
128
  spinner.stop(pc.green('Sandbox image rebuilt'));
137
129
  } else {
138
- p.log.step(`Removing old image ${config.imageName}`);
139
- removeImageIfPresent(config.imageName, engine);
140
130
  p.log.step('Building image');
141
131
  runVerboseEngine(
142
132
  engine,
@@ -146,6 +136,7 @@ export async function rebuild(args: string[]): Promise<void> {
146
136
  );
147
137
  p.log.success(pc.green('Sandbox image rebuilt'));
148
138
  }
139
+ pruneSandboxDanglingImages(config, engine);
149
140
  } finally {
150
141
  preparedDockerfile.cleanup();
151
142
  }
@@ -15,6 +15,7 @@ import {
15
15
  worktreeDirCandidates
16
16
  } from '../constants.ts';
17
17
  import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.ts';
18
+ import { pruneSandboxDanglingImages } from '../image-prune.ts';
18
19
  import { removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
19
20
  import { runOk, runSafe, runSafeEngine } from '../shell.ts';
20
21
  import { resolveTaskBranch } from '../task-resolver.ts';
@@ -208,6 +209,8 @@ async function rmAll(config: SandboxConfig, tools: SandboxTool[]): Promise<void>
208
209
  runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
209
210
  }
210
211
 
212
+ pruneSandboxDanglingImages(config, engine);
213
+
211
214
  if (isManagedEngine(engine)) {
212
215
  if (engine === ENGINES.WSL2) {
213
216
  p.log.warn('Windows uses Docker Desktop with WSL2. Stop it from Docker Desktop or run "wsl --shutdown" manually.');