@fitlab-ai/agent-infra 0.8.4 → 0.8.5

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 (51) hide show
  1. package/dist/lib/defaults.json +8 -0
  2. package/dist/lib/init.js +5 -0
  3. package/dist/lib/sandbox/commands/create.js +30 -4
  4. package/dist/lib/sandbox/engine.js +33 -3
  5. package/dist/lib/sandbox/engines/colima.js +2 -2
  6. package/dist/lib/sandbox/runtimes/base.dockerfile +4 -0
  7. package/dist/lib/server/daemon.js +9 -0
  8. package/dist/lib/server/process-control.js +66 -73
  9. package/dist/lib/server/process-state.js +168 -0
  10. package/dist/lib/update.js +2 -0
  11. package/lib/defaults.json +8 -0
  12. package/lib/init.ts +9 -0
  13. package/lib/sandbox/commands/create.ts +32 -3
  14. package/lib/sandbox/engine.ts +37 -3
  15. package/lib/sandbox/engines/colima.ts +2 -2
  16. package/lib/sandbox/runtimes/base.dockerfile +4 -0
  17. package/lib/server/daemon.ts +9 -0
  18. package/lib/server/process-control.ts +70 -71
  19. package/lib/server/process-state.ts +183 -0
  20. package/lib/update.ts +6 -0
  21. package/package.json +2 -1
  22. package/runtime/platform-adapters/platform-sync.github.js +1501 -0
  23. package/templates/.agents/README.en.md +2 -0
  24. package/templates/.agents/README.zh-CN.md +2 -0
  25. package/templates/.agents/rules/review-handshake.en.md +5 -4
  26. package/templates/.agents/rules/review-handshake.zh-CN.md +5 -4
  27. package/templates/.agents/scripts/lib/agent-infra-package.js +149 -0
  28. package/templates/.agents/scripts/lib/post-review-commit.js +5 -0
  29. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +25 -1459
  30. package/templates/.agents/scripts/review-diff-fingerprint.js +95 -24
  31. package/templates/.agents/scripts/validate-artifact.js +68 -55
  32. package/templates/.agents/skills/commit/SKILL.en.md +5 -3
  33. package/templates/.agents/skills/commit/SKILL.zh-CN.md +5 -3
  34. package/templates/.agents/skills/commit/reference/task-status-update.en.md +29 -2
  35. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +29 -2
  36. package/templates/.agents/skills/complete-task/SKILL.en.md +2 -2
  37. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
  38. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
  39. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
  40. package/templates/.agents/skills/init-milestones/SKILL.en.md +2 -2
  41. package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +2 -2
  42. package/templates/.agents/skills/init-milestones/scripts/init-milestones.github.sh +5 -4
  43. package/templates/.agents/skills/review-code/SKILL.en.md +2 -2
  44. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +2 -2
  45. package/templates/.agents/skills/review-code/config/verify.en.json +2 -1
  46. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +2 -1
  47. package/templates/.agents/skills/review-code/reference/report-template.en.md +2 -1
  48. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +2 -1
  49. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +5 -1
  50. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +5 -1
  51. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +174 -136
