@fitlab-ai/agent-infra 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/bin/cli.ts +4 -4
  4. package/dist/bin/cli.js +4 -4
  5. package/dist/lib/defaults.json +1 -0
  6. package/dist/lib/run/index.js +96 -5
  7. package/dist/lib/sandbox/capture.js +113 -4
  8. package/dist/lib/sandbox/commands/create.js +37 -4
  9. package/dist/lib/sandbox/commands/enter.js +7 -3
  10. package/dist/lib/sandbox/config.js +1 -1
  11. package/dist/lib/sandbox/credentials.js +42 -3
  12. package/dist/lib/sandbox/runtimes/base.dockerfile +13 -0
  13. package/dist/lib/sandbox/runtimes/node20.dockerfile +1 -1
  14. package/dist/lib/sandbox/runtimes/node22.dockerfile +1 -1
  15. package/dist/lib/sandbox/tools.js +9 -0
  16. package/dist/lib/server/adapters/feishu/index.js +5 -3
  17. package/dist/lib/server/adapters/feishu/transport.js +20 -2
  18. package/dist/lib/server/protocol.js +20 -4
  19. package/dist/lib/task/commands/log.js +36 -14
  20. package/dist/lib/task/commands/status.js +144 -45
  21. package/dist/lib/update.js +31 -0
  22. package/lib/defaults.json +1 -0
  23. package/lib/run/index.ts +119 -6
  24. package/lib/sandbox/capture.ts +136 -5
  25. package/lib/sandbox/commands/create.ts +42 -4
  26. package/lib/sandbox/commands/enter.ts +8 -2
  27. package/lib/sandbox/config.ts +1 -1
  28. package/lib/sandbox/credentials.ts +57 -3
  29. package/lib/sandbox/runtimes/base.dockerfile +13 -0
  30. package/lib/sandbox/runtimes/node20.dockerfile +1 -1
  31. package/lib/sandbox/runtimes/node22.dockerfile +1 -1
  32. package/lib/sandbox/tools.ts +9 -0
  33. package/lib/server/adapters/feishu/index.ts +5 -3
  34. package/lib/server/adapters/feishu/transport.ts +32 -3
  35. package/lib/server/protocol.ts +20 -4
  36. package/lib/task/commands/log.ts +36 -14
  37. package/lib/task/commands/status.ts +200 -58
  38. package/lib/update.ts +34 -0
  39. package/package.json +5 -4
  40. package/templates/.agents/README.en.md +3 -3
  41. package/templates/.agents/README.zh-CN.md +3 -3
  42. package/templates/.agents/rules/next-step-output.en.md +1 -1
  43. package/templates/.agents/rules/next-step-output.zh-CN.md +1 -1
  44. package/templates/.agents/rules/pr-sync.github.en.md +3 -3
  45. package/templates/.agents/rules/pr-sync.github.zh-CN.md +3 -3
  46. package/templates/.agents/rules/task-management.en.md +1 -1
  47. package/templates/.agents/rules/task-management.zh-CN.md +1 -1
  48. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  49. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -2
  50. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +1 -1
  51. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +1 -1
  52. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +3 -3
  53. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +4 -4
  54. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
  55. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
  56. package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
  57. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
  58. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
  59. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
  60. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +11 -11
  61. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +11 -11
  62. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +4 -4
  63. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +4 -4
  64. package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +5 -5
  65. package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +5 -5
  66. package/templates/.agents/skills/review-code/SKILL.en.md +4 -4
  67. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -4
  68. package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
  69. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
  70. package/templates/.agents/skills/review-code/reference/output-templates.en.md +16 -16
  71. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +12 -12
  72. package/templates/.agents/skills/review-code/reference/report-template.en.md +4 -4
  73. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +4 -4
  74. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +5 -5
  75. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +5 -5
  76. package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
  77. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
  78. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
  79. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
  80. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +11 -11
  81. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +11 -11
  82. package/templates/.agents/skills/review-plan/reference/report-template.en.md +4 -4
  83. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +4 -4
  84. package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +5 -5
  85. package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +5 -5
  86. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +26 -1
@@ -22,22 +22,45 @@ export type SandboxCaptureResult = {
22
22
  signal: NodeJS.Signals | null;
23
23
  stdout: string;
24
24
  stderr: string;
25
+ run?: SandboxRunMetadata;
26
+ };
27
+
28
+ export type SandboxRunMetadata = {
29
+ runId: string;
30
+ engine: string;
31
+ container: string;
32
+ runDir: string;
25
33
  };
26
34
 
