@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +5 -5
  2. package/dist/bin/self-relay-server.mjs +5 -2
  3. package/dist/shared/app-server-events.cjs +45 -19
  4. package/dist/shared/p2p-data-channel.cjs +50 -0
  5. package/dist/src/app-server-tasks.mjs +28 -25
  6. package/dist/src/lan-socket-server.mjs +8 -2
  7. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  8. package/dist/src/platform/windows/background-process.mjs +7 -2
  9. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  10. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  11. package/dist/src/self-relay/admin-state.mjs +61 -0
  12. package/dist/src/self-relay/client.mjs +2 -0
  13. package/dist/src/self-relay/connector.mjs +23 -8
  14. package/dist/src/self-relay/demo.mjs +2 -2
  15. package/dist/src/self-relay/lifecycle.mjs +1 -1
  16. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  17. package/dist/src/self-relay/server.mjs +137 -11
  18. package/dist/src/server.mjs +337 -255
  19. package/dist/src/shared-app-server.mjs +180 -24
  20. package/dist/src/shared-catalog.mjs +4 -14
  21. package/dist/src/thread-catalog.mjs +12 -7
  22. package/dist/web/activity-view.js +283 -0
  23. package/dist/web/capabilities.js +2 -2
  24. package/dist/web/chat-transport.js +15 -9
  25. package/dist/web/chat.css +139 -93
  26. package/dist/web/chat.js +1451 -2770
  27. package/dist/web/community-view.js +20 -0
  28. package/dist/web/community.css +77 -0
  29. package/dist/web/community.html +37 -0
  30. package/dist/web/composer-controller.js +101 -0
  31. package/dist/web/conversation-controller.js +99 -0
  32. package/dist/web/disclosure-state-controller.js +95 -0
  33. package/dist/web/draft-controller.js +103 -0
  34. package/dist/web/harmony-platform.js +3 -2
  35. package/dist/web/history-cache.js +112 -18
  36. package/dist/web/history-controller.js +167 -0
  37. package/dist/web/index.html +141 -38
  38. package/dist/web/link-action-controller.js +212 -0
  39. package/dist/web/message-send-controller.js +177 -0
  40. package/dist/web/message-view.js +98 -0
  41. package/dist/web/p2p-data-channel.js +50 -0
  42. package/dist/web/p2p-probe.js +67 -27
  43. package/dist/web/page-resume.js +28 -0
  44. package/dist/web/pending-message-store.js +108 -0
  45. package/dist/web/queue-controller.js +82 -0
  46. package/dist/web/resources.json +1 -1
  47. package/dist/web/self-relay-session.js +28 -18
  48. package/dist/web/station-connection-controller.js +75 -0
  49. package/dist/web/task-list-view.js +296 -0
  50. package/dist/web/thread-attention-controller.js +124 -0
  51. package/dist/web/thread-context-controller.js +30 -0
  52. package/dist/web/thread-list-controller.js +61 -0
  53. package/dist/web/thread-list-sync.js +86 -0
  54. package/dist/web/thread-title-controller.js +57 -0
  55. package/dist/web/timeline-formatters.js +249 -0
  56. package/dist/web/timeline-reducer.js +81 -0
  57. package/dist/web/timeline-renderer.js +161 -0
  58. package/dist/web/timeline-scroll-controller.js +50 -0
  59. package/dist/web/usage-controller.js +258 -0
  60. package/dist/web/vendor/lucide.LICENSE.txt +17 -0
  61. package/package.json +1 -1
  62. package/tools/postinstall.mjs +111 -2
  63. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
package/README.md CHANGED
@@ -4,13 +4,13 @@ Bridge 让 HarmonyOS 客户端控制 Windows 上的 Codex Desktop。当前要求
4
4
 
5
5
  ## 安装
6
6
 
7
- 计划发布到 npm 后在线安装;当前命令为发布占位,尚不能从 registry 执行:
7
+ 公开 beta 已发布到 npm,安装或更新使用:
8
8
 
9
9
  ```powershell
10
- npm.cmd install -g @walkhi/code-relax@beta
10
+ npm i -g @walkhi/code-relax@beta --allow-scripts=@walkhi/code-relax
11
11
  ```
12
12
 
13
- 首次安装或更新前先保存工作,并从 Codex 菜单或 Windows 托盘完全退出 Codexnpm 全局安装会自动执行 `setup` 和 Skill 安装。`setup` 将完整服务部署到 `%LOCALAPPDATA%\Programs\CodexRemote\service`,记录安装所用 Node 的绝对路径,不复制 Node。配置、日志和二维码以 `installation.json.stateRoot` 为准,通常位于 `%LOCALAPPDATA%\CodexRemote`,MSIX 环境可能解析到包缓存目录;附件位于 `%ProgramData%\CodexRemote\attachments`。
13
+ 首次安装前先保存工作,并从 Codex 菜单或 Windows 托盘完全退出 Codex。更新无需先停止正在运行的 Bridge;安装器会短暂停止、替换并恢复服务。npm 全局安装会自动执行 `setup` 和 Skill 安装。`setup` 将完整服务部署到 `%LOCALAPPDATA%\Programs\CodexRemote\service`,记录安装所用 Node 的绝对路径,不复制 Node。配置、日志和二维码以 `installation.json.stateRoot` 为准,通常位于 `%LOCALAPPDATA%\CodexRemote`,MSIX 环境可能解析到包缓存目录;附件位于 `%ProgramData%\CodexRemote\attachments`。
14
14
 
