@fitlab-ai/agent-infra 0.6.2 → 0.6.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 (90) 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 +218 -0
  6. package/dist/lib/sandbox/clipboard/darwin.js +66 -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 +15 -2
  12. package/dist/lib/sandbox/commands/enter.js +14 -3
  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/host-timezone.js +33 -0
  19. package/dist/lib/sandbox/index.js +7 -1
  20. package/dist/lib/sandbox/managed-fs.js +25 -0
  21. package/dist/lib/sandbox/runtimes/base.dockerfile +21 -16
  22. package/dist/lib/sandbox/tools.js +1 -1
  23. package/dist/lib/version.js +9 -2
  24. package/lib/sandbox/clipboard/bridge.ts +286 -0
  25. package/lib/sandbox/clipboard/darwin.ts +91 -0
  26. package/lib/sandbox/clipboard/index.ts +13 -0
  27. package/lib/sandbox/clipboard/keys.ts +78 -0
  28. package/lib/sandbox/clipboard/node-pty.d.ts +19 -0
  29. package/lib/sandbox/clipboard/node-pty.ts +34 -0
  30. package/lib/sandbox/clipboard/paths.ts +71 -0
  31. package/lib/sandbox/commands/create.ts +19 -2
  32. package/lib/sandbox/commands/enter.ts +15 -3
  33. package/lib/sandbox/commands/ls.ts +28 -4
  34. package/lib/sandbox/commands/prune.ts +211 -0
  35. package/lib/sandbox/commands/rm.ts +30 -32
  36. package/lib/sandbox/config.ts +2 -0
  37. package/lib/sandbox/constants.ts +9 -0
  38. package/lib/sandbox/host-timezone.ts +42 -0
  39. package/lib/sandbox/index.ts +7 -1
  40. package/lib/sandbox/managed-fs.ts +27 -0
  41. package/lib/sandbox/runtimes/base.dockerfile +21 -16
  42. package/lib/sandbox/tools.ts +1 -1
  43. package/lib/version.ts +11 -4
  44. package/package.json +10 -6
  45. package/templates/.agents/README.en.md +19 -0
  46. package/templates/.agents/README.zh-CN.md +19 -0
  47. package/templates/.agents/rules/create-issue.github.en.md +19 -1
  48. package/templates/.agents/rules/create-issue.github.zh-CN.md +19 -1
  49. package/templates/.agents/rules/milestone-inference.github.en.md +12 -0
  50. package/templates/.agents/rules/milestone-inference.github.zh-CN.md +12 -0
  51. package/templates/.agents/rules/testing-discipline.en.md +44 -0
  52. package/templates/.agents/rules/testing-discipline.zh-CN.md +44 -0
  53. package/templates/.agents/skills/analyze-task/SKILL.en.md +26 -0
  54. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +26 -0
  55. package/templates/.agents/skills/analyze-task/config/verify.en.json +51 -0
  56. package/templates/.agents/skills/analyze-task/config/{verify.json → verify.zh-CN.json} +6 -2
  57. package/templates/.agents/skills/complete-task/SKILL.en.md +15 -0
  58. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +15 -0
  59. package/templates/.agents/skills/complete-task/config/{verify.json → verify.en.json} +10 -0
  60. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +48 -0
  61. package/templates/.agents/skills/create-task/SKILL.en.md +2 -0
  62. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +2 -0
  63. package/templates/.agents/skills/create-task/config/verify.json +1 -0
  64. package/templates/.agents/skills/implement-task/SKILL.en.md +14 -0
  65. package/templates/.agents/skills/implement-task/SKILL.zh-CN.md +14 -0
  66. package/templates/.agents/skills/implement-task/config/verify.en.json +51 -0
  67. package/templates/.agents/skills/implement-task/config/{verify.json → verify.zh-CN.json} +7 -2
  68. package/templates/.agents/skills/implement-task/reference/report-template.en.md +15 -0
  69. package/templates/.agents/skills/implement-task/reference/report-template.zh-CN.md +15 -0
  70. package/templates/.agents/skills/import-issue/SKILL.en.md +1 -1
  71. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
  72. package/templates/.agents/skills/plan-task/SKILL.en.md +22 -0
  73. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +22 -0
  74. package/templates/.agents/skills/plan-task/config/verify.en.json +52 -0
  75. package/templates/.agents/skills/plan-task/config/{verify.json → verify.zh-CN.json} +6 -2
  76. package/templates/.agents/skills/post-release/SKILL.en.md +1 -0
  77. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +1 -0
  78. package/templates/.agents/skills/refine-task/SKILL.en.md +14 -0
  79. package/templates/.agents/skills/refine-task/SKILL.zh-CN.md +14 -0
  80. package/templates/.agents/skills/refine-task/config/verify.en.json +47 -0
  81. package/templates/.agents/skills/refine-task/config/{verify.json → verify.zh-CN.json} +7 -2
  82. package/templates/.agents/skills/refine-task/reference/report-template.en.md +15 -0
  83. package/templates/.agents/skills/refine-task/reference/report-template.zh-CN.md +15 -0
  84. package/templates/.agents/skills/review-task/SKILL.en.md +14 -0
  85. package/templates/.agents/skills/review-task/SKILL.zh-CN.md +14 -0
  86. package/templates/.agents/skills/review-task/config/verify.en.json +50 -0
  87. package/templates/.agents/skills/review-task/config/{verify.json → verify.zh-CN.json} +5 -2
  88. package/templates/.agents/skills/review-task/reference/report-template.en.md +15 -0
  89. package/templates/.agents/skills/review-task/reference/report-template.zh-CN.md +15 -0
  90. package/dist/package.json +0 -5