@@ -87,13 +87,27 @@ alias gy='gemini --yolo; tput ed'
87
87
  `;
88
88
  const CONTAINER_HOME = '/home/devuser';
89
89
  const CONTAINER_SHELL_CONFIG_MOUNT = `${CONTAINER_HOME}/.host-shell-config`;
90
- const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh]
90
+ const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh] [--inherit-proxy|-P]
91
91
 
92
92
  Host aliases:
93
93
  ${'~'}/.agent-infra/aliases/sandbox.sh is auto-created on first run and exposed
94
94
  as ${CONTAINER_HOME}/.bash_aliases inside the sandbox container (the host
95
95
  shell-config directory is bind-mounted at ${CONTAINER_SHELL_CONFIG_MOUNT} and
96
- symlinked into $HOME).`;
96
+ symlinked into $HOME).
97
+
98
+ Proxy:
99
+ --inherit-proxy, -P Copy non-empty standard host proxy variables into the
100
+ container environment through the private env file.`;
101
+ const HOST_PROXY_ENV_KEYS = [
102
+ 'http_proxy',
103
+ 'HTTP_PROXY',
104
+ 'https_proxy',
105
+ 'HTTPS_PROXY',
106
+ 'all_proxy',
107
+ 'ALL_PROXY',
108
+ 'no_proxy',
109
+ 'NO_PROXY'
110
+ ] as const;
97
111
 
98
112
  type SandboxCreateConfig = ReturnType<typeof loadConfig>;
99
113
  type PreparedDockerfile = ReturnType<typeof prepareDockerfile>;
@@ -626,11 +640,20 @@ function formatEnvFileEntry(key: string, value: string): string {
626
640
  return `${key}=${value}`;
627
641
  }
628
642
 
643
+ export function collectHostProxyEntries(env: NodeJS.ProcessEnv): Array<[string, string]> {
644
+ const entriesByExactKey = new Map(Object.entries(env));
645
+ return HOST_PROXY_ENV_KEYS.flatMap((key) => {
646
+ const value = entriesByExactKey.get(key);
647
+ return typeof value === 'string' && value !== '' ? [[key, value]] : [];
648
+ });
649
+ }
650
+
629
651
  export function buildContainerEnvFile(
630
652
  resolvedTools: ResolvedTool[],
631
653
  engine: string,
632
654
  runSafeEngineFn: EngineRunSafeFn = runSafeEngine,
633
655
  options: {
656
+ additionalEntries?: Array<[string, string]>;
634
657
  mkdtempFn?: typeof fs.mkdtempSync;
635
658
  writeFileFn?: typeof fs.writeFileSync;
636
659
  chmodFn?: typeof fs.chmodSync;
@@ -640,6 +663,7 @@ export function buildContainerEnvFile(
640
663
  } = {}
641
664
  ): { dockerArgs: string[]; cleanup: () => void } {
642
665
  const {
666
+ additionalEntries = [],
643
667
  mkdtempFn = fs.mkdtempSync,
644
668
  writeFileFn = fs.writeFileSync,
645
669
  chmodFn = fs.chmodSync,
@@ -649,6 +673,7 @@ export function buildContainerEnvFile(
649
673
  } = options;
650
674
 
651
675
  const entries: Array<[string, string]> = resolvedTools.flatMap(({ tool }) => Object.entries(tool.envVars ?? {}));
676
+ entries.push(...additionalEntries);
652
677
  let ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
653
678
  if (!ghToken && engine === 'wsl2') {
654
679
  ghToken = runSafeFn('gh', ['auth', 'token']);
@@ -1246,6 +1271,7 @@ export async function create(args: string[]): Promise<void> {
1246
1271
  cpu: { type: 'string' },
1247
1272
  memory: { type: 'string' },
1248
1273
  'no-refresh': { type: 'boolean' },
1274
+ 'inherit-proxy': { type: 'boolean', short: 'P' },
1249
1275
  help: { type: 'boolean', short: 'h' }
1250
1276
  }
1251
1277
  });
@@ -1503,7 +1529,10 @@ export async function create(args: string[]): Promise<void> {
1503
1529
  signingKey
1504
1530
  )
1505
1531
  : null;
1506
- const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
1532
+ const proxyEntries = values['inherit-proxy'] ? collectHostProxyEntries(process.env) : [];
1533
+ const envFile = buildContainerEnvFile(effectiveResolvedTools, engine, undefined, {
1534
+ additionalEntries: proxyEntries
1535
+ });
1507
1536
  let hostShellConfig: HostShellConfig;
1508
1537
  let tmpfsSeedPlan: TmpfsSeedPlanEntry[] = [];
1509
1538
  try {
@@ -2,7 +2,7 @@ import { platform } from 'node:os';
2
2
  import { detectHostResources } from './constants.ts';
3
3
  import { ADAPTERS, enginesForPlatform, getAdapter } from './engines/index.ts';
4
4
  import type { EffectiveSandboxConfig, OnMessage, RunFns, SandboxAdapter, SandboxVmConfig } from './engines/index.ts';
5
- import { run, runOk, runSafe, runVerbose } from './shell.ts';
5
+ import { run, runOk, runOkEngine, runSafe, runVerbose } from './shell.ts';
6
6
 
7
7
  export const ENGINES = Object.freeze({
8
8
  COLIMA: 'colima',
@@ -26,6 +26,7 @@ type EngineDependencies = {
26
26
  platformFn?: typeof platform;
27
27
  runFn?: RunFns['run'];
28
28
  runOkFn?: RunFns['runOk'];
29
+ runOkEngineFn?: typeof runOkEngine;
29
30
  runSafeFn?: RunFns['runSafe'];
30
31
  runVerboseFn?: RunFns['runVerbose'];
31
32
  };
@@ -50,6 +51,37 @@ function applyDockerContext(adapter: SandboxAdapter): void {
50
51
  }
51
52
  }
52
53
 
54
+ function buildKitError(engine: string): Error {
55
+ let repair: string;
56
+ switch (engine) {
57
+ case ENGINES.COLIMA:
58
+ repair = 'Install the Docker Buildx plugin with: brew install docker-buildx';
59
+ break;
60
+ case ENGINES.DOCKER_DESKTOP:
61
+ case ENGINES.WSL2:
62
+ repair = 'Upgrade or repair Docker Desktop so Docker Buildx and BuildKit are available.';
63
+ break;
64
+ case ENGINES.NATIVE:
65
+ repair = 'Install the Docker Buildx plugin for your Docker Engine installation.';
66
+ break;
67
+ default:
68
+ repair = `Upgrade or repair ${engineDisplayName(engine)} so Docker Buildx and BuildKit are available.`;
69
+ }
70
+
71
+ return new Error([
72
+ `Docker BuildKit is not available for ${engineDisplayName(engine)}.`,
73
+ repair,
74
+ 'Verify the builder with: docker buildx inspect --bootstrap',
75
+ 'Then retry the sandbox create or rebuild command.'
76
+ ].join('\n'));
77
+ }
78
+
79
+ function ensureBuildKit(engine: string, runOkEngineFn: typeof runOkEngine): void {
80
+ if (!runOkEngineFn(engine, 'docker', ['buildx', 'inspect', '--bootstrap'])) {
81
+ throw buildKitError(engine);
82
+ }
83
+ }
84
+
53
85
  export function validateSandboxEngine(
54
86
  engine: string | null | undefined,
55
87
  { platformFn = platform }: Pick<EngineDependencies, 'platformFn'> = {}
@@ -144,10 +176,12 @@ export async function ensureDocker(
144
176
  const engine = detectEngine(config, dependencies);
145
177
  const adapter = getAdapter(engine);
146
178
  const effectiveConfig = effectiveConfigFor(adapter, config);
179
+ const fns = runFns(dependencies);
147
180
 
148
181
  applyDockerContext(adapter);
149
- const vmJustStarted = await adapter.ensure(effectiveConfig, onMessage, runFns(dependencies));
150
- adapter.syncResources(effectiveConfig, onMessage, runFns(dependencies), { vmJustStarted });
182
+ const vmJustStarted = await adapter.ensure(effectiveConfig, onMessage, fns);
183
+ ensureBuildKit(engine, dependencies.runOkEngineFn ?? runOkEngine);
184
+ adapter.syncResources(effectiveConfig, onMessage, fns, { vmJustStarted });
151
185
  }
152
186
 
153
187
  export function isVmManaged(config: EngineConfig = {}, dependencies: EngineDependencies = {}): boolean {
@@ -35,8 +35,8 @@ export const colimaAdapter: SandboxAdapter = {
35
35
  let started = false;
36
36
 
37
37
  if (!runOk('which', ['colima'])) {
38
- onMessage?.('Installing colima + docker via Homebrew...');
39
- runVerbose('brew', ['install', 'colima', 'docker']);
38
+ onMessage?.('Installing colima + docker + docker-buildx via Homebrew...');
39
+ runVerbose('brew', ['install', 'colima', 'docker', 'docker-buildx']);
40
40
  }
41
41
 
42
42
  if (!runOk('colima', ['status'])) {
@@ -191,6 +191,10 @@ fi
191
191
  exec tmux new-session -s "$SESSION"
192
192
  SCRIPT
193
193
 
194
+ RUN test -s /usr/local/bin/cc-token-status && test -x /usr/local/bin/cc-token-status && \
195
+ test -s /usr/local/bin/sandbox-dotfiles-link && test -x /usr/local/bin/sandbox-dotfiles-link && \
196
+ test -s /usr/local/bin/sandbox-tmux-entry && test -x /usr/local/bin/sandbox-tmux-entry
197
+
194
198
  ENV LANG=en_US.UTF-8
195
199
  ENV LC_ALL=en_US.UTF-8
196
200
  ENV TERM=xterm-256color
@@ -12,6 +12,7 @@ import type { RunnerOptions, RunnerResult } from './runner.ts';
12
12
  import { streamCommand } from './streamer.ts';
13
13
  import { markdownMessage, replyOutbound, textMessage } from './display.ts';
14
14
  import { buildStatusModel, statusModelToDisplay, type StatusModel } from '../task/commands/status.ts';
15
+ import { getProcessStartTime, removePidRecordIfMatches } from './process-state.ts';
15
16
 
16
17
  function errorMessage(error: unknown): string {
17
18
  return error instanceof Error ? error.message : String(error);
@@ -110,6 +111,7 @@ export async function runDaemon(): Promise<void> {
110
111
 
111
112
  const logger = createLogger(config.log);
112
113
  logger.info(`daemon starting agent-infra ${VERSION} pid=${process.pid}`);
114
+ const ownStartTime = getProcessStartTime(process.pid);
113
115
 
114
116
  const abortController = new AbortController();
115
117
  let resolveShutdown: () => void = () => {};
@@ -135,6 +137,13 @@ export async function runDaemon(): Promise<void> {
135
137
  await unloadAdapters(adapters);
136
138
  clearInterval(heartbeat);
137
139
  logger.close();
140
+ if (ownStartTime !== null) {
141
+ removePidRecordIfMatches(config.pidFile, {
142
+ version: 1,
143
+ pid: process.pid,
144
+ startTime: ownStartTime
145
+ });
146
+ }
138
147
  resolveShutdown();
139
148
  })();
140
149
  };
@@ -1,9 +1,19 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
  import { spawn, execFileSync } from 'node:child_process';
4
3
  import { loadServerConfig } from './config.ts';
5
4
  import type { ServerConfig } from './config.ts';
6
5
  import { runDaemon } from './daemon.ts';
6
+ import {
7
+ getProcessStartTime,
8
+ isProcessAlive,
9
+ processIdentityMatches,
10
+ readProcessState,
11
+ removePidFileIfMatches,
12
+ writePidRecord
13
+ } from './process-state.ts';
14
+ import type { PidRecord } from './process-state.ts';
15
+
16
+ export { isProcessAlive } from './process-state.ts';
7
17
 
8
18
  export type StartOptions = { foreground?: boolean };
9
19
  export type LogsOptions = { follow?: boolean };
@@ -20,59 +30,33 @@ export function buildStopCommand(pid: number, platform: NodeJS.Platform): StopCo
20
30
  }
21
31
  return { kind: 'signal', signal: 'SIGTERM' };
22
32
  }
23
-
24
-
25
- // Liveness check that treats an exited-but-unreaped daemon as dead.
26
- //
27
- // The daemon is spawned detached and re-parented to init when `ai server start`
28
- // exits. After it terminates, `process.kill(pid, 0)` still succeeds until init
29
- // reaps the zombie. On Linux we therefore also check /proc state so status/stop
30
- // /start don't treat an already-exited daemon as running. macOS reaps orphans
31
- // via launchd and Windows has no zombies, so the kill(pid, 0) result is enough.
32
- export function isProcessAlive(pid: number): boolean {
33
- try {
34
- process.kill(pid, 0);
35
- } catch (error) {
36
- // EPERM means the process exists but we may not signal it → still alive.
37
- return (error as NodeJS.ErrnoException).code === 'EPERM';
38
- }
39
- if (process.platform === 'linux') {
40
- try {
41
- const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
42
- // The process state is the first token after the parenthesised comm.
43
- const state = stat.slice(stat.lastIndexOf(')') + 1).trim().charAt(0);
44
- if (state === 'Z') return false;
45
- } catch {
46
- return false; // /proc entry vanished → not alive
47
- }
48
- }
49
- return true;
33
+ function delay(ms: number): Promise<void> {
34
+ return new Promise((resolve) => {
35
+ setTimeout(resolve, ms);
36
+ });
50
37
  }
51
38
 
52
- function readPid(pidFile: string): number | null {
53
- try {
54
- const raw = fs.readFileSync(pidFile, 'utf8').trim();
55
- const pid = Number.parseInt(raw, 10);
56
- return Number.isInteger(pid) && pid > 0 ? pid : null;
57
- } catch {
58
- return null;
39
+ async function waitForProcessIdentity(pid: number, timeoutMs = 2000): Promise<PidRecord | null> {
40
+ const deadline = Date.now() + timeoutMs;
41
+ while (Date.now() < deadline) {
42
+ const startTime = getProcessStartTime(pid);
43
+ if (startTime !== null) return { version: 1, pid, startTime };
44
+ await delay(25);
59
45
  }
46
+ return null;
60
47
  }
61
48
 
62
- function removePidFile(pidFile: string): void {
49
+ function terminateIfMatching(record: PidRecord): void {
50
+ if (!processIdentityMatches(record)) return;
51
+ const command = buildStopCommand(record.pid, process.platform);
63
52
  try {
64
- fs.unlinkSync(pidFile);
53
+ if (command.kind === 'exec') execFileSync(command.command, command.args);
54
+ else process.kill(record.pid, command.signal);
65
55
  } catch {
66
- // Already gone.
56
+ // Best effort cleanup for a daemon whose PID record could not be published.
67
57
  }
68
58
  }
69
59
 
70
- function delay(ms: number): Promise<void> {
71
- return new Promise((resolve) => {
72
- setTimeout(resolve, ms);
73
- });
74
- }
75
-
76
60
  function enabledAdapterNames(config: ServerConfig): string[] {
77
61
  return Object.entries(config.adapters)
78
62
  .filter(([, adapter]) => adapter?.enabled === true)
@@ -83,15 +67,13 @@ export async function start({ foreground = false }: StartOptions = {}): Promise<
83
67
  const config = loadServerConfig();
84
68
  const pidPath = config.pidFile;
85
69
 
86
- // Zombie PID cleanup: a stale PID file from a crashed daemon must not block a
87
- // fresh start.
88
- const existing = readPid(pidPath);
89
- if (existing !== null && isProcessAlive(existing)) {
90
- process.stdout.write(`server already running (pid ${existing})\n`);
70
+ const existing = readProcessState(pidPath);
71
+ if (existing.kind === 'running') {
72
+ process.stdout.write(`server already running (pid ${existing.record.pid})\n`);
91
73
  return;
92
74
  }
93
- if (existing !== null) {
94
- removePidFile(pidPath);
75
+ if (existing.kind !== 'missing') {
76
+ removePidFileIfMatches(pidPath, existing.snapshot);
95
77
  }
96
78
 
97
79
  if (foreground) {
@@ -116,52 +98,69 @@ export async function start({ foreground = false }: StartOptions = {}): Promise<
116
98
  if (typeof child.pid !== 'number') {
117
99
  throw new Error('server: failed to spawn daemon process');
118
100
  }
119
- fs.mkdirSync(path.dirname(pidPath), { recursive: true });
120
- fs.writeFileSync(pidPath, `${child.pid}\n`);
121
- process.stdout.write(`server started (pid ${child.pid})\n`);
101
+
102
+ // The initial check, spawn, and record publication are not a mutually
103
+ // exclusive transaction. Atomic publication keeps the record complete, but
104
+ // a separate locking design is required to serialize concurrent starts.
105
+ const record = await waitForProcessIdentity(child.pid);
106
+ if (record === null || !processIdentityMatches(record)) {
107
+ throw new Error('server: unable to verify spawned daemon process identity');
108
+ }
109
+ try {
110
+ writePidRecord(pidPath, record);
111
+ } catch (error) {
112
+ terminateIfMatching(record);
113
+ throw error;
114
+ }
115
+ process.stdout.write(`server started (pid ${record.pid})\n`);
122
116
  }
123
117
 
124
118
  export async function stop(): Promise<void> {
125
119
  const config = loadServerConfig();
126
- const pid = readPid(config.pidFile);
120
+ const state = readProcessState(config.pidFile);
127
121
 
128
- if (pid === null) {
122
+ if (state.kind === 'missing') {
129
123
  process.stdout.write('server is not running (no pid file)\n');
130
124
  return;
131
125
  }
132
- if (!isProcessAlive(pid)) {
133
- removePidFile(config.pidFile);
126
+ if (state.kind !== 'running') {
127
+ removePidFileIfMatches(config.pidFile, state.snapshot);
134
128
  process.stdout.write('server is not running (removed stale pid file)\n');
135
129
  return;
136
130
  }
137
131
 
138
- const command = buildStopCommand(pid, process.platform);
132
+ const { record } = state;
133
+ if (!processIdentityMatches(record)) {
134
+ removePidFileIfMatches(config.pidFile, state.snapshot);
135
+ process.stdout.write('server is not running (removed stale pid file)\n');
136
+ return;
137
+ }
138
+
139
+ const command = buildStopCommand(record.pid, process.platform);
139
140
  if (command.kind === 'exec') {
140
141
  execFileSync(command.command, command.args);
141
142
  } else {
142
- process.kill(pid, command.signal);
143
+ process.kill(record.pid, command.signal);
143
144
  const deadline = Date.now() + 5000;
144
- while (isProcessAlive(pid) && Date.now() < deadline) {
145
+ while (isProcessAlive(record.pid) && Date.now() < deadline) {
145
146
  await delay(100);
146
147
  }
147
- if (isProcessAlive(pid)) {
148
- process.kill(pid, 'SIGKILL');
148
+ if (isProcessAlive(record.pid) && processIdentityMatches(record)) {
149
+ process.kill(record.pid, 'SIGKILL');
149
150
  }
150
151
  }
151
152
 
152
- removePidFile(config.pidFile);
153
- process.stdout.write(`server stopped (pid ${pid})\n`);
153
+ removePidFileIfMatches(config.pidFile, state.snapshot);
154
+ process.stdout.write(`server stopped (pid ${record.pid})\n`);
154
155
  }
155
156
 
156
157
  export function status(): void {
157
158
  const config = loadServerConfig();
158
- const pid = readPid(config.pidFile);
159
+ const state = readProcessState(config.pidFile);
159
160
 
160
- if (pid === null || !isProcessAlive(pid)) {
161
+ if (state.kind !== 'running') {
162
+ if (state.kind !== 'missing') removePidFileIfMatches(config.pidFile, state.snapshot);
161
163
  process.stdout.write('server: stopped\n');
162
- if (pid !== null) {
163
- process.stdout.write(` (stale pid file references pid ${pid})\n`);
164
- }
165
164
  return;
166
165
  }
167
166
 
@@ -174,7 +173,7 @@ export function status(): void {
174
173
  const adapters = enabledAdapterNames(config);
175
174
  process.stdout.write(
176
175
  `server: running\n` +
177
- ` pid: ${pid}\n` +
176
+ ` pid: ${state.record.pid}\n` +
178
177
  ` started: ${startedAt}\n` +
179
178
  ` adapters: ${adapters.length > 0 ? adapters.join(', ') : '(none)'}\n` +
180
179
  ` pid file: ${config.pidFile}\n` +
@@ -0,0 +1,183 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+
5
+ export type PidRecord = {
6
+ version: 1;
7
+ pid: number;
8
+ startTime: string;
9
+ };
10
+
11
+ export type ProcessStartTimeQuery = { command: string; args: string[] };
12
+
13
+ export type ProcessState =
14
+ | { kind: 'missing'; snapshot: null }
15
+ | { kind: 'invalid'; snapshot: string }
16
+ | { kind: 'legacy'; snapshot: string; pid: number }
17
+ | { kind: 'stale'; snapshot: string; record: PidRecord }
18
+ | { kind: 'running'; snapshot: string; record: PidRecord };
19
+
20
+ export type LinuxProcessStat = { state: string; startTime: string };
21
+
22
+ export function parseLinuxProcessStat(raw: string): LinuxProcessStat | null {
23
+ const closeParen = raw.lastIndexOf(')');
24
+ if (closeParen < 0) return null;
25
+ const fields = raw.slice(closeParen + 1).trim().split(/\s+/);
26
+ const state = fields[0];
27
+ const startTime = fields[19];
28
+ if (typeof state !== 'string' || state.length !== 1 || !/^\d+$/.test(startTime ?? '')) {
29
+ return null;
30
+ }
31
+ return { state, startTime: startTime! };
32
+ }
33
+
34
+ export function buildProcessStartTimeQuery(
35
+ pid: number,
36
+ platform: NodeJS.Platform
37
+ ): ProcessStartTimeQuery | null {
38
+ if (platform === 'darwin') {
39
+ return { command: 'ps', args: ['-p', String(pid), '-o', 'lstart='] };
40
+ }
41
+ if (platform === 'win32') {
42
+ const script =
43
+ `$process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}'; ` +
44
+ `if ($null -ne $process) { $process.CreationDate.ToUniversalTime().ToString('o') }`;
45
+ return {
46
+ command: 'powershell.exe',
47
+ args: ['-NoProfile', '-NonInteractive', '-Command', script]
48
+ };
49
+ }
50
+ return null;
51
+ }
52
+
53
+ export function isProcessAlive(pid: number, platform: NodeJS.Platform = process.platform): boolean {
54
+ if (!Number.isInteger(pid) || pid <= 0) return false;
55
+ if (platform === 'linux') {
56
+ try {
57
+ const stat = parseLinuxProcessStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8'));
58
+ return stat !== null && stat.state !== 'Z';
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+ try {
64
+ process.kill(pid, 0);
65
+ return true;
66
+ } catch (error) {
67
+ return (error as NodeJS.ErrnoException).code === 'EPERM';
68
+ }
69
+ }
70
+
71
+ export function getProcessStartTime(
72
+ pid: number,
73
+ platform: NodeJS.Platform = process.platform
74
+ ): string | null {
75
+ if (!Number.isInteger(pid) || pid <= 0) return null;
76
+ if (platform === 'linux') {
77
+ try {
78
+ const stat = parseLinuxProcessStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8'));
79
+ return stat !== null && stat.state !== 'Z' ? stat.startTime : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+ if (!isProcessAlive(pid, platform)) return null;
85
+ const query = buildProcessStartTimeQuery(pid, platform);
86
+ if (query === null) return null;
87
+ try {
88
+ const output = execFileSync(query.command, query.args, { encoding: 'utf8' }).trim();
89
+ if (output.length === 0) return null;
90
+ if (platform === 'win32') {
91
+ const timestamp = new Date(output);
92
+ return Number.isNaN(timestamp.getTime()) ? null : timestamp.toISOString();
93
+ }
94
+ return output;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ function parsePidRecord(raw: string): { kind: 'invalid' } | { kind: 'legacy'; pid: number } | { kind: 'record'; record: PidRecord } {
101
+ const trimmed = raw.trim();
102
+ if (/^[1-9]\d*$/.test(trimmed)) {
103
+ const pid = Number(trimmed);
104
+ return Number.isSafeInteger(pid) ? { kind: 'legacy', pid } : { kind: 'invalid' };
105
+ }
106
+ try {
107
+ const value = JSON.parse(trimmed) as Partial<PidRecord> | null;
108
+ if (
109
+ value !== null &&
110
+ value.version === 1 &&
111
+ Number.isSafeInteger(value.pid) &&
112
+ (value.pid ?? 0) > 0 &&
113
+ typeof value.startTime === 'string' &&
114
+ value.startTime.length > 0
115
+ ) {
116
+ return {
117
+ kind: 'record',
118
+ record: { version: 1, pid: value.pid!, startTime: value.startTime }
119
+ };
120
+ }
121
+ } catch {
122
+ // Invalid JSON is an invalid PID record.
123
+ }
124
+ return { kind: 'invalid' };
125
+ }
126
+
127
+ export function readProcessState(pidFile: string): ProcessState {
128
+ let snapshot: string;
129
+ try {
130
+ snapshot = fs.readFileSync(pidFile, 'utf8');
131
+ } catch (error) {
132
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing', snapshot: null };
133
+ return { kind: 'invalid', snapshot: '' };
134
+ }
135
+
136
+ const parsed = parsePidRecord(snapshot);
137
+ if (parsed.kind === 'invalid') return { kind: 'invalid', snapshot };
138
+ if (parsed.kind === 'legacy') return { kind: 'legacy', snapshot, pid: parsed.pid };
139
+
140
+ const currentStartTime = getProcessStartTime(parsed.record.pid);
141
+ if (currentStartTime === null || currentStartTime !== parsed.record.startTime) {
142
+ return { kind: 'stale', snapshot, record: parsed.record };
143
+ }
144
+ return { kind: 'running', snapshot, record: parsed.record };
145
+ }
146
+
147
+ export function writePidRecord(pidFile: string, record: PidRecord): void {
148
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
149
+ const temporary = `${pidFile}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
150
+ try {
151
+ fs.writeFileSync(temporary, `${JSON.stringify(record)}\n`);
152
+ fs.renameSync(temporary, pidFile);
153
+ } catch (error) {
154
+ try {
155
+ fs.unlinkSync(temporary);
156
+ } catch {
157
+ // The temporary file was never created or was already renamed.
158
+ }
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ export function removePidFileIfMatches(pidFile: string, expectedSnapshot: string | null): boolean {
164
+ if (expectedSnapshot === null) return false;
165
+ try {
166
+ if (fs.readFileSync(pidFile, 'utf8') !== expectedSnapshot) return false;
167
+ fs.unlinkSync(pidFile);
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ export function removePidRecordIfMatches(pidFile: string, expected: PidRecord): boolean {
175
+ const state = readProcessState(pidFile);
176
+ if (state.kind !== 'running') return false;
177
+ if (state.record.pid !== expected.pid || state.record.startTime !== expected.startTime) return false;
178
+ return removePidFileIfMatches(pidFile, state.snapshot);
179
+ }
180
+
181
+ export function processIdentityMatches(record: PidRecord): boolean {
182
+ return getProcessStartTime(record.pid) === record.startTime;
183
+ }
package/lib/update.ts CHANGED
@@ -194,6 +194,12 @@ async function cmdUpdate(): Promise<void> {
194
194
  platformType
195
195
  );
196
196
  ok('Updated .agents/skills/update-agent-infra/');
197
+ renderFile(
198
+ path.join(templateDir, '.agents', 'scripts', 'lib', 'agent-infra-package.js'),
199
+ path.join('.agents', 'scripts', 'lib', 'agent-infra-package.js'),
200
+ replacements
201
+ );
202
+ ok('Updated .agents/scripts/lib/agent-infra-package.js');
197
203
  try {
198
204
  fs.unlinkSync(path.join('.agents', 'skills', 'update-agent-infra', 'scripts', 'sync-templates.cjs'));
199
205
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Gemini CLI, and OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,6 +26,7 @@
26
26
  "!dist/**/*.map",
27
27
  "bin/cli.ts",
28
28
  "lib/",
29
+ "runtime/",
29
30
  "templates/"
30
31
  ],
31
32
  "engines": {