@router-agent/runtime 0.0.0-stage → 0.3.31

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 (124) hide show
  1. package/dist/agents/claude.d.ts +51 -0
  2. package/dist/agents/claude.js +270 -0
  3. package/dist/agents/claude.js.map +1 -0
  4. package/dist/agents/codex.d.ts +36 -0
  5. package/dist/agents/codex.js +292 -0
  6. package/dist/agents/codex.js.map +1 -0
  7. package/dist/agents/profile.d.ts +39 -0
  8. package/dist/agents/profile.js +2 -0
  9. package/dist/agents/profile.js.map +1 -0
  10. package/dist/agents/registry.d.ts +21 -0
  11. package/dist/agents/registry.js +78 -0
  12. package/dist/agents/registry.js.map +1 -0
  13. package/dist/errors.d.ts +4 -0
  14. package/dist/errors.js +9 -0
  15. package/dist/errors.js.map +1 -0
  16. package/dist/evidence-gate.d.ts +78 -0
  17. package/dist/evidence-gate.js +196 -0
  18. package/dist/evidence-gate.js.map +1 -0
  19. package/dist/index.d.ts +22 -0
  20. package/dist/index.js +21 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/naming.d.ts +23 -0
  23. package/dist/naming.js +40 -0
  24. package/dist/naming.js.map +1 -0
  25. package/dist/orca/commands.js +60 -0
  26. package/dist/orca/commands.js.map +1 -0
  27. package/dist/orca/index.js +452 -0
  28. package/dist/orca/index.js.map +1 -0
  29. package/dist/orca/options.js +33 -0
  30. package/dist/orca/options.js.map +1 -0
  31. package/dist/orca/report-poller.js +143 -0
  32. package/dist/orca/report-poller.js.map +1 -0
  33. package/dist/permissions/acp.d.ts +21 -0
  34. package/dist/permissions/acp.js +73 -0
  35. package/dist/permissions/acp.js.map +1 -0
  36. package/dist/permissions.d.ts +1 -0
  37. package/dist/permissions.js +2 -0
  38. package/dist/permissions.js.map +1 -0
  39. package/dist/plugins/loader.d.ts +46 -0
  40. package/dist/plugins/loader.js +117 -0
  41. package/dist/plugins/loader.js.map +1 -0
  42. package/dist/plugins/types.d.ts +52 -0
  43. package/dist/plugins/types.js +2 -0
  44. package/dist/plugins/types.js.map +1 -0
  45. package/dist/port.d.ts +295 -0
  46. package/dist/port.js +17 -0
  47. package/dist/port.js.map +1 -0
  48. package/dist/registry.d.ts +13 -0
  49. package/dist/registry.js +35 -0
  50. package/dist/registry.js.map +1 -0
  51. package/dist/report-poller.d.ts +58 -0
  52. package/dist/report-poller.js +130 -0
  53. package/dist/report-poller.js.map +1 -0
  54. package/dist/resume.d.ts +52 -0
  55. package/dist/resume.js +109 -0
  56. package/dist/resume.js.map +1 -0
  57. package/dist/runtimes/acp/admin.js +470 -0
  58. package/dist/runtimes/acp/admin.js.map +1 -0
  59. package/dist/runtimes/acp/agents.js +167 -0
  60. package/dist/runtimes/acp/agents.js.map +1 -0
  61. package/dist/runtimes/acp/detect.js +379 -0
  62. package/dist/runtimes/acp/detect.js.map +1 -0
  63. package/dist/runtimes/acp/index.js +633 -0
  64. package/dist/runtimes/acp/index.js.map +1 -0
  65. package/dist/runtimes/acp/install.js +264 -0
  66. package/dist/runtimes/acp/install.js.map +1 -0
  67. package/dist/runtimes/acp/permissions.js +83 -0
  68. package/dist/runtimes/acp/permissions.js.map +1 -0
  69. package/dist/runtimes/acp/protocol.js +207 -0
  70. package/dist/runtimes/acp/protocol.js.map +1 -0
  71. package/dist/runtimes/acp-agents.d.ts +85 -0
  72. package/dist/runtimes/acp-agents.js +167 -0
  73. package/dist/runtimes/acp-agents.js.map +1 -0
  74. package/dist/runtimes/acp-detect-utils.d.ts +4 -0
  75. package/dist/runtimes/acp-detect-utils.js +35 -0
  76. package/dist/runtimes/acp-detect-utils.js.map +1 -0
  77. package/dist/runtimes/builtin-options.d.ts +3 -0
  78. package/dist/runtimes/builtin-options.js +44 -0
  79. package/dist/runtimes/builtin-options.js.map +1 -0
  80. package/dist/runtimes/catalog.d.ts +31 -0
  81. package/dist/runtimes/catalog.js +75 -0
  82. package/dist/runtimes/catalog.js.map +1 -0
  83. package/dist/runtimes/claude-code/index.js +544 -0
  84. package/dist/runtimes/claude-code/index.js.map +1 -0
  85. package/dist/runtimes/claude-code/inject.js +71 -0
  86. package/dist/runtimes/claude-code/inject.js.map +1 -0
  87. package/dist/runtimes/codex/client.js +203 -0
  88. package/dist/runtimes/codex/client.js.map +1 -0
  89. package/dist/runtimes/codex/detect.js +240 -0
  90. package/dist/runtimes/codex/detect.js.map +1 -0
  91. package/dist/runtimes/codex/index.js +1066 -0
  92. package/dist/runtimes/codex/index.js.map +1 -0
  93. package/dist/runtimes/herdr/agents.js +38 -0
  94. package/dist/runtimes/herdr/agents.js.map +1 -0
  95. package/dist/runtimes/herdr/commands.js +68 -0
  96. package/dist/runtimes/herdr/commands.js.map +1 -0
  97. package/dist/runtimes/herdr/index.js +712 -0
  98. package/dist/runtimes/herdr/index.js.map +1 -0
  99. package/dist/runtimes/herdr/options.js +29 -0
  100. package/dist/runtimes/herdr/options.js.map +1 -0
  101. package/dist/runtimes/herdr/server.js +75 -0
  102. package/dist/runtimes/herdr/server.js.map +1 -0
  103. package/dist/runtimes/options.d.ts +60 -0
  104. package/dist/runtimes/options.js +89 -0
  105. package/dist/runtimes/options.js.map +1 -0
  106. package/dist/spec/contract.md +120 -0
  107. package/dist/spec/index.d.ts +35 -0
  108. package/dist/spec/index.js +59 -0
  109. package/dist/spec/index.js.map +1 -0
  110. package/dist/spec/prompt-mode.md +176 -0
  111. package/dist/spec/reader.d.ts +5 -0
  112. package/dist/spec/reader.js +32 -0
  113. package/dist/spec/reader.js.map +1 -0
  114. package/dist/status.d.ts +8 -0
  115. package/dist/status.js +22 -0
  116. package/dist/status.js.map +1 -0
  117. package/dist/workspaces/git-worktree.d.ts +44 -0
  118. package/dist/workspaces/git-worktree.js +183 -0
  119. package/dist/workspaces/git-worktree.js.map +1 -0
  120. package/dist/workspaces/overview.d.ts +66 -0
  121. package/dist/workspaces/overview.js +12 -0
  122. package/dist/workspaces/overview.js.map +1 -0
  123. package/package.json +36 -4
  124. package/README.md +0 -3
