@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
@@ -5,6 +5,8 @@ import { formatCredentialWarnings, formatRemaining, reconcileClaudeCredentials,
5
5
  import { runInteractiveEngine, runSafeEngine } from "../shell.js";
6
6
  import { resolveTaskBranch } from "../task-resolver.js";
7
7
  import { dotfilesCacheDir, materializeDotfiles } from "../dotfiles.js";
8
+ import { runInteractiveWithClipboardBridge } from "../clipboard/bridge.js";
9
+ import { detectHostTimezone } from "../host-timezone.js";
8
10
  const USAGE = `Usage: ai sandbox exec <branch> [cmd...]`;
9
11
  const TMUX_ENTRY_PATH = '/usr/local/bin/sandbox-tmux-entry';
10
12
  // Terminal-detection variables that interactive TUIs (e.g. claude-code)
@@ -28,6 +30,10 @@ export function terminalEnvFlags(env = process.env) {
28
30
  }
29
31
  return flags;
30
32
  }
33
+ export function hostTimezoneEnvFlags(detect = detectHostTimezone) {
34
+ const tz = detect();
35
+ return tz ? ['-e', `TZ=${tz}`] : [];
36
+ }
31
37
  export function formatCredentialSyncStatus(result, isTTY = process.stderr.isTTY) {
32
38
  if (result.status === 'STALE_ACCESS') {
33
39
  return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
@@ -51,7 +57,7 @@ export function formatCredentialSyncStatus(result, isTTY = process.stderr.isTTY)
51
57
  }
52
58
  return null;
53
59
  }