15
15
  `setup` 立即启动 Bridge 和固定 45839 受管 app-server,并启用当前用户登录自启;自启不打开 Codex。受管实例健康后配置当前用户 `CODEX_APP_SERVER_WS_URL`,之后正常启动的 Desktop 自动接入。若安装时 Desktop 仍在运行,不中断现有任务,允许两个 app-server 暂时共存,并在手机提示立即或稍后重启。
16
16
 
@@ -64,7 +64,7 @@ relax dev-stop --json
64
64
  更新固定服务:
65
65
 
66
66
  ```powershell
67
- npm.cmd install -g @walkhi/code-relax@beta
67
+ npm i -g @walkhi/code-relax@beta --allow-scripts=@walkhi/code-relax
68
68
  ```
69
69
 
70
70
  `setup` 先暂存并校验新版本,再短暂停止 Bridge/中继完成切换;原服务此前正在运行时会自动恢复,新版本启动失败则回滚并启动旧版本。无需提前执行 `stop`。Node 路径变化或原 Node 被删除后需重新 setup。升级可能因 Node 可执行文件路径变化而再次触发 Windows 防火墙授权。
@@ -80,4 +80,4 @@ npm uninstall -g @walkhi/code-relax
80
80
 
81
81
  ## 发布边界
82
82
 
83
- 包名为 `@walkhi/code-relax@0.1.0-beta.1`,主 CLI 为 `relax`,并保留 `code-relax` 兼容入口。首次公开 beta 发布前仍需确认 npm scope 所有权、第三方许可、新电脑首次安装、防火墙、升级/卸载和移动端到端验收。详细规则见 [npm 分发](../docs/npm-distribution.md)。
83
+ registry 当前公开包为 `@walkhi/code-relax@0.1.0-beta.2`,主 CLI 为 `relax`,并保留 `code-relax` 兼容入口。待发布的 `0.1.0-beta.3` WMI 后的第二层 PowerShell broker 改为 Node worker,并加入 `%LOCALAPPDATA%\CodexRemote\install.log` 持久安装日志;本机启动交接和 npm 打包已验证,发布后仍需在原故障电脑复核。详细规则见 [npm 分发](../docs/npm-distribution.md)。
@@ -12,6 +12,9 @@ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Inv
12
12
  const stateFile = process.env.RELAY_STATE_FILE;
13
13
  if (!stateFile) throw new Error('RELAY_STATE_FILE is required');
14
14
  const debugAllowlistFile = process.env.RELAY_DEBUG_ALLOWLIST_FILE || '/etc/codex-relay/debug-allowlist.json';
15
+ const adminStateFile = process.env.RELAY_ADMIN_STATE_FILE || '/var/lib/codex-relay/admin.json';
16
+ const maxRelaySessions = Number(process.env.RELAY_MAX_RELAY_SESSIONS || 8);
17
+ if (!Number.isSafeInteger(maxRelaySessions) || maxRelaySessions < 1 || maxRelaySessions > 256) throw new Error('Invalid RELAY_MAX_RELAY_SESSIONS');
15
18
  const debugCommand = process.argv.find(value => ['--debug-list', '--debug-allow', '--debug-deny'].includes(value));
16
19
  if (debugCommand) {
17
20
  const records = deviceStore(stateFile).entries();
@@ -41,10 +44,10 @@ if (revokeIndex >= 0) {
41
44
  store.save({ ...record, status: 'revoked', clientHash: undefined, clientId: undefined, revision: record.revision + 1, updatedAt: new Date().toISOString() });
42
45
  console.log('Computer and phone authorization revoked'); process.exit(0);
43
46
  }
44
- const relay = await startSelfRelay({ port, hostKey, stateFile, debugAllowlistFile,
47
+ const relay = await startSelfRelay({ port, hostKey, stateFile, debugAllowlistFile, adminStateFile, maxRelaySessions,
45
48
  publicRelayUrl: process.env.RELAY_PUBLIC_URL || '', appDownloadUrl: process.env.RELAY_APP_DOWNLOAD_URL || '',
46
49
  log: entry => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })) });
