feishu-codex-console 1.0.0-beta.6

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 (113) hide show
  1. package/.env.example +101 -0
  2. package/.feishu-codex-policy.example.json +11 -0
  3. package/.feishu-codex-runbooks.example.json +36 -0
  4. package/CHANGELOG.md +129 -0
  5. package/CODE_OF_CONDUCT.md +7 -0
  6. package/CONTRIBUTING.md +52 -0
  7. package/LICENSE +21 -0
  8. package/README.en.md +81 -0
  9. package/README.md +398 -0
  10. package/ROADMAP.md +40 -0
  11. package/SECURITY.md +53 -0
  12. package/dist/account-quota-card.js +233 -0
  13. package/dist/account-quota.js +125 -0
  14. package/dist/app-server-client.js +281 -0
  15. package/dist/card-session.js +166 -0
  16. package/dist/codex-events.js +1 -0
  17. package/dist/codex-runner.js +875 -0
  18. package/dist/config.js +198 -0
  19. package/dist/confirmation-card.js +135 -0
  20. package/dist/control-card.js +345 -0
  21. package/dist/conversation-turn-session.js +209 -0
  22. package/dist/data-maintenance.js +71 -0
  23. package/dist/device-card.js +460 -0
  24. package/dist/device-health.js +94 -0
  25. package/dist/diagnostics.js +253 -0
  26. package/dist/doctor.js +250 -0
  27. package/dist/fallback-card-session.js +37 -0
  28. package/dist/health-file.js +75 -0
  29. package/dist/index.js +4330 -0
  30. package/dist/lark-cli.js +558 -0
  31. package/dist/lark-retry.js +34 -0
  32. package/dist/maintenance.js +140 -0
  33. package/dist/model-capabilities.js +31 -0
  34. package/dist/onboarding-card.js +312 -0
  35. package/dist/permission-lease.js +22 -0
  36. package/dist/policy.js +506 -0
  37. package/dist/progress.js +267 -0
  38. package/dist/project-card.js +303 -0
  39. package/dist/project-overview-card.js +182 -0
  40. package/dist/project-overview.js +278 -0
  41. package/dist/project-policy.js +160 -0
  42. package/dist/project-registry.js +259 -0
  43. package/dist/project-status.js +45 -0
  44. package/dist/project-workspace.js +55 -0
  45. package/dist/quota-card.js +94 -0
  46. package/dist/recovery-policy.js +26 -0
  47. package/dist/redaction.js +67 -0
  48. package/dist/remote-ready.js +112 -0
  49. package/dist/response-card.js +139 -0
  50. package/dist/result-card.js +166 -0
  51. package/dist/review-card.js +452 -0
  52. package/dist/runbook-card.js +272 -0
  53. package/dist/runbooks.js +191 -0
  54. package/dist/runtime-card.js +337 -0
  55. package/dist/session-card.js +128 -0
  56. package/dist/session-naming.js +14 -0
  57. package/dist/smoke.js +28 -0
  58. package/dist/state-backup.js +302 -0
  59. package/dist/state-store.js +874 -0
  60. package/dist/task-card.js +640 -0
  61. package/dist/task-center-card.js +176 -0
  62. package/dist/task-failure.js +43 -0
  63. package/dist/task-intent.js +76 -0
  64. package/dist/task-queue.js +187 -0
  65. package/dist/task-reconciliation.js +80 -0
  66. package/dist/task-review.js +497 -0
  67. package/dist/team-card.js +275 -0
  68. package/dist/team-directory.js +54 -0
  69. package/dist/team-policy.js +93 -0
  70. package/dist/types.js +1 -0
  71. package/dist/version.js +9 -0
  72. package/dist/workspace-session.js +64 -0
  73. package/docs/ARCHITECTURE.md +54 -0
  74. package/docs/COMPATIBILITY.md +55 -0
  75. package/docs/CONFIGURATION.md +88 -0
  76. package/docs/DEMO.md +45 -0
  77. package/docs/GOOD_FIRST_ISSUES.md +23 -0
  78. package/docs/INSTALLATION.md +207 -0
  79. package/docs/OPEN_SOURCE_PRODUCT_PLAN.md +113 -0
  80. package/docs/PRODUCT_REQUIREMENTS_MAP.md +591 -0
  81. package/docs/RELEASE_CHECKLIST.md +65 -0
  82. package/docs/TEAM_DEPLOYMENT.md +35 -0
  83. package/docs/TROUBLESHOOTING.md +130 -0
  84. package/docs/V4_WORKSPACE_SESSION_FLOW.md +232 -0
  85. package/docs/requirements/D10_MAINTENANCE_AND_ECOSYSTEM.md +103 -0
  86. package/docs/requirements/D1_INSTALLATION_AND_FIRST_CONNECTION.md +479 -0
  87. package/docs/requirements/D2_DEVICE_AND_CONNECTIVITY.md +54 -0
  88. package/docs/requirements/D3_PROJECTS_AND_SESSIONS.md +107 -0
  89. package/docs/requirements/D4_REMOTE_TASK_EXECUTION.md +102 -0
  90. package/docs/requirements/D5_CODEX_NATIVE_INTERACTIONS.md +99 -0
  91. package/docs/requirements/D6_RESULTS_AND_CODE_REVIEW.md +100 -0
  92. package/docs/requirements/D7_SECURITY_GOVERNANCE.md +106 -0
  93. package/docs/requirements/D8_RELIABILITY_AND_RECOVERY.md +182 -0
  94. package/docs/requirements/D9_TEAM_COLLABORATION.md +129 -0
  95. package/package.json +76 -0
  96. package/scripts/capability-probe.mjs +113 -0
  97. package/scripts/cli.mjs +919 -0
  98. package/scripts/config-file.mjs +137 -0
  99. package/scripts/discovery-lib.mjs +78 -0
  100. package/scripts/install-card.mjs +37 -0
  101. package/scripts/install-detection.mjs +126 -0
  102. package/scripts/install-state.mjs +107 -0
  103. package/scripts/launchd.mjs +161 -0
  104. package/scripts/migrate-legacy.mjs +97 -0
  105. package/scripts/package-smoke.mjs +163 -0
  106. package/scripts/release-dist-tag.mjs +7 -0
  107. package/scripts/runbook-template.mjs +36 -0
  108. package/scripts/service-health.mjs +110 -0
  109. package/scripts/service.mjs +24 -0
  110. package/scripts/setup-lib.mjs +118 -0
  111. package/scripts/systemd.mjs +96 -0
  112. package/scripts/upgrade-lib.mjs +99 -0
  113. package/scripts/verify-release.mjs +37 -0