@@ -11,6 +11,8 @@ import {
11
11
  import { runInteractiveEngine, runSafeEngine } from '../shell.ts';
12
12
  import { resolveTaskBranch } from '../task-resolver.ts';
13
13
  import { dotfilesCacheDir, materializeDotfiles } from '../dotfiles.ts';
14
+ import { runInteractiveWithClipboardBridge } from '../clipboard/bridge.ts';
15
+ import { detectHostTimezone } from '../host-timezone.ts';
14
16
 
15
17
  const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
16
18
  const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
@@ -38,6 +40,11 @@ export function terminalEnvFlags(env: NodeJS.ProcessEnv = process.env): string[]
38
40
  return flags;
39
41
  }
40
42
 
43
+ export function hostTimezoneEnvFlags(detect = detectHostTimezone): string[] {
44
+ const tz = detect();
45
+ return tz ? ['-e', `TZ=${tz}`] : [];
46
+ }
47
+
41
48
  export function formatCredentialSyncStatus(
42
49
  result: ReturnType<typeof reconcileClaudeCredentials>,
43
50
  isTTY = process.stderr.isTTY
@@ -65,7 +72,7 @@ export function formatCredentialSyncStatus(
65
72
  return null;
66
73
  }
67
74
 
68
- export function enter(args: string[]): number {
75
+ export async function enter(args: string[]): Promise<number> {
69
76
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
70
77
  process.stdout.write(`${USAGE}\n`);
71
78
  if (args.length === 0) {
@@ -100,7 +107,7 @@ export function enter(args: string[]): number {
100
107
  }
101
108
  }
102
109
 
103
- const envFlags = terminalEnvFlags();
110
+ const envFlags = [...terminalEnvFlags(), ...hostTimezoneEnvFlags()];
104
111
  if (cmd.length === 0) {
105
112
  try {
106
113
  materializeDotfiles(config.dotfilesDir, dotfilesCacheDir(config.home, config.project));
@@ -108,7 +115,12 @@ export function enter(args: string[]): number {
108
115
  process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
109
116
  }
110
117
 
111
- return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH]);
118
+ return runInteractiveWithClipboardBridge({
119
+ engine,
120
+ dockerArgs: ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH],
121
+ container,
122
+ home: config.home
123
+ });
112
124
  }
113
125
 
114
126
  return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, ...cmd]);
