@very_aq/codex-cli-web 0.0.1

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 (204) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +228 -0
  3. package/README.zh-CN.md +228 -0
  4. package/package.json +38 -0
  5. package/server/dist/admin/userAdminRoutes.d.ts +7 -0
  6. package/server/dist/admin/userAdminRoutes.js +91 -0
  7. package/server/dist/admin/userAdminRoutes.js.map +1 -0
  8. package/server/dist/app.d.ts +22 -0
  9. package/server/dist/app.js +993 -0
  10. package/server/dist/app.js.map +1 -0
  11. package/server/dist/auth/adminInit.d.ts +41 -0
  12. package/server/dist/auth/adminInit.js +126 -0
  13. package/server/dist/auth/adminInit.js.map +1 -0
  14. package/server/dist/auth/bootstrapAdmin.d.ts +6 -0
  15. package/server/dist/auth/bootstrapAdmin.js +11 -0
  16. package/server/dist/auth/bootstrapAdmin.js.map +1 -0
  17. package/server/dist/auth/httpAuth.d.ts +16 -0
  18. package/server/dist/auth/httpAuth.js +31 -0
  19. package/server/dist/auth/httpAuth.js.map +1 -0
  20. package/server/dist/auth/password.d.ts +2 -0
  21. package/server/dist/auth/password.js +68 -0
  22. package/server/dist/auth/password.js.map +1 -0
  23. package/server/dist/auth/requireAdmin.d.ts +2 -0
  24. package/server/dist/auth/requireAdmin.js +14 -0
  25. package/server/dist/auth/requireAdmin.js.map +1 -0
  26. package/server/dist/auth/roles.d.ts +3 -0
  27. package/server/dist/auth/roles.js +13 -0
  28. package/server/dist/auth/roles.js.map +1 -0
  29. package/server/dist/auth/session.d.ts +24 -0
  30. package/server/dist/auth/session.js +127 -0
  31. package/server/dist/auth/session.js.map +1 -0
  32. package/server/dist/auth/sqlite/authDb.d.ts +18 -0
  33. package/server/dist/auth/sqlite/authDb.js +26 -0
  34. package/server/dist/auth/sqlite/authDb.js.map +1 -0
  35. package/server/dist/auth/sqlite/legacyImport.d.ts +22 -0
  36. package/server/dist/auth/sqlite/legacyImport.js +208 -0
  37. package/server/dist/auth/sqlite/legacyImport.js.map +1 -0
  38. package/server/dist/auth/sqlite/schema.d.ts +11 -0
  39. package/server/dist/auth/sqlite/schema.js +47 -0
  40. package/server/dist/auth/sqlite/schema.js.map +1 -0
  41. package/server/dist/auth/sqlite/sqliteUserStore.d.ts +12 -0
  42. package/server/dist/auth/sqlite/sqliteUserStore.js +194 -0
  43. package/server/dist/auth/sqlite/sqliteUserStore.js.map +1 -0
  44. package/server/dist/auth/userStore.d.ts +19 -0
  45. package/server/dist/auth/userStore.js +26 -0
  46. package/server/dist/auth/userStore.js.map +1 -0
  47. package/server/dist/auth/userTypes.d.ts +13 -0
  48. package/server/dist/auth/userTypes.js +3 -0
  49. package/server/dist/auth/userTypes.js.map +1 -0
  50. package/server/dist/chat/attachmentPathRedaction.d.ts +5 -0
  51. package/server/dist/chat/attachmentPathRedaction.js +67 -0
  52. package/server/dist/chat/attachmentPathRedaction.js.map +1 -0
  53. package/server/dist/chat/chatItemEnricher.d.ts +5 -0
  54. package/server/dist/chat/chatItemEnricher.js +40 -0
  55. package/server/dist/chat/chatItemEnricher.js.map +1 -0
  56. package/server/dist/chat/codexEventProjector.d.ts +33 -0
  57. package/server/dist/chat/codexEventProjector.js +482 -0
  58. package/server/dist/chat/codexEventProjector.js.map +1 -0
  59. package/server/dist/chat/contextUsageProjector.d.ts +10 -0
  60. package/server/dist/chat/contextUsageProjector.js +472 -0
  61. package/server/dist/chat/contextUsageProjector.js.map +1 -0
  62. package/server/dist/chat/fileChangeExtractor.d.ts +5 -0
  63. package/server/dist/chat/fileChangeExtractor.js +121 -0
  64. package/server/dist/chat/fileChangeExtractor.js.map +1 -0
  65. package/server/dist/chat/markdown/markdownAst.d.ts +5 -0
  66. package/server/dist/chat/markdown/markdownAst.js +154 -0
  67. package/server/dist/chat/markdown/markdownAst.js.map +1 -0
  68. package/server/dist/chat/systemToolCallSummary.d.ts +10 -0
  69. package/server/dist/chat/systemToolCallSummary.js +112 -0
  70. package/server/dist/chat/systemToolCallSummary.js.map +1 -0
  71. package/server/dist/chat/terminal/terminalPlainText.d.ts +14 -0
  72. package/server/dist/chat/terminal/terminalPlainText.js +139 -0
  73. package/server/dist/chat/terminal/terminalPlainText.js.map +1 -0
  74. package/server/dist/chat/textMetrics.d.ts +9 -0
  75. package/server/dist/chat/textMetrics.js +24 -0
  76. package/server/dist/chat/textMetrics.js.map +1 -0
  77. package/server/dist/chat/threadTurnsProjector.d.ts +12 -0
  78. package/server/dist/chat/threadTurnsProjector.js +292 -0
  79. package/server/dist/chat/threadTurnsProjector.js.map +1 -0
  80. package/server/dist/chat/todoPlanProjector.d.ts +8 -0
  81. package/server/dist/chat/todoPlanProjector.js +94 -0
  82. package/server/dist/chat/todoPlanProjector.js.map +1 -0
  83. package/server/dist/chat/todoPlanTypes.d.ts +21 -0
  84. package/server/dist/chat/todoPlanTypes.js +3 -0
  85. package/server/dist/chat/todoPlanTypes.js.map +1 -0
  86. package/server/dist/chat/types.d.ts +138 -0
  87. package/server/dist/chat/types.js +3 -0
  88. package/server/dist/chat/types.js.map +1 -0
  89. package/server/dist/cli/configArg.d.ts +21 -0
  90. package/server/dist/cli/configArg.js +70 -0
  91. package/server/dist/cli/configArg.js.map +1 -0
  92. package/server/dist/codex/appServerProcess.d.ts +24 -0
  93. package/server/dist/codex/appServerProcess.js +56 -0
  94. package/server/dist/codex/appServerProcess.js.map +1 -0
  95. package/server/dist/codex/cliArgs.d.ts +17 -0
  96. package/server/dist/codex/cliArgs.js +34 -0
  97. package/server/dist/codex/cliArgs.js.map +1 -0
  98. package/server/dist/codex/codexAppServer.d.ts +103 -0
  99. package/server/dist/codex/codexAppServer.js +206 -0
  100. package/server/dist/codex/codexAppServer.js.map +1 -0
  101. package/server/dist/codex/jsonl.d.ts +4 -0
  102. package/server/dist/codex/jsonl.js +23 -0
  103. package/server/dist/codex/jsonl.js.map +1 -0
  104. package/server/dist/codex/jsonrpc.d.ts +43 -0
  105. package/server/dist/codex/jsonrpc.js +96 -0
  106. package/server/dist/codex/jsonrpc.js.map +1 -0
  107. package/server/dist/config/serverConfig.d.ts +150 -0
  108. package/server/dist/config/serverConfig.js +64 -0
  109. package/server/dist/config/serverConfig.js.map +1 -0
  110. package/server/dist/env.d.ts +101 -0
  111. package/server/dist/env.js +523 -0
  112. package/server/dist/env.js.map +1 -0
  113. package/server/dist/history/http/historyRoutes.d.ts +18 -0
  114. package/server/dist/history/http/historyRoutes.js +67 -0
  115. package/server/dist/history/http/historyRoutes.js.map +1 -0
  116. package/server/dist/history/index.d.ts +24 -0
  117. package/server/dist/history/index.js +30 -0
  118. package/server/dist/history/index.js.map +1 -0
  119. package/server/dist/history/ingest/historyIngestService.d.ts +15 -0
  120. package/server/dist/history/ingest/historyIngestService.js +42 -0
  121. package/server/dist/history/ingest/historyIngestService.js.map +1 -0
  122. package/server/dist/history/projector/extractIds.d.ts +36 -0
  123. package/server/dist/history/projector/extractIds.js +111 -0
  124. package/server/dist/history/projector/extractIds.js.map +1 -0
  125. package/server/dist/history/projector/projectCodexEvent.d.ts +27 -0
  126. package/server/dist/history/projector/projectCodexEvent.js +845 -0
  127. package/server/dist/history/projector/projectCodexEvent.js.map +1 -0
  128. package/server/dist/history/query/historyQueryService.d.ts +34 -0
  129. package/server/dist/history/query/historyQueryService.js +170 -0
  130. package/server/dist/history/query/historyQueryService.js.map +1 -0
  131. package/server/dist/history/sqlite/schema.d.ts +11 -0
  132. package/server/dist/history/sqlite/schema.js +34 -0
  133. package/server/dist/history/sqlite/schema.js.map +1 -0
  134. package/server/dist/history/sqlite/sqliteHistoryStore.d.ts +69 -0
  135. package/server/dist/history/sqlite/sqliteHistoryStore.js +206 -0
  136. package/server/dist/history/sqlite/sqliteHistoryStore.js.map +1 -0
  137. package/server/dist/history/types.d.ts +29 -0
  138. package/server/dist/history/types.js +3 -0
  139. package/server/dist/history/types.js.map +1 -0
  140. package/server/dist/index.d.ts +1 -0
  141. package/server/dist/index.js +166 -0
  142. package/server/dist/index.js.map +1 -0
  143. package/server/dist/security/executionPolicy.d.ts +33 -0
  144. package/server/dist/security/executionPolicy.js +72 -0
  145. package/server/dist/security/executionPolicy.js.map +1 -0
  146. package/server/dist/security/origin.d.ts +11 -0
  147. package/server/dist/security/origin.js +40 -0
  148. package/server/dist/security/origin.js.map +1 -0
  149. package/server/dist/settings/sqliteUserSettingsStore.d.ts +12 -0
  150. package/server/dist/settings/sqliteUserSettingsStore.js +62 -0
  151. package/server/dist/settings/sqliteUserSettingsStore.js.map +1 -0
  152. package/server/dist/settings/userSettingsRoutes.d.ts +8 -0
  153. package/server/dist/settings/userSettingsRoutes.js +55 -0
  154. package/server/dist/settings/userSettingsRoutes.js.map +1 -0
  155. package/server/dist/settings/userSettingsStore.d.ts +19 -0
  156. package/server/dist/settings/userSettingsStore.js +26 -0
  157. package/server/dist/settings/userSettingsStore.js.map +1 -0
  158. package/server/dist/settings/userSettingsTypes.d.ts +70 -0
  159. package/server/dist/settings/userSettingsTypes.js +196 -0
  160. package/server/dist/settings/userSettingsTypes.js.map +1 -0
  161. package/server/dist/status/codexTaskTracker.d.ts +21 -0
  162. package/server/dist/status/codexTaskTracker.js +123 -0
  163. package/server/dist/status/codexTaskTracker.js.map +1 -0
  164. package/server/dist/status/threadContextUsage.d.ts +19 -0
  165. package/server/dist/status/threadContextUsage.js +229 -0
  166. package/server/dist/status/threadContextUsage.js.map +1 -0
  167. package/server/dist/threadList/threadListServerCache.d.ts +10 -0
  168. package/server/dist/threadList/threadListServerCache.js +42 -0
  169. package/server/dist/threadList/threadListServerCache.js.map +1 -0
  170. package/server/dist/tools/cwdSuggest.d.ts +11 -0
  171. package/server/dist/tools/cwdSuggest.js +128 -0
  172. package/server/dist/tools/cwdSuggest.js.map +1 -0
  173. package/server/dist/workspace/accessControl.d.ts +16 -0
  174. package/server/dist/workspace/accessControl.js +82 -0
  175. package/server/dist/workspace/accessControl.js.map +1 -0
  176. package/server/dist/workspace/sqliteUserWorkspaceStore.d.ts +12 -0
  177. package/server/dist/workspace/sqliteUserWorkspaceStore.js +82 -0
  178. package/server/dist/workspace/sqliteUserWorkspaceStore.js.map +1 -0
  179. package/server/dist/workspace/threadAccess.d.ts +19 -0
  180. package/server/dist/workspace/threadAccess.js +22 -0
  181. package/server/dist/workspace/threadAccess.js.map +1 -0
  182. package/server/dist/workspace/threadListVisibility.d.ts +25 -0
  183. package/server/dist/workspace/threadListVisibility.js +104 -0
  184. package/server/dist/workspace/threadListVisibility.js.map +1 -0
  185. package/server/dist/workspace/userWorkspaceRoutes.d.ts +7 -0
  186. package/server/dist/workspace/userWorkspaceRoutes.js +124 -0
  187. package/server/dist/workspace/userWorkspaceRoutes.js.map +1 -0
  188. package/server/dist/workspace/userWorkspaceStore.d.ts +12 -0
  189. package/server/dist/workspace/userWorkspaceStore.js +23 -0
  190. package/server/dist/workspace/userWorkspaceStore.js.map +1 -0
  191. package/server/dist/ws/socketIoBridge.d.ts +32 -0
  192. package/server/dist/ws/socketIoBridge.js +194 -0
  193. package/server/dist/ws/socketIoBridge.js.map +1 -0
  194. package/server/dist/ws/types.d.ts +113 -0
  195. package/server/dist/ws/types.js +3 -0
  196. package/server/dist/ws/types.js.map +1 -0
  197. package/server/dist/ws/wsHub.d.ts +119 -0
  198. package/server/dist/ws/wsHub.js +1259 -0
  199. package/server/dist/ws/wsHub.js.map +1 -0
  200. package/web/dist/assets/index-CY6cnwQz.js +174 -0
  201. package/web/dist/assets/index-DI7kJHr2.css +32 -0
  202. package/web/dist/favicon-mask.svg +9 -0
  203. package/web/dist/favicon.svg +26 -0
  204. package/web/dist/index.html +75 -0