@@ -0,0 +1,875 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { AppServerRpcError, CodexAppServerClient, resolveBundledCodexCommand, } from "./app-server-client.js";
4
+ import { normalizeAccountQuotaResponse, } from "./account-quota.js";
5
+ import { isSupportedImageFile } from "./maintenance.js";
6
+ import { commandPolicyDecision, externalActionLabel } from "./policy.js";
7
+ export class CodexCancelledError extends Error {
8
+ reason;
9
+ constructor(message, reason) {
10
+ super(message);
11
+ this.reason = reason;
12
+ this.name = "CodexCancelledError";
13
+ }
14
+ }
15
+ export class CodexPolicyViolationError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "CodexPolicyViolationError";
19
+ }
20
+ }
21
+ const BRIDGE_DEVELOPER_INSTRUCTIONS = [
22
+ "This request comes from a remote Feishu control surface; keep execution proportional to the user's request.",
23
+ "Do not spawn subagents or delegate work unless the user explicitly asks for agents, delegation, or parallel work.",
24
+ "For a simple question or repository inspection, read only the minimum relevant files and stop once there is enough evidence to answer.",
25
+ "Do not exhaustively scan tests, assets, styles, documentation, or the whole repository unless the request requires it.",
26
+ "Do not run tests or builds for a read-only question unless verification is requested or materially needed for accuracy.",
27
+ ].join(" ");
28
+ export class CodexRunner {
29
+ config;
30
+ client;
31
+ activeRuns = new Map();
32
+ constructor(config) {
33
+ this.config = config;
34
+ const featureArgs = config.multiAgentEnabled ? [] : ["--disable", "multi_agent"];
35
+ const command = config.codexCliPath
36
+ ? { command: config.codexCliPath, args: ["app-server", ...featureArgs] }
37
+ : resolveBundledCodexCommand(featureArgs);
38
+ this.client = new CodexAppServerClient({
39
+ env: {
40
+ ...buildCodexEnvironment(config),
41
+ CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "feishu_codex_bridge",
42
+ },
43
+ command,
44
+ onNotification: (notification) => this.handleNotification(notification),
45
+ onRequest: (request) => this.handleServerRequest(request),
46
+ onExit: (error) => this.handleAppServerExit(error),
47
+ });
48
+ }
49
+ async run(conversationKey, taskId, prompt, workingDirectory, isGitRepository, existingThreadId, onEvent, attachments = [], allowedExternalActions = [], handlers = {}, executionSettings = { sandboxMode: this.config.sandboxMode }) {
50
+ if (this.activeRuns.has(conversationKey)) {
51
+ throw new Error("A Codex turn is already running for this conversation");
52
+ }
53
+ const active = {
54
+ taskId,
55
+ conversationKey,
56
+ finalResponse: "",
57
+ usage: null,
58
+ usageBaseline: null,
59
+ lastThreadUsage: null,
60
+ modelCalls: 0,
61
+ agentMessages: new Map(),
62
+ allowedExternalActions,
63
+ sandboxMode: executionSettings.sandboxMode,
64
+ ...(onEvent ? { onEvent } : {}),
65
+ handlers,
66
+ completion: deferred(),
67
+ startedEventSent: false,
68
+ };
69
+ this.activeRuns.set(conversationKey, active);
70
+ const timeout = setTimeout(() => {
71
+ active.reason = "timeout";
72
+ void this.interruptActive(active);
73
+ }, this.config.codexTimeoutMs);
74
+ timeout.unref();
75
+ try {
76
+ const input = await this.buildInput(prompt, attachments, allowedExternalActions);
77
+ const thread = await this.openThread(workingDirectory, isGitRepository, existingThreadId, executionSettings);
78
+ active.threadId = thread.thread.id;
79
+ this.emit(active, { type: "thread.started", thread_id: active.threadId });
80
+ const turn = await this.client.request("turn/start", {
81
+ threadId: active.threadId,
82
+ input,
83
+ cwd: workingDirectory,
84
+ approvalPolicy: this.approvalPolicy(executionSettings.sandboxMode),
85
+ model: executionSettings.model ?? this.config.model ?? null,
86
+ effort: executionSettings.reasoningEffort ?? this.config.reasoningEffort ?? null,
87
+ });
88
+ active.turnId = turn.turn.id;
89
+ if (!active.startedEventSent) {
90
+ active.startedEventSent = true;
91
+ this.emit(active, { type: "turn.started" });
92
+ }
93
+ if (active.reason)
94
+ await this.interruptActive(active);
95
+ return await active.completion.promise;
96
+ }
97
+ catch (error) {
98
+ if (active.policyViolation) {
99
+ throw new CodexPolicyViolationError(`安全闸门已停止命令:${active.policyViolation}`);
100
+ }
101
+ if (active.reason) {
102
+ throw new CodexCancelledError(`Codex task cancelled: ${active.reason}`, active.reason);
103
+ }
104
+ throw error;
105
+ }
106
+ finally {
107
+ clearTimeout(timeout);
108
+ this.activeRuns.delete(conversationKey);
109
+ }
110
+ }
111
+ async steer(conversationKey, text) {
112
+ const active = this.activeRuns.get(conversationKey);
113
+ if (!active?.threadId || !active.turnId || active.completion.settled)
114
+ return false;
115
+ await this.client.request("turn/steer", {
116
+ threadId: active.threadId,
117
+ expectedTurnId: active.turnId,
118
+ input: [{ type: "text", text, text_elements: [] }],
119
+ });
120
+ return true;
121
+ }
122
+ cancel(conversationKey, reason = "manual") {
123
+ const active = this.activeRuns.get(conversationKey);
124
+ if (!active || active.completion.settled)
125
+ return false;
126
+ active.reason = reason;
127
+ void this.interruptActive(active);
128
+ return true;
129
+ }
130
+ cancelAll(reason = "shutdown") {
131
+ const count = this.activeRuns.size;
132
+ for (const active of this.activeRuns.values()) {
133
+ active.reason = reason;
134
+ void this.interruptActive(active);
135
+ }
136
+ return count;
137
+ }
138
+ async close() {
139
+ this.cancelAll("shutdown");
140
+ await this.client.close();
141
+ }
142
+ getActiveTask(conversationKey) {
143
+ return this.activeRuns.get(conversationKey)?.taskId;
144
+ }
145
+ get activeCount() {
146
+ return this.activeRuns.size;
147
+ }
148
+ getHealth() {
149
+ return this.client.getHealth();
150
+ }
151
+ async listModels() {
152
+ const response = await this.client.request("model/list", {
153
+ limit: 100,
154
+ includeHidden: false,
155
+ });
156
+ return response.data.flatMap(normalizeModel);
157
+ }
158
+ async readAccountQuota() {
159
+ const response = await this.client.request("account/rateLimits/read");
160
+ return normalizeAccountQuotaResponse(response);
161
+ }
162
+ async listThreads(workingDirectory, limit = 12) {
163
+ const response = await this.client.request("thread/list", {
164
+ limit: Math.max(1, Math.min(50, limit)),
165
+ sortKey: "updated_at",
166
+ sortDirection: "desc",
167
+ cwd: workingDirectory,
168
+ archived: false,
169
+ });
170
+ return response.data
171
+ .flatMap(normalizeThreadSummary)
172
+ .filter((thread) => threadBelongsToWorkingDirectory(thread, workingDirectory));
173
+ }
174
+ async compactThread(threadId) {
175
+ await this.client.request("thread/compact/start", { threadId }, 60_000);
176
+ }
177
+ async nameThread(threadId, name) {
178
+ await this.client.request("thread/name/set", { threadId, name: name.slice(0, 120) });
179
+ }
180
+ async openThread(workingDirectory, isGitRepository, existingThreadId, executionSettings = { sandboxMode: this.config.sandboxMode }) {
181
+ const params = this.threadParams(workingDirectory, isGitRepository, executionSettings);
182
+ if (existingThreadId) {
183
+ try {
184
+ return await this.client.request("thread/resume", {
185
+ threadId: existingThreadId,
186
+ excludeTurns: true,
187
+ ...params,
188
+ });
189
+ }
190
+ catch (error) {
191
+ console.warn(`[codex] unable to resume thread=${existingThreadId}; starting a new thread`, error);
192
+ }
193
+ }
194
+ return this.client.request("thread/start", params);
195
+ }
196
+ threadParams(workingDirectory, isGitRepository, executionSettings) {
197
+ const config = {
198
+ web_search: this.config.webSearchMode,
199
+ sandbox_workspace_write: { network_access: this.config.networkAccessEnabled },
200
+ };
201
+ const workspaceInstruction = !isGitRepository && this.config.skipGitRepoCheck
202
+ ? "This selected workspace is an authorized non-Git folder. Work in it directly."
203
+ : "";
204
+ return {
205
+ cwd: workingDirectory,
206
+ runtimeWorkspaceRoots: [workingDirectory],
207
+ approvalPolicy: this.approvalPolicy(executionSettings.sandboxMode),
208
+ approvalsReviewer: "user",
209
+ sandbox: executionSettings.sandboxMode,
210
+ model: executionSettings.model ?? this.config.model ?? null,
211
+ developerInstructions: [BRIDGE_DEVELOPER_INSTRUCTIONS, workspaceInstruction]
212
+ .filter(Boolean)
213
+ .join(" "),
214
+ config,
215
+ };
216
+ }
217
+ approvalPolicy(sandboxMode) {
218
+ return sandboxMode === "danger-full-access" ? "untrusted" : "on-request";
219
+ }
220
+ handleNotification(notification) {
221
+ const params = asRecord(notification.params);
222
+ const active = this.findActive(params.threadId, params.turnId);
223
+ if (!active)
224
+ return;
225
+ switch (notification.method) {
226
+ case "turn/started":
227
+ if (params.turn?.id)
228
+ active.turnId = params.turn.id;
229
+ if (!active.startedEventSent) {
230
+ active.startedEventSent = true;
231
+ this.emit(active, { type: "turn.started" });
232
+ }
233
+ return;
234
+ case "thread/tokenUsage/updated":
235
+ this.updateTaskUsage(active, params.tokenUsage);
236
+ return;
237
+ case "item/started":
238
+ case "item/completed": {
239
+ const item = normalizeThreadItem(params.item);
240
+ if (!item)
241
+ return;
242
+ if (item.type === "agent_message") {
243
+ active.agentMessages.set(item.id, item.text);
244
+ if (item.text)
245
+ active.finalResponse = item.text;
246
+ }
247
+ if (item.type === "command_execution" && notification.method === "item/started") {
248
+ const violation = this.commandViolation(item.command, active.allowedExternalActions);
249
+ if (violation) {
250
+ active.policyViolation = violation;
251
+ void this.interruptActive(active);
252
+ }
253
+ }
254
+ this.emit(active, {
255
+ type: notification.method === "item/started" ? "item.started" : "item.completed",
256
+ item,
257
+ });
258
+ return;
259
+ }
260
+ case "item/agentMessage/delta": {
261
+ if (!params.itemId || typeof params.delta !== "string")
262
+ return;
263
+ const text = `${active.agentMessages.get(params.itemId) ?? ""}${params.delta}`;
264
+ active.agentMessages.set(params.itemId, text);
265
+ active.finalResponse = text;
266
+ this.emit(active, {
267
+ type: "item.updated",
268
+ item: { id: params.itemId, type: "agent_message", text },
269
+ });
270
+ return;
271
+ }
272
+ case "turn/plan/updated": {
273
+ const items = (params.plan ?? []).map((step) => ({
274
+ text: step.step ?? "计划步骤",
275
+ completed: step.status === "completed",
276
+ }));
277
+ this.emit(active, {
278
+ type: "item.updated",
279
+ item: { id: `plan-${params.turnId ?? active.turnId ?? "active"}`, type: "todo_list", items },
280
+ });
281
+ return;
282
+ }
283
+ case "error": {
284
+ const message = params.error?.message ?? "Codex app-server error";
285
+ this.emit(active, { type: "error", message });
286
+ if (!params.willRetry)
287
+ active.completion.reject(new Error(message));
288
+ return;
289
+ }
290
+ case "turn/completed":
291
+ this.finishTurn(active, params);
292
+ return;
293
+ default:
294
+ return;
295
+ }
296
+ }
297
+ updateTaskUsage(active, tokenUsage) {
298
+ const total = normalizeUsage(tokenUsage?.total);
299
+ const last = normalizeUsage(tokenUsage?.last);
300
+ if (!total && !last)
301
+ return;
302
+ // App-server's `total` is cumulative for the whole thread, while `last`
303
+ // covers only the most recent model response. Derive the thread baseline
304
+ // from the first event, then report the delta so a resumed thread does not
305
+ // charge old turns to the current Feishu task.
306
+ if (total && last) {
307
+ if (active.lastThreadUsage && sameUsage(total, active.lastThreadUsage))
308
+ return;
309
+ const derived = deriveTaskUsage(total, last, active.usageBaseline, active.modelCalls + 1, tokenUsage?.modelContextWindow);
310
+ active.usageBaseline = derived.baseline;
311
+ active.lastThreadUsage = total;
312
+ active.modelCalls += 1;
313
+ active.usage = derived.usage;
314
+ return;
315
+ }
316
+ // Compatibility fallback for older app-server builds that only emit one
317
+ // side of the breakdown. It is less precise, but never drops usage data.
318
+ active.modelCalls += 1;
319
+ const value = total ?? last;
320
+ active.usage = {
321
+ ...value,
322
+ model_calls: active.modelCalls,
323
+ last_input_tokens: last?.input_tokens ?? value.input_tokens,
324
+ last_cached_input_tokens: last?.cached_input_tokens ?? value.cached_input_tokens,
325
+ ...(typeof tokenUsage?.modelContextWindow === "number"
326
+ ? { model_context_window: tokenUsage.modelContextWindow }
327
+ : {}),
328
+ };
329
+ }
330
+ finishTurn(active, params) {
331
+ const status = params.turn?.status ?? "completed";
332
+ if (active.policyViolation) {
333
+ active.completion.reject(new CodexPolicyViolationError(`安全闸门已停止命令:${active.policyViolation}`));
334
+ return;
335
+ }
336
+ if (active.reason || status === "interrupted") {
337
+ const reason = active.reason ?? "manual";
338
+ active.completion.reject(new CodexCancelledError(`Codex task cancelled: ${reason}`, reason));
339
+ return;
340
+ }
341
+ if (status === "failed") {
342
+ const message = params.turn?.error?.message ?? "Codex turn failed";
343
+ this.emit(active, { type: "turn.failed", error: { message } });
344
+ active.completion.reject(new Error(message));
345
+ return;
346
+ }
347
+ this.emit(active, { type: "turn.completed", usage: active.usage });
348
+ active.completion.resolve({
349
+ finalResponse: active.finalResponse,
350
+ threadId: active.threadId ?? "",
351
+ usage: active.usage,
352
+ });
353
+ }
354
+ async handleServerRequest(request) {
355
+ const params = asRecord(request.params);
356
+ const threadId = stringValue(params.threadId) ?? stringValue(params.conversationId);
357
+ const turnId = stringValue(params.turnId);
358
+ const active = this.findActive(threadId, turnId);
359
+ if (!active)
360
+ throw new AppServerRpcError("No active Feishu task for request", -32001);
361
+ switch (request.method) {
362
+ case "item/commandExecution/requestApproval":
363
+ return this.handleCommandApproval(request, params, active, false);
364
+ case "execCommandApproval":
365
+ return this.handleCommandApproval(request, params, active, true);
366
+ case "item/fileChange/requestApproval":
367
+ case "applyPatchApproval":
368
+ return this.handleFileApproval(request, params, active, request.method === "applyPatchApproval");
369
+ case "item/tool/requestUserInput":
370
+ return this.handleUserInputRequest(request, params, active);
371
+ case "item/permissions/requestApproval":
372
+ return this.handlePermissionsApproval(request, params, active);
373
+ case "mcpServer/elicitation/request":
374
+ return { action: "decline", content: null, _meta: null };
375
+ default:
376
+ throw new AppServerRpcError(`Unsupported Codex request: ${request.method}`, -32601);
377
+ }
378
+ }
379
+ async handleCommandApproval(request, params, active, legacy) {
380
+ const rawCommand = params.command;
381
+ const command = Array.isArray(rawCommand)
382
+ ? rawCommand.map(String).join(" ")
383
+ : stringValue(rawCommand) ?? "未知命令";
384
+ const violation = this.commandViolation(command, active.allowedExternalActions);
385
+ if (violation) {
386
+ active.policyViolation = violation;
387
+ return { decision: legacy ? "denied" : "decline" };
388
+ }
389
+ if (active.sandboxMode === "danger-full-access") {
390
+ return { decision: legacy ? "approved" : "accept" };
391
+ }
392
+ const reason = stringValue(params.reason);
393
+ const cwd = stringValue(params.cwd);
394
+ const approval = {
395
+ id: String(request.id),
396
+ kind: "command",
397
+ threadId: active.threadId ?? "",
398
+ turnId: active.turnId ?? "",
399
+ itemId: stringValue(params.itemId) ?? stringValue(params.callId) ?? String(request.id),
400
+ title: "Codex 请求执行命令",
401
+ detail: command,
402
+ command,
403
+ ...(cwd ? { cwd } : {}),
404
+ ...(reason ? { reason } : {}),
405
+ };
406
+ const decision = await this.requestApproval(active, approval);
407
+ return { decision: legacy ? legacyDecision(decision) : decision };
408
+ }
409
+ async handleFileApproval(request, params, active, legacy) {
410
+ const reason = stringValue(params.reason);
411
+ const paths = Object.keys(asRecord(params.fileChanges));
412
+ const approval = {
413
+ id: String(request.id),
414
+ kind: "file_change",
415
+ threadId: active.threadId ?? "",
416
+ turnId: active.turnId ?? "",
417
+ itemId: stringValue(params.itemId) ?? stringValue(params.callId) ?? String(request.id),
418
+ title: "Codex 请求扩大文件写入范围",
419
+ detail: paths.length > 0 ? paths.slice(0, 12).join("\n") : reason ?? "写入当前沙箱之外的文件",
420
+ ...(reason ? { reason } : {}),
421
+ };
422
+ const decision = await this.requestApproval(active, approval);
423
+ return { decision: legacy ? legacyDecision(decision) : decision };
424
+ }
425
+ async handlePermissionsApproval(request, params, active) {
426
+ const requestedPermissions = asRecord(params.permissions);
427
+ const reason = stringValue(params.reason);
428
+ const cwd = stringValue(params.cwd);
429
+ const approval = {
430
+ id: String(request.id),
431
+ kind: "permissions",
432
+ threadId: active.threadId ?? "",
433
+ turnId: active.turnId ?? "",
434
+ itemId: stringValue(params.itemId) ?? String(request.id),
435
+ title: "Codex 请求临时权限",
436
+ detail: reason ?? JSON.stringify(requestedPermissions, null, 2),
437
+ ...(cwd ? { cwd } : {}),
438
+ ...(reason ? { reason } : {}),
439
+ requestedPermissions,
440
+ };
441
+ const decision = await this.requestApproval(active, approval);
442
+ return {
443
+ permissions: decision === "decline" ? {} : requestedPermissions,
444
+ scope: decision === "acceptForSession" ? "session" : "turn",
445
+ };
446
+ }
447
+ async handleUserInputRequest(request, params, active) {
448
+ const questions = Array.isArray(params.questions)
449
+ ? params.questions.map(normalizeQuestion).filter((value) => value !== null)
450
+ : [];
451
+ const questionRequest = {
452
+ id: String(request.id),
453
+ threadId: active.threadId ?? "",
454
+ turnId: active.turnId ?? "",
455
+ itemId: stringValue(params.itemId) ?? String(request.id),
456
+ questions,
457
+ autoResolutionMs: typeof params.autoResolutionMs === "number" ? params.autoResolutionMs : null,
458
+ };
459
+ const answers = active.handlers.requestUserInput
460
+ ? await active.handlers.requestUserInput(questionRequest)
461
+ : Object.fromEntries(questions.map((question) => [question.id, []]));
462
+ return {
463
+ answers: Object.fromEntries(questions.map((question) => [question.id, { answers: answers[question.id] ?? [] }])),
464
+ };
465
+ }
466
+ async requestApproval(active, request) {
467
+ if (!active.handlers.requestApproval)
468
+ return "decline";
469
+ return active.handlers.requestApproval(request);
470
+ }
471
+ commandViolation(command, allowedExternalActions) {
472
+ const decision = commandPolicyDecision(command);
473
+ const missingActions = decision.requiredActions.filter((action) => !allowedExternalActions.includes(action));
474
+ return (decision.blockedReason ??
475
+ (missingActions.length > 0
476
+ ? `未在飞书确认卡中授权:${missingActions.map(externalActionLabel).join("、")}`
477
+ : undefined));
478
+ }
479
+ async interruptActive(active) {
480
+ if (!active.threadId || !active.turnId || active.completion.settled)
481
+ return;
482
+ try {
483
+ await this.client.request("turn/interrupt", { threadId: active.threadId, turnId: active.turnId }, 10_000);
484
+ }
485
+ catch (error) {
486
+ if (!active.completion.settled) {
487
+ if (active.policyViolation) {
488
+ active.completion.reject(new CodexPolicyViolationError(`安全闸门已停止命令:${active.policyViolation}`));
489
+ }
490
+ else if (active.reason) {
491
+ active.completion.reject(new CodexCancelledError(`Codex task cancelled: ${active.reason}`, active.reason));
492
+ }
493
+ else {
494
+ active.completion.reject(error instanceof Error ? error : new Error(String(error)));
495
+ }
496
+ }
497
+ }
498
+ }
499
+ handleAppServerExit(error) {
500
+ for (const active of this.activeRuns.values()) {
501
+ if (!active.completion.settled)
502
+ active.completion.reject(error);
503
+ }
504
+ }
505
+ findActive(threadId, turnId) {
506
+ for (const active of this.activeRuns.values()) {
507
+ if (threadId && active.threadId && active.threadId !== threadId)
508
+ continue;
509
+ if (turnId && active.turnId && active.turnId !== turnId)
510
+ continue;
511
+ if (threadId && !active.threadId)
512
+ continue;
513
+ return active;
514
+ }
515
+ return undefined;
516
+ }
517
+ emit(active, event) {
518
+ try {
519
+ active.onEvent?.(event);
520
+ }
521
+ catch (error) {
522
+ console.error(`[codex] progress handler failed task=${active.taskId}`, error);
523
+ }
524
+ }
525
+ async buildInput(userPrompt, attachments, allowedExternalActions) {
526
+ const textAttachments = await Promise.all(attachments
527
+ .filter((attachment) => attachment.kind === "text")
528
+ .map(async (attachment) => ({
529
+ attachment,
530
+ text: await readTextAttachment(attachment, this.config),
531
+ })));
532
+ const imageAttachments = attachments.filter((attachment) => attachment.kind === "image");
533
+ await Promise.all(imageAttachments.map((attachment) => validateImageAttachment(attachment, this.config)));
534
+ const actionPolicy = allowedExternalActions.length > 0
535
+ ? `The user explicitly confirmed these external actions: ${allowedExternalActions.join(", ")}. You may perform only those confirmed actions when required by the request.`
536
+ : "Do not commit, push, deploy, publish, release, create a pull request, or merge a pull request.";
537
+ const text = [
538
+ "This request came from an authorized Feishu user through a restricted bridge.",
539
+ "Operate only inside the selected project's configured working directory.",
540
+ "You may inspect and edit workspace files and run relevant checks.",
541
+ actionPolicy,
542
+ "Never change credentials, contact people, or delete external data.",
543
+ "For chat readability, lead with the answer, use short paragraphs and at most five bullets; avoid tables, repeated headings, and execution metadata unless asked.",
544
+ "When a choice is required, use request_user_input so the Feishu user can answer remotely.",
545
+ "",
546
+ "User request:",
547
+ userPrompt,
548
+ ...textAttachments.flatMap(({ attachment, text: attachmentText }) => [
549
+ "",
550
+ `Attached text file: ${attachment.name}`,
551
+ "```text",
552
+ attachmentText,
553
+ "```",
554
+ ]),
555
+ ].join("\n");
556
+ return [
557
+ { type: "text", text, text_elements: [] },
558
+ ...imageAttachments.map((attachment) => ({
559
+ type: "localImage",
560
+ path: attachment.path,
561
+ })),
562
+ ];
563
+ }
564
+ }
565
+ const SAFE_CODEX_ENV_NAMES = new Set([
566
+ "PATH",
567
+ "HOME",
568
+ "USER",
569
+ "LOGNAME",
570
+ "SHELL",
571
+ "TMPDIR",
572
+ "LANG",
573
+ "LC_ALL",
574
+ "LC_CTYPE",
575
+ "TERM",
576
+ "COLORTERM",
577
+ "SSH_AUTH_SOCK",
578
+ "GPG_TTY",
579
+ "CODEX_HOME",
580
+ "XDG_CONFIG_HOME",
581
+ "XDG_CACHE_HOME",
582
+ "XDG_DATA_HOME",
583
+ "XDG_RUNTIME_DIR",
584
+ "NVM_BIN",
585
+ "NVM_DIR",
586
+ "NVM_INC",
587
+ "PNPM_HOME",
588
+ "VOLTA_HOME",
589
+ "HOMEBREW_PREFIX",
590
+ "HOMEBREW_CELLAR",
591
+ "HOMEBREW_REPOSITORY",
592
+ "GIT_SSH",
593
+ "GIT_SSH_COMMAND",
594
+ ]);
595
+ export function buildCodexEnvironment(config, source = process.env) {
596
+ const allowed = new Set([...SAFE_CODEX_ENV_NAMES, ...config.codexAllowedEnvVars]);
597
+ return Object.fromEntries(Object.entries(source).filter((entry) => allowed.has(entry[0]) && entry[1] !== undefined));
598
+ }
599
+ export function normalizeThreadItem(value) {
600
+ const item = asRecord(value);
601
+ const id = stringValue(item.id) ?? "unknown";
602
+ switch (item.type) {
603
+ case "agentMessage":
604
+ return { id, type: "agent_message", text: stringValue(item.text) ?? "" };
605
+ case "reasoning":
606
+ return { id, type: "reasoning" };
607
+ case "commandExecution":
608
+ return {
609
+ id,
610
+ type: "command_execution",
611
+ command: stringValue(item.command) ?? "",
612
+ aggregated_output: stringValue(item.aggregatedOutput) ?? "",
613
+ ...(typeof item.exitCode === "number" ? { exit_code: item.exitCode } : {}),
614
+ status: normalizeStatus(item.status),
615
+ };
616
+ case "fileChange":
617
+ return {
618
+ id,
619
+ type: "file_change",
620
+ changes: Array.isArray(item.changes)
621
+ ? item.changes.map((change) => {
622
+ const record = asRecord(change);
623
+ return {
624
+ path: stringValue(record.path) ?? "unknown",
625
+ kind: normalizeChangeKind(record.kind),
626
+ };
627
+ })
628
+ : [],
629
+ status: normalizeStatus(item.status),
630
+ };
631
+ case "mcpToolCall":
632
+ return {
633
+ id,
634
+ type: "mcp_tool_call",
635
+ server: stringValue(item.server) ?? "mcp",
636
+ tool: stringValue(item.tool) ?? "unknown",
637
+ status: normalizeStatus(item.status),
638
+ };
639
+ case "dynamicToolCall":
640
+ return {
641
+ id,
642
+ type: "mcp_tool_call",
643
+ server: stringValue(item.namespace) ?? "codex",
644
+ tool: stringValue(item.tool) ?? "unknown",
645
+ status: normalizeStatus(item.status),
646
+ };
647
+ case "collabAgentToolCall":
648
+ return {
649
+ id,
650
+ type: "mcp_tool_call",
651
+ server: "codex",
652
+ tool: `agent/${stringValue(item.tool) ?? "collaboration"}`,
653
+ status: normalizeStatus(item.status),
654
+ };
655
+ case "webSearch":
656
+ return { id, type: "web_search", query: stringValue(item.query) ?? "" };
657
+ default:
658
+ return null;
659
+ }
660
+ }
661
+ export function normalizeModel(value) {
662
+ const model = asRecord(value);
663
+ const id = stringValue(model.id);
664
+ const slug = stringValue(model.model) ?? id;
665
+ if (!id || !slug || model.hidden === true)
666
+ return [];
667
+ const supported = Array.isArray(model.supportedReasoningEfforts)
668
+ ? model.supportedReasoningEfforts.flatMap((option) => {
669
+ const effort = stringValue(asRecord(option).reasoningEffort);
670
+ return effort && isReasoningEffort(effort) ? [effort] : [];
671
+ })
672
+ : [];
673
+ const defaultEffortValue = stringValue(model.defaultReasoningEffort);
674
+ const defaultReasoningEffort = defaultEffortValue && isReasoningEffort(defaultEffortValue)
675
+ ? defaultEffortValue
676
+ : supported[0] ?? "medium";
677
+ return [
678
+ {
679
+ id,
680
+ model: slug,
681
+ displayName: stringValue(model.displayName) ?? slug,
682
+ description: stringValue(model.description) ?? "",
683
+ isDefault: model.isDefault === true,
684
+ supportedReasoningEfforts: supported,
685
+ defaultReasoningEffort,
686
+ },
687
+ ];
688
+ }
689
+ export function normalizeThreadSummary(value) {
690
+ const thread = asRecord(value);
691
+ const id = stringValue(thread.id);
692
+ const cwd = stringValue(thread.cwd);
693
+ if (!id || !cwd || stringValue(thread.parentThreadId))
694
+ return [];
695
+ const rawStatus = stringValue(asRecord(thread.status).type);
696
+ const status = rawStatus === "active"
697
+ ? "active"
698
+ : rawStatus === "idle"
699
+ ? "idle"
700
+ : rawStatus === "systemError"
701
+ ? "error"
702
+ : "not_loaded";
703
+ return [
704
+ {
705
+ id,
706
+ name: typeof thread.name === "string" && thread.name.trim() ? thread.name.trim() : null,
707
+ preview: stringValue(thread.preview) ?? "",
708
+ cwd,
709
+ createdAt: typeof thread.createdAt === "number" ? thread.createdAt : 0,
710
+ updatedAt: typeof thread.updatedAt === "number" ? thread.updatedAt : 0,
711
+ status,
712
+ },
713
+ ];
714
+ }
715
+ export function threadBelongsToWorkingDirectory(thread, workingDirectory) {
716
+ return Boolean(thread.cwd) && path.resolve(thread.cwd) === path.resolve(workingDirectory);
717
+ }
718
+ function isReasoningEffort(value) {
719
+ return ["minimal", "low", "medium", "high", "xhigh", "ultra"].includes(value);
720
+ }
721
+ function normalizeQuestion(value) {
722
+ const question = asRecord(value);
723
+ const id = stringValue(question.id);
724
+ const text = stringValue(question.question);
725
+ if (!id || !text)
726
+ return null;
727
+ return {
728
+ id,
729
+ header: stringValue(question.header) ?? "需要你的输入",
730
+ question: text,
731
+ isOther: question.isOther === true,
732
+ isSecret: question.isSecret === true,
733
+ options: Array.isArray(question.options)
734
+ ? question.options.map((option) => {
735
+ const record = asRecord(option);
736
+ return {
737
+ label: stringValue(record.label) ?? "选项",
738
+ description: stringValue(record.description) ?? "",
739
+ };
740
+ })
741
+ : [],
742
+ };
743
+ }
744
+ function normalizeUsage(value) {
745
+ if (!value)
746
+ return null;
747
+ return {
748
+ input_tokens: value.inputTokens ?? 0,
749
+ cached_input_tokens: value.cachedInputTokens ?? 0,
750
+ output_tokens: value.outputTokens ?? 0,
751
+ reasoning_output_tokens: value.reasoningOutputTokens ?? 0,
752
+ };
753
+ }
754
+ export function deriveTaskUsage(threadTotal, lastCall, existingBaseline, modelCalls, modelContextWindow) {
755
+ const baseline = existingBaseline ?? subtractUsage(threadTotal, lastCall);
756
+ return {
757
+ baseline,
758
+ usage: {
759
+ ...subtractUsage(threadTotal, baseline),
760
+ model_calls: modelCalls,
761
+ last_input_tokens: lastCall.input_tokens,
762
+ last_cached_input_tokens: lastCall.cached_input_tokens,
763
+ ...(typeof modelContextWindow === "number"
764
+ ? { model_context_window: modelContextWindow }
765
+ : {}),
766
+ },
767
+ };
768
+ }
769
+ function subtractUsage(total, baseline) {
770
+ return {
771
+ input_tokens: Math.max(0, total.input_tokens - baseline.input_tokens),
772
+ cached_input_tokens: Math.max(0, total.cached_input_tokens - baseline.cached_input_tokens),
773
+ output_tokens: Math.max(0, total.output_tokens - baseline.output_tokens),
774
+ reasoning_output_tokens: Math.max(0, total.reasoning_output_tokens - baseline.reasoning_output_tokens),
775
+ };
776
+ }
777
+ function sameUsage(left, right) {
778
+ return (left.input_tokens === right.input_tokens &&
779
+ left.cached_input_tokens === right.cached_input_tokens &&
780
+ left.output_tokens === right.output_tokens &&
781
+ left.reasoning_output_tokens === right.reasoning_output_tokens);
782
+ }
783
+ function normalizeStatus(value) {
784
+ if (value === "completed")
785
+ return "completed";
786
+ if (value === "failed")
787
+ return "failed";
788
+ if (value === "declined")
789
+ return "declined";
790
+ return "in_progress";
791
+ }
792
+ function normalizeChangeKind(value) {
793
+ const kind = typeof value === "string" ? value : asRecord(value).type;
794
+ if (kind === "add" || kind === "delete")
795
+ return kind;
796
+ return "update";
797
+ }
798
+ function legacyDecision(decision) {
799
+ if (decision === "accept")
800
+ return "approved";
801
+ if (decision === "acceptForSession")
802
+ return "approved_for_session";
803
+ return "denied";
804
+ }
805
+ function deferred() {
806
+ let resolvePromise;
807
+ let rejectPromise;
808
+ const result = {
809
+ promise: Promise.resolve(undefined),
810
+ resolve: () => undefined,
811
+ reject: () => undefined,
812
+ settled: false,
813
+ };
814
+ result.promise = new Promise((resolve, reject) => {
815
+ resolvePromise = resolve;
816
+ rejectPromise = reject;
817
+ });
818
+ result.resolve = (value) => {
819
+ if (result.settled)
820
+ return;
821
+ result.settled = true;
822
+ resolvePromise(value);
823
+ };
824
+ result.reject = (error) => {
825
+ if (result.settled)
826
+ return;
827
+ result.settled = true;
828
+ rejectPromise(error);
829
+ };
830
+ return result;
831
+ }
832
+ function asRecord(value) {
833
+ return typeof value === "object" && value !== null && !Array.isArray(value)
834
+ ? value
835
+ : {};
836
+ }
837
+ function stringValue(value) {
838
+ return typeof value === "string" && value ? value : undefined;
839
+ }
840
+ async function readTextAttachment(attachment, config) {
841
+ let details;
842
+ try {
843
+ details = await stat(attachment.path);
844
+ }
845
+ catch {
846
+ throw new Error(`附件 ${attachment.name} 已过期或已被清理,请重新发送。`);
847
+ }
848
+ if (!details.isFile() || details.size > config.maxTextAttachmentBytes) {
849
+ throw new Error(`附件 ${attachment.name} 不再符合安全读取限制,请重新发送。`);
850
+ }
851
+ const bytes = await readFile(attachment.path);
852
+ try {
853
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
854
+ if (text.includes("\u0000"))
855
+ throw new Error("binary text attachment");
856
+ return text;
857
+ }
858
+ catch {
859
+ throw new Error(`附件 ${attachment.name} 不是有效的 UTF-8 文本。`);
860
+ }
861
+ }
862
+ async function validateImageAttachment(attachment, config) {
863
+ let details;
864
+ try {
865
+ details = await stat(attachment.path);
866
+ }
867
+ catch {
868
+ throw new Error(`附件 ${attachment.name} 已过期或已被清理,请重新发送。`);
869
+ }
870
+ if (!details.isFile() ||
871
+ details.size > config.maxAttachmentBytes ||
872
+ !(await isSupportedImageFile(attachment.path))) {
873
+ throw new Error(`附件 ${attachment.name} 不再符合安全读取限制,请重新发送。`);
874
+ }
875
+ }