@@ -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
  }
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+
4
+ export type DetectHostTimezoneOptions = {
5
+ platform?: NodeJS.Platform;
6
+ readlink?: (targetPath: string) => string;
7
+ env?: NodeJS.ProcessEnv;
8
+ };
9
+
10
+ const ZONEINFO_MARK = '/zoneinfo/';
11
+ const IANA_ZONE_RE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
12
+
13
+ function safeTimezone(value: string | undefined): string | null {
14
+ if (!value || !IANA_ZONE_RE.test(value)) {
15
+ return null;
16
+ }
17
+ return value;
18
+ }
19
+
20
+ export function detectHostTimezone(options: DetectHostTimezoneOptions = {}): string | null {
21
+ const platform = options.platform ?? os.platform();
22
+ const env = options.env ?? process.env;
23
+ if (env.TZ) {
24
+ return safeTimezone(env.TZ);
25
+ }
26
+
27
+ if (platform !== 'darwin' && platform !== 'linux') {
28
+ return null;
29
+ }
30
+
31
+ const readlink = options.readlink ?? fs.readlinkSync;
32
+ try {
33
+ const target = readlink('/etc/localtime');
34
+ const idx = target.indexOf(ZONEINFO_MARK);
35
+ if (idx < 0) {
36
+ return null;
37
+ }
38
+ return safeTimezone(target.slice(idx + ZONEINFO_MARK.length));
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
@@ -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
+ }
@@ -3,7 +3,6 @@ FROM ubuntu:22.04
3
3
  LABEL description="AI coding sandbox"
4
4
 
5
5
  ENV DEBIAN_FRONTEND=noninteractive
6
- ENV TZ=Asia/Shanghai
7
6
 
8
7
  ARG HOST_UID=1000
9
8
  ARG HOST_GID=1000
@@ -22,7 +21,7 @@ RUN apt-get update && apt-get install -y \
22
21
  build-essential ca-certificates gnupg lsb-release \
23
22
  libevent-core-2.1-7 libncursesw6 libtinfo6 \
24
23
  pkg-config bison libevent-dev libncurses-dev \
25
- locales \
24
+ locales tzdata \
26
25
  && locale-gen en_US.UTF-8 \
27
26
  && (curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
28
27
  | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg) \
@@ -45,13 +44,13 @@ RUN apt-get update && apt-get install -y \
45
44
  && rm -rf /var/lib/apt/lists/*
46
45
 
47
46
  # Enable extended keys in CSI u format so Shift+Enter and other modified
48
- # keys are forwarded through tmux. Preserve terminal-detection variables
47
+ # keys are forwarded through tmux. Preserve terminal/timezone variables
49
48
  # injected at `docker exec` time when new tmux sessions are created.
50
49
  RUN printf '%s\n' \
51
50
  'set -g extended-keys always' \
52
51
  'set -g extended-keys-format csi-u' \
53
52
  "set -as terminal-features 'xterm*:extkeys'" \
54
- "set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION'" \
53
+ "set -ga update-environment 'TERM_PROGRAM TERM_PROGRAM_VERSION LC_TERMINAL LC_TERMINAL_VERSION TZ'" \
55
54
  'set -g mouse on' \
56
55
  'set -g status-interval 1' \
57
56
  'set -g status-right-length 80' \
@@ -146,7 +145,7 @@ RUN cat > /usr/local/bin/sandbox-tmux-entry <<'SCRIPT' && chmod +x /usr/local/bi
146
145
  #!/bin/sh
147
146
  set -eu
148
147
 
149
- sandbox-dotfiles-link >/dev/null || true
148
+ sandbox-dotfiles-link >/dev/null 2>&1 || true
150
149
 
151
150
  SESSION=work
152
151
 
@@ -154,20 +153,26 @@ if ! command -v tmux >/dev/null 2>&1; then
154
153
  exec bash
155
154
  fi
156
155
 
157
- if ! tmux has-session -t "$SESSION" 2>/dev/null; then
158
- exec tmux new-session -s "$SESSION"
159
- fi
160
-
161
- tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null | \
162
- while read -r name attached; do
163
- [ "$name" = "$SESSION" ] && continue
156
+ # Drop stale grouped sessions left by older entry-script versions (the windows
157
+ # live on $SESSION, so killing the group members only removes view entries).
158
+ tmux list-sessions -F '#{session_name}' 2>/dev/null | while IFS= read -r name; do
164
159
  case "$name" in
165
- ''|*[!0-9]*) continue ;;
160
+ "$SESSION"-*) tmux kill-session -t "$name" 2>/dev/null || true ;;
166
161
  esac
167
- [ "$attached" = "0" ] && tmux kill-session -t "$name" 2>/dev/null || true
168
- done
162
+ done
163
+
164
+ # Reuse the single $SESSION; -d detaches any pre-existing client so the new
165
+ # one becomes the sole owner of window-size, eliminating size races.
166
+ if tmux has-session -t "$SESSION" 2>/dev/null; then
167
+ # Push the per-exec TZ into the running session's env so new
168
+ # windows/panes pick up the host timezone without a session kill.
169
+ if [ -n "${TZ:-}" ]; then
170
+ tmux set-environment -t "$SESSION" TZ "$TZ" 2>/dev/null || true
171
+ fi
172
+ exec tmux attach -d -t "$SESSION"
173
+ fi
169
174
 
170
- exec tmux new-session -t "$SESSION"
175
+ exec tmux new-session -s "$SESSION"
171
176
  SCRIPT
172
177
 
173
178
  ENV LANG=en_US.UTF-8
@@ -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',