@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.
Files changed (173) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/bin/cli.ts +53 -18
  4. package/dist/bin/cli.js +56 -19
  5. package/dist/lib/decide.js +110 -0
  6. package/dist/lib/run/host.js +39 -0
  7. package/dist/lib/run/index.js +102 -0
  8. package/dist/lib/run/prompt.js +2 -0
  9. package/dist/lib/run/skills.js +29 -0
  10. package/dist/lib/run/tui.js +35 -0
  11. package/dist/lib/sandbox/capture.js +49 -0
  12. package/dist/lib/sandbox/commands/create.js +55 -6
  13. package/dist/lib/sandbox/commands/ls.js +4 -33
  14. package/dist/lib/sandbox/commands/show.js +67 -0
  15. package/dist/lib/sandbox/index.js +7 -0
  16. package/dist/lib/server/adapters/_contract.js +9 -0
  17. package/dist/lib/server/adapters/feishu/index.js +40 -0
  18. package/dist/lib/server/adapters/feishu/transport.js +95 -0
  19. package/dist/lib/server/auth.js +19 -0
  20. package/dist/lib/server/config.js +183 -0
  21. package/dist/lib/server/daemon.js +96 -0
  22. package/dist/lib/server/index.js +47 -0
  23. package/dist/lib/server/logger.js +34 -0
  24. package/dist/lib/server/plugin-loader.js +73 -0
  25. package/dist/lib/server/process-control.js +200 -0
  26. package/dist/lib/server/protocol.js +69 -0
  27. package/dist/lib/server/redact.js +12 -0
  28. package/dist/lib/server/runner.js +75 -0
  29. package/dist/lib/server/streamer.js +44 -0
  30. package/dist/lib/task/commands/decisions.js +258 -0
  31. package/dist/lib/task/commands/issue-body.js +94 -0
  32. package/dist/lib/task/commands/log.js +67 -9
  33. package/dist/lib/task/index.js +17 -0
  34. package/dist/lib/task/issue-form.js +66 -0
  35. package/dist/lib/task/ledger.js +66 -0
  36. package/dist/lib/task/sections.js +73 -0
  37. package/lib/decide.ts +124 -0
  38. package/lib/run/host.ts +47 -0
  39. package/lib/run/index.ts +124 -0
  40. package/lib/run/prompt.ts +1 -0
  41. package/lib/run/skills.ts +34 -0
  42. package/lib/run/tui.ts +40 -0
  43. package/lib/sandbox/capture.ts +84 -0
  44. package/lib/sandbox/commands/create.ts +61 -9
  45. package/lib/sandbox/commands/ls.ts +4 -36
  46. package/lib/sandbox/commands/show.ts +80 -0
  47. package/lib/sandbox/index.ts +7 -0
  48. package/lib/server/adapters/_contract.ts +42 -0
  49. package/lib/server/adapters/feishu/index.ts +47 -0
  50. package/lib/server/adapters/feishu/transport.ts +128 -0
  51. package/lib/server/auth.ts +28 -0
  52. package/lib/server/config.ts +228 -0
  53. package/lib/server/daemon.ts +110 -0
  54. package/lib/server/index.ts +50 -0
  55. package/lib/server/logger.ts +48 -0
  56. package/lib/server/plugin-loader.ts +84 -0
  57. package/lib/server/process-control.ts +225 -0
  58. package/lib/server/protocol.ts +79 -0
  59. package/lib/server/redact.ts +12 -0
  60. package/lib/server/runner.ts +95 -0
  61. package/lib/server/server.schema.json +127 -0
  62. package/lib/server/streamer.ts +57 -0
  63. package/lib/task/commands/decisions.ts +272 -0
  64. package/lib/task/commands/issue-body.ts +102 -0
  65. package/lib/task/commands/log.ts +73 -9
  66. package/lib/task/index.ts +17 -0
  67. package/lib/task/issue-form.ts +77 -0
  68. package/lib/task/ledger.ts +73 -0
  69. package/lib/task/sections.ts +73 -0
  70. package/package.json +9 -4
  71. package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
  72. package/templates/.agents/README.en.md +3 -1
  73. package/templates/.agents/README.zh-CN.md +3 -1
  74. package/templates/.agents/rules/create-issue.github.en.md +20 -29
  75. package/templates/.agents/rules/create-issue.github.zh-CN.md +20 -29
  76. package/templates/.agents/rules/issue-fields.github.en.md +1 -1
  77. package/templates/.agents/rules/issue-fields.github.zh-CN.md +1 -1
  78. package/templates/.agents/rules/issue-pr-commands.github.en.md +6 -0
  79. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +6 -0
  80. package/templates/.agents/rules/next-step-output.en.md +30 -1
  81. package/templates/.agents/rules/next-step-output.zh-CN.md +30 -1
  82. package/templates/.agents/rules/no-mid-flow-questions.en.md +7 -2
  83. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +7 -2
  84. package/templates/.agents/rules/review-handshake.en.md +5 -1
  85. package/templates/.agents/rules/review-handshake.zh-CN.md +5 -1
  86. package/templates/.agents/rules/task-management.en.md +3 -1
  87. package/templates/.agents/rules/task-management.zh-CN.md +3 -1
  88. package/templates/.agents/scripts/lib/post-review-commit.js +15 -18
  89. package/templates/.agents/scripts/validate-artifact.js +8 -0
  90. package/templates/.agents/skills/analyze-task/SKILL.en.md +18 -4
  91. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +18 -4
  92. package/templates/.agents/skills/analyze-task/config/verify.en.json +2 -1
  93. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +2 -1
  94. package/templates/.agents/skills/archive-tasks/SKILL.en.md +3 -1
  95. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +3 -1
  96. package/templates/.agents/skills/block-task/SKILL.en.md +3 -1
  97. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +3 -1
  98. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -1
  99. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -1
  100. package/templates/.agents/skills/check-task/SKILL.en.md +30 -81
  101. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +30 -80
  102. package/templates/.agents/skills/close-codescan/SKILL.en.md +3 -1
  103. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +3 -1
  104. package/templates/.agents/skills/close-dependabot/SKILL.en.md +3 -1
  105. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +3 -1
  106. package/templates/.agents/skills/code-task/SKILL.en.md +4 -2
  107. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +4 -2
  108. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +7 -2
  109. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +7 -2
  110. package/templates/.agents/skills/code-task/scripts/detect-mode.js +6 -14
  111. package/templates/.agents/skills/commit/SKILL.en.md +27 -5
  112. package/templates/.agents/skills/commit/SKILL.zh-CN.md +27 -5
  113. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -1
  114. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -1
  115. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -1
  116. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -1
  117. package/templates/.agents/skills/create-pr/SKILL.en.md +3 -1
  118. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +3 -1
  119. package/templates/.agents/skills/create-release-note/SKILL.en.md +3 -1
  120. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +3 -1
  121. package/templates/.agents/skills/create-task/SKILL.en.md +4 -4
  122. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -4
  123. package/templates/.agents/skills/import-codescan/SKILL.en.md +3 -4
  124. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +3 -4
  125. package/templates/.agents/skills/import-dependabot/SKILL.en.md +3 -2
  126. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +3 -2
  127. package/templates/.agents/skills/import-issue/SKILL.en.md +20 -4
  128. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +20 -4
  129. package/templates/.agents/skills/init-labels/SKILL.en.md +3 -1
  130. package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +3 -1
  131. package/templates/.agents/skills/init-milestones/SKILL.en.md +3 -1
  132. package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +3 -1
  133. package/templates/.agents/skills/plan-task/SKILL.en.md +4 -3
  134. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -3
  135. package/templates/.agents/skills/post-release/SKILL.en.md +3 -1
  136. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +3 -1
  137. package/templates/.agents/skills/refine-title/SKILL.en.md +3 -1
  138. package/templates/.agents/skills/refine-title/SKILL.zh-CN.md +3 -1
  139. package/templates/.agents/skills/release/SKILL.en.md +3 -1
  140. package/templates/.agents/skills/release/SKILL.zh-CN.md +3 -1
  141. package/templates/.agents/skills/restore-task/SKILL.en.md +3 -1
  142. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +3 -1
  143. package/templates/.agents/skills/review-analysis/SKILL.en.md +6 -2
  144. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +6 -2
  145. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +1 -1
  146. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +1 -1
  147. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +1 -1
  148. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +1 -2
  149. package/templates/.agents/skills/review-code/SKILL.en.md +5 -2
  150. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -2
  151. package/templates/.agents/skills/review-code/reference/output-templates.en.md +1 -1
  152. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +1 -1
  153. package/templates/.agents/skills/review-code/reference/report-template.en.md +1 -1
  154. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +1 -2
  155. package/templates/.agents/skills/review-plan/SKILL.en.md +6 -2
  156. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +6 -2
  157. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +1 -1
  158. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +1 -1
  159. package/templates/.agents/skills/review-plan/reference/report-template.en.md +1 -1
  160. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +1 -2
  161. package/templates/.agents/skills/test/SKILL.en.md +3 -1
  162. package/templates/.agents/skills/test/SKILL.zh-CN.md +1 -1
  163. package/templates/.agents/skills/test-integration/SKILL.en.md +3 -1
  164. package/templates/.agents/skills/test-integration/SKILL.zh-CN.md +3 -1
  165. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +3 -1
  166. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +3 -1
  167. package/templates/.agents/skills/upgrade-dependency/SKILL.en.md +3 -1
  168. package/templates/.agents/skills/upgrade-dependency/SKILL.zh-CN.md +3 -1
  169. package/templates/.agents/skills/watch-pr/SKILL.en.md +3 -1
  170. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +3 -1
  171. package/templates/.agents/templates/task.en.md +3 -3
  172. package/templates/.agents/templates/task.zh-CN.md +3 -3
  173. package/templates/.github/workflows/metadata-sync.yml +0 -18