@@ -0,0 +1,1066 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { preview } from '@router-agent/core/events.js';
5
+ import { errorMessage, noopLogger } from '@router-agent/infra/logger.js';
6
+ import { realRunner } from '@router-agent/infra/process.js';
7
+ import { GitWorktreeProvider } from '../../workspaces/git-worktree.js';
8
+ import { workBranch, worktreeName } from '../../naming.js';
9
+ import { latestActivityAt } from '../../status.js';
10
+ import { shellAllowed } from '../acp/permissions.js';
11
+ import { WorkerRuntimeError, } from '../../port.js';
12
+ import { CodexAppClient, CodexProtocolError } from './client.js';
13
+ import { detectCodexBinary, } from './detect.js';
14
+ const CLIENT_NAME = 'router-agent';
15
+ const CLIENT_VERSION = '0.3.0';
16
+ const CHANNEL = 'codex-app';
17
+ const READ_ONLY_TOOLS = { fileAccess: 'read-only', shell: 'deny', allowNetwork: false };
18
+ /**
19
+ * Codex App Server 运行时。
20
+ *
21
+ * 直连 `codex app-server`(JSON-RPC over stdio),用 thread/turn 原语承载工单:
22
+ * - thread/start + turn/start 首轮;
23
+ * - thread/resume 重启后恢复;
24
+ * - turn/steer 执行中追加输入,turn/interrupt 停止;
25
+ * - 原生 command/file/permission 审批请求按 worker.tools 决策。
26
+ *
27
+ * 二进制发现顺序:显式配置 > 环境变量 > Codex App bundle > PATH。
28
+ * 默认 sandbox=danger-full-access、approvalPolicy=never,不产生审批请求。
29
+ */
30
+ export class CodexAppRuntime {
31
+ id;
32
+ capabilities = {
33
+ agent: { liveStatus: true, processExit: true, output: false, activity: true, structuredInput: true, nudge: false },
34
+ workspace: { cleanup: true, prLink: false, discovery: false },
35
+ };
36
+ opts;
37
+ workersDir;
38
+ home;
39
+ env;
40
+ logger;
41
+ initializeTimeoutMs;
42
+ args;
43
+ prependArgs;
44
+ gitProvider;
45
+ workspaces;
46
+ sessions = new Map();
47
+ byThreadId = new Map();
48
+ detectBinary;
49
+ constructor(opts) {
50
+ this.id = opts.id ?? 'codex-app';
51
+ const permission = opts.permissions ?? 'full';
52
+ const approval = opts.approvalPolicy ?? 'never';
53
+ this.opts = {
54
+ ...(opts.command ? { command: opts.command } : {}),
55
+ ...(opts.model ? { model: opts.model } : {}),
56
+ ...(opts.modelProvider ? { modelProvider: opts.modelProvider } : {}),
57
+ permissions: permission,
58
+ approvalPolicy: approval,
59
+ network: opts.network ?? true,
60
+ args: opts.args ?? [],
61
+ env: opts.env ?? {},
62
+ };
63
+ this.workersDir = opts.workersDir;
64
+ this.home = opts.home;
65
+ // Codex 桌面端固定读写 ~/.codex;这里用同一个 HOME,避免服务从别的 CODEX_HOME 启动时
66
+ // thread 落在别处,桌面端侧边栏看不到。显式 env 优先于这个默认值。
67
+ this.env = { CODEX_HOME: join(homedir(), '.codex'), ...this.opts.env };
68
+ this.logger = opts.logger ?? noopLogger;
69
+ this.initializeTimeoutMs = opts.initializeTimeoutMs ?? 30_000;
70
+ this.args = this.opts.args;
71
+ this.prependArgs = opts.prependArgs ?? [];
72
+ this.gitProvider = new GitWorktreeProvider({
73
+ git: opts.git,
74
+ worktreesRoot: opts.worktreesRoot ?? join(opts.home, 'worktrees'),
75
+ });
76
+ this.workspaces = opts.workspaceProvider ?? this.gitProvider;
77
+ this.detectBinary = opts.detectBinary ?? (async (input) => detectCodexBinary({
78
+ home: input.home,
79
+ env: input.env,
80
+ ...(input.command ? { command: input.command } : {}),
81
+ ...(input.commandOptions ? { options: input.commandOptions } : {}),
82
+ }));
83
+ }
84
+ async start(ctx) {
85
+ const ticket = ctx.ticket;
86
+ const repo = ticket.repo;
87
+ const baseBranch = ticket.branch;
88
+ if (!repo)
89
+ throw new WorkerRuntimeError('workspace:missing-repo', '工单没有指定仓库,拒绝创建 worktree');
90
+ if (!baseBranch)
91
+ throw new WorkerRuntimeError('workspace:missing-branch', '工单没有指定起始分支,拒绝创建 worktree');
92
+ const detection = await this.resolveBinary();
93
+ if (!detection.command || !detection.compatible) {
94
+ throw new WorkerRuntimeError('runtime:unavailable', detection.reason ?? 'Codex App Server 不可用', {
95
+ retryable: false,
96
+ detail: {
97
+ runtimeId: this.id,
98
+ source: detection.source,
99
+ command: detection.command,
100
+ hint: detection.installHint,
101
+ installUrl: detection.installUrl,
102
+ },
103
+ });
104
+ }
105
+ const tools = ctx.tools ?? READ_ONLY_TOOLS;
106
+ const sandboxMode = this.sandboxModeOf(tools);
107
+ const branch = workBranch(ticket);
108
+ const name = worktreeName(ticket);
109
+ const workdir = ctx.workdir || join(this.home, 'worktrees', name);
110
+ const dir = join(this.workersDir, ticket.id);
111
+ mkdirSync(dir, { recursive: true });
112
+ const briefPath = join(dir, 'brief.md');
113
+ const workspace = await this.workspaces.create({
114
+ ticketId: ticket.id,
115
+ repo,
116
+ baseBranch,
117
+ workBranch: branch,
118
+ name,
119
+ workdir,
120
+ });
121
+ const contract = (ctx.brief?.contract ?? `执行工单 ${ticket.id}:${ticket.title}`)
122
+ .replace('(由运行时决定)', workspace.workdir);
123
+ writeFileSync(briefPath, contract, 'utf8');
124
+ const meta = {
125
+ schemaVersion: 1,
126
+ ticketId: ticket.id,
127
+ ...(ctx.assignment.id ? { assignmentId: ctx.assignment.id } : {}),
128
+ runtime: this.id,
129
+ handle: '',
130
+ workdir: workspace.workdir,
131
+ repo,
132
+ branch: baseBranch,
133
+ workBranch: workspace.branch,
134
+ status: 'starting',
135
+ startedAt: new Date().toISOString(),
136
+ tools,
137
+ sandboxMode,
138
+ approvalPolicy: this.opts.approvalPolicy,
139
+ binaryPath: detection.command,
140
+ binarySource: detection.source,
141
+ ...(detection.version ? { binaryVersion: detection.version } : {}),
142
+ };
143
+ this.writeMeta(ticket.id, meta);
144
+ this.logger.info('codex:binary-resolved', {
145
+ ticketId: ticket.id,
146
+ source: detection.source,
147
+ path: detection.command,
148
+ version: detection.version,
149
+ });
150
+ if (sandboxMode === 'danger-full-access') {
151
+ this.logger.warn('codex:full-access', {
152
+ ticketId: ticket.id,
153
+ approvalPolicy: this.opts.approvalPolicy,
154
+ sandboxMode,
155
+ });
156
+ }
157
+ let client;
158
+ try {
159
+ client = this.spawnClient(ticket.id, workspace.workdir, detection, sandboxMode);
160
+ await this.initialize(client);
161
+ const thread = await this.startThread(client, ticket.id, ticket.title, workspace.workdir, contract, sandboxMode);
162
+ const session = this.registerSession({
163
+ ticketId: ticket.id,
164
+ threadId: thread,
165
+ workdir: workspace.workdir,
166
+ branch: workspace.branch,
167
+ tools,
168
+ client,
169
+ sandboxMode,
170
+ approvalPolicy: this.opts.approvalPolicy,
171
+ });
172
+ meta.handle = thread;
173
+ meta.sessionId = thread;
174
+ if (client.pid !== undefined)
175
+ meta.pid = client.pid;
176
+ meta.status = 'running';
177
+ this.writeMeta(ticket.id, meta);
178
+ await this.startTurn(session, `${contract}\n\n完成后用 ra report 上报。`, []);
179
+ return {
180
+ handle: thread,
181
+ workdir: workspace.workdir,
182
+ branch: workspace.branch,
183
+ sessionId: thread,
184
+ workspaceRef: workspace.workspaceRef,
185
+ };
186
+ }
187
+ catch (error) {
188
+ await client?.close().catch(() => { });
189
+ await this.rollback(workspace.workdir, workspace.branch, repo);
190
+ throw this.launchError(error, workspace.workdir, workspace.branch);
191
+ }
192
+ }
193
+ async deliver(ref, inputs, opts = {}) {
194
+ const inputIds = inputs.map(input => input.id);
195
+ if (inputs.length === 0)
196
+ return { status: 'accepted', inputIds };
197
+ let meta = this.readMeta(ref.ticketId);
198
+ let session = this.sessions.get(ref.ticketId);
199
+ if (session && !session.client.alive) {
200
+ this.dropSession(session);
201
+ session = undefined;
202
+ }
203
+ if (!session) {
204
+ const revived = await this.revive(meta);
205
+ if (!revived) {
206
+ return { status: 'rejected', inputIds, reason: 'codex-app:agent-gone', retryable: true, retryAfterMs: 30_000 };
207
+ }
208
+ session = revived;
209
+ meta = this.readMeta(ref.ticketId) ?? meta;
210
+ }
211
+ const dedupe = opts.redeliver !== true
212
+ && meta?.lastHandedOffInputId === inputs.at(-1)?.id
213
+ && meta?.lastHandedOffAttempt === (opts.attempt ?? 1);
214
+ if (dedupe)
215
+ return { status: 'accepted', inputIds };
216
+ const text = `${inputs.map(input => `【Router 输入 ${input.id}】\n${input.text}`).join('\n\n')}\n\n继续执行工单,完成后用 ra report 上报。`;
217
+ if (session.inFlight) {
218
+ if (session.activeTurnId) {
219
+ try {
220
+ await session.client.request('turn/steer', {
221
+ threadId: session.threadId,
222
+ expectedTurnId: session.activeTurnId,
223
+ input: [{ type: 'text', text }],
224
+ }, this.initializeTimeoutMs);
225
+ this.rememberDelivered(ref.ticketId, meta, inputs.at(-1)?.id, opts.attempt ?? 1);
226
+ this.logger.info('codex:turn-steered', {
227
+ ticketId: ref.ticketId,
228
+ threadId: session.threadId,
229
+ turnId: session.activeTurnId,
230
+ inputId: inputs.at(-1)?.id,
231
+ });
232
+ return { status: 'accepted', inputIds };
233
+ }
234
+ catch (error) {
235
+ this.logger.warn('codex:steer-failed', {
236
+ ticketId: ref.ticketId,
237
+ threadId: session.threadId,
238
+ reason: errorMessage(error),
239
+ });
240
+ return { status: 'queued', inputIds, reason: 'codex-app:busy' };
241
+ }
242
+ }
243
+ return { status: 'queued', inputIds, reason: 'codex-app:busy' };
244
+ }
245
+ try {
246
+ await this.startTurn(session, text, inputs);
247
+ this.rememberDelivered(ref.ticketId, meta, inputs.at(-1)?.id, opts.attempt ?? 1);
248
+ return { status: 'accepted', inputIds };
249
+ }
250
+ catch (error) {
251
+ return {
252
+ status: 'rejected',
253
+ inputIds,
254
+ reason: error instanceof WorkerRuntimeError ? error.reason : 'codex-app:deliver-failed',
255
+ retryable: true,
256
+ retryAfterMs: 30_000,
257
+ };
258
+ }
259
+ }
260
+ async stop(ref, _reason) {
261
+ const session = this.sessions.get(ref.ticketId);
262
+ if (session) {
263
+ if (session.activeTurnId) {
264
+ await session.client.request('turn/interrupt', {
265
+ threadId: session.threadId,
266
+ turnId: session.activeTurnId,
267
+ }, 5_000).catch(() => { });
268
+ }
269
+ this.dropSession(session);
270
+ await session.client.close().catch(() => { });
271
+ }
272
+ const meta = this.readMeta(ref.ticketId);
273
+ if (meta) {
274
+ meta.status = 'stopped';
275
+ this.writeMeta(ref.ticketId, meta);
276
+ }
277
+ }
278
+ /** 重启对账用:用 thread/resume 接回已有 Codex thread。 */
279
+ async reattach(ref) {
280
+ const session = this.sessions.get(ref.ticketId);
281
+ if (session?.client.alive)
282
+ return true;
283
+ if (session)
284
+ this.dropSession(session);
285
+ return (await this.revive(this.readMeta(ref.ticketId))) !== null;
286
+ }
287
+ /**
288
+ * 查询最后一轮是否被中途打断。
289
+ *
290
+ * Codex 把每轮的执行记录落在磁盘 rollout 里,`thread/turns/list` 能读回
291
+ * 每一轮的状态。被 ra 重启打断的那一轮状态是 `interrupted` —— 这是精确信号,
292
+ * 不需要靠时间窗口或内存状态去猜。
293
+ *
294
+ * 只在「上一轮没跑完」时返回;正常完成 / 没有历史 / 查不到都返回 null。
295
+ */
296
+ async interruptedTurn(ref) {
297
+ const meta = this.readMeta(ref.ticketId);
298
+ const threadId = ref.sessionId ?? meta?.sessionId;
299
+ if (!threadId)
300
+ return null;
301
+ const session = this.sessions.get(ref.ticketId);
302
+ const client = session?.client;
303
+ if (!client?.alive)
304
+ return null;
305
+ try {
306
+ const response = await client.request('thread/turns/list', {
307
+ threadId,
308
+ limit: 5,
309
+ itemsView: 'full',
310
+ }, this.initializeTimeoutMs);
311
+ const turns = Array.isArray(response?.data) ? response.data : [];
312
+ const last = turns[0];
313
+ if (!last || typeof last.id !== 'string')
314
+ return null;
315
+ // 只有 interrupted 才是「被中途打断」;failed 是 worker 自己报错,交给别的路径处理。
316
+ if (last.status !== 'interrupted')
317
+ return null;
318
+ const summary = summarizeTurnItems(last.items);
319
+ return {
320
+ turnId: last.id,
321
+ ...(summary ? { summary } : {}),
322
+ ...(typeof last.startedAt === 'number' ? { at: new Date(last.startedAt * 1000).toISOString() } : {}),
323
+ };
324
+ }
325
+ catch (error) {
326
+ // 查不到不算失败:只是无法判定有没有被中断,不触发任何恢复动作。
327
+ this.logger.warn('codex:interrupted-turn-check-failed', {
328
+ ticketId: ref.ticketId,
329
+ threadId,
330
+ reason: errorMessage(error),
331
+ });
332
+ return null;
333
+ }
334
+ }
335
+ async status(ref) {
336
+ const session = this.sessions.get(ref.ticketId);
337
+ const meta = this.readMeta(ref.ticketId);
338
+ if (!session) {
339
+ if (!meta)
340
+ return null;
341
+ const lastActivityAt = this.lastEventAtFromFile(ref.ticketId) ?? meta.startedAt;
342
+ return {
343
+ alive: false,
344
+ phase: 'exited',
345
+ acceptingInput: Boolean(meta.sessionId),
346
+ ...(lastActivityAt ? { lastActivityAt } : {}),
347
+ detail: {
348
+ source: this.id,
349
+ status: meta.status ?? 'exited',
350
+ ...(meta.sessionId ? { threadId: meta.sessionId } : {}),
351
+ ...(meta.sandboxMode ? { sandboxMode: meta.sandboxMode } : {}),
352
+ },
353
+ };
354
+ }
355
+ const lastActivityAt = latestActivityAt(session.lastEventAt, session.lastPromptStartedAt) ?? meta?.startedAt;
356
+ if (!session.client.alive) {
357
+ return {
358
+ alive: false,
359
+ phase: 'exited',
360
+ acceptingInput: true,
361
+ ...(lastActivityAt ? { lastActivityAt } : {}),
362
+ ...(session.client.exit.code !== null ? { exitCode: session.client.exit.code } : {}),
363
+ ...(session.client.exit.signal ? { exitSignal: session.client.exit.signal } : {}),
364
+ detail: { source: this.id, status: 'exited', threadId: session.threadId },
365
+ };
366
+ }
367
+ if (session.waitingApproval) {
368
+ return {
369
+ alive: true,
370
+ phase: 'blocked',
371
+ acceptingInput: false,
372
+ ...(lastActivityAt ? { lastActivityAt } : {}),
373
+ detail: { source: this.id, status: 'waiting-approval', threadId: session.threadId },
374
+ };
375
+ }
376
+ if (session.inFlight) {
377
+ return {
378
+ alive: true,
379
+ phase: 'running',
380
+ acceptingInput: false,
381
+ ...(lastActivityAt ? { lastActivityAt } : {}),
382
+ detail: {
383
+ source: this.id,
384
+ status: 'working',
385
+ threadId: session.threadId,
386
+ ...(session.activeTurnId ? { turnId: session.activeTurnId } : {}),
387
+ },
388
+ };
389
+ }
390
+ return {
391
+ alive: true,
392
+ phase: 'running',
393
+ acceptingInput: true,
394
+ ...(lastActivityAt ? { lastActivityAt } : {}),
395
+ detail: {
396
+ source: this.id,
397
+ status: session.lastError ? 'error' : 'idle',
398
+ threadId: session.threadId,
399
+ ...(session.lastError ? { error: session.lastError } : {}),
400
+ ...(session.lastTurnStatus ? { lastTurnStatus: session.lastTurnStatus } : {}),
401
+ },
402
+ };
403
+ }
404
+ async activity(query) {
405
+ const meta = this.readMeta(query.ref.ticketId);
406
+ const session = this.sessions.get(query.ref.ticketId);
407
+ if (!meta && !session)
408
+ return null;
409
+ const events = this.readEvents(query.ref.ticketId);
410
+ const max = query.opts?.maxEvents ?? 40;
411
+ const tail = events.slice(-max);
412
+ const sessionId = query.ref.sessionId ?? session?.threadId ?? meta?.sessionId;
413
+ const at = tail.at(-1)?.at ?? session?.lastEventAt ?? meta?.startedAt ?? new Date().toISOString();
414
+ return { at, events: tail, ...(sessionId ? { sessionId } : {}) };
415
+ }
416
+ async inspectWorkspace(ref) {
417
+ return this.workspaces.inspect(ref.workdir);
418
+ }
419
+ async removeWorkspace(ref) {
420
+ const meta = this.readMeta(ref.ticketId);
421
+ await this.workspaces.remove(ref.workdir, { repo: ref.repo ?? meta?.repo });
422
+ }
423
+ async repoSlug(ref) {
424
+ if (!ref.workdir)
425
+ return null;
426
+ return this.gitProvider.remoteRepo(ref.workdir);
427
+ }
428
+ async doctor() {
429
+ const checks = [];
430
+ const detection = await this.resolveBinary();
431
+ if (!detection.command || !detection.compatible) {
432
+ checks.push({
433
+ name: 'Codex App / CLI',
434
+ status: 'fail',
435
+ detail: detection.reason ?? '未找到可用的 Codex',
436
+ hint: `${detection.installHint}${detection.installUrl ? `(${detection.installUrl})` : ''}`,
437
+ });
438
+ }
439
+ else {
440
+ checks.push({
441
+ name: 'Codex App / CLI',
442
+ status: 'ok',
443
+ detail: `${detection.command}${detection.version ? ` · ${detection.version}` : ''}(${detection.source})`,
444
+ });
445
+ const login = await this.probeLogin(detection.command);
446
+ checks.push({
447
+ name: 'Codex 登录',
448
+ status: login ? 'ok' : 'warn',
449
+ detail: login ? 'codex login status 通过' : '未检测到登录态',
450
+ ...(login ? {} : { hint: '先运行 codex login(或 codex-acp login)完成登录' }),
451
+ });
452
+ const appServer = await this.probeAppServer(detection.command);
453
+ checks.push({
454
+ name: 'Codex app-server',
455
+ status: appServer ? 'ok' : 'fail',
456
+ detail: appServer ? 'app-server --help 可用' : 'app-server 子命令不可用',
457
+ ...(appServer ? {} : { hint: '升级 Codex / Codex App 到包含 app-server 的版本' }),
458
+ });
459
+ }
460
+ const git = await this.gitProvider.version();
461
+ checks.push({
462
+ name: 'git worktree',
463
+ status: git === null ? 'fail' : 'ok',
464
+ detail: git ?? 'git --version 不可用',
465
+ ...(git === null ? { hint: '安装 git,并确认 git worktree 可用' } : {}),
466
+ });
467
+ return checks;
468
+ }
469
+ async resolveBinary() {
470
+ return this.detectBinary({
471
+ home: this.home,
472
+ env: { ...process.env, ...this.env },
473
+ ...(this.opts.command ? { command: this.opts.command } : {}),
474
+ commandOptions: this.opts,
475
+ });
476
+ }
477
+ spawnClient(ticketId, workdir, detection, sandboxMode) {
478
+ if (!detection.command) {
479
+ throw new WorkerRuntimeError('runtime:unavailable', detection.reason ?? 'Codex 不可用', {
480
+ detail: { runtimeId: this.id, hint: detection.installHint },
481
+ });
482
+ }
483
+ const args = [...this.prependArgs, ...this.serverArgs(ticketId, workdir, sandboxMode)];
484
+ this.logger.info('codex:codex-home', {
485
+ ticketId,
486
+ codexHome: this.env.CODEX_HOME ?? '',
487
+ });
488
+ return new CodexAppClient({
489
+ command: detection.command,
490
+ args,
491
+ cwd: workdir,
492
+ env: { ...process.env, ...this.env },
493
+ onNotification: (method, params) => this.onNotification(ticketId, method, params),
494
+ onRequest: (method, params) => this.onRequest(ticketId, method, params),
495
+ onExit: (code, signal, stderr) => this.onExit(ticketId, code, signal, stderr),
496
+ });
497
+ }
498
+ /** `codex -c ... app-server --stdio`;`-c` 覆盖只作用于本次工单进程。 */
499
+ serverArgs(ticketId, workdir, sandboxMode) {
500
+ const args = [...this.args];
501
+ const set = (key, value) => {
502
+ args.push('-c', `${key}=${value}`);
503
+ };
504
+ set('sandbox_mode', tomlString(sandboxMode));
505
+ set('approval_policy', tomlString(this.opts.approvalPolicy));
506
+ if (sandboxMode === 'workspace-write') {
507
+ set('sandbox_workspace_write.network_access', this.opts.network ? 'true' : 'false');
508
+ const roots = [workdir, join(this.workersDir, ticketId)];
509
+ set('sandbox_workspace_write.writable_roots', `[${roots.map(tomlString).join(', ')}]`);
510
+ }
511
+ if (this.opts.model)
512
+ set('model', tomlString(this.opts.model));
513
+ if (this.opts.modelProvider)
514
+ set('model_provider', tomlString(this.opts.modelProvider));
515
+ args.push('app-server', '--stdio');
516
+ return args;
517
+ }
518
+ async initialize(client) {
519
+ const result = await client.request('initialize', {
520
+ clientInfo: { name: CLIENT_NAME, title: 'Router Agent', version: CLIENT_VERSION },
521
+ capabilities: { experimentalApi: true },
522
+ }, this.initializeTimeoutMs);
523
+ client.notify('initialized');
524
+ return result;
525
+ }
526
+ async startThread(client, ticketId, title, workdir, contract, sandboxMode) {
527
+ const response = await client.request('thread/start', {
528
+ cwd: workdir,
529
+ sandbox: sandboxMode,
530
+ approvalPolicy: this.opts.approvalPolicy,
531
+ ...(this.opts.model ? { model: this.opts.model } : {}),
532
+ ...(this.opts.modelProvider ? { modelProvider: this.opts.modelProvider } : {}),
533
+ developerInstructions: [
534
+ '你是 router-agent 派出的 Worker。',
535
+ '所有状态通过 `ra report` 上报;不要直接给飞书发消息,不要自己联系发起人。',
536
+ '工单正文见本轮用户输入;完成后必须用 ra report 上报。',
537
+ ].join('\n'),
538
+ }, this.initializeTimeoutMs);
539
+ const threadId = response?.thread?.id;
540
+ if (!threadId) {
541
+ throw new WorkerRuntimeError('agent:launch-failed', 'Codex thread/start 没有返回 thread id', {
542
+ detail: { ticketId, workdir },
543
+ });
544
+ }
545
+ this.logger.info('codex:thread-started', {
546
+ ticketId,
547
+ threadId,
548
+ cwd: workdir,
549
+ sandboxMode,
550
+ approvalPolicy: this.opts.approvalPolicy,
551
+ contract: preview(contract, 80),
552
+ });
553
+ // 让 Codex 桌面端的 thread 列表显示工单号/标题,而不是默认摘要。
554
+ await client.request('thread/name/set', {
555
+ threadId,
556
+ name: `${ticketId} ${title}`.trim(),
557
+ }, this.initializeTimeoutMs).catch(error => {
558
+ this.logger.warn('codex:thread-name-failed', { ticketId, threadId, reason: errorMessage(error) });
559
+ });
560
+ return threadId;
561
+ }
562
+ async startTurn(session, text, inputs) {
563
+ if (session.inFlight) {
564
+ throw new WorkerRuntimeError('agent:not-accepting-input', 'Codex 会话仍在执行上一轮');
565
+ }
566
+ session.inFlight = true;
567
+ session.lastPromptStartedAt = new Date().toISOString();
568
+ session.lastError = undefined;
569
+ session.assistantText = '';
570
+ session.streamedItems.clear();
571
+ try {
572
+ const response = await session.client.request('turn/start', {
573
+ threadId: session.threadId,
574
+ input: [{ type: 'text', text }],
575
+ }, this.initializeTimeoutMs);
576
+ const turnId = response?.turn?.id;
577
+ // turn 可能在响应前就已结束(极快 turn);只在仍处于 inFlight 时记录活动 turn。
578
+ if (session.inFlight)
579
+ session.activeTurnId = turnId;
580
+ this.logger.info('codex:turn-started', {
581
+ ticketId: session.ticketId,
582
+ threadId: session.threadId,
583
+ ...(turnId ? { turnId } : {}),
584
+ inputIds: inputs.map(input => input.id),
585
+ preview: preview(text, 100),
586
+ });
587
+ return turnId;
588
+ }
589
+ catch (error) {
590
+ session.inFlight = false;
591
+ session.activeTurnId = undefined;
592
+ session.lastError = errorMessage(error);
593
+ this.logger.error('codex:turn-start-failed', {
594
+ ticketId: session.ticketId,
595
+ threadId: session.threadId,
596
+ reason: session.lastError,
597
+ });
598
+ throw error;
599
+ }
600
+ }
601
+ registerSession(input) {
602
+ const session = {
603
+ ...input,
604
+ inFlight: false,
605
+ waitingApproval: false,
606
+ assistantText: '',
607
+ streamedItems: new Set(),
608
+ };
609
+ this.sessions.set(session.ticketId, session);
610
+ this.byThreadId.set(session.threadId, session);
611
+ return session;
612
+ }
613
+ dropSession(session) {
614
+ this.sessions.delete(session.ticketId);
615
+ this.byThreadId.delete(session.threadId);
616
+ }
617
+ onNotification(ticketId, method, params) {
618
+ const session = this.sessions.get(ticketId);
619
+ if (!session)
620
+ return;
621
+ const at = new Date().toISOString();
622
+ session.lastEventAt = at;
623
+ const record = params && typeof params === 'object' ? params : {};
624
+ switch (method) {
625
+ case 'thread/status/changed': {
626
+ const status = record.status && typeof record.status === 'object' ? record.status : {};
627
+ const flags = Array.isArray(status.activeFlags) ? status.activeFlags : [];
628
+ session.waitingApproval = flags.includes('waitingOnApproval') || flags.includes('waitingOnUserInput');
629
+ if (status.type === 'idle') {
630
+ session.inFlight = false;
631
+ session.activeTurnId = undefined;
632
+ }
633
+ if (status.type === 'systemError')
634
+ session.lastError = 'Codex thread 进入 systemError';
635
+ return;
636
+ }
637
+ case 'turn/started': {
638
+ const turn = record.turn && typeof record.turn === 'object' ? record.turn : {};
639
+ session.inFlight = true;
640
+ if (typeof turn.id === 'string')
641
+ session.activeTurnId = turn.id;
642
+ return;
643
+ }
644
+ case 'turn/completed': {
645
+ const turn = record.turn && typeof record.turn === 'object' ? record.turn : {};
646
+ session.inFlight = false;
647
+ session.activeTurnId = undefined;
648
+ session.waitingApproval = false;
649
+ session.lastTurnStatus = typeof turn.status === 'string' ? turn.status : 'unknown';
650
+ if (session.lastTurnStatus === 'failed') {
651
+ const error = turn.error && typeof turn.error === 'object' ? turn.error : {};
652
+ session.lastError = typeof error.message === 'string' ? error.message : 'Codex turn failed';
653
+ this.logger.error('codex:turn-failed', {
654
+ ticketId: session.ticketId,
655
+ threadId: session.threadId,
656
+ turnId: typeof turn.id === 'string' ? turn.id : undefined,
657
+ reason: session.lastError,
658
+ });
659
+ }
660
+ else {
661
+ session.lastError = undefined;
662
+ this.logger.info('codex:turn-completed', {
663
+ ticketId: session.ticketId,
664
+ threadId: session.threadId,
665
+ turnId: typeof turn.id === 'string' ? turn.id : undefined,
666
+ status: session.lastTurnStatus,
667
+ durationMs: typeof turn.durationMs === 'number' ? turn.durationMs : undefined,
668
+ });
669
+ }
670
+ this.writeMetaStatus(session.ticketId, {
671
+ status: session.lastTurnStatus === 'failed' ? 'error' : 'running',
672
+ lastError: session.lastError,
673
+ });
674
+ return;
675
+ }
676
+ case 'item/completed': {
677
+ const item = record.item && typeof record.item === 'object' ? record.item : {};
678
+ this.recordItem(session, item, at);
679
+ return;
680
+ }
681
+ case 'item/agentMessage/delta': {
682
+ const delta = typeof record.delta === 'string' ? record.delta : '';
683
+ if (!delta)
684
+ return;
685
+ session.assistantText = `${session.assistantText}${delta}`.slice(-4_000);
686
+ if (typeof record.itemId === 'string')
687
+ session.streamedItems.add(record.itemId);
688
+ this.recordEvent(session, { at, kind: 'text', text: delta.slice(-2_000) });
689
+ return;
690
+ }
691
+ case 'error': {
692
+ const error = record.error && typeof record.error === 'object' ? record.error : {};
693
+ const message = typeof error.message === 'string' ? error.message : 'Codex 返回错误';
694
+ session.lastError = message;
695
+ this.logger.warn('codex:error', { ticketId: session.ticketId, threadId: session.threadId, reason: message });
696
+ return;
697
+ }
698
+ case 'serverRequest/resolved':
699
+ session.waitingApproval = false;
700
+ return;
701
+ default:
702
+ return;
703
+ }
704
+ }
705
+ recordItem(session, item, at) {
706
+ const type = typeof item.type === 'string' ? item.type : '';
707
+ if (type === 'agentMessage') {
708
+ const itemId = typeof item.id === 'string' ? item.id : '';
709
+ // 已经流过 delta 的消息不重复写整段;关闭 streaming 时用 completed 的完整文本兜底。
710
+ if (itemId && session.streamedItems.has(itemId)) {
711
+ session.streamedItems.delete(itemId);
712
+ return;
713
+ }
714
+ const text = typeof item.text === 'string' ? item.text : session.assistantText;
715
+ if (text)
716
+ this.recordEvent(session, { at, kind: 'text', text: text.slice(-8_000) });
717
+ return;
718
+ }
719
+ const tool = toolItemOf(item);
720
+ if (tool)
721
+ this.recordEvent(session, { at, kind: 'tool', text: tool.text, tool: { name: tool.name, ...(tool.target ? { target: tool.target } : {}) } });
722
+ }
723
+ async onRequest(ticketId, method, params) {
724
+ const session = this.sessions.get(ticketId);
725
+ if (!session)
726
+ throw new CodexProtocolError(-32602, '未知的 Codex thread');
727
+ const record = params && typeof params === 'object' ? params : {};
728
+ switch (method) {
729
+ case 'item/commandExecution/requestApproval':
730
+ case 'execCommandApproval': {
731
+ const command = typeof record.command === 'string' ? record.command : null;
732
+ const decision = this.decideCommand(session, command);
733
+ session.waitingApproval = false;
734
+ this.logger.info('codex:approval', {
735
+ ticketId,
736
+ threadId: session.threadId,
737
+ kind: 'command',
738
+ decision,
739
+ command: preview(command ?? '', 120),
740
+ });
741
+ return { decision };
742
+ }
743
+ case 'item/fileChange/requestApproval':
744
+ case 'applyPatchApproval': {
745
+ const decision = session.tools.fileAccess === 'read-write' ? 'accept' : 'decline';
746
+ session.waitingApproval = false;
747
+ this.logger.info('codex:approval', {
748
+ ticketId,
749
+ threadId: session.threadId,
750
+ kind: 'fileChange',
751
+ decision,
752
+ });
753
+ return { decision };
754
+ }
755
+ case 'item/permissions/requestApproval': {
756
+ session.waitingApproval = false;
757
+ this.logger.info('codex:approval', {
758
+ ticketId,
759
+ threadId: session.threadId,
760
+ kind: 'permissions',
761
+ decision: 'decline',
762
+ });
763
+ return { permissions: {} };
764
+ }
765
+ case 'item/tool/requestUserInput':
766
+ session.waitingApproval = false;
767
+ return { answers: {} };
768
+ case 'mcpServer/elicitation/request':
769
+ session.waitingApproval = false;
770
+ return { action: 'decline' };
771
+ case 'account/chatgptAuthTokens/refresh':
772
+ throw new CodexProtocolError(-32601, 'router-agent 不支持托管 ChatGPT token 刷新');
773
+ default:
774
+ throw new CodexProtocolError(-32601, `router-agent 不支持 Codex server request: ${method}`);
775
+ }
776
+ }
777
+ decideCommand(session, command) {
778
+ if (session.approvalPolicy === 'never')
779
+ return 'accept';
780
+ return shellAllowed(session.tools, command) ? 'accept' : 'decline';
781
+ }
782
+ onExit(ticketId, code, signal, stderr) {
783
+ const session = this.sessions.get(ticketId);
784
+ if (!session)
785
+ return;
786
+ session.inFlight = false;
787
+ session.activeTurnId = undefined;
788
+ session.lastError = `Codex app-server 退出(code=${code ?? 'null'}, signal=${signal ?? 'null'})${stderr ? `: ${stderr.slice(-300)}` : ''}`;
789
+ this.logger.warn('codex:process-exit', {
790
+ ticketId,
791
+ threadId: session.threadId,
792
+ code,
793
+ signal,
794
+ reason: preview(stderr, 200),
795
+ });
796
+ this.writeMetaStatus(ticketId, { status: 'exited', lastError: session.lastError });
797
+ }
798
+ async revive(meta) {
799
+ if (!meta?.sessionId || !meta.workdir)
800
+ return null;
801
+ const detection = await this.resolveBinary().catch(() => null);
802
+ if (!detection?.command || !detection.compatible)
803
+ return null;
804
+ const sandboxMode = meta.sandboxMode ?? (this.opts.permissions === 'full' ? 'danger-full-access' : 'workspace-write');
805
+ const client = this.spawnClient(meta.ticketId, meta.workdir, detection, sandboxMode);
806
+ try {
807
+ await this.initialize(client);
808
+ await client.request('thread/resume', {
809
+ threadId: meta.sessionId,
810
+ cwd: meta.workdir,
811
+ excludeTurns: true,
812
+ sandbox: sandboxMode,
813
+ approvalPolicy: this.opts.approvalPolicy,
814
+ ...(this.opts.model ? { model: this.opts.model } : {}),
815
+ ...(this.opts.modelProvider ? { modelProvider: this.opts.modelProvider } : {}),
816
+ }, this.initializeTimeoutMs);
817
+ const session = this.registerSession({
818
+ ticketId: meta.ticketId,
819
+ threadId: meta.sessionId,
820
+ workdir: meta.workdir,
821
+ branch: meta.workBranch,
822
+ tools: meta.tools ?? READ_ONLY_TOOLS,
823
+ client,
824
+ sandboxMode,
825
+ approvalPolicy: this.opts.approvalPolicy,
826
+ });
827
+ session.lastEventAt = new Date().toISOString();
828
+ meta.status = 'running';
829
+ if (client.pid !== undefined)
830
+ meta.pid = client.pid;
831
+ this.writeMeta(meta.ticketId, meta);
832
+ this.logger.info('codex:resumed', { ticketId: meta.ticketId, threadId: meta.sessionId });
833
+ return session;
834
+ }
835
+ catch (error) {
836
+ await client.close().catch(() => { });
837
+ this.logger.warn('codex:resume-failed', {
838
+ ticketId: meta.ticketId,
839
+ threadId: meta.sessionId,
840
+ reason: errorMessage(error),
841
+ });
842
+ return null;
843
+ }
844
+ }
845
+ sandboxModeOf(tools) {
846
+ if (tools.fileAccess === 'read-only')
847
+ return 'read-only';
848
+ return this.opts.permissions === 'full' ? 'danger-full-access' : 'workspace-write';
849
+ }
850
+ recordEvent(session, event) {
851
+ session.lastEventAt = event.at;
852
+ try {
853
+ const dir = join(this.workersDir, session.ticketId);
854
+ mkdirSync(dir, { recursive: true });
855
+ appendFileSync(join(dir, `${CHANNEL}-events.jsonl`), `${JSON.stringify(event)}\n`, 'utf8');
856
+ }
857
+ catch {
858
+ // 活动落盘失败不影响主流程。
859
+ }
860
+ }
861
+ readEvents(ticketId) {
862
+ try {
863
+ const text = readFileSync(join(this.workersDir, ticketId, `${CHANNEL}-events.jsonl`), 'utf8');
864
+ const events = [];
865
+ for (const line of text.split('\n')) {
866
+ if (!line.trim())
867
+ continue;
868
+ try {
869
+ const parsed = JSON.parse(line);
870
+ if (parsed && typeof parsed.at === 'string' && (parsed.kind === 'text' || parsed.kind === 'tool'))
871
+ events.push(parsed);
872
+ }
873
+ catch {
874
+ // 跳过损坏行。
875
+ }
876
+ }
877
+ return events;
878
+ }
879
+ catch {
880
+ return [];
881
+ }
882
+ }
883
+ lastEventAtFromFile(ticketId) {
884
+ try {
885
+ return statSync(join(this.workersDir, ticketId, `${CHANNEL}-events.jsonl`)).mtime.toISOString();
886
+ }
887
+ catch {
888
+ return undefined;
889
+ }
890
+ }
891
+ async rollback(workdir, branch, repo) {
892
+ await this.workspaces.remove(workdir, { repo }).catch(() => { });
893
+ if (this.workspaces.deleteBranch)
894
+ await this.workspaces.deleteBranch(branch, repo).catch(() => { });
895
+ }
896
+ async probeLogin(command) {
897
+ try {
898
+ const res = await realRunner(command).run(['login', 'status'], {
899
+ cwd: this.home,
900
+ env: { ...process.env, ...this.env },
901
+ timeoutMs: 5_000,
902
+ });
903
+ return res.code === 0;
904
+ }
905
+ catch {
906
+ return false;
907
+ }
908
+ }
909
+ async probeAppServer(command) {
910
+ try {
911
+ const res = await realRunner(command).run(['app-server', '--help'], {
912
+ cwd: this.home,
913
+ env: { ...process.env, ...this.env },
914
+ timeoutMs: 5_000,
915
+ });
916
+ return res.code === 0;
917
+ }
918
+ catch {
919
+ return false;
920
+ }
921
+ }
922
+ rememberDelivered(ticketId, meta, inputId, attempt) {
923
+ if (!meta)
924
+ return;
925
+ meta.lastHandedOffInputId = inputId;
926
+ meta.lastHandedOffAttempt = attempt;
927
+ meta.status = 'running';
928
+ this.writeMeta(ticketId, meta);
929
+ }
930
+ launchError(error, workdir, branch) {
931
+ if (error instanceof WorkerRuntimeError) {
932
+ return new WorkerRuntimeError(error.code, error.reason, {
933
+ retryable: error.retryable,
934
+ cause: error.cause,
935
+ detail: { ...error.detail, workdir, branch, command: this.opts.command },
936
+ });
937
+ }
938
+ const message = errorMessage(error);
939
+ this.logger.error('codex:launch-failed', { workdir, branch, reason: message });
940
+ return new WorkerRuntimeError('agent:launch-failed', `Codex App Server 启动失败: ${message}`, {
941
+ cause: error instanceof Error ? error.stack : undefined,
942
+ detail: { workdir, branch, command: this.opts.command },
943
+ });
944
+ }
945
+ metaPath(ticketId) {
946
+ return join(this.workersDir, ticketId, 'meta.json');
947
+ }
948
+ readMeta(ticketId) {
949
+ try {
950
+ return JSON.parse(readFileSync(this.metaPath(ticketId), 'utf8'));
951
+ }
952
+ catch {
953
+ return null;
954
+ }
955
+ }
956
+ writeMeta(ticketId, meta) {
957
+ const path = this.metaPath(ticketId);
958
+ mkdirSync(join(this.workersDir, ticketId), { recursive: true });
959
+ const tmp = `${path}.tmp`;
960
+ writeFileSync(tmp, `${JSON.stringify(meta, null, 2)}\n`, 'utf8');
961
+ renameSync(tmp, path);
962
+ }
963
+ writeMetaStatus(ticketId, patch) {
964
+ const meta = this.readMeta(ticketId);
965
+ if (!meta)
966
+ return;
967
+ this.writeMeta(ticketId, { ...meta, ...patch });
968
+ }
969
+ }
970
+ function tomlString(value) {
971
+ return JSON.stringify(value);
972
+ }
973
+ function toolItemOf(item) {
974
+ const type = typeof item.type === 'string' ? item.type : '';
975
+ switch (type) {
976
+ case 'commandExecution': {
977
+ const command = typeof item.command === 'string' ? item.command : '';
978
+ return { name: 'commandExecution', text: command || 'commandExecution', ...(command ? { target: command } : {}) };
979
+ }
980
+ case 'fileChange': {
981
+ const changes = Array.isArray(item.changes) ? item.changes : [];
982
+ const paths = changes
983
+ .map(change => change && typeof change === 'object' ? change.path : undefined)
984
+ .filter((path) => typeof path === 'string');
985
+ return { name: 'fileChange', text: paths.join(', ') || 'fileChange', ...(paths[0] ? { target: paths[0] } : {}) };
986
+ }
987
+ case 'mcpToolCall': {
988
+ const server = typeof item.server === 'string' ? item.server : '';
989
+ const tool = typeof item.tool === 'string' ? item.tool : '';
990
+ const text = [server, tool].filter(Boolean).join('.') || 'mcpToolCall';
991
+ return { name: text, text };
992
+ }
993
+ case 'dynamicToolCall': {
994
+ const tool = typeof item.tool === 'string' ? item.tool : 'dynamicToolCall';
995
+ return { name: tool, text: tool };
996
+ }
997
+ case 'webSearch': {
998
+ const query = typeof item.query === 'string' ? item.query : '';
999
+ return { name: 'webSearch', text: query || 'webSearch', ...(query ? { target: query } : {}) };
1000
+ }
1001
+ default:
1002
+ return null;
1003
+ }
1004
+ }
1005
+ /**
1006
+ * 把被中断那一轮的 items 压成一句人话,用作恢复提示。
1007
+ *
1008
+ * 目标是让 worker 知道自己「做到哪一步」,从而自己判断该不该重做——
1009
+ * 中断的现场可能是半成品(命令写了一半文件),盲目重跑会留下重复内容。
1010
+ */
1011
+ function summarizeTurnItems(items) {
1012
+ if (!Array.isArray(items) || items.length === 0)
1013
+ return undefined;
1014
+ const parts = [];
1015
+ for (const raw of items) {
1016
+ if (!raw || typeof raw !== 'object')
1017
+ continue;
1018
+ const item = raw;
1019
+ const type = typeof item.type === 'string' ? item.type : '';
1020
+ if (type === 'userMessage') {
1021
+ const text = userMessageText(item);
1022
+ if (text)
1023
+ parts.push(`要求:${text}`);
1024
+ continue;
1025
+ }
1026
+ if (type === 'commandExecution') {
1027
+ const command = typeof item.command === 'string' ? item.command : '';
1028
+ const status = typeof item.status === 'string' ? item.status : '';
1029
+ if (command)
1030
+ parts.push(`执行命令${status ? `(${status})` : ''}:${command}`);
1031
+ continue;
1032
+ }
1033
+ if (type === 'fileChange') {
1034
+ const changes = Array.isArray(item.changes) ? item.changes : [];
1035
+ const paths = changes
1036
+ .map(change => change && typeof change === 'object' ? change.path : undefined)
1037
+ .filter((path) => typeof path === 'string');
1038
+ if (paths.length > 0)
1039
+ parts.push(`修改文件:${paths.join('、')}`);
1040
+ continue;
1041
+ }
1042
+ if (type === 'agentMessage') {
1043
+ const text = typeof item.text === 'string' ? item.text.trim() : '';
1044
+ if (text)
1045
+ parts.push(`助手输出:${text}`);
1046
+ continue;
1047
+ }
1048
+ }
1049
+ if (parts.length === 0)
1050
+ return undefined;
1051
+ return parts.slice(0, 6).join(';');
1052
+ }
1053
+ function userMessageText(item) {
1054
+ const content = Array.isArray(item.content) ? item.content : [];
1055
+ const texts = [];
1056
+ for (const block of content) {
1057
+ if (!block || typeof block !== 'object')
1058
+ continue;
1059
+ const value = block.text;
1060
+ if (typeof value === 'string' && value.trim())
1061
+ texts.push(value.trim());
1062
+ }
1063
+ const joined = texts.join(' ');
1064
+ return joined.length > 200 ? `${joined.slice(0, 200)}…` : joined;
1065
+ }
1066
+ //# sourceMappingURL=index.js.map