@fitlab-ai/agent-infra 0.7.2 → 0.7.4

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 (126) hide show
  1. package/README.md +35 -787
  2. package/README.zh-CN.md +37 -762
  3. package/bin/cli.ts +1 -1
  4. package/dist/bin/cli.js +1 -1
  5. package/dist/lib/defaults.json +0 -1
  6. package/dist/lib/init.js +0 -3
  7. package/dist/lib/sandbox/commands/create.js +44 -3
  8. package/dist/lib/sandbox/commands/enter.js +13 -15
  9. package/dist/lib/sandbox/commands/list-running.js +36 -1
  10. package/dist/lib/sandbox/commands/ls.js +9 -4
  11. package/dist/lib/sandbox/commands/rm.js +99 -19
  12. package/dist/lib/sandbox/commands/start.js +36 -0
  13. package/dist/lib/sandbox/index.js +11 -1
  14. package/dist/lib/sandbox/readme-scaffold.js +6 -6
  15. package/dist/lib/table.js +11 -2
  16. package/dist/lib/task/artifacts.js +58 -0
  17. package/dist/lib/task/commands/cat.js +38 -0
  18. package/dist/lib/task/commands/files.js +47 -0
  19. package/dist/lib/task/commands/grep.js +143 -0
  20. package/dist/lib/task/commands/log.js +75 -0
  21. package/dist/lib/task/commands/ls.js +1 -1
  22. package/dist/lib/task/commands/show.js +5 -114
  23. package/dist/lib/task/commands/status.js +239 -0
  24. package/dist/lib/task/index.js +37 -0
  25. package/dist/lib/task/resolve-ref.js +150 -0
  26. package/dist/lib/task/short-id.js +10 -0
  27. package/dist/lib/update.js +25 -8
  28. package/lib/defaults.json +0 -1
  29. package/lib/init.ts +0 -10
  30. package/lib/sandbox/commands/create.ts +47 -4
  31. package/lib/sandbox/commands/enter.ts +33 -14
  32. package/lib/sandbox/commands/list-running.ts +43 -1
  33. package/lib/sandbox/commands/ls.ts +12 -4
  34. package/lib/sandbox/commands/rm.ts +128 -19
  35. package/lib/sandbox/commands/start.ts +61 -0
  36. package/lib/sandbox/index.ts +11 -1
  37. package/lib/sandbox/readme-scaffold.ts +6 -6
  38. package/lib/table.ts +14 -2
  39. package/lib/task/artifacts.ts +72 -0
  40. package/lib/task/commands/cat.ts +39 -0
  41. package/lib/task/commands/files.ts +53 -0
  42. package/lib/task/commands/grep.ts +147 -0
  43. package/lib/task/commands/log.ts +80 -0
  44. package/lib/task/commands/ls.ts +1 -1
  45. package/lib/task/commands/show.ts +5 -117
  46. package/lib/task/commands/status.ts +302 -0
  47. package/lib/task/index.ts +37 -0
  48. package/lib/task/resolve-ref.ts +160 -0
  49. package/lib/task/short-id.ts +10 -0
  50. package/lib/update.ts +28 -10
  51. package/package.json +1 -1
  52. package/templates/.agents/README.en.md +1 -0
  53. package/templates/.agents/README.zh-CN.md +1 -0
  54. package/templates/.agents/hooks/auto-resume.sh +21 -4
  55. package/templates/.agents/rules/README.en.md +41 -0
  56. package/templates/.agents/rules/README.zh-CN.md +40 -0
  57. package/templates/.agents/rules/debugging-guide.en.md +25 -0
  58. package/templates/.agents/rules/debugging-guide.zh-CN.md +25 -0
  59. package/templates/.agents/rules/next-step-output.en.md +6 -3
  60. package/templates/.agents/rules/next-step-output.zh-CN.md +6 -3
  61. package/templates/.agents/rules/pr-checks-commands.en.md +5 -0
  62. package/templates/.agents/rules/pr-checks-commands.github.en.md +62 -0
  63. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +62 -0
  64. package/templates/.agents/rules/pr-checks-commands.zh-CN.md +5 -0
  65. package/templates/.agents/rules/pr-sync.github.en.md +7 -0
  66. package/templates/.agents/rules/pr-sync.github.zh-CN.md +7 -0
  67. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -1
  68. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +1 -1
  69. package/templates/.agents/skills/block-task/SKILL.en.md +8 -1
  70. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +8 -1
  71. package/templates/.agents/skills/cancel-task/SKILL.en.md +8 -1
  72. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +8 -1
  73. package/templates/.agents/skills/check-task/SKILL.en.md +1 -1
  74. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +1 -1
  75. package/templates/.agents/skills/close-codescan/SKILL.en.md +8 -1
  76. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +8 -1
  77. package/templates/.agents/skills/close-dependabot/SKILL.en.md +8 -1
  78. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +8 -1
  79. package/templates/.agents/skills/code-task/SKILL.en.md +3 -1
  80. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +3 -1
  81. package/templates/.agents/skills/commit/SKILL.en.md +2 -3
  82. package/templates/.agents/skills/commit/SKILL.zh-CN.md +2 -3
  83. package/templates/.agents/skills/commit/reference/task-status-update.en.md +31 -23
  84. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +31 -23
  85. package/templates/.agents/skills/complete-task/SKILL.en.md +36 -3
  86. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +36 -3
  87. package/templates/.agents/skills/create-pr/SKILL.en.md +16 -7
  88. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +16 -7
  89. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -0
  90. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -0
  91. package/templates/.agents/skills/create-task/SKILL.en.md +1 -1
  92. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
  93. package/templates/.agents/skills/import-codescan/SKILL.en.md +1 -1
  94. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
  95. package/templates/.agents/skills/import-dependabot/SKILL.en.md +1 -1
  96. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
  97. package/templates/.agents/skills/import-issue/SKILL.en.md +1 -1
  98. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  99. package/templates/.agents/skills/plan-task/SKILL.en.md +1 -1
  100. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +1 -1
  101. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  102. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  103. package/templates/.agents/skills/review-code/SKILL.en.md +1 -1
  104. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +1 -1
  105. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  106. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  107. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +0 -1
  108. package/templates/.agents/skills/watch-pr/SKILL.en.md +131 -0
  109. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +131 -0
  110. package/templates/.agents/skills/watch-pr/config/verify.json +22 -0
  111. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +43 -0
  112. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +43 -0
  113. package/templates/.agents/templates/task.en.md +1 -0
  114. package/templates/.agents/templates/task.zh-CN.md +1 -0
  115. package/templates/.agents/workflows/bug-fix.en.yaml +6 -4
  116. package/templates/.agents/workflows/bug-fix.zh-CN.yaml +5 -4
  117. package/templates/.agents/workflows/feature-development.en.yaml +6 -4
  118. package/templates/.agents/workflows/feature-development.zh-CN.yaml +5 -4
  119. package/templates/.agents/workflows/refactoring.en.yaml +6 -4
  120. package/templates/.agents/workflows/refactoring.zh-CN.yaml +5 -4
  121. package/templates/.claude/commands/watch-pr.en.md +8 -0
  122. package/templates/.claude/commands/watch-pr.zh-CN.md +8 -0
  123. package/templates/.gemini/commands/_project_/watch-pr.en.toml +8 -0
  124. package/templates/.gemini/commands/_project_/watch-pr.zh-CN.toml +8 -0
  125. package/templates/.opencode/commands/watch-pr.en.md +11 -0
  126. package/templates/.opencode/commands/watch-pr.zh-CN.md +11 -0
