@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.
- package/dist/lib/defaults.json +8 -0
- package/dist/lib/init.js +5 -0
- package/dist/lib/sandbox/commands/create.js +30 -4
- package/dist/lib/sandbox/engine.js +33 -3
- package/dist/lib/sandbox/engines/colima.js +2 -2
- package/dist/lib/sandbox/runtimes/base.dockerfile +4 -0
- package/dist/lib/server/daemon.js +9 -0
- package/dist/lib/server/process-control.js +66 -73
- package/dist/lib/server/process-state.js +168 -0
- package/dist/lib/update.js +2 -0
- package/lib/defaults.json +8 -0
- package/lib/init.ts +9 -0
- package/lib/sandbox/commands/create.ts +32 -3
- package/lib/sandbox/engine.ts +37 -3
- package/lib/sandbox/engines/colima.ts +2 -2
- package/lib/sandbox/runtimes/base.dockerfile +4 -0
- package/lib/server/daemon.ts +9 -0
- package/lib/server/process-control.ts +70 -71
- package/lib/server/process-state.ts +183 -0
- package/lib/update.ts +6 -0
- package/package.json +2 -1
- package/runtime/platform-adapters/platform-sync.github.js +1501 -0
- package/templates/.agents/README.en.md +2 -0
- package/templates/.agents/README.zh-CN.md +2 -0
- package/templates/.agents/rules/review-handshake.en.md +5 -4
- package/templates/.agents/rules/review-handshake.zh-CN.md +5 -4
- package/templates/.agents/scripts/lib/agent-infra-package.js +149 -0
- package/templates/.agents/scripts/lib/post-review-commit.js +5 -0
- package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +25 -1459
- package/templates/.agents/scripts/review-diff-fingerprint.js +95 -24
- package/templates/.agents/scripts/validate-artifact.js +68 -55
- package/templates/.agents/skills/commit/SKILL.en.md +5 -3
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +5 -3
- package/templates/.agents/skills/commit/reference/task-status-update.en.md +29 -2
- package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +29 -2
- package/templates/.agents/skills/complete-task/SKILL.en.md +2 -2
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
- package/templates/.agents/skills/init-milestones/SKILL.en.md +2 -2
- package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/init-milestones/scripts/init-milestones.github.sh +5 -4
- package/templates/.agents/skills/review-code/SKILL.en.md +2 -2
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +2 -2
- package/templates/.agents/skills/review-code/config/verify.en.json +2 -1
- package/templates/.agents/skills/review-code/config/verify.zh-CN.json +2 -1
- package/templates/.agents/skills/review-code/reference/report-template.en.md +2 -1
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +2 -1
- package/templates/.agents/skills/update-agent-infra/SKILL.en.md +5 -1
- package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +5 -1
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +174 -136
package/dist/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/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
|
|
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,
|
|
95
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
28
|
+
function terminateIfMatching(record) {
|
|
29
|
+
if (!processIdentityMatches(record))
|
|
30
|
+
return;
|
|
31
|
+
const command = buildStopCommand(record.pid, process.platform);
|
|
52
32
|
try {
|
|
53
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 !==
|
|
80
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
108
|
-
if (
|
|
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 (
|
|
113
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
137
|
-
if (
|
|
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
|
package/dist/lib/update.js
CHANGED
|
@@ -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.`);
|