@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
@@ -44,8 +44,16 @@
44
44
  ".gemini/commands/",
45
45
  ".git-hooks/check-version-format.sh",
46
46
  ".github/scripts/",
47
+ ".github/workflows/metadata-sync.yml",
48
+ ".github/workflows/pr-label.yml",
49
+ ".github/workflows/status-label.yml",
47
50
  ".opencode/commands/"
48
51
  ],
52
+ "guardedManaged": [
53
+ ".github/workflows/metadata-sync.yml",
54
+ ".github/workflows/pr-label.yml",
55
+ ".github/workflows/status-label.yml"
56
+ ],
49
57
  "merged": [
50
58
  "**/post-release.*",
51
59
  "**/release.*",
package/dist/lib/init.js CHANGED
@@ -197,6 +197,8 @@ async function cmdInit() {
197
197
  // install skill
198
198
  copySkillDir(path.join(templateDir, '.agents', 'skills', 'update-agent-infra'), path.join('.agents', 'skills', 'update-agent-infra'), replacements, language, platformType);
199
199
  ok('Installed .agents/skills/update-agent-infra/');
200
+ renderFile(path.join(templateDir, '.agents', 'scripts', 'lib', 'agent-infra-package.js'), path.join('.agents', 'scripts', 'lib', 'agent-infra-package.js'), replacements);
201
+ ok('Installed .agents/scripts/lib/agent-infra-package.js');
200
202
  // install Claude command (only if enabled)
201
203
  if (enabledTUISet.has('claude-code')) {
202
204
  renderFile(path.join(templateDir, '.claude', 'commands', claudeSrc), path.join('.claude', 'commands', 'update-agent-infra.md'), replacements);
@@ -245,6 +247,9 @@ async function cmdInit() {
245
247
  console.log('');
246
248
  ok('Project initialized successfully!');
247
249
  console.log('');
250
+ console.log(' If this init used npx, install agent-infra persistently before update or validation:');
251
+ console.log(' npm install -g @fitlab-ai/agent-infra');
252
+ console.log('');
248
253
  if (enabledTUISet.size === 0) {
249
254
  console.log(' No built-in TUI selected.');
250
255
  console.log(` Configure "customTUIs" in ${configPath} before running update-agent-infra.`);
@@ -47,13 +47,27 @@ alias gy='gemini --yolo; tput ed'
47
47
  `;
48
48
  const CONTAINER_HOME = '/home/devuser';
49
49
  const CONTAINER_SHELL_CONFIG_MOUNT = `${CONTAINER_HOME}/.host-shell-config`;
50
- const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh]
50
+ const USAGE = `Usage: ai sandbox create <branch> [base] [--cpu <n>] [--memory <n>] [--no-refresh] [--inherit-proxy|-P]
51
51
 
52
52
  Host aliases:
53
53
  ${'~'}/.agent-infra/aliases/sandbox.sh is auto-created on first run and exposed
54
54
  as ${CONTAINER_HOME}/.bash_aliases inside the sandbox container (the host
55
55
  shell-config directory is bind-mounted at ${CONTAINER_SHELL_CONFIG_MOUNT} and
56
- symlinked into $HOME).`;
56
+ symlinked into $HOME).
57
+
58
+ Proxy:
59
+ --inherit-proxy, -P Copy non-empty standard host proxy variables into the
60
+ container environment through the private env file.`;
61
+ const HOST_PROXY_ENV_KEYS = [
62
+ 'http_proxy',
63
+ 'HTTP_PROXY',
64
+ 'https_proxy',
65
+ 'HTTPS_PROXY',
66
+ 'all_proxy',
67
+ 'ALL_PROXY',
68
+ 'no_proxy',
69
+ 'NO_PROXY'
70
+ ];
57
71
  function resolveToolDirs(config, tools, branch) {
58
72
  return tools.map((tool) => {
59
73
  const candidates = toolConfigDirCandidates(tool, config.project, branch);
@@ -425,9 +439,17 @@ function formatEnvFileEntry(key, value) {
425
439
  }
426
440
  return `${key}=${value}`;
427
441
  }
442
+ export function collectHostProxyEntries(env) {
443
+ const entriesByExactKey = new Map(Object.entries(env));
444
+ return HOST_PROXY_ENV_KEYS.flatMap((key) => {
445
+ const value = entriesByExactKey.get(key);
446
+ return typeof value === 'string' && value !== '' ? [[key, value]] : [];
447
+ });
448
+ }
428
449
  export function buildContainerEnvFile(resolvedTools, engine, runSafeEngineFn = runSafeEngine, options = {}) {
429
- const { mkdtempFn = fs.mkdtempSync, writeFileFn = fs.writeFileSync, chmodFn = fs.chmodSync, rmFn = fs.rmSync, tmpDir = os.tmpdir(), runSafeFn = runSafe } = options;
450
+ const { additionalEntries = [], mkdtempFn = fs.mkdtempSync, writeFileFn = fs.writeFileSync, chmodFn = fs.chmodSync, rmFn = fs.rmSync, tmpDir = os.tmpdir(), runSafeFn = runSafe } = options;
430
451
  const entries = resolvedTools.flatMap(({ tool }) => Object.entries(tool.envVars ?? {}));
452
+ entries.push(...additionalEntries);
431
453
  let ghToken = runSafeEngineFn(engine, 'gh', ['auth', 'token']);
432
454
  if (!ghToken && engine === 'wsl2') {
433
455
  ghToken = runSafeFn('gh', ['auth', 'token']);
@@ -933,6 +955,7 @@ export async function create(args) {
933
955
  cpu: { type: 'string' },
934
956
  memory: { type: 'string' },
935
957
  'no-refresh': { type: 'boolean' },
958
+ 'inherit-proxy': { type: 'boolean', short: 'P' },
936
959
  help: { type: 'boolean', short: 'h' }
937
960
  }
938
961
  });
@@ -1148,7 +1171,10 @@ export async function create(args) {
1148
1171
  const cachedGpg = needsGpg
1149
1172
  ? readGpgCache(effectiveConfig.home, effectiveConfig.project, undefined, signingKey)
1150
1173
  : null;
1151
- const envFile = buildContainerEnvFile(effectiveResolvedTools, engine);
1174
+ const proxyEntries = values['inherit-proxy'] ? collectHostProxyEntries(process.env) : [];
1175
+ const envFile = buildContainerEnvFile(effectiveResolvedTools, engine, undefined, {
1176
+ additionalEntries: proxyEntries
1177
+ });
1152
1178
  let hostShellConfig;
1153
1179
  let tmpfsSeedPlan = [];
1154
1180
  try {
@@ -1,7 +1,7 @@
1
1
  import { platform } from 'node:os';
2
2
  import { detectHostResources } from './constants.js';
3
3
  import { ADAPTERS, enginesForPlatform, getAdapter } from './engines/index.js';
4
- import { run, runOk, runSafe, runVerbose } from './shell.js';
4
+ import { run, runOk, runOkEngine, runSafe, runVerbose } from './shell.js';
5
5
  export const ENGINES = Object.freeze({
6
6
  COLIMA: 'colima',
7
7
  ORBSTACK: 'orbstack',
@@ -27,6 +27,34 @@ function applyDockerContext(adapter) {
27
27
  process.env.DOCKER_CONTEXT = adapter.dockerContext;
28
28
  }
29
29
  }
30
+ function buildKitError(engine) {
31
+ let repair;
32
+ switch (engine) {
33
+ case ENGINES.COLIMA:
34
+ repair = 'Install the Docker Buildx plugin with: brew install docker-buildx';
35
+ break;
36
+ case ENGINES.DOCKER_DESKTOP:
37
+ case ENGINES.WSL2:
38
+ repair = 'Upgrade or repair Docker Desktop so Docker Buildx and BuildKit are available.';
39
+ break;
40
+ case ENGINES.NATIVE:
41
+ repair = 'Install the Docker Buildx plugin for your Docker Engine installation.';
42
+ break;
43
+ default:
44
+ repair = `Upgrade or repair ${engineDisplayName(engine)} so Docker Buildx and BuildKit are available.`;
45
+ }
46
+ return new Error([
47
+ `Docker BuildKit is not available for ${engineDisplayName(engine)}.`,
48
+ repair,
49
+ 'Verify the builder with: docker buildx inspect --bootstrap',
50
+ 'Then retry the sandbox create or rebuild command.'
51
+ ].join('\n'));
52
+ }
53
+ function ensureBuildKit(engine, runOkEngineFn) {
54
+ if (!runOkEngineFn(engine, 'docker', ['buildx', 'inspect', '--bootstrap'])) {
55
+ throw buildKitError(engine);
56
+ }
57
+ }
30
58
  export function validateSandboxEngine(engine, { platformFn = platform } = {}) {
31
59
  if (engine === null || engine === undefined) {
32
60
  return null;
@@ -90,9 +118,11 @@ export async function ensureDocker(config, onMessage, dependencies = {}) {
90
118
  const engine = detectEngine(config, dependencies);
91
119
  const adapter = getAdapter(engine);
92
120
  const effectiveConfig = effectiveConfigFor(adapter, config);
121
+ const fns = runFns(dependencies);
93
122
  applyDockerContext(adapter);
94
- const vmJustStarted = await adapter.ensure(effectiveConfig, onMessage, runFns(dependencies));
95
- adapter.syncResources(effectiveConfig, onMessage, runFns(dependencies), { vmJustStarted });
123
+ const vmJustStarted = await adapter.ensure(effectiveConfig, onMessage, fns);
124
+ ensureBuildKit(engine, dependencies.runOkEngineFn ?? runOkEngine);
125
+ adapter.syncResources(effectiveConfig, onMessage, fns, { vmJustStarted });
96
126
  }
97
127
  export function isVmManaged(config = {}, dependencies = {}) {
98
128
  try {
@@ -28,8 +28,8 @@ export const colimaAdapter = {
28
28
  async ensure(config, onMessage, { runOk, runSafe, runVerbose }) {
29
29
  let started = false;
30
30
  if (!runOk('which', ['colima'])) {
31
- onMessage?.('Installing colima + docker via Homebrew...');
32
- runVerbose('brew', ['install', 'colima', 'docker']);
31
+ onMessage?.('Installing colima + docker + docker-buildx via Homebrew...');
32
+ runVerbose('brew', ['install', 'colima', 'docker', 'docker-buildx']);
33
33
  }
34
34
  if (!runOk('colima', ['status'])) {
35
35
  onMessage?.('Starting Colima VM...');
@@ -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
@@ -8,6 +8,7 @@ import { runAi } from './runner.js';
8
8
  import { streamCommand } from './streamer.js';
9
9
  import { markdownMessage, replyOutbound, textMessage } from './display.js';
10
10
  import { buildStatusModel, statusModelToDisplay } from '../task/commands/status.js';
11
+ import { getProcessStartTime, removePidRecordIfMatches } from './process-state.js';
11
12
  function errorMessage(error) {
12
13
  return error instanceof Error ? error.message : String(error);
13
14
  }
@@ -85,6 +86,7 @@ export async function runDaemon() {
85
86
  }
86
87
  const logger = createLogger(config.log);
87
88
  logger.info(`daemon starting agent-infra ${VERSION} pid=${process.pid}`);
89
+ const ownStartTime = getProcessStartTime(process.pid);
88
90
  const abortController = new AbortController();
89
91
  let resolveShutdown = () => { };
90
92
  const shutdown = new Promise((resolve) => {
@@ -106,6 +108,13 @@ export async function runDaemon() {
106
108
  await unloadAdapters(adapters);
107
109
  clearInterval(heartbeat);
108
110
  logger.close();
111
+ if (ownStartTime !== null) {
112
+ removePidRecordIfMatches(config.pidFile, {
113
+ version: 1,
114
+ pid: process.pid,
115
+ startTime: ownStartTime
116
+ });
117
+ }
109
118
  resolveShutdown();
110
119
  })();
111
120
  };
@@ -1,66 +1,44 @@
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.js';
5
4
  import { runDaemon } from './daemon.js';
5
+ import { getProcessStartTime, isProcessAlive, processIdentityMatches, readProcessState, removePidFileIfMatches, writePidRecord } from './process-state.js';
6
+ export { isProcessAlive } from './process-state.js';
6
7
  export function buildStopCommand(pid, platform) {
7
8
  if (platform === 'win32') {
8
9
  return { kind: 'exec', command: 'taskkill', args: ['/PID', String(pid), '/T', '/F'] };
9
10
  }
10
11
  return { kind: 'signal', signal: 'SIGTERM' };
11
12
  }
12
- // Liveness check that treats an exited-but-unreaped daemon as dead.
13
- //
14
- // The daemon is spawned detached and re-parented to init when `ai server start`
15
- // exits. After it terminates, `process.kill(pid, 0)` still succeeds until init
16
- // reaps the zombie. On Linux we therefore also check /proc state so status/stop
17
- // /start don't treat an already-exited daemon as running. macOS reaps orphans
18
- // via launchd and Windows has no zombies, so the kill(pid, 0) result is enough.
19
- export function isProcessAlive(pid) {
20
- try {
21
- process.kill(pid, 0);
22
- }
23
- catch (error) {
24
- // EPERM means the process exists but we may not signal it → still alive.
25
- return error.code === 'EPERM';
26
- }
27
- if (process.platform === 'linux') {
28
- try {
29
- const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
30
- // The process state is the first token after the parenthesised comm.
31
- const state = stat.slice(stat.lastIndexOf(')') + 1).trim().charAt(0);
32
- if (state === 'Z')
33
- return false;
34
- }
35
- catch {
36
- return false; // /proc entry vanished → not alive
37
- }
38
- }
39
- return true;
13
+ function delay(ms) {
14
+ return new Promise((resolve) => {
15
+ setTimeout(resolve, ms);
16
+ });
40
17
  }
41
- function readPid(pidFile) {
42
- try {
43
- const raw = fs.readFileSync(pidFile, 'utf8').trim();
44
- const pid = Number.parseInt(raw, 10);
45
- return Number.isInteger(pid) && pid > 0 ? pid : null;
46
- }
47
- catch {
48
- return null;
49
- }
18
+ async function waitForProcessIdentity(pid, timeoutMs = 2000) {
19
+ const deadline = Date.now() + timeoutMs;
20
+ while (Date.now() < deadline) {
21
+ const startTime = getProcessStartTime(pid);
22
+ if (startTime !== null)
23
+ return { version: 1, pid, startTime };
24
+ await delay(25);
25
+ }
26
+ return null;
50
27
  }
51
- function removePidFile(pidFile) {
28
+ function terminateIfMatching(record) {
29
+ if (!processIdentityMatches(record))
30
+ return;
31
+ const command = buildStopCommand(record.pid, process.platform);
52
32
  try {
53
- fs.unlinkSync(pidFile);
33
+ if (command.kind === 'exec')
34
+ execFileSync(command.command, command.args);
35
+ else
36
+ process.kill(record.pid, command.signal);
54
37
  }
55
38
  catch {
56
- // Already gone.
39
+ // Best effort cleanup for a daemon whose PID record could not be published.
57
40
  }
58
41
  }
59
- function delay(ms) {
60
- return new Promise((resolve) => {
61
- setTimeout(resolve, ms);
62
- });
63
- }
64
42
  function enabledAdapterNames(config) {
65
43
  return Object.entries(config.adapters)
66
44
  .filter(([, adapter]) => adapter?.enabled === true)
@@ -69,15 +47,13 @@ function enabledAdapterNames(config) {
69
47
  export async function start({ foreground = false } = {}) {
70
48
  const config = loadServerConfig();
71
49
  const pidPath = config.pidFile;
72
- // Zombie PID cleanup: a stale PID file from a crashed daemon must not block a
73
- // fresh start.
74
- const existing = readPid(pidPath);
75
- if (existing !== null && isProcessAlive(existing)) {
76
- process.stdout.write(`server already running (pid ${existing})\n`);
50
+ const existing = readProcessState(pidPath);
51
+ if (existing.kind === 'running') {
52
+ process.stdout.write(`server already running (pid ${existing.record.pid})\n`);
77
53
  return;
78
54
  }
79
- if (existing !== null) {
80
- removePidFile(pidPath);
55
+ if (existing.kind !== 'missing') {
56
+ removePidFileIfMatches(pidPath, existing.snapshot);
81
57
  }
82
58
  if (foreground) {
83
59
  await runDaemon();
@@ -98,47 +74,64 @@ export async function start({ foreground = false } = {}) {
98
74
  if (typeof child.pid !== 'number') {
99
75
  throw new Error('server: failed to spawn daemon process');
100
76
  }
101
- fs.mkdirSync(path.dirname(pidPath), { recursive: true });
102
- fs.writeFileSync(pidPath, `${child.pid}\n`);
103
- process.stdout.write(`server started (pid ${child.pid})\n`);
77
+ // The initial check, spawn, and record publication are not a mutually
78
+ // exclusive transaction. Atomic publication keeps the record complete, but
79
+ // a separate locking design is required to serialize concurrent starts.
80
+ const record = await waitForProcessIdentity(child.pid);
81
+ if (record === null || !processIdentityMatches(record)) {
82
+ throw new Error('server: unable to verify spawned daemon process identity');
83
+ }
84
+ try {
85
+ writePidRecord(pidPath, record);
86
+ }
87
+ catch (error) {
88
+ terminateIfMatching(record);
89
+ throw error;
90
+ }
91
+ process.stdout.write(`server started (pid ${record.pid})\n`);
104
92
  }
105
93
  export async function stop() {
106
94
  const config = loadServerConfig();
107
- const pid = readPid(config.pidFile);
108
- if (pid === null) {
95
+ const state = readProcessState(config.pidFile);
96
+ if (state.kind === 'missing') {
109
97
  process.stdout.write('server is not running (no pid file)\n');
110
98
  return;
111
99
  }
112
- if (!isProcessAlive(pid)) {
113
- removePidFile(config.pidFile);
100
+ if (state.kind !== 'running') {
101
+ removePidFileIfMatches(config.pidFile, state.snapshot);
114
102
  process.stdout.write('server is not running (removed stale pid file)\n');
115
103
  return;
116
104
  }
117
- const command = buildStopCommand(pid, process.platform);
105
+ const { record } = state;
106
+ if (!processIdentityMatches(record)) {
107
+ removePidFileIfMatches(config.pidFile, state.snapshot);
108
+ process.stdout.write('server is not running (removed stale pid file)\n');
109
+ return;
110
+ }
111
+ const command = buildStopCommand(record.pid, process.platform);
118
112
  if (command.kind === 'exec') {
119
113
  execFileSync(command.command, command.args);
120
114
  }
121
115
  else {
122
- process.kill(pid, command.signal);
116
+ process.kill(record.pid, command.signal);
123
117
  const deadline = Date.now() + 5000;
124
- while (isProcessAlive(pid) && Date.now() < deadline) {
118
+ while (isProcessAlive(record.pid) && Date.now() < deadline) {
125
119
  await delay(100);
126
120
  }
127
- if (isProcessAlive(pid)) {
128
- process.kill(pid, 'SIGKILL');
121
+ if (isProcessAlive(record.pid) && processIdentityMatches(record)) {
122
+ process.kill(record.pid, 'SIGKILL');
129
123
  }
130
124
  }
131
- removePidFile(config.pidFile);
132
- process.stdout.write(`server stopped (pid ${pid})\n`);
125
+ removePidFileIfMatches(config.pidFile, state.snapshot);
126
+ process.stdout.write(`server stopped (pid ${record.pid})\n`);
133
127
  }
134
128
  export function status() {
135
129
  const config = loadServerConfig();
136
- const pid = readPid(config.pidFile);
137
- if (pid === null || !isProcessAlive(pid)) {
130
+ const state = readProcessState(config.pidFile);
131
+ if (state.kind !== 'running') {
132
+ if (state.kind !== 'missing')
133
+ removePidFileIfMatches(config.pidFile, state.snapshot);
138
134
  process.stdout.write('server: stopped\n');
139
- if (pid !== null) {
140
- process.stdout.write(` (stale pid file references pid ${pid})\n`);
141
- }
142
135
  return;
143
136
  }
144
137
  let startedAt = 'unknown';
@@ -150,7 +143,7 @@ export function status() {
150
143
  }
151
144
  const adapters = enabledAdapterNames(config);
152
145
  process.stdout.write(`server: running\n` +
153
- ` pid: ${pid}\n` +
146
+ ` pid: ${state.record.pid}\n` +
154
147
  ` started: ${startedAt}\n` +
155
148
  ` adapters: ${adapters.length > 0 ? adapters.join(', ') : '(none)'}\n` +
156
149
  ` pid file: ${config.pidFile}\n` +
@@ -0,0 +1,168 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ export function parseLinuxProcessStat(raw) {
5
+ const closeParen = raw.lastIndexOf(')');
6
+ if (closeParen < 0)
7
+ return null;
8
+ const fields = raw.slice(closeParen + 1).trim().split(/\s+/);
9
+ const state = fields[0];
10
+ const startTime = fields[19];
11
+ if (typeof state !== 'string' || state.length !== 1 || !/^\d+$/.test(startTime ?? '')) {
12
+ return null;
13
+ }
14
+ return { state, startTime: startTime };
15
+ }
16
+ export function buildProcessStartTimeQuery(pid, platform) {
17
+ if (platform === 'darwin') {
18
+ return { command: 'ps', args: ['-p', String(pid), '-o', 'lstart='] };
19
+ }
20
+ if (platform === 'win32') {
21
+ const script = `$process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}'; ` +
22
+ `if ($null -ne $process) { $process.CreationDate.ToUniversalTime().ToString('o') }`;
23
+ return {
24
+ command: 'powershell.exe',
25
+ args: ['-NoProfile', '-NonInteractive', '-Command', script]
26
+ };
27
+ }
28
+ return null;
29
+ }
30
+ export function isProcessAlive(pid, platform = process.platform) {
31
+ if (!Number.isInteger(pid) || pid <= 0)
32
+ return false;
33
+ if (platform === 'linux') {
34
+ try {
35
+ const stat = parseLinuxProcessStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8'));
36
+ return stat !== null && stat.state !== 'Z';
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ }
46
+ catch (error) {
47
+ return error.code === 'EPERM';
48
+ }
49
+ }
50
+ export function getProcessStartTime(pid, platform = process.platform) {
51
+ if (!Number.isInteger(pid) || pid <= 0)
52
+ return null;
53
+ if (platform === 'linux') {
54
+ try {
55
+ const stat = parseLinuxProcessStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8'));
56
+ return stat !== null && stat.state !== 'Z' ? stat.startTime : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ if (!isProcessAlive(pid, platform))
63
+ return null;
64
+ const query = buildProcessStartTimeQuery(pid, platform);
65
+ if (query === null)
66
+ return null;
67
+ try {
68
+ const output = execFileSync(query.command, query.args, { encoding: 'utf8' }).trim();
69
+ if (output.length === 0)
70
+ return null;
71
+ if (platform === 'win32') {
72
+ const timestamp = new Date(output);
73
+ return Number.isNaN(timestamp.getTime()) ? null : timestamp.toISOString();
74
+ }
75
+ return output;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ function parsePidRecord(raw) {
82
+ const trimmed = raw.trim();
83
+ if (/^[1-9]\d*$/.test(trimmed)) {
84
+ const pid = Number(trimmed);
85
+ return Number.isSafeInteger(pid) ? { kind: 'legacy', pid } : { kind: 'invalid' };
86
+ }
87
+ try {
88
+ const value = JSON.parse(trimmed);
89
+ if (value !== null &&
90
+ value.version === 1 &&
91
+ Number.isSafeInteger(value.pid) &&
92
+ (value.pid ?? 0) > 0 &&
93
+ typeof value.startTime === 'string' &&
94
+ value.startTime.length > 0) {
95
+ return {
96
+ kind: 'record',
97
+ record: { version: 1, pid: value.pid, startTime: value.startTime }
98
+ };
99
+ }
100
+ }
101
+ catch {
102
+ // Invalid JSON is an invalid PID record.
103
+ }
104
+ return { kind: 'invalid' };
105
+ }
106
+ export function readProcessState(pidFile) {
107
+ let snapshot;
108
+ try {
109
+ snapshot = fs.readFileSync(pidFile, 'utf8');
110
+ }
111
+ catch (error) {
112
+ if (error.code === 'ENOENT')
113
+ return { kind: 'missing', snapshot: null };
114
+ return { kind: 'invalid', snapshot: '' };
115
+ }
116
+ const parsed = parsePidRecord(snapshot);
117
+ if (parsed.kind === 'invalid')
118
+ return { kind: 'invalid', snapshot };
119
+ if (parsed.kind === 'legacy')
120
+ return { kind: 'legacy', snapshot, pid: parsed.pid };
121
+ const currentStartTime = getProcessStartTime(parsed.record.pid);
122
+ if (currentStartTime === null || currentStartTime !== parsed.record.startTime) {
123
+ return { kind: 'stale', snapshot, record: parsed.record };
124
+ }
125
+ return { kind: 'running', snapshot, record: parsed.record };
126
+ }
127
+ export function writePidRecord(pidFile, record) {
128
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
129
+ const temporary = `${pidFile}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
130
+ try {
131
+ fs.writeFileSync(temporary, `${JSON.stringify(record)}\n`);
132
+ fs.renameSync(temporary, pidFile);
133
+ }
134
+ catch (error) {
135
+ try {
136
+ fs.unlinkSync(temporary);
137
+ }
138
+ catch {
139
+ // The temporary file was never created or was already renamed.
140
+ }
141
+ throw error;
142
+ }
143
+ }
144
+ export function removePidFileIfMatches(pidFile, expectedSnapshot) {
145
+ if (expectedSnapshot === null)
146
+ return false;
147
+ try {
148
+ if (fs.readFileSync(pidFile, 'utf8') !== expectedSnapshot)
149
+ return false;
150
+ fs.unlinkSync(pidFile);
151
+ return true;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ export function removePidRecordIfMatches(pidFile, expected) {
158
+ const state = readProcessState(pidFile);
159
+ if (state.kind !== 'running')
160
+ return false;
161
+ if (state.record.pid !== expected.pid || state.record.startTime !== expected.startTime)
162
+ return false;
163
+ return removePidFileIfMatches(pidFile, state.snapshot);
164
+ }
165
+ export function processIdentityMatches(record) {
166
+ return getProcessStartTime(record.pid) === record.startTime;
167
+ }
168
+ //# sourceMappingURL=process-state.js.map
@@ -145,6 +145,8 @@ async function cmdUpdate() {
145
145
  // update skill
146
146
  copySkillDir(path.join(templateDir, '.agents', 'skills', 'update-agent-infra'), path.join('.agents', 'skills', 'update-agent-infra'), replacements, language, platformType);
147
147
  ok('Updated .agents/skills/update-agent-infra/');
148
+ renderFile(path.join(templateDir, '.agents', 'scripts', 'lib', 'agent-infra-package.js'), path.join('.agents', 'scripts', 'lib', 'agent-infra-package.js'), replacements);
149
+ ok('Updated .agents/scripts/lib/agent-infra-package.js');
148
150
  try {
149
151
  fs.unlinkSync(path.join('.agents', 'skills', 'update-agent-infra', 'scripts', 'sync-templates.cjs'));
150
152
  }
package/lib/defaults.json CHANGED
@@ -44,8 +44,16 @@
44
44
  ".gemini/commands/",
45
45
  ".git-hooks/check-version-format.sh",
46
46
  ".github/scripts/",
47
+ ".github/workflows/metadata-sync.yml",
48
+ ".github/workflows/pr-label.yml",
49
+ ".github/workflows/status-label.yml",
47
50
  ".opencode/commands/"
48
51
  ],
52
+ "guardedManaged": [
53
+ ".github/workflows/metadata-sync.yml",
54
+ ".github/workflows/pr-label.yml",
55
+ ".github/workflows/status-label.yml"
56
+ ],
49
57
  "merged": [
50
58
  "**/post-release.*",
51
59
  "**/release.*",
package/lib/init.ts CHANGED
@@ -278,6 +278,12 @@ async function cmdInit(): Promise<void> {
278
278
  platformType
279
279
  );
280
280
  ok('Installed .agents/skills/update-agent-infra/');
281
+ renderFile(
282
+ path.join(templateDir, '.agents', 'scripts', 'lib', 'agent-infra-package.js'),
283
+ path.join('.agents', 'scripts', 'lib', 'agent-infra-package.js'),
284
+ replacements
285
+ );
286
+ ok('Installed .agents/scripts/lib/agent-infra-package.js');
281
287
 
282
288
  // install Claude command (only if enabled)
283
289
  if (enabledTUISet.has('claude-code')) {
@@ -347,6 +353,9 @@ async function cmdInit(): Promise<void> {
347
353
  console.log('');
348
354
  ok('Project initialized successfully!');
349
355
  console.log('');
356
+ console.log(' If this init used npx, install agent-infra persistently before update or validation:');
357
+ console.log(' npm install -g @fitlab-ai/agent-infra');
358
+ console.log('');
350
359
  if (enabledTUISet.size === 0) {
351
360
  console.log(' No built-in TUI selected.');
352
361
  console.log(` Configure "customTUIs" in ${configPath} before running update-agent-infra.`);