@fitlab-ai/agent-infra 0.6.2 → 0.6.3

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 (75) hide show
  1. package/README.md +13 -3
  2. package/README.zh-CN.md +10 -3
  3. package/bin/cli.ts +6 -1
  4. package/dist/bin/cli.js +6 -1
  5. package/dist/lib/sandbox/clipboard/bridge.js +216 -0
  6. package/dist/lib/sandbox/clipboard/darwin.js +73 -0
  7. package/dist/lib/sandbox/clipboard/index.js +9 -0
  8. package/dist/lib/sandbox/clipboard/keys.js +58 -0
  9. package/dist/lib/sandbox/clipboard/node-pty.js +13 -0
  10. package/dist/lib/sandbox/clipboard/paths.js +59 -0
  11. package/dist/lib/sandbox/commands/create.js +11 -2
  12. package/dist/lib/sandbox/commands/enter.js +8 -2
  13. package/dist/lib/sandbox/commands/ls.js +19 -4
  14. package/dist/lib/sandbox/commands/prune.js +176 -0
  15. package/dist/lib/sandbox/commands/rm.js +27 -33
  16. package/dist/lib/sandbox/config.js +1 -0
  17. package/dist/lib/sandbox/constants.js +6 -0
  18. package/dist/lib/sandbox/index.js +7 -1
  19. package/dist/lib/sandbox/managed-fs.js +25 -0
  20. package/dist/lib/sandbox/tools.js +1 -1
  21. package/dist/lib/version.js +9 -2
  22. package/lib/sandbox/clipboard/bridge.ts +285 -0
  23. package/lib/sandbox/clipboard/darwin.ts +90 -0
  24. package/lib/sandbox/clipboard/index.ts +13 -0
  25. package/lib/sandbox/clipboard/keys.ts +78 -0
  26. package/lib/sandbox/clipboard/node-pty.d.ts +19 -0
  27. package/lib/sandbox/clipboard/node-pty.ts +34 -0
  28. package/lib/sandbox/clipboard/paths.ts +71 -0
  29. package/lib/sandbox/commands/create.ts +15 -2
  30. package/lib/sandbox/commands/enter.ts +8 -2
  31. package/lib/sandbox/commands/ls.ts +28 -4
  32. package/lib/sandbox/commands/prune.ts +211 -0
  33. package/lib/sandbox/commands/rm.ts +30 -32
  34. package/lib/sandbox/config.ts +2 -0
  35. package/lib/sandbox/constants.ts +9 -0
  36. package/lib/sandbox/index.ts +7 -1
  37. package/lib/sandbox/managed-fs.ts +27 -0
  38. package/lib/sandbox/tools.ts +1 -1
  39. package/lib/version.ts +11 -4
  40. package/package.json +5 -1
  41. package/templates/.agents/README.en.md +19 -0
  42. package/templates/.agents/README.zh-CN.md +19 -0
  43. package/templates/.agents/skills/analyze-task/SKILL.en.md +26 -0
  44. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +26 -0
  45. package/templates/.agents/skills/analyze-task/config/verify.en.json +51 -0
  46. package/templates/.agents/skills/analyze-task/config/{verify.json → verify.zh-CN.json} +6 -2
  47. package/templates/.agents/skills/complete-task/SKILL.en.md +15 -0
  48. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +15 -0
  49. package/templates/.agents/skills/complete-task/config/{verify.json → verify.en.json} +10 -0
  50. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +48 -0
  51. package/templates/.agents/skills/implement-task/SKILL.en.md +14 -0
  52. package/templates/.agents/skills/implement-task/SKILL.zh-CN.md +14 -0
  53. package/templates/.agents/skills/implement-task/config/verify.en.json +51 -0
  54. package/templates/.agents/skills/implement-task/config/{verify.json → verify.zh-CN.json} +7 -2
  55. package/templates/.agents/skills/implement-task/reference/report-template.en.md +15 -0
  56. package/templates/.agents/skills/implement-task/reference/report-template.zh-CN.md +15 -0
  57. package/templates/.agents/skills/plan-task/SKILL.en.md +22 -0
  58. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +22 -0
  59. package/templates/.agents/skills/plan-task/config/verify.en.json +52 -0
  60. package/templates/.agents/skills/plan-task/config/{verify.json → verify.zh-CN.json} +6 -2
  61. package/templates/.agents/skills/post-release/SKILL.en.md +1 -0
  62. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +1 -0
  63. package/templates/.agents/skills/refine-task/SKILL.en.md +14 -0
  64. package/templates/.agents/skills/refine-task/SKILL.zh-CN.md +14 -0
  65. package/templates/.agents/skills/refine-task/config/verify.en.json +47 -0
  66. package/templates/.agents/skills/refine-task/config/{verify.json → verify.zh-CN.json} +7 -2
  67. package/templates/.agents/skills/refine-task/reference/report-template.en.md +15 -0
  68. package/templates/.agents/skills/refine-task/reference/report-template.zh-CN.md +15 -0
  69. package/templates/.agents/skills/review-task/SKILL.en.md +14 -0
  70. package/templates/.agents/skills/review-task/SKILL.zh-CN.md +14 -0
  71. package/templates/.agents/skills/review-task/config/verify.en.json +50 -0
  72. package/templates/.agents/skills/review-task/config/{verify.json → verify.zh-CN.json} +5 -2
  73. package/templates/.agents/skills/review-task/reference/report-template.en.md +15 -0
  74. package/templates/.agents/skills/review-task/reference/report-template.zh-CN.md +15 -0
  75. package/dist/package.json +0 -5
