@fitlab-ai/agent-infra 0.8.2 → 0.8.3
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/bin/cli.js +1 -1
- package/dist/lib/cp.js +1 -1
- package/dist/lib/decide.js +2 -2
- package/dist/lib/defaults.json +1 -0
- package/dist/lib/init.js +7 -7
- package/dist/lib/merge.js +1 -1
- package/dist/lib/prompt.js +1 -1
- package/dist/lib/run/index.js +7 -7
- package/dist/lib/run/prompt.js +1 -1
- package/dist/lib/sandbox/capture.js +5 -5
- package/dist/lib/sandbox/clipboard/bridge.js +48 -13
- package/dist/lib/sandbox/clipboard/index.js +5 -6
- package/dist/lib/sandbox/clipboard/linux.js +91 -0
- package/dist/lib/sandbox/clipboard/paths.js +1 -1
- package/dist/lib/sandbox/clipboard/win32.js +144 -0
- package/dist/lib/sandbox/commands/create.js +67 -65
- package/dist/lib/sandbox/commands/enter.js +9 -9
- package/dist/lib/sandbox/commands/list-running.js +2 -2
- package/dist/lib/sandbox/commands/ls.js +7 -7
- package/dist/lib/sandbox/commands/prune.js +8 -8
- package/dist/lib/sandbox/commands/rebuild.js +38 -50
- package/dist/lib/sandbox/commands/refresh.js +2 -2
- package/dist/lib/sandbox/commands/rm.js +11 -11
- package/dist/lib/sandbox/commands/show.js +4 -4
- package/dist/lib/sandbox/commands/start.js +4 -4
- package/dist/lib/sandbox/commands/vm.js +4 -4
- package/dist/lib/sandbox/config.js +10 -4
- package/dist/lib/sandbox/constants.js +4 -1
- package/dist/lib/sandbox/credentials.js +1 -1
- package/dist/lib/sandbox/dotfiles.js +1 -1
- package/dist/lib/sandbox/engine.js +3 -3
- package/dist/lib/sandbox/engines/index.js +5 -5
- package/dist/lib/sandbox/engines/wsl2-paths.js +1 -1
- package/dist/lib/sandbox/image-build.js +80 -0
- package/dist/lib/sandbox/image-prune.js +2 -2
- package/dist/lib/sandbox/index.js +10 -10
- package/dist/lib/sandbox/managed-fs.js +1 -1
- package/dist/lib/sandbox/readme-scaffold.js +1 -1
- package/dist/lib/sandbox/tools.js +2 -2
- package/dist/lib/server/adapters/feishu/index.js +10 -3
- package/dist/lib/server/adapters/feishu/renderer.js +88 -0
- package/dist/lib/server/adapters/feishu/transport.js +2 -13
- package/dist/lib/server/daemon.js +64 -43
- package/dist/lib/server/display.js +83 -0
- package/dist/lib/server/index.js +2 -2
- package/dist/lib/server/process-control.js +2 -2
- package/dist/lib/server/protocol.js +2 -2
- package/dist/lib/server/streamer.js +5 -4
- package/dist/lib/task/commands/cat.js +2 -2
- package/dist/lib/task/commands/decisions.js +4 -4
- package/dist/lib/task/commands/files.js +3 -3
- package/dist/lib/task/commands/grep.js +3 -3
- package/dist/lib/task/commands/issue-body.js +4 -4
- package/dist/lib/task/commands/log.js +5 -5
- package/dist/lib/task/commands/ls.js +3 -3
- package/dist/lib/task/commands/show.js +1 -1
- package/dist/lib/task/commands/status.js +71 -25
- package/dist/lib/task/index.js +9 -9
- package/dist/lib/task/resolve-ref.js +1 -1
- package/dist/lib/task/workflow-warnings.js +94 -0
- package/dist/lib/update.js +4 -4
- package/lib/defaults.json +1 -0
- package/lib/sandbox/clipboard/bridge.ts +50 -8
- package/lib/sandbox/clipboard/index.ts +5 -6
- package/lib/sandbox/clipboard/linux.ts +124 -0
- package/lib/sandbox/clipboard/win32.ts +173 -0
- package/lib/sandbox/commands/create.ts +84 -64
- package/lib/sandbox/commands/rebuild.ts +42 -54
- package/lib/sandbox/config.ts +13 -1
- package/lib/sandbox/constants.ts +4 -0
- package/lib/sandbox/image-build.ts +134 -0
- package/lib/server/adapters/_contract.ts +3 -0
- package/lib/server/adapters/feishu/index.ts +11 -3
- package/lib/server/adapters/feishu/renderer.ts +99 -0
- package/lib/server/adapters/feishu/transport.ts +5 -18
- package/lib/server/daemon.ts +74 -38
- package/lib/server/display.ts +136 -0
- package/lib/server/streamer.ts +5 -4
- package/lib/task/commands/log.ts +3 -3
- package/lib/task/commands/status.ts +102 -21
- package/lib/task/workflow-warnings.ts +121 -0
- package/package.json +2 -2
- package/templates/.agents/rules/create-issue.github.en.md +3 -3
- package/templates/.agents/rules/create-issue.github.zh-CN.md +3 -3
- package/templates/.agents/rules/issue-pr-commands.github.en.md +7 -2
- package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +7 -2
- package/templates/.agents/rules/issue-sync.github.en.md +13 -0
- package/templates/.agents/rules/issue-sync.github.zh-CN.md +13 -0
- package/templates/.agents/rules/next-step-output.en.md +15 -1
- package/templates/.agents/rules/next-step-output.zh-CN.md +15 -1
- package/templates/.agents/rules/pr-sync.github.en.md +17 -2
- package/templates/.agents/rules/pr-sync.github.zh-CN.md +17 -2
- package/templates/.agents/rules/task-management.en.md +1 -1
- package/templates/.agents/rules/task-management.zh-CN.md +1 -1
- package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +57 -0
- package/templates/.agents/scripts/validate-artifact.js +120 -0
- package/templates/.agents/scripts/workflow-warnings.js +290 -0
- package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +118 -0
- package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +118 -0
- package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +49 -0
- package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +49 -0
- package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +48 -0
- package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +48 -0
- package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +60 -0
- package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +87 -0
- package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/create-task/SKILL.en.md +4 -1
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +7 -0
- package/templates/.agents/templates/task.en.md +7 -0
- package/templates/.agents/templates/task.zh-CN.md +7 -0
- package/templates/.claude/commands/complete-manual-validation.en.md +9 -0
- package/templates/.claude/commands/complete-manual-validation.zh-CN.md +9 -0
- package/templates/.gemini/commands/_project_/complete-manual-validation.en.toml +8 -0
- package/templates/.gemini/commands/_project_/complete-manual-validation.zh-CN.toml +8 -0
- package/templates/.github/workflows/metadata-sync.yml +5 -0
- package/templates/.opencode/commands/complete-manual-validation.en.md +11 -0
- package/templates/.opencode/commands/complete-manual-validation.zh-CN.md +11 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { detectEngine } from './engine.ts';
|
|
3
|
+
import { resolveBuildUid } from './engines/native.ts';
|
|
4
|
+
import { toEnginePath } from './engines/wsl2-paths.ts';
|
|
5
|
+
import { sandboxImageConfigLabel, sandboxImageRefreshLabel, sandboxLabel } from './constants.ts';
|
|
6
|
+
import { runEngine, runSafeEngine } from './shell.ts';
|
|
7
|
+
import {
|
|
8
|
+
imageSignatureFields,
|
|
9
|
+
toolNpmPackagesArg,
|
|
10
|
+
toolShellInstallScriptBase64
|
|
11
|
+
} from './tools.ts';
|
|
12
|
+
import type { SandboxTool } from './tools.ts';
|
|
13
|
+
|
|
14
|
+
type PreparedDockerfileSignature = {
|
|
15
|
+
signature: unknown;
|
|
16
|
+
};
|
|
17
|
+
type BuildImageConfig = {
|
|
18
|
+
project: string;
|
|
19
|
+
imageName: string;
|
|
20
|
+
repoRoot: string;
|
|
21
|
+
engine?: string | null;
|
|
22
|
+
};
|
|
23
|
+
type EngineRunFn = (engine: string, cmd: string, args: string[], opts?: { cwd?: string }) => string;
|
|
24
|
+
type EngineRunSafeFn = EngineRunFn;
|
|
25
|
+
|
|
26
|
+
export function buildImageSignature(preparedDockerfile: PreparedDockerfileSignature, tools: SandboxTool[]): string {
|
|
27
|
+
return createHash('sha256')
|
|
28
|
+
.update(JSON.stringify({
|
|
29
|
+
dockerfile: preparedDockerfile.signature,
|
|
30
|
+
tools: imageSignatureFields(tools)
|
|
31
|
+
}))
|
|
32
|
+
.digest('hex')
|
|
33
|
+
.slice(0, 12);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildSandboxImageArgs(
|
|
37
|
+
config: BuildImageConfig,
|
|
38
|
+
tools: SandboxTool[],
|
|
39
|
+
dockerfilePath: string,
|
|
40
|
+
imageSignature: string,
|
|
41
|
+
{
|
|
42
|
+
engine,
|
|
43
|
+
runFn = runEngine,
|
|
44
|
+
runSafeFn = runSafeEngine,
|
|
45
|
+
env = process.env,
|
|
46
|
+
refresh = false,
|
|
47
|
+
lastRefresh
|
|
48
|
+
}: {
|
|
49
|
+
engine?: string;
|
|
50
|
+
runFn?: EngineRunFn;
|
|
51
|
+
runSafeFn?: EngineRunSafeFn;
|
|
52
|
+
env?: NodeJS.ProcessEnv;
|
|
53
|
+
refresh?: boolean;
|
|
54
|
+
lastRefresh?: number;
|
|
55
|
+
} = {}
|
|
56
|
+
): string[] {
|
|
57
|
+
const selectedEngine = engine ?? detectEngine({ engine: config.engine });
|
|
58
|
+
const { uid: hostUid, gid: hostGid } = resolveBuildUid({
|
|
59
|
+
engine: selectedEngine,
|
|
60
|
+
runFn,
|
|
61
|
+
runSafeFn,
|
|
62
|
+
env
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const args = [
|
|
66
|
+
'build',
|
|
67
|
+
'-t',
|
|
68
|
+
config.imageName,
|
|
69
|
+
'--build-arg',
|
|
70
|
+
`HOST_UID=${hostUid}`,
|
|
71
|
+
'--build-arg',
|
|
72
|
+
`HOST_GID=${hostGid}`,
|
|
73
|
+
'--build-arg',
|
|
74
|
+
`AI_TOOL_PACKAGES=${toolNpmPackagesArg(tools)}`,
|
|
75
|
+
'--build-arg',
|
|
76
|
+
`AI_TOOLS_SHELL_INSTALL_B64=${toolShellInstallScriptBase64(tools)}`,
|
|
77
|
+
'--label',
|
|
78
|
+
sandboxLabel(config),
|
|
79
|
+
'--label',
|
|
80
|
+
`${sandboxImageConfigLabel(config)}=${imageSignature}`
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
if (lastRefresh !== undefined) {
|
|
84
|
+
args.push('--label', `${sandboxImageRefreshLabel(config)}=${lastRefresh}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
args.push(
|
|
88
|
+
'-f',
|
|
89
|
+
toEnginePath(selectedEngine, dockerfilePath),
|
|
90
|
+
toEnginePath(selectedEngine, config.repoRoot)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (refresh) {
|
|
94
|
+
args.splice(1, 0, '--no-cache', '--pull');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return args;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseRefreshTimestamp(value: string): number {
|
|
101
|
+
const parsed = Number(value);
|
|
102
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseImageLabels(raw: string): Record<string, string> {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
108
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
return Object.fromEntries(
|
|
112
|
+
Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isRefreshDisabled(env: NodeJS.ProcessEnv, noRefreshFlag: boolean): boolean {
|
|
120
|
+
if (noRefreshFlag) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const value = env.AI_SANDBOX_NO_REFRESH?.trim().toLowerCase();
|
|
125
|
+
return value === '1' || value === 'true' || value === 'yes';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isRefreshDue(lastRefresh: number, now: number, intervalDays: number): boolean {
|
|
129
|
+
if (intervalDays <= 0 || lastRefresh > now) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return now - lastRefresh >= intervalDays * 24 * 60 * 60 * 1000;
|
|
134
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ServerConfig } from '../config.ts';
|
|
2
|
+
import type { OutboundMessage } from '../display.ts';
|
|
2
3
|
import type { Logger } from '../logger.ts';
|
|
3
4
|
|
|
4
5
|
// Adapter contract for agent-infra-server.
|
|
@@ -19,6 +20,7 @@ export type InboundMessage = {
|
|
|
19
20
|
messageId: string;
|
|
20
21
|
raw: unknown;
|
|
21
22
|
reply: (text: string) => Promise<void>;
|
|
23
|
+
replyDisplay?: (message: OutboundMessage) => Promise<void>;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
// Runtime context passed to every adapter's start(). dispatch() is registered
|
|
@@ -36,6 +38,7 @@ export type Adapter = {
|
|
|
36
38
|
start: (ctx: AdapterCtx) => Promise<void>;
|
|
37
39
|
stop: () => Promise<void>;
|
|
38
40
|
sendMessage: (target: { chatId: string }, text: string) => Promise<void>;
|
|
41
|
+
sendDisplayMessage?: (target: { chatId: string }, message: OutboundMessage) => Promise<void>;
|
|
39
42
|
};
|
|
40
43
|
|
|
41
44
|
// The shape of an adapter module's default export (a factory).
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Adapter, AdapterCtx, AdapterFactory, InboundMessage } from '../_contract.ts';
|
|
2
|
-
import {
|
|
2
|
+
import type { OutboundMessage } from '../../display.ts';
|
|
3
|
+
import { renderFeishuMessage } from './renderer.ts';
|
|
4
|
+
import { createFeishuTransport, normalizeMessage } from './transport.ts';
|
|
3
5
|
import type { FeishuTransport } from './transport.ts';
|
|
4
6
|
|
|
5
7
|
// Assemble the feishu adapter. The transport is injectable so unit tests can
|
|
@@ -26,7 +28,10 @@ export function createFeishuAdapter(
|
|
|
26
28
|
messageId: normalized.messageId,
|
|
27
29
|
raw: normalized.raw,
|
|
28
30
|
reply: async (text) => {
|
|
29
|
-
await transport.send(normalized.chatId,
|
|
31
|
+
await transport.send(normalized.chatId, renderFeishuMessage(text));
|
|
32
|
+
},
|
|
33
|
+
replyDisplay: async (message: OutboundMessage) => {
|
|
34
|
+
await transport.send(normalized.chatId, renderFeishuMessage(message));
|
|
30
35
|
}
|
|
31
36
|
};
|
|
32
37
|
await adapterCtx.dispatch(message);
|
|
@@ -39,7 +44,10 @@ export function createFeishuAdapter(
|
|
|
39
44
|
await transport.stop();
|
|
40
45
|
},
|
|
41
46
|
async sendMessage(target, text) {
|
|
42
|
-
await transport.send(target.chatId,
|
|
47
|
+
await transport.send(target.chatId, renderFeishuMessage(text));
|
|
48
|
+
},
|
|
49
|
+
async sendDisplayMessage(target, message) {
|
|
50
|
+
await transport.send(target.chatId, renderFeishuMessage(message));
|
|
43
51
|
}
|
|
44
52
|
};
|
|
45
53
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
2
|
+
import { normalizeOutbound, outboundToText, type DisplayMessage, type OutboundMessage } from '../../display.ts';
|
|
3
|
+
import type { FeishuMessagePayload } from './transport.ts';
|
|
4
|
+
|
|
5
|
+
const FEISHU_LARK_MD_MAX_LENGTH = 20_000;
|
|
6
|
+
|
|
7
|
+
type FeishuElement =
|
|
8
|
+
| { tag: 'div'; text: { tag: 'lark_md'; content: string } }
|
|
9
|
+
| { tag: 'div'; fields: Array<{ is_short: boolean; text: { tag: 'lark_md'; content: string } }> };
|
|
10
|
+
|
|
11
|
+
function templateForTone(tone: 'info' | 'success' | 'warning' | 'danger' | 'running'): string {
|
|
12
|
+
if (tone === 'success') return 'green';
|
|
13
|
+
if (tone === 'warning') return 'yellow';
|
|
14
|
+
if (tone === 'danger') return 'red';
|
|
15
|
+
return 'blue';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function templateForResult(exitCode: number | null): string {
|
|
19
|
+
return exitCode === 0 ? 'green' : 'red';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function cleanFeishuText(text: string): string {
|
|
23
|
+
return stripVTControlCharacters(text).replace(/\r\n/g, '\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clampLarkMarkdown(text: string): string {
|
|
27
|
+
const clean = cleanFeishuText(text);
|
|
28
|
+
if (clean.length <= FEISHU_LARK_MD_MAX_LENGTH) return clean;
|
|
29
|
+
return `${clean.slice(0, FEISHU_LARK_MD_MAX_LENGTH - 18)}\n\n...(truncated)`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function div(content: string): FeishuElement {
|
|
33
|
+
return { tag: 'div', text: { tag: 'lark_md', content: clampLarkMarkdown(content) } };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fields(rows: [string, string][]): FeishuElement {
|
|
37
|
+
return {
|
|
38
|
+
tag: 'div',
|
|
39
|
+
fields: rows.map(([label, value]) => ({
|
|
40
|
+
is_short: true,
|
|
41
|
+
text: { tag: 'lark_md', content: clampLarkMarkdown(`**${label}**\n${value}`) }
|
|
42
|
+
}))
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function card(title: string, template: string, elements: FeishuElement[]): FeishuMessagePayload {
|
|
47
|
+
return {
|
|
48
|
+
msg_type: 'interactive',
|
|
49
|
+
content: JSON.stringify({
|
|
50
|
+
config: { wide_screen_mode: true },
|
|
51
|
+
header: { title: { tag: 'plain_text', content: cleanFeishuText(title) }, template },
|
|
52
|
+
elements
|
|
53
|
+
})
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tableMarkdown(message: Extract<DisplayMessage, { kind: 'table' }>): string {
|
|
58
|
+
return outboundToText(message);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderStatus(message: Extract<DisplayMessage, { kind: 'status-card' }>): FeishuMessagePayload {
|
|
62
|
+
const elements: FeishuElement[] = [];
|
|
63
|
+
if (message.fields && message.fields.length > 0) elements.push(fields(message.fields));
|
|
64
|
+
if (message.body) elements.push(div(message.body));
|
|
65
|
+
if (elements.length === 0) elements.push(div(message.title));
|
|
66
|
+
return card(message.title, templateForTone(message.tone), elements);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderStream(message: Extract<DisplayMessage, { kind: 'stream-event' }>): FeishuMessagePayload {
|
|
70
|
+
const template = message.phase === 'finished' ? templateForResult(message.exitCode ?? null) : 'blue';
|
|
71
|
+
return card(message.title, template, [div(outboundToText(message))]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function renderCommandResult(message: Extract<DisplayMessage, { kind: 'command-result' }>): FeishuMessagePayload {
|
|
75
|
+
return card(message.title, templateForResult(message.exitCode), [div(outboundToText(message))]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function renderUnknownDisplayKind(message: never): FeishuMessagePayload {
|
|
79
|
+
return card('agent-infra', 'blue', [div(`[unknown display kind: ${String((message as { kind?: unknown }).kind)}]`)]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderFeishuMessage(message: OutboundMessage): FeishuMessagePayload {
|
|
83
|
+
const normalized = normalizeOutbound(message);
|
|
84
|
+
switch (normalized.kind) {
|
|
85
|
+
case 'text':
|
|
86
|
+
return card('agent-infra', 'blue', [div(normalized.text)]);
|
|
87
|
+
case 'markdown':
|
|
88
|
+
return card('agent-infra', 'blue', [div(normalized.markdown)]);
|
|
89
|
+
case 'table':
|
|
90
|
+
return card(normalized.title ?? 'agent-infra', 'blue', [div(tableMarkdown(normalized))]);
|
|
91
|
+
case 'status-card':
|
|
92
|
+
return renderStatus(normalized);
|
|
93
|
+
case 'stream-event':
|
|
94
|
+
return renderStream(normalized);
|
|
95
|
+
case 'command-result':
|
|
96
|
+
return renderCommandResult(normalized);
|
|
97
|
+
}
|
|
98
|
+
return renderUnknownDisplayKind(normalized);
|
|
99
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as lark from '@larksuiteoapi/node-sdk';
|
|
2
|
-
import { stripVTControlCharacters } from 'node:util';
|
|
3
2
|
|
|
4
3
|
// Transport layer for the feishu adapter. All @larksuiteoapi/node-sdk surface is
|
|
5
4
|
// confined here so the adapter body (index.ts) depends only on this narrow
|
|
@@ -11,10 +10,10 @@ export type FeishuTransport = {
|
|
|
11
10
|
// each inbound im.message.receive_v1.
|
|
12
11
|
start: (onMessage: (raw: unknown) => Promise<void>) => Promise<void>;
|
|
13
12
|
stop: () => Promise<void>;
|
|
14
|
-
send: (chatId: string, message:
|
|
13
|
+
send: (chatId: string, message: FeishuMessagePayload) => Promise<void>;
|
|
15
14
|
};
|
|
16
15
|
|
|
17
|
-
export type
|
|
16
|
+
export type FeishuMessagePayload = { msg_type: 'interactive'; content: string };
|
|
18
17
|
|
|
19
18
|
type FeishuCreateData = {
|
|
20
19
|
receive_id: string;
|
|
@@ -78,23 +77,11 @@ function resolveDomain(value: unknown): number {
|
|
|
78
77
|
return value === 'lark' || value === 'Lark' ? lark.Domain.Lark : lark.Domain.Feishu;
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
export function
|
|
82
|
-
return stripVTControlCharacters(text).replace(/\r\n/g, '\n');
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function cardMessage(text: string): FeishuOutgoingMessage {
|
|
86
|
-
return { kind: 'interactive', title: 'agent-infra', text: cleanFeishuText(text) };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function toFeishuCreateData(chatId: string, message: FeishuOutgoingMessage): FeishuCreateData {
|
|
80
|
+
export function toFeishuCreateData(chatId: string, message: FeishuMessagePayload): FeishuCreateData {
|
|
90
81
|
return {
|
|
91
82
|
receive_id: chatId,
|
|
92
|
-
msg_type:
|
|
93
|
-
content:
|
|
94
|
-
config: { wide_screen_mode: true },
|
|
95
|
-
header: { title: { tag: 'plain_text', content: message.title }, template: 'blue' },
|
|
96
|
-
elements: [{ tag: 'div', text: { tag: 'lark_md', content: message.text } }]
|
|
97
|
-
})
|
|
83
|
+
msg_type: message.msg_type,
|
|
84
|
+
content: message.content
|
|
98
85
|
};
|
|
99
86
|
}
|
|
100
87
|
|
package/lib/server/daemon.ts
CHANGED
|
@@ -1,87 +1,123 @@
|
|
|
1
1
|
import { VERSION } from '../version.ts';
|
|
2
2
|
import { loadServerConfig } from './config.ts';
|
|
3
|
+
import type { ServerConfig } from './config.ts';
|
|
3
4
|
import { createLogger } from './logger.ts';
|
|
5
|
+
import type { Logger } from './logger.ts';
|
|
4
6
|
import { loadAdapters, unloadAdapters } from './plugin-loader.ts';
|
|
5
7
|
import type { InboundMessage } from './adapters/_contract.ts';
|
|
6
8
|
import { authorize } from './auth.ts';
|
|
7
9
|
import { commandHelp, parseCommand } from './protocol.ts';
|
|
8
10
|
import { runAi } from './runner.ts';
|
|
11
|
+
import type { RunnerOptions, RunnerResult } from './runner.ts';
|
|
9
12
|
import { streamCommand } from './streamer.ts';
|
|
13
|
+
import { markdownMessage, replyOutbound, textMessage } from './display.ts';
|
|
14
|
+
import { buildStatusModel, statusModelToDisplay, type StatusModel } from '../task/commands/status.ts';
|
|
10
15
|
|
|
11
16
|
function errorMessage(error: unknown): string {
|
|
12
17
|
return error instanceof Error ? error.message : String(error);
|
|
13
18
|
}
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
}
|
|
20
|
+
type MessageDispatcherOptions = {
|
|
21
|
+
config: ServerConfig;
|
|
22
|
+
logger: Pick<Logger, 'info'>;
|
|
23
|
+
runAi?: (args: string[], options?: RunnerOptions) => Promise<RunnerResult>;
|
|
24
|
+
buildStatusModel?: (ref: string) => StatusModel;
|
|
25
|
+
statusModelToDisplay?: (model: StatusModel) => ReturnType<typeof statusModelToDisplay>;
|
|
26
|
+
};
|
|
33
27
|
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
function isTaskStatus(argv: string[]): boolean {
|
|
29
|
+
return argv[0] === 'task' && argv[1] === 'status' && typeof argv[2] === 'string';
|
|
30
|
+
}
|
|
36
31
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
});
|
|
32
|
+
export function createMessageDispatcher(options: MessageDispatcherOptions): (message: InboundMessage) => Promise<void> {
|
|
33
|
+
const runAiImpl = options.runAi ?? runAi;
|
|
34
|
+
const buildStatusModelImpl = options.buildStatusModel ?? buildStatusModel;
|
|
35
|
+
const statusModelToDisplayImpl = options.statusModelToDisplay ?? statusModelToDisplay;
|
|
42
36
|
|
|
43
|
-
|
|
37
|
+
return async (message: InboundMessage): Promise<void> => {
|
|
44
38
|
const plan = parseCommand(message.text);
|
|
45
39
|
if (plan.kind === 'ignore') return;
|
|
46
40
|
if (plan.kind === 'error') {
|
|
47
|
-
await message
|
|
48
|
-
logger.info(`command rejected from ${message.adapter}:${message.userId}: ${plan.message}`);
|
|
41
|
+
await replyOutbound(message, textMessage(plan.message));
|
|
42
|
+
options.logger.info(`command rejected from ${message.adapter}:${message.userId}: ${plan.message}`);
|
|
49
43
|
return;
|
|
50
44
|
}
|
|
51
45
|
if (plan.kind === 'builtin' && plan.name === 'ping') {
|
|
52
|
-
await message
|
|
46
|
+
await replyOutbound(message, textMessage(`pong ${VERSION}`));
|
|
53
47
|
return;
|
|
54
48
|
}
|
|
55
49
|
if (plan.kind === 'builtin' && plan.name === 'help') {
|
|
56
|
-
await message
|
|
50
|
+
await replyOutbound(message, markdownMessage(commandHelp()));
|
|
57
51
|
return;
|
|
58
52
|
}
|
|
59
53
|
if (plan.kind === 'builtin' && plan.name === 'version') {
|
|
60
|
-
await message
|
|
54
|
+
await replyOutbound(message, textMessage(`agent-infra ${VERSION}`));
|
|
61
55
|
return;
|
|
62
56
|
}
|
|
63
57
|
if (plan.kind === 'ai') {
|
|
64
58
|
const allowed = authorize(
|
|
65
59
|
{ adapter: message.adapter, userId: message.userId },
|
|
66
60
|
plan.role,
|
|
67
|
-
config.auth
|
|
61
|
+
options.config.auth
|
|
68
62
|
);
|
|
69
63
|
if (!allowed.ok) {
|
|
70
|
-
await message
|
|
71
|
-
logger.info(`unauthorized command from ${message.adapter}:${message.userId}: ${allowed.message}`);
|
|
64
|
+
await replyOutbound(message, textMessage(allowed.message));
|
|
65
|
+
options.logger.info(`unauthorized command from ${message.adapter}:${message.userId}: ${allowed.message}`);
|
|
72
66
|
return;
|
|
73
67
|
}
|
|
68
|
+
|
|
69
|
+
if (isTaskStatus(plan.argv)) {
|
|
70
|
+
try {
|
|
71
|
+
await replyOutbound(message, statusModelToDisplayImpl(buildStatusModelImpl(plan.argv[2]!)));
|
|
72
|
+
return;
|
|
73
|
+
} catch {
|
|
74
|
+
// Fall back to the existing CLI streaming path. This preserves the
|
|
75
|
+
// old behavior for invalid refs and any status-model collection error.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
74
79
|
await streamCommand(
|
|
75
80
|
{
|
|
76
81
|
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
|
|
82
|
+
chunkChars: typeof options.config.stream?.chunkChars === 'number' ? options.config.stream.chunkChars : 4000,
|
|
83
|
+
throttleMs: typeof options.config.stream?.throttleMs === 'number' ? options.config.stream.throttleMs : 1500
|
|
79
84
|
},
|
|
80
|
-
(emit) =>
|
|
81
|
-
(
|
|
85
|
+
(emit) => runAiImpl(plan.argv, { onChunk: emit }),
|
|
86
|
+
(outbound) => replyOutbound(message, outbound)
|
|
82
87
|
);
|
|
83
88
|
}
|
|
84
89
|
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The daemon main loop. Runs in the detached child spawned by
|
|
93
|
+
// process-control.start(), or in the foreground for debugging.
|
|
94
|
+
//
|
|
95
|
+
// Lifecycle (keep-alive / shutdown model):
|
|
96
|
+
// - The heartbeat interval is kept *ref'd*. It is both the keep-alive that
|
|
97
|
+
// holds the event loop open while subtask A has no adapters, and the
|
|
98
|
+
// observable signal that `ai server logs -f` shows.
|
|
99
|
+
// - runDaemon() awaits a shutdown promise that only resolves once a
|
|
100
|
+
// SIGINT/SIGTERM handler has finished graceful cleanup. We never unref()
|
|
101
|
+
// the only keep-alive timer (that would let the process exit immediately).
|
|
102
|
+
export async function runDaemon(): Promise<void> {
|
|
103
|
+
let config;
|
|
104
|
+
try {
|
|
105
|
+
config = loadServerConfig();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
process.stderr.write(`${errorMessage(error)}\n`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const logger = createLogger(config.log);
|
|
112
|
+
logger.info(`daemon starting agent-infra ${VERSION} pid=${process.pid}`);
|
|
113
|
+
|
|
114
|
+
const abortController = new AbortController();
|
|
115
|
+
let resolveShutdown: () => void = () => {};
|
|
116
|
+
const shutdown = new Promise<void>((resolve) => {
|
|
117
|
+
resolveShutdown = resolve;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const dispatch = createMessageDispatcher({ config, logger });
|
|
85
121
|
|
|
86
122
|
const ctx = { config, logger, dispatch, signal: abortController.signal };
|
|
87
123
|
const adapters = await loadAdapters(config, ctx);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export type OutboundMessage = string | DisplayMessage;
|
|
2
|
+
|
|
3
|
+
export type DisplayTone = 'info' | 'success' | 'warning' | 'danger' | 'running';
|
|
4
|
+
|
|
5
|
+
export type DisplayMessage =
|
|
6
|
+
| { kind: 'text'; text: string }
|
|
7
|
+
| { kind: 'markdown'; markdown: string }
|
|
8
|
+
| { kind: 'table'; title?: string; columns: string[]; rows: string[][] }
|
|
9
|
+
| { kind: 'status-card'; title: string; tone: DisplayTone; fields?: [string, string][]; body?: string }
|
|
10
|
+
| {
|
|
11
|
+
kind: 'stream-event';
|
|
12
|
+
title: string;
|
|
13
|
+
phase: 'started' | 'chunk' | 'finished';
|
|
14
|
+
text?: string;
|
|
15
|
+
exitCode?: number | null;
|
|
16
|
+
signal?: NodeJS.Signals | null;
|
|
17
|
+
}
|
|
18
|
+
| {
|
|
19
|
+
kind: 'command-result';
|
|
20
|
+
title: string;
|
|
21
|
+
exitCode: number | null;
|
|
22
|
+
signal: NodeJS.Signals | null;
|
|
23
|
+
stdout?: string;
|
|
24
|
+
stderr?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function textMessage(text: string): DisplayMessage {
|
|
28
|
+
return { kind: 'text', text };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function markdownMessage(markdown: string): DisplayMessage {
|
|
32
|
+
return { kind: 'markdown', markdown };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function tableMessage(columns: string[], rows: string[][], title?: string): DisplayMessage {
|
|
36
|
+
return { kind: 'table', title, columns, rows };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function statusCard(
|
|
40
|
+
title: string,
|
|
41
|
+
tone: DisplayTone,
|
|
42
|
+
fields?: [string, string][],
|
|
43
|
+
body?: string
|
|
44
|
+
): DisplayMessage {
|
|
45
|
+
return { kind: 'status-card', title, tone, fields, body };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function streamEvent(
|
|
49
|
+
title: string,
|
|
50
|
+
phase: 'started' | 'chunk' | 'finished',
|
|
51
|
+
text?: string,
|
|
52
|
+
exitCode?: number | null,
|
|
53
|
+
signal?: NodeJS.Signals | null
|
|
54
|
+
): DisplayMessage {
|
|
55
|
+
const message: Extract<DisplayMessage, { kind: 'stream-event' }> = { kind: 'stream-event', title, phase };
|
|
56
|
+
if (text !== undefined) message.text = text;
|
|
57
|
+
if (exitCode !== undefined) message.exitCode = exitCode;
|
|
58
|
+
if (signal !== undefined) message.signal = signal;
|
|
59
|
+
return message;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function commandResult(
|
|
63
|
+
title: string,
|
|
64
|
+
exitCode: number | null,
|
|
65
|
+
signal: NodeJS.Signals | null,
|
|
66
|
+
stdout?: string,
|
|
67
|
+
stderr?: string
|
|
68
|
+
): DisplayMessage {
|
|
69
|
+
return { kind: 'command-result', title, exitCode, signal, stdout, stderr };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function normalizeOutbound(message: OutboundMessage): DisplayMessage {
|
|
73
|
+
return typeof message === 'string' ? textMessage(message) : message;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function tableToText(message: Extract<DisplayMessage, { kind: 'table' }>): string {
|
|
77
|
+
const lines: string[] = [];
|
|
78
|
+
if (message.title) lines.push(message.title);
|
|
79
|
+
lines.push(message.columns.join(' | '));
|
|
80
|
+
lines.push(message.columns.map(() => '---').join(' | '));
|
|
81
|
+
for (const row of message.rows) {
|
|
82
|
+
lines.push(row.join(' | '));
|
|
83
|
+
}
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function compact(lines: Array<string | undefined>): string {
|
|
88
|
+
return lines.filter((line): line is string => typeof line === 'string' && line !== '').join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function unknownDisplayKind(message: never): string {
|
|
92
|
+
return `[unknown: ${String((message as { kind?: unknown }).kind)}]`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function outboundToText(message: OutboundMessage): string {
|
|
96
|
+
const normalized = normalizeOutbound(message);
|
|
97
|
+
switch (normalized.kind) {
|
|
98
|
+
case 'text':
|
|
99
|
+
return normalized.text;
|
|
100
|
+
case 'markdown':
|
|
101
|
+
return normalized.markdown;
|
|
102
|
+
case 'table':
|
|
103
|
+
return tableToText(normalized);
|
|
104
|
+
case 'status-card':
|
|
105
|
+
return compact([
|
|
106
|
+
normalized.title,
|
|
107
|
+
...(normalized.fields ?? []).map(([label, value]) => `${label}: ${value}`),
|
|
108
|
+
normalized.body
|
|
109
|
+
]);
|
|
110
|
+
case 'stream-event':
|
|
111
|
+
if (normalized.phase === 'started') return `started ${normalized.title}`;
|
|
112
|
+
if (normalized.phase === 'chunk') return normalized.text ?? '';
|
|
113
|
+
return `finished ${normalized.title} exitCode=${normalized.exitCode ?? 'null'} signal=${normalized.signal ?? 'null'}`;
|
|
114
|
+
case 'command-result':
|
|
115
|
+
return compact([
|
|
116
|
+
`finished ${normalized.title} exitCode=${normalized.exitCode ?? 'null'} signal=${normalized.signal ?? 'null'}`,
|
|
117
|
+
normalized.stdout,
|
|
118
|
+
normalized.stderr
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
return unknownDisplayKind(normalized);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function replyOutbound(
|
|
125
|
+
inbound: {
|
|
126
|
+
reply: (text: string) => Promise<void>;
|
|
127
|
+
replyDisplay?: (message: OutboundMessage) => Promise<void>;
|
|
128
|
+
},
|
|
129
|
+
message: OutboundMessage
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
if (inbound.replyDisplay) {
|
|
132
|
+
await inbound.replyDisplay(message);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
await inbound.reply(outboundToText(message));
|
|
136
|
+
}
|
package/lib/server/streamer.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { streamEvent, type OutboundMessage } from './display.ts';
|
|
1
2
|
import { redactSecrets } from './redact.ts';
|
|
2
3
|
import type { RunnerResult } from './runner.ts';
|
|
3
4
|
|
|
@@ -19,9 +20,9 @@ function chunks(text: string, size: number): string[] {
|
|
|
19
20
|
export async function streamCommand(
|
|
20
21
|
options: StreamOptions,
|
|
21
22
|
run: (emit?: (chunk: string) => Promise<void>) => Promise<RunnerResult>,
|
|
22
|
-
send: (
|
|
23
|
+
send: (message: OutboundMessage) => Promise<void>
|
|
23
24
|
): Promise<RunnerResult> {
|
|
24
|
-
await send(
|
|
25
|
+
await send(streamEvent(options.title, 'started'));
|
|
25
26
|
const size = options.chunkChars ?? 4000;
|
|
26
27
|
const throttleMs = options.throttleMs ?? 0;
|
|
27
28
|
let streamed = false;
|
|
@@ -34,7 +35,7 @@ export async function streamCommand(
|
|
|
34
35
|
buffer = '';
|
|
35
36
|
lastFlush = Date.now();
|
|
36
37
|
for (const chunk of chunks(text, size)) {
|
|
37
|
-
await send(chunk);
|
|
38
|
+
await send(streamEvent(options.title, 'chunk', chunk));
|
|
38
39
|
}
|
|
39
40
|
};
|
|
40
41
|
|
|
@@ -52,6 +53,6 @@ export async function streamCommand(
|
|
|
52
53
|
buffer += [result.stdout, result.stderr].filter(Boolean).join('\n');
|
|
53
54
|
}
|
|
54
55
|
await flush();
|
|
55
|
-
await send(
|
|
56
|
+
await send(streamEvent(options.title, 'finished', undefined, result.exitCode, result.signal));
|
|
56
57
|
return result;
|
|
57
58
|
}
|