@fitlab-ai/agent-infra 0.7.7 → 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 +42 -15
- package/dist/bin/cli.js +46 -16
- 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/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/log.js +2 -27
- package/dist/lib/task/index.js +9 -0
- package/dist/lib/task/ledger.js +66 -0
- package/dist/lib/task/sections.js +30 -1
- 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/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/log.ts +2 -25
- package/lib/task/index.ts +9 -0
- package/lib/task/ledger.ts +73 -0
- package/lib/task/sections.ts +30 -1
- package/package.json +7 -3
- package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
- 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/next-step-output.en.md +4 -0
- package/templates/.agents/rules/next-step-output.zh-CN.md +4 -0
- 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 +2 -2
- package/templates/.agents/rules/task-management.zh-CN.md +2 -2
- 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 +15 -3
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +15 -3
- 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/code-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/complete-task/SKILL.en.md +1 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -0
- 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-task/SKILL.en.md +1 -3
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -3
- package/templates/.agents/skills/import-codescan/SKILL.en.md +0 -3
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +0 -3
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +0 -1
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +0 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +1 -2
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -2
- package/templates/.agents/skills/plan-task/SKILL.en.md +1 -2
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +1 -2
- 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
|
@@ -514,6 +514,58 @@ function readHostJsonSafe(filePath) {
|
|
|
514
514
|
return null;
|
|
515
515
|
}
|
|
516
516
|
}
|
|
517
|
+
const CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS = [
|
|
518
|
+
'model',
|
|
519
|
+
'fallbackModel',
|
|
520
|
+
'availableModels',
|
|
521
|
+
'modelOverrides',
|
|
522
|
+
'enforceAvailableModels',
|
|
523
|
+
'advisorModel',
|
|
524
|
+
'apiKeyHelper',
|
|
525
|
+
'effortLevel'
|
|
526
|
+
];
|
|
527
|
+
function isJsonObjectRecord(value) {
|
|
528
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
529
|
+
}
|
|
530
|
+
function mergeMissingStringEnvFields(target, source) {
|
|
531
|
+
if (!isJsonObjectRecord(source.env)) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
if (Object.hasOwn(target, 'env') && !isJsonObjectRecord(target.env)) {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
let targetEnv = target.env;
|
|
538
|
+
let changed = false;
|
|
539
|
+
for (const [key, value] of Object.entries(source.env)) {
|
|
540
|
+
if (typeof value !== 'string' || value === '') {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (!targetEnv) {
|
|
544
|
+
targetEnv = {};
|
|
545
|
+
target.env = targetEnv;
|
|
546
|
+
}
|
|
547
|
+
if (!Object.hasOwn(targetEnv, key)) {
|
|
548
|
+
targetEnv[key] = value;
|
|
549
|
+
changed = true;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return changed;
|
|
553
|
+
}
|
|
554
|
+
function mergeMissingTopLevelSettings(target, source) {
|
|
555
|
+
let changed = false;
|
|
556
|
+
for (const key of CLAUDE_SETTINGS_INHERIT_TOP_LEVEL_KEYS) {
|
|
557
|
+
if (!Object.hasOwn(source, key) || Object.hasOwn(target, key)) {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const value = source[key];
|
|
561
|
+
if (value === null || value === undefined || value === '') {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
target[key] = value;
|
|
565
|
+
changed = true;
|
|
566
|
+
}
|
|
567
|
+
return changed;
|
|
568
|
+
}
|
|
517
569
|
export function ensureClaudeOnboarding(toolDir, hostHomeDir) {
|
|
518
570
|
const claudeJsonPath = path.join(toolDir, '.claude.json');
|
|
519
571
|
let data = {};
|
|
@@ -592,12 +644,9 @@ export function ensureClaudeSettings(toolDir, hostHomeDir) {
|
|
|
592
644
|
}
|
|
593
645
|
if (hostHomeDir) {
|
|
594
646
|
const hostSettings = readHostJsonSafe(path.join(hostHomeDir, '.claude', 'settings.json'));
|
|
595
|
-
if (hostSettings
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
&& !Object.hasOwn(data, 'effortLevel')) {
|
|
599
|
-
data.effortLevel = hostSettings.effortLevel;
|
|
600
|
-
changed = true;
|
|
647
|
+
if (hostSettings) {
|
|
648
|
+
changed = mergeMissingStringEnvFields(data, hostSettings) || changed;
|
|
649
|
+
changed = mergeMissingTopLevelSettings(data, hostSettings) || changed;
|
|
601
650
|
}
|
|
602
651
|
}
|
|
603
652
|
if (changed) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Adapter contract for agent-infra-server.
|
|
2
|
+
//
|
|
3
|
+
// This is a pure type + constant module: it carries no runtime logic and no
|
|
4
|
+
// third-party imports. Subtask B (the feishu adapter) and subtask C (the
|
|
5
|
+
// command protocol / runner) build against these shapes. Bump
|
|
6
|
+
// ADAPTER_CONTRACT_VERSION on a breaking change so plugin-loader can reject
|
|
7
|
+
// adapters compiled against an older contract.
|
|
8
|
+
export const ADAPTER_CONTRACT_VERSION = 1;
|
|
9
|
+
//# sourceMappingURL=_contract.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createFeishuTransport, normalizeMessage } from "./transport.js";
|
|
2
|
+
// Assemble the feishu adapter. The transport is injectable so unit tests can
|
|
3
|
+
// drive start/dispatch/reply/sendMessage against a fake transport without the
|
|
4
|
+
// real SDK; production uses the default SDK-backed transport.
|
|
5
|
+
export function createFeishuAdapter(config, transport = createFeishuTransport(config)) {
|
|
6
|
+
let ctx = null;
|
|
7
|
+
return {
|
|
8
|
+
name: 'feishu',
|
|
9
|
+
async start(adapterCtx) {
|
|
10
|
+
ctx = adapterCtx;
|
|
11
|
+
await transport.start(async (raw) => {
|
|
12
|
+
try {
|
|
13
|
+
const normalized = normalizeMessage(raw);
|
|
14
|
+
const message = {
|
|
15
|
+
adapter: 'feishu',
|
|
16
|
+
userId: normalized.userId,
|
|
17
|
+
chatId: normalized.chatId,
|
|
18
|
+
text: normalized.text,
|
|
19
|
+
messageId: normalized.messageId,
|
|
20
|
+
raw: normalized.raw,
|
|
21
|
+
reply: (text) => transport.send(normalized.chatId, text)
|
|
22
|
+
};
|
|
23
|
+
await adapterCtx.dispatch(message);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
ctx?.logger.err(`feishu: dropped message: ${error instanceof Error ? error.message : String(error)}`);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
async stop() {
|
|
31
|
+
await transport.stop();
|
|
32
|
+
},
|
|
33
|
+
async sendMessage(target, text) {
|
|
34
|
+
await transport.send(target.chatId, text);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const factory = (adapterConfig) => createFeishuAdapter(adapterConfig);
|
|
39
|
+
export default factory;
|
|
40
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as lark from '@larksuiteoapi/node-sdk';
|
|
2
|
+
function asRecord(value) {
|
|
3
|
+
return typeof value === 'object' && value !== null ? value : {};
|
|
4
|
+
}
|
|
5
|
+
// Strip feishu mention placeholders (e.g. "@_user_1") that the platform injects
|
|
6
|
+
// for @-mentions, then collapse surrounding whitespace. A group message like
|
|
7
|
+
// "@_user_1 /ping" normalizes to "/ping" so the daemon dispatcher matches it.
|
|
8
|
+
function stripMentions(text) {
|
|
9
|
+
return text.replace(/@_user_\d+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
10
|
+
}
|
|
11
|
+
// Parse an im.message.receive_v1 event into the daemon's shape. Pure: no SDK,
|
|
12
|
+
// no IO. Throws on missing/invalid fields so the adapter can drop a single bad
|
|
13
|
+
// message without crashing the long connection.
|
|
14
|
+
export function normalizeMessage(event) {
|
|
15
|
+
const message = asRecord(asRecord(event).message);
|
|
16
|
+
const messageId = message.message_id;
|
|
17
|
+
const chatId = message.chat_id;
|
|
18
|
+
const content = message.content;
|
|
19
|
+
if (typeof messageId !== 'string' || typeof chatId !== 'string' || typeof content !== 'string') {
|
|
20
|
+
throw new Error('feishu: malformed im.message.receive_v1 event (missing message_id/chat_id/content)');
|
|
21
|
+
}
|
|
22
|
+
const senderId = asRecord(asRecord(asRecord(event).sender).sender_id);
|
|
23
|
+
const userId = (typeof senderId.open_id === 'string' && senderId.open_id) ||
|
|
24
|
+
(typeof senderId.union_id === 'string' && senderId.union_id) ||
|
|
25
|
+
(typeof senderId.user_id === 'string' && senderId.user_id) ||
|
|
26
|
+
'';
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(content);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error('feishu: message.content is not valid JSON');
|
|
33
|
+
}
|
|
34
|
+
const rawText = asRecord(parsed).text;
|
|
35
|
+
if (typeof rawText !== 'string') {
|
|
36
|
+
throw new Error('feishu: unsupported message content (no text field)');
|
|
37
|
+
}
|
|
38
|
+
return { userId, chatId, text: stripMentions(rawText), messageId, raw: event };
|
|
39
|
+
}
|
|
40
|
+
function resolveDomain(value) {
|
|
41
|
+
return value === 'lark' || value === 'Lark' ? lark.Domain.Lark : lark.Domain.Feishu;
|
|
42
|
+
}
|
|
43
|
+
// Build the real SDK-backed transport from adapter config. appId/appSecret come
|
|
44
|
+
// from server config (appSecret only via server.local.json / env). The WSClient
|
|
45
|
+
// long connection delivers events; the Client REST call sends replies.
|
|
46
|
+
// Feishu self-built app IDs have this shape; the SDK's WSClient.start() silently
|
|
47
|
+
// rejects anything else, so we validate up front to fail loudly instead.
|
|
48
|
+
const APP_ID_PATTERN = /^cli_[0-9a-fA-F]{16}$/;
|
|
49
|
+
export function createFeishuTransport(config) {
|
|
50
|
+
const appId = String(config.appId ?? '').trim();
|
|
51
|
+
const appSecret = String(config.appSecret ?? '').trim();
|
|
52
|
+
const domain = resolveDomain(config.domain);
|
|
53
|
+
// Fail fast on missing or malformed credentials. The SDK only logs to its own
|
|
54
|
+
// logger and resolves WSClient.start() for an empty or malformed appId, so
|
|
55
|
+
// without these checks an enabled-but-misconfigured feishu adapter would be
|
|
56
|
+
// counted as loaded while the long connection never opens. Throwing here lets
|
|
57
|
+
// loadAdapters log `failed to load adapter "feishu": ...` and skip it.
|
|
58
|
+
if (appId === '' || appSecret === '') {
|
|
59
|
+
throw new Error('feishu: appId and appSecret are required; put appSecret in .agents/server.local.json or AGENT_INFRA_SERVER_adapters__feishu__appSecret');
|
|
60
|
+
}
|
|
61
|
+
if (!APP_ID_PATTERN.test(appId)) {
|
|
62
|
+
throw new Error('feishu: appId must match cli_[0-9a-fA-F]{16}');
|
|
63
|
+
}
|
|
64
|
+
const client = new lark.Client({ appId, appSecret, domain });
|
|
65
|
+
const wsClient = new lark.WSClient({ appId, appSecret, domain, loggerLevel: lark.LoggerLevel.warn });
|
|
66
|
+
return {
|
|
67
|
+
async start(onMessage) {
|
|
68
|
+
const eventDispatcher = new lark.EventDispatcher({}).register({
|
|
69
|
+
'im.message.receive_v1': async (data) => {
|
|
70
|
+
await onMessage(data);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
wsClient.start({ eventDispatcher });
|
|
74
|
+
},
|
|
75
|
+
async stop() {
|
|
76
|
+
// Best-effort: close the long connection if the SDK exposes it. Never throw
|
|
77
|
+
// from shutdown — unloadAdapters isolates stop failures, but staying quiet
|
|
78
|
+
// keeps the daemon shutdown path clean.
|
|
79
|
+
const closable = wsClient;
|
|
80
|
+
try {
|
|
81
|
+
closable.close?.();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Ignore: the daemon is shutting down regardless.
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
async send(chatId, text) {
|
|
88
|
+
await client.im.message.create({
|
|
89
|
+
params: { receive_id_type: 'chat_id' },
|
|
90
|
+
data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) }
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=transport.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const ROLE_RANK = { read: 1, write: 2, exec: 3 };
|
|
2
|
+
function userRole(auth, key) {
|
|
3
|
+
const users = auth.users;
|
|
4
|
+
if (!users || typeof users !== 'object' || Array.isArray(users))
|
|
5
|
+
return null;
|
|
6
|
+
const record = users[key];
|
|
7
|
+
if (!record || typeof record !== 'object' || Array.isArray(record))
|
|
8
|
+
return null;
|
|
9
|
+
const role = record.role;
|
|
10
|
+
return role === 'read' || role === 'write' || role === 'exec' ? role : null;
|
|
11
|
+
}
|
|
12
|
+
export function authorize(user, required, auth) {
|
|
13
|
+
const role = userRole(auth ?? {}, `${user.adapter}:${user.userId}`);
|
|
14
|
+
if (!role || ROLE_RANK[role] < ROLE_RANK[required]) {
|
|
15
|
+
return { ok: false, message: `requires ${required}` };
|
|
16
|
+
}
|
|
17
|
+
return { ok: true };
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
const ENV_PREFIX = 'AGENT_INFRA_SERVER_';
|
|
7
|
+
// Keys whose presence in the *committed* server.json is treated as a leaked
|
|
8
|
+
// secret. Secrets belong in .agents/server.local.json or the environment.
|
|
9
|
+
const SECRET_KEY_PATTERN = /secret|token|password|passwd|credential|apikey|api_key/i;
|
|
10
|
+
const DEFAULT_ROTATE_BYTES = 52_428_800; // 50 MiB
|
|
11
|
+
export const DEFAULT_SERVER_CONFIG = {
|
|
12
|
+
log: { rotateAtBytes: DEFAULT_ROTATE_BYTES },
|
|
13
|
+
heartbeatMs: 30_000,
|
|
14
|
+
adapters: {}
|
|
15
|
+
};
|
|
16
|
+
function isPlainObject(value) {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
// Daemon runtime state (log + PID) lives OUTSIDE the repo, under the user's home
|
|
20
|
+
// directory, keyed by the .airc.json "project" AND a stable hash of the repo
|
|
21
|
+
// root path:
|
|
22
|
+
// ~/.agent-infra/logs/<project>/<repo-hash>/server.log
|
|
23
|
+
// ~/.agent-infra/run/<project>/<repo-hash>/server.pid
|
|
24
|
+
// The <project> segment groups a project's checkouts for readability; the
|
|
25
|
+
// <repo-hash> segment guarantees that two checkouts/worktrees of the same
|
|
26
|
+
// project (same "project" but different absolute path) get ISOLATED runtime
|
|
27
|
+
// dirs, so they never read/control each other's daemon. Using os.homedir() +
|
|
28
|
+
// path.join keeps this correct on Windows too (C:\Users\<name>\.agent-infra\...).
|
|
29
|
+
// An explicit log.path in server.json/.local/env still overrides the log default.
|
|
30
|
+
function resolveProjectKey(repoRoot) {
|
|
31
|
+
try {
|
|
32
|
+
const airc = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
|
|
33
|
+
if (typeof airc.project === 'string' && airc.project.trim() !== '') {
|
|
34
|
+
return airc.project.trim();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// No .airc.json / unreadable → fall back to the repo directory name.
|
|
39
|
+
}
|
|
40
|
+
return path.basename(repoRoot);
|
|
41
|
+
}
|
|
42
|
+
// Short, stable, filesystem-safe discriminator for a checkout's absolute path.
|
|
43
|
+
function repoKey(repoRoot) {
|
|
44
|
+
return createHash('sha256').update(repoRoot).digest('hex').slice(0, 12);
|
|
45
|
+
}
|
|
46
|
+
function runtimePath(repoRoot, projectKey, kind, file) {
|
|
47
|
+
return path.join(homedir(), '.agent-infra', kind, projectKey, repoKey(repoRoot), file);
|
|
48
|
+
}
|
|
49
|
+
function detectRepoRoot() {
|
|
50
|
+
try {
|
|
51
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
54
|
+
}).trim();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error('server: current directory is not inside a git repository');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Plain-object deep merge: objects recurse, everything else (arrays, scalars)
|
|
61
|
+
// replaces. Intentionally small — server config is shallow and this avoids
|
|
62
|
+
// coupling to lib/merge.ts, whose semantics target the task workspace.
|
|
63
|
+
function deepMerge(base, override) {
|
|
64
|
+
const result = { ...base };
|
|
65
|
+
for (const [key, value] of Object.entries(override)) {
|
|
66
|
+
const current = result[key];
|
|
67
|
+
if (isPlainObject(current) && isPlainObject(value)) {
|
|
68
|
+
result[key] = deepMerge(current, value);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
result[key] = value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
function readJsonIfPresent(filePath) {
|
|
77
|
+
if (!fs.existsSync(filePath))
|
|
78
|
+
return {};
|
|
79
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
80
|
+
if (!isPlainObject(parsed)) {
|
|
81
|
+
throw new Error(`server: ${path.basename(filePath)} must contain a JSON object`);
|
|
82
|
+
}
|
|
83
|
+
return parsed;
|
|
84
|
+
}
|
|
85
|
+
function coerceEnvValue(raw) {
|
|
86
|
+
if (raw === 'true')
|
|
87
|
+
return true;
|
|
88
|
+
if (raw === 'false')
|
|
89
|
+
return false;
|
|
90
|
+
if (raw !== '' && !Number.isNaN(Number(raw)))
|
|
91
|
+
return Number(raw);
|
|
92
|
+
return raw;
|
|
93
|
+
}
|
|
94
|
+
// Map AGENT_INFRA_SERVER_<path> env vars into a nested override object. The
|
|
95
|
+
// path after the prefix uses `__` to separate nesting levels and is treated
|
|
96
|
+
// case-sensitively (e.g. AGENT_INFRA_SERVER_adapters__dev__enabled=false ->
|
|
97
|
+
// { adapters: { dev: { enabled: false } } }).
|
|
98
|
+
function envOverrides(env) {
|
|
99
|
+
const override = {};
|
|
100
|
+
for (const [key, rawValue] of Object.entries(env)) {
|
|
101
|
+
if (!key.startsWith(ENV_PREFIX) || rawValue === undefined)
|
|
102
|
+
continue;
|
|
103
|
+
const segments = key.slice(ENV_PREFIX.length).split('__').filter(Boolean);
|
|
104
|
+
if (segments.length === 0)
|
|
105
|
+
continue;
|
|
106
|
+
let cursor = override;
|
|
107
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
108
|
+
const segment = segments[i];
|
|
109
|
+
const next = cursor[segment];
|
|
110
|
+
if (!isPlainObject(next)) {
|
|
111
|
+
cursor[segment] = {};
|
|
112
|
+
}
|
|
113
|
+
cursor = cursor[segment];
|
|
114
|
+
}
|
|
115
|
+
cursor[segments[segments.length - 1]] = coerceEnvValue(rawValue);
|
|
116
|
+
}
|
|
117
|
+
return override;
|
|
118
|
+
}
|
|
119
|
+
// Collect dotted paths of secret-looking, non-empty string fields.
|
|
120
|
+
function collectSecretFields(value, trail = []) {
|
|
121
|
+
if (!isPlainObject(value))
|
|
122
|
+
return [];
|
|
123
|
+
const found = [];
|
|
124
|
+
for (const [key, child] of Object.entries(value)) {
|
|
125
|
+
const here = [...trail, key];
|
|
126
|
+
if (SECRET_KEY_PATTERN.test(key) && typeof child === 'string' && child.trim() !== '') {
|
|
127
|
+
found.push(here.join('.'));
|
|
128
|
+
}
|
|
129
|
+
else if (isPlainObject(child)) {
|
|
130
|
+
found.push(...collectSecretFields(child, here));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return found;
|
|
134
|
+
}
|
|
135
|
+
// Reject secrets that live in the committed server.json. server.local.json and
|
|
136
|
+
// the environment are the sanctioned places for secrets and are not scanned.
|
|
137
|
+
export function validateServerConfig(committed) {
|
|
138
|
+
const fields = collectSecretFields(committed);
|
|
139
|
+
if (fields.length > 0) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
fields,
|
|
143
|
+
error: `server: refusing to start — secret-like field(s) found in committed .agents/server.json: ${fields.join(', ')}. ` +
|
|
144
|
+
'Move them to .agents/server.local.json or AGENT_INFRA_SERVER_* environment variables.'
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { ok: true };
|
|
148
|
+
}
|
|
149
|
+
export function loadServerConfig({ rootDir } = {}) {
|
|
150
|
+
const repoRoot = rootDir ?? detectRepoRoot();
|
|
151
|
+
const agentsDir = path.join(repoRoot, '.agents');
|
|
152
|
+
const committed = readJsonIfPresent(path.join(agentsDir, 'server.json'));
|
|
153
|
+
const validation = validateServerConfig(committed);
|
|
154
|
+
if (!validation.ok) {
|
|
155
|
+
throw new Error(validation.error);
|
|
156
|
+
}
|
|
157
|
+
const local = readJsonIfPresent(path.join(agentsDir, 'server.local.json'));
|
|
158
|
+
let merged = deepMerge({ log: { rotateAtBytes: DEFAULT_ROTATE_BYTES }, heartbeatMs: DEFAULT_SERVER_CONFIG.heartbeatMs, adapters: {} }, committed);
|
|
159
|
+
merged = deepMerge(merged, local);
|
|
160
|
+
merged = deepMerge(merged, envOverrides(process.env));
|
|
161
|
+
const projectKey = resolveProjectKey(repoRoot);
|
|
162
|
+
const log = isPlainObject(merged.log) ? merged.log : {};
|
|
163
|
+
// No explicit log.path → default under ~/.agent-infra/logs/<project>/.
|
|
164
|
+
// Explicit relative path resolves against the repo root; absolute is used as-is.
|
|
165
|
+
const explicitPath = typeof log.path === 'string' ? log.path : null;
|
|
166
|
+
const resolvedLogPath = explicitPath === null
|
|
167
|
+
? runtimePath(repoRoot, projectKey, 'logs', 'server.log')
|
|
168
|
+
: (path.isAbsolute(explicitPath) ? explicitPath : path.join(repoRoot, explicitPath));
|
|
169
|
+
return {
|
|
170
|
+
repoRoot,
|
|
171
|
+
pidFile: runtimePath(repoRoot, projectKey, 'run', 'server.pid'),
|
|
172
|
+
log: {
|
|
173
|
+
path: resolvedLogPath,
|
|
174
|
+
rotateAtBytes: typeof log.rotateAtBytes === 'number' ? log.rotateAtBytes : DEFAULT_ROTATE_BYTES
|
|
175
|
+
},
|
|
176
|
+
heartbeatMs: typeof merged.heartbeatMs === 'number' ? merged.heartbeatMs : DEFAULT_SERVER_CONFIG.heartbeatMs,
|
|
177
|
+
adapters: isPlainObject(merged.adapters) ? merged.adapters : {},
|
|
178
|
+
command: isPlainObject(merged.command) ? merged.command : undefined,
|
|
179
|
+
auth: isPlainObject(merged.auth) ? merged.auth : undefined,
|
|
180
|
+
stream: isPlainObject(merged.stream) ? merged.stream : undefined
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { VERSION } from "../version.js";
|
|
2
|
+
import { loadServerConfig } from "./config.js";
|
|
3
|
+
import { createLogger } from "./logger.js";
|
|
4
|
+
import { loadAdapters, unloadAdapters } from "./plugin-loader.js";
|
|
5
|
+
import { authorize } from "./auth.js";
|
|
6
|
+
import { commandHelp, parseCommand } from "./protocol.js";
|
|
7
|
+
import { runAi } from "./runner.js";
|
|
8
|
+
import { streamCommand } from "./streamer.js";
|
|
9
|
+
function errorMessage(error) {
|
|
10
|
+
return error instanceof Error ? error.message : String(error);
|
|
11
|
+
}
|
|
12
|
+
// The daemon main loop. Runs in the detached child spawned by
|
|
13
|
+
// process-control.start(), or in the foreground for debugging.
|
|
14
|
+
//
|
|
15
|
+
// Lifecycle (keep-alive / shutdown model):
|
|
16
|
+
// - The heartbeat interval is kept *ref'd*. It is both the keep-alive that
|
|
17
|
+
// holds the event loop open while subtask A has no adapters, and the
|
|
18
|
+
// observable signal that `ai server logs -f` shows.
|
|
19
|
+
// - runDaemon() awaits a shutdown promise that only resolves once a
|
|
20
|
+
// SIGINT/SIGTERM handler has finished graceful cleanup. We never unref()
|
|
21
|
+
// the only keep-alive timer (that would let the process exit immediately).
|
|
22
|
+
export async function runDaemon() {
|
|
23
|
+
let config;
|
|
24
|
+
try {
|
|
25
|
+
config = loadServerConfig();
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const logger = createLogger(config.log);
|
|
32
|
+
logger.info(`daemon starting agent-infra ${VERSION} pid=${process.pid}`);
|
|
33
|
+
const abortController = new AbortController();
|
|
34
|
+
let resolveShutdown = () => { };
|
|
35
|
+
const shutdown = new Promise((resolve) => {
|
|
36
|
+
resolveShutdown = resolve;
|
|
37
|
+
});
|
|
38
|
+
const dispatch = async (message) => {
|
|
39
|
+
const plan = parseCommand(message.text);
|
|
40
|
+
if (plan.kind === 'ignore')
|
|
41
|
+
return;
|
|
42
|
+
if (plan.kind === 'error') {
|
|
43
|
+
await message.reply(plan.message);
|
|
44
|
+
logger.info(`command rejected from ${message.adapter}:${message.userId}: ${plan.message}`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (plan.kind === 'builtin' && plan.name === 'ping') {
|
|
48
|
+
await message.reply(`pong ${VERSION}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (plan.kind === 'builtin' && plan.name === 'help') {
|
|
52
|
+
await message.reply(commandHelp());
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (plan.kind === 'builtin' && plan.name === 'version') {
|
|
56
|
+
await message.reply(`agent-infra ${VERSION}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (plan.kind === 'ai') {
|
|
60
|
+
const allowed = authorize({ adapter: message.adapter, userId: message.userId }, plan.role, config.auth);
|
|
61
|
+
if (!allowed.ok) {
|
|
62
|
+
await message.reply(allowed.message);
|
|
63
|
+
logger.info(`unauthorized command from ${message.adapter}:${message.userId}: ${allowed.message}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await streamCommand({
|
|
67
|
+
title: `ai ${plan.argv.join(' ')}`,
|
|
68
|
+
chunkChars: typeof config.stream?.chunkChars === 'number' ? config.stream.chunkChars : 4000,
|
|
69
|
+
throttleMs: typeof config.stream?.throttleMs === 'number' ? config.stream.throttleMs : 1500
|
|
70
|
+
}, (emit) => runAi(plan.argv, { onChunk: emit }), (text) => message.reply(text));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const ctx = { config, logger, dispatch, signal: abortController.signal };
|
|
74
|
+
const adapters = await loadAdapters(config, ctx);
|
|
75
|
+
logger.ok(`loaded ${adapters.length} adapter(s)`);
|
|
76
|
+
const heartbeat = setInterval(() => logger.info('heartbeat'), config.heartbeatMs);
|
|
77
|
+
let shuttingDown = false;
|
|
78
|
+
const handleSignal = (signal) => {
|
|
79
|
+
if (shuttingDown)
|
|
80
|
+
return;
|
|
81
|
+
shuttingDown = true;
|
|
82
|
+
logger.info(`received ${signal}, shutting down`);
|
|
83
|
+
abortController.abort();
|
|
84
|
+
void (async () => {
|
|
85
|
+
await unloadAdapters(adapters);
|
|
86
|
+
clearInterval(heartbeat);
|
|
87
|
+
logger.close();
|
|
88
|
+
resolveShutdown();
|
|
89
|
+
})();
|
|
90
|
+
};
|
|
91
|
+
process.on('SIGINT', () => handleSignal('SIGINT'));
|
|
92
|
+
process.on('SIGTERM', () => handleSignal('SIGTERM'));
|
|
93
|
+
await shutdown;
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=daemon.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { runDaemon } from "./daemon.js";
|
|
2
|
+
import { start, stop, status, logs } from "./process-control.js";
|
|
3
|
+
const USAGE = `Usage: ai server <command> [options]
|
|
4
|
+
|
|
5
|
+
Commands:
|
|
6
|
+
start [--foreground] Start the local daemon (detached; --foreground stays attached for debugging)
|
|
7
|
+
stop Stop the running daemon
|
|
8
|
+
status Show whether the daemon is running
|
|
9
|
+
logs [-f | --follow] Print the daemon log ('-f' to follow new lines)
|
|
10
|
+
help Show this help message
|
|
11
|
+
|
|
12
|
+
The daemon hosts IM adapters and command routing for agent-infra.`;
|
|
13
|
+
export async function runServer(args) {
|
|
14
|
+
const [subcommand, ...rest] = args;
|
|
15
|
+
if (!subcommand) {
|
|
16
|
+
process.stdout.write(`${USAGE}\n`);
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') {
|
|
21
|
+
process.stdout.write(`${USAGE}\n`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
switch (subcommand) {
|
|
25
|
+
case 'start':
|
|
26
|
+
await start({ foreground: rest.includes('--foreground') });
|
|
27
|
+
break;
|
|
28
|
+
case 'stop':
|
|
29
|
+
await stop();
|
|
30
|
+
break;
|
|
31
|
+
case 'status':
|
|
32
|
+
status();
|
|
33
|
+
break;
|
|
34
|
+
case 'logs':
|
|
35
|
+
await logs({ follow: rest.includes('-f') || rest.includes('--follow') });
|
|
36
|
+
break;
|
|
37
|
+
// Internal entry point for the detached daemon child (not shown in USAGE).
|
|
38
|
+
case '__daemon':
|
|
39
|
+
await runDaemon();
|
|
40
|
+
break;
|
|
41
|
+
default:
|
|
42
|
+
process.stderr.write(`Unknown server command: ${subcommand}\n`);
|
|
43
|
+
process.stdout.write(`${USAGE}\n`);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
// Startup-only rotation: if the existing log already exceeds the threshold,
|
|
4
|
+
// move it aside to `<path>.1` before the daemon starts appending. We do not
|
|
5
|
+
// rotate again while running — keeping a single append stream is simpler and
|
|
6
|
+
// good enough for a local daemon log.
|
|
7
|
+
function rotateIfOversized(logPath, rotateAtBytes) {
|
|
8
|
+
try {
|
|
9
|
+
const { size } = fs.statSync(logPath);
|
|
10
|
+
if (size > rotateAtBytes) {
|
|
11
|
+
fs.renameSync(logPath, `${logPath}.1`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// No existing log file (or stat failed) → nothing to rotate.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function createLogger({ path: logPath, rotateAtBytes }) {
|
|
19
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
20
|
+
rotateIfOversized(logPath, rotateAtBytes);
|
|
21
|
+
const write = (level, message) => {
|
|
22
|
+
const line = `[${new Date().toISOString()}] [${level}] ${message}\n`;
|
|
23
|
+
fs.appendFileSync(logPath, line);
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
info: (message) => write('INFO', message),
|
|
27
|
+
ok: (message) => write('OK', message),
|
|
28
|
+
err: (message) => write('ERROR', message),
|
|
29
|
+
// Synchronous appendFileSync keeps no open handle to flush; close() exists
|
|
30
|
+
// so the daemon shutdown path has a single, stable hook to call.
|
|
31
|
+
close: () => { }
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=logger.js.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { pathToFileURL } from 'node:url';
|
|
11
|
+
const defaultImportAdapter = (name) => {
|
|
12
|
+
// Test-only seam: when AGENT_INFRA_SERVER_TEST_ADAPTERS_DIR is set (never in
|
|
13
|
+
// production), resolve the named adapter as a plain ESM `.js` from that
|
|
14
|
+
// fixtures dir. This lets a built daemon (`node dist/bin/cli.js`) load a test
|
|
15
|
+
// injection adapter on any node>=22 without depending on type stripping.
|
|
16
|
+
const testDir = process.env.AGENT_INFRA_SERVER_TEST_ADAPTERS_DIR;
|
|
17
|
+
if (testDir) {
|
|
18
|
+
return import(__rewriteRelativeImportExtension(pathToFileURL(path.join(testDir, name, 'index.js')).href));
|
|
19
|
+
}
|
|
20
|
+
return import(__rewriteRelativeImportExtension(`./adapters/${name}/index.ts`));
|
|
21
|
+
};
|
|
22
|
+
function errorMessage(error) {
|
|
23
|
+
return error instanceof Error ? error.message : String(error);
|
|
24
|
+
}
|
|
25
|
+
function isAdapter(value) {
|
|
26
|
+
if (typeof value !== 'object' || value === null)
|
|
27
|
+
return false;
|
|
28
|
+
const candidate = value;
|
|
29
|
+
return (typeof candidate.name === 'string' &&
|
|
30
|
+
typeof candidate.start === 'function' &&
|
|
31
|
+
typeof candidate.stop === 'function' &&
|
|
32
|
+
typeof candidate.sendMessage === 'function');
|
|
33
|
+
}
|
|
34
|
+
// Load every adapter whose config has `enabled === true`. Each adapter is
|
|
35
|
+
// loaded, instantiated and started in isolation: a failure in one adapter is
|
|
36
|
+
// logged and skipped so it never blocks the others.
|
|
37
|
+
export async function loadAdapters(config, ctx, options = {}) {
|
|
38
|
+
const importAdapter = options.importAdapter ?? defaultImportAdapter;
|
|
39
|
+
const loaded = [];
|
|
40
|
+
for (const [name, adapterConfig] of Object.entries(config.adapters)) {
|
|
41
|
+
if (adapterConfig?.enabled !== true)
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
const mod = (await importAdapter(name));
|
|
45
|
+
const factory = mod?.default;
|
|
46
|
+
if (typeof factory !== 'function') {
|
|
47
|
+
throw new Error(`adapter "${name}" has no default export factory`);
|
|
48
|
+
}
|
|
49
|
+
const instance = factory(adapterConfig);
|
|
50
|
+
if (!isAdapter(instance)) {
|
|
51
|
+
throw new Error(`adapter "${name}" does not satisfy the Adapter contract`);
|
|
52
|
+
}
|
|
53
|
+
await instance.start(ctx);
|
|
54
|
+
loaded.push(instance);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
ctx.logger.err(`failed to load adapter "${name}": ${errorMessage(error)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return loaded;
|
|
61
|
+
}
|
|
62
|
+
// Stop adapters in reverse load order, isolating per-adapter stop failures.
|
|
63
|
+
export async function unloadAdapters(adapters) {
|
|
64
|
+
for (const adapter of [...adapters].reverse()) {
|
|
65
|
+
try {
|
|
66
|
+
await adapter.stop();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// A failing stop must not block the rest of shutdown.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=plugin-loader.js.map
|