47
- console.log(JSON.stringify({ service: 'codex-self-relay', listen: relay.url, maxConnections: 32,
50
+ console.log(JSON.stringify({ service: 'codex-self-relay', listen: relay.url, maxConnections: 32, maxRelaySessions,
48
51
  maxFrameMiB: 8, encryption: 'TLS must be supplied by reverse proxy; no end-to-end encryption' }));
49
52
  await new Promise(resolve => { process.once('SIGTERM', resolve); process.once('SIGINT', resolve); });
50
53
  await relay.close();
@@ -2,31 +2,58 @@
2
2
  if (typeof module === 'object' && module.exports) module.exports = factory();
3
3
  else root.CodexAppServerEvents = factory();
4
4
  })(globalThis, function () {
5
- function mergeItems(existing, incoming = []) {
6
- for (const item of incoming) {
7
- const index = existing.findIndex(current => current.id === item.id);
8
- if (index < 0) existing.push(item);
9
- else existing[index] = { ...existing[index], ...item };
5
+ function mergeItems(existing, incoming = []) {
6
+ for (const source of incoming) {
7
+ const item = compactItem(source);
8
+ const index = existing.findIndex(current => current.id === item.id);
9
+ if (index < 0) existing.push(item);
10
+ else existing[index] = { ...existing[index], ...item };
10
11
  }
11
- return existing;
12
- }
12
+ return existing;
13
+ }
14
+
15
+ function compactItem(item) {
16
+ if (!item || typeof item !== 'object') return item;
17
+ if (item.type === 'commandExecution') {
18
+ const { aggregatedOutput, output, ...metadata } = item;
19
+ return metadata;
20
+ }
21
+ if (item.type === 'mcpToolCall') {
22
+ const { result, ...metadata } = item;
23
+ return metadata;
24
+ }
25
+ if (item.type === 'dynamicToolCall') {
26
+ const { contentItems, ...metadata } = item;
27
+ return metadata;
28
+ }
29
+ if (item.type === 'imageGeneration') {
30
+ const { result, ...metadata } = item;
31
+ return metadata;
32
+ }
33
+ return item;
34
+ }
35
+
36
+ function compactTurn(turn) {
37
+ return { ...turn, items: Array.isArray(turn?.items) ? turn.items.map(compactItem) : [] };
38
+ }
13
39
 
14
40
  // Both relay and local WebSocket carry these same app-server notifications.
15
41
  // Mutate one thread and return only the affected turn for incremental rendering.
16
42
  function applyNotification(thread, method, p) {
17
43
  if (method === 'thread/status/changed') thread.status = p.status;
18
44
  else if (method === 'thread/name/updated') thread.name = p.threadName;
19
- else if (!['turn/started', 'turn/completed', 'item/started', 'item/completed', 'item/agentMessage/delta',
20
- 'item/reasoning/summaryTextDelta', 'item/reasoning/summaryPartAdded', 'item/commandExecution/outputDelta'].includes(method)) return null;
45
+ else if (!['turn/started', 'turn/completed', 'item/started', 'item/completed', 'item/agentMessage/delta',
46
+ 'item/reasoning/summaryTextDelta', 'item/reasoning/summaryPartAdded'].includes(method)) return null;
21
47
  thread.turns ||= [];
22
48
  let turn = thread.turns.find(t => t.id === (p.turnId || p.turn?.id));
23
- if (!turn && p.turnId && p.item) { turn = { id: p.turnId, status: 'inProgress', items: [] }; thread.turns.push(turn); }
24
- if (p.turn) {
25
- if (!turn) { turn = { ...p.turn, items: p.turn.items || [] }; thread.turns.push(turn); }
26
- else Object.assign(turn, { ...p.turn, items: mergeItems(turn.items || [], p.turn.items) });
27
- thread.status = method === 'turn/started' ? 'active' : method === 'turn/completed' ? 'idle' : thread.status;
28
- }
29
- if (p.item && turn) mergeItems(turn.items, [p.item]);
49
+ if (!turn && p.turnId && p.item) { turn = { id: p.turnId, status: 'inProgress', items: [] }; thread.turns.push(turn); }
50
+ if (p.turn) {
51
+ const incomingTurn = compactTurn(p.turn);
52
+ if (!turn) { turn = incomingTurn; thread.turns.push(turn); }
53
+ else Object.assign(turn, { ...incomingTurn, items: mergeItems(turn.items || [], incomingTurn.items) });
54
+ thread.status = method === 'turn/started' ? 'active' : method === 'turn/completed' ? 'idle' : thread.status;
55
+ }
56
+ if (p.item && turn) mergeItems(turn.items, [compactItem(p.item)]);
30
57
  const item = turn?.items.find(item => item.id === p.itemId);
31
58
  if (item) {
32
59
  if (method === 'item/agentMessage/delta') item.text = (item.text || '') + p.delta;
@@ -34,8 +61,7 @@
34
61
  item.summary ||= []; item.summary[p.summaryIndex] = (item.summary[p.summaryIndex] || '') + p.delta;
35
62
  }
36
63
  if (method === 'item/reasoning/summaryPartAdded') { item.summary ||= []; item.summary[p.summaryIndex] ??= ''; }
37
- if (method === 'item/commandExecution/outputDelta') item.aggregatedOutput = (item.aggregatedOutput || '') + p.delta;
38
- }
64
+ }
39
65
  return turn || (['thread/status/changed', 'thread/name/updated'].includes(method) ? thread.turns.at(-1) : null);
40
66
  }
41
67
  function mergeAvailable(previous, incoming) {
@@ -261,6 +287,6 @@
261
287
  }
262
288
  return readQueue(request, threadId);
263
289
  }
264
- return { createThread, readQueue, addQueue, changeQueue, mergeItems, applyNotification, mergeUsage, UsageCache, readProjects, orderDesktopProjects, threadProjectId, projectlessThreadTarget, updateSettings,
290
+ return { createThread, readQueue, addQueue, changeQueue, compactItem, compactTurn, mergeItems, applyNotification, mergeUsage, UsageCache, readProjects, orderDesktopProjects, threadProjectId, projectlessThreadTarget, updateSettings,
265
291
  permissionState, availablePermissionModes, PermissionOptionsCache, updatePermissions };
266
292
  });