@@ -1,11 +1,8 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
1
  import * as p from '@clack/prompts';
4
2
  import pc from 'picocolors';
5
3
  import { loadConfig } from '../config.ts';
6
4
  import { sandboxBranchLabel, sandboxLabel } from '../constants.ts';
7
5
  import { detectEngine } from '../engine.ts';
8
- import { resolveTools, toolProjectDirCandidates } from '../tools.ts';
9
6
  import { formatTable } from '../../table.ts';
10
7
  import { lookupShortIdByBranch } from '../../task/short-id.ts';
11
8
  import { fetchSandboxRows } from './list-running.ts';
@@ -20,7 +17,10 @@ id bound to each container's branch (via
20
17
  .agents/workspace/active/.short-ids.json), or '-' if no active task is
21
18
  bound. Pass the SHORT value to "ai sandbox exec" (e.g. 'ai sandbox exec 11').
22
19
  A '-' means no active task is bound to that branch, so the sandbox is free
23
- to remove with "ai sandbox rm <branch>".`;
20
+ to remove with "ai sandbox rm <branch>".
21
+
22
+ Use "ai sandbox show <ref>" for a single sandbox's worktree and per-tool
23
+ state paths.`;
24
24
 
25
25
  const CONTAINER_TABLE_HEADERS = ['#', 'SHORT', 'NAMES', 'STATUS', 'BRANCH'] as const;
