@fitlab-ai/agent-infra 0.7.6 → 0.8.0
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/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/bin/cli.ts +53 -18
- package/dist/bin/cli.js +56 -19
- package/dist/lib/decide.js +110 -0
- package/dist/lib/run/host.js +39 -0
- package/dist/lib/run/index.js +102 -0
- package/dist/lib/run/prompt.js +2 -0
- package/dist/lib/run/skills.js +29 -0
- package/dist/lib/run/tui.js +35 -0
- package/dist/lib/sandbox/capture.js +49 -0
- package/dist/lib/sandbox/commands/create.js +55 -6
- package/dist/lib/sandbox/commands/ls.js +4 -33
- package/dist/lib/sandbox/commands/show.js +67 -0
- package/dist/lib/sandbox/index.js +7 -0
- package/dist/lib/server/adapters/_contract.js +9 -0
- package/dist/lib/server/adapters/feishu/index.js +40 -0
- package/dist/lib/server/adapters/feishu/transport.js +95 -0
- package/dist/lib/server/auth.js +19 -0
- package/dist/lib/server/config.js +183 -0
- package/dist/lib/server/daemon.js +96 -0
- package/dist/lib/server/index.js +47 -0
- package/dist/lib/server/logger.js +34 -0
- package/dist/lib/server/plugin-loader.js +73 -0
- package/dist/lib/server/process-control.js +200 -0
- package/dist/lib/server/protocol.js +69 -0
- package/dist/lib/server/redact.js +12 -0
- package/dist/lib/server/runner.js +75 -0
- package/dist/lib/server/streamer.js +44 -0
- package/dist/lib/task/commands/decisions.js +258 -0
- package/dist/lib/task/commands/issue-body.js +94 -0
- package/dist/lib/task/commands/log.js +67 -9
- package/dist/lib/task/index.js +17 -0
- package/dist/lib/task/issue-form.js +66 -0
- package/dist/lib/task/ledger.js +66 -0
- package/dist/lib/task/sections.js +73 -0
- package/lib/decide.ts +124 -0
- package/lib/run/host.ts +47 -0
- package/lib/run/index.ts +124 -0
- package/lib/run/prompt.ts +1 -0
- package/lib/run/skills.ts +34 -0
- package/lib/run/tui.ts +40 -0
- package/lib/sandbox/capture.ts +84 -0
- package/lib/sandbox/commands/create.ts +61 -9
- package/lib/sandbox/commands/ls.ts +4 -36
- package/lib/sandbox/commands/show.ts +80 -0
- package/lib/sandbox/index.ts +7 -0
- package/lib/server/adapters/_contract.ts +42 -0
- package/lib/server/adapters/feishu/index.ts +47 -0
- package/lib/server/adapters/feishu/transport.ts +128 -0
- package/lib/server/auth.ts +28 -0
- package/lib/server/config.ts +228 -0
- package/lib/server/daemon.ts +110 -0
- package/lib/server/index.ts +50 -0
- package/lib/server/logger.ts +48 -0
- package/lib/server/plugin-loader.ts +84 -0
- package/lib/server/process-control.ts +225 -0
- package/lib/server/protocol.ts +79 -0
- package/lib/server/redact.ts +12 -0
- package/lib/server/runner.ts +95 -0
- package/lib/server/server.schema.json +127 -0
- package/lib/server/streamer.ts +57 -0
- package/lib/task/commands/decisions.ts +272 -0
- package/lib/task/commands/issue-body.ts +102 -0
- package/lib/task/commands/log.ts +73 -9
- package/lib/task/index.ts +17 -0
- package/lib/task/issue-form.ts +77 -0
- package/lib/task/ledger.ts +73 -0
- package/lib/task/sections.ts +73 -0
- package/package.json +9 -4
- package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
- package/templates/.agents/README.en.md +3 -1
- package/templates/.agents/README.zh-CN.md +3 -1
- package/templates/.agents/rules/create-issue.github.en.md +20 -29
- package/templates/.agents/rules/create-issue.github.zh-CN.md +20 -29
- package/templates/.agents/rules/issue-fields.github.en.md +1 -1
- package/templates/.agents/rules/issue-fields.github.zh-CN.md +1 -1
- package/templates/.agents/rules/issue-pr-commands.github.en.md +6 -0
- package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +6 -0
- package/templates/.agents/rules/next-step-output.en.md +30 -1
- package/templates/.agents/rules/next-step-output.zh-CN.md +30 -1
- package/templates/.agents/rules/no-mid-flow-questions.en.md +7 -2
- package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +7 -2
- package/templates/.agents/rules/review-handshake.en.md +5 -1
- package/templates/.agents/rules/review-handshake.zh-CN.md +5 -1
- package/templates/.agents/rules/task-management.en.md +3 -1
- package/templates/.agents/rules/task-management.zh-CN.md +3 -1
- package/templates/.agents/scripts/lib/post-review-commit.js +15 -18
- package/templates/.agents/scripts/validate-artifact.js +8 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +18 -4
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +18 -4
- package/templates/.agents/skills/analyze-task/config/verify.en.json +2 -1
- package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +2 -1
- package/templates/.agents/skills/archive-tasks/SKILL.en.md +3 -1
- package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/block-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/check-task/SKILL.en.md +30 -81
- package/templates/.agents/skills/check-task/SKILL.zh-CN.md +30 -80
- package/templates/.agents/skills/close-codescan/SKILL.en.md +3 -1
- package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/close-dependabot/SKILL.en.md +3 -1
- package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +4 -2
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +4 -2
- package/templates/.agents/skills/code-task/reference/dual-mode.en.md +7 -2
- package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +7 -2
- package/templates/.agents/skills/code-task/scripts/detect-mode.js +6 -14
- package/templates/.agents/skills/commit/SKILL.en.md +27 -5
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +27 -5
- package/templates/.agents/skills/complete-task/SKILL.en.md +4 -1
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/complete-task/config/verify.en.json +2 -1
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -1
- package/templates/.agents/skills/create-pr/SKILL.en.md +3 -1
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/create-release-note/SKILL.en.md +3 -1
- package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/create-task/SKILL.en.md +4 -4
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -4
- package/templates/.agents/skills/import-codescan/SKILL.en.md +3 -4
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +3 -4
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +3 -2
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +3 -2
- package/templates/.agents/skills/import-issue/SKILL.en.md +20 -4
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +20 -4
- package/templates/.agents/skills/init-labels/SKILL.en.md +3 -1
- package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/init-milestones/SKILL.en.md +3 -1
- package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/plan-task/SKILL.en.md +4 -3
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -3
- package/templates/.agents/skills/post-release/SKILL.en.md +3 -1
- package/templates/.agents/skills/post-release/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/refine-title/SKILL.en.md +3 -1
- package/templates/.agents/skills/refine-title/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/release/SKILL.en.md +3 -1
- package/templates/.agents/skills/release/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/restore-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/review-analysis/SKILL.en.md +6 -2
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +6 -2
- package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +1 -1
- package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +1 -1
- package/templates/.agents/skills/review-analysis/reference/report-template.en.md +1 -1
- package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +1 -2
- package/templates/.agents/skills/review-code/SKILL.en.md +5 -2
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -2
- package/templates/.agents/skills/review-code/reference/output-templates.en.md +1 -1
- package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +1 -1
- package/templates/.agents/skills/review-code/reference/report-template.en.md +1 -1
- package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +1 -2
- package/templates/.agents/skills/review-plan/SKILL.en.md +6 -2
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +6 -2
- package/templates/.agents/skills/review-plan/reference/output-templates.en.md +1 -1
- package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +1 -1
- package/templates/.agents/skills/review-plan/reference/report-template.en.md +1 -1
- package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +1 -2
- package/templates/.agents/skills/test/SKILL.en.md +3 -1
- package/templates/.agents/skills/test/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/test-integration/SKILL.en.md +3 -1
- package/templates/.agents/skills/test-integration/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/update-agent-infra/SKILL.en.md +3 -1
- package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/upgrade-dependency/SKILL.en.md +3 -1
- package/templates/.agents/skills/upgrade-dependency/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/watch-pr/SKILL.en.md +3 -1
- package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +3 -1
- package/templates/.agents/templates/task.en.md +3 -3
- package/templates/.agents/templates/task.zh-CN.md +3 -3
- package/templates/.github/workflows/metadata-sync.yml +0 -18
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { VERSION } from '../version.ts';
|
|
2
|
+
import { loadServerConfig } from './config.ts';
|
|
3
|
+
import { createLogger } from './logger.ts';
|
|
4
|
+
import { loadAdapters, unloadAdapters } from './plugin-loader.ts';
|
|
5
|
+
import type { InboundMessage } from './adapters/_contract.ts';
|
|
6
|
+
import { authorize } from './auth.ts';
|
|
7
|
+
import { commandHelp, parseCommand } from './protocol.ts';
|
|
8
|
+
import { runAi } from './runner.ts';
|
|
9
|
+
import { streamCommand } from './streamer.ts';
|
|
10
|
+
|
|
11
|
+
function errorMessage(error: unknown): string {
|
|
12
|
+
return error instanceof Error ? error.message : String(error);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The daemon main loop. Runs in the detached child spawned by
|
|
16
|
+
// process-control.start(), or in the foreground for debugging.
|
|
17
|
+
//
|
|
18
|
+
// Lifecycle (keep-alive / shutdown model):
|
|
19
|
+
// - The heartbeat interval is kept *ref'd*. It is both the keep-alive that
|
|
20
|
+
// holds the event loop open while subtask A has no adapters, and the
|
|
21
|
+
// observable signal that `ai server logs -f` shows.
|
|
22
|
+
// - runDaemon() awaits a shutdown promise that only resolves once a
|
|
23
|
+
// SIGINT/SIGTERM handler has finished graceful cleanup. We never unref()
|
|
24
|
+
// the only keep-alive timer (that would let the process exit immediately).
|
|
25
|
+
export async function runDaemon(): Promise<void> {
|
|
26
|
+
let config;
|
|
27
|
+
try {
|
|
28
|
+
config = loadServerConfig();
|
|
29
|
+
} catch (error) {
|
|
30
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const logger = createLogger(config.log);
|
|
35
|
+
logger.info(`daemon starting agent-infra ${VERSION} pid=${process.pid}`);
|
|
36
|
+
|
|
37
|
+
const abortController = new AbortController();
|
|
38
|
+
let resolveShutdown: () => void = () => {};
|
|
39
|
+
const shutdown = new Promise<void>((resolve) => {
|
|
40
|
+
resolveShutdown = resolve;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const dispatch = async (message: InboundMessage): Promise<void> => {
|
|
44
|
+
const plan = parseCommand(message.text);
|
|
45
|
+
if (plan.kind === 'ignore') return;
|
|
46
|
+
if (plan.kind === 'error') {
|
|
47
|
+
await message.reply(plan.message);
|
|
48
|
+
logger.info(`command rejected from ${message.adapter}:${message.userId}: ${plan.message}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (plan.kind === 'builtin' && plan.name === 'ping') {
|
|
52
|
+
await message.reply(`pong ${VERSION}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (plan.kind === 'builtin' && plan.name === 'help') {
|
|
56
|
+
await message.reply(commandHelp());
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (plan.kind === 'builtin' && plan.name === 'version') {
|
|
60
|
+
await message.reply(`agent-infra ${VERSION}`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (plan.kind === 'ai') {
|
|
64
|
+
const allowed = authorize(
|
|
65
|
+
{ adapter: message.adapter, userId: message.userId },
|
|
66
|
+
plan.role,
|
|
67
|
+
config.auth
|
|
68
|
+
);
|
|
69
|
+
if (!allowed.ok) {
|
|
70
|
+
await message.reply(allowed.message);
|
|
71
|
+
logger.info(`unauthorized command from ${message.adapter}:${message.userId}: ${allowed.message}`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await streamCommand(
|
|
75
|
+
{
|
|
76
|
+
title: `ai ${plan.argv.join(' ')}`,
|
|
77
|
+
chunkChars: typeof config.stream?.chunkChars === 'number' ? config.stream.chunkChars : 4000,
|
|
78
|
+
throttleMs: typeof config.stream?.throttleMs === 'number' ? config.stream.throttleMs : 1500
|
|
79
|
+
},
|
|
80
|
+
(emit) => runAi(plan.argv, { onChunk: emit }),
|
|
81
|
+
(text) => message.reply(text)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const ctx = { config, logger, dispatch, signal: abortController.signal };
|
|
87
|
+
const adapters = await loadAdapters(config, ctx);
|
|
88
|
+
logger.ok(`loaded ${adapters.length} adapter(s)`);
|
|
89
|
+
|
|
90
|
+
const heartbeat = setInterval(() => logger.info('heartbeat'), config.heartbeatMs);
|
|
91
|
+
|
|
92
|
+
let shuttingDown = false;
|
|
93
|
+
const handleSignal = (signal: NodeJS.Signals): void => {
|
|
94
|
+
if (shuttingDown) return;
|
|
95
|
+
shuttingDown = true;
|
|
96
|
+
logger.info(`received ${signal}, shutting down`);
|
|
97
|
+
abortController.abort();
|
|
98
|
+
void (async () => {
|
|
99
|
+
await unloadAdapters(adapters);
|
|
100
|
+
clearInterval(heartbeat);
|
|
101
|
+
logger.close();
|
|
102
|
+
resolveShutdown();
|
|
103
|
+
})();
|
|
104
|
+
};
|
|
105
|
+
process.on('SIGINT', () => handleSignal('SIGINT'));
|
|
106
|
+
process.on('SIGTERM', () => handleSignal('SIGTERM'));
|
|
107
|
+
|
|
108
|
+
await shutdown;
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { runDaemon } from './daemon.ts';
|
|
2
|
+
import { start, stop, status, logs } from './process-control.ts';
|
|
3
|
+
|
|
4
|
+
const USAGE = `Usage: ai server <command> [options]
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
start [--foreground] Start the local daemon (detached; --foreground stays attached for debugging)
|
|
8
|
+
stop Stop the running daemon
|
|
9
|
+
status Show whether the daemon is running
|
|
10
|
+
logs [-f | --follow] Print the daemon log ('-f' to follow new lines)
|
|
11
|
+
help Show this help message
|
|
12
|
+
|
|
13
|
+
The daemon hosts IM adapters and command routing for agent-infra.`;
|
|
14
|
+
|
|
15
|
+
export async function runServer(args: string[]): Promise<void> {
|
|
16
|
+
const [subcommand, ...rest] = args;
|
|
17
|
+
|
|
18
|
+
if (!subcommand) {
|
|
19
|
+
process.stdout.write(`${USAGE}\n`);
|
|
20
|
+
process.exitCode = 1;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
|
|
24
|
+
process.stdout.write(`${USAGE}\n`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
switch (subcommand) {
|
|
29
|
+
case 'start':
|
|
30
|
+
await start({ foreground: rest.includes('--foreground') });
|
|
31
|
+
break;
|
|
32
|
+
case 'stop':
|
|
33
|
+
await stop();
|
|
34
|
+
break;
|
|
35
|
+
case 'status':
|
|
36
|
+
status();
|
|
37
|
+
break;
|
|
38
|
+
case 'logs':
|
|
39
|
+
await logs({ follow: rest.includes('-f') || rest.includes('--follow') });
|
|
40
|
+
break;
|
|
41
|
+
// Internal entry point for the detached daemon child (not shown in USAGE).
|
|
42
|
+
case '__daemon':
|
|
43
|
+
await runDaemon();
|
|
44
|
+
break;
|
|
45
|
+
default:
|
|
46
|
+
process.stderr.write(`Unknown server command: ${subcommand}\n`);
|
|
47
|
+
process.stdout.write(`${USAGE}\n`);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type LoggerOptions = {
|
|
5
|
+
path: string;
|
|
6
|
+
rotateAtBytes: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type Logger = {
|
|
10
|
+
info: (message: string) => void;
|
|
11
|
+
ok: (message: string) => void;
|
|
12
|
+
err: (message: string) => void;
|
|
13
|
+
close: () => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Startup-only rotation: if the existing log already exceeds the threshold,
|
|
17
|
+
// move it aside to `<path>.1` before the daemon starts appending. We do not
|
|
18
|
+
// rotate again while running — keeping a single append stream is simpler and
|
|
19
|
+
// good enough for a local daemon log.
|
|
20
|
+
function rotateIfOversized(logPath: string, rotateAtBytes: number): void {
|
|
21
|
+
try {
|
|
22
|
+
const { size } = fs.statSync(logPath);
|
|
23
|
+
if (size > rotateAtBytes) {
|
|
24
|
+
fs.renameSync(logPath, `${logPath}.1`);
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// No existing log file (or stat failed) → nothing to rotate.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createLogger({ path: logPath, rotateAtBytes }: LoggerOptions): Logger {
|
|
32
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
33
|
+
rotateIfOversized(logPath, rotateAtBytes);
|
|
34
|
+
|
|
35
|
+
const write = (level: string, message: string): void => {
|
|
36
|
+
const line = `[${new Date().toISOString()}] [${level}] ${message}\n`;
|
|
37
|
+
fs.appendFileSync(logPath, line);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
info: (message) => write('INFO', message),
|
|
42
|
+
ok: (message) => write('OK', message),
|
|
43
|
+
err: (message) => write('ERROR', message),
|
|
44
|
+
// Synchronous appendFileSync keeps no open handle to flush; close() exists
|
|
45
|
+
// so the daemon shutdown path has a single, stable hook to call.
|
|
46
|
+
close: () => {}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import type { ServerConfig } from './config.ts';
|
|
4
|
+
import type { Adapter, AdapterCtx } from './adapters/_contract.ts';
|
|
5
|
+
|
|
6
|
+
export type ImportAdapter = (name: string) => Promise<unknown>;
|
|
7
|
+
|
|
8
|
+
export type LoadAdaptersOptions = {
|
|
9
|
+
// Test seam: override how an adapter module is resolved so enabled-adapter
|
|
10
|
+
// loading can be exercised without writing a fake adapter into the source
|
|
11
|
+
// tree. Production resolves `./adapters/<name>/index.ts`.
|
|
12
|
+
importAdapter?: ImportAdapter;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const defaultImportAdapter: ImportAdapter = (name) => {
|
|
16
|
+
// Test-only seam: when AGENT_INFRA_SERVER_TEST_ADAPTERS_DIR is set (never in
|
|
17
|
+
// production), resolve the named adapter as a plain ESM `.js` from that
|
|
18
|
+
// fixtures dir. This lets a built daemon (`node dist/bin/cli.js`) load a test
|
|
19
|
+
// injection adapter on any node>=22 without depending on type stripping.
|
|
20
|
+
const testDir = process.env.AGENT_INFRA_SERVER_TEST_ADAPTERS_DIR;
|
|
21
|
+
if (testDir) {
|
|
22
|
+
return import(pathToFileURL(path.join(testDir, name, 'index.js')).href);
|
|
23
|
+
}
|
|
24
|
+
return import(`./adapters/${name}/index.ts`);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function errorMessage(error: unknown): string {
|
|
28
|
+
return error instanceof Error ? error.message : String(error);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isAdapter(value: unknown): value is Adapter {
|
|
32
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
33
|
+
const candidate = value as Record<string, unknown>;
|
|
34
|
+
return (
|
|
35
|
+
typeof candidate.name === 'string' &&
|
|
36
|
+
typeof candidate.start === 'function' &&
|
|
37
|
+
typeof candidate.stop === 'function' &&
|
|
38
|
+
typeof candidate.sendMessage === 'function'
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Load every adapter whose config has `enabled === true`. Each adapter is
|
|
43
|
+
// loaded, instantiated and started in isolation: a failure in one adapter is
|
|
44
|
+
// logged and skipped so it never blocks the others.
|
|
45
|
+
export async function loadAdapters(
|
|
46
|
+
config: ServerConfig,
|
|
47
|
+
ctx: AdapterCtx,
|
|
48
|
+
options: LoadAdaptersOptions = {}
|
|
49
|
+
): Promise<Adapter[]> {
|
|
50
|
+
const importAdapter = options.importAdapter ?? defaultImportAdapter;
|
|
51
|
+
const loaded: Adapter[] = [];
|
|
52
|
+
|
|
53
|
+
for (const [name, adapterConfig] of Object.entries(config.adapters)) {
|
|
54
|
+
if (adapterConfig?.enabled !== true) continue;
|
|
55
|
+
try {
|
|
56
|
+
const mod = (await importAdapter(name)) as { default?: unknown };
|
|
57
|
+
const factory = mod?.default;
|
|
58
|
+
if (typeof factory !== 'function') {
|
|
59
|
+
throw new Error(`adapter "${name}" has no default export factory`);
|
|
60
|
+
}
|
|
61
|
+
const instance: unknown = factory(adapterConfig);
|
|
62
|
+
if (!isAdapter(instance)) {
|
|
63
|
+
throw new Error(`adapter "${name}" does not satisfy the Adapter contract`);
|
|
64
|
+
}
|
|
65
|
+
await instance.start(ctx);
|
|
66
|
+
loaded.push(instance);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
ctx.logger.err(`failed to load adapter "${name}": ${errorMessage(error)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return loaded;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Stop adapters in reverse load order, isolating per-adapter stop failures.
|
|
76
|
+
export async function unloadAdapters(adapters: Adapter[]): Promise<void> {
|
|
77
|
+
for (const adapter of [...adapters].reverse()) {
|
|
78
|
+
try {
|
|
79
|
+
await adapter.stop();
|
|
80
|
+
} catch {
|
|
81
|
+
// A failing stop must not block the rest of shutdown.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
4
|
+
import { loadServerConfig } from './config.ts';
|
|
5
|
+
import type { ServerConfig } from './config.ts';
|
|
6
|
+
import { runDaemon } from './daemon.ts';
|
|
7
|
+
|
|
8
|
+
export type StartOptions = { foreground?: boolean };
|
|
9
|
+
export type LogsOptions = { follow?: boolean };
|
|
10
|
+
|
|
11
|
+
// How to terminate the daemon on a given platform. Pure + exported so the
|
|
12
|
+
// win32 branch can be asserted without running on Windows.
|
|
13
|
+
export type StopCommand =
|
|
14
|
+
| { kind: 'signal'; signal: NodeJS.Signals }
|
|
15
|
+
| { kind: 'exec'; command: string; args: string[] };
|
|
16
|
+
|
|
17
|
+
export function buildStopCommand(pid: number, platform: NodeJS.Platform): StopCommand {
|
|
18
|
+
if (platform === 'win32') {
|
|
19
|
+
return { kind: 'exec', command: 'taskkill', args: ['/PID', String(pid), '/T', '/F'] };
|
|
20
|
+
}
|
|
21
|
+
return { kind: 'signal', signal: 'SIGTERM' };
|
|
22
|
+
}
|
|
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;
|
|
50
|
+
}
|
|
51
|
+
|
|
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;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function removePidFile(pidFile: string): void {
|
|
63
|
+
try {
|
|
64
|
+
fs.unlinkSync(pidFile);
|
|
65
|
+
} catch {
|
|
66
|
+
// Already gone.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function delay(ms: number): Promise<void> {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
setTimeout(resolve, ms);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function enabledAdapterNames(config: ServerConfig): string[] {
|
|
77
|
+
return Object.entries(config.adapters)
|
|
78
|
+
.filter(([, adapter]) => adapter?.enabled === true)
|
|
79
|
+
.map(([name]) => name);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function start({ foreground = false }: StartOptions = {}): Promise<void> {
|
|
83
|
+
const config = loadServerConfig();
|
|
84
|
+
const pidPath = config.pidFile;
|
|
85
|
+
|
|
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`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (existing !== null) {
|
|
94
|
+
removePidFile(pidPath);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (foreground) {
|
|
98
|
+
await runDaemon();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const cliEntry = process.argv[1];
|
|
103
|
+
if (!cliEntry) {
|
|
104
|
+
throw new Error('server: unable to determine CLI entry point for daemon spawn');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Re-spawn ourselves detached. process.execArgv is forwarded so the dev path
|
|
108
|
+
// (node --experimental-strip-types ./bin/cli.ts) and the built path
|
|
109
|
+
// (node dist/bin/cli.js) both work.
|
|
110
|
+
const child = spawn(process.execPath, [...process.execArgv, cliEntry, 'server', '__daemon'], {
|
|
111
|
+
detached: true,
|
|
112
|
+
stdio: 'ignore'
|
|
113
|
+
});
|
|
114
|
+
child.unref();
|
|
115
|
+
|
|
116
|
+
if (typeof child.pid !== 'number') {
|
|
117
|
+
throw new Error('server: failed to spawn daemon process');
|
|
118
|
+
}
|
|
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`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function stop(): Promise<void> {
|
|
125
|
+
const config = loadServerConfig();
|
|
126
|
+
const pid = readPid(config.pidFile);
|
|
127
|
+
|
|
128
|
+
if (pid === null) {
|
|
129
|
+
process.stdout.write('server is not running (no pid file)\n');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!isProcessAlive(pid)) {
|
|
133
|
+
removePidFile(config.pidFile);
|
|
134
|
+
process.stdout.write('server is not running (removed stale pid file)\n');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const command = buildStopCommand(pid, process.platform);
|
|
139
|
+
if (command.kind === 'exec') {
|
|
140
|
+
execFileSync(command.command, command.args);
|
|
141
|
+
} else {
|
|
142
|
+
process.kill(pid, command.signal);
|
|
143
|
+
const deadline = Date.now() + 5000;
|
|
144
|
+
while (isProcessAlive(pid) && Date.now() < deadline) {
|
|
145
|
+
await delay(100);
|
|
146
|
+
}
|
|
147
|
+
if (isProcessAlive(pid)) {
|
|
148
|
+
process.kill(pid, 'SIGKILL');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
removePidFile(config.pidFile);
|
|
153
|
+
process.stdout.write(`server stopped (pid ${pid})\n`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function status(): void {
|
|
157
|
+
const config = loadServerConfig();
|
|
158
|
+
const pid = readPid(config.pidFile);
|
|
159
|
+
|
|
160
|
+
if (pid === null || !isProcessAlive(pid)) {
|
|
161
|
+
process.stdout.write('server: stopped\n');
|
|
162
|
+
if (pid !== null) {
|
|
163
|
+
process.stdout.write(` (stale pid file references pid ${pid})\n`);
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let startedAt = 'unknown';
|
|
169
|
+
try {
|
|
170
|
+
startedAt = fs.statSync(config.pidFile).mtime.toISOString();
|
|
171
|
+
} catch {
|
|
172
|
+
// Leave as unknown.
|
|
173
|
+
}
|
|
174
|
+
const adapters = enabledAdapterNames(config);
|
|
175
|
+
process.stdout.write(
|
|
176
|
+
`server: running\n` +
|
|
177
|
+
` pid: ${pid}\n` +
|
|
178
|
+
` started: ${startedAt}\n` +
|
|
179
|
+
` adapters: ${adapters.length > 0 ? adapters.join(', ') : '(none)'}\n` +
|
|
180
|
+
` pid file: ${config.pidFile}\n` +
|
|
181
|
+
` log: ${config.log.path}\n`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function logs({ follow = false }: LogsOptions = {}): Promise<void> {
|
|
186
|
+
const config = loadServerConfig();
|
|
187
|
+
const logPath = config.log.path;
|
|
188
|
+
|
|
189
|
+
if (!fs.existsSync(logPath)) {
|
|
190
|
+
process.stdout.write('server: no log file yet\n');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const initial = fs.readFileSync(logPath, 'utf8');
|
|
195
|
+
process.stdout.write(initial);
|
|
196
|
+
if (!follow) return;
|
|
197
|
+
|
|
198
|
+
let position = Buffer.byteLength(initial);
|
|
199
|
+
const watcher = fs.watch(logPath, () => {
|
|
200
|
+
try {
|
|
201
|
+
const { size } = fs.statSync(logPath);
|
|
202
|
+
if (size < position) position = 0; // truncated or rotated
|
|
203
|
+
if (size > position) {
|
|
204
|
+
const fd = fs.openSync(logPath, 'r');
|
|
205
|
+
try {
|
|
206
|
+
const buffer = Buffer.alloc(size - position);
|
|
207
|
+
fs.readSync(fd, buffer, 0, buffer.length, position);
|
|
208
|
+
process.stdout.write(buffer.toString('utf8'));
|
|
209
|
+
} finally {
|
|
210
|
+
fs.closeSync(fd);
|
|
211
|
+
}
|
|
212
|
+
position = size;
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// Transient read error during rotation; ignore and wait for next event.
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await new Promise<void>((resolve) => {
|
|
220
|
+
process.on('SIGINT', () => {
|
|
221
|
+
watcher.close();
|
|
222
|
+
resolve();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { VERSION } from '../version.ts';
|
|
2
|
+
import { getSkillRunSpec } from '../run/skills.ts';
|
|
3
|
+
import type { CommandRole } from './auth.ts';
|
|
4
|
+
|
|
5
|
+
export type CommandPlan =
|
|
6
|
+
| { kind: 'ignore' }
|
|
7
|
+
| { kind: 'error'; message: string }
|
|
8
|
+
| { kind: 'builtin'; name: 'ping' | 'help' | 'version'; role: 'read'; args: string[] }
|
|
9
|
+
| { kind: 'ai'; role: CommandRole; argv: string[] };
|
|
10
|
+
|
|
11
|
+
const TASK_READ = new Set(['ls', 'list', 'status', 'show', 'log', 'decisions']);
|
|
12
|
+
const SANDBOX_READ = new Set(['ls', 'list', 'show']);
|
|
13
|
+
const SANDBOX_WRITE = new Set(['create', 'start']);
|
|
14
|
+
|
|
15
|
+
function splitWords(text: string): string[] {
|
|
16
|
+
return text.trim().split(/\s+/).filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function taskSubcommand(subcommand: string): string {
|
|
20
|
+
return subcommand === 'list' ? 'ls' : subcommand;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function commandHelp(): string {
|
|
24
|
+
return [
|
|
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> ...'
|
|
30
|
+
].join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function parseCommand(text: string): CommandPlan {
|
|
34
|
+
const trimmed = text.trim();
|
|
35
|
+
if (!trimmed.startsWith('/')) return { kind: 'ignore' };
|
|
36
|
+
const words = splitWords(trimmed);
|
|
37
|
+
const [command = '', subcommand = '', ...rest] = words;
|
|
38
|
+
|
|
39
|
+
if (command === '/help') return { kind: 'builtin', name: 'help', role: 'read', args: rest };
|
|
40
|
+
if (command === '/ping') return { kind: 'builtin', name: 'ping', role: 'read', args: rest };
|
|
41
|
+
if (command === '/version') return { kind: 'builtin', name: 'version', role: 'read', args: rest };
|
|
42
|
+
|
|
43
|
+
if (command === '/decide') {
|
|
44
|
+
return { kind: 'ai', role: 'exec', argv: ['decide', subcommand, ...rest] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (command === '/task') {
|
|
48
|
+
if (!TASK_READ.has(subcommand)) return { kind: 'error', message: 'Unknown /task command' };
|
|
49
|
+
return { kind: 'ai', role: 'read', argv: ['task', taskSubcommand(subcommand), ...rest] };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (command === '/sandbox') {
|
|
53
|
+
if (subcommand === 'vm') {
|
|
54
|
+
if (rest[0] !== 'status') return { kind: 'error', message: 'Only /sandbox vm status is allowed' };
|
|
55
|
+
return { kind: 'ai', role: 'read', argv: ['sandbox', 'vm', 'status'] };
|
|
56
|
+
}
|
|
57
|
+
if (SANDBOX_READ.has(subcommand)) {
|
|
58
|
+
return { kind: 'ai', role: 'read', argv: ['sandbox', taskSubcommand(subcommand), ...rest] };
|
|
59
|
+
}
|
|
60
|
+
if (SANDBOX_WRITE.has(subcommand)) {
|
|
61
|
+
return { kind: 'ai', role: 'write', argv: ['sandbox', subcommand, ...rest] };
|
|
62
|
+
}
|
|
63
|
+
if (subcommand === 'rm') {
|
|
64
|
+
return {
|
|
65
|
+
kind: 'error',
|
|
66
|
+
message: '/sandbox rm is not available from IM because it requires interactive confirmation'
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { kind: 'error', message: 'Unknown /sandbox command' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (command === '/run') {
|
|
73
|
+
const spec = getSkillRunSpec(subcommand);
|
|
74
|
+
if (!spec) return { kind: 'error', message: `Unknown skill: ${subcommand}` };
|
|
75
|
+
return { kind: 'ai', role: spec.role, argv: ['run', subcommand, ...rest] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { kind: 'error', message: `Unknown command: ${command}` };
|
|
79
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const SECRET_PATTERNS = [
|
|
2
|
+
/\b(token|secret|password|passwd|api[_-]?key)=([^\s]+)/gi,
|
|
3
|
+
/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/g
|
|
4
|
+
];
|
|
5
|
+
|
|
6
|
+
export function redactSecrets(text: string): string {
|
|
7
|
+
let out = text;
|
|
8
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
9
|
+
out = out.replace(pattern, (_match, prefix) => `${prefix}=<redacted>`);
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|