@@ -0,0 +1,50 @@
1
+ (function (root, factory) {
2
+ if (typeof module === 'object' && module.exports) module.exports = factory();
3
+ else root.CodeRelaxP2pDataFramer = factory();
4
+ })(globalThis, () => {
5
+ const CHUNK_CHARS = 12000;
6
+ const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
7
+ const MAX_PARTS = 1024;
8
+ const byteLength = value => new TextEncoder().encode(value).length;
9
+
10
+ return class {
11
+ constructor() { this.inbound = undefined; }
12
+ create(payload, id) {
13
+ if (typeof payload !== 'string' || !/^[a-f0-9]{32}-[0-9]{1,10}$/.test(id)) throw new Error('Invalid P2P payload');
14
+ const bytes = byteLength(payload);
15
+ if (bytes > MAX_MESSAGE_BYTES) throw new Error('Code Relax 直连消息超过 8 MiB');
16
+ const total = Math.max(1, Math.ceil(payload.length / CHUNK_CHARS));
17
+ if (total > MAX_PARTS) throw new Error('Code Relax 直连消息分片过多');
18
+ return { id, payload, bytes, index: 0, total };
19
+ }
20
+ frame(item) {
21
+ if (!item || item.index >= item.total) return undefined;
22
+ const start = item.index * CHUNK_CHARS;
23
+ return JSON.stringify({ type: 'data', id: item.id, index: item.index, total: item.total,
24
+ payload: item.payload.slice(start, start + CHUNK_CHARS) });
25
+ }
26
+ commit(item) { item.index++; return item.index >= item.total; }
27
+ accept(message) {
28
+ if (!message || message.type !== 'data') return { handled: false };
29
+ if (!/^[a-f0-9]{32}-[0-9]{1,10}$/.test(message.id || '') || !Number.isSafeInteger(message.index) ||
30
+ !Number.isSafeInteger(message.total) || message.total < 1 || message.total > MAX_PARTS ||
31
+ message.index < 0 || message.index >= message.total || typeof message.payload !== 'string' ||
32
+ message.payload.length > CHUNK_CHARS) throw new Error('Invalid P2P data frame');
33
+ if (!this.inbound) {
34
+ if (message.index !== 0) throw new Error('Invalid P2P data order');
35
+ this.inbound = { id: message.id, total: message.total, next: 0, bytes: 0, parts: [] };
36
+ }
37
+ const current = this.inbound;
38
+ if (current.id !== message.id || current.total !== message.total || current.next !== message.index) {
39
+ throw new Error('Invalid P2P data order');
40
+ }
41
+ current.bytes += byteLength(message.payload);
42
+ if (current.bytes > MAX_MESSAGE_BYTES) throw new Error('P2P data message too large');
43
+ current.parts.push(message.payload); current.next++;
44
+ if (current.next !== current.total) return { handled: true, complete: false };
45
+ this.inbound = undefined;
46
+ return { handled: true, complete: true, payload: current.parts.join('') };
47
+ }
48
+ reset() { this.inbound = undefined; }
49
+ };
50
+ });
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { AppServerRpcError } from './app-server-client.mjs';
5
+ import events from '../shared/app-server-events.cjs';
6
6
 
7
7
  const STORE_SCHEMA_VERSION = 1;
8
8
 
@@ -45,7 +45,7 @@ export class AppServerTaskStore {
45
45
  }
46
46
 
47
47
  list() {
48
- return [...this.records.values()].sort((left, right) => Number(right.updatedAt) - Number(left.updatedAt));
48
+ return [...this.records.values()].sort((left, right) => taskRecency(right) - taskRecency(left));
49
49
  }
50
50
 
