@fitlab-ai/agent-infra 0.8.1 → 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.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/bin/cli.ts +3 -3
  4. package/dist/bin/cli.js +4 -4
  5. package/dist/lib/cp.js +1 -1
  6. package/dist/lib/decide.js +2 -2
  7. package/dist/lib/defaults.json +2 -0
  8. package/dist/lib/init.js +7 -7
  9. package/dist/lib/merge.js +1 -1
  10. package/dist/lib/prompt.js +1 -1
  11. package/dist/lib/run/index.js +7 -7
  12. package/dist/lib/run/prompt.js +1 -1
  13. package/dist/lib/sandbox/capture.js +5 -5
  14. package/dist/lib/sandbox/clipboard/bridge.js +48 -13
  15. package/dist/lib/sandbox/clipboard/index.js +5 -6
  16. package/dist/lib/sandbox/clipboard/linux.js +91 -0
  17. package/dist/lib/sandbox/clipboard/paths.js +1 -1
  18. package/dist/lib/sandbox/clipboard/win32.js +144 -0
  19. package/dist/lib/sandbox/commands/create.js +101 -66
  20. package/dist/lib/sandbox/commands/enter.js +9 -9
  21. package/dist/lib/sandbox/commands/list-running.js +2 -2
  22. package/dist/lib/sandbox/commands/ls.js +7 -7
  23. package/dist/lib/sandbox/commands/prune.js +8 -8
  24. package/dist/lib/sandbox/commands/rebuild.js +38 -50
  25. package/dist/lib/sandbox/commands/refresh.js +2 -2
  26. package/dist/lib/sandbox/commands/rm.js +11 -11
  27. package/dist/lib/sandbox/commands/show.js +4 -4
  28. package/dist/lib/sandbox/commands/start.js +4 -4
  29. package/dist/lib/sandbox/commands/vm.js +4 -4
  30. package/dist/lib/sandbox/config.js +11 -5
  31. package/dist/lib/sandbox/constants.js +4 -1
  32. package/dist/lib/sandbox/credentials.js +1 -1
  33. package/dist/lib/sandbox/dotfiles.js +1 -1
  34. package/dist/lib/sandbox/engine.js +3 -3
  35. package/dist/lib/sandbox/engines/index.js +5 -5
  36. package/dist/lib/sandbox/engines/wsl2-paths.js +1 -1
  37. package/dist/lib/sandbox/image-build.js +80 -0
  38. package/dist/lib/sandbox/image-prune.js +2 -2
  39. package/dist/lib/sandbox/index.js +10 -10
  40. package/dist/lib/sandbox/managed-fs.js +1 -1
  41. package/dist/lib/sandbox/readme-scaffold.js +1 -1
  42. package/dist/lib/sandbox/runtimes/node20.dockerfile +1 -1
  43. package/dist/lib/sandbox/runtimes/node22.dockerfile +1 -1
  44. package/dist/lib/sandbox/tools.js +11 -2
  45. package/dist/lib/server/adapters/feishu/index.js +12 -3
  46. package/dist/lib/server/adapters/feishu/renderer.js +88 -0
  47. package/dist/lib/server/adapters/feishu/transport.js +9 -2
  48. package/dist/lib/server/daemon.js +64 -43
  49. package/dist/lib/server/display.js +83 -0
  50. package/dist/lib/server/index.js +2 -2
  51. package/dist/lib/server/process-control.js +2 -2
  52. package/dist/lib/server/protocol.js +22 -6
  53. package/dist/lib/server/streamer.js +5 -4
  54. package/dist/lib/task/commands/cat.js +2 -2
  55. package/dist/lib/task/commands/decisions.js +4 -4
  56. package/dist/lib/task/commands/files.js +3 -3
  57. package/dist/lib/task/commands/grep.js +3 -3
  58. package/dist/lib/task/commands/issue-body.js +4 -4
  59. package/dist/lib/task/commands/log.js +5 -5
  60. package/dist/lib/task/commands/ls.js +3 -3
  61. package/dist/lib/task/commands/show.js +1 -1
  62. package/dist/lib/task/commands/status.js +71 -25
  63. package/dist/lib/task/index.js +9 -9
  64. package/dist/lib/task/resolve-ref.js +1 -1
  65. package/dist/lib/task/workflow-warnings.js +94 -0
  66. package/dist/lib/update.js +35 -4
  67. package/lib/defaults.json +2 -0
  68. package/lib/sandbox/clipboard/bridge.ts +50 -8
  69. package/lib/sandbox/clipboard/index.ts +5 -6
  70. package/lib/sandbox/clipboard/linux.ts +124 -0
  71. package/lib/sandbox/clipboard/win32.ts +173 -0
  72. package/lib/sandbox/commands/create.ts +123 -65
  73. package/lib/sandbox/commands/rebuild.ts +42 -54
  74. package/lib/sandbox/config.ts +14 -2
  75. package/lib/sandbox/constants.ts +4 -0
  76. package/lib/sandbox/image-build.ts +134 -0
  77. package/lib/sandbox/runtimes/node20.dockerfile +1 -1
  78. package/lib/sandbox/runtimes/node22.dockerfile +1 -1
  79. package/lib/sandbox/tools.ts +9 -0
  80. package/lib/server/adapters/_contract.ts +3 -0
  81. package/lib/server/adapters/feishu/index.ts +12 -2
  82. package/lib/server/adapters/feishu/renderer.ts +99 -0
  83. package/lib/server/adapters/feishu/transport.ts +19 -3
  84. package/lib/server/daemon.ts +74 -38
  85. package/lib/server/display.ts +136 -0
  86. package/lib/server/protocol.ts +20 -4
  87. package/lib/server/streamer.ts +5 -4
  88. package/lib/task/commands/log.ts +3 -3
  89. package/lib/task/commands/status.ts +102 -21
  90. package/lib/task/workflow-warnings.ts +121 -0
  91. package/lib/update.ts +34 -0
  92. package/package.json +3 -3
  93. package/templates/.agents/README.en.md +3 -3
  94. package/templates/.agents/README.zh-CN.md +3 -3
  95. package/templates/.agents/rules/create-issue.github.en.md +3 -3
  96. package/templates/.agents/rules/create-issue.github.zh-CN.md +3 -3
  97. package/templates/.agents/rules/issue-pr-commands.github.en.md +7 -2
  98. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +7 -2
  99. package/templates/.agents/rules/issue-sync.github.en.md +13 -0
  100. package/templates/.agents/rules/issue-sync.github.zh-CN.md +13 -0
  101. package/templates/.agents/rules/next-step-output.en.md +15 -1
  102. package/templates/.agents/rules/next-step-output.zh-CN.md +15 -1
  103. package/templates/.agents/rules/pr-sync.github.en.md +17 -2
  104. package/templates/.agents/rules/pr-sync.github.zh-CN.md +17 -2
  105. package/templates/.agents/rules/task-management.en.md +1 -1
  106. package/templates/.agents/rules/task-management.zh-CN.md +1 -1
  107. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +57 -0
  108. package/templates/.agents/scripts/validate-artifact.js +120 -0
  109. package/templates/.agents/scripts/workflow-warnings.js +290 -0
  110. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +118 -0
  111. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +118 -0
  112. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +49 -0
  113. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +49 -0
  114. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +48 -0
  115. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +48 -0
  116. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +60 -0
  117. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +87 -0
  118. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -0
  119. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -0
  120. package/templates/.agents/skills/create-task/SKILL.en.md +4 -1
  121. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -1
  122. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +33 -1
  123. package/templates/.agents/templates/task.en.md +7 -0
  124. package/templates/.agents/templates/task.zh-CN.md +7 -0
  125. package/templates/.claude/commands/complete-manual-validation.en.md +9 -0
  126. package/templates/.claude/commands/complete-manual-validation.zh-CN.md +9 -0
  127. package/templates/.gemini/commands/_project_/complete-manual-validation.en.toml +8 -0
  128. package/templates/.gemini/commands/_project_/complete-manual-validation.zh-CN.toml +8 -0
  129. package/templates/.github/workflows/metadata-sync.yml +5 -0
  130. package/templates/.opencode/commands/complete-manual-validation.en.md +11 -0
  131. package/templates/.opencode/commands/complete-manual-validation.zh-CN.md +11 -0