@@ -1,5 +1,10 @@
1
1
  import { loadConfig } from '../config.ts';
2
- import { assertValidBranchName, containerNameCandidates } from '../constants.ts';
2
+ import {
3
+ assertValidBranchName,
4
+ containerNameCandidates,
5
+ sandboxBranchLabel,
6
+ sandboxLabel
7
+ } from '../constants.ts';
3
8
  import { detectEngine } from '../engine.ts';
4
9
  import {
5
10
  formatCredentialWarnings,
@@ -8,12 +13,16 @@ import {
8
13
  redactCommandError,
9
14
  validateClaudeCredentialsEnvOverride
10
15
  } from '../credentials.ts';
11
- import { runInteractiveEngine, runSafeEngine } from '../shell.ts';
12
- import { resolveTaskBranch } from '../task-resolver.ts';
16
+ import { runInteractiveEngine } from '../shell.ts';
13
17
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
14
18
  import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.ts';
15
19
  import { detectHostTimezone } from '../host-timezone.ts';
16
- import { isTaskShortRef, resolveTaskShortRef } from './list-running.ts';
20
+ import {
21
+ fetchSandboxRows,
22
+ resolveBranchArg,
23
+ selectSandboxContainer,
24
+ startSandboxContainer
25
+ } from './list-running.ts';
17
26
 
18
27
  const USAGE = `Usage: ai sandbox exec <branch | TASK-id | N | '#N'> [cmd...]
19
28
 
@@ -122,19 +131,29 @@ export async function enter(args: string[]): Promise<number> {
122
131
  validateClaudeCredentialsEnvOverride();
123
132
  const engine = detectEngine(config);
124
133
  const [firstArg = '', ...cmd] = args;
125
- let branch: string;
126
- if (isTaskShortRef(firstArg)) {
127
- branch = resolveTaskShortRef(firstArg, { repoRoot: config.repoRoot });
128
- } else {
129
- branch = resolveTaskBranch(firstArg, config.repoRoot);
130
- }
134
+ const branch = resolveBranchArg(firstArg, { repoRoot: config.repoRoot });
131
135
  assertValidBranchName(branch);
132
- const running = runSafeEngine(engine, 'docker', ['ps', '--format', '{{.Names}}']).split('\n');
133
- const container = containerNameCandidates(config, branch).find((name) => running.includes(name));
134
136
 
135
- if (!container) {
136
- throw new Error(`No running sandbox found for branch '${branch}'`);
137
+ const { running, nonRunning } = fetchSandboxRows(
138
+ engine,
139
+ sandboxLabel(config),
140
+ sandboxBranchLabel(config)
141
+ );
142
+ const found = selectSandboxContainer(
143
+ [...running, ...nonRunning],
144
+ containerNameCandidates(config, branch)
145
+ );
146
+
147
+ if (!found) {
148
+ throw new Error(
149
+ `No sandbox found for branch '${branch}'. Run 'ai sandbox create ${branch}' to create one.`
150
+ );
151
+ }
152
+ if (!found.running) {
153
+ process.stderr.write(`Sandbox '${found.name}' is stopped; starting it...\n`);
154
+ startSandboxContainer(engine, found.name);
137
155
  }
156
+ const container = found.name;
138
157
 
139
158
  if (config.tools.includes('claude-code')) {
140
159
  try {
@@ -1,7 +1,8 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { runSafeEngine } from '../shell.ts';
4
+ import { runSafeEngine, runVerboseEngine } from '../shell.ts';
5
+ import { resolveTaskBranch } from '../task-resolver.ts';
5
6
 
6
7
  export type SandboxRow = {
7
8
  name: string;
@@ -172,3 +173,44 @@ export function resolveTaskShortRef(
172
173
  `use a task short id (e.g. 'ai sandbox exec 11'), a TASK-id, or a branch name.`
173
174
  );
174
175
  }
176
+
177
+ /**
178
+ * Resolve a sandbox command argument (`<branch | TASK-id | N | '#N'>`) to a
179
+ * branch name, mirroring `ai sandbox exec` so that `start` and `exec` share one
180
+ * input contract. Short refs go through the registry-only resolver (which throws
181
+ * an actionable error on a miss); everything else flows through resolveTaskBranch
182
+ * (plain branch names pass through unchanged, TASK-ids resolve via task.md).
183
+ * Callers still run assertValidBranchName on the result.
184
+ */
185
+ export function resolveBranchArg(arg: string, ctx: { repoRoot: string }): string {
186
+ return isTaskShortRef(arg)
187
+ ? resolveTaskShortRef(arg, ctx)
188
+ : resolveTaskBranch(arg, ctx.repoRoot);
189
+ }
190
+
191
+ /**
192
+ * Pick the sandbox container row whose name matches one of the candidate
193
+ * container names (covers both the '..' and legacy '-' branch sanitizations).
194
+ * Pure: no IO. Returns null when no row matches.
195
+ */
196
+ export function selectSandboxContainer(
197
+ rows: SandboxRow[],
198
+ candidates: string[]
199
+ ): SandboxRow | null {
200
+ return rows.find((row) => candidates.includes(row.name)) ?? null;
201
+ }
202
+
203
+ /**
204
+ * Start an existing (stopped) sandbox container by name. Throws a distinct,
205
+ * actionable error when `docker start` fails, so callers can tell "start failed"
206
+ * apart from "container not found".
207
+ */
208
+ export function startSandboxContainer(engine: string, name: string): void {
209
+ try {
210
+ runVerboseEngine(engine, 'docker', ['start', name]);
211
+ } catch (error) {
212
+ throw new Error(
213
+ `Failed to start sandbox container '${name}': ${error instanceof Error ? error.message : 'unknown error'}`
214
+ );
215
+ }
216
+ }
@@ -18,7 +18,9 @@ Lists all containers for the current project. The '#' column is a
18
18
  display-only row number; the 'SHORT' column shows the active task short
19
19
  id bound to each container's branch (via
20
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').`;
21
+ bound. Pass the SHORT value to "ai sandbox exec" (e.g. 'ai sandbox exec 11').
22
+ A '-' means no active task is bound to that branch, so the sandbox is free
23
+ to remove with "ai sandbox rm <branch>".`;
22
24
 
23
25
  const CONTAINER_TABLE_HEADERS = ['#', 'SHORT', 'NAMES', 'STATUS', 'BRANCH'] as const;
24
26
 
@@ -30,10 +32,11 @@ type ContainerTableRow = {
30
32
  branch: string;
31
33
  };
32
34
 
33
- export function formatContainerTable(rows: ContainerTableRow[]): string[] {
35
+ export function formatContainerTable(rows: ContainerTableRow[], zebra = false): string[] {
34
36
  return formatTable(
35
37
  CONTAINER_TABLE_HEADERS,
36
- rows.map((r) => [r.row, r.shortId, r.name, r.status, r.branch])
38
+ rows.map((r) => [r.row, r.shortId, r.name, r.status, r.branch]),
39
+ { zebra }
37
40
  );
38
41
  }
39
42
 
@@ -74,10 +77,15 @@ export function ls(args: string[] = []): void {
74
77
  branch: container.branch
75
78
  };
76
79
  });
77
- for (const line of formatContainerTable(tableRows)) {
80
+ for (const line of formatContainerTable(tableRows, Boolean(process.stdout.isTTY))) {
78
81
  process.stdout.write(` ${line}\n`);
79
82
  }
80
83
  process.stdout.write(` Total: ${ordered.length} containers\n`);
84
+ if (tableRows.some((r) => r.shortId === '-')) {
85
+ process.stdout.write(
86
+ ` SHORT '-' = no active task bound; that sandbox is free to remove with 'ai sandbox rm <branch>'.\n`
87
+ );
88
+ }
81
89
  }
82
90
 
83
91
  p.log.step('Worktrees');
@@ -21,15 +21,27 @@ import { runOk, runSafe, runSafeEngine } from '../shell.ts';
21
21
  import { resolveTaskBranch } from '../task-resolver.ts';
22
22
  import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.ts';
23
23
  import type { SandboxTool } from '../tools.ts';
24
+ import { fetchSandboxRows } from './list-running.ts';
25
+ import { lookupShortIdByBranch } from '../../task/short-id.ts';
24
26
 
25
- const USAGE = `Usage: ai sandbox rm <branch> [--all]`;
27
+ const USAGE = `Usage:
28
+ ai sandbox rm <branch> Remove one sandbox (branch | TASK-id | short id)
29
+ ai sandbox rm --all [--dry-run] [--yes] Remove every sandbox not bound to an active task
30
+ ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
26
31
  export { assertManagedPath } from '../managed-fs.ts';
27
32
 
28
33
  function projectToolDirs(config: SandboxConfig, tools: SandboxTool[]): string[] {
29
34
  return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
30
35
  }
31
36
 
32
- async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string): Promise<void> {
37
+ type RmOneOptions = { assumeYes?: boolean; quiet?: boolean };
38
+
39
+ async function rmOne(
40
+ config: SandboxConfig,
41
+ tools: SandboxTool[],
42
+ branch: string,
43
+ options: RmOneOptions = {}
44
+ ): Promise<void> {
33
45
  assertValidBranchName(branch);
34
46
  const engine = detectEngine(config);
35
47
  let effectiveBranch = branch;
@@ -39,7 +51,9 @@ async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string
39
51
  candidates: toolConfigDirCandidates(tool, config.project, branch)
40
52
  }));