51
51
  upsert(task) {
@@ -126,6 +126,7 @@ export class AppServerTaskTransport extends EventEmitter {
126
126
  projectId: projectId || null,
127
127
  createdAt: Number(started.thread.createdAt) || now,
128
128
  updatedAt: Number(started.thread.updatedAt) || now,
129
+ recencyAt: Number(started.thread.recencyAt) || Number(started.thread.updatedAt) || now,
129
130
  status: threadStatus(started.thread.status),
130
131
  activeFlags: threadActiveFlags(started.thread.status),
131
132
  model: model || started.model || started.thread.model || '',
@@ -142,7 +143,7 @@ export class AppServerTaskTransport extends EventEmitter {
142
143
  if (model) turnParams.model = model;
143
144
  if (effort) turnParams.effort = effort;
144
145
  const turn = await this.client.request('turn/start', turnParams, 60_000);
145
- this.store.upsert({ ...record, status: 'active', activeFlags: [], updatedAt: Math.floor(Date.now() / 1000) });
146
+ this.store.upsert({ ...record, status: 'active', activeFlags: [] });
146
147
  return { thread: started.thread, turn: turn.turn, record: this.store.get(record.id) };
147
148
  }
148
149
 
@@ -163,6 +164,7 @@ export class AppServerTaskTransport extends EventEmitter {
163
164
  title: thread.name || record.title,
164
165
  cwd: thread.cwd || record.cwd,
165
166
  updatedAt: Number(thread.updatedAt) || record.updatedAt,
167
+ recencyAt: Number(thread.recencyAt) || record.recencyAt || record.updatedAt,
166
168
  status: threadStatus(thread.status),
167
169
  activeFlags: threadActiveFlags(thread.status),
168
170
  model: thread.model || record.model,
@@ -179,24 +181,17 @@ export class AppServerTaskTransport extends EventEmitter {
179
181
  async readTask(threadId, { includeTurns = true, turnLimit = 10 } = {}) {
180
182
  this.record(threadId);
181
183
  await this.client.start();
182
- try {
183
- const result = await this.readInitializedThread({ threadId, includeTurns });
184
- if (includeTurns && Array.isArray(result.thread?.turns)) {
185
- result.thread.turns = result.thread.turns.slice(-turnLimit);
186
- }
187
- return result.thread;
188
- } catch (error) {
189
- if (!includeTurns || !(error instanceof AppServerRpcError) || error.code !== -32601) throw error;
190
- const [threadResult, turnsResult] = await Promise.all([
191
- this.readInitializedThread({ threadId, includeTurns: false }),
192
- this.client.request('thread/turns/list', {
193
- threadId,
194
- limit: turnLimit,
195
- sortDirection: 'desc',
196
- }, 30_000),
197
- ]);
198
- return { ...threadResult.thread, turns: [...(turnsResult.data || [])].reverse() };
199
- }
184
+ if (!includeTurns) return (await this.readInitializedThread({ threadId, includeTurns: false })).thread;
185
+ const [threadResult, turnsResult] = await Promise.all([
186
+ this.readInitializedThread({ threadId, includeTurns: false }),
187
+ this.client.request('thread/turns/list', {
188
+ threadId,
189
+ limit: turnLimit,
190
+ sortDirection: 'desc',
191
+ itemsView: 'full',
192
+ }, 30_000),
193
+ ]);
194
+ return { ...threadResult.thread, turns: [...(turnsResult.data || [])].reverse().map(events.compactTurn) };
200
195
  }
201
196
 
202
197
  async readInitializedThread(params) {
@@ -225,7 +220,6 @@ export class AppServerTaskTransport extends EventEmitter {
225
220
  ...record,
226
221
  status: 'active',
227
222
  activeFlags: [],
228
- updatedAt: Math.floor(Date.now() / 1000),
229
223
  model: model || record.model,
230
224
  effort: effort || record.effort,
231
225
  });
@@ -245,11 +239,13 @@ export class AppServerTaskTransport extends EventEmitter {
245
239
  throw error;
246
240
  }
247
241
  if (result.thread.id !== thread.id) throw new Error('恢复后的聊天 ID 不一致。');
248
- const now = Math.floor(Date.now() / 1000);
249
242
  const record = this.store.upsert({
250
243
  id: thread.id, title: thread.title || result.thread.name || thread.id,
251
244
  cwd: result.thread.cwd, projectId: thread.projectId || null,
252
- createdAt: result.thread.createdAt || now, updatedAt: now,
245
+ createdAt: Number(result.thread.createdAt) || Number(thread.createdAt) || 0,
246
+ updatedAt: Number(result.thread.updatedAt) || Number(thread.updatedAt) || 0,
247
+ recencyAt: Number(result.thread.recencyAt) || Number(thread.recencyAt)
248
+ || Number(result.thread.updatedAt) || Number(thread.updatedAt) || 0,
253
249
  status: threadStatus(result.thread.status), activeFlags: threadActiveFlags(result.thread.status),
254
250
  origin: 'desktop', transport: 'app-server',
255
251
  });
@@ -288,7 +284,7 @@ export class AppServerTaskTransport extends EventEmitter {
288
284
  if (!threadId || !this.owns(threadId)) return;
289
285
  const current = this.store.get(threadId);
290
286
  if (current && ['thread/status/changed', 'turn/started', 'turn/completed', 'thread/name/updated'].includes(record.method)) {
291
- const next = { ...current, updatedAt: Math.floor(Date.now() / 1000) };
287
+ const next = { ...current };
292
288
  if (record.method === 'thread/status/changed') {
293
289
  next.status = threadStatus(record.params?.status);
294
290
  next.activeFlags = threadActiveFlags(record.params?.status);
@@ -297,6 +293,7 @@ export class AppServerTaskTransport extends EventEmitter {
297
293
  if (record.method === 'turn/completed') { next.status = 'idle'; next.activeFlags = []; }
298
294
  if (record.method === 'thread/name/updated' && record.params?.threadName) next.title = record.params.threadName;
299
295
  this.store.upsert(next);
296
+ this.hydratedGeneration = 0;
300
297
  }
301
298
  this.emit('thread-event', { threadId, record });
302
299
  }
@@ -311,6 +308,7 @@ export function summaryFromRecord(record, status = record.status || 'unknown') {
311
308
  cwd: record.cwd,
312
309
  projectId: record.projectId,
313
310
  updatedAt: record.updatedAt,
311
+ recencyAt: record.recencyAt ?? record.updatedAt,
314
312
  status,
315
313
  activeFlags: Array.isArray(record.activeFlags) ? record.activeFlags : [],
316
314
  transport: 'app-server',
@@ -324,6 +322,7 @@ function summaryFromThread(thread, record) {
324
322
  title: thread.name || record.title || thread.preview || thread.id,
325
323
  cwd: thread.cwd || record.cwd,
326
324
  updatedAt: Number(thread.updatedAt) || record.updatedAt,
325
+ recencyAt: Number(thread.recencyAt) || record.recencyAt || record.updatedAt,
327
326
  };
328
327
  }
329
328
 
@@ -333,6 +332,10 @@ function validTaskRecord(task) {
333
332
  && Number.isFinite(Number(task.createdAt)) && Number.isFinite(Number(task.updatedAt));
334
333
  }
335
334
 
335
+ function taskRecency(task) {
336
+ return Number(task?.recencyAt ?? task?.updatedAt) || 0;
337
+ }
338
+
336
339
  function delay(milliseconds) {
337
340
  return new Promise(resolve => setTimeout(resolve, milliseconds));
338
341
  }
@@ -13,8 +13,14 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
13
13
  let allowed = request.url === '/api/socket';
14
14
  // Packaged ArkWeb pages have an opaque ("null") origin; device authentication remains required.
15
15
  if (origin && origin !== 'null') {
16
- try { allowed &&= new URL(origin).host === request.headers.host && ['http:', 'https:'].includes(new URL(origin).protocol); }
17
- catch { allowed = false; }
16
+ try {
17
+ const originUrl = new URL(origin);
18
+ const requestUrl = new URL(`http://${request.headers.host}`);
19
+ const nativeClient = request.headers['x-code-relax-native'] === '1';
20
+ allowed &&= ['http:', 'https:'].includes(originUrl.protocol) &&
21
+ (originUrl.host === request.headers.host || (nativeClient && originUrl.hostname === requestUrl.hostname));
22
+ }
23
+ catch { allowed = false; }
18
24
  }
19
25
  if (!allowed) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
20
26
  wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
@@ -48,8 +48,8 @@ public static class IsolatedProcess {
48
48
  if(handle==new IntPtr(-1)) throw new Win32Exception(Marshal.GetLastWin32Error());
49
49
  return handle;
50
50
  }
51
- public static int Start(string executable,string[] arguments,string stdout,string stderr) {
52
- IntPtr input=IntPtr.Zero,output=IntPtr.Zero,error=IntPtr.Zero,list=IntPtr.Zero,handles=IntPtr.Zero;
51
+ public static int Start(string executable,string[] arguments,string stdout,string stderr,string[] environment) {
52
+ IntPtr input=IntPtr.Zero,output=IntPtr.Zero,error=IntPtr.Zero,list=IntPtr.Zero,handles=IntPtr.Zero,environmentBlock=IntPtr.Zero;
53
53
  bool initialized=false;
54
54
  try {
55
55
  input=File("NUL",true); output=File(String.IsNullOrEmpty(stdout)?"NUL":stdout,false); error=File(String.IsNullOrEmpty(stderr)?"NUL":stderr,false);
@@ -63,12 +63,16 @@ public static class IsolatedProcess {
63
63
  startup.startup.flags=0x101; startup.startup.show=0;
64
64
  startup.startup.input=input; startup.startup.output=output; startup.startup.error=error; startup.attributes=list;
65
65
  var command=new StringBuilder(Quote(executable)); foreach(var argument in arguments) command.Append(' ').Append(Quote(argument));
66
+ Array.Sort(environment,StringComparer.OrdinalIgnoreCase);
67
+ var environmentBytes=Encoding.Unicode.GetBytes(String.Join("\0",environment)+"\0\0");
68
+ environmentBlock=Marshal.AllocHGlobal(environmentBytes.Length); Marshal.Copy(environmentBytes,0,environmentBlock,environmentBytes.Length);
66
69
  ProcessInfo info;
67
- if(!CreateProcessW(executable,command,IntPtr.Zero,IntPtr.Zero,true,0x80010,IntPtr.Zero,null,ref startup,out info)) throw new Win32Exception(Marshal.GetLastWin32Error());
70
+ if(!CreateProcessW(executable,command,IntPtr.Zero,IntPtr.Zero,true,0x80410,environmentBlock,null,ref startup,out info)) throw new Win32Exception(Marshal.GetLastWin32Error());
68
71
  CloseHandle(info.thread); CloseHandle(info.process); return info.pid;
69
72
  } finally {
70
73
  if(initialized) DeleteProcThreadAttributeList(list);
71
74
  if(list!=IntPtr.Zero) Marshal.FreeHGlobal(list); if(handles!=IntPtr.Zero) Marshal.FreeHGlobal(handles);
75
+ if(environmentBlock!=IntPtr.Zero) Marshal.FreeHGlobal(environmentBlock);
72
76
  if(input!=IntPtr.Zero) CloseHandle(input); if(output!=IntPtr.Zero) CloseHandle(output); if(error!=IntPtr.Zero) CloseHandle(error);
73
77
  }
74
78
  }
@@ -5,9 +5,14 @@ import path from 'node:path';
5
5
  export function startWindowsProcess(executable, args, environment, logs = []) {
6
6
  return new Promise((resolve, reject) => {
7
7
  const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
8
+ const helperEnvironment = {};
9
+ for (const name of ['SystemRoot', 'windir', 'LOCALAPPDATA', 'USERPROFILE', 'TEMP', 'TMP', 'PSModulePath']) {
10
+ const key = Object.keys(process.env).find(item => item.toLowerCase() === name.toLowerCase());
11
+ if (key) helperEnvironment[key] = process.env[key];
12
+ }
8
13
  const helper = spawn(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
9
14
  fileURLToPath(new URL('./start-hidden-console.ps1', import.meta.url))], {
10
- windowsHide: true, env: environment, stdio: ['pipe', 'pipe', 'pipe'],
15
+ windowsHide: true, env: helperEnvironment, stdio: ['pipe', 'pipe', 'pipe'],
11
16
  });
12
17
  let output = '', error = '';
13
18
  helper.stdout.setEncoding('utf8').on('data', chunk => { output += chunk; });
@@ -20,6 +25,6 @@ export function startWindowsProcess(executable, args, environment, logs = []) {
20
25
  if (!Number.isSafeInteger(pid) || pid <= 0) return reject(new Error('后台启动未返回有效 PID'));
21
26
  resolve(pid);
22
27
  });
23
- helper.stdin.end(JSON.stringify({ executable, args, logs }));
28
+ helper.stdin.end(JSON.stringify({ brokerExecutable: process.execPath, executable, args, logs, environment }));
24
29
  });
25
30
  }
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+
4
+ const [requestPath, readyPath, instance, diagnosticPath] = process.argv.slice(2);
5
+ let result = { instance, brokerPid: process.pid };
6
+
7
+ function diagnostic(message) {
8
+ if (!diagnosticPath) return;
9
+ fs.appendFileSync(diagnosticPath, `${new Date().toISOString()} ${message}\n`, 'utf8');
10
+ }
11
+
12
+ try {
13
+ const spec = JSON.parse(fs.readFileSync(requestPath, 'utf8').replace(/^\uFEFF/u, ''));
14
+ diagnostic('request loaded');
15
+ if (spec.instance !== instance) throw new Error('Windows process broker request identity mismatch.');
16
+ fs.rmSync(requestPath, { force: true });
17
+ const descriptors = (spec.logs || []).map(file => fs.openSync(file, 'a'));
18
+ try {
19
+ const child = spawn(spec.executable, spec.args || [], {
20
+ detached: true,
21
+ windowsHide: true,
22
+ env: spec.environment,
23
+ stdio: ['ignore', descriptors[0] ?? 'ignore', descriptors[1] ?? 'ignore'],
24
+ });
25
+ await new Promise((resolve, reject) => {
26
+ child.once('spawn', resolve);
27
+ child.once('error', reject);
28
+ });
29
+ child.unref();
30
+ result.pid = child.pid;
31
+ diagnostic(`target started (PID ${child.pid})`);
32
+ } finally {
33
+ descriptors.forEach(descriptor => fs.closeSync(descriptor));
34
+ }
35
+ } catch (error) {
36
+ result.error = error?.stack || error?.message || String(error);
37
+ try { diagnostic(`failed: ${result.error}`); } catch {}
38
+ }
39
+
40
+ const temporary = `${readyPath}.tmp`;
41
+ fs.writeFileSync(temporary, JSON.stringify(result), 'utf8');
42
+ fs.renameSync(temporary, readyPath);
@@ -10,30 +10,40 @@ try {
10
10
  Add-Type -Path (Join-Path $PSScriptRoot 'IsolatedProcess.cs')
11
11
  $launchRoot = [IsolatedProcess]::ResolvePath($launchRoot)
12
12
  $ready = Join-Path $launchRoot ($instance + '.json')
13
+ $request = Join-Path $launchRoot ($instance + '.request.json')
14
+ $diagnostic = Join-Path $launchRoot ($instance + '.broker.log')
13
15
  $spec | Add-Member -NotePropertyName instance -NotePropertyValue $instance
14
- $spec | Add-Member -NotePropertyName ready -NotePropertyValue $ready
15
- $environment = @(Get-ChildItem Env: | Where-Object { $_.Name -ne 'CODEX_REMOTE_PROCESS_SPEC' } | ForEach-Object { $_.Name + '=' + $_.Value })
16
- $environment += 'CODEX_REMOTE_PROCESS_SPEC=' + ($spec | ConvertTo-Json -Compress -Depth 10)
16
+ $spec | Add-Member -NotePropertyName diagnostic -NotePropertyValue $diagnostic
17
+ $requestTemporary = $request + '.tmp'
18
+ [IO.File]::WriteAllText($requestTemporary, ($spec | ConvertTo-Json -Compress -Depth 10), (New-Object Text.UTF8Encoding($false)))
19
+ [IO.File]::Move($requestTemporary, $request)
17
20
  $startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{
18
- ShowWindow=[uint16]0; CreateFlags=[uint32]0x1000010; EnvironmentVariables=[string[]]$environment
21
+ ShowWindow=[uint16]0; CreateFlags=[uint32]0x1000010
19
22
  }
20
- $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
21
- $command = '"' + $powershell + '" -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + (Join-Path $PSScriptRoot 'launch-worker.ps1') + '"'
23
+ $worker = Join-Path $PSScriptRoot 'launch-worker.mjs'
24
+ $command = '"' + [string]$spec.brokerExecutable + '" "' + $worker + '" "' + $request + '" "' + $ready + '" "' + $instance + '" "' + $diagnostic + '"'
22
25
  $broker = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
23
26
  CommandLine=$command; CurrentDirectory=$env:USERPROFILE; ProcessStartupInformation=$startup
24
27
  }
25
28
  if ($broker.ReturnValue -ne 0) { throw ('Windows process broker failed: ' + $broker.ReturnValue) }
26
- $deadline = [DateTime]::UtcNow.AddSeconds(20)
29
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
27
30
  while (-not (Test-Path -LiteralPath $ready)) {
28
- if ([DateTime]::UtcNow -gt $deadline) { throw 'Windows process broker handoff timed out.' }
31
+ if ([DateTime]::UtcNow -gt $deadline) {
32
+ $detail = if (Test-Path -LiteralPath $diagnostic) { (Get-Content -LiteralPath $diagnostic -Raw -Encoding UTF8).Trim() } else { 'broker did not create a diagnostic log' }
33
+ throw ('Windows process broker handoff timed out (broker PID ' + $broker.ProcessId + '): ' + $detail)
34
+ }
29
35
  Start-Sleep -Milliseconds 50
30
36
  }
31
37
  $result = Get-Content -LiteralPath $ready -Raw -Encoding UTF8 | ConvertFrom-Json
32
38
  Remove-Item -LiteralPath $ready
33
39
  if ($result.instance -ne $instance -or $result.brokerPid -ne $broker.ProcessId) { throw 'Windows process broker identity mismatch.' }
34
40
  if ($result.error) { throw [string]$result.error }
41
+ if (Test-Path -LiteralPath $diagnostic) { Remove-Item -LiteralPath $diagnostic -Force }
35
42
  [Console]::WriteLine([int]$result.pid)
36
43
  } catch {
37
44
  [Console]::Error.WriteLine($_.Exception.Message)
38
45
  exit 1
46
+ } finally {
47
+ if ($request -and (Test-Path -LiteralPath $request)) { Remove-Item -LiteralPath $request -Force }
48
+ if ($requestTemporary -and (Test-Path -LiteralPath $requestTemporary)) { Remove-Item -LiteralPath $requestTemporary -Force }
39
49
  }
@@ -0,0 +1,61 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ export function relayAdminState(file, initialMaxRelaySessions) {
6
+ let value = { version: 1, maxRelaySessions: initialMaxRelaySessions, announcement: null };
7
+ if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
8
+
9
+ function save(next) {
10
+ value = validate(next);
11
+ if (!file) return value;
12
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
13
+ const temporary = `${file}.${randomUUID()}.tmp`;
14
+ let descriptor;
15
+ try {
16
+ descriptor = fs.openSync(temporary, 'wx', 0o600);
17
+ fs.writeFileSync(descriptor, JSON.stringify(value));
18
+ fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined;
19
+ fs.renameSync(temporary, file);
20
+ if (process.platform !== 'win32') {
21
+ const directory = fs.openSync(path.dirname(file), 'r');
22
+ try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
23
+ }
24
+ } finally {
25
+ if (descriptor !== undefined) fs.closeSync(descriptor);
26
+ fs.rmSync(temporary, { force: true });
27
+ }
28
+ return value;
29
+ }
30
+
31
+ return {
32
+ read: () => structuredClone(value),
33
+ setMaxRelaySessions(maxRelaySessions) { return save({ ...value, maxRelaySessions }); },
34
+ publish(title, message) {
35
+ return save({ ...value, announcement: { id: randomUUID(), title: clean(title, 80), message: clean(message, 2000),
36
+ publishedAt: new Date().toISOString() } });
37
+ },
38
+ withdraw() { return save({ ...value, announcement: null }); },
39
+ };
40
+ }
41
+
42
+ function validate(source) {
43
+ if (!source || source.version !== 1 || !Number.isSafeInteger(source.maxRelaySessions) ||
44
+ source.maxRelaySessions < 1 || source.maxRelaySessions > 256) throw new Error('Invalid relay admin state');
45
+ let announcement = null;
46
+ if (source.announcement !== null && source.announcement !== undefined) {
47
+ const item = source.announcement;
48
+ if (!item || !/^[\w-]{16,128}$/.test(item.id) || !Number.isFinite(Date.parse(item.publishedAt))) {
49
+ throw new Error('Invalid relay announcement');
50
+ }
51
+ announcement = { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000), publishedAt: item.publishedAt };
52
+ }
53
+ return { version: 1, maxRelaySessions: source.maxRelaySessions, announcement };
54
+ }
55
+
56
+ function clean(value, limit) {
57
+ if (typeof value !== 'string' || !value.trim() || value.trim().length > limit || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) {
58
+ throw new Error('Invalid announcement text');
59
+ }
60
+ return value.trim();
61
+ }
@@ -27,6 +27,7 @@ export function connectRelayClient({ relayUrl, room, code, credential, clientId,
27
27
  else if (message.type === 'lan-candidates') events.emit('lan-candidates', message.candidates);
28
28
  else if (message.type === 'official-computer') events.emit('official-computer', message.computer);
29
29
  else if (message.type === 'official-computer-saved') events.emit('official-computer-saved', message.computer);
30
+ else if (message.type === 'announcement') events.emit('announcement', message.announcement);
30
31
  else if (message.type === 'ready') { clearTimeout(timer); connected = true; resolve({ room, credential: pairedCredential, clientId: pairedClientId }); }
31
32
  else if (message.type === 'data') {
32
33
  const body = decode(message.payload, false), request = pending.get(body.id);
@@ -50,6 +51,7 @@ export function connectRelayClient({ relayUrl, room, code, credential, clientId,
50
51
  });
51
52
  },
52
53
  send(message) { if (!connected) throw new Error('Relay not ready'); return send(socket, { type: 'data', payload: JSON.stringify(message) }); },
54
+ routeDirect() { if (!connected) throw new Error('Relay not ready'); return send(socket, { type: 'p2p-route', direct: 1 }); },
53
55
  recordOfficialComputer(computer) {
54
56
  if (!connected || !observe) throw new Error('Metadata channel not ready');
55
57
  return send(socket, { type: 'official-computer', ...computer });