@@ -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,4 +1,6 @@
1
1
  import type { Adapter, AdapterCtx, AdapterFactory, InboundMessage } from '../_contract.ts';
2
+ import type { OutboundMessage } from '../../display.ts';
3
+ import { renderFeishuMessage } from './renderer.ts';
2
4
  import { createFeishuTransport, normalizeMessage } from './transport.ts';
3
5
  import type { FeishuTransport } from './transport.ts';
4
6
 
@@ -25,7 +27,12 @@ export function createFeishuAdapter(
25
27
  text: normalized.text,
26
28
  messageId: normalized.messageId,
27
29
  raw: normalized.raw,
28
- reply: (text) => transport.send(normalized.chatId, text)
30
+ reply: async (text) => {
31
+ await transport.send(normalized.chatId, renderFeishuMessage(text));
32
+ },
33
+ replyDisplay: async (message: OutboundMessage) => {
34
+ await transport.send(normalized.chatId, renderFeishuMessage(message));
35
+ }
29
36
  };
30
37
  await adapterCtx.dispatch(message);
31
38
  } catch (error) {
@@ -37,7 +44,10 @@ export function createFeishuAdapter(
37
44
  await transport.stop();
38
45
  },
39
46
  async sendMessage(target, text) {
40
- await transport.send(target.chatId, text);
47
+ await transport.send(target.chatId, renderFeishuMessage(text));
48
+ },
49
+ async sendDisplayMessage(target, message) {
50
+ await transport.send(target.chatId, renderFeishuMessage(message));
41
51
  }
42
52
  };