@@ -9,7 +9,13 @@ import { runSafeEngine } from '../shell.ts';
9
9
  import { resolveTools, toolProjectDirCandidates } from '../tools.ts';
10
10
 
11
11
  const USAGE = 'Usage: ai sandbox ls';
12
- const CONTAINER_LIST_HEADER = 'NAMES\tSTATUS\tBRANCH';
12
+ const CONTAINER_TABLE_HEADERS = ['NAMES', 'STATUS', 'BRANCH'] as const;
13
+
14
+ type ContainerTableRow = {
15
+ name: string;
16
+ status: string;
17
+ branch: string;
18
+ };
13
19
 
14
20
  // Exported to lock the docker/podman-compatible format in unit tests.
15
21
  export function containerListFormat(): string {
@@ -35,6 +41,22 @@ export function parseLabels(csv: string): Record<string, string> {
35
41
  return labels;
36
42
  }
37
43
 
44
+ export function formatContainerTable(rows: ContainerTableRow[]): string[] {
45
+ const columns = rows.map((row) => [row.name, row.status, row.branch] as const);
46
+ const widths = [
47
+ Math.max(CONTAINER_TABLE_HEADERS[0].length, ...rows.map((row) => row.name.length)),
48
+ Math.max(CONTAINER_TABLE_HEADERS[1].length, ...rows.map((row) => row.status.length)),
49
+ Math.max(CONTAINER_TABLE_HEADERS[2].length, ...rows.map((row) => row.branch.length))
50
+ ] as const;
51
+ const renderRow = (values: readonly [string, string, string]): string =>
52
+ `${values[0].padEnd(widths[0])} ${values[1].padEnd(widths[1])} ${values[2]}`.trimEnd();
53
+
54
+ return [
55
+ renderRow(CONTAINER_TABLE_HEADERS),
56
+ ...columns.map((column) => renderRow(column))
57
+ ];
58
+ }
59
+
38
60
  function listChildren(dir: string): string[] {
39
61
  if (!fs.existsSync(dir)) {
40
62
  return [];
@@ -69,11 +91,13 @@ export function ls(args: string[] = []): void {
69
91
  p.log.warn(' No sandbox containers');
70
92
  } else {
71
93
  const branchKey = sandboxBranchLabel(config);
72
- process.stdout.write(` ${CONTAINER_LIST_HEADER}\n`);
73
- for (const line of containers.split('\n')) {
94
+ const rows = containers.split('\n').map((line) => {
74
95
  const [name = '', status = '', labelsCsv = ''] = line.split('\t');
75
96
  const branch = parseLabels(labelsCsv)[branchKey] ?? '';
76
- process.stdout.write(` ${name}\t${status}\t${branch}\n`);
97
+ return { name, status, branch };
98
+ });
99
+ for (const line of formatContainerTable(rows)) {
100
+ process.stdout.write(` ${line}\n`);
77
101
  }
78
102
  }
79
103
 
@@ -0,0 +1,211 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import * as p from '@clack/prompts';
5
+ import pc from 'picocolors';
6
+ import { loadConfig } from '../config.ts';
7
+ import type { SandboxConfig } from '../config.ts';
8
+ import { safeNameCandidates, sandboxBranchLabel, sandboxLabel } from '../constants.ts';
9
+ import { detectEngine } from '../engine.ts';
10
+ import { hostJoin } from '../engines/wsl2-paths.ts';
11
+ import { removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
12
+ import { parseLabels } from './ls.ts';
13
+ import { runEngine, runSafe } from '../shell.ts';
14
+ import { resolveTools } from '../tools.ts';
15
+ import type { SandboxTool } from '../tools.ts';
16
+
17
+ const USAGE = `Usage: ai sandbox prune [--dry-run]`;
18
+
19
+ type OrphanKind = 'shell' | 'worktree' | 'share' | 'tool';
20
+
21
+ export type OrphanGroup = {
22
+ kind: OrphanKind;
23
+ label: string;
24
+ base: string;
25
+ dirs: string[];
26
+ };
27
+
28
+ function listChildDirs(base: string): string[] {
29
+ if (!fs.existsSync(base)) {
30
+ return [];
31
+ }
32
+
33
+ return fs.readdirSync(base)
34
+ .sort()
35
+ .map((entry) => path.join(base, entry))
36
+ .filter((entry) => {
37
+ try {
38
+ return fs.statSync(entry).isDirectory();
39
+ } catch {
40
+ return false;
41
+ }
42
+ });
43
+ }
44
+
45
+ function activeSafeNames(activeBranches: string[]): Set<string> {
46
+ const names = new Set<string>();
47
+ for (const branch of activeBranches) {
48
+ try {
49
+ for (const name of safeNameCandidates(branch)) {
50
+ names.add(name);
51
+ }
52
+ } catch {
53
+ names.add(branch);
54
+ }
55
+ }
56
+ return names;
57
+ }
58
+
59
+ function orphanDirs(base: string, activeNames: Set<string>): string[] {
60
+ return listChildDirs(base).filter((dir) => !activeNames.has(path.basename(dir)));
61
+ }
62
+
63
+ function addGroup(groups: OrphanGroup[], group: OrphanGroup): void {
64
+ if (group.dirs.length > 0) {
65
+ groups.push(group);
66
+ }
67
+ }
68
+
69
+ export function collectOrphanGroups(
70
+ config: SandboxConfig,
71
+ tools: SandboxTool[],
72
+ activeBranches: string[]
73
+ ): OrphanGroup[] {
74
+ const activeNames = activeSafeNames(activeBranches);
75
+ const groups: OrphanGroup[] = [];
76
+ const shareBranchesBase = hostJoin(config.shareBase, 'branches');
77
+
78
+ addGroup(groups, {
79
+ kind: 'shell',
80
+ label: 'Shell config dirs',
81
+ base: config.shellConfigBase,
82
+ dirs: orphanDirs(config.shellConfigBase, activeNames)
83
+ });
84
+ addGroup(groups, {
85
+ kind: 'worktree',
86
+ label: 'Worktrees',
87
+ base: config.worktreeBase,
88
+ dirs: orphanDirs(config.worktreeBase, activeNames)
89
+ });
90
+ addGroup(groups, {
91
+ kind: 'share',
92
+ label: 'Share branch dirs',
93
+ base: shareBranchesBase,
94
+ dirs: orphanDirs(shareBranchesBase, activeNames)
95
+ });
96
+
97
+ for (const tool of tools) {
98
+ const base = hostJoin(tool.sandboxBase, config.project);
99
+ addGroup(groups, {
100
+ kind: 'tool',
101
+ label: `${tool.name} state`,
102
+ base,
103
+ dirs: orphanDirs(base, activeNames)
104
+ });
105
+ }
106
+
107
+ return groups;
108
+ }
109
+
110
+ export function removeOrphanGroups(config: SandboxConfig, groups: OrphanGroup[]): boolean {
111
+ let removedWorktrees = false;
112
+ for (const group of groups) {
113
+ for (const dir of group.dirs) {
114
+ if (group.kind === 'worktree') {
115
+ removeWorktreeDir(config.repoRoot, group.base, dir);
116
+ removedWorktrees = true;
117
+ } else {
118
+ removeManagedDir(group.base, dir);
119
+ }
120
+ }
121
+ }
122
+ return removedWorktrees;
123
+ }
124
+
125
+ function activeBranchesFromLabels(config: SandboxConfig, labelsOutput: string): string[] {
126
+ const branchKey = sandboxBranchLabel(config);
127
+ return labelsOutput.split('\n')
128
+ .map((line) => parseLabels(line)[branchKey] ?? '')
129
+ .filter(Boolean);
130
+ }
131
+
132
+ function orphanCount(groups: OrphanGroup[]): number {
133
+ return groups.reduce((sum, group) => sum + group.dirs.length, 0);
134
+ }
135
+
136
+ function writeGroups(groups: OrphanGroup[]): void {
137
+ for (const group of groups) {
138
+ p.log.step(group.label);
139
+ for (const dir of group.dirs) {
140
+ process.stdout.write(` ${dir}\n`);
141
+ }
142
+ }
143
+ }
144
+
145
+ export async function prune(args: string[]): Promise<void> {
146
+ const { values } = parseArgs({
147
+ args,
148
+ strict: true,
149
+ options: {
150
+ 'dry-run': { type: 'boolean' },
151
+ help: { type: 'boolean', short: 'h' }
152
+ }
153
+ });
154
+
155
+ if (values.help) {
156
+ process.stdout.write(`${USAGE}\n`);
157
+ return;
158
+ }
159
+
160
+ const config = loadConfig();
161
+ const tools = resolveTools(config);
162
+ const engine = detectEngine(config);
163
+ const psArgs = [
164
+ 'ps',
165
+ '-a',
166
+ '--filter',
167
+ `label=${sandboxLabel(config)}`,
168
+ '--format',
169
+ '{{.Labels}}'
170
+ ];
171
+ let labelsOutput: string;
172
+ try {
173
+ labelsOutput = runEngine(engine, 'docker', psArgs);
174
+ } catch {
175
+ throw new Error('Unable to determine active sandbox branches: docker ps failed');
176
+ }
177
+ const groups = collectOrphanGroups(config, tools, activeBranchesFromLabels(config, labelsOutput));
178
+ const count = orphanCount(groups);
179
+
180
+ p.intro(pc.cyan(`Pruning orphaned sandbox state for ${config.project}`));
181
+
182
+ if (count === 0) {
183
+ p.log.success('No orphaned sandbox state dirs found');
184
+ p.outro(pc.green('Sandbox prune complete'));
185
+ return;
186
+ }
187
+
188
+ writeGroups(groups);
189
+
190
+ if (values['dry-run']) {
191
+ p.outro(pc.green('Dry run complete'));
192
+ return;
193
+ }
194
+
195
+ const shouldRemove = await p.confirm({
196
+ message: `Remove ${count} orphaned sandbox state dirs?`,
197
+ initialValue: true
198
+ });
199
+
200
+ if (p.isCancel(shouldRemove) || !shouldRemove) {
201
+ p.outro('Cancelled');
202
+ return;
203
+ }
204
+
205
+ const removedWorktrees = removeOrphanGroups(config, groups);
206
+ if (removedWorktrees) {
207
+ runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
208
+ }
209
+
210
+ p.outro(pc.green('Orphaned sandbox state dirs removed'));
211
+ }
@@ -11,31 +11,23 @@ import {
11
11
  sandboxBranchLabel,
12
12
  sandboxLabel,
13
13
  shareBranchDir,
14
+ shellConfigDirCandidates,
14
15
  worktreeDirCandidates
15
16
  } from '../constants.ts';
16
17
  import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.ts';
17
- import { run, runOk, runSafe, runSafeEngine } from '../shell.ts';
18
+ import { removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
19
+ import { runOk, runSafe, runSafeEngine } from '../shell.ts';
18
20
  import { resolveTaskBranch } from '../task-resolver.ts';
19
21
  import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.ts';
20
22
  import type { SandboxTool } from '../tools.ts';
21
23
 
22
24
  const USAGE = `Usage: ai sandbox rm <branch> [--all]`;
25
+ export { assertManagedPath } from '../managed-fs.ts';
23
26
 
24
27
  function projectToolDirs(config: SandboxConfig, tools: SandboxTool[]): string[] {
25
28
  return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
26
29
  }
27
30
 
28
- export function assertManagedPath(root: string, target: string): void {
29
- const resolvedRoot = path.resolve(root);
30
- const resolvedTarget = path.resolve(target);
31
- const relative = path.relative(resolvedRoot, resolvedTarget);
32
- if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
33
- return;
34
- }
35
-
36
- throw new Error(`Refusing to remove path outside managed sandbox root: ${target}`);
37
- }
38
-
39
31
  async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string): Promise<void> {
40
32
  assertValidBranchName(branch);
41
33
  const engine = detectEngine(config);
@@ -93,12 +85,7 @@ async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string
93
85
 
94
86
  if (shouldRemoveWorktree) {
95
87
  for (const worktree of existingWorktrees) {
96
- try {
97
- run('git', ['-C', config.repoRoot, 'worktree', 'remove', worktree, '--force']);
98
- } catch {
99
- assertManagedPath(config.worktreeBase, worktree);
100
- fs.rmSync(worktree, { recursive: true, force: true });
101
- }
88
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, worktree);
102
89
  }
103
90
 
104
91
  const shouldDeleteBranch = await p.confirm({
@@ -116,12 +103,16 @@ async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string
116
103
 
117
104
  for (const { tool, candidates } of toolCandidates) {
118
105
  for (const dir of candidates.filter((candidate) => fs.existsSync(candidate))) {
119
- assertManagedPath(tool.sandboxBase, dir);
120
- fs.rmSync(dir, { recursive: true, force: true });
106
+ removeManagedDir(tool.sandboxBase, dir);
121
107
  p.log.success(`${tool.name} state removed: ${dir}`);
122
108
  }
123
109
  }
124
110
 
111
+ for (const dir of shellConfigDirCandidates(config, effectiveBranch).filter((candidate) => fs.existsSync(candidate))) {
112
+ removeManagedDir(config.shellConfigBase, dir);
113
+ p.log.success(`Shell config removed: ${dir}`);
114
+ }
115
+
125
116
  const shareBranch = shareBranchDir(config, effectiveBranch);
126
117
  if (fs.existsSync(shareBranch)) {
127
118
  const shouldRemoveShare = await p.confirm({
@@ -129,8 +120,7 @@ async function rmOne(config: SandboxConfig, tools: SandboxTool[], branch: string
129
120
  initialValue: true
130
121
  });
131
122
  if (!p.isCancel(shouldRemoveShare) && shouldRemoveShare) {
132
- assertManagedPath(config.shareBase, shareBranch);
133
- fs.rmSync(shareBranch, { recursive: true, force: true });
123
+ removeManagedDir(config.shareBase, shareBranch);
134
124
  p.log.success(`Share dir removed: ${shareBranch}`);
135
125
  }
136
126
  }
@@ -171,12 +161,7 @@ async function rmAll(config: SandboxConfig, tools: SandboxTool[]): Promise<void>
171
161
  if (!p.isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
172
162
  for (const entry of fs.readdirSync(config.worktreeBase)) {
173
163
  const dir = path.join(config.worktreeBase, entry);
174
- try {
175
- run('git', ['-C', config.repoRoot, 'worktree', 'remove', dir, '--force']);
176
- } catch {
177
- assertManagedPath(config.worktreeBase, dir);
178
- fs.rmSync(dir, { recursive: true, force: true });
179
- }
164
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, dir);
180
165
  }
181
166
  runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
182
167
  }
@@ -184,20 +169,33 @@ async function rmAll(config: SandboxConfig, tools: SandboxTool[]): Promise<void>
184
169
 
185
170
  for (const dir of projectToolDirs(config, tools)) {
186
171
  if (fs.existsSync(dir)) {
187
- assertManagedPath(path.dirname(dir), dir);
188
- fs.rmSync(dir, { recursive: true, force: true });
172
+ removeManagedDir(path.dirname(dir), dir);
189
173
  p.log.success(`Removed tool state: ${dir}`);
190
174
  }
191
175
  }
192
176
 
177
+ if (fs.existsSync(config.shellConfigBase) && fs.readdirSync(config.shellConfigBase).length > 0) {
178
+ const shouldRemoveShellConfigs = await p.confirm({
179
+ message: `Remove all shell config dirs in ${config.shellConfigBase}?`,
180
+ initialValue: true
181
+ });
182
+
183
+ if (!p.isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
184
+ for (const entry of fs.readdirSync(config.shellConfigBase)) {
185
+ const dir = path.join(config.shellConfigBase, entry);
186
+ removeManagedDir(config.shellConfigBase, dir);
187
+ }
188
+ p.log.success(`Project shell config dirs removed: ${config.shellConfigBase}`);
189
+ }
190
+ }
191
+
193
192
  if (fs.existsSync(config.shareBase) && fs.readdirSync(config.shareBase).length > 0) {
194
193
  const shouldRemoveAllShares = await p.confirm({
195
194
  message: `Remove all share dirs for project (${config.shareBase})?`,
196
195
  initialValue: true
197
196
  });
198
197
  if (!p.isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
199
- assertManagedPath(path.dirname(config.shareBase), config.shareBase);
200
- fs.rmSync(config.shareBase, { recursive: true, force: true });
198
+ removeManagedDir(path.dirname(config.shareBase), config.shareBase);
201
199
  p.log.success(`Project share dirs removed: ${config.shareBase}`);
202
200
  }
203
201
  }
@@ -46,6 +46,7 @@ export type SandboxConfig = {
46
46
  imageName: string;
47
47
  worktreeBase: string;
48
48
  shareBase: string;
49
+ shellConfigBase: string;
49
50
  dotfilesDir: string;
50
51
  engine: string | null;
51
52
  runtimes: string[];
@@ -145,6 +146,7 @@ export function loadConfig({
145
146
  imageName: `${project}-sandbox:latest`,
146
147
  worktreeBase: hostJoin(home, '.agent-infra', 'worktrees', project),
147
148
  shareBase: hostJoin(home, '.agent-infra', 'share', project),
149
+ shellConfigBase: hostJoin(home, '.agent-infra', 'config', project),
148
150
  dotfilesDir: hostJoin(home, '.agent-infra', 'dotfiles'),
149
151
  engine,
150
152
  runtimes,
@@ -9,6 +9,7 @@ type SandboxPathConfig = {
9
9
  containerPrefix: string;
10
10
  worktreeBase: string;
11
11
  shareBase: string;
12
+ shellConfigBase: string;
12
13
  };
13
14
 
14
15
  type HostResources = {
@@ -86,6 +87,14 @@ export function shareBranchDir(config: Pick<SandboxPathConfig, 'shareBase'>, bra
86
87
  return hostJoin(config.shareBase, 'branches', sanitizeBranchName(branch));
87
88
  }
88
89
 
90
+ export function shellConfigDir(config: Pick<SandboxPathConfig, 'shellConfigBase'>, branch: string): string {
91
+ return hostJoin(config.shellConfigBase, sanitizeBranchName(branch));
92
+ }
93
+
94
+ export function shellConfigDirCandidates(config: Pick<SandboxPathConfig, 'shellConfigBase'>, branch: string): string[] {
95
+ return safeNameCandidates(branch).map((name) => hostJoin(config.shellConfigBase, name));
96
+ }
97
+
89
98
  export function sandboxLabel(config: Pick<SandboxPathConfig, 'project'>): string {
90
99
  return `${config.project}.sandbox`;
91
100
  }
@@ -6,6 +6,7 @@ Commands:
6
6
  refresh Sync host Claude Code credentials to all sandbox copies
7
7
  ls List sandboxes for the current project
8
8
  rm <branch> [--all] Remove a sandbox or all sandboxes
9
+ prune [--dry-run] Remove orphaned per-branch state dirs
9
10
  vm status|start|stop Manage the sandbox VM (macOS) or check the backend (Windows)
10
11
  rebuild [--quiet] Rebuild the sandbox image
11
12
 
@@ -33,7 +34,7 @@ export async function runSandbox(args: string[]): Promise<void> {
33
34
  }
34
35
  case 'exec': {
35
36
  const { enter } = await import('./commands/enter.ts');
36
- const exitCode = enter(rest);
37
+ const exitCode = await enter(rest);
37
38
  if (typeof exitCode === 'number' && exitCode !== 0) {
38
39
  process.exitCode = exitCode;
39
40
  }
@@ -57,6 +58,11 @@ export async function runSandbox(args: string[]): Promise<void> {
57
58
  await rm(rest);
58
59
  break;
59
60
  }
61
+ case 'prune': {
62
+ const { prune } = await import('./commands/prune.ts');
63
+ await prune(rest);
64
+ break;
65
+ }
60
66
  case 'vm': {
61
67
  const { vm } = await import('./commands/vm.ts');
62
68
  await vm(rest);
@@ -0,0 +1,27 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { run } from './shell.ts';
4
+
5
+ export function assertManagedPath(root: string, target: string): void {
6
+ const resolvedRoot = path.resolve(root);
7
+ const resolvedTarget = path.resolve(target);
8
+ const relative = path.relative(resolvedRoot, resolvedTarget);
9
+ if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
10
+ return;
11
+ }
12
+
13
+ throw new Error(`Refusing to remove path outside managed sandbox root: ${target}`);
14
+ }
15
+
16
+ export function removeManagedDir(root: string, dir: string): void {
17
+ assertManagedPath(root, dir);
18
+ fs.rmSync(dir, { recursive: true, force: true });
19
+ }
20
+
21
+ export function removeWorktreeDir(repoRoot: string, worktreeBase: string, dir: string): void {
22
+ try {
23
+ run('git', ['-C', repoRoot, 'worktree', 'remove', dir, '--force']);
24
+ } catch {
25
+ removeManagedDir(worktreeBase, dir);
26
+ }
27
+ }
@@ -28,7 +28,7 @@ function createBuiltinTools(home: string, project: string): Record<string, Sandb
28
28
  'claude-code': {
29
29
  id: 'claude-code',
30
30
  name: 'Claude Code',
31
- npmPackage: '@anthropic-ai/claude-code',
31
+ npmPackage: '@anthropic-ai/claude-code@stable',
32
32
  sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'claude-code'),
33
33
  containerMount: '/home/devuser/.claude',
34
34
  versionCmd: 'claude --version',
package/lib/version.ts CHANGED
@@ -1,8 +1,15 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
 
3
- const { version } = JSON.parse(
4
- readFileSync(new URL('../package.json', import.meta.url), 'utf8')
5
- );
3
+ const packageJsonUrl = [
4
+ new URL('../package.json', import.meta.url),
5
+ new URL('../../package.json', import.meta.url),
6
+ ].find((url) => existsSync(url));
7
+
8
+ if (!packageJsonUrl) {
9
+ throw new Error('Unable to locate package.json for agent-infra version');
10
+ }
11
+
12
+ const { version } = JSON.parse(readFileSync(packageJsonUrl, 'utf8'));
6
13
  const VERSION = `v${version}`;
7
14
 
8
15
  export { VERSION };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Gemini CLI, and OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -57,6 +57,7 @@
57
57
  "test:smoke": "npm run build && node --experimental-strip-types --no-warnings --test tests/templates/*.test.ts tests/core/airc.test.ts tests/core/release.test.ts tests/core/metadata-sync-workflow.test.ts tests/core/pr-label-workflow.test.ts tests/core/status-label-workflow.test.ts tests/core/test-tier-coverage.test.ts tests/cli/lib.test.ts tests/cli/sync-templates.test.ts tests/scripts/sync-templates-platform-gating.test.ts",
58
58
  "test:core": "npm run build && node --experimental-strip-types --no-warnings --test tests/templates/*.test.ts tests/core/airc.test.ts tests/core/release.test.ts tests/core/metadata-sync-workflow.test.ts tests/core/pr-label-workflow.test.ts tests/core/status-label-workflow.test.ts tests/core/test-tier-coverage.test.ts tests/cli/lib.test.ts tests/cli/sync-templates.test.ts tests/scripts/sync-templates-platform-gating.test.ts tests/cli/cli.test.ts tests/cli/merge.test.ts tests/cli/sandbox.test.ts tests/core/custom-skills.test.ts tests/core/custom-tuis.test.ts tests/core/demo-regen.test.ts tests/scripts/find-existing-task.test.ts tests/scripts/platform-adapter-defaults.test.ts",
59
59
  "test": "npm run build && node --experimental-strip-types --no-warnings --test tests/cli/*.test.ts tests/templates/*.test.ts tests/core/*.test.ts tests/scripts/*.test.ts",
60
+ "test:coverage": "npm run build && node --experimental-strip-types --no-warnings --test --experimental-test-coverage --test-coverage-exclude='tests/**' --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage.lcov tests/cli/*.test.ts tests/templates/*.test.ts tests/core/*.test.ts tests/scripts/*.test.ts",
60
61
  "prepublishOnly": "npm run build && node --experimental-strip-types --no-warnings --test tests/cli/*.test.ts tests/templates/*.test.ts tests/core/*.test.ts tests/scripts/*.test.ts"
61
62
  },
62
63
  "devDependencies": {
@@ -64,5 +65,8 @@
64
65
  "@types/node": "^25.9.1",
65
66
  "@types/semver": "^7.7.1",
66
67
  "typescript": "~6.0"
68
+ },
69
+ "optionalDependencies": {
70
+ "@lydell/node-pty": "^1.2.0-beta.12"
67
71
  }
68
72
  }
@@ -193,6 +193,23 @@ Each source should mirror the `.agents/skills/` layout and include `SKILL.md` at
193
193
  - Built-in skills are not overridable by custom sources; if a source skill name conflicts with a built-in skill, the source copy is skipped
194
194
  - Use `files.ejected` if the project must take ownership of a built-in skill or command
195
195
 
196
+ ## File Ownership and Sync Strategy
197
+
198
+ The `files` field in `.agents/.airc.json` groups project files into three categories:
199
+
200
+ | Category | When the template has the file | When the template does not have the file | Cleanup behavior |
201
+ |----------|--------------------------------|------------------------------------------|------------------|
202
+ | `managed` | Write from the template and overwrite | Treat as removed from the template | Delete the local project copy |
203
+ | `merged` | Merge semantically by AI or humans | Do not write from the template | Keep the local project copy |
204
+ | `ejected` | May be created from the template first; skip overwrite once it exists | Do not write from the template | Keep the local project copy |
205
+
206
+ `ejected` has two common uses:
207
+
208
+ 1. **Taking over a built-in file**: the project needs full control over a rule, command, or config file that originally came from the template.
209
+ 2. **Declaring a project-only file**: the project owns a file under a managed directory wildcard, but the template does not contain that file; list it in `files.ejected` so sync does not treat it as a removed template file.
210
+
211
+ `ejected` entries support literal paths or globs, using the same matching rules as `merged`.
212
+
196
213
  ## Custom TUI Configuration
197
214
 
198
215
  Use the top-level `.agents/.airc.json` `customTUIs` array when your team uses an AI TUI that is not one of the built-in command targets. This config lets agent-infra show the correct next-step commands and generate command files for project custom skills by learning from an existing command in the custom TUI directory.
@@ -257,6 +274,7 @@ When writing or updating `.agents/skills/*/SKILL.md` files and their templates,
257
274
 
258
275
  - Keep SKILL.md as concise as possible; move detailed rules, long templates, and large script blocks into a sibling `reference/` or `scripts/` directory.
259
276
  - Store declarative configuration in a sibling `config/` directory, for example `config/verify.json`.
277
+ When `required_sections` or `required_patterns` contain language-specific text, provide `config/verify.en.json` and `config/verify.zh-CN.json`; sync strips the selected language variant back to `config/verify.json`.
260
278
  - Use explicit navigation in the skeleton, such as: `Read reference/xxx.md before executing this step.`
261
279
  - Keep scripts in `scripts/` and execute them instead of inlining long bash blocks.
262
280
 
@@ -269,6 +287,7 @@ node .agents/scripts/validate-artifact.js gate <skill-name> <task-dir> [artifact
269
287
  ```
270
288
 
271
289
  - Each skill declares its own checks in `config/verify.json`; keep the file focused on what that skill must validate
290
+ - For language-specific artifact headings or anchors, keep only `required_sections` and language-specific `required_patterns` different between `config/verify.en.json` and `config/verify.zh-CN.json`
272
291
  - If a skill also prints next-step guidance, run the gate first and only show those instructions after the gate passes
273
292
  - For user-facing final validation, prefer `--format text` so the reply contains a readable summary instead of raw JSON
274
293
  - Shared validation logic belongs in `.agents/scripts/validate-artifact.js`; do not move detailed rules back into SKILL.md
@@ -193,6 +193,23 @@ args: "<task-id>" # 可选
193
193
  - 自定义 source 不能覆盖内置 skill;如果与内置 skill 同名,会跳过该 source skill
194
194
  - 如果项目必须接管某个内置 skill 或命令,请使用 `files.ejected`
195
195
 
196
+ ## 文件归属与同步策略
197
+
198
+ `.agents/.airc.json` 的 `files` 字段把项目文件分为三类:
199
+
200
+ | 类别 | 模板中存在时 | 模板中不存在时 | 清理行为 |
201
+ |------|--------------|----------------|----------|
202
+ | `managed` | 从模板写入并覆盖 | 视为模板已下线 | 删除项目本地副本 |
203
+ | `merged` | 由 AI 或人工语义合并 | 不从模板写入 | 保留项目本地副本 |
204
+ | `ejected` | 首次可从模板创建,已存在时跳过覆盖 | 不从模板写入 | 保留项目本地副本 |
205
+
206
+ `ejected` 有两种常见用法:
207
+
208
+ 1. **接管内置文件**:项目需要完全控制原本来自模板的规则、命令或配置文件,避免后续同步覆盖本地内容。
209
+ 2. **声明项目独占文件**:项目自己的文件落在 managed 目录通配下,但模板中没有同名文件;把它列入 `files.ejected`,避免同步时被当作模板已下线文件删除。
210
+
211
+ `ejected` 条目支持字面路径或 glob,匹配规则与 `merged` 相同。
212
+
196
213
  ## 自定义 TUI 配置
197
214
 
198
215
  当团队使用的 AI TUI 不属于内置命令目标时,可以在 `.agents/.airc.json` 顶层配置 `customTUIs` 数组。该配置用于让 agent-infra 输出正确的下一步命令,并通过学习自定义 TUI 目录中的既有命令文件,为项目自定义 skill 生成同格式命令。
@@ -257,6 +274,7 @@ args: "<task-id>" # 可选
257
274
 
258
275
  - SKILL.md 正文尽可能精简,把详细规则、长模板和大段脚本拆分到同级 `reference/` 或 `scripts/` 目录。
259
276
  - 声明式配置统一放在同级 `config/` 目录,例如 `config/verify.json`。
277
+ 当 `required_sections` 或 `required_patterns` 包含语言相关文案时,提供 `config/verify.en.json` 和 `config/verify.zh-CN.json`;sync 会把选中的语言变体剥离为 `config/verify.json`。
260
278
  - 骨架中使用明确导航,例如:`执行此步骤前,先读取 reference/xxx.md。`
261
279
  - 长脚本继续放在 `scripts/` 目录,优先执行脚本而不是内联大段 bash。
262
280
 
@@ -269,6 +287,7 @@ node .agents/scripts/validate-artifact.js gate <skill-name> <task-dir> [artifact
269
287
  ```
270
288
 
271
289
  - 每个 skill 在自己的 `config/verify.json` 中声明需要检查的事项
290
+ - 对语言相关的产物标题或锚点,`config/verify.en.json` 和 `config/verify.zh-CN.json` 之间只应让 `required_sections` 与语言相关的 `required_patterns` 不同
272
291
  - 如果 skill 还会展示“下一步”提示,必须先通过完成校验,再输出这些指引
273
292
  - 面向用户展示最终校验结果时,优先使用 `--format text` 输出可读摘要,而不是原始 JSON
274
293
  - 共享逻辑集中在 `.agents/scripts/validate-artifact.js`,不要把详细校验规则重新塞回 SKILL.md