27
35
  export type SandboxCaptureOptions = {
28
36
  engine?: string;
29
37
  repoRoot?: string;
38
+ runId?: string;
30
39
  containerCandidates?: string[];
31
40
  rows?: SandboxRow[];
32
41
  startContainer?: (name: string) => void;
33
42
  spawn?: (file: string, args: string[]) => Promise<SandboxCaptureResult>;
34
43
  };
35
44
 
36
- async function spawnCapture(file: string, args: string[]): Promise<SandboxCaptureResult> {
45
+ async function spawnCapture(
46
+ file: string,
47
+ args: string[]
48
+ ): Promise<SandboxCaptureResult> {
37
49
  return new Promise((resolve, reject) => {
38
50
  const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'] });
39
51
  let stdout = '';
40
52
  let stderr = '';
53
+ let settled = false;
54
+ const rejectOnce = (error: unknown): void => {
55
+ if (settled) return;
56
+ settled = true;
57
+ reject(error);
58
+ };
59
+ const resolveOnce = (result: SandboxCaptureResult): void => {
60
+ if (settled) return;
61
+ settled = true;
62
+ resolve(result);
63
+ };
41
64
  child.stdout.setEncoding('utf8');
42
65
  child.stderr.setEncoding('utf8');
43
66
  child.stdout.on('data', (chunk) => {
@@ -46,11 +69,106 @@ async function spawnCapture(file: string, args: string[]): Promise<SandboxCaptur
46
69
  child.stderr.on('data', (chunk) => {
47
70
  stderr += chunk;
48
71
  });
49
- child.on('error', reject);
50
- child.on('close', (exitCode, signal) => resolve({ exitCode, signal, stdout, stderr }));
72
+ child.on('error', rejectOnce);
73
+ child.on('close', (exitCode, signal) => {
74
+ resolveOnce({ exitCode, signal, stdout, stderr });
75
+ });
51
76
  });
52
77
  }
53
78
 
79
+ function shellQuote(value: string): string {
80
+ return `'${value.replace(/'/g, `'\\''`)}'`;
81
+ }
82
+
83
+ export function createRunId(date: Date = new Date()): string {
84
+ return `run-${date.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/[-:]/g, '').replace('T', '-').replace('Z', '')}-${process.pid}`;
85
+ }
86
+
87
+ function buildRunScript(params: {
88
+ command: string[];
89
+ runDir: string;
90
+ runId: string;
91
+ }): string {
92
+ const command = params.command.map(shellQuote).join(' ');
93
+ const runDir = shellQuote(params.runDir);
94
+ const runId = shellQuote(params.runId);
95
+ return `#!/bin/sh
96
+ set -u
97
+ cd /workspace
98
+ date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/started_at
99
+ printf '%s\\n' running > ${runDir}/status
100
+ ${command}
101
+ code=$?
102
+ printf '%s\\n' "$code" > ${runDir}/exit_code
103
+ date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/finished_at
104
+ if [ "$code" -eq 0 ]; then
105
+ printf '%s\\n' completed > ${runDir}/status
106
+ else
107
+ printf '%s\\n' failed > ${runDir}/status
108
+ fi
109
+ printf '\\n[agent-infra] run %s finished with exit code %s\\n' ${runId} "$code"
110
+ exec bash -l
111
+ `;
112
+ }
113
+
114
+ function buildTmuxLauncher(params: {
115
+ request: SandboxCaptureRequest;
116
+ runId: string;
117
+ }): string {
118
+ const session = 'work';
119
+ const window = `ai-${params.runId.replace(/^run-/, '').slice(0, 18)}`;
120
+ const runRoot = '/tmp/agent-infra-runs';
121
+ const runDir = `${runRoot}/${params.runId}`;
122
+ const runScript = buildRunScript({ command: params.request.command, runDir, runId: params.runId });
123
+ const runScriptBase64 = Buffer.from(runScript, 'utf8').toString('base64');
124
+ const paneCommand = `cd /workspace && ${shellQuote(`${runDir}/run.sh`)}`;
125
+
126
+ return `set -eu
127
+ session=${shellQuote(session)}
128
+ window=${shellQuote(window)}
129
+ run_id=${shellQuote(params.runId)}
130
+ run_dir=${shellQuote(runDir)}
131
+ task_ref=${shellQuote(params.request.taskRef)}
132
+ branch=${shellQuote(params.request.branch)}
133
+
134
+ sandbox-dotfiles-link >/dev/null 2>&1 || true
135
+ mkdir -p "$run_dir"
136
+ printf '%s\\n' pending > "$run_dir/status"
137
+ printf '%s\\n' "$task_ref" > "$run_dir/task_ref"
138
+ printf '%s\\n' "$branch" > "$run_dir/branch"
139
+ printf '%s\\n' ${shellQuote(params.request.command.join(' '))} > "$run_dir/command"
140
+ printf '%s' ${shellQuote(runScriptBase64)} | base64 -d > "$run_dir/run.sh"
141
+ chmod +x "$run_dir/run.sh"
142
+
143
+ if ! command -v tmux >/dev/null 2>&1; then
144
+ printf '%s\\n' "tmux is not installed in this sandbox" >&2
145
+ exit 127
146
+ fi
147
+
148
+ if ! tmux has-session -t "$session" 2>/dev/null; then
149
+ tmux new-session -d -s "$session" -n shell
150
+ fi
151
+
152
+ if [ -n "\${TZ:-}" ]; then
153
+ tmux set-environment -t "$session" TZ "$TZ" 2>/dev/null || true
154
+ fi
155
+
156
+ pane=$(tmux new-window -d -P -F '#{pane_id}' -t "$session" -n "$window")
157
+ printf '%s\\n' "$session" > "$run_dir/session"
158
+ printf '%s\\n' "$window" > "$run_dir/window"
159
+ printf '%s\\n' "$pane" > "$run_dir/pane"
160
+ tmux pipe-pane -o -t "$pane" "cat > $run_dir/output.log"
161
+ tmux send-keys -t "$pane" ${shellQuote(paneCommand)} Enter
162
+
163
+ cat <<EOF
164
+ Started sandbox run $run_id in tmux session '$session', window '$window', pane '$pane'.
165
+ Attach with: ai sandbox enter $task_ref
166
+ Status file: $run_dir/status
167
+ Output log: $run_dir/output.log
168
+ EOF
169
+ `;
170
+ }
171
+
54
172
  export async function runInSandbox(
55
173
  request: SandboxCaptureRequest,
56
174
  options: SandboxCaptureOptions = {}
@@ -73,12 +191,25 @@ export async function runInSandbox(
73
191
  if (!found.running) {
74
192
  (options.startContainer ?? ((name: string) => startSandboxContainer(engine, name)))(found.name);
75
193
  }
194
+ const runId = options.runId ?? createRunId();
195
+ const runDir = `/tmp/agent-infra-runs/${runId}`;
76
196
  const dockerArgs = [
77
197
  'exec',
78
198
  ...terminalEnvFlags(),
79
199
  ...hostTimezoneEnvFlags(),
80
200
  found.name,
81
- ...request.command
201
+ 'bash',
202
+ '-lc',
203
+ buildTmuxLauncher({ request, runId })
82
204
  ];
83
- return (options.spawn ?? spawnCapture)('docker', dockerArgs);
205
+ const result = await (options.spawn ?? spawnCapture)('docker', dockerArgs);
206
+ return {
207
+ ...result,
208
+ run: {
209
+ runId,
210
+ engine,
211
+ container: found.name,
212
+ runDir
213
+ }
214
+ };
84
215
  }
@@ -915,6 +915,42 @@ function resolveHostCatalogPath(value: unknown, hostHomeDir: string): string | n
915
915
  }
916
916
  }
917
917
 
918
+ const CODEX_DISABLED_FEATURE_FLAGS = ['apps', 'enable_mcp_apps'] as const;
919
+
920
+ function removeCodexMcpServers(sandboxParsed: JsonObject): boolean {
921
+ if (!Object.hasOwn(sandboxParsed, 'mcp_servers')) {
922
+ return false;
923
+ }
924
+ delete sandboxParsed.mcp_servers;
925
+ return true;
926
+ }
927
+
928
+ function inheritDisabledCodexFeatureFlags(sandboxParsed: JsonObject, hostParsed: JsonObject): boolean {
929
+ if (!isJsonObjectRecord(hostParsed.features)) {
930
+ return false;
931
+ }
932
+ if (Object.hasOwn(sandboxParsed, 'features') && !isJsonObjectRecord(sandboxParsed.features)) {
933
+ return false;
934
+ }
935
+
936
+ let sandboxFeatures = sandboxParsed.features as JsonObject | undefined;
937
+ let changed = false;
938
+ for (const key of CODEX_DISABLED_FEATURE_FLAGS) {
939
+ if (hostParsed.features[key] !== false) {
940
+ continue;
941
+ }
942
+ if (!sandboxFeatures) {
943
+ sandboxFeatures = {};
944
+ sandboxParsed.features = sandboxFeatures;
945
+ }
946
+ if (sandboxFeatures[key] !== false) {
947
+ sandboxFeatures[key] = false;
948
+ changed = true;
949
+ }
950
+ }
951
+ return changed;
952
+ }
953
+
918
954
  export function ensureCodexModelInheritance(
919
955
  toolDir: string,
920
956
  hostHomeDir?: string,
@@ -947,13 +983,15 @@ export function ensureCodexModelInheritance(
947
983
  }
948
984
  }
949
985
 
986
+ let changed = removeCodexMcpServers(sandboxParsed);
987
+ changed = inheritDisabledCodexFeatureFlags(sandboxParsed, hostParsed) || changed;
988
+
950
989
  const inheritSpecs: Array<readonly [string, 'string' | 'number']> = [
951
990
  ['model', 'string'],
952
991
  ['model_reasoning_effort', 'string'],
953
992
  ['model_auto_compact_token_limit', 'number']
954
993
  ];
955
994
 
956
- let changed = false;
957
995
  for (const [key, type] of inheritSpecs) {
958
996
  if (Object.hasOwn(sandboxParsed, key)) {
959
997
  continue;
@@ -1438,9 +1476,9 @@ export async function create(args: string[]): Promise<void> {
1438
1476
  if (claudeCodeEntry) {
1439
1477
  ensureClaudeOnboarding(claudeCodeEntry.dir, effectiveConfig.home);
1440
1478
  ensureClaudeSettings(claudeCodeEntry.dir, effectiveConfig.home);
1441
- // prepareClaudeCredentials wrote the shared credentials file
1442
- // before this point. If credentials were missing, the
1443
- // claude-code entry was removed from effectiveResolvedTools.
1479
+ // prepareClaudeCredentials wrote OAuth credentials or confirmed
1480
+ // provider/API settings are enough for Claude Code. If no usable
1481
+ // auth was present, the claude-code entry was removed above.
1444
1482
  }
1445
1483
  const codexEntry = effectiveResolvedTools.find(({ tool }) => tool.id === 'codex');
1446
1484
  if (codexEntry) {
@@ -9,6 +9,7 @@ import { detectEngine } from '../engine.ts';
9
9
  import {
10
10
  formatCredentialWarnings,
11
11
  formatRemaining,
12
+ hasClaudeProviderAuth,
12
13
  reconcileClaudeCredentials,
13
14
  redactCommandError,
14
15
  validateClaudeCredentialsEnvOverride
@@ -93,8 +94,12 @@ export function runSandboxInteractive(params: {
93
94
 
94
95
  export function formatCredentialSyncStatus(
95
96
  result: ReturnType<typeof reconcileClaudeCredentials>,
96
- isTTY = process.stderr.isTTY
97
+ isTTY = process.stderr.isTTY,
98
+ providerAuthAvailable = false
97
99
  ): string | null {
100
+ if (providerAuthAvailable && (result.status === 'STALE_ACCESS' || result.status === 'MISSING')) {
101
+ return null;
102
+ }
98
103
  if (result.status === 'STALE_ACCESS') {
99
104
  return 'Warning: Claude Code credentials on host appear stale. Run "ai sandbox refresh" or "claude /login" to renew.\n';
100
105
  }
@@ -158,8 +163,9 @@ export async function enter(args: string[]): Promise<number> {
158
163
  if (config.tools.includes('claude-code')) {
159
164
  try {
160
165
  // Scan all projects so a refresh from a neighbouring sandbox can still flow back to the host.
166
+ const providerAuthAvailable = hasClaudeProviderAuth(config.home);
161
167
  const result = reconcileClaudeCredentials(config.home);
162
- const message = formatCredentialSyncStatus(result);
168
+ const message = formatCredentialSyncStatus(result, process.stderr.isTTY, providerAuthAvailable);
163
169
  if (message) {
164
170
  process.stderr.write(message);
165
171
  }
@@ -12,7 +12,7 @@ import type { SandboxTool } from './tools.ts';
12
12
  const DEFAULTS = Object.freeze({
13
13
  engine: null,
14
14
  runtimes: ['node22'],
15
- tools: ['claude-code', 'codex', 'gemini-cli', 'opencode'],
15
+ tools: ['agent-infra', 'claude-code', 'codex', 'gemini-cli', 'opencode'],
16
16
  dockerfile: null,
17
17
  vm: {
18
18
  cpu: null,
@@ -55,6 +55,11 @@ type InspectOptions = {
55
55
  envFn?: EnvFn;
56
56
  };
57
57
 
58
+ type ProviderAuthOptions = {
59
+ readFn?: ReadFn;
60
+ existsFn?: ExistsFn;
61
+ };
62
+
58
63
  type WriteHostOptions = {
59
64
  execFn?: ExecFn;
60
65
  mkdirFn?: typeof fs.mkdirSync;
@@ -114,6 +119,14 @@ const REDACTION_PATTERNS = [
114
119
  { pattern: /Bearer\s+[A-Za-z0-9._~+/=-]{20,}/gi, replacement: 'Bearer [REDACTED]' }
115
120
  ];
116
121
 
122
+ function isRecord(value: unknown): value is Record<string, unknown> {
123
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
124
+ }
125
+
126
+ function hasNonEmptyString(value: unknown): boolean {
127
+ return typeof value === 'string' && value.trim().length > 0;
128
+ }
129
+
117
130
  function errorMessage(error: unknown): string {
118
131
  return error instanceof Error ? error.message : 'unknown error';
119
132
  }
@@ -219,6 +232,35 @@ export function validateClaudeCredentialsEnvOverride(env: NodeJS.ProcessEnv = pr
219
232
  }
220
233
  }
221
234
 
235
+ export function hasClaudeProviderAuthInSettings(settings: unknown): boolean {
236
+ if (!isRecord(settings)) {
237
+ return false;
238
+ }
239
+
240
+ const env = isRecord(settings.env) ? settings.env : {};
241
+ return hasNonEmptyString(env.ANTHROPIC_AUTH_TOKEN)
242
+ || hasNonEmptyString(env.ANTHROPIC_API_KEY)
243
+ || hasNonEmptyString(settings.apiKeyHelper);
244
+ }
245
+
246
+ export function hasClaudeProviderAuth(home: string, options: ProviderAuthOptions = {}): boolean {
247
+ const {
248
+ readFn = (targetPath) => fs.readFileSync(targetPath, 'utf8'),
249
+ existsFn = fs.existsSync
250
+ } = options;
251
+ const settingsPath = path.join(home, '.claude', 'settings.json');
252
+
253
+ if (!existsFn(settingsPath)) {
254
+ return false;
255
+ }
256
+
257
+ try {
258
+ return hasClaudeProviderAuthInSettings(JSON.parse(readFn(settingsPath)));
259
+ } catch {
260
+ return false;
261
+ }
262
+ }
263
+
222
264
  // Reconcile treats the freshest valid endpoint as authoritative so sandbox
223
265
  // token rotations can flow back to the host credential store.
224
266
  function validateClaudeCredentialsBlob(raw: unknown, blob: string | null = null): CredentialInspection {
@@ -595,13 +637,22 @@ export function prepareClaudeCredentials(
595
637
  resolvedTools: ResolvedTool[],
596
638
  extractFn: (home: string) => string | null = extractClaudeCredentialsBlob,
597
639
  writeFn: (home: string, project: string, blob: string) => void = writeClaudeCredentialsFile,
598
- inspectFn: (home: string) => CredentialInspection = inspectClaudeKeychainStatus
640
+ inspectFn: (home: string) => CredentialInspection = inspectClaudeKeychainStatus,
641
+ providerAuthFn: (home: string) => boolean = hasClaudeProviderAuth
599
642
  ): ClaudeCredentialOutcome {
600
643
  const claudeCodeEntry = resolvedTools.find(({ tool }) => tool.id === 'claude-code');
601
644
  if (!claudeCodeEntry) {
602
645
  return { status: 'NOT_APPLICABLE' };
603
646
  }
604
647
 
648
+ const providerAuthAvailable = () => {
649
+ try {
650
+ return providerAuthFn(home);
651
+ } catch {
652
+ return false;
653
+ }
654
+ };
655
+
605
656
  let blob: string | null = null;
606
657
  const hasCustomInspectFn = inspectFn !== inspectClaudeKeychainStatus;
607
658
  const hasCustomExtractFn = extractFn !== extractClaudeCredentialsBlob;
@@ -612,7 +663,7 @@ export function prepareClaudeCredentials(
612
663
  blob = inspection.blob;
613
664
  break;
614
665
  case 'MISSING':
615
- return { status: 'SKIPPED' };
666
+ return providerAuthAvailable() ? { status: 'OK' } : { status: 'SKIPPED' };
616
667
  case 'KEYCHAIN_LOCKED':
617
668
  throw new Error([
618
669
  'Claude Code credentials are stored in the macOS keychain, but the keychain is locked.',
@@ -620,6 +671,9 @@ export function prepareClaudeCredentials(
620
671
  buildLockedGuidance()
621
672
  ].join('\n'));
622
673
  case 'STALE_ACCESS':
674
+ if (providerAuthAvailable()) {
675
+ return { status: 'OK' };
676
+ }
623
677
  throw new Error([
624
678
  'Claude Code credentials on host are invalid or expired.',
625
679
  '',
@@ -648,7 +702,7 @@ export function prepareClaudeCredentials(
648
702
  } else {
649
703
  blob = extractFn(home);
650
704
  if (!blob) {
651
- return { status: 'SKIPPED' };
705
+ return providerAuthAvailable() ? { status: 'OK' } : { status: 'SKIPPED' };
652
706
  }
653
707
  }
654
708
 
@@ -61,6 +61,19 @@ RUN cat > /usr/local/bin/cc-token-status <<'SCRIPT' && chmod +x /usr/local/bin/c
61
61
  #!/bin/sh
62
62
  set -eu
63
63
 
64
+ SETTINGS_FILE="/home/devuser/.claude/settings.json"
65
+ if [ -r "$SETTINGS_FILE" ] && jq -e '
66
+ def has_nonempty($v):
67
+ if ($v | type) == "string" then ($v | gsub("^\\s+|\\s+$"; "") | length) > 0 else false end;
68
+ (if type == "object" then . else {} end) as $settings
69
+ | (if ($settings.env | type) == "object" then $settings.env else {} end) as $env
70
+ | has_nonempty($env.ANTHROPIC_AUTH_TOKEN)
71
+ or has_nonempty($env.ANTHROPIC_API_KEY)
72
+ or has_nonempty($settings.apiKeyHelper)
73
+ ' "$SETTINGS_FILE" >/dev/null 2>&1; then
74
+ exit 0
75
+ fi
76
+
64
77
  CRED_FILE="/home/devuser/.claude/.credentials.json"
65
78
  [ -r "$CRED_FILE" ] || exit 0
66
79
 
@@ -1,3 +1,3 @@
1
- RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
1
+ RUN bash -o pipefail -c 'curl -fsSL --retry 5 --retry-delay 2 --retry-all-errors https://deb.nodesource.com/setup_20.x | bash -' && \
2
2
  apt-get install -y nodejs && \
3
3
  rm -rf /var/lib/apt/lists/*
@@ -1,3 +1,3 @@
1
- RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
1
+ RUN bash -o pipefail -c 'curl -fsSL --retry 5 --retry-delay 2 --retry-all-errors https://deb.nodesource.com/setup_22.x | bash -' && \
2
2
  apt-get install -y nodejs && \
3
3
  rm -rf /var/lib/apt/lists/*
@@ -124,6 +124,15 @@ function createBuiltinTools(home: string, project: string): Record<string, Sandb
124
124
  { hostPath: hostJoin(home, '.gemini', 'settings.json'), sandboxName: 'settings.json' },
125
125
  { hostPath: hostJoin(home, '.gemini', 'google_accounts.json'), sandboxName: 'google_accounts.json' }
126
126
  ]
127
+ },
128
+ 'agent-infra': {
129
+ id: 'agent-infra',
130
+ name: 'agent-infra CLI',
131
+ install: { type: 'npm', cmd: '@fitlab-ai/agent-infra@latest' },
132
+ sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'agent-infra'),
133
+ containerMount: '/home/devuser/.agent-infra-cli',
134
+ versionCmd: 'ai version --raw',
135
+ setupHint: 'Provides the ai and agent-infra CLI commands inside the sandbox.'
127
136
  }
128
137
  };
129
138
  }
@@ -1,5 +1,5 @@
1
1
  import type { Adapter, AdapterCtx, AdapterFactory, InboundMessage } from '../_contract.ts';
2
- import { createFeishuTransport, normalizeMessage } from './transport.ts';
2
+ import { cardMessage, createFeishuTransport, normalizeMessage } from './transport.ts';
3
3
  import type { FeishuTransport } from './transport.ts';
4
4
 
5
5
  // Assemble the feishu adapter. The transport is injectable so unit tests can
@@ -25,7 +25,9 @@ export function createFeishuAdapter(
25
25
  text: normalized.text,
26
26
  messageId: normalized.messageId,
27
27
  raw: normalized.raw,
28
- reply: (text) => transport.send(normalized.chatId, text)
28
+ reply: async (text) => {
29
+ await transport.send(normalized.chatId, cardMessage(text));
30
+ }
29
31
  };
30
32
  await adapterCtx.dispatch(message);
31
33
  } catch (error) {
@@ -37,7 +39,7 @@ export function createFeishuAdapter(
37
39
  await transport.stop();
38
40
  },
39
41
  async sendMessage(target, text) {
40
- await transport.send(target.chatId, text);
42
+ await transport.send(target.chatId, cardMessage(text));
41
43
  }
42
44
  };
43
45
  }
@@ -1,4 +1,5 @@
1
1
  import * as lark from '@larksuiteoapi/node-sdk';
2
+ import { stripVTControlCharacters } from 'node:util';
2
3
 
3
4
  // Transport layer for the feishu adapter. All @larksuiteoapi/node-sdk surface is
4
5
  // confined here so the adapter body (index.ts) depends only on this narrow
@@ -10,7 +11,15 @@ export type FeishuTransport = {
10
11
  // each inbound im.message.receive_v1.
11
12
  start: (onMessage: (raw: unknown) => Promise<void>) => Promise<void>;
12
13
  stop: () => Promise<void>;
13
- send: (chatId: string, text: string) => Promise<void>;
14
+ send: (chatId: string, message: FeishuOutgoingMessage) => Promise<void>;
15
+ };
16
+
17
+ export type FeishuOutgoingMessage = { kind: 'interactive'; title: string; text: string };
18
+
19
+ type FeishuCreateData = {
20
+ receive_id: string;
21
+ msg_type: 'interactive';
22
+ content: string;
14
23
  };
15
24
 
16
25
  export type NormalizedMessage = {
@@ -69,6 +78,26 @@ function resolveDomain(value: unknown): number {
69
78
  return value === 'lark' || value === 'Lark' ? lark.Domain.Lark : lark.Domain.Feishu;
70
79
  }
71
80
 
81
+ export function cleanFeishuText(text: string): string {
82
+ return stripVTControlCharacters(text).replace(/\r\n/g, '\n');
83
+ }
84
+
85
+ export function cardMessage(text: string): FeishuOutgoingMessage {
86
+ return { kind: 'interactive', title: 'agent-infra', text: cleanFeishuText(text) };
87
+ }
88
+
89
+ export function toFeishuCreateData(chatId: string, message: FeishuOutgoingMessage): FeishuCreateData {
90
+ return {
91
+ receive_id: chatId,
92
+ msg_type: 'interactive',
93
+ content: JSON.stringify({
94
+ config: { wide_screen_mode: true },
95
+ header: { title: { tag: 'plain_text', content: message.title }, template: 'blue' },
96
+ elements: [{ tag: 'div', text: { tag: 'lark_md', content: message.text } }]
97
+ })
98
+ };
99
+ }
100
+
72
101
  // Build the real SDK-backed transport from adapter config. appId/appSecret come
73
102
  // from server config (appSecret only via server.local.json / env). The WSClient
74
103
  // long connection delivers events; the Client REST call sends replies.
@@ -118,10 +147,10 @@ export function createFeishuTransport(config: Record<string, unknown>): FeishuTr
118
147
  // Ignore: the daemon is shutting down regardless.
119
148
  }
120
149
  },
121
- async send(chatId, text) {
150
+ async send(chatId, message) {
122
151
  await client.im.message.create({
123
152
  params: { receive_id_type: 'chat_id' },
124
- data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) }
153
+ data: toFeishuCreateData(chatId, message)
125
154
  });
126
155
  }
127
156
  };
@@ -23,10 +23,26 @@ function taskSubcommand(subcommand: string): string {
23
23
  export function commandHelp(): string {
24
24
  return [
25
25
  `agent-infra ${VERSION}`,
26
- 'Built-ins: /help, /ping, /version',
27
- 'Read: /sandbox ls|show|vm status, /task decisions|log|ls|show|status',
28
- 'Write: /sandbox create|start',
29
- 'Exec: /decide <task-ref> <HD-id> <decision>, /run create-task <description>, /run <skill> <task-ref> ...'
26
+ 'Built-ins:',
27
+ '/help',
28
+ '/ping',
29
+ '/version',
30
+ 'Read:',
31
+ '/sandbox ls',
32
+ '/sandbox show {ref}',
33
+ '/sandbox vm status',
34
+ '/task decisions {ref}',
35
+ '/task log {ref}',
36
+ '/task ls',
37
+ '/task show {ref}',
38
+ '/task status {ref}',
39
+ 'Write:',
40
+ '/sandbox create {ref}',
41
+ '/sandbox start {ref}',
42
+ 'Exec:',
43
+ '/decide {task-ref} {HD-id} {decision}',
44
+ '/run create-task {description}',
45
+ '/run {skill} {task-ref}'
30
46
  ].join('\n');
31
47
  }
32
48
 
@@ -13,7 +13,7 @@ completion time (or '(in progress)' while still running).
13
13
  Columns: # (row) / STEP / AGENT / STARTED / DONE / NOTE
14
14
  A human-executed step shows AGENT as 'human' and, when it has no start marker,
15
15
  a '-' STARTED placeholder. Review-step NOTE also carries two human counts in
16
- the verdict list, right after blockers/major/minor: manual-verify (env-blocked)
16
+ the verdict list, right after blockers/major/minor: manual-validation
17
17
  and human-decision (current ledger stage total).
18
18
  `;
19
19
 
@@ -46,10 +46,6 @@ const STARTED_SUFFIX_RE = /\s*\[started\]\s*$/;
46
46
  // note these differ from the `.airc.json` long names claude-code/gemini-cli).
47
47
  // Any other executor token (a human name, possibly CJK) is treated as human.
48
48
  const KNOWN_AI_AGENTS = new Set(['claude', 'codex', 'gemini', 'opencode', 'cursor']);
49
- const ENV_BLOCKED_RE = /\(\+\s*(\d+)\s+env-blocked\)/i;
50
- // Same match plus any leading whitespace, so folding the count into the verdict
51
- // text drops the redundant `(+ n env-blocked)` fragment without leaving a gap.
52
- const ENV_BLOCKED_STRIP_RE = /\s*\(\+\s*\d+\s+env-blocked\)/i;
53
49
  const REVIEW_STAGE_PREFIXES: { prefix: string; stage: ReviewStage }[] = [
54
50
  { prefix: 'Review Analysis', stage: 'analysis' },
55
51
  { prefix: 'Review Plan', stage: 'plan' },
@@ -126,9 +122,31 @@ function reviewStageForStep(step: string): ReviewStage | undefined {
126
122
  return REVIEW_STAGE_PREFIXES.find(({ prefix }) => step.startsWith(prefix))?.stage;
127
123
  }
128
124
 
125
+ function splitArtifactSuffix(note: string): { verdict: string; suffix: string } {
126
+ const arrow = note.indexOf(' → ');
127
+ return arrow === -1 ? { verdict: note, suffix: '' } : { verdict: note.slice(0, arrow), suffix: note.slice(arrow) };
128
+ }
129
+
130
+ function fieldNumber(field: string, label: string): number | undefined {
131
+ const trimmed = field.trim();
132
+ const colon = trimmed.indexOf(':');
133
+ if (colon === -1) return undefined;
134
+ if (trimmed.slice(0, colon).trim().toLowerCase() !== label) return undefined;
135
+ const value = Number(trimmed.slice(colon + 1).trim());
136
+ return Number.isInteger(value) && value >= 0 ? value : undefined;
137
+ }
138
+
129
139
  function humanValidationCount(note: string): number {
130
- const match = ENV_BLOCKED_RE.exec(note);
131
- return match ? Number(match[1]) : 0;
140
+ const { verdict } = splitArtifactSuffix(note);
141
+ for (const field of verdict.split(',')) {
142
+ const value = fieldNumber(field, 'manual-validation');
143
+ if (value !== undefined) return value;
144
+ }
145
+ return 0;
146
+ }
147
+
148
+ function isHumanCountField(field: string): boolean {
149
+ return fieldNumber(field, 'manual-validation') !== undefined || fieldNumber(field, 'human-decision') !== undefined;
132
150
  }
133
151
 
134
152
  // A step is human-executed when its agent token is not a known AI token. Take
@@ -141,13 +159,17 @@ function isHumanAgent(agent: string): boolean {
141
159
 
142
160
  // Fold the two human counts into a review row's verdict NOTE: comma-joined, right
143
161
  // after the blockers/major/minor list and before the ` → artifact` link, mirroring
144
- // the review count line. The raw `(+ n env-blocked)` fragment is dropped so the
145
- // env-blocked number is not shown twice (it becomes the manual-verify count).
146
- function foldHumanCounts(note: string, decisions: number, envBlocked: number): string {
147
- const base = note.replace(ENV_BLOCKED_STRIP_RE, '');
148
- const group = `Manual-verify: ${envBlocked}, Human-decision: ${decisions}`;
149
- const arrow = base.indexOf(' → ');
150
- return arrow === -1 ? `${base}, ${group}` : `${base.slice(0, arrow)}, ${group}${base.slice(arrow)}`;
162
+ // the review count line. Review done notes already carry `Manual-validation` as a
163
+ // source field, so build the final verdict field list once instead of cleaning a
164
+ // previously rendered string.
165
+ function foldHumanCounts(note: string, decisions: number, manualValidation: number): string {
166
+ const { verdict, suffix } = splitArtifactSuffix(note);
167
+ const fields = verdict
168
+ .split(',')
169
+ .map((field) => field.trim())
170
+ .filter((field) => field !== '' && !isHumanCountField(field));
171
+ const group = `Manual-validation: ${manualValidation}, Human-decision: ${decisions}`;
172
+ return `${[...fields, group].join(', ')}${suffix}`;
151
173
  }
152
174
 
153
175
  function log(args: string[] = []): void {