43
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
+ }
@@ -10,7 +10,15 @@ export type FeishuTransport = {
10
10
  // each inbound im.message.receive_v1.
11
11
  start: (onMessage: (raw: unknown) => Promise<void>) => Promise<void>;
12
12
  stop: () => Promise<void>;
13
- send: (chatId: string, text: string) => Promise<void>;
13
+ send: (chatId: string, message: FeishuMessagePayload) => Promise<void>;
14
+ };
15
+
16
+ export type FeishuMessagePayload = { msg_type: 'interactive'; content: string };
17
+
18
+ type FeishuCreateData = {
19
+ receive_id: string;
20
+ msg_type: 'interactive';
21
+ content: string;
14
22
  };
15
23
 
16
24
  export type NormalizedMessage = {
@@ -69,6 +77,14 @@ function resolveDomain(value: unknown): number {
69
77
  return value === 'lark' || value === 'Lark' ? lark.Domain.Lark : lark.Domain.Feishu;
70
78
  }
71
79
 
80
+ export function toFeishuCreateData(chatId: string, message: FeishuMessagePayload): FeishuCreateData {
81
+ return {
82
+ receive_id: chatId,
83
+ msg_type: message.msg_type,
84
+ content: message.content
85
+ };
86
+ }
87
+
72
88
  // Build the real SDK-backed transport from adapter config. appId/appSecret come
73
89
  // from server config (appSecret only via server.local.json / env). The WSClient
74
90
  // long connection delivers events; the Client REST call sends replies.
@@ -118,10 +134,10 @@ export function createFeishuTransport(config: Record<string, unknown>): FeishuTr
118
134
  // Ignore: the daemon is shutting down regardless.
119
135
  }
120
136
  },
121
- async send(chatId, text) {
137
+ async send(chatId, message) {
122
138
  await client.im.message.create({
123
139
  params: { receive_id_type: 'chat_id' },
124
- data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text }) }
140
+ data: toFeishuCreateData(chatId, message)
125
141
  });
126
142
  }
127
143
  };