41
53
 
42
- p.intro(pc.cyan(`Removing sandbox for ${branch}`));
54
+ if (!options.quiet) {
55
+ p.intro(pc.cyan(`Removing sandbox for ${branch}`));
56
+ }
43
57
 
44
58
  const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
45
59
  const matchedContainers = containerNameCandidates(config, branch)
@@ -74,10 +88,12 @@ async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string
74
88
 
75
89
  const existingWorktrees = worktreeCandidates.filter((candidate) => fs.existsSync(candidate));
76
90
  if (existingWorktrees.length > 0) {
77
- const shouldRemoveWorktree = await p.confirm({
78
- message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
79
- initialValue: true
80
- });
91
+ const shouldRemoveWorktree = options.assumeYes
92
+ ? true
93
+ : await p.confirm({
94
+ message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
95
+ initialValue: true
96
+ });
81
97
 
82
98
  if (p.isCancel(shouldRemoveWorktree)) {
83
99
  p.outro('Cancelled');
@@ -89,10 +105,12 @@ async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string
89
105
  removeWorktreeDir(config.repoRoot, config.worktreeBase, worktree);
90
106
  }
91
107
 
92
- const shouldDeleteBranch = await p.confirm({
93
- message: `Also delete local branch '${effectiveBranch}'?`,
94
- initialValue: true
95
- });
108
+ const shouldDeleteBranch = options.assumeYes
109
+ ? true
110
+ : await p.confirm({
111
+ message: `Also delete local branch '${effectiveBranch}'?`,
112
+ initialValue: true
113
+ });
96
114
 
97
115
  if (!p.isCancel(shouldDeleteBranch) && shouldDeleteBranch) {
98
116
  if (!runOk('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch])) {
@@ -116,20 +134,24 @@ async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string
116
134
 
117
135
  const shareBranch = shareBranchDir(config, effectiveBranch);
118
136
  if (fs.existsSync(shareBranch)) {
119
- const shouldRemoveShare = await p.confirm({
120
- message: `Remove share dir for branch '${effectiveBranch}' (${shareBranch})?`,
121
- initialValue: true
122
- });
137
+ const shouldRemoveShare = options.assumeYes
138
+ ? true
139
+ : await p.confirm({
140
+ message: `Remove share dir for branch '${effectiveBranch}' (${shareBranch})?`,
141
+ initialValue: true
142
+ });
123
143
  if (!p.isCancel(shouldRemoveShare) && shouldRemoveShare) {
124
144
  removeManagedDir(config.shareBase, shareBranch);
125
145
  p.log.success(`Share dir removed: ${shareBranch}`);
126
146
  }
127
147
  }
128
148
 
129
- p.outro(pc.green('Sandbox removed'));
149
+ if (!options.quiet) {
150
+ p.outro(pc.green('Sandbox removed'));
151
+ }
130
152
  }
131
153
 
132
- async function rmAll(config: SandboxConfig, tools: SandboxTool[]): Promise<void> {
154
+ async function rmPurge(config: SandboxConfig, tools: SandboxTool[]): Promise<void> {
133
155
  const engine = detectEngine(config);
134
156
  p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
135
157
 
@@ -231,6 +253,70 @@ async function rmAll(config: SandboxConfig, tools: SandboxTool[]): Promise<void>
231
253
  p.outro(pc.green('All project sandboxes removed'));
232
254
  }
233
255
 
256
+ async function rmUnbound(
257
+ config: SandboxConfig,
258
+ tools: SandboxTool[],
259
+ options: { dryRun: boolean; assumeYes: boolean }
260
+ ): Promise<void> {
261
+ const engine = detectEngine(config);
262
+ const { running, nonRunning } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
263
+ const removable = [...running, ...nonRunning].filter(
264
+ (row) => row.branch && lookupShortIdByBranch(row.branch, config.repoRoot) === null
265
+ );
266
+
267
+ p.intro(pc.cyan(`Removing sandboxes not bound to an active task for ${config.project}`));
268
+
269
+ if (removable.length === 0) {
270
+ p.outro('No removable sandboxes: every container is bound to an active task (or none exist)');
271
+ return;
272
+ }
273
+
274
+ for (const row of removable) {
275
+ p.log.message(`${row.name} ${row.branch}`);
276
+ }
277
+
278
+ if (options.dryRun) {
279
+ p.outro(`Dry run: ${removable.length} sandbox(es) would be removed, nothing deleted`);
280
+ return;
281
+ }
282
+
283
+ if (!options.assumeYes) {
284
+ if (!process.stdin.isTTY) {
285
+ throw new Error(
286
+ 'Refusing to remove sandboxes without confirmation in a non-interactive shell; pass --yes to proceed.'
287
+ );
288
+ }
289
+ const confirmed = await p.confirm({
290
+ message: `Remove these ${removable.length} sandbox(es)?`,
291
+ initialValue: false
292
+ });
293
+ if (p.isCancel(confirmed) || !confirmed) {
294
+ p.outro('Cancelled');
295
+ return;
296
+ }
297
+ }
298
+
299
+ const failures: { branch: string; message: string }[] = [];
300
+ for (const row of removable) {
301
+ try {
302
+ await rmOne(config, tools, row.branch, { assumeYes: true, quiet: true });
303
+ } catch (error) {
304
+ failures.push({ branch: row.branch, message: error instanceof Error ? error.message : String(error) });
305
+ }
306
+ }
307
+
308
+ if (failures.length > 0) {
309
+ for (const failure of failures) {
310
+ p.log.error(`Failed to remove '${failure.branch}': ${failure.message}`);
311
+ }
312
+ throw new Error(
313
+ `Removed ${removable.length - failures.length}/${removable.length} sandbox(es); ${failures.length} failed`
314
+ );
315
+ }
316
+
317
+ p.outro(pc.green(`Removed ${removable.length} sandbox(es)`));
318
+ }
319
+
234
320
  export async function rm(args: string[]): Promise<void> {
235
321
  const { values, positionals } = parseArgs({
236
322
  args,
@@ -238,6 +324,9 @@ export async function rm(args: string[]): Promise<void> {
238
324
  strict: true,
239
325
  options: {
240
326
  all: { type: 'boolean' },
327
+ purge: { type: 'boolean' },
328
+ 'dry-run': { type: 'boolean' },
329
+ yes: { type: 'boolean', short: 'y' },
241
330
  help: { type: 'boolean', short: 'h' }
242
331
  }
243
332
  });
@@ -247,15 +336,35 @@ export async function rm(args: string[]): Promise<void> {
247
336
  return;
248
337
  }
249
338
 
250
- if (!values.all && positionals.length !== 1) {
339
+ if (values.all && values.purge) {
340
+ throw new Error('--all and --purge are mutually exclusive');
341
+ }
342
+
343
+ if ((values['dry-run'] || values.yes) && !values.all) {
344
+ throw new Error('--dry-run and --yes only apply to --all');
345
+ }
346
+
347
+ if ((values.all || values.purge) && positionals.length > 0) {
348
+ throw new Error(`${values.all ? '--all' : '--purge'} does not take a branch argument`);
349
+ }
350
+
351
+ if (!values.all && !values.purge && positionals.length !== 1) {
251
352
  throw new Error(USAGE);
252
353
  }
253
354
 
254
355
  const config = loadConfig();
255
356
  const tools = resolveTools(config);
256
357
 
358
+ if (values.purge) {
359
+ await rmPurge(config, tools);
360
+ return;
361
+ }
362
+
257
363
  if (values.all) {
258
- await rmAll(config, tools);
364
+ await rmUnbound(config, tools, {
365
+ dryRun: Boolean(values['dry-run']),
366
+ assumeYes: Boolean(values.yes)
367
+ });
259
368
  return;
260
369
  }
261
370
 
@@ -0,0 +1,61 @@
1
+ import { loadConfig } from '../config.ts';
2
+ import {
3
+ assertValidBranchName,
4
+ containerNameCandidates,
5
+ sandboxBranchLabel,
6
+ sandboxLabel
7
+ } from '../constants.ts';
8
+ import { detectEngine } from '../engine.ts';
9
+ import {
10
+ fetchSandboxRows,
11
+ resolveBranchArg,
12
+ selectSandboxContainer,
13
+ startSandboxContainer
14
+ } from './list-running.ts';
15
+
16
+ const USAGE = `Usage: ai sandbox start <branch | TASK-id | N | '#N'>
17
+
18
+ Start an existing sandbox container that has stopped (for example after the
19
+ Docker daemon was restarted or replaced). The container must already exist:
20
+ if none is found, run 'ai sandbox create <branch>' first. A container that is
21
+ already running is left untouched.`;
22
+
23
+ export async function start(args: string[]): Promise<void> {
24
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
25
+ process.stdout.write(`${USAGE}\n`);
26
+ if (args.length === 0) {
27
+ process.exitCode = 1;
28
+ }
29
+ return;
30
+ }
31
+
32
+ const [firstArg = ''] = args;
33
+ const config = loadConfig();
34
+ const engine = detectEngine(config);
35
+ const branch = resolveBranchArg(firstArg, { repoRoot: config.repoRoot });
36
+ assertValidBranchName(branch);
37
+
38
+ const { running, nonRunning } = fetchSandboxRows(
39
+ engine,
40
+ sandboxLabel(config),
41
+ sandboxBranchLabel(config)
42
+ );
43
+ const found = selectSandboxContainer(
44
+ [...running, ...nonRunning],
45
+ containerNameCandidates(config, branch)
46
+ );
47
+
48
+ if (!found) {
49
+ throw new Error(
50
+ `No sandbox container for branch '${branch}'. Run 'ai sandbox create ${branch}' to create one.`
51
+ );
52
+ }
53
+
54
+ if (found.running) {
55
+ process.stdout.write(`Sandbox '${found.name}' is already running.\n`);
56
+ return;
57
+ }
58
+
59
+ startSandboxContainer(engine, found.name);
60
+ process.stdout.write(`Started sandbox '${found.name}'.\n`);
61
+ }
@@ -6,6 +6,9 @@ Commands:
6
6
  Enter sandbox or run a command. N (bare) is the
7
7
  recommended form for task short ids (e.g.
8
8
  'ai sandbox exec 11'); '#N' is also accepted.
9
+ start <branch | TASK-id | N | '#N'>
10
+ Start an existing stopped sandbox container
11
+ (e.g. after the Docker daemon restarted)
9
12
  ls List sandboxes for the current project (the '#'
10
13
  column is a display-only row number; the 'SHORT'
11
14
  column shows the active task short id, '-' if none)
@@ -13,7 +16,9 @@ Commands:
13
16
  rebuild [--quiet] [--refresh]
14
17
  Rebuild the sandbox image (--refresh pulls base + tools)
15
18
  refresh Sync host Claude Code credentials to all sandbox copies
16
- rm <branch> [--all] Remove a sandbox or all sandboxes
19
+ rm <branch> | --all | --purge
20
+ Remove one sandbox, all sandboxes not bound to an
21
+ active task (--all), or tear down everything (--purge)
17
22
  vm status|start|stop Manage the sandbox VM (macOS) or check the backend (Windows)
18
23
 
19
24
  Run 'ai sandbox <command> --help' for details.`;
@@ -54,6 +59,11 @@ export async function runSandbox(args: string[]): Promise<void> {
54
59
  }
55
60
  break;
56
61
  }
62
+ case 'start': {
63
+ const { start } = await import('./commands/start.ts');
64
+ await start(rest);
65
+ break;
66
+ }
57
67
  case 'ls': {
58
68
  const { ls } = await import('./commands/ls.ts');
59
69
  ls(rest);
@@ -18,7 +18,7 @@ symlink under \`$HOME\` (e.g. \`.tmux.conf\` -> \`/home/devuser/.tmux.conf\`),
18
18
  overriding image defaults so your editor, shell, and tool preferences follow
19
19
  you across \`ai sandbox destroy + create\`.
20
20
 
21
- See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#user-level-dotfiles-channel
21
+ See: https://github.com/fitlab-ai/agent-infra/blob/main/docs/en/sandbox.md#user-level-dotfiles-channel
22
22
 
23
23
  Common usage - drop files or symlinks here:
24
24
 
@@ -46,7 +46,7 @@ only writes \`README.md\` when it is missing, never when it already exists.
46
46
  (例如 \`.tmux.conf -> /home/devuser/.tmux.conf\`),覆盖镜像默认值,让你的编辑器、
47
47
  shell、工具偏好跨 \`ai sandbox destroy + create\` 持久存在。
48
48
 
49
- 参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#用户级-dotfiles-通道
49
+ 参考:https://github.com/fitlab-ai/agent-infra/blob/main/docs/zh-CN/sandbox.md#用户级-dotfiles-通道
50
50
 
51
51
  常见用法:把文件或符号链接放进来:
52
52
 
@@ -72,7 +72,7 @@ This directory is mounted **read-write** into every sandbox container of this
72
72
  project at \`/share/common\`, regardless of branch. Drop files here to share
73
73
  between host and any sandbox without polluting the git worktree.
74
74
 
75
- See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#host-sandbox-file-exchange
75
+ See: https://github.com/fitlab-ai/agent-infra/blob/main/docs/en/sandbox.md#host-sandbox-file-exchange
76
76
 
77
77
  This file is safe to delete; the next \`ai sandbox create\` will re-create it.
78
78
 
@@ -83,7 +83,7 @@ This file is safe to delete; the next \`ai sandbox create\` will re-create it.
83
83
  该目录被以**读写**方式挂载到本项目所有 sandbox 容器的 \`/share/common\`,
84
84
  跨分支可见。可用来在宿主和任意 sandbox 之间传文件,无需弄脏 git worktree。
85
85
 
86
- 参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#宿主-沙箱文件交换
86
+ 参考:https://github.com/fitlab-ai/agent-infra/blob/main/docs/zh-CN/sandbox.md#宿主-沙箱文件交换
87
87
 
88
88
  该文件可以安全删除;下一次 \`ai sandbox create\` 会重新生成。
89
89
  `;
@@ -94,7 +94,7 @@ This directory is mounted **read-write** into the sandbox container of this
94
94
  project's current branch at \`/share/branch\`. Files here are exclusive to this
95
95
  branch's sandbox and do not leak across branches.
96
96
 
97
- See: https://github.com/fitlab-ai/agent-infra/blob/main/README.md#host-sandbox-file-exchange
97
+ See: https://github.com/fitlab-ai/agent-infra/blob/main/docs/en/sandbox.md#host-sandbox-file-exchange
98
98
 
99
99
  This file is safe to delete; the next \`ai sandbox create\` will re-create it.
100
100
 
@@ -105,7 +105,7 @@ This file is safe to delete; the next \`ai sandbox create\` will re-create it.
105
105
  该目录被以**读写**方式挂载到本项目当前分支 sandbox 容器的 \`/share/branch\`,
106
106
  仅当前分支可见,不会跨分支泄漏。
107
107
 
108
- 参考:https://github.com/fitlab-ai/agent-infra/blob/main/README.zh-CN.md#宿主-沙箱文件交换
108
+ 参考:https://github.com/fitlab-ai/agent-infra/blob/main/docs/zh-CN/sandbox.md#宿主-沙箱文件交换
109
109
 
110
110
  该文件可以安全删除;下一次 \`ai sandbox create\` 会重新生成。
111
111
  `;
package/lib/table.ts CHANGED
@@ -1,7 +1,11 @@
1
+ import pc from 'picocolors';
2
+
1
3
  function formatTable(
2
4
  headers: readonly string[],
3
- rows: readonly (readonly string[])[]
5
+ rows: readonly (readonly string[])[],
6
+ options: { zebra?: boolean } = {}
4
7
  ): string[] {
8
+ const { zebra = false } = options;
5
9
  const columnCount = headers.length;
6
10
  const widths = headers.map((header, i) => {
7
11
  const headerLen = header.length;
@@ -26,7 +30,15 @@ function formatTable(
26
30
  return parts.join(' ').trimEnd();
27
31
  };
28
32
 
29
- return [renderRow(headers), ...rows.map((row) => renderRow(row))];
33
+ const dataLines = rows.map((row, i) => {
34
+ const line = renderRow(row);
35
+ // Zebra stripes: dim even-numbered data rows (rows 2, 4, 6... -> 0-based
36
+ // odd index). The header and odd rows are left untouched. When zebra is
37
+ // off, pc.dim is never called, so the output is byte-identical to before.
38
+ return zebra && i % 2 === 1 ? pc.dim(line) : line;
39
+ });
40
+
41
+ return [renderRow(headers), ...dataLines];
30
42
  }
31
43
 
32
44
  export { formatTable };
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ type Artifact = {
5
+ index: number;
6
+ name: string;
7
+ path: string;
8
+ size: number;
9
+ mtimeMs: number;
10
+ };
11
+
12
+ /**
13
+ * Enumerate a task directory's artifacts ordered by modification time, oldest
14
+ * first, so the listing reads like the task's timeline. Filename ascending is a
15
+ * deterministic tiebreak when two files share the same mtime (e.g. written in
16
+ * the same millisecond).
17
+ *
18
+ * Only top-level regular files are included; subdirectories and dotfiles are
19
+ * skipped so every entry is something `cat` can print. The returned 1-based
20
+ * `index` is the source of truth shared by `files` and `cat`.
21
+ */
22
+ function enumerateArtifacts(taskDir: string): Artifact[] {
23
+ const entries = fs
24
+ .readdirSync(taskDir, { withFileTypes: true })
25
+ .filter((dirent) => dirent.isFile() && !dirent.name.startsWith('.'))
26
+ .map((dirent) => {
27
+ const abs = path.join(taskDir, dirent.name);
28
+ const stat = fs.statSync(abs);
29
+ return { name: dirent.name, path: abs, size: stat.size, mtimeMs: stat.mtimeMs };
30
+ });
31
+
32
+ entries.sort((a, b) => {
33
+ if (a.mtimeMs !== b.mtimeMs) return a.mtimeMs - b.mtimeMs;
34
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
35
+ });
36
+
37
+ return entries.map((entry, i) => ({ index: i + 1, ...entry }));
38
+ }
39
+
40
+ /**
41
+ * Resolve an artifact selector to an absolute path within `taskDir`. The
42
+ * selector is either a 1-based index `N` (as listed by `files`) or a filename
43
+ * (with or without the `.md` suffix). Throws with a clear message on failure.
44
+ */
45
+ function resolveArtifact(taskDir: string, artifactOrN: string): string {
46
+ if (path.basename(artifactOrN) !== artifactOrN) {
47
+ throw new Error('artifact name must not contain path separators');
48
+ }
49
+
50
+ if (/^\d+$/.test(artifactOrN)) {
51
+ const n = Number(artifactOrN);
52
+ const match = enumerateArtifacts(taskDir).find((a) => a.index === n);
53
+ if (!match) {
54
+ throw new Error(`invalid artifact index ${n} (run 'ai task files <ref>' to list)`);
55
+ }
56
+ return match.path;
57
+ }
58
+
59
+ const candidates = artifactOrN.endsWith('.md')
60
+ ? [artifactOrN]
61
+ : [artifactOrN, `${artifactOrN}.md`];
62
+ for (const candidate of candidates) {
63
+ const abs = path.join(taskDir, candidate);
64
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
65
+ return abs;
66
+ }
67
+ }
68
+ throw new Error(`artifact '${artifactOrN}' not found in task directory`);
69
+ }
70
+
71
+ export { enumerateArtifacts, resolveArtifact };
72
+ export type { Artifact };
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import { resolveTaskRef } from '../resolve-ref.ts';
3
+ import { resolveArtifact } from '../artifacts.ts';
4
+
5
+ const USAGE = `Usage: ai task cat <N | #N | TASK-id> <artifact | N>
6
+
7
+ Prints a task artifact's raw content to stdout.
8
+ <ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
9
+ <artifact | N> Artifact filename (with or without '.md'), or the number from 'ai task files'.
10
+ `;
11
+
12
+ function cat(args: string[] = []): void {
13
+ if (args[0] === '--help' || args[0] === '-h') {
14
+ process.stdout.write(USAGE);
15
+ return;
16
+ }
17
+ if (args.length < 2) {
18
+ process.stdout.write(USAGE);
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+ const resolved = resolveTaskRef(args[0]!);
23
+ if (!resolved.ok) {
24
+ process.stderr.write(`ai task cat: ${resolved.message}\n`);
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ let artifactPath: string;
29
+ try {
30
+ artifactPath = resolveArtifact(resolved.taskDir, args[1]!);
31
+ } catch (e) {
32
+ process.stderr.write(`ai task cat: ${(e as Error).message}\n`);
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+ process.stdout.write(fs.readFileSync(artifactPath, 'utf8'));
37
+ }
38
+
39
+ export { cat };