@@ -0,0 +1,1259 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.WsHub = void 0;
7
+ const path_1 = __importDefault(require("path"));
8
+ const ws_1 = __importDefault(require("ws"));
9
+ const env_1 = require("../env");
10
+ const session_1 = require("../auth/session");
11
+ const roles_1 = require("../auth/roles");
12
+ const threadContextUsage_1 = require("../status/threadContextUsage");
13
+ const accessControl_1 = require("../workspace/accessControl");
14
+ const threadListVisibility_1 = require("../workspace/threadListVisibility");
15
+ const executionPolicy_1 = require("../security/executionPolicy");
16
+ const codexEventProjector_1 = require("../chat/codexEventProjector");
17
+ const fileChangeExtractor_1 = require("../chat/fileChangeExtractor");
18
+ const threadTurnsProjector_1 = require("../chat/threadTurnsProjector");
19
+ function sanitizeStatusSnapshotForWs(snapshot) {
20
+ // `codexTask.lastEventAtMs` updates on every Codex notification, even when the meaningful execution state
21
+ // (active turn counts, active threads) hasn't changed. If we include it, WS status JSON always changes and
22
+ // the hub will broadcast `status` far more often than the UI needs.
23
+ const codexTask = snapshot?.codexTask;
24
+ if (!codexTask || typeof codexTask !== "object")
25
+ return snapshot;
26
+ if (!Object.prototype.hasOwnProperty.call(codexTask, "lastEventAtMs"))
27
+ return snapshot;
28
+ return {
29
+ ...snapshot,
30
+ codexTask: {
31
+ ...codexTask,
32
+ lastEventAtMs: null,
33
+ },
34
+ };
35
+ }
36
+ async function resolveCwdForNewThread(requestedCwd, user) {
37
+ // 统一复用 accessControl:保证 HTTP 与 WS 的工作区权限一致。
38
+ const raw = typeof requestedCwd === "string" ? requestedCwd : "";
39
+ return (0, accessControl_1.assertCwdAllowedForUser)({ cwd: raw, user });
40
+ }
41
+ function getErrorMessage(err) {
42
+ if (err instanceof Error) {
43
+ const msg = String(err.message || "").trim();
44
+ if (msg)
45
+ return msg;
46
+ const name = String(err.name || "").trim();
47
+ if (name)
48
+ return name;
49
+ return "Request failed";
50
+ }
51
+ if (typeof err === "string") {
52
+ const msg = err.trim();
53
+ return msg || "Request failed";
54
+ }
55
+ const maybeMessage = typeof err?.message === "string" ? String(err.message).trim() : "";
56
+ if (maybeMessage)
57
+ return maybeMessage;
58
+ try {
59
+ return JSON.stringify(err);
60
+ }
61
+ catch {
62
+ return "Request failed";
63
+ }
64
+ }
65
+ function getErrorDetails(err) {
66
+ if (err instanceof Error)
67
+ return String(err.stack || err.message || err.name || "Request failed");
68
+ return String(err);
69
+ }
70
+ function describeClientAction(msg) {
71
+ // Keep this short; it appears in the UI error banner.
72
+ return typeof msg?.type === "string" ? String(msg.type) : "request";
73
+ }
74
+ class WsHub {
75
+ wss;
76
+ codex;
77
+ sessionSecret;
78
+ getStatusSnapshot;
79
+ clients = new Set();
80
+ readyClients = new Set();
81
+ threadOwners = new Map();
82
+ threadOwnerUsername = new Map();
83
+ threadOwnerUsernameMax = 1000;
84
+ // 线程关联的 cwd 缓存:用于在 submit/interrupt 等操作中做权限校验。
85
+ threadCwdById = new Map();
86
+ pending = new Map();
87
+ seenSubmitIdsByThread = new Map();
88
+ submitDedupTtlMs = 10 * 60_000;
89
+ submitDedupMaxPerThread = 500;
90
+ wsUser = new Map();
91
+ wsByUser = new Map();
92
+ openThreadResultsByUser = new Map();
93
+ openThreadDedupTtlMs = 10 * 60_000;
94
+ openThreadDedupMaxPerUser = 200;
95
+ threadTurnsByUser = new Map();
96
+ threadTurnsCacheTtlMs = 30 * 60_000;
97
+ threadTurnsCacheMaxPerUser = 200;
98
+ // 从 Codex session `.jsonl` 读取 token_count,用于补齐线程“初始上下文使用率”(CLI 可见,Web 默认拿不到)。
99
+ threadContextUsageReader = new threadContextUsage_1.ThreadContextUsageReader();
100
+ // 后端聊天投影器:把 Codex 通知转换为 UI 友好的 chat_ops,降低前端解析压力。
101
+ chatProjector = new codexEventProjector_1.ChatProjector();
102
+ // 线程创建时间(ms)缓存:用于 turns slice 生成稳定的 ChatItem 时间戳。
103
+ threadCreatedAtMsById = new Map();
104
+ // 首次 `open_thread` 时下发给客户端的 turns 数量上限。
105
+ // 默认限制为 200,避免打开超长线程时一次性返回全部 turns 导致前端解析/渲染卡顿;可通过 env 调整。
106
+ initialThreadTurnLimit = (0, env_1.getWebOpenThreadTurnsLimit)();
107
+ loadThreadTurnsDefaultLimit = 50;
108
+ loadThreadTurnsMaxLimit = 200;
109
+ seenInterruptIdsByThread = new Map();
110
+ interruptDedupTtlMs = 2 * 60_000;
111
+ interruptDedupMaxPerThread = 500;
112
+ statusTimer = null;
113
+ lastStatusJson = null;
114
+ getActiveTurnIds;
115
+ pendingTimeoutMs;
116
+ heartbeatTimer = null;
117
+ userStore;
118
+ historyIngest;
119
+ workspaceRoutingSnapshot = null;
120
+ workspaceRoutingSnapshotPromise = null;
121
+ workspaceRoutingSnapshotTtlMs = 30_000;
122
+ constructor(wss, codex, sessionSecret, getStatusSnapshot, opts = {}) {
123
+ this.wss = wss;
124
+ this.codex = codex;
125
+ this.sessionSecret = sessionSecret;
126
+ this.getStatusSnapshot = getStatusSnapshot;
127
+ this.userStore = opts.userStore ?? null;
128
+ this.historyIngest = opts.historyIngest ?? null;
129
+ this.getActiveTurnIds = opts.getActiveTurnIds ?? (() => []);
130
+ this.pendingTimeoutMs = Number.isFinite(opts.pendingTimeoutMs) ? Math.max(0, Math.floor(opts.pendingTimeoutMs)) : 3 * 60_000;
131
+ this.codex.onNotification(({ method, params }) => {
132
+ const threadId = extractThreadId(params);
133
+ if (threadId && this.historyIngest) {
134
+ void this.historyIngest.recordCodexEvent({ threadId, method, params }).catch(() => {
135
+ // 历史落库失败不应阻断实时事件广播链路。
136
+ });
137
+ }
138
+ if (!threadId)
139
+ return;
140
+ const nowMs = Date.now();
141
+ const payload = { method, params };
142
+ const projection = this.chatProjector.projectNotification({ threadId, payload, nowMs });
143
+ const messages = [];
144
+ if (projection.ops.length) {
145
+ messages.push({ type: "chat_ops", threadId, ops: projection.ops });
146
+ }
147
+ if (projection.todoUpdate) {
148
+ messages.push({ type: "todo_plan_update", update: projection.todoUpdate });
149
+ }
150
+ if (typeof projection.usagePercent === "number") {
151
+ messages.push({ type: "thread_context_usage", threadId, usagePercent: projection.usagePercent });
152
+ }
153
+ if (!messages.length)
154
+ return;
155
+ this.broadcastMessagesToAuthorizedUsers(threadId, messages);
156
+ });
157
+ this.codex.onServerRequest(async (req) => this.handleCodexServerRequest(req));
158
+ this.wss.on("connection", (ws, req) => this.onConnection(ws, req));
159
+ this.startStatusPump(500);
160
+ this.startHeartbeat(Number.isFinite(opts.heartbeatIntervalMs) ? Math.max(100, Math.floor(opts.heartbeatIntervalMs)) : 30_000);
161
+ }
162
+ dispose() {
163
+ if (this.statusTimer)
164
+ clearInterval(this.statusTimer);
165
+ this.statusTimer = null;
166
+ if (this.heartbeatTimer)
167
+ clearInterval(this.heartbeatTimer);
168
+ this.heartbeatTimer = null;
169
+ }
170
+ onConnection(ws, req) {
171
+ // 防御性处理:任何畸形 header 都不应导致握手阶段抛错(DoS 风险)。
172
+ let session = null;
173
+ try {
174
+ const cookies = (0, session_1.parseCookieHeader)(req.headers.cookie);
175
+ const raw = cookies[session_1.SESSION_COOKIE_NAME];
176
+ session = raw ? (0, session_1.verifySessionCookieValueWithRole)(raw, this.sessionSecret) : null;
177
+ }
178
+ catch {
179
+ session = null;
180
+ }
181
+ if (!session) {
182
+ ws.close(1008, "unauthorized");
183
+ return;
184
+ }
185
+ this.clients.add(ws);
186
+ setIsAlive(ws, true);
187
+ ws.on("pong", () => setIsAlive(ws, true));
188
+ void (async () => {
189
+ const user = await this.resolveWsUserInfo(session);
190
+ this.wsUser.set(ws, user);
191
+ this.addUserClient(user.username, ws);
192
+ await this.sendReady(ws);
193
+ this.readyClients.add(ws);
194
+ this.sendStatus(ws);
195
+ setTimeout(() => void this.flushPendingForWs(ws), 0);
196
+ })();
197
+ ws.on("message", async (data) => {
198
+ let msg;
199
+ try {
200
+ msg = JSON.parse(data.toString("utf8"));
201
+ }
202
+ catch {
203
+ return;
204
+ }
205
+ try {
206
+ await this.handleAuthedMessage(ws, msg);
207
+ }
208
+ catch (err) {
209
+ const action = describeClientAction(msg);
210
+ const base = getErrorMessage(err);
211
+ const message = base === "Request failed" ? `${action} failed` : `${action} failed: ${base}`;
212
+ // 透传 clientMessageId,便于前端把失败原因绑定到具体 optimistic 消息。
213
+ const clientMessageId = typeof msg?.clientMessageId === "string" ? String(msg.clientMessageId) : "";
214
+ // 透传 requestId,便于前端将 list_threads 错误与最新请求做精确关联。
215
+ const requestId = typeof msg?.requestId === "string" ? String(msg.requestId) : "";
216
+ this.safeSend(ws, {
217
+ type: "error",
218
+ message,
219
+ details: getErrorDetails(err),
220
+ clientMessageId: clientMessageId || undefined,
221
+ requestId: requestId || undefined,
222
+ });
223
+ }
224
+ });
225
+ ws.on("close", () => {
226
+ const username = this.wsUser.get(ws)?.username ?? "";
227
+ this.clients.delete(ws);
228
+ this.readyClients.delete(ws);
229
+ this.wsUser.delete(ws);
230
+ this.removeUserClient(username, ws);
231
+ for (const [threadId, owner] of this.threadOwners.entries()) {
232
+ if (owner === ws)
233
+ this.threadOwners.delete(threadId);
234
+ }
235
+ });
236
+ }
237
+ async resolveWsUserInfo(session) {
238
+ const username = String(session.username ?? "").trim();
239
+ const role = session.role;
240
+ // 未配置 userStore 时保持兼容:不做用户级工作区限制(仍受全局 CODEX_ALLOWED_CWD_ROOTS 限制)。
241
+ if (!this.userStore) {
242
+ return { username, role, workspaces: [] };
243
+ }
244
+ const stored = await this.userStore.getUserByUsername(username);
245
+ return stored
246
+ ? { username: stored.username, role: stored.role, workspaces: (0, threadListVisibility_1.normalizeWorkspacePaths)(stored.workspaces) }
247
+ : { username, role, workspaces: [] };
248
+ }
249
+ getWsUserInfo(ws) {
250
+ const user = this.wsUser.get(ws);
251
+ if (!user)
252
+ throw new Error("unauthorized");
253
+ return user;
254
+ }
255
+ async assertThreadAllowedForUser(ws, threadId) {
256
+ const user = this.getWsUserInfo(ws);
257
+ if ((0, roles_1.isAdminRole)(user.role))
258
+ return;
259
+ const cachedCwd = this.threadCwdById.get(threadId);
260
+ const cwd = cachedCwd || String((await this.codex.readThread(threadId, false))?.cwd ?? "");
261
+ if (!cwd)
262
+ throw new Error("cwd not allowed");
263
+ const canonical = await (0, accessControl_1.assertCwdAllowedForUser)({ cwd, user });
264
+ this.threadCwdById.set(threadId, canonical);
265
+ }
266
+ startHeartbeat(intervalMs) {
267
+ if (this.heartbeatTimer)
268
+ clearInterval(this.heartbeatTimer);
269
+ this.heartbeatTimer = setInterval(() => {
270
+ for (const ws of this.clients) {
271
+ if (ws.readyState !== ws_1.default.OPEN)
272
+ continue;
273
+ if (!getIsAlive(ws)) {
274
+ ws.terminate();
275
+ continue;
276
+ }
277
+ setIsAlive(ws, false);
278
+ try {
279
+ ws.ping();
280
+ }
281
+ catch {
282
+ ws.terminate();
283
+ }
284
+ }
285
+ }, intervalMs);
286
+ }
287
+ async handleAuthedMessage(ws, msg) {
288
+ if (msg.type === "get_status") {
289
+ this.sendStatus(ws);
290
+ return;
291
+ }
292
+ if (msg.type === "list_threads") {
293
+ // 会话列表必须按“当前用户权限 + 可选 cwd 过滤”返回,避免前端本地过滤造成越权可见。
294
+ const requestedListThreadsRequestId = typeof msg.requestId === "string" ? msg.requestId.trim() : "";
295
+ const { threads, workspaceFilterCwd } = await this.listThreadsForClient(ws, msg.cwd);
296
+ this.safeSend(ws, {
297
+ type: "threads",
298
+ threads,
299
+ requestId: requestedListThreadsRequestId || undefined,
300
+ cwd: workspaceFilterCwd,
301
+ });
302
+ return;
303
+ }
304
+ if (msg.type === "open_thread") {
305
+ const user = this.getWsUserInfo(ws);
306
+ const username = user.username;
307
+ const clientMessageId = typeof msg.clientMessageId === "string" ? String(msg.clientMessageId) : "";
308
+ if (username && clientMessageId) {
309
+ const cached = this.getCachedOpenThreadResult(username, clientMessageId);
310
+ if (cached) {
311
+ const cachedThreadId = String(cached.thread?.id ?? "");
312
+ if (cachedThreadId)
313
+ this.setThreadOwner(cachedThreadId, ws, username);
314
+ const threadForClient = { ...cached.thread };
315
+ await this.attachThreadContextUsagePercent(threadForClient);
316
+ const chatItems = (0, threadTurnsProjector_1.projectThreadToChatItems)(threadForClient);
317
+ if (cachedThreadId) {
318
+ const createdAtMs = Number.isFinite(threadForClient?.createdAt)
319
+ ? Number(threadForClient.createdAt) * 1000
320
+ : Date.now();
321
+ this.threadCreatedAtMsById.set(cachedThreadId, createdAtMs);
322
+ }
323
+ this.safeSend(ws, { type: "thread_opened", thread: threadForClient, chatItems });
324
+ this.safeSend(ws, { type: "ack", ackType: "open_thread", clientMessageId, threadId: cachedThreadId || undefined });
325
+ return;
326
+ }
327
+ }
328
+ const thread = await (async () => {
329
+ if (msg.threadId && msg.threadId.trim()) {
330
+ const resumed = await this.codex.resumeThread(msg.threadId);
331
+ const read = await this.codex.readThread(String(resumed.id ?? msg.threadId), true);
332
+ const resolvedThreadId = String(read?.id ?? resumed.id ?? msg.threadId);
333
+ const cwd = String(read?.cwd ?? "");
334
+ if (resolvedThreadId && cwd) {
335
+ const canonical = await (0, accessControl_1.assertCwdAllowedForUser)({ cwd, user });
336
+ this.threadCwdById.set(resolvedThreadId, canonical);
337
+ }
338
+ return read;
339
+ }
340
+ // 统一在服务端按角色收敛执行策略:
341
+ // - member 不允许通过 WS 提升到 `never`/`danger-full-access`;
342
+ // - admin 保持原有可配置能力。
343
+ const approvalPolicy = (0, executionPolicy_1.coerceApprovalPolicyForUser)({
344
+ userRole: user.role,
345
+ requested: msg.approvalPolicy,
346
+ fallback: (0, env_1.getCodexApprovalPolicy)(),
347
+ });
348
+ const sandbox = (0, executionPolicy_1.coerceSandboxModeForUser)({
349
+ userRole: user.role,
350
+ requested: msg.sandbox,
351
+ fallback: (0, env_1.getCodexSandboxMode)(),
352
+ });
353
+ const cwd = await resolveCwdForNewThread(msg.cwd, user);
354
+ return this.codex.startThread({
355
+ cwd,
356
+ approvalPolicy,
357
+ sandbox,
358
+ model: typeof msg.model === "string" ? msg.model : undefined,
359
+ });
360
+ })();
361
+ const threadId = String(thread.id ?? msg.threadId ?? "");
362
+ if (threadId)
363
+ this.setThreadOwner(threadId, ws, username);
364
+ const threadCwd = String(thread?.cwd ?? "");
365
+ if (threadId && threadCwd)
366
+ this.threadCwdById.set(threadId, threadCwd);
367
+ const { threadForClient, turns } = this.trimThreadTurnsForClient(thread, this.initialThreadTurnLimit);
368
+ if (username && threadId && turns.length)
369
+ this.cacheThreadTurns(username, threadId, turns);
370
+ await this.attachThreadContextUsagePercent(threadForClient);
371
+ const chatItems = (0, threadTurnsProjector_1.projectThreadToChatItems)(threadForClient);
372
+ if (threadId) {
373
+ const createdAtMs = Number.isFinite(threadForClient?.createdAt)
374
+ ? Number(threadForClient.createdAt) * 1000
375
+ : Date.now();
376
+ this.threadCreatedAtMsById.set(threadId, createdAtMs);
377
+ }
378
+ this.safeSend(ws, { type: "thread_opened", thread: threadForClient, chatItems });
379
+ if (username && clientMessageId)
380
+ this.cacheOpenThreadResult(username, clientMessageId, threadForClient);
381
+ if (clientMessageId)
382
+ this.safeSend(ws, { type: "ack", ackType: "open_thread", clientMessageId, threadId: threadId || undefined });
383
+ return;
384
+ }
385
+ if (msg.type === "load_thread_turns") {
386
+ const username = this.getWsUserInfo(ws).username;
387
+ const threadId = String(msg.threadId ?? "").trim();
388
+ if (!threadId)
389
+ return;
390
+ await this.assertThreadAllowedForUser(ws, threadId);
391
+ const clientMessageId = typeof msg.clientMessageId === "string" ? String(msg.clientMessageId) : "";
392
+ const before = Number(msg.before);
393
+ const requestedLimit = typeof msg.limit === "number" && Number.isFinite(msg.limit) ? Math.floor(msg.limit) : this.loadThreadTurnsDefaultLimit;
394
+ const limit = Math.max(1, Math.min(this.loadThreadTurnsMaxLimit, requestedLimit));
395
+ const turns = await this.getOrFetchThreadTurns(username, threadId);
396
+ const total = turns.length;
397
+ const safeBefore = Number.isFinite(before) ? Math.max(0, Math.min(total, Math.floor(before))) : total;
398
+ const start = Math.max(0, safeBefore - limit);
399
+ const slice = turns.slice(start, safeBefore);
400
+ const createdAtMs = this.threadCreatedAtMsById.get(threadId) ?? Date.now();
401
+ const chatItems = (0, threadTurnsProjector_1.projectTurnsToChatItems)(slice, { createdAtMs, turnsStart: start });
402
+ this.safeSend(ws, { type: "thread_turns", threadId, turns: slice, turnsStart: start, turnsTotal: total, chatItems });
403
+ if (clientMessageId)
404
+ this.safeSend(ws, { type: "ack", ackType: "load_thread_turns", clientMessageId, threadId });
405
+ return;
406
+ }
407
+ if (msg.type === "submit") {
408
+ const threadId = String(msg.threadId ?? "").trim();
409
+ if (!threadId)
410
+ return;
411
+ await this.assertThreadAllowedForUser(ws, threadId);
412
+ const user = this.getWsUserInfo(ws);
413
+ const clientMessageId = typeof msg.clientMessageId === "string" ? String(msg.clientMessageId) : "";
414
+ if (clientMessageId) {
415
+ const isDup = this.rememberSubmitId(threadId, clientMessageId);
416
+ if (isDup) {
417
+ this.safeSend(ws, { type: "ack", ackType: "submit", threadId, clientMessageId });
418
+ return;
419
+ }
420
+ }
421
+ const requestedApprovalPolicy = msg.approvalPolicy;
422
+ const requestedSandbox = msg.sandbox;
423
+ // turn 级别的权限也要按角色收敛,避免 member 通过消息覆写更高权限。
424
+ const approvalPolicy = typeof requestedApprovalPolicy === "string" && requestedApprovalPolicy.trim()
425
+ ? (0, executionPolicy_1.coerceApprovalPolicyForUser)({
426
+ userRole: user.role,
427
+ requested: requestedApprovalPolicy,
428
+ fallback: (0, env_1.getCodexApprovalPolicy)(),
429
+ })
430
+ : undefined;
431
+ const sandbox = typeof requestedSandbox === "string" && requestedSandbox.trim()
432
+ ? (0, executionPolicy_1.coerceSandboxModeForUser)({
433
+ userRole: user.role,
434
+ requested: requestedSandbox,
435
+ fallback: (0, env_1.getCodexSandboxMode)(),
436
+ })
437
+ : undefined;
438
+ const turnOpts = {
439
+ model: typeof msg.model === "string" ? msg.model : undefined,
440
+ effort: typeof msg.effort === "string" ? msg.effort : typeof msg.reasoningEffort === "string" ? msg.reasoningEffort : undefined,
441
+ approvalPolicy,
442
+ sandbox,
443
+ collaborationMode: msg.collaborationMode && typeof msg.collaborationMode === "object" ? msg.collaborationMode : undefined,
444
+ };
445
+ try {
446
+ await this.codex.startTurn(threadId, msg.text, turnOpts);
447
+ }
448
+ catch (firstErr) {
449
+ // When backend state is stale after reconnect/restart, try to recover the thread once before failing.
450
+ const recoverable = this.isThreadNotFoundError(firstErr);
451
+ if (!recoverable) {
452
+ if (clientMessageId)
453
+ this.forgetSubmitId(threadId, clientMessageId);
454
+ throw firstErr;
455
+ }
456
+ try {
457
+ await this.recoverThreadForSubmit(ws, threadId);
458
+ await this.codex.startTurn(threadId, msg.text, turnOpts);
459
+ }
460
+ catch (retryErr) {
461
+ if (clientMessageId)
462
+ this.forgetSubmitId(threadId, clientMessageId);
463
+ throw retryErr;
464
+ }
465
+ }
466
+ if (clientMessageId)
467
+ this.safeSend(ws, { type: "ack", ackType: "submit", threadId, clientMessageId });
468
+ return;
469
+ }
470
+ if (msg.type === "interrupt") {
471
+ const threadId = String(msg.threadId ?? "").trim();
472
+ if (!threadId)
473
+ return;
474
+ await this.assertThreadAllowedForUser(ws, threadId);
475
+ const clientMessageId = typeof msg.clientMessageId === "string" ? String(msg.clientMessageId) : "";
476
+ if (clientMessageId) {
477
+ const isDup = this.rememberInterruptId(threadId, clientMessageId);
478
+ if (isDup) {
479
+ this.safeSend(ws, { type: "ack", ackType: "interrupt", clientMessageId, threadId });
480
+ return;
481
+ }
482
+ }
483
+ const turnIds = await this.resolveInterruptTurnIds(ws, threadId);
484
+ if (!turnIds.length) {
485
+ if (clientMessageId)
486
+ this.safeSend(ws, { type: "ack", ackType: "interrupt", clientMessageId, threadId });
487
+ return;
488
+ }
489
+ await Promise.allSettled(turnIds.map((turnId) => this.codex.interruptTurn(threadId, turnId)));
490
+ if (clientMessageId)
491
+ this.safeSend(ws, { type: "ack", ackType: "interrupt", clientMessageId, threadId });
492
+ return;
493
+ }
494
+ if (msg.type === "respond_user_input") {
495
+ const pending = this.pending.get(msg.requestId);
496
+ const clientMessageId = typeof msg.clientMessageId === "string" ? String(msg.clientMessageId) : "";
497
+ if (!pending) {
498
+ if (clientMessageId) {
499
+ this.safeSend(ws, { type: "ack", ackType: "respond_user_input", clientMessageId, requestId: msg.requestId });
500
+ }
501
+ return;
502
+ }
503
+ const user = this.getWsUserInfo(ws);
504
+ if (!this.isUserAuthorizedForPending(user, pending)) {
505
+ if (clientMessageId) {
506
+ this.safeSend(ws, { type: "ack", ackType: "respond_user_input", clientMessageId, requestId: msg.requestId });
507
+ }
508
+ return;
509
+ }
510
+ clearTimeout(pending.timeout);
511
+ this.pending.delete(msg.requestId);
512
+ const mappedResponse = mapUserResponseToCodexResult(pending.method, msg.response);
513
+ pending.resolve(mappedResponse);
514
+ if (pending.threadId && this.historyIngest) {
515
+ void this.historyIngest
516
+ .recordCodexEvent({
517
+ threadId: pending.threadId,
518
+ method: "web/user_input_resolved",
519
+ params: {
520
+ threadId: pending.threadId,
521
+ requestId: msg.requestId,
522
+ method: pending.method,
523
+ response: msg.response,
524
+ mappedResponse,
525
+ requestParams: pending.params,
526
+ },
527
+ })
528
+ .catch(() => {
529
+ // 历史落库失败不应影响用户输入响应链路。
530
+ });
531
+ }
532
+ this.broadcastUserInputResolved(msg.requestId, pending, {
533
+ response: msg.response,
534
+ mappedResponse,
535
+ requestParams: pending.params,
536
+ });
537
+ if (clientMessageId) {
538
+ this.safeSend(ws, { type: "ack", ackType: "respond_user_input", clientMessageId, requestId: msg.requestId });
539
+ }
540
+ return;
541
+ }
542
+ }
543
+ // 根据当前用户权限与可选 cwd 过滤条件,返回可见会话列表。
544
+ async listThreadsForClient(ws, requestedCwd) {
545
+ // 每个连接都绑定了认证用户,列表过滤必须以该用户为基准。
546
+ const user = this.getWsUserInfo(ws);
547
+ const routingSnapshot = await this.getWorkspaceRoutingSnapshot().catch(() => null);
548
+ const workspaceFilterCwd = await (0, threadListVisibility_1.resolveThreadListWorkspaceFilter)(requestedCwd, user);
549
+ const threads = await this.codex.listThreads();
550
+ const visibleThreads = threads.filter((thread) => (0, threadListVisibility_1.shouldIncludeThreadForList)({
551
+ thread,
552
+ user,
553
+ workspaceFilterCwd,
554
+ routingSnapshot,
555
+ }));
556
+ return {
557
+ threads: visibleThreads,
558
+ workspaceFilterCwd,
559
+ };
560
+ }
561
+ async getWorkspaceRoutingSnapshot() {
562
+ if (!this.userStore)
563
+ return null;
564
+ const now = Date.now();
565
+ const cached = this.workspaceRoutingSnapshot;
566
+ if (cached && now - cached.updatedAtMs <= this.workspaceRoutingSnapshotTtlMs) {
567
+ return cached;
568
+ }
569
+ const inflight = this.workspaceRoutingSnapshotPromise;
570
+ if (inflight)
571
+ return inflight;
572
+ const refreshP = this.userStore
573
+ .listUsers()
574
+ .then((users) => (0, threadListVisibility_1.buildWorkspaceRoutingSnapshot)(users))
575
+ .then((snapshot) => {
576
+ this.workspaceRoutingSnapshot = snapshot;
577
+ return snapshot;
578
+ })
579
+ .finally(() => {
580
+ this.workspaceRoutingSnapshotPromise = null;
581
+ });
582
+ this.workspaceRoutingSnapshotPromise = refreshP;
583
+ return refreshP;
584
+ }
585
+ // 判断某个 cwd 是否在当前用户可见范围内(admin 全可见,member 仅分配根目录范围)。
586
+ isCwdVisibleToUser(cwd, user, routingSnapshot) {
587
+ return (0, threadListVisibility_1.isThreadCwdVisibleToUser)({
588
+ cwd,
589
+ user,
590
+ routingSnapshot,
591
+ });
592
+ }
593
+ // 获取 thread cwd,并缓存到 threadCwdById;用于 WS 事件与审批路由等场景。
594
+ async getOrFetchThreadCwd(threadId) {
595
+ const cached = this.threadCwdById.get(threadId);
596
+ if (cached)
597
+ return cached;
598
+ const thread = await this.codex.readThread(threadId, false);
599
+ const cwd = String(thread?.cwd ?? "").trim();
600
+ if (!cwd)
601
+ return null;
602
+ this.threadCwdById.set(threadId, cwd);
603
+ return cwd;
604
+ }
605
+ /**
606
+ * 将消息广播给“对该 thread cwd 有权限”的在线用户,避免跨工作区泄露。
607
+ * 说明:同一通知可能对应多条下发消息(chat_ops + todo + usage),因此这里支持批量发送。
608
+ */
609
+ broadcastMessagesToAuthorizedUsers(threadId, messages) {
610
+ void (async () => {
611
+ const normalizedThreadId = typeof threadId === "string" ? threadId.trim() : "";
612
+ if (!normalizedThreadId)
613
+ return;
614
+ if (!messages.length)
615
+ return;
616
+ const threadCwd = await this.getOrFetchThreadCwd(normalizedThreadId).catch(() => null);
617
+ if (!threadCwd)
618
+ return;
619
+ const resolvedThreadCwd = path_1.default.resolve(threadCwd);
620
+ const routingSnapshot = await this.getWorkspaceRoutingSnapshot().catch(() => null);
621
+ const workspaceRoot = routingSnapshot ? (0, threadListVisibility_1.resolveWorkspaceRootForCwd)(routingSnapshot, resolvedThreadCwd) : null;
622
+ for (const ws of this.readyClients) {
623
+ if (ws.readyState !== ws_1.default.OPEN)
624
+ continue;
625
+ const user = this.wsUser.get(ws);
626
+ if (!user)
627
+ continue;
628
+ if (workspaceRoot) {
629
+ if (!(0, roles_1.isAdminRole)(user.role) && !user.workspaces.includes(workspaceRoot))
630
+ continue;
631
+ }
632
+ else {
633
+ if (!this.isCwdVisibleToUser(resolvedThreadCwd, user, routingSnapshot))
634
+ continue;
635
+ }
636
+ for (const message of messages) {
637
+ this.safeSend(ws, message);
638
+ }
639
+ }
640
+ })().catch(() => {
641
+ // best-effort only
642
+ });
643
+ }
644
+ async sendReady(ws) {
645
+ try {
646
+ // ready 阶段也使用与 list_threads 一致的过滤,避免首次连接时暴露越权会话。
647
+ const { threads } = await this.listThreadsForClient(ws, null);
648
+ this.safeSend(ws, { type: "ready", serverVersion: "0.0.0", threads });
649
+ }
650
+ catch (err) {
651
+ this.safeSend(ws, { type: "error", message: "Failed to list threads", details: String(err) });
652
+ }
653
+ }
654
+ rememberSubmitId(threadId, clientMessageId) {
655
+ const now = Date.now();
656
+ let map = this.seenSubmitIdsByThread.get(threadId);
657
+ if (!map) {
658
+ map = new Map();
659
+ this.seenSubmitIdsByThread.set(threadId, map);
660
+ }
661
+ // Drop expired ids (best-effort; also keeps the map small).
662
+ for (const [id, ts] of map.entries()) {
663
+ if (now - ts > this.submitDedupTtlMs)
664
+ map.delete(id);
665
+ }
666
+ const isDup = map.has(clientMessageId);
667
+ map.set(clientMessageId, now);
668
+ // Cap per-thread memory. Keep the newest ids.
669
+ if (map.size > this.submitDedupMaxPerThread) {
670
+ const entries = Array.from(map.entries()).sort((a, b) => a[1] - b[1]);
671
+ const toDrop = entries.length - this.submitDedupMaxPerThread;
672
+ for (let i = 0; i < toDrop; i += 1)
673
+ map.delete(entries[i][0]);
674
+ }
675
+ return isDup;
676
+ }
677
+ forgetSubmitId(threadId, clientMessageId) {
678
+ const map = this.seenSubmitIdsByThread.get(threadId);
679
+ if (!map)
680
+ return;
681
+ map.delete(clientMessageId);
682
+ if (!map.size)
683
+ this.seenSubmitIdsByThread.delete(threadId);
684
+ }
685
+ getCachedOpenThreadResult(username, clientMessageId) {
686
+ const now = Date.now();
687
+ const map = this.openThreadResultsByUser.get(username);
688
+ if (!map)
689
+ return null;
690
+ for (const [id, v] of map.entries()) {
691
+ if (now - v.ts > this.openThreadDedupTtlMs)
692
+ map.delete(id);
693
+ }
694
+ return map.get(clientMessageId) ?? null;
695
+ }
696
+ cacheOpenThreadResult(username, clientMessageId, thread) {
697
+ const now = Date.now();
698
+ let map = this.openThreadResultsByUser.get(username);
699
+ if (!map) {
700
+ map = new Map();
701
+ this.openThreadResultsByUser.set(username, map);
702
+ }
703
+ map.set(clientMessageId, { ts: now, thread });
704
+ if (map.size > this.openThreadDedupMaxPerUser) {
705
+ const entries = Array.from(map.entries()).sort((a, b) => a[1].ts - b[1].ts);
706
+ const toDrop = entries.length - this.openThreadDedupMaxPerUser;
707
+ for (let i = 0; i < toDrop; i += 1)
708
+ map.delete(entries[i][0]);
709
+ }
710
+ }
711
+ trimThreadTurnsForClient(thread, limit) {
712
+ const t = thread;
713
+ const allTurns = Array.isArray(t?.turns) ? t.turns : [];
714
+ const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 0;
715
+ const start = Math.max(0, allTurns.length - safeLimit);
716
+ const sliced = safeLimit > 0 ? allTurns.slice(start) : [];
717
+ const threadForClient = {
718
+ ...t,
719
+ turns: sliced,
720
+ turnsStart: start,
721
+ turnsTotal: allTurns.length,
722
+ };
723
+ return { threadForClient, turns: allTurns };
724
+ }
725
+ /**
726
+ * 从线程 session 文件中读取最新 token_count,并写入 `contextUsagePercent` 字段。
727
+ * 说明:前端会从 thread_opened payload 中读取该值,以解决“刚进来没有上下文使用率”的问题。
728
+ */
729
+ async attachThreadContextUsagePercent(thread) {
730
+ const t = thread;
731
+ const sessionPath = String(t?.path ?? "").trim();
732
+ if (!sessionPath)
733
+ return;
734
+ const usagePercent = await this.threadContextUsageReader.readUsagePercent(sessionPath);
735
+ if (usagePercent === null)
736
+ return;
737
+ t.contextUsagePercent = usagePercent;
738
+ }
739
+ cacheThreadTurns(username, threadId, turns) {
740
+ if (!username || !threadId)
741
+ return;
742
+ const now = Date.now();
743
+ let map = this.threadTurnsByUser.get(username);
744
+ if (!map) {
745
+ map = new Map();
746
+ this.threadTurnsByUser.set(username, map);
747
+ }
748
+ // Drop expired entries best-effort.
749
+ for (const [id, v] of map.entries()) {
750
+ if (now - v.ts > this.threadTurnsCacheTtlMs)
751
+ map.delete(id);
752
+ }
753
+ map.set(threadId, { ts: now, turns });
754
+ if (map.size > this.threadTurnsCacheMaxPerUser) {
755
+ const entries = Array.from(map.entries()).sort((a, b) => a[1].ts - b[1].ts);
756
+ const toDrop = entries.length - this.threadTurnsCacheMaxPerUser;
757
+ for (let i = 0; i < toDrop; i += 1)
758
+ map.delete(entries[i][0]);
759
+ }
760
+ }
761
+ getCachedThreadTurns(username, threadId) {
762
+ const map = this.threadTurnsByUser.get(username);
763
+ if (!map)
764
+ return null;
765
+ const v = map.get(threadId);
766
+ if (!v)
767
+ return null;
768
+ if (Date.now() - v.ts > this.threadTurnsCacheTtlMs) {
769
+ map.delete(threadId);
770
+ return null;
771
+ }
772
+ return v.turns;
773
+ }
774
+ async getOrFetchThreadTurns(username, threadId) {
775
+ const cached = this.getCachedThreadTurns(username, threadId);
776
+ if (cached)
777
+ return cached;
778
+ const thread = await this.codex.readThread(threadId, true);
779
+ const t = thread;
780
+ const turns = Array.isArray(t?.turns) ? t.turns : [];
781
+ if (username && turns.length)
782
+ this.cacheThreadTurns(username, threadId, turns);
783
+ return turns;
784
+ }
785
+ /**
786
+ * 解析本次 interrupt 应该尝试中断的 turnId 列表。
787
+ * 优先使用任务跟踪器提供的 active turnId;若为空,则回退为线程中最新的 turnId(尽力而为)。
788
+ */
789
+ async resolveInterruptTurnIds(ws, threadId) {
790
+ const activeTurnIds = this.getActiveTurnIds(threadId).filter((turnId) => Boolean(String(turnId || "").trim()));
791
+ if (activeTurnIds.length)
792
+ return activeTurnIds;
793
+ const fallbackTurnId = await this.getLatestThreadTurnId(ws, threadId);
794
+ return fallbackTurnId ? [fallbackTurnId] : [];
795
+ }
796
+ /**
797
+ * 从线程 turns 中提取“最新一个可用的 turnId”,用于 active turnId 缺失时的保底中断。
798
+ * 注意:这是 best-effort,任何读取/解析异常都应返回 null,避免影响 ack 流程。
799
+ */
800
+ async getLatestThreadTurnId(ws, threadId) {
801
+ const username = this.getWsUserInfo(ws).username;
802
+ try {
803
+ const turns = await this.getOrFetchThreadTurns(username, threadId);
804
+ for (let index = turns.length - 1; index >= 0; index -= 1) {
805
+ const turnId = extractThreadTurnId(turns[index]);
806
+ if (turnId)
807
+ return turnId;
808
+ }
809
+ return null;
810
+ }
811
+ catch {
812
+ return null;
813
+ }
814
+ }
815
+ isThreadNotFoundError(err) {
816
+ // Only auto-recover when the error clearly indicates thread lookup failure.
817
+ const normalizedMessage = getErrorMessage(err).toLowerCase();
818
+ return normalizedMessage.includes("thread") && normalizedMessage.includes("not found");
819
+ }
820
+ async recoverThreadForSubmit(ws, threadId) {
821
+ // Re-hydrate thread state and send thread_opened so frontend state aligns with backend state.
822
+ const resumed = await this.codex.resumeThread(threadId);
823
+ const recoveredThreadId = String(resumed?.id ?? threadId);
824
+ const thread = await this.codex.readThread(recoveredThreadId, true);
825
+ const username = this.getWsUserInfo(ws).username;
826
+ const effectiveThreadId = String(thread?.id ?? recoveredThreadId);
827
+ if (effectiveThreadId)
828
+ this.setThreadOwner(effectiveThreadId, ws, username);
829
+ const threadCwd = String(thread?.cwd ?? "");
830
+ if (effectiveThreadId && threadCwd)
831
+ this.threadCwdById.set(effectiveThreadId, threadCwd);
832
+ const { threadForClient, turns } = this.trimThreadTurnsForClient(thread, this.initialThreadTurnLimit);
833
+ if (username && effectiveThreadId && turns.length)
834
+ this.cacheThreadTurns(username, effectiveThreadId, turns);
835
+ await this.attachThreadContextUsagePercent(threadForClient);
836
+ const chatItems = (0, threadTurnsProjector_1.projectThreadToChatItems)(threadForClient);
837
+ if (effectiveThreadId) {
838
+ const createdAtMs = Number.isFinite(threadForClient?.createdAt)
839
+ ? Number(threadForClient.createdAt) * 1000
840
+ : Date.now();
841
+ this.threadCreatedAtMsById.set(effectiveThreadId, createdAtMs);
842
+ }
843
+ this.safeSend(ws, { type: "thread_opened", thread: threadForClient, chatItems });
844
+ }
845
+ rememberInterruptId(threadId, clientMessageId) {
846
+ const now = Date.now();
847
+ let map = this.seenInterruptIdsByThread.get(threadId);
848
+ if (!map) {
849
+ map = new Map();
850
+ this.seenInterruptIdsByThread.set(threadId, map);
851
+ }
852
+ for (const [id, ts] of map.entries()) {
853
+ if (now - ts > this.interruptDedupTtlMs)
854
+ map.delete(id);
855
+ }
856
+ const isDup = map.has(clientMessageId);
857
+ map.set(clientMessageId, now);
858
+ if (map.size > this.interruptDedupMaxPerThread) {
859
+ const entries = Array.from(map.entries()).sort((a, b) => a[1] - b[1]);
860
+ const toDrop = entries.length - this.interruptDedupMaxPerThread;
861
+ for (let i = 0; i < toDrop; i += 1)
862
+ map.delete(entries[i][0]);
863
+ }
864
+ return isDup;
865
+ }
866
+ startStatusPump(intervalMs) {
867
+ if (this.statusTimer)
868
+ clearInterval(this.statusTimer);
869
+ this.statusTimer = setInterval(() => {
870
+ if (!this.readyClients.size)
871
+ return;
872
+ this.broadcastStatus();
873
+ }, intervalMs);
874
+ }
875
+ sendStatus(ws) {
876
+ const snapshot = sanitizeStatusSnapshotForWs(this.getStatusSnapshot());
877
+ this.safeSend(ws, { type: "status", snapshot });
878
+ }
879
+ broadcastStatus() {
880
+ const snapshot = sanitizeStatusSnapshotForWs(this.getStatusSnapshot());
881
+ const msg = { type: "status", snapshot };
882
+ const encoded = JSON.stringify(msg);
883
+ if (encoded === this.lastStatusJson)
884
+ return;
885
+ this.lastStatusJson = encoded;
886
+ for (const ws of this.readyClients) {
887
+ if (ws.readyState !== ws_1.default.OPEN)
888
+ continue;
889
+ ws.send(encoded);
890
+ }
891
+ }
892
+ broadcast(message) {
893
+ const encoded = JSON.stringify(message);
894
+ for (const ws of this.clients) {
895
+ if (ws.readyState !== ws_1.default.OPEN)
896
+ continue;
897
+ ws.send(encoded);
898
+ }
899
+ }
900
+ safeSend(ws, message) {
901
+ if (ws.readyState !== ws_1.default.OPEN)
902
+ return;
903
+ ws.send(JSON.stringify(message));
904
+ }
905
+ async handleCodexServerRequest(req) {
906
+ if (req.method === "item/tool/call") {
907
+ const params = req.params;
908
+ const tool = String(params?.tool ?? "unknown");
909
+ return {
910
+ success: false,
911
+ contentItems: [{ type: "inputText", text: `Dynamic tool calls are not supported yet: ${tool}` }],
912
+ };
913
+ }
914
+ const rawThreadId = extractThreadId(req.params);
915
+ const normalizedThreadId = typeof rawThreadId === "string" ? rawThreadId.trim() : "";
916
+ const threadId = normalizedThreadId ? normalizedThreadId : null;
917
+ const threadCwdRaw = threadId ? await this.getOrFetchThreadCwd(threadId).catch(() => null) : null;
918
+ const threadCwd = threadCwdRaw ? path_1.default.resolve(threadCwdRaw) : null;
919
+ // user_input_required / resolved 必须按“最具体工作区”路由,避免父工作区收到子工作区的审批/交互请求。
920
+ const routingSnapshot = threadCwd ? await this.getWorkspaceRoutingSnapshot().catch(() => null) : null;
921
+ const workspaceRoot = threadCwd && routingSnapshot ? (0, threadListVisibility_1.resolveWorkspaceRootForCwd)(routingSnapshot, threadCwd) : null;
922
+ const targetUsername = !threadCwd && threadId ? this.resolvePendingTargetUsername(threadId) : null;
923
+ // 为 fileChange approval 预解析变更列表,避免前端在主线程解析 raw params。
924
+ const fileChanges = req.method === "item/fileChange/requestApproval" ? (0, fileChangeExtractor_1.extractFileChangesFromAny)(req.params) : [];
925
+ const normalizedFileChanges = fileChanges.length ? fileChanges : null;
926
+ return new Promise((resolve, reject) => {
927
+ const timeout = setTimeout(() => {
928
+ this.pending.delete(req.id);
929
+ resolve(autoDeclineResult(req.method));
930
+ }, this.pendingTimeoutMs);
931
+ const pending = {
932
+ method: req.method,
933
+ params: req.params,
934
+ threadId,
935
+ threadCwd,
936
+ workspaceRoot,
937
+ targetUsername,
938
+ fileChanges: normalizedFileChanges,
939
+ sentTo: new WeakSet(),
940
+ resolve,
941
+ reject,
942
+ timeout,
943
+ };
944
+ this.pending.set(req.id, pending);
945
+ this.dispatchPendingToReadyClients(req.id, pending);
946
+ if (threadId && this.historyIngest) {
947
+ void this.historyIngest
948
+ .recordCodexEvent({
949
+ threadId,
950
+ method: "web/user_input_required",
951
+ params: {
952
+ threadId,
953
+ requestId: req.id,
954
+ method: req.method,
955
+ params: req.params,
956
+ },
957
+ })
958
+ .catch(() => {
959
+ // 历史落库失败不应影响用户输入请求下发。
960
+ });
961
+ }
962
+ });
963
+ }
964
+ resolvePendingTargetUsername(threadId) {
965
+ const ownerWs = this.threadOwners.get(threadId) ?? null;
966
+ if (ownerWs && ownerWs.readyState === ws_1.default.OPEN) {
967
+ const username = String(this.wsUser.get(ownerWs)?.username ?? "").trim();
968
+ if (username)
969
+ return username;
970
+ }
971
+ const cached = String(this.threadOwnerUsername.get(threadId)?.username ?? "").trim();
972
+ return cached || null;
973
+ }
974
+ setThreadOwner(threadId, ws, username) {
975
+ this.threadOwners.set(threadId, ws);
976
+ if (username) {
977
+ this.threadOwnerUsername.set(threadId, { username, ts: Date.now() });
978
+ this.pruneThreadOwnerUsernames();
979
+ }
980
+ }
981
+ pruneThreadOwnerUsernames() {
982
+ if (this.threadOwnerUsername.size <= this.threadOwnerUsernameMax)
983
+ return;
984
+ const entries = Array.from(this.threadOwnerUsername.entries()).sort((a, b) => a[1].ts - b[1].ts);
985
+ const toDrop = entries.length - this.threadOwnerUsernameMax;
986
+ for (let i = 0; i < toDrop; i += 1)
987
+ this.threadOwnerUsername.delete(entries[i][0]);
988
+ }
989
+ addUserClient(username, ws) {
990
+ if (!username)
991
+ return;
992
+ const set = this.wsByUser.get(username) ?? new Set();
993
+ set.add(ws);
994
+ this.wsByUser.set(username, set);
995
+ }
996
+ removeUserClient(username, ws) {
997
+ if (!username)
998
+ return;
999
+ const set = this.wsByUser.get(username);
1000
+ if (!set)
1001
+ return;
1002
+ set.delete(ws);
1003
+ if (!set.size)
1004
+ this.wsByUser.delete(username);
1005
+ }
1006
+ isUserAuthorizedForPending(user, pending) {
1007
+ if ((0, roles_1.isAdminRole)(user.role))
1008
+ return true;
1009
+ if (pending.workspaceRoot)
1010
+ return user.workspaces.includes(pending.workspaceRoot);
1011
+ if (pending.threadCwd)
1012
+ return this.isCwdVisibleToUser(pending.threadCwd, user, null);
1013
+ if (pending.targetUsername)
1014
+ return user.username === pending.targetUsername;
1015
+ return false;
1016
+ }
1017
+ flushPendingForWs(ws) {
1018
+ if (ws.readyState !== ws_1.default.OPEN)
1019
+ return;
1020
+ const user = this.wsUser.get(ws);
1021
+ if (!user)
1022
+ return;
1023
+ for (const [requestId, pending] of this.pending.entries()) {
1024
+ if (pending.sentTo.has(ws))
1025
+ continue;
1026
+ if (!this.isUserAuthorizedForPending(user, pending))
1027
+ continue;
1028
+ pending.sentTo.add(ws);
1029
+ this.safeSend(ws, {
1030
+ type: "user_input_required",
1031
+ requestId,
1032
+ threadId: pending.threadId,
1033
+ method: pending.method,
1034
+ params: pending.params,
1035
+ fileChanges: pending.fileChanges ?? undefined,
1036
+ });
1037
+ }
1038
+ }
1039
+ dispatchPendingToReadyClients(requestId, pending) {
1040
+ if (pending.threadCwd) {
1041
+ for (const ws of this.readyClients) {
1042
+ if (ws.readyState !== ws_1.default.OPEN)
1043
+ continue;
1044
+ if (pending.sentTo.has(ws))
1045
+ continue;
1046
+ const user = this.wsUser.get(ws);
1047
+ if (!user)
1048
+ continue;
1049
+ if (pending.workspaceRoot) {
1050
+ if (!(0, roles_1.isAdminRole)(user.role) && !user.workspaces.includes(pending.workspaceRoot))
1051
+ continue;
1052
+ }
1053
+ else {
1054
+ if (!this.isCwdVisibleToUser(pending.threadCwd, user, null))
1055
+ continue;
1056
+ }
1057
+ pending.sentTo.add(ws);
1058
+ this.safeSend(ws, {
1059
+ type: "user_input_required",
1060
+ requestId,
1061
+ threadId: pending.threadId,
1062
+ method: pending.method,
1063
+ params: pending.params,
1064
+ fileChanges: pending.fileChanges ?? undefined,
1065
+ });
1066
+ }
1067
+ return;
1068
+ }
1069
+ if (pending.targetUsername) {
1070
+ const set = this.wsByUser.get(pending.targetUsername);
1071
+ if (!set)
1072
+ return;
1073
+ for (const ws of set) {
1074
+ if (!this.readyClients.has(ws))
1075
+ continue;
1076
+ if (ws.readyState !== ws_1.default.OPEN)
1077
+ continue;
1078
+ if (pending.sentTo.has(ws))
1079
+ continue;
1080
+ pending.sentTo.add(ws);
1081
+ this.safeSend(ws, {
1082
+ type: "user_input_required",
1083
+ requestId,
1084
+ threadId: pending.threadId,
1085
+ method: pending.method,
1086
+ params: pending.params,
1087
+ fileChanges: pending.fileChanges ?? undefined,
1088
+ });
1089
+ }
1090
+ }
1091
+ }
1092
+ broadcastUserInputResolved(requestId, pending, resolved) {
1093
+ const msg = {
1094
+ type: "user_input_resolved",
1095
+ requestId,
1096
+ threadId: pending.threadId,
1097
+ method: pending.method,
1098
+ response: resolved?.response,
1099
+ mappedResponse: resolved?.mappedResponse,
1100
+ requestParams: resolved?.requestParams,
1101
+ };
1102
+ if (pending.threadCwd) {
1103
+ for (const ws of this.readyClients) {
1104
+ if (ws.readyState !== ws_1.default.OPEN)
1105
+ continue;
1106
+ const user = this.wsUser.get(ws);
1107
+ if (!user)
1108
+ continue;
1109
+ if (pending.workspaceRoot) {
1110
+ if (!(0, roles_1.isAdminRole)(user.role) && !user.workspaces.includes(pending.workspaceRoot))
1111
+ continue;
1112
+ }
1113
+ else {
1114
+ if (!this.isCwdVisibleToUser(pending.threadCwd, user, null))
1115
+ continue;
1116
+ }
1117
+ this.safeSend(ws, msg);
1118
+ }
1119
+ return;
1120
+ }
1121
+ if (pending.targetUsername) {
1122
+ const set = this.wsByUser.get(pending.targetUsername);
1123
+ if (!set)
1124
+ return;
1125
+ for (const ws of set) {
1126
+ if (!this.readyClients.has(ws))
1127
+ continue;
1128
+ if (ws.readyState !== ws_1.default.OPEN)
1129
+ continue;
1130
+ this.safeSend(ws, msg);
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+ exports.WsHub = WsHub;
1136
+ function getIsAlive(ws) {
1137
+ return Boolean(ws.isAlive);
1138
+ }
1139
+ function setIsAlive(ws, value) {
1140
+ ws.isAlive = value;
1141
+ }
1142
+ function extractThreadId(params) {
1143
+ const p = params;
1144
+ if (p && typeof p.threadId === "string")
1145
+ return p.threadId;
1146
+ if (p && typeof p.conversationId === "string")
1147
+ return p.conversationId;
1148
+ // Be liberal in what we accept: official app-server payloads sometimes use snake_case.
1149
+ if (p && typeof p.thread_id === "string")
1150
+ return p.thread_id;
1151
+ if (p && typeof p.conversation_id === "string")
1152
+ return p.conversation_id;
1153
+ if (p && typeof p.thread?.id === "string")
1154
+ return p.thread.id;
1155
+ if (p && typeof p.conversation?.id === "string")
1156
+ return p.conversation.id;
1157
+ return null;
1158
+ }
1159
+ /**
1160
+ * 从 thread turns 列表的元素中提取 turnId。
1161
+ * 这里接受多个字段名,避免不同 codex/app-server 版本字段差异导致回退中断失效。
1162
+ */
1163
+ function extractThreadTurnId(turn) {
1164
+ const t = turn;
1165
+ const raw = t?.id ?? t?.turnId ?? t?.turn_id ?? t?.turn?.id ?? null;
1166
+ if (raw === null || raw === undefined)
1167
+ return null;
1168
+ const normalizedTurnId = String(raw).trim();
1169
+ return normalizedTurnId || null;
1170
+ }
1171
+ function mapUserResponseToCodexResult(method, response) {
1172
+ const r = response;
1173
+ if (method === "item/commandExecution/requestApproval") {
1174
+ const d = r?.decision;
1175
+ if (d === "accept" || d === "acceptForSession" || d === "decline" || d === "cancel")
1176
+ return { decision: d };
1177
+ // Schema uses an object decision for execpolicy amendments:
1178
+ // { decision: { acceptWithExecpolicyAmendment: { execpolicy_amendment: string[] } } }
1179
+ if (d && typeof d === "object" && !Array.isArray(d)) {
1180
+ const inner = d.acceptWithExecpolicyAmendment;
1181
+ const amendment = inner?.execpolicy_amendment;
1182
+ if (inner && Array.isArray(amendment)) {
1183
+ return {
1184
+ decision: {
1185
+ acceptWithExecpolicyAmendment: { execpolicy_amendment: amendment.map(String) },
1186
+ },
1187
+ };
1188
+ }
1189
+ }
1190
+ // Be tolerant of older UI payloads.
1191
+ if (d === "acceptWithExecpolicyAmendment") {
1192
+ const amendment = Array.isArray(r?.execpolicy_amendment)
1193
+ ? r.execpolicy_amendment
1194
+ : Array.isArray(r?.execPolicyAmendment)
1195
+ ? r.execPolicyAmendment
1196
+ : null;
1197
+ if (amendment) {
1198
+ return {
1199
+ decision: { acceptWithExecpolicyAmendment: { execpolicy_amendment: amendment.map(String) } },
1200
+ };
1201
+ }
1202
+ }
1203
+ return { decision: "decline" };
1204
+ }
1205
+ if (method === "item/fileChange/requestApproval") {
1206
+ const decision = r?.decision;
1207
+ if (decision === "accept" || decision === "acceptForSession" || decision === "decline" || decision === "cancel") {
1208
+ return { decision };
1209
+ }
1210
+ return { decision: "decline" };
1211
+ }
1212
+ if (method === "item/tool/requestUserInput") {
1213
+ const answers = (r?.answers ?? {});
1214
+ const mapped = {};
1215
+ for (const [k, v] of Object.entries(answers)) {
1216
+ const arr = Array.isArray(v) ? v.map(String) : [String(v)];
1217
+ mapped[k] = { answers: arr };
1218
+ }
1219
+ return { answers: mapped };
1220
+ }
1221
+ if (method === "applyPatchApproval" || method === "execCommandApproval") {
1222
+ const d = r?.decision;
1223
+ if (d === "approved" || d === "approved_for_session" || d === "denied" || d === "abort")
1224
+ return { decision: d };
1225
+ if (d && typeof d === "object" && !Array.isArray(d)) {
1226
+ const inner = d.approved_execpolicy_amendment;
1227
+ const amendment = inner?.proposed_execpolicy_amendment;
1228
+ if (inner && Array.isArray(amendment)) {
1229
+ return { decision: { approved_execpolicy_amendment: { proposed_execpolicy_amendment: amendment.map(String) } } };
1230
+ }
1231
+ }
1232
+ // Tolerate older UIs that send a string and the amendment separately.
1233
+ if (d === "approved_execpolicy_amendment") {
1234
+ const amendment = Array.isArray(r?.proposed_execpolicy_amendment)
1235
+ ? r.proposed_execpolicy_amendment
1236
+ : Array.isArray(r?.proposedExecpolicyAmendment)
1237
+ ? r.proposedExecpolicyAmendment
1238
+ : null;
1239
+ if (amendment) {
1240
+ return { decision: { approved_execpolicy_amendment: { proposed_execpolicy_amendment: amendment.map(String) } } };
1241
+ }
1242
+ }
1243
+ return { decision: "denied" };
1244
+ }
1245
+ return {};
1246
+ }
1247
+ function autoDeclineResult(method) {
1248
+ if (method === "item/commandExecution/requestApproval" || method === "item/fileChange/requestApproval") {
1249
+ return { decision: "decline" };
1250
+ }
1251
+ if (method === "item/tool/requestUserInput") {
1252
+ return { answers: {} };
1253
+ }
1254
+ if (method === "applyPatchApproval" || method === "execCommandApproval") {
1255
+ return { decision: "denied" };
1256
+ }
1257
+ return {};
1258
+ }
1259
+ //# sourceMappingURL=wsHub.js.map