@@ -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
- // The daemon main loop. Runs in the detached child spawned by
16
- // process-control.start(), or in the foreground for debugging.
17
- //
18
- // Lifecycle (keep-alive / shutdown model):
19
- // - The heartbeat interval is kept *ref'd*. It is both the keep-alive that
20
- // holds the event loop open while subtask A has no adapters, and the
21
- // observable signal that `ai server logs -f` shows.
22
- // - runDaemon() awaits a shutdown promise that only resolves once a
23
- // SIGINT/SIGTERM handler has finished graceful cleanup. We never unref()
24
- // the only keep-alive timer (that would let the process exit immediately).
25
- export async function runDaemon(): Promise<void> {
26
- let config;
27
- try {
28
- config = loadServerConfig();
29
- } catch (error) {
30
- process.stderr.write(`${errorMessage(error)}\n`);
31
- process.exit(1);
32
- }
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
- const logger = createLogger(config.log);
35
- logger.info(`daemon starting agent-infra ${VERSION} pid=${process.pid}`);
28
+ function isTaskStatus(argv: string[]): boolean {
29
+ return argv[0] === 'task' && argv[1] === 'status' && typeof argv[2] === 'string';
30
+ }
36
31
 
37
- const abortController = new AbortController();
38
- let resolveShutdown: () => void = () => {};
39
- const shutdown = new Promise<void>((resolve) => {
40
- resolveShutdown = resolve;
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
- const dispatch = async (message: InboundMessage): Promise<void> => {
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.reply(plan.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.reply(`pong ${VERSION}`);
46
+ await replyOutbound(message, textMessage(`pong ${VERSION}`));
53
47
  return;
54
48
  }
55
49
  if (plan.kind === 'builtin' && plan.name === 'help') {
56
- await message.reply(commandHelp());
50
+ await replyOutbound(message, markdownMessage(commandHelp()));
57
51
  return;
58
52
  }
59
53
  if (plan.kind === 'builtin' && plan.name === 'version') {
60
- await message.reply(`agent-infra ${VERSION}`);
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.reply(allowed.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) => runAi(plan.argv, { onChunk: emit }),
81
- (text) => message.reply(text)
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
+ }
@@ -23,10 +23,26 @@ function taskSubcommand(subcommand: string): string {
23
23
  export function commandHelp(): string {
24
24
  return [
25
25
  `agent-infra ${VERSION}`,
26
- 'Built-ins: /help, /ping, /version',
27
- 'Read: /sandbox ls|show|vm status, /task decisions|log|ls|show|status',
28
- 'Write: /sandbox create|start',
29
- 'Exec: /decide <task-ref> <HD-id> <decision>, /run create-task <description>, /run <skill> <task-ref> ...'
26
+ 'Built-ins:',
27
+ '/help',
28
+ '/ping',
29
+ '/version',
30
+ 'Read:',
31
+ '/sandbox ls',
32
+ '/sandbox show {ref}',
33
+ '/sandbox vm status',
34
+ '/task decisions {ref}',
35
+ '/task log {ref}',
36
+ '/task ls',
37
+ '/task show {ref}',
38
+ '/task status {ref}',
39
+ 'Write:',
40
+ '/sandbox create {ref}',
41
+ '/sandbox start {ref}',
42
+ 'Exec:',
43
+ '/decide {task-ref} {HD-id} {decision}',
44
+ '/run create-task {description}',
45
+ '/run {skill} {task-ref}'
30
46
  ].join('\n');
31
47
  }
32
48
 
@@ -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: (text: string) => Promise<void>
23
+ send: (message: OutboundMessage) => Promise<void>
23
24
  ): Promise<RunnerResult> {
24
- await send(`started ${options.title}`);
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(`finished ${options.title} exitCode=${result.exitCode} signal=${result.signal ?? 'null'}`);
56
+ await send(streamEvent(options.title, 'finished', undefined, result.exitCode, result.signal));
56
57
  return result;
57
58
  }
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { formatTable } from '../../table.ts';
3
3
  import { resolveTaskRef } from '../resolve-ref.ts';
4
- import { parseLedger, HUMAN_DECISION_STATUSES, type LedgerRow } from '../ledger.ts';
4
+ import { parseLedger, type LedgerRow } from '../ledger.ts';
5
5
 
6
6
  const USAGE = `Usage: ai task log <N | #N | TASK-id>
7
7
 
@@ -14,7 +14,7 @@ Columns: # (row) / STEP / AGENT / STARTED / DONE / NOTE
14
14
  A human-executed step shows AGENT as 'human' and, when it has no start marker,
15
15
  a '-' STARTED placeholder. Review-step NOTE also carries two human counts in
16
16
  the verdict list, right after blockers/major/minor: manual-validation
17
- and human-decision (current ledger stage total).
17
+ and human-decision (current pending ledger stage total).
18
18
  `;
19
19
 
20
20
  const TABLE_HEADERS = ['#', 'STEP', 'AGENT', 'STARTED', 'DONE', 'NOTE'] as const;
@@ -108,7 +108,7 @@ function pairEntries(entries: LogEntry[]): StepRow[] {
108
108
  function countHumanDecisionsByStage(rows: LedgerRow[]): Map<ReviewStage, number> {
109
109
  const counts = new Map<ReviewStage, number>();
110
110
  for (const row of rows) {
111
- if (!isReviewStage(row.stage) || !HUMAN_DECISION_STATUSES.has(row.status)) continue;
111
+ if (!isReviewStage(row.stage) || row.status !== 'needs-human-decision') continue;
112
112
  counts.set(row.stage, (counts.get(row.stage) ?? 0) + 1);
113
113
  }
114
114
  return counts;