26
26
 
@@ -40,14 +40,6 @@ export function formatContainerTable(rows: ContainerTableRow[], zebra = false):
40
40
  );
41
41
  }
42
42
 
43
- function listChildren(dir: string): string[] {
44
- if (!fs.existsSync(dir)) {
45
- return [];
46
- }
47
-
48
- return fs.readdirSync(dir).sort().map((entry) => path.join(dir, entry));
49
- }
50
-
51
43
  export function ls(args: string[] = []): void {
52
44
  if (args.length > 0 && (args[0] === '--help' || args[0] === '-h')) {
53
45
  process.stdout.write(`${USAGE}\n`);
@@ -56,7 +48,6 @@ export function ls(args: string[] = []): void {
56
48
 
57
49
  const config = loadConfig();
58
50
  const engine = detectEngine(config);
59
- const tools = resolveTools(config);
60
51
  const label = sandboxLabel(config);
61
52
  const { running, nonRunning } = fetchSandboxRows(engine, label, sandboxBranchLabel(config));
62
53
 
@@ -87,27 +78,4 @@ export function ls(args: string[] = []): void {
87
78
  );
88
79
  }
89
80
  }
90
-
91
- p.log.step('Worktrees');
92
- const worktrees = listChildren(config.worktreeBase);
93
- if (worktrees.length === 0) {
94
- p.log.warn(' No sandbox worktrees');
95
- } else {
96
- for (const worktree of worktrees) {
97
- process.stdout.write(` ${worktree}\n`);
98
- }
99
- }
100
-
101
- for (const tool of tools) {
102
- p.log.step(`${tool.name} state`);
103
- const entries = toolProjectDirCandidates(tool, config.project)
104
- .flatMap((dir) => listChildren(dir));
105
- if (entries.length === 0) {
106
- p.log.warn(` No ${tool.name} sandbox state`);
107
- continue;
108
- }
109
- for (const entry of entries) {
110
- process.stdout.write(` ${entry}\n`);
111
- }
112
- }
113
81
  }
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import * as p from '@clack/prompts';
3
+ import pc from 'picocolors';
4
+ import type { SandboxConfig } from '../config.ts';
5
+ import { loadConfig } from '../config.ts';
6
+ import { assertValidBranchName, worktreeDirCandidates } from '../constants.ts';
7
+ import { resolveBranchArg } from './list-running.ts';
8
+ import { resolveTools, toolConfigDirCandidates } from '../tools.ts';
9
+
10
+ const USAGE = `Usage: ai sandbox show <branch | TASK-id | N | '#N'>
11
+
12
+ Shows one sandbox's worktree path and per-tool state paths (Claude Code,
13
+ Codex, Gemini CLI, OpenCode). The argument follows the same contract as
14
+ 'ai sandbox exec' and 'ai sandbox start': N (bare) is the recommended form
15
+ for task short ids (e.g. 'ai sandbox show 11'); '#N', a TASK-id, or a plain
16
+ branch name are also accepted. Use 'ai sandbox ls' for the container list.`;
17
+
18
+ export type SandboxDetail = {
19
+ worktrees: string[];
20
+ toolStates: { name: string; entries: string[] }[];
21
+ };
22
+
23
+ function existingDirs(candidates: string[]): string[] {
24
+ const seen = new Set<string>();
25
+ const result: string[] = [];
26
+ for (const candidate of candidates) {
27
+ if (!seen.has(candidate) && fs.existsSync(candidate)) {
28
+ seen.add(candidate);
29
+ result.push(candidate);
30
+ }
31
+ }
32
+ return result;
33
+ }
34
+
35
+ export function collectSandboxDetail(config: SandboxConfig, branch: string): SandboxDetail {
36
+ const worktrees = existingDirs(worktreeDirCandidates(config, branch));
37
+ const toolStates = resolveTools(config).map((tool) => ({
38
+ name: tool.name,
39
+ entries: existingDirs(toolConfigDirCandidates(tool, config.project, branch))
40
+ }));
41
+ return { worktrees, toolStates };
42
+ }
43
+
44
+ export function show(args: string[] = []): void {
45
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
46
+ process.stdout.write(`${USAGE}\n`);
47
+ if (args.length === 0) {
48
+ process.exitCode = 1;
49
+ }
50
+ return;
51
+ }
52
+
53
+ const config = loadConfig();
54
+ const branch = resolveBranchArg(args[0]!, { repoRoot: config.repoRoot });
55
+ assertValidBranchName(branch);
56
+
57
+ const detail = collectSandboxDetail(config, branch);
58
+
59
+ p.intro(pc.cyan(`Sandbox detail for ${config.project} · ${branch}`));
60
+
61
+ p.log.step('Worktree');
62
+ if (detail.worktrees.length === 0) {
63
+ p.log.warn(' No worktree for this branch');
64
+ } else {
65
+ for (const worktree of detail.worktrees) {
66
+ process.stdout.write(` ${worktree}\n`);
67
+ }
68
+ }
69
+
70
+ for (const tool of detail.toolStates) {
71
+ p.log.step(`${tool.name} state`);
72
+ if (tool.entries.length === 0) {
73
+ p.log.warn(` No ${tool.name} sandbox state`);
74
+ continue;
75
+ }
76
+ for (const entry of tool.entries) {
77
+ process.stdout.write(` ${entry}\n`);
78
+ }
79
+ }
80
+ }
@@ -9,6 +9,8 @@ Commands:
9
9
  ls List sandboxes for the current project (the '#'
10
10
  column is a display-only row number; the 'SHORT'
11
11
  column shows the active task short id, '-' if none)
