@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.2
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.
- package/README.md +5 -5
- package/dist/bin/self-relay-server.mjs +5 -2
- package/dist/shared/app-server-events.cjs +45 -19
- package/dist/shared/p2p-data-channel.cjs +50 -0
- package/dist/src/app-server-tasks.mjs +28 -25
- package/dist/src/lan-socket-server.mjs +8 -2
- package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
- package/dist/src/platform/windows/background-process.mjs +7 -2
- package/dist/src/platform/windows/launch-worker.ps1 +13 -6
- package/dist/src/platform/windows/start-hidden-console.ps1 +11 -6
- package/dist/src/self-relay/admin-state.mjs +61 -0
- package/dist/src/self-relay/client.mjs +2 -0
- package/dist/src/self-relay/connector.mjs +23 -8
- package/dist/src/self-relay/demo.mjs +2 -2
- package/dist/src/self-relay/lifecycle.mjs +1 -1
- package/dist/src/self-relay/p2p-probe.mjs +123 -83
- package/dist/src/self-relay/server.mjs +137 -11
- package/dist/src/server.mjs +337 -255
- package/dist/src/shared-app-server.mjs +180 -24
- package/dist/src/shared-catalog.mjs +4 -14
- package/dist/src/thread-catalog.mjs +12 -7
- package/dist/web/activity-view.js +283 -0
- package/dist/web/capabilities.js +2 -2
- package/dist/web/chat-transport.js +15 -9
- package/dist/web/chat.css +139 -93
- package/dist/web/chat.js +1451 -2770
- package/dist/web/community-view.js +20 -0
- package/dist/web/community.css +77 -0
- package/dist/web/community.html +37 -0
- package/dist/web/composer-controller.js +101 -0
- package/dist/web/conversation-controller.js +99 -0
- package/dist/web/disclosure-state-controller.js +95 -0
- package/dist/web/draft-controller.js +103 -0
- package/dist/web/harmony-platform.js +3 -2
- package/dist/web/history-cache.js +112 -18
- package/dist/web/history-controller.js +167 -0
- package/dist/web/index.html +141 -38
- package/dist/web/link-action-controller.js +212 -0
- package/dist/web/message-send-controller.js +177 -0
- package/dist/web/message-view.js +98 -0
- package/dist/web/p2p-data-channel.js +50 -0
- package/dist/web/p2p-probe.js +67 -27
- package/dist/web/page-resume.js +28 -0
- package/dist/web/pending-message-store.js +108 -0
- package/dist/web/queue-controller.js +82 -0
- package/dist/web/resources.json +1 -1
- package/dist/web/self-relay-session.js +28 -18
- package/dist/web/station-connection-controller.js +75 -0
- package/dist/web/task-list-view.js +296 -0
- package/dist/web/thread-attention-controller.js +124 -0
- package/dist/web/thread-context-controller.js +30 -0
- package/dist/web/thread-list-controller.js +61 -0
- package/dist/web/thread-list-sync.js +86 -0
- package/dist/web/thread-title-controller.js +57 -0
- package/dist/web/timeline-formatters.js +249 -0
- package/dist/web/timeline-reducer.js +81 -0
- package/dist/web/timeline-renderer.js +161 -0
- package/dist/web/timeline-scroll-controller.js +50 -0
- package/dist/web/usage-controller.js +258 -0
- package/dist/web/vendor/lucide.LICENSE.txt +17 -0
- package/package.json +1 -1
- package/tools/postinstall.mjs +66 -2
package/README.md
CHANGED
|
@@ -4,13 +4,13 @@ Bridge 让 HarmonyOS 客户端控制 Windows 上的 Codex Desktop。当前要求
|
|
|
4
4
|
|
|
5
5
|
## 安装
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
公开 beta 已发布到 npm,安装或更新使用:
|
|
8
8
|
|
|
9
9
|
```powershell
|
|
10
|
-
npm
|
|
10
|
+
npm i -g @walkhi/code-relax@beta --allow-scripts=@walkhi/code-relax
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
83
|
+
当前包名为 `@walkhi/code-relax@0.1.0-beta.2`,主 CLI 为 `relax`,并保留 `code-relax` 兼容入口。当前已完成 npm scope 发布和本机 registry 在线安装;本版修复了另一台电脑首次安装时的 Windows broker 交接超时,仍需在原电脑复核。详细规则见 [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
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
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'
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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: []
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 ||
|
|
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
|
|
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 {
|
|
17
|
-
|
|
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,
|
|
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:
|
|
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({ executable, args, logs, environment }));
|
|
24
29
|
});
|
|
25
30
|
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory=$true)][string]$RequestPath,
|
|
3
|
+
[Parameter(Mandatory=$true)][string]$ReadyPath,
|
|
4
|
+
[Parameter(Mandatory=$true)][string]$Instance
|
|
5
|
+
)
|
|
1
6
|
$ErrorActionPreference = 'Stop'
|
|
2
|
-
$
|
|
3
|
-
Remove-Item Env:CODEX_REMOTE_PROCESS_SPEC
|
|
4
|
-
$result = @{instance=$spec.instance; brokerPid=$PID}
|
|
7
|
+
$result = @{instance=$Instance; brokerPid=$PID}
|
|
5
8
|
try {
|
|
9
|
+
$spec = Get-Content -LiteralPath $RequestPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
10
|
+
if ($spec.instance -ne $Instance) { throw 'Windows process broker request identity mismatch.' }
|
|
11
|
+
Remove-Item -LiteralPath $RequestPath -Force
|
|
6
12
|
Add-Type -Path (Join-Path $PSScriptRoot 'IsolatedProcess.cs')
|
|
7
13
|
$stdout = if ($spec.logs.Count -gt 0) { [string]$spec.logs[0] } else { 'NUL' }
|
|
8
14
|
$stderr = if ($spec.logs.Count -gt 1) { [string]$spec.logs[1] } else { 'NUL' }
|
|
9
|
-
$
|
|
15
|
+
$environment = @($spec.environment.PSObject.Properties | ForEach-Object { $_.Name + '=' + [string]$_.Value })
|
|
16
|
+
$result.pid = [IsolatedProcess]::Start([string]$spec.executable, [string[]]$spec.args, $stdout, $stderr, [string[]]$environment)
|
|
10
17
|
} catch { $result.error = $_.Exception.Message }
|
|
11
|
-
$temporary = $
|
|
18
|
+
$temporary = $ReadyPath + '.tmp'
|
|
12
19
|
[IO.File]::WriteAllText($temporary, ($result | ConvertTo-Json -Compress), (New-Object Text.UTF8Encoding($false)))
|
|
13
|
-
[IO.File]::Move($temporary, $
|
|
20
|
+
[IO.File]::Move($temporary, $ReadyPath)
|
|
@@ -10,22 +10,24 @@ 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')
|
|
13
14
|
$spec | Add-Member -NotePropertyName instance -NotePropertyValue $instance
|
|
14
|
-
$
|
|
15
|
-
$
|
|
16
|
-
$
|
|
15
|
+
$requestTemporary = $request + '.tmp'
|
|
16
|
+
[IO.File]::WriteAllText($requestTemporary, ($spec | ConvertTo-Json -Compress -Depth 10), (New-Object Text.UTF8Encoding($false)))
|
|
17
|
+
[IO.File]::Move($requestTemporary, $request)
|
|
18
|
+
$environment = @(Get-ChildItem Env: | ForEach-Object { $_.Name + '=' + $_.Value })
|
|
17
19
|
$startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{
|
|
18
20
|
ShowWindow=[uint16]0; CreateFlags=[uint32]0x1000010; EnvironmentVariables=[string[]]$environment
|
|
19
21
|
}
|
|
20
22
|
$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
|
+
$command = '"' + $powershell + '" -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + (Join-Path $PSScriptRoot 'launch-worker.ps1') + '" -RequestPath "' + $request + '" -ReadyPath "' + $ready + '" -Instance "' + $instance + '"'
|
|
22
24
|
$broker = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
|
23
25
|
CommandLine=$command; CurrentDirectory=$env:USERPROFILE; ProcessStartupInformation=$startup
|
|
24
26
|
}
|
|
25
27
|
if ($broker.ReturnValue -ne 0) { throw ('Windows process broker failed: ' + $broker.ReturnValue) }
|
|
26
|
-
$deadline = [DateTime]::UtcNow.AddSeconds(
|
|
28
|
+
$deadline = [DateTime]::UtcNow.AddSeconds(30)
|
|
27
29
|
while (-not (Test-Path -LiteralPath $ready)) {
|
|
28
|
-
if ([DateTime]::UtcNow -gt $deadline) { throw 'Windows process broker handoff timed out.' }
|
|
30
|
+
if ([DateTime]::UtcNow -gt $deadline) { throw ('Windows process broker handoff timed out (broker PID ' + $broker.ProcessId + ').') }
|
|
29
31
|
Start-Sleep -Milliseconds 50
|
|
30
32
|
}
|
|
31
33
|
$result = Get-Content -LiteralPath $ready -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
@@ -36,4 +38,7 @@ try {
|
|
|
36
38
|
} catch {
|
|
37
39
|
[Console]::Error.WriteLine($_.Exception.Message)
|
|
38
40
|
exit 1
|
|
41
|
+
} finally {
|
|
42
|
+
if ($request -and (Test-Path -LiteralPath $request)) { Remove-Item -LiteralPath $request -Force }
|
|
43
|
+
if ($requestTemporary -and (Test-Path -LiteralPath $requestTemporary)) { Remove-Item -LiteralPath $requestTemporary -Force }
|
|
39
44
|
}
|
|
@@ -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 });
|