54
- export function enter(args) {
60
+ export async function enter(args) {
55
61
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
56
62
  process.stdout.write(`${USAGE}\n`);
57
63
  if (args.length === 0) {
@@ -83,7 +89,7 @@ export function enter(args) {
83
89
  process.stderr.write(`Warning: Failed to sync Claude Code credentials: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
84
90
  }
85
91
  }
86
- const envFlags = terminalEnvFlags();
92
+ const envFlags = [...terminalEnvFlags(), ...hostTimezoneEnvFlags()];
87
93
  if (cmd.length === 0) {
88
94
  try {
89
95
  materializeDotfiles(config.dotfilesDir, dotfilesCacheDir(config.home, config.project));
@@ -91,7 +97,12 @@ export function enter(args) {
91
97
  catch (error) {
92
98
  process.stderr.write(`Warning: dotfiles snapshot rebuild failed: ${redactCommandError(error instanceof Error ? error.message : 'unknown error')}\n`);
93
99
  }
94
- return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH]);
100
+ return runInteractiveWithClipboardBridge({
101
+ engine,
102
+ dockerArgs: ['exec', '-it', ...envFlags, container, 'bash', TMUX_ENTRY_PATH],
103
+ container,
104
+ home: config.home
105
+ });
95
106
  }
96
107
  return runInteractiveEngine(engine, 'docker', ['exec', '-it', ...envFlags, container, ...cmd]);
97
108
  }
@@ -8,7 +8,7 @@ import { detectEngine } from "../engine.js";
8
8
  import { runSafeEngine } from "../shell.js";
9
9
  import { resolveTools, toolProjectDirCandidates } from "../tools.js";
10
10
  const USAGE = 'Usage: ai sandbox ls';
11
- const CONTAINER_LIST_HEADER = 'NAMES\tSTATUS\tBRANCH';
11
+ const CONTAINER_TABLE_HEADERS = ['NAMES', 'STATUS', 'BRANCH'];
12
12
  // Exported to lock the docker/podman-compatible format in unit tests.
13
13
  export function containerListFormat() {
14
14
  return '{{.Names}}\t{{.Status}}\t{{.Labels}}';
@@ -30,6 +30,19 @@ export function parseLabels(csv) {
30
30
  }
31
31
  return labels;
32
32
  }
33
+ export function formatContainerTable(rows) {
34
+ const columns = rows.map((row) => [row.name, row.status, row.branch]);
35
+ const widths = [
36
+ Math.max(CONTAINER_TABLE_HEADERS[0].length, ...rows.map((row) => row.name.length)),
37
+ Math.max(CONTAINER_TABLE_HEADERS[1].length, ...rows.map((row) => row.status.length)),
38
+ Math.max(CONTAINER_TABLE_HEADERS[2].length, ...rows.map((row) => row.branch.length))
39
+ ];
40
+ const renderRow = (values) => `${values[0].padEnd(widths[0])} ${values[1].padEnd(widths[1])} ${values[2]}`.trimEnd();
41
+ return [
42
+ renderRow(CONTAINER_TABLE_HEADERS),
43
+ ...columns.map((column) => renderRow(column))
44
+ ];
45
+ }
33
46
  function listChildren(dir) {
34
47
  if (!fs.existsSync(dir)) {
35
48
  return [];
@@ -60,11 +73,13 @@ export function ls(args = []) {
60
73
  }
61
74
  else {
62
75
  const branchKey = sandboxBranchLabel(config);
63
- process.stdout.write(` ${CONTAINER_LIST_HEADER}\n`);
64
- for (const line of containers.split('\n')) {
76
+ const rows = containers.split('\n').map((line) => {
65
77
  const [name = '', status = '', labelsCsv = ''] = line.split('\t');
66
78
  const branch = parseLabels(labelsCsv)[branchKey] ?? '';
67
- process.stdout.write(` ${name}\t${status}\t${branch}\n`);
79
+ return { name, status, branch };
80
+ });
81
+ for (const line of formatContainerTable(rows)) {
82
+ process.stdout.write(` ${line}\n`);
68
83
  }
69
84
  }
70
85
  p.log.step('Worktrees');
@@ -0,0 +1,176 @@
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.js";
7
+ import { safeNameCandidates, sandboxBranchLabel, sandboxLabel } from "../constants.js";
8
+ import { detectEngine } from "../engine.js";
9
+ import { hostJoin } from "../engines/wsl2-paths.js";
10
+ import { removeManagedDir, removeWorktreeDir } from "../managed-fs.js";
11
+ import { parseLabels } from "./ls.js";
12
+ import { runEngine, runSafe } from "../shell.js";
13
+ import { resolveTools } from "../tools.js";
14
+ const USAGE = `Usage: ai sandbox prune [--dry-run]`;
15
+ function listChildDirs(base) {
16
+ if (!fs.existsSync(base)) {
17
+ return [];
18
+ }
19
+ return fs.readdirSync(base)
20
+ .sort()
21
+ .map((entry) => path.join(base, entry))
22
+ .filter((entry) => {
23
+ try {
24
+ return fs.statSync(entry).isDirectory();
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ });
30
+ }
31
+ function activeSafeNames(activeBranches) {
32
+ const names = new Set();
33
+ for (const branch of activeBranches) {
34
+ try {
35
+ for (const name of safeNameCandidates(branch)) {
36
+ names.add(name);
37
+ }
38
+ }
39
+ catch {
40
+ names.add(branch);
41
+ }
42
+ }
43
+ return names;
44
+ }
45
+ function orphanDirs(base, activeNames) {
46
+ return listChildDirs(base).filter((dir) => !activeNames.has(path.basename(dir)));
47
+ }
48
+ function addGroup(groups, group) {
49
+ if (group.dirs.length > 0) {
50
+ groups.push(group);
51
+ }
52
+ }
53
+ export function collectOrphanGroups(config, tools, activeBranches) {
54
+ const activeNames = activeSafeNames(activeBranches);
55
+ const groups = [];
56
+ const shareBranchesBase = hostJoin(config.shareBase, 'branches');
57
+ addGroup(groups, {
58
+ kind: 'shell',
59
+ label: 'Shell config dirs',
60
+ base: config.shellConfigBase,
61
+ dirs: orphanDirs(config.shellConfigBase, activeNames)
62
+ });
63
+ addGroup(groups, {
64
+ kind: 'worktree',
65
+ label: 'Worktrees',
66
+ base: config.worktreeBase,
67
+ dirs: orphanDirs(config.worktreeBase, activeNames)
68
+ });
69
+ addGroup(groups, {
70
+ kind: 'share',
71
+ label: 'Share branch dirs',
72
+ base: shareBranchesBase,
73
+ dirs: orphanDirs(shareBranchesBase, activeNames)
74
+ });
75
+ for (const tool of tools) {
76
+ const base = hostJoin(tool.sandboxBase, config.project);
77
+ addGroup(groups, {
78
+ kind: 'tool',
79
+ label: `${tool.name} state`,
80
+ base,
81
+ dirs: orphanDirs(base, activeNames)
82
+ });
83
+ }
84
+ return groups;
85
+ }
86
+ export function removeOrphanGroups(config, groups) {
87
+ let removedWorktrees = false;
88
+ for (const group of groups) {
89
+ for (const dir of group.dirs) {
90
+ if (group.kind === 'worktree') {
91
+ removeWorktreeDir(config.repoRoot, group.base, dir);
92
+ removedWorktrees = true;
93
+ }
94
+ else {
95
+ removeManagedDir(group.base, dir);
96
+ }
97
+ }
98
+ }
99
+ return removedWorktrees;
100
+ }
101
+ function activeBranchesFromLabels(config, labelsOutput) {
102
+ const branchKey = sandboxBranchLabel(config);
103
+ return labelsOutput.split('\n')
104
+ .map((line) => parseLabels(line)[branchKey] ?? '')
105
+ .filter(Boolean);
106
+ }
107
+ function orphanCount(groups) {
108
+ return groups.reduce((sum, group) => sum + group.dirs.length, 0);
109
+ }
110
+ function writeGroups(groups) {
111
+ for (const group of groups) {
112
+ p.log.step(group.label);
113
+ for (const dir of group.dirs) {
114
+ process.stdout.write(` ${dir}\n`);
115
+ }
116
+ }
117
+ }
118
+ export async function prune(args) {
119
+ const { values } = parseArgs({
120
+ args,
121
+ strict: true,
122
+ options: {
123
+ 'dry-run': { type: 'boolean' },
124
+ help: { type: 'boolean', short: 'h' }
125
+ }
126
+ });
127
+ if (values.help) {
128
+ process.stdout.write(`${USAGE}\n`);
129
+ return;
130
+ }
131
+ const config = loadConfig();
132
+ const tools = resolveTools(config);
133
+ const engine = detectEngine(config);
134
+ const psArgs = [
135
+ 'ps',
136
+ '-a',
137
+ '--filter',
138
+ `label=${sandboxLabel(config)}`,
139
+ '--format',
140
+ '{{.Labels}}'
141
+ ];
142
+ let labelsOutput;
143
+ try {
144
+ labelsOutput = runEngine(engine, 'docker', psArgs);
145
+ }
146
+ catch {
147
+ throw new Error('Unable to determine active sandbox branches: docker ps failed');
148
+ }
149
+ const groups = collectOrphanGroups(config, tools, activeBranchesFromLabels(config, labelsOutput));
150
+ const count = orphanCount(groups);
151
+ p.intro(pc.cyan(`Pruning orphaned sandbox state for ${config.project}`));
152
+ if (count === 0) {
153
+ p.log.success('No orphaned sandbox state dirs found');
154
+ p.outro(pc.green('Sandbox prune complete'));
155
+ return;
156
+ }
157
+ writeGroups(groups);
158
+ if (values['dry-run']) {
159
+ p.outro(pc.green('Dry run complete'));
160
+ return;
161
+ }
162
+ const shouldRemove = await p.confirm({
163
+ message: `Remove ${count} orphaned sandbox state dirs?`,
164
+ initialValue: true
165
+ });
166
+ if (p.isCancel(shouldRemove) || !shouldRemove) {
167
+ p.outro('Cancelled');
168
+ return;
169
+ }
170
+ const removedWorktrees = removeOrphanGroups(config, groups);
171
+ if (removedWorktrees) {
172
+ runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
173
+ }
174
+ p.outro(pc.green('Orphaned sandbox state dirs removed'));
175
+ }
176
+ //# sourceMappingURL=prune.js.map
@@ -4,24 +4,17 @@ import { parseArgs } from 'node:util';
4
4
  import * as p from '@clack/prompts';
5
5
  import pc from 'picocolors';
6
6
  import { loadConfig } from "../config.js";
7
- import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel, shareBranchDir, worktreeDirCandidates } from "../constants.js";
7
+ import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel, shareBranchDir, shellConfigDirCandidates, worktreeDirCandidates } from "../constants.js";
8
8
  import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from "../engine.js";
9
- import { run, runOk, runSafe, runSafeEngine } from "../shell.js";
9
+ import { removeManagedDir, removeWorktreeDir } from "../managed-fs.js";
10
+ import { runOk, runSafe, runSafeEngine } from "../shell.js";
10
11
  import { resolveTaskBranch } from "../task-resolver.js";
11
12
  import { resolveTools, toolConfigDirCandidates, toolProjectDirCandidates } from "../tools.js";
12
13
  const USAGE = `Usage: ai sandbox rm <branch> [--all]`;
14
+ export { assertManagedPath } from "../managed-fs.js";
13
15
  function projectToolDirs(config, tools) {
14
16
  return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
15
17
  }
16
- export function assertManagedPath(root, target) {
17
- const resolvedRoot = path.resolve(root);
18
- const resolvedTarget = path.resolve(target);
19
- const relative = path.relative(resolvedRoot, resolvedTarget);
20
- if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
21
- return;
22
- }
23
- throw new Error(`Refusing to remove path outside managed sandbox root: ${target}`);
24
- }
25
18
  async function rmOne(config, tools, branch) {
26
19
  assertValidBranchName(branch);
27
20
  const engine = detectEngine(config);
@@ -73,13 +66,7 @@ async function rmOne(config, tools, branch) {
73
66
  }
74
67
  if (shouldRemoveWorktree) {
75
68
  for (const worktree of existingWorktrees) {
76
- try {
77
- run('git', ['-C', config.repoRoot, 'worktree', 'remove', worktree, '--force']);
78
- }
79
- catch {
80
- assertManagedPath(config.worktreeBase, worktree);
81
- fs.rmSync(worktree, { recursive: true, force: true });
82
- }
69
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, worktree);
83
70
  }
84
71
  const shouldDeleteBranch = await p.confirm({
85
72
  message: `Also delete local branch '${effectiveBranch}'?`,
@@ -94,11 +81,14 @@ async function rmOne(config, tools, branch) {
94
81
  }
95
82
  for (const { tool, candidates } of toolCandidates) {
96
83
  for (const dir of candidates.filter((candidate) => fs.existsSync(candidate))) {
97
- assertManagedPath(tool.sandboxBase, dir);
98
- fs.rmSync(dir, { recursive: true, force: true });
84
+ removeManagedDir(tool.sandboxBase, dir);
99
85
  p.log.success(`${tool.name} state removed: ${dir}`);
100
86
  }
101
87
  }
88
+ for (const dir of shellConfigDirCandidates(config, effectiveBranch).filter((candidate) => fs.existsSync(candidate))) {
89
+ removeManagedDir(config.shellConfigBase, dir);
90
+ p.log.success(`Shell config removed: ${dir}`);
91
+ }
102
92
  const shareBranch = shareBranchDir(config, effectiveBranch);
103
93
  if (fs.existsSync(shareBranch)) {
104
94
  const shouldRemoveShare = await p.confirm({
@@ -106,8 +96,7 @@ async function rmOne(config, tools, branch) {
106
96
  initialValue: true
107
97
  });
108
98
  if (!p.isCancel(shouldRemoveShare) && shouldRemoveShare) {
109
- assertManagedPath(config.shareBase, shareBranch);
110
- fs.rmSync(shareBranch, { recursive: true, force: true });
99
+ removeManagedDir(config.shareBase, shareBranch);
111
100
  p.log.success(`Share dir removed: ${shareBranch}`);
112
101
  }
113
102
  }
@@ -144,32 +133,37 @@ async function rmAll(config, tools) {
144
133
  if (!p.isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
145
134
  for (const entry of fs.readdirSync(config.worktreeBase)) {
146
135
  const dir = path.join(config.worktreeBase, entry);
147
- try {
148
- run('git', ['-C', config.repoRoot, 'worktree', 'remove', dir, '--force']);
149
- }
150
- catch {
151
- assertManagedPath(config.worktreeBase, dir);
152
- fs.rmSync(dir, { recursive: true, force: true });
153
- }
136
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, dir);
154
137
  }
155
138
  runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
156
139
  }
157
140
  }
158
141
  for (const dir of projectToolDirs(config, tools)) {
159
142
  if (fs.existsSync(dir)) {
160
- assertManagedPath(path.dirname(dir), dir);
161
- fs.rmSync(dir, { recursive: true, force: true });
143
+ removeManagedDir(path.dirname(dir), dir);
162
144
  p.log.success(`Removed tool state: ${dir}`);
163
145
  }
164
146
  }
147
+ if (fs.existsSync(config.shellConfigBase) && fs.readdirSync(config.shellConfigBase).length > 0) {
148
+ const shouldRemoveShellConfigs = await p.confirm({
149
+ message: `Remove all shell config dirs in ${config.shellConfigBase}?`,
150
+ initialValue: true
151
+ });
152
+ if (!p.isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
153
+ for (const entry of fs.readdirSync(config.shellConfigBase)) {
154
+ const dir = path.join(config.shellConfigBase, entry);
155
+ removeManagedDir(config.shellConfigBase, dir);
156
+ }
157
+ p.log.success(`Project shell config dirs removed: ${config.shellConfigBase}`);
158
+ }
159
+ }
165
160
  if (fs.existsSync(config.shareBase) && fs.readdirSync(config.shareBase).length > 0) {
166
161
  const shouldRemoveAllShares = await p.confirm({
167
162
  message: `Remove all share dirs for project (${config.shareBase})?`,
168
163
  initialValue: true
169
164
  });
170
165
  if (!p.isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
171
- assertManagedPath(path.dirname(config.shareBase), config.shareBase);
172
- fs.rmSync(config.shareBase, { recursive: true, force: true });
166
+ removeManagedDir(path.dirname(config.shareBase), config.shareBase);
173
167
  p.log.success(`Project share dirs removed: ${config.shareBase}`);
174
168
  }
175
169
  }
@@ -86,6 +86,7 @@ export function loadConfig({ platformFn = platform, writeStderr = (chunk) => pro
86
86
  imageName: `${project}-sandbox:latest`,
87
87
  worktreeBase: hostJoin(home, '.agent-infra', 'worktrees', project),
88
88
  shareBase: hostJoin(home, '.agent-infra', 'share', project),
89
+ shellConfigBase: hostJoin(home, '.agent-infra', 'config', project),
89
90
  dotfilesDir: hostJoin(home, '.agent-infra', 'dotfiles'),
90
91
  engine,
91
92
  runtimes,
@@ -57,6 +57,12 @@ export function shareCommonDir(config) {
57
57
  export function shareBranchDir(config, branch) {
58
58
  return hostJoin(config.shareBase, 'branches', sanitizeBranchName(branch));
59
59
  }
60
+ export function shellConfigDir(config, branch) {
61
+ return hostJoin(config.shellConfigBase, sanitizeBranchName(branch));
62
+ }
63
+ export function shellConfigDirCandidates(config, branch) {
64
+ return safeNameCandidates(branch).map((name) => hostJoin(config.shellConfigBase, name));
65
+ }
60
66
  export function sandboxLabel(config) {
61
67
  return `${config.project}.sandbox`;
62
68
  }
@@ -0,0 +1,33 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ const ZONEINFO_MARK = '/zoneinfo/';
4
+ const IANA_ZONE_RE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
5
+ function safeTimezone(value) {
6
+ if (!value || !IANA_ZONE_RE.test(value)) {
7
+ return null;
8
+ }
9
+ return value;
10
+ }
11
+ export function detectHostTimezone(options = {}) {
12
+ const platform = options.platform ?? os.platform();
13
+ const env = options.env ?? process.env;
14
+ if (env.TZ) {
15
+ return safeTimezone(env.TZ);
16
+ }
17
+ if (platform !== 'darwin' && platform !== 'linux') {
18
+ return null;
19
+ }
20
+ const readlink = options.readlink ?? fs.readlinkSync;
21
+ try {
22
+ const target = readlink('/etc/localtime');
23
+ const idx = target.indexOf(ZONEINFO_MARK);
24
+ if (idx < 0) {
25
+ return null;
26
+ }
27
+ return safeTimezone(target.slice(idx + ZONEINFO_MARK.length));
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ //# sourceMappingURL=host-timezone.js.map
@@ -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
 
@@ -29,7 +30,7 @@ export async function runSandbox(args) {
29
30
  }
30
31
  case 'exec': {
31
32
  const { enter } = await import("./commands/enter.js");
32
- const exitCode = enter(rest);
33
+ const exitCode = await enter(rest);
33
34
  if (typeof exitCode === 'number' && exitCode !== 0) {
34
35
  process.exitCode = exitCode;
35
36
  }
@@ -53,6 +54,11 @@ export async function runSandbox(args) {
53
54
  await rm(rest);
54
55
  break;
55
56
  }
57
+ case 'prune': {
58
+ const { prune } = await import("./commands/prune.js");
59
+ await prune(rest);
60
+ break;
61
+ }
56
62
  case 'vm': {
57
63
  const { vm } = await import("./commands/vm.js");
58
64
  await vm(rest);
@@ -0,0 +1,25 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { run } from "./shell.js";
4
+ export function assertManagedPath(root, target) {
5
+ const resolvedRoot = path.resolve(root);
6
+ const resolvedTarget = path.resolve(target);
7
+ const relative = path.relative(resolvedRoot, resolvedTarget);
8
+ if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
9
+ return;
10
+ }
11
+ throw new Error(`Refusing to remove path outside managed sandbox root: ${target}`);
12
+ }
13
+ export function removeManagedDir(root, dir) {
14
+ assertManagedPath(root, dir);
15
+ fs.rmSync(dir, { recursive: true, force: true });
16
+ }
17
+ export function removeWorktreeDir(repoRoot, worktreeBase, dir) {
18
+ try {
19
+ run('git', ['-C', repoRoot, 'worktree', 'remove', dir, '--force']);
20
+ }
21
+ catch {
22
+ removeManagedDir(worktreeBase, dir);
23
+ }
24
+ }
25
+ //# sourceMappingURL=managed-fs.js.map
@@ -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
@@ -5,7 +5,7 @@ function createBuiltinTools(home, project) {
5
5
  'claude-code': {
6
6
  id: 'claude-code',
7
7
  name: 'Claude Code',
8
- npmPackage: '@anthropic-ai/claude-code',
8
+ npmPackage: '@anthropic-ai/claude-code@stable',
9
9
  sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'claude-code'),
10
10
  containerMount: '/home/devuser/.claude',
11
11
  versionCmd: 'claude --version',
@@ -1,5 +1,12 @@
1
- import { readFileSync } from 'node:fs';
2
- const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ const packageJsonUrl = [
3
+ new URL('../package.json', import.meta.url),
4
+ new URL('../../package.json', import.meta.url),
5
+ ].find((url) => existsSync(url));
6
+ if (!packageJsonUrl) {
7
+ throw new Error('Unable to locate package.json for agent-infra version');
8
+ }
9
+ const { version } = JSON.parse(readFileSync(packageJsonUrl, 'utf8'));
3
10
  const VERSION = `v${version}`;
4
11
  export { VERSION };
5
12
  //# sourceMappingURL=version.js.map