12
+ show <branch | TASK-id | N | '#N'>
13
+ Show one sandbox's worktree and per-tool state paths
12
14
  prune [--dry-run] Remove orphaned per-branch state dirs
13
15
  rebuild [--quiet] [--refresh]
14
16
  Rebuild the sandbox image (--refresh pulls base + tools)
@@ -56,6 +58,11 @@ export async function runSandbox(args: string[]): Promise<void> {
56
58
  ls(rest);
57
59
  break;
58
60
  }
61
+ case 'show': {
62
+ const { show } = await import('./commands/show.ts');
63
+ show(rest);
64
+ break;
65
+ }
59
66
  case 'prune': {
60
67
  const { prune } = await import('./commands/prune.ts');
61
68
  await prune(rest);
@@ -0,0 +1,42 @@
1
+ import type { ServerConfig } from '../config.ts';
2
+ import type { Logger } from '../logger.ts';
3
+
4
+ // Adapter contract for agent-infra-server.
5
+ //
6
+ // This is a pure type + constant module: it carries no runtime logic and no
7
+ // third-party imports. Subtask B (the feishu adapter) and subtask C (the
8
+ // command protocol / runner) build against these shapes. Bump
9
+ // ADAPTER_CONTRACT_VERSION on a breaking change so plugin-loader can reject
10
+ // adapters compiled against an older contract.
11
+ export const ADAPTER_CONTRACT_VERSION = 1;
12
+
13
+ // A normalized inbound message handed to the daemon command dispatcher.
14
+ export type InboundMessage = {
15
+ adapter: string;
16
+ userId: string;
17
+ chatId: string;
18
+ text: string;
19
+ messageId: string;
20
+ raw: unknown;
21
+ reply: (text: string) => Promise<void>;
22
+ };
23
+
24
+ // Runtime context passed to every adapter's start(). dispatch() is registered
25
+ // by the daemon; signal aborts when the daemon shuts down.
26
+ export type AdapterCtx = {
27
+ config: ServerConfig;
28
+ logger: Logger;
29
+ dispatch: (message: InboundMessage) => Promise<void>;
30
+ signal: AbortSignal;
31
+ };
32
+
33
+ // The interface every adapter factory's default export must produce.
34
+ export type Adapter = {
35
+ name: string;
36
+ start: (ctx: AdapterCtx) => Promise<void>;
37
+ stop: () => Promise<void>;
38
+ sendMessage: (target: { chatId: string }, text: string) => Promise<void>;
39
+ };
40
+
41
+ // The shape of an adapter module's default export (a factory).
42
+ export type AdapterFactory = (adapterConfig: Record<string, unknown>) => Adapter;
@@ -0,0 +1,47 @@
1
+ import type { Adapter, AdapterCtx, AdapterFactory, InboundMessage } from '../_contract.ts';
2
+ import { createFeishuTransport, normalizeMessage } from './transport.ts';
3
+ import type { FeishuTransport } from './transport.ts';
4
+
5
+ // Assemble the feishu adapter. The transport is injectable so unit tests can
6
+ // drive start/dispatch/reply/sendMessage against a fake transport without the
7
+ // real SDK; production uses the default SDK-backed transport.
8
+ export function createFeishuAdapter(
9
+ config: Record<string, unknown>,
10
+ transport: FeishuTransport = createFeishuTransport(config)
11
+ ): Adapter {
12
+ let ctx: AdapterCtx | null = null;
13
+
14
+ return {
15
+ name: 'feishu',
16
+ async start(adapterCtx) {
17
+ ctx = adapterCtx;
18
+ await transport.start(async (raw) => {
19
+ try {
20
+ const normalized = normalizeMessage(raw);
21
+ const message: InboundMessage = {
22
+ adapter: 'feishu',
23
+ userId: normalized.userId,
24
+ chatId: normalized.chatId,
25
+ text: normalized.text,
26
+ messageId: normalized.messageId,
27
+ raw: normalized.raw,
28
+ reply: (text) => transport.send(normalized.chatId, text)
29
+ };
30
+ await adapterCtx.dispatch(message);
31
+ } catch (error) {
32
+ ctx?.logger.err(`feishu: dropped message: ${error instanceof Error ? error.message : String(error)}`);
33
+ }
34
+ });
35
+ },
36
+ async stop() {
37
+ await transport.stop();
38
+ },
39
+ async sendMessage(target, text) {
40
+ await transport.send(target.chatId, text);
41
+ }
42
+ };
43
+ }
44
+
45
+ const factory: AdapterFactory = (adapterConfig) => createFeishuAdapter(adapterConfig);
46
+
47
+ export default factory;
@@ -0,0 +1,128 @@
1
+ import * as lark from '@larksuiteoapi/node-sdk';
2
+
3
+ // Transport layer for the feishu adapter. All @larksuiteoapi/node-sdk surface is
4
+ // confined here so the adapter body (index.ts) depends only on this narrow
5
+ // interface plus the pure normalizeMessage(). That keeps normalize unit-testable
6
+ // without the SDK and lets the adapter be assembled against a fake transport.
7
+
8
+ export type FeishuTransport = {
9
+ // Begin receiving messages. onMessage is invoked with the raw SDK event for
10
+ // each inbound im.message.receive_v1.
11
+ start: (onMessage: (raw: unknown) => Promise<void>) => Promise<void>;
12
+ stop: () => Promise<void>;
13
+ send: (chatId: string, text: string) => Promise<void>;
14
+ };
15
+
16
+ export type NormalizedMessage = {
17
+ userId: string;
18
+ chatId: string;
19
+ text: string;
20
+ messageId: string;
21
+ raw: unknown;
22
+ };
23
+
24
+ function asRecord(value: unknown): Record<string, unknown> {
25
+ return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
26
+ }
27
+
28
+ // Strip feishu mention placeholders (e.g. "@_user_1") that the platform injects
29
+ // for @-mentions, then collapse surrounding whitespace. A group message like
30
+ // "@_user_1 /ping" normalizes to "/ping" so the daemon dispatcher matches it.
31
+ function stripMentions(text: string): string {
32
+ return text.replace(/@_user_\d+/g, ' ').replace(/\s+/g, ' ').trim();
33
+ }
34
+
35
+ // Parse an im.message.receive_v1 event into the daemon's shape. Pure: no SDK,
36
+ // no IO. Throws on missing/invalid fields so the adapter can drop a single bad
37
+ // message without crashing the long connection.
38
+ export function normalizeMessage(event: unknown): NormalizedMessage {
39
+ const message = asRecord(asRecord(event).message);
40
+ const messageId = message.message_id;
41
+ const chatId = message.chat_id;
42
+ const content = message.content;
43
+ if (typeof messageId !== 'string' || typeof chatId !== 'string' || typeof content !== 'string') {
44
+ throw new Error('feishu: malformed im.message.receive_v1 event (missing message_id/chat_id/content)');
45
+ }
46
+
47
+ const senderId = asRecord(asRecord(asRecord(event).sender).sender_id);
48
+ const userId =
49
+ (typeof senderId.open_id === 'string' && senderId.open_id) ||
50
+ (typeof senderId.union_id === 'string' && senderId.union_id) ||
51
+ (typeof senderId.user_id === 'string' && senderId.user_id) ||
52
+ '';
53
+
54
+ let parsed: unknown;
55
+ try {
56
+ parsed = JSON.parse(content);
57
+ } catch {
58
+ throw new Error('feishu: message.content is not valid JSON');
59
+ }
60
+ const rawText = asRecord(parsed).text;
61
+ if (typeof rawText !== 'string') {
62
+ throw new Error('feishu: unsupported message content (no text field)');
63
+ }
64
+
65
+ return { userId, chatId, text: stripMentions(rawText), messageId, raw: event };
66
+ }
67
+
68
+ function resolveDomain(value: unknown): number {
69
+ return value === 'lark' || value === 'Lark' ? lark.Domain.Lark : lark.Domain.Feishu;
70
+ }
71
+
72
+ // Build the real SDK-backed transport from adapter config. appId/appSecret come
73
+ // from server config (appSecret only via server.local.json / env). The WSClient
74
+ // long connection delivers events; the Client REST call sends replies.
75
+ // Feishu self-built app IDs have this shape; the SDK's WSClient.start() silently
76
+ // rejects anything else, so we validate up front to fail loudly instead.
77
+ const APP_ID_PATTERN = /^cli_[0-9a-fA-F]{16}$/;
78
+
79
+ export function createFeishuTransport(config: Record<string, unknown>): FeishuTransport {
80
+ const appId = String(config.appId ?? '').trim();
81
+ const appSecret = String(config.appSecret ?? '').trim();
82
+ const domain = resolveDomain(config.domain);
83
+
84
+ // Fail fast on missing or malformed credentials. The SDK only logs to its own
85
+ // logger and resolves WSClient.start() for an empty or malformed appId, so
86
+ // without these checks an enabled-but-misconfigured feishu adapter would be
87
+ // counted as loaded while the long connection never opens. Throwing here lets
88
+ // loadAdapters log `failed to load adapter "feishu": ...` and skip it.
89
+ if (appId === '' || appSecret === '') {
90
+ throw new Error(
91
+ 'feishu: appId and appSecret are required; put appSecret in .agents/server.local.json or AGENT_INFRA_SERVER_adapters__feishu__appSecret'
92
+ );
93
+ }
94
+ if (!APP_ID_PATTERN.test(appId)) {
95
+ throw new Error('feishu: appId must match cli_[0-9a-fA-F]{16}');
96
+ }
97
+
98
+ const client = new lark.Client({ appId, appSecret, domain });
99
+ const wsClient = new lark.WSClient({ appId, appSecret, domain, loggerLevel: lark.LoggerLevel.warn });
100
+
101
+ return {
102
+ async start(onMessage) {
103
+ const eventDispatcher = new lark.EventDispatcher({}).register({
104
+ 'im.message.receive_v1': async (data: unknown) => {
105
+ await onMessage(data);
106
+ }
107
+ });
108
+ wsClient.start({ eventDispatcher });
109
+ },
110
+ async stop() {
111
+ // Best-effort: close the long connection if the SDK exposes it. Never throw
112
+ // from shutdown — unloadAdapters isolates stop failures, but staying quiet
113
+ // keeps the daemon shutdown path clean.
114
+ const closable = wsClient as unknown as { close?: () => void };
115
+ try {
116
+ closable.close?.();
117
+ } catch {
118
+ // Ignore: the daemon is shutting down regardless.
119
+ }
120
+ },
121
+ async send(chatId, text) {
122
+ await client.im.message.create({
123
+ params: { receive_id_type: 'chat_id' },
124
+ data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) }
125
+ });
126
+ }
127
+ };
128
+ }
@@ -0,0 +1,28 @@
1
+ export type CommandRole = 'read' | 'write' | 'exec';
2
+
3
+ type AuthConfig = {
4
+ users?: unknown;
5
+ };
6
+
7
+ const ROLE_RANK: Record<CommandRole, number> = { read: 1, write: 2, exec: 3 };
8
+
9
+ function userRole(auth: AuthConfig, key: string): CommandRole | null {
10
+ const users = auth.users;
11
+ if (!users || typeof users !== 'object' || Array.isArray(users)) return null;
12
+ const record = (users as Record<string, unknown>)[key];
13
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
14
+ const role = (record as Record<string, unknown>).role;
15
+ return role === 'read' || role === 'write' || role === 'exec' ? role : null;
16
+ }
17
+
18
+ export function authorize(
19
+ user: { adapter: string; userId: string },
20
+ required: CommandRole,
21
+ auth: AuthConfig | undefined
22
+ ): { ok: true } | { ok: false; message: string } {
23
+ const role = userRole(auth ?? {}, `${user.adapter}:${user.userId}`);
24
+ if (!role || ROLE_RANK[role] < ROLE_RANK[required]) {
25
+ return { ok: false, message: `requires ${required}` };
26
+ }
27
+ return { ok: true };
28
+ }
@@ -0,0 +1,228 @@
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
+
7
+ export type ServerLogConfig = {
8
+ path: string;
9
+ rotateAtBytes: number;
10
+ };
11
+
12
+ export type ServerAdapterConfig = {
13
+ enabled: boolean;
14
+ [key: string]: unknown;
15
+ };
16
+
17
+ export type ServerConfig = {
18
+ repoRoot: string;
19
+ log: ServerLogConfig;
20
+ // Absolute path to the PID file, under ~/.agent-infra/run/<project>/server.pid.
21
+ pidFile: string;
22
+ heartbeatMs: number;
23
+ adapters: Record<string, ServerAdapterConfig>;
24
+ command?: Record<string, unknown>;
25
+ auth?: Record<string, unknown>;
26
+ stream?: Record<string, unknown>;
27
+ };
28
+
29
+ export type ServerValidation =
30
+ | { ok: true }
31
+ | { ok: false; error: string; fields: string[] };
32
+
33
+ const ENV_PREFIX = 'AGENT_INFRA_SERVER_';
34
+
35
+ // Keys whose presence in the *committed* server.json is treated as a leaked
36
+ // secret. Secrets belong in .agents/server.local.json or the environment.
37
+ const SECRET_KEY_PATTERN = /secret|token|password|passwd|credential|apikey|api_key/i;
38
+
39
+ const DEFAULT_ROTATE_BYTES = 52_428_800; // 50 MiB
40
+
41
+ export const DEFAULT_SERVER_CONFIG: {
42
+ log: { rotateAtBytes: number };
43
+ heartbeatMs: number;
44
+ adapters: Record<string, ServerAdapterConfig>;
45
+ } = {
46
+ log: { rotateAtBytes: DEFAULT_ROTATE_BYTES },
47
+ heartbeatMs: 30_000,
48
+ adapters: {}
49
+ };
50
+
51
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
52
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
53
+ }
54
+
55
+ // Daemon runtime state (log + PID) lives OUTSIDE the repo, under the user's home
56
+ // directory, keyed by the .airc.json "project" AND a stable hash of the repo
57
+ // root path:
58
+ // ~/.agent-infra/logs/<project>/<repo-hash>/server.log
59
+ // ~/.agent-infra/run/<project>/<repo-hash>/server.pid
60
+ // The <project> segment groups a project's checkouts for readability; the
61
+ // <repo-hash> segment guarantees that two checkouts/worktrees of the same
62
+ // project (same "project" but different absolute path) get ISOLATED runtime
63
+ // dirs, so they never read/control each other's daemon. Using os.homedir() +
64
+ // path.join keeps this correct on Windows too (C:\Users\<name>\.agent-infra\...).
65
+ // An explicit log.path in server.json/.local/env still overrides the log default.
66
+ function resolveProjectKey(repoRoot: string): string {
67
+ try {
68
+ const airc = JSON.parse(
69
+ fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8')
70
+ ) as { project?: unknown };
71
+ if (typeof airc.project === 'string' && airc.project.trim() !== '') {
72
+ return airc.project.trim();
73
+ }
74
+ } catch {
75
+ // No .airc.json / unreadable → fall back to the repo directory name.
76
+ }
77
+ return path.basename(repoRoot);
78
+ }
79
+
80
+ // Short, stable, filesystem-safe discriminator for a checkout's absolute path.
81
+ function repoKey(repoRoot: string): string {
82
+ return createHash('sha256').update(repoRoot).digest('hex').slice(0, 12);
83
+ }
84
+
85
+ function runtimePath(repoRoot: string, projectKey: string, kind: 'logs' | 'run', file: string): string {
86
+ return path.join(homedir(), '.agent-infra', kind, projectKey, repoKey(repoRoot), file);
87
+ }
88
+
89
+ function detectRepoRoot(): string {
90
+ try {
91
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
92
+ encoding: 'utf8',
93
+ stdio: ['pipe', 'pipe', 'pipe']
94
+ }).trim();
95
+ } catch {
96
+ throw new Error('server: current directory is not inside a git repository');
97
+ }
98
+ }
99
+
100
+ // Plain-object deep merge: objects recurse, everything else (arrays, scalars)
101
+ // replaces. Intentionally small — server config is shallow and this avoids
102
+ // coupling to lib/merge.ts, whose semantics target the task workspace.
103
+ function deepMerge<T extends Record<string, unknown>>(base: T, override: Record<string, unknown>): T {
104
+ const result: Record<string, unknown> = { ...base };
105
+ for (const [key, value] of Object.entries(override)) {
106
+ const current = result[key];
107
+ if (isPlainObject(current) && isPlainObject(value)) {
108
+ result[key] = deepMerge(current, value);
109
+ } else {
110
+ result[key] = value;
111
+ }
112
+ }
113
+ return result as T;
114
+ }
115
+
116
+ function readJsonIfPresent(filePath: string): Record<string, unknown> {
117
+ if (!fs.existsSync(filePath)) return {};
118
+ const parsed: unknown = JSON.parse(fs.readFileSync(filePath, 'utf8'));
119
+ if (!isPlainObject(parsed)) {
120
+ throw new Error(`server: ${path.basename(filePath)} must contain a JSON object`);
121
+ }
122
+ return parsed;
123
+ }
124
+
125
+ function coerceEnvValue(raw: string): unknown {
126
+ if (raw === 'true') return true;
127
+ if (raw === 'false') return false;
128
+ if (raw !== '' && !Number.isNaN(Number(raw))) return Number(raw);
129
+ return raw;
130
+ }
131
+
132
+ // Map AGENT_INFRA_SERVER_<path> env vars into a nested override object. The
133
+ // path after the prefix uses `__` to separate nesting levels and is treated
134
+ // case-sensitively (e.g. AGENT_INFRA_SERVER_adapters__dev__enabled=false ->
135
+ // { adapters: { dev: { enabled: false } } }).
136
+ function envOverrides(env: NodeJS.ProcessEnv): Record<string, unknown> {
137
+ const override: Record<string, unknown> = {};
138
+ for (const [key, rawValue] of Object.entries(env)) {
139
+ if (!key.startsWith(ENV_PREFIX) || rawValue === undefined) continue;
140
+ const segments = key.slice(ENV_PREFIX.length).split('__').filter(Boolean);
141
+ if (segments.length === 0) continue;
142
+ let cursor = override;
143
+ for (let i = 0; i < segments.length - 1; i += 1) {
144
+ const segment = segments[i] as string;
145
+ const next = cursor[segment];
146
+ if (!isPlainObject(next)) {
147
+ cursor[segment] = {};
148
+ }
149
+ cursor = cursor[segment] as Record<string, unknown>;
150
+ }
151
+ cursor[segments[segments.length - 1] as string] = coerceEnvValue(rawValue);
152
+ }
153
+ return override;
154
+ }
155
+
156
+ // Collect dotted paths of secret-looking, non-empty string fields.
157
+ function collectSecretFields(value: unknown, trail: string[] = []): string[] {
158
+ if (!isPlainObject(value)) return [];
159
+ const found: string[] = [];
160
+ for (const [key, child] of Object.entries(value)) {
161
+ const here = [...trail, key];
162
+ if (SECRET_KEY_PATTERN.test(key) && typeof child === 'string' && child.trim() !== '') {
163
+ found.push(here.join('.'));
164
+ } else if (isPlainObject(child)) {
165
+ found.push(...collectSecretFields(child, here));
166
+ }
167
+ }
168
+ return found;
169
+ }
170
+
171
+ // Reject secrets that live in the committed server.json. server.local.json and
172
+ // the environment are the sanctioned places for secrets and are not scanned.
173
+ export function validateServerConfig(committed: Record<string, unknown>): ServerValidation {
174
+ const fields = collectSecretFields(committed);
175
+ if (fields.length > 0) {
176
+ return {
177
+ ok: false,
178
+ fields,
179
+ error:
180
+ `server: refusing to start — secret-like field(s) found in committed .agents/server.json: ${fields.join(', ')}. ` +
181
+ 'Move them to .agents/server.local.json or AGENT_INFRA_SERVER_* environment variables.'
182
+ };
183
+ }
184
+ return { ok: true };
185
+ }
186
+
187
+ export function loadServerConfig({ rootDir }: { rootDir?: string } = {}): ServerConfig {
188
+ const repoRoot = rootDir ?? detectRepoRoot();
189
+ const agentsDir = path.join(repoRoot, '.agents');
190
+
191
+ const committed = readJsonIfPresent(path.join(agentsDir, 'server.json'));
192
+ const validation = validateServerConfig(committed);
193
+ if (!validation.ok) {
194
+ throw new Error(validation.error);
195
+ }
196
+ const local = readJsonIfPresent(path.join(agentsDir, 'server.local.json'));
197
+
198
+ let merged: Record<string, unknown> = deepMerge<Record<string, unknown>>(
199
+ { log: { rotateAtBytes: DEFAULT_ROTATE_BYTES }, heartbeatMs: DEFAULT_SERVER_CONFIG.heartbeatMs, adapters: {} },
200
+ committed
201
+ );
202
+ merged = deepMerge(merged, local);
203
+ merged = deepMerge(merged, envOverrides(process.env));
204
+
205
+ const projectKey = resolveProjectKey(repoRoot);
206
+
207
+ const log = isPlainObject(merged.log) ? merged.log : {};
208
+ // No explicit log.path → default under ~/.agent-infra/logs/<project>/.
209
+ // Explicit relative path resolves against the repo root; absolute is used as-is.
210
+ const explicitPath = typeof log.path === 'string' ? log.path : null;
211
+ const resolvedLogPath = explicitPath === null
212
+ ? runtimePath(repoRoot, projectKey, 'logs', 'server.log')
213
+ : (path.isAbsolute(explicitPath) ? explicitPath : path.join(repoRoot, explicitPath));
214
+
215
+ return {
216
+ repoRoot,
217
+ pidFile: runtimePath(repoRoot, projectKey, 'run', 'server.pid'),
218
+ log: {
219
+ path: resolvedLogPath,
220
+ rotateAtBytes: typeof log.rotateAtBytes === 'number' ? log.rotateAtBytes : DEFAULT_ROTATE_BYTES
221
+ },
222
+ heartbeatMs: typeof merged.heartbeatMs === 'number' ? merged.heartbeatMs : DEFAULT_SERVER_CONFIG.heartbeatMs,
223
+ adapters: isPlainObject(merged.adapters) ? (merged.adapters as Record<string, ServerAdapterConfig>) : {},
224
+ command: isPlainObject(merged.command) ? merged.command : undefined,
225
+ auth: isPlainObject(merged.auth) ? merged.auth : undefined,
226
+ stream: isPlainObject(merged.stream) ? merged.stream : undefined
227
+ };
228
+ }