@walkhi/code-relax 0.1.0-beta.42 → 0.1.0-beta.43

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 CHANGED
@@ -14,7 +14,7 @@ npm i -g @walkhi/code-relax@latest --allow-scripts=@walkhi/code-relax --foregrou
14
14
 
15
15
  以下目录与环境变量配置说明适用于 Windows;Mac/Linux 见下方跨平台入口。首次安装前先保存工作,并从 Codex 菜单或 Windows 托盘完全退出 Codex。更新无需先停止正在运行的桌面服务;安装器会短暂停止、替换并恢复服务。npm 全局安装会自动执行 `setup` 和 Skill 安装。`setup` 将完整服务部署到 `%LOCALAPPDATA%\Programs\CodexRemote\service`,记录安装所用 Node 的绝对路径,不复制 Node。配置、日志和二维码以 `installation.json.stateRoot` 为准,通常位于 `%LOCALAPPDATA%\CodexRemote`,MSIX 环境可能解析到包缓存目录;附件位于 `%ProgramData%\CodexRemote\attachments`。
16
16
 
17
- `setup` 立即启动桌面服务和固定 45839 Codex 内核,并启用当前用户登录自启;自启不打开 Codex。Codex 内核健康后配置当前用户 `CODEX_APP_SERVER_WS_URL`,之后正常启动的 Desktop 自动接入。若安装时 Desktop 仍未接入,安装器会关闭并重新打开 Desktop;安装前应保存工作。普通更新无接入或启动链迁移需求时复用现有内核。
17
+ `setup` 立即启动桌面服务和自动选用可用回环端口的 Codex 内核,并启用当前用户登录自启;自启不打开 Codex。Codex 内核健康后配置当前用户 `CODEX_APP_SERVER_WS_URL`,之后正常启动的 Desktop 自动接入。若安装时 Desktop 仍未接入,安装器会关闭并重新打开 Desktop;安装前应保存工作。普通更新无接入或启动链迁移需求时复用现有内核。
18
18
 
19
19
  ## Linux 源码测试
20
20
 
@@ -32,9 +32,9 @@ relax diag # 输出供客服或技术人员使用的详细诊断报告
32
32
  relax kernel-restart --confirm # 重启内核并启用已选更新,等待新内核就绪
33
33
  ```
34
34
 
35
- Codex 内核由官方 `codex app-server --listen ws://127.0.0.1:45839` 提供执行服务,Desktop 和桌面服务均可连接;它只监听回环地址。桌面服务独立报告内核健康、Desktop 接入和手机可写状态;Desktop 退出后,手机仍可操作内核中的任务。同一 thread 已被另一个实例持有时,手机只读且可重新检测。
35
+ Codex 内核由官方 `codex app-server --listen ws://127.0.0.1:<实际端口>` 提供执行服务,Desktop 和桌面服务均可连接;它只监听回环地址。桌面服务独立报告内核健康、Desktop 接入和手机可写状态;Desktop 退出后,手机仍可操作内核中的任务。同一 thread 已被另一个实例持有时,手机只读且可重新检测。
36
36
 
37
- 发现待启用内核更新时,手机“重启并更新”和 `relax kernel-restart --confirm` 复用同一受控停启流程:先验证候选,再停止旧内核,确认新内核协议就绪后恢复 Desktop,并按清单清理不再运行的旧版保留目录。手机工作站详情可确认让 Desktop 接入Codex 内核,独立进程逐步执行并保留桌面服务/中继连接,见[启用流程](../docs/shared-recovery.md)。本机恢复命令为 `relax desktop-recover --confirm`,查看恢复记录用 `relax diag`。桌面服务状态匹配且健康时直接复用;未知进程占用 45831/45839 时拒绝接管。
37
+ 发现待启用内核更新时,手机“重启并更新”和 `relax kernel-restart --confirm` 复用同一受控停启流程:先验证候选,再停止旧内核,确认新内核协议就绪后恢复 Desktop,并按清单清理不再运行的旧版保留目录。手机工作站详情可确认让 Desktop 接入Codex 内核,独立进程逐步执行并保留桌面服务/中继连接,见[启用流程](../docs/shared-recovery.md)。本机恢复命令为 `relax desktop-recover --confirm`,查看恢复记录用 `relax diag`。桌面服务状态匹配且健康时直接复用;未知进程占用 Bridge 端口 45831 时拒绝接管;内核默认尝试 45839,被占用则自动选择其他回环端口,不结束占用者。
38
38
 
39
39
  Codex 内核通过隐藏控制台启动,使后续控制台子进程继承隐藏窗口。手机的项目、任务列表和新建任务均使用Codex 内核,不依赖 Desktop 工具管道;项目和任务 ID 保持原生值。
40
40
 
@@ -93,3 +93,13 @@ registry 默认安装使用 `@walkhi/code-relax@latest`,主 CLI 为 `relax`,
93
93
  安装和启动成功后只显示结果与 `relax pair` 入口;配对成功显示二维码文件。`relax doctor` 报告工作站健康及实际问题,独立的 `relax diag` 默认用可读文本输出系统、Node、服务进程、逐项检查和 Desktop 接入排障记录;常规命令不再提供 `--detail`;`--json` 保留给安装器等现有程序调用。服务未运行或内核未就绪判为失败;未连接局域网只提示,不阻断中继使用。手机连通性不作为 CLI 检查项,doctor 不创建配对邀请。
94
94
 
95
95
  `doctor` 默认逐项说明安装文件、工作站服务和 Codex 内核的当前状态,问题集中给出处理方法。上次 Desktop 接入记录仅由 `diag` 读取,并明确标注排障用途;常规 `doctor` 不读取该记录,不参与当前健康判定,不拼接互相矛盾的阶段消息与最终错误。
96
+
97
+ ### 内核端口自动恢复(beta.43)
98
+
99
+ 无需手工设置端口。先复用记录中身份、监听与协议均验证通过的内核;没有可用内核时尝试上次端口/默认 45839,冲突后选择系统分配的空闲回环端口。端口抢占导致刚启动的进程退出时最多尝试五次;已启动但未就绪的内核保留身份记录,不创建第二个实例。
100
+
101
+ 只有进程身份、监听所有者、HTTP 就绪和 WebSocket 初始化全部通过后,才公布实际地址,并同步 Windows 当前用户的 `CODEX_APP_SERVER_WS_URL`。原值由 Code Relax 管理时自动更新;用户另行设置的值保留并明确报错。Mac/Linux 继续通过启动时环境传递实际地址,不修改 shell 配置。正常登录自启同样自动选端口。
102
+
103
+ 从旧版恢复可运行 `npm install -g @walkhi/code-relax@beta`,其安装流程自动部署并运行 setup;需要重试安装时运行 `relax setup`。不需要重启电脑或结束未知 PID。`relax status --json` 的 `managedAppServer.url` 显示实际地址。已经打开的终端和 Desktop 不能被外部原地修改环境;setup 沿用既有受控 Desktop 接入流程,新启动的进程使用更新后的值。
104
+
105
+ 实现依据与验证范围见 [动态端口审阅](../docs/managed-kernel-dynamic-port.md)。
@@ -22,7 +22,7 @@ import { resolveCodexRuntime } from '../src/platform/codex-runtime.mjs';
22
22
  import { controlRequest } from '../src/service-control.mjs';
23
23
  import { processAlive } from '../src/background-process.mjs';
24
24
  import { prepareNativePackage } from '../src/platform/native-package.mjs';
25
- import { managedAppServerUrl } from '../src/managed-app-server.mjs';
25
+ import { readManagedEndpoint } from '../src/managed-endpoint.mjs';
26
26
  import { powerConfigPath, powerEnabled } from '../src/service-power.mjs';
27
27
  import { readDotsTasks } from '../src/dots-task-catalog.mjs';
28
28
  import { installDesktopHelper, desktopHelperStatus, removeDesktopHelper } from '../src/platform/windows/desktop-helper.mjs';
@@ -96,7 +96,9 @@ async function main() {
96
96
  return;
97
97
  }
98
98
  if (command === 'dots-tasks') {
99
- const result = await readDotsTasks(managedAppServerUrl);
99
+ const url = readManagedEndpoint(runtimePaths().stateRoot);
100
+ if (!url) throw new Error('Codex 内核尚未就绪,请先运行 relax start。');
101
+ const result = await readDotsTasks(url);
100
102
  const statuses = { completed: '完成', inProgress: '进行中', failed: '失败', interrupted: '已中断' };
101
103
  console.log(json ? JSON.stringify(result) : result.tasks.length
102
104
  ? result.tasks.map(task => `${task.title || task.id} — ${statuses[task.latestTurn?.status] || task.status || '暂无轮次'}\n${task.cwd || ''}`).join('\n\n')
@@ -313,7 +315,7 @@ async function main() {
313
315
  throw new Error(bridge.managedAppServer?.error || '桌面服务已安装,但 Codex 内核未就绪;未写入 Desktop 环境变量。');
314
316
  }
315
317
  const environment = desktopBeforeSetup.desktop
316
- ? await configureManagedEnvironment(physicalStateRoot) : { configured: false, reason: desktopHost.desktopIntegration === false ? 'desktop-integration-unsupported' : 'desktop-not-installed' };
318
+ ? await configureManagedEnvironment(physicalStateRoot, bridge.managedAppServer.url) : { configured: false, reason: desktopHost.desktopIntegration === false ? 'desktop-integration-unsupported' : 'desktop-not-installed' };
317
319
  let managedAppServerMigrated = false;
318
320
  const managedAppServerMigration = {
319
321
  required: bridge.managedAppServer.launcherRestartRequired === true || bridge.managedAppServer.updatePending === true,
@@ -1 +1 @@
1
- {"layout":"npm-platform-packages","version":"0.1.0-beta.42"}
1
+ {"layout":"npm-platform-packages","version":"0.1.0-beta.43"}
@@ -1,6 +1,14 @@
1
- # Detect the Desktop connection, not merely a listening standalone server.
2
- $connections = @(Get-NetTCPConnection -RemotePort 45839 -State Established -ErrorAction SilentlyContinue | Where-Object {
1
+ param([Parameter(Mandatory=$true)][string]$AppServerUrl)
2
+ $ErrorActionPreference = 'Stop'
3
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
+ if ($AppServerUrl -cnotmatch '^ws://127\.0\.0\.1:([1-9][0-9]{0,4})\z' -or [int]$Matches[1] -gt 65535) { throw 'Invalid managed app-server URL.' }
5
+ $port = [int]$Matches[1]
6
+ $package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
7
+ if (-not $package) { exit }
8
+ $desktop = Join-Path $package.InstallLocation 'app/ChatGPT.exe'
9
+ # Match an installed Desktop process connected to the selected loopback port.
10
+ $connections = @(Get-NetTCPConnection -RemotePort $port -State Established -ErrorAction SilentlyContinue | Where-Object {
3
11
  $_.RemoteAddress -eq '127.0.0.1' -and
4
- (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName -eq 'ChatGPT'
12
+ (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).Path -eq $desktop
5
13
  })
6
- if ($connections.Count) { 'ws://127.0.0.1:45839' }
14
+ if ($connections.Count) { $AppServerUrl }
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
4
  import { readRecord } from './service-lifecycle.mjs';
5
- import { ManagedAppServer, managedAppServerUrl as sharedUrl, managedPortAvailable as portAvailable } from './managed-app-server.mjs';
5
+ import { ManagedAppServer, managedPortAvailable as portAvailable } from './managed-app-server.mjs';
6
6
  export { portAvailable };
7
7
  export async function launchDesktop({ stateRoot, host, environment = process.env, available = portAvailable, wait = delay, timeout = 30000, recoveryId, progress = () => {} }) {
8
8
  fs.mkdirSync(stateRoot, { recursive: true });
@@ -31,7 +31,7 @@ export async function launchDesktop({ stateRoot, host, environment = process.env
31
31
  if (!await managed.start()) throw new Error(managed.snapshot().error || 'Codex 内核未就绪。');
32
32
  const runtime = readRecord(path.join(stateRoot, 'codex-runtime.json'));
33
33
  if (runtime?.codexHome) childEnv.CODEX_HOME = runtime.codexHome;
34
- childEnv.CODEX_APP_SERVER_WS_URL = sharedUrl;
34
+ childEnv.CODEX_APP_SERVER_WS_URL = managed.snapshot().url;
35
35
  progress('starting-desktop');
36
36
  await host.startDesktop(info.desktop, childEnv);
37
37
  const deadline = Date.now() + timeout;
@@ -242,6 +242,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
242
242
  if (record.changeBytes > 8 * 1024 * 1024) { record.recoverAfter = sequence; record.changes.clear(); record.changeBytes = 0; }
243
243
  this.emit('turn', { thread: record.thread, turn, sequence, immediate: true });
244
244
  }
245
+ this.chatActivity.completeReply(threadId, changed);
245
246
  this.emit('notification', { method: 'thread/status/changed', params: { threadId, status: record.thread.status,
246
247
  updatedAt: record.thread.updatedAt, chatRevision: record.thread.chatRevision, chatMessageRevision: record.thread.chatMessageRevision } });
247
248
  this.saveHistoryWindow(threadId);
@@ -1,12 +1,21 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
 
3
- // Desktop's room-live SSE owns the watch; Celsius carries its revisioned state.
3
+ // Desktop 26.1007.9446.0: room-live activity and responding heartbeats are
4
+ // independent. Heartbeats expire after 10s; Dot reply handoff lasts at most 5s.
5
+ const typingTimeout = 10000, handoffTimeout = 5000;
6
+ const statusLabels = {
7
+ working: '正在处理…', searching: '正在搜索…', creating: '正在创建…',
8
+ payment: '等待支付', 'needs input': '等待你的输入', paused: '已暂停',
9
+ };
10
+ const waitingStates = new Set(['payment', 'needs input', 'paused']);
11
+
4
12
  export class DotsRoomActivity extends EventEmitter {
5
13
  constructor(server) {
6
14
  super(); this.server = server; this.watches = new Map();
7
15
  this.message = (topic, event) => {
8
- if (event?.type !== 'calpico-room-live') return;
9
16
  for (const watch of this.watches.values()) {
17
+ if (topic === 'calpico-chatgpt-messaging') { this.roomEvent(watch, event); continue; }
18
+ if (event?.type !== 'calpico-room-live') continue;
10
19
  const value = event.payload;
11
20
  if (watch.topic !== topic || watch.roomId !== value?.room_id || value.version !== 1) continue;
12
21
  if (!watch.subscriptionId) {
@@ -37,6 +46,57 @@ export class DotsRoomActivity extends EventEmitter {
37
46
  watch.activity = activity;
38
47
  for (const listener of watch.listeners) listener({ threadId: watch.threadId, activity });
39
48
  }
49
+ refresh(watch) {
50
+ const activity = watch.typing ? { kind: 'replying', label: '正在回复…', spinning: true }
51
+ : watch.liveActivity || (watch.handoffTimer ? { kind: 'replying', label: '正在回复…', spinning: true }
52
+ : watch.unavailable ? { kind: 'unavailable', label: '活动状态暂不可用', spinning: false } : null);
53
+ this.publish(watch, activity);
54
+ }
55
+ clearReply(watch) {
56
+ clearTimeout(watch.typingTimer); clearTimeout(watch.handoffTimer);
57
+ watch.typingTimer = null; watch.handoffTimer = null; watch.typing = false; watch.handoffMessageId = null;
58
+ }
59
+ handoff(watch, messageId = null) {
60
+ const wasTyping = watch.typing;
61
+ clearTimeout(watch.typingTimer); watch.typingTimer = null; watch.typing = false;
62
+ if (messageId && (wasTyping || watch.handoffTimer)) watch.handoffMessageId = messageId;
63
+ if (wasTyping && !watch.handoffTimer) {
64
+ watch.handoffTimer = setTimeout(() => {
65
+ watch.handoffTimer = null; watch.handoffMessageId = null;
66
+ if (this.watches.get(watch.threadId) === watch) this.refresh(watch);
67
+ }, handoffTimeout);
68
+ watch.handoffTimer.unref();
69
+ }
70
+ this.refresh(watch);
71
+ }
72
+ completeReply(threadId, turns) {
73
+ const watch = this.watches.get(threadId);
74
+ if (!watch?.handoffMessageId || !turns.some(turn => turn.id === watch.handoffMessageId
75
+ && turn.items.some(item => item.type === 'agentMessage' && !item.dotsStreaming))) return;
76
+ // The matching formal message has entered the existing chat stream.
77
+ clearTimeout(watch.handoffTimer); watch.handoffTimer = null; watch.handoffMessageId = null;
78
+ this.refresh(watch);
79
+ }
80
+ roomEvent(watch, event) {
81
+ if (!this.server.rooms.ready || !watch.roomId || event?.payload?.room_id !== watch.roomId) return;
82
+ const payload = event.payload, heartbeat = event.type === 'calpico-is-responding-heartbeat';
83
+ if (!heartbeat && event.type !== 'calpico-message-add') return;
84
+ const room = this.server.bindings.get(watch.threadId);
85
+ const sender = heartbeat ? payload.source : payload.message;
86
+ if (room?.id !== watch.roomId || !room.aeon_id || heartbeat && sender?.role !== 'user'
87
+ || !sender?.account_user_id
88
+ || !room.members.some(member => member.account_user_id === sender.account_user_id && member.aeon_id === room.aeon_id)) return;
89
+ if (!heartbeat || payload.typing === false) {
90
+ this.handoff(watch, heartbeat ? null : payload.message.id); return;
91
+ }
92
+ if (payload.typing != null && payload.typing !== true) return;
93
+ this.clearReply(watch); watch.typing = true;
94
+ watch.typingTimer = setTimeout(() => {
95
+ watch.typingTimer = null;
96
+ if (this.watches.get(watch.threadId) === watch) this.handoff(watch);
97
+ }, typingTimeout);
98
+ watch.typingTimer.unref(); this.refresh(watch);
99
+ }
40
100
  apply(watch, value) {
41
101
  if (value.subscription_id !== watch.subscriptionId || !Number.isSafeInteger(value.revision) || value.revision <= watch.revision) return;
42
102
  watch.revision = value.revision;
@@ -45,10 +105,23 @@ export class DotsRoomActivity extends EventEmitter {
45
105
  }
46
106
  if (value.type !== 'calpico-room-live-state' || !Array.isArray(value.threads)) return;
47
107
  const current = value.threads[0];
48
- if (current?.stream_state !== 'attached' || current.turn_status !== 'in_progress') { this.publish(watch, null); return; }
49
- watch.attempts = 0;
108
+ watch.attempts = 0; watch.unavailable = false; watch.liveActivity = null;
109
+ if (current?.stream_state !== 'attached') { this.refresh(watch); return; }
110
+ if (current.turn_status === 'failed') {
111
+ this.clearReply(watch); watch.liveActivity = { kind: 'error', label: '回复出错', spinning: false };
112
+ this.refresh(watch); return;
113
+ }
114
+ if (current.turn_status !== 'in_progress') { this.refresh(watch); return; }
115
+ const status = typeof current.activity_status?.status_type === 'string' ? current.activity_status.status_type.toLowerCase() : '';
50
116
  const label = current.activity_status?.status_label;
51
- this.publish(watch, { kind: 'thinking', label: typeof label === 'string' && label.trim() ? label : '正在思考…' });
117
+ if (status !== 'ready') {
118
+ const waiting = waitingStates.has(status);
119
+ const fallback = Object.hasOwn(statusLabels, status) ? statusLabels[status] : null;
120
+ if (waiting) this.clearReply(watch);
121
+ watch.liveActivity = { kind: waiting ? 'waiting' : fallback ? 'operation' : 'thinking',
122
+ label: typeof label === 'string' && label.trim() ? label : fallback || '正在思考…', spinning: !waiting };
123
+ }
124
+ this.refresh(watch);
52
125
  }
53
126
  async start(watch) {
54
127
  const generation = ++watch.generation, controller = new AbortController();
@@ -77,15 +150,18 @@ export class DotsRoomActivity extends EventEmitter {
77
150
  } catch (error) { if (current()) this.fail(watch, error, ![401, 403, 404].includes(error.status)); }
78
151
  }
79
152
  fail(watch, error, retry) {
80
- this.stop(watch); this.emit('unavailable', error);
153
+ this.stop(watch, true); watch.unavailable = true; this.refresh(watch); this.emit('unavailable', error);
81
154
  if (retry && watch.listeners.size && this.server.ready && watch.attempts < 5) {
82
155
  watch.timer = setTimeout(() => { watch.timer = null; void this.start(watch); }, Math.min(30000, 1000 * 2 ** watch.attempts++));
83
156
  watch.timer.unref();
84
157
  }
85
158
  }
86
- stop(watch) {
159
+ stop(watch, keepReply = false) {
87
160
  watch.generation++; clearTimeout(watch.timer); watch.timer = null;
88
- watch.controller?.abort(); watch.controller = null; watch.pending?.clear(); this.publish(watch, null);
161
+ watch.controller?.abort(); watch.controller = null; watch.pending?.clear(); watch.liveActivity = null;
162
+ watch.subscriptionId = null; watch.revision = -1;
163
+ if (!keepReply) { this.clearReply(watch); watch.unavailable = false; watch.roomId = null; }
164
+ if (!keepReply) this.publish(watch, null);
89
165
  // The SSE cancellation ends the server watch. The shared topic remains
90
166
  // subscribed while this account socket lives, avoiding unsubscribe races.
91
167
  }
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { executableKey } from './platform/path-identity.mjs';
3
3
  import net from 'node:net';
4
+ import { randomUUID } from 'node:crypto';
4
5
  import path from 'node:path';
5
6
  import { EventEmitter } from 'node:events';
6
7
  import { setTimeout as delay } from 'node:timers/promises';
@@ -8,31 +9,47 @@ import { readRecord, writeRecord } from './service-lifecycle.mjs';
8
9
  import { SharedAppServer } from './shared-app-server.mjs';
9
10
  import { pruneRetainedDesktopRuntimes, resolveCodexRuntime, runtimeFilesAvailable } from './platform/codex-runtime.mjs';
10
11
 
11
- export const managedAppServerUrl = 'ws://127.0.0.1:45839';
12
- export const managedAppServerReadyUrl = 'http://127.0.0.1:45839/readyz';
12
+ import { defaultManagedPort, managedProcessUrl } from './managed-endpoint.mjs';
13
+ import { configureManagedEnvironment } from './platform/user-environment.mjs';
13
14
  export const managedAppServerLauncherRevision = 3;
14
15
  const retryDelays = [1_000, 2_000, 5_000, 10_000, 20_000];
15
16
 
16
- export function managedPortAvailable() {
17
+ export function managedPortAvailable(port = defaultManagedPort) {
17
18
  return new Promise(resolve => {
18
19
  const server = net.createServer();
19
20
  server.once('error', () => resolve(false));
20
- server.listen(45839, '127.0.0.1', () => server.close(() => resolve(true)));
21
+ server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
22
+ });
23
+ }
24
+ export function availableManagedPort() {
25
+ return new Promise((resolve, reject) => {
26
+ const server = net.createServer();
27
+ server.once('error', reject);
28
+ server.listen(0, '127.0.0.1', () => {
29
+ const port = server.address().port;
30
+ server.close(error => error ? reject(error) : resolve(port));
31
+ });
21
32
  });
22
33
  }
23
34
 
24
35
  export class ManagedAppServer extends EventEmitter {
25
36
  constructor({ stateRoot, host, environment = process.env, available = managedPortAvailable,
26
- wait = delay, timeout = 30_000, now = Date.now, client = new SharedAppServer(managedAppServerUrl) }) {
37
+ wait = delay, timeout = 30_000, now = Date.now, client = new SharedAppServer(`ws://127.0.0.1:${defaultManagedPort}`),
38
+ selectPort = availableManagedPort, configureEnvironment = configureManagedEnvironment, resolveRuntime = resolveCodexRuntime }) {
27
39
  super();
28
40
  this.stateRoot = stateRoot;
29
41
  this.host = host;
30
42
  this.environment = environment;
31
43
  this.available = available;
44
+ this.selectPort = selectPort;
45
+ this.configureEnvironment = configureEnvironment;
46
+ this.resolveRuntime = resolveRuntime;
32
47
  this.wait = wait;
33
48
  this.timeout = timeout;
34
49
  this.now = now;
35
50
  this.recordPath = path.join(stateRoot, 'managed-app-server.json');
51
+ this.pendingPath = path.join(stateRoot, 'managed-app-server.pending.json');
52
+ this.lockPath = path.join(stateRoot, 'managed-app-server.lock');
36
53
  this.client = client;
37
54
  this.state = { state: 'absent', ready: false, error: null };
38
55
  this.client.diagnosticIdentity = () => ({ kernelPid: this.state.pid ?? null, kernelCreated: this.state.created ?? null });
@@ -42,7 +59,7 @@ export class ManagedAppServer extends EventEmitter {
42
59
  this.retryAttempt = 0;
43
60
  this.retryAfter = 0;
44
61
  this.client.on('disconnected', () => {
45
- if (!this.stopped && !this.restarting) this.failed('Codex 内核连接断开。');
62
+ if (!this.stopped && !this.restarting && !this.work) this.failed('Codex 内核连接断开。');
46
63
  });
47
64
  }
48
65
 
@@ -66,49 +83,83 @@ export class ManagedAppServer extends EventEmitter {
66
83
  return this.work;
67
84
  }
68
85
 
69
- async ensureNow() {
86
+ async ensureNow(lockHeld = false) {
70
87
  // Keep an already verified instance available while the periodic identity
71
88
  // check runs; publishing "starting" every three seconds makes health flicker.
72
89
  if (!this.state.ready) this.publish({ state: 'starting', ready: false, error: null });
73
90
  try {
74
91
  fs.mkdirSync(this.stateRoot, { recursive: true });
75
- let record = null, actual = null;
76
- const candidate = readRecord(this.recordPath);
77
- const process = candidate?.pid ? await this.host.process(candidate.pid) : null;
78
- if (sameManagedProcess(candidate, process)) { record = candidate; actual = process; }
92
+ const release = lockHeld ? () => {} : await this.acquireLock();
93
+ if (!release) return false;
94
+ try {
95
+ const pending = readRecord(this.pendingPath);
96
+ if (pending?.phase === 'spawning') throw new Error('上次内核启动的进程交接未完成,已停止自动启动以避免重复内核;请检查内核启动日志与 managed-app-server.pending.json。');
97
+ let record = pending || readRecord(this.recordPath);
98
+ let actual = record?.pid ? await this.host.process(record.pid) : null;
99
+ if (actual && !sameManagedProcess(record, actual)) {
100
+ // Stale PID now belongs to someone else: leave it untouched and select
101
+ // another available endpoint rather than adopting or stopping it.
102
+ actual = null;
103
+ record = null;
104
+ }
79
105
  const startedHere = !actual;
80
106
  const savedRuntime = actual ? readRecord(path.join(this.stateRoot, 'codex-runtime.json')) : null;
81
107
  const runtime = actual && savedRuntime?.executable && runtimeFilesAvailable(savedRuntime.executable)
82
108
  ? savedRuntime
83
- : await resolveCodexRuntime(this.stateRoot, { refresh: !actual, environment: this.environment });
109
+ : await this.resolveRuntime(this.stateRoot, { refresh: !actual, environment: this.environment });
84
110
  if (actual && !runtimeFilesAvailable(actual.executable)) {
85
- throw new Error(`运行中的 Codex 内核目录已被移除或缺少配套 code-mode-host:${actual.executable};已找到可用内核 ${runtime.executable},请确认重启内核。`);
111
+ throw new Error(`运行中的 Codex 内核目录已被移除或缺少配套 code-mode-host:${actual.executable};请确认重启内核。`);
86
112
  }
87
113
  if (!actual) {
88
- if (!await this.available()) throw new Error('45839 被未识别进程占用,未启动或结束任何进程。');
89
- const childEnvironment = { ...this.environment };
90
- childEnvironment.CODEX_HOME = runtime.codexHome;
91
- // Desktop's inherited INFO tracing can emit enter/exit on every poll.
92
- // The managed background kernel retains warnings and errors only.
93
- childEnvironment.RUST_LOG = 'warn';
114
+ fs.rmSync(this.pendingPath, { force: true });
115
+ const childEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, RUST_LOG: 'warn' };
94
116
  for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnvironment[key];
95
117
  for (const key of Object.keys(childEnvironment)) {
96
118
  if (/^npm_/iu.test(key) || key.toUpperCase() === 'INIT_CWD') delete childEnvironment[key];
97
119
  }
98
- const pid = await this.host.start(runtime.executable, ['app-server', '--listen', managedAppServerUrl], childEnvironment,
99
- [path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
100
- const started = await this.host.process(pid);
101
- if (!sameManagedProcess(started, started)) throw new Error('Codex 内核启动后进程身份无法确认。');
102
- actual = started;
103
- record = { ...started, launcherRevision: managedAppServerLauncherRevision };
104
- writeRecord(this.recordPath, record);
105
- } else if (!readRecord(this.recordPath)) {
106
- record = { ...actual, launcherRevision: Number.isSafeInteger(record?.launcherRevision) ? record.launcherRevision : 0 };
107
- writeRecord(this.recordPath, record);
108
- }
109
- await this.waitUntilReady(actual);
110
- await this.client.connect();
111
- if (!this.client.ready) throw new Error('Codex 内核协议初始化未完成。');
120
+ // A probe cannot reserve a port for another process. Retry only when our
121
+ // previous child has definitely exited, never on a live unready kernel.
122
+ for (let attempt = 0; attempt < 5; attempt++) {
123
+ const preferred = attempt === 0 ? Number(managedProcessUrl(record)?.split(':').at(-1)) || defaultManagedPort : 0;
124
+ const port = preferred && await this.available(preferred) ? preferred : await this.selectPort();
125
+ const url = `ws://127.0.0.1:${port}`;
126
+ // Commit intent before spawning: if the launcher dies before it can
127
+ // record the child PID, a successor must fail closed, not duplicate it.
128
+ writeRecord(this.pendingPath, { phase: 'spawning', url, executable: runtime.executable, startedAt: new Date().toISOString() });
129
+ const pid = await this.host.start(runtime.executable, ['app-server', '--listen', url], childEnvironment,
130
+ [path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
131
+ const started = await this.host.process(pid);
132
+ if (!started) {
133
+ fs.rmSync(this.pendingPath, { force: true });
134
+ if (attempt < 4) continue;
135
+ throw new Error('Codex 内核启动后退出,动态端口尝试已用完。');
136
+ }
137
+ record = { ...started, url, launcherRevision: managedAppServerLauncherRevision };
138
+ if (!sameManagedProcess(record, started) || executableKey(started.executable) !== executableKey(runtime.executable)) {
139
+ throw new Error('Codex 内核启动后进程身份无法确认,未启动第二个实例。');
140
+ }
141
+ // Private ownership journal is not a published endpoint. It prevents
142
+ // duplicate children even if the launcher crashes before readiness.
143
+ writeRecord(this.pendingPath, record);
144
+ actual = started;
145
+ try { await this.connectReady(record); break; }
146
+ catch (error) {
147
+ const remaining = await this.host.process(pid);
148
+ if (remaining || attempt === 4) throw error;
149
+ fs.rmSync(this.pendingPath, { force: true });
150
+ actual = null;
151
+ }
152
+ }
153
+ } else await this.connectReady(record);
154
+ const url = managedProcessUrl(record);
155
+ // Do not publish a guessed port or update Desktop before ownership,
156
+ // readiness and protocol initialization have all succeeded.
157
+ if (this.publishedUrl !== url) await this.configureEnvironment(this.stateRoot, url);
158
+ this.environment.CODEX_APP_SERVER_WS_URL = url;
159
+ process.env.CODEX_APP_SERVER_WS_URL = url;
160
+ if (this.publishedUrl !== url || readRecord(this.pendingPath)) writeRecord(this.recordPath, { ...record, url });
161
+ this.publishedUrl = url;
162
+ fs.rmSync(this.pendingPath, { force: true });
112
163
  if (startedHere) {
113
164
  try { await pruneRetainedDesktopRuntimes(this.stateRoot, actual.executable); }
114
165
  catch (error) { console.error('旧版 Codex 内核文件清理失败:', error); }
@@ -122,21 +173,93 @@ export class ManagedAppServer extends EventEmitter {
122
173
  created: actual.created, executable: actual.executable, preferredExecutable,
123
174
  updatePending, launcherRevision, selectedVersion: runtime.version, source: runtime.source,
124
175
  launcherRestartRequired: launcherRevision !== managedAppServerLauncherRevision,
125
- url: managedAppServerUrl });
176
+ url });
126
177
  return true;
178
+ } finally { release(); }
127
179
  } catch (error) {
128
180
  this.failed(error instanceof Error ? error.message : String(error));
129
181
  return false;
130
182
  }
131
183
  }
132
184
 
185
+ async acquireLock() {
186
+ const token = randomUUID();
187
+ const temporary = `${this.lockPath}.${token}.tmp`;
188
+ const owner = await this.host.process(process.pid);
189
+ if (!owner) throw new Error('启动器进程身份无法确认。');
190
+ try {
191
+ // Publish a fully written record atomically; a crash cannot leave an
192
+ // empty lock that has no identifiable owner.
193
+ fs.writeFileSync(temporary, JSON.stringify({ ...owner, token }), { flag: 'wx', mode: 0o600 });
194
+ try { fs.linkSync(temporary, this.lockPath); }
195
+ catch (error) {
196
+ if (error.code !== 'EEXIST') throw error;
197
+ const previous = readRecord(this.lockPath);
198
+ if (!previous?.token) throw new Error('内核启动锁无法识别,请检查 managed-app-server.lock。');
199
+ const actual = await this.host.process(previous.pid);
200
+ if (!actual || actual.created !== previous.created || executableKey(actual.executable) !== executableKey(previous.executable)) {
201
+ // One permanent tombstone per abandoned generation means two stale
202
+ // observers cannot delete a replacement owner's lock (ABA race).
203
+ if (await this.claimRecovery(previous, this.lockPath, temporary)) {
204
+ if (readRecord(this.lockPath)?.token === previous.token) fs.rmSync(this.lockPath, { force: true });
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+ return () => { if (readRecord(this.lockPath)?.token === token) fs.rmSync(this.lockPath, { force: true }); };
210
+ } finally { fs.rmSync(temporary, { force: true }); }
211
+ }
212
+
213
+ async claimRecovery(previous, target, ownerFile, depth = 0) {
214
+ if (depth > 16) throw new Error('内核启动锁多次异常中断,请检查启动器日志。');
215
+ const recovered = `${this.lockPath}.${previous.token}.recovered`;
216
+ try { fs.linkSync(ownerFile, recovered); return true; }
217
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
218
+ const claimant = readRecord(recovered);
219
+ if (!claimant?.token) throw new Error('内核启动锁回收记录无法识别。');
220
+ const actual = await this.host.process(claimant.pid);
221
+ if (actual && actual.created === claimant.created && executableKey(actual.executable) === executableKey(claimant.executable)) return false;
222
+ // A reclaimer can itself crash. Keep an append-only ownership chain so its
223
+ // successor can complete cleanup without allowing two live reclaimers.
224
+ return this.claimRecovery(claimant, recovered, ownerFile, depth + 1);
225
+ }
226
+
227
+ async connectReady(expected) {
228
+ const url = managedProcessUrl(expected);
229
+ if (!url) throw new Error('Codex 内核地址无效。');
230
+ await this.waitUntilReady(expected);
231
+ if (this.client.url !== url) {
232
+ await this.disconnectClient();
233
+ this.client.url = url;
234
+ }
235
+ await this.client.connect();
236
+ if (!this.client.ready || !sameManagedProcess(expected, await this.host.process(expected.pid))
237
+ || !await this.host.ownsListener(expected, url)) {
238
+ this.client.close();
239
+ throw new Error('Codex 内核协议初始化或监听进程验证未完成。');
240
+ }
241
+ }
242
+
243
+ async disconnectClient() {
244
+ const socket = this.client.socket;
245
+ this.client.close();
246
+ if (socket && socket.readyState !== 3) {
247
+ await new Promise(resolve => {
248
+ const timer = setTimeout(() => { socket.terminate(); }, 1000);
249
+ socket.once('close', () => { clearTimeout(timer); resolve(); });
250
+ });
251
+ }
252
+ this.client.ready = false;
253
+ }
254
+
133
255
  async waitUntilReady(expected) {
256
+ const url = managedProcessUrl(expected);
134
257
  const deadline = this.now() + this.timeout;
135
258
  while (true) {
136
259
  const actual = await this.host.process(expected.pid);
137
260
  if (!sameManagedProcess(expected, actual)) throw new Error('Codex 内核在就绪前退出。');
138
- if (await this.host.ready(managedAppServerReadyUrl)) return;
139
- if (this.now() >= deadline) throw new Error('Codex 内核未在 30 秒内就绪,请查看工作站诊断日志。');
261
+ if (await this.host.ownsListener(expected, url) && await this.host.ready(url.replace('ws:', 'http:') + '/readyz')) return;
262
+ if (this.now() >= deadline) throw new Error('Codex 内核未在 30 秒内就绪;保留已启动实例,不会另起重复内核。');
140
263
  await this.wait(250);
141
264
  }
142
265
  }
@@ -158,33 +281,37 @@ export class ManagedAppServer extends EventEmitter {
158
281
 
159
282
  async restartNow() {
160
283
  this.restarting = true;
284
+ await this.work;
161
285
  const previous = this.state;
286
+ let release;
162
287
  this.publish({ ...previous, state: 'restarting', ready: false, error: null });
163
288
  try {
164
- const record = readRecord(this.recordPath);
289
+ release = await this.acquireLock();
290
+ if (!release) throw new Error('另一个内核启动操作正在进行,请稍后重试。');
291
+ const pending = readRecord(this.pendingPath);
292
+ if (pending?.phase === 'spawning') throw new Error('上次内核启动交接未完成,无法安全重启未知进程;请检查内核启动日志。');
293
+ const record = pending || readRecord(this.recordPath);
165
294
  const actual = record?.pid ? await this.host.process(record.pid) : null;
166
295
  if (actual && !sameManagedProcess(record, actual)) {
167
296
  throw new Error('Codex 内核进程身份已经变化,未结束任何进程。');
168
297
  }
169
- if (!actual && !await this.available()) throw new Error('45839 被未识别进程占用,未结束任何进程。');
170
298
  this.restartStage('stopping');
171
299
  // Resolve the replacement before stopping the current working process.
172
- const runtime = await resolveCodexRuntime(this.stateRoot, { refresh: true, environment: this.environment });
300
+ const runtime = await this.resolveRuntime(this.stateRoot, { refresh: true, environment: this.environment });
173
301
  const desktop = await this.host.prepareKernelRestart?.();
174
- this.client.close();
302
+ await this.disconnectClient();
175
303
  if (actual) {
176
304
  await this.host.stop(actual.pid, actual);
177
305
  await this.waitUntilStopped(actual);
178
- } else if (!await this.available()) {
179
- throw new Error('45839 被未识别进程占用,未结束任何进程。');
180
306
  }
181
- fs.rmSync(this.recordPath, { force: true });
307
+ fs.rmSync(this.pendingPath, { force: true });
308
+ this.publishedUrl = null;
182
309
  this.retryAttempt = 0;
183
310
  this.retryAfter = 0;
184
311
  this.restartStage('starting');
185
- const ready = await this.ensureNow();
312
+ const ready = await this.ensureNow(true);
186
313
  if (!ready) throw new Error(this.state.error || 'Codex 内核未能重新启动。');
187
- const desktopEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, CODEX_APP_SERVER_WS_URL: managedAppServerUrl };
314
+ const desktopEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, CODEX_APP_SERVER_WS_URL: this.state.url };
188
315
  delete desktopEnvironment.CODEX_APP_SERVER_FORCE_CLI;
189
316
  delete desktopEnvironment.CODEX_APP_TOOLS_PIPE_PATH;
190
317
  await this.host.resumeAfterKernelRestart?.(desktop, desktopEnvironment);
@@ -198,6 +325,7 @@ export class ManagedAppServer extends EventEmitter {
198
325
  completedAt: new Date(this.now()).toISOString() };
199
326
  this.emit('state', this.snapshot());
200
327
  } finally {
328
+ release?.();
201
329
  this.restarting = false;
202
330
  }
203
331
  }
@@ -211,7 +339,7 @@ export class ManagedAppServer extends EventEmitter {
211
339
  const deadline = this.now() + 10_000;
212
340
  while (true) {
213
341
  const actual = await this.host.process(expected.pid);
214
- if (!sameManagedProcess(expected, actual) && await this.available()) return;
342
+ if (!sameManagedProcess(expected, actual)) return;
215
343
  if (this.now() >= deadline) throw new Error('Codex 内核未在 10 秒内退出;不会启动第二个实例。');
216
344
  await this.wait(100);
217
345
  }
@@ -238,8 +366,8 @@ export class ManagedAppServer extends EventEmitter {
238
366
  export function sameManagedProcess(record, actual) {
239
367
  if (!record || !actual || record.pid !== actual.pid || record.created !== actual.created) return false;
240
368
  if (!record.executable || !actual.executable || executableKey(record.executable) !== executableKey(actual.executable)) return false;
241
- if (Array.isArray(actual.args)) return JSON.stringify(record.args) === JSON.stringify(actual.args)
242
- && JSON.stringify(actual.args.slice(1)) === JSON.stringify(['app-server', '--listen', managedAppServerUrl]);
243
- const command = String(actual.command || '').toLowerCase();
244
- return command.includes('app-server') && command.includes(managedAppServerUrl);
369
+ const url = managedProcessUrl(record);
370
+ if (!url || managedProcessUrl(actual) !== url) return false;
371
+ return Array.isArray(actual.args) ? JSON.stringify(record.args) === JSON.stringify(actual.args)
372
+ : record.command === actual.command;
245
373
  }
@@ -0,0 +1,25 @@
1
+ import path from 'node:path';
2
+ import { readRecord } from './service-lifecycle.mjs';
3
+
4
+ export const defaultManagedPort = 45839;
5
+ export function managedEndpointUrl(value) {
6
+ if (typeof value !== 'string' || !/^ws:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}$/.test(value)) return null;
7
+ const port = Number(value.slice(value.lastIndexOf(':') + 1));
8
+ return port <= 65535 ? `ws://127.0.0.1:${port}` : null;
9
+ }
10
+ export function managedProcessUrl(record) {
11
+ if (!record) return null;
12
+ let value;
13
+ if (Array.isArray(record.args)) {
14
+ if (record.args.length !== 4 || record.args[1] !== 'app-server' || record.args[2] !== '--listen') return null;
15
+ value = record.args[3];
16
+ } else {
17
+ const match = /^\s*(?:"[^"]+"|\S+)\s+app-server\s+--listen\s+(?:"(ws:\/\/127\.0\.0\.1:[0-9]+)"|(ws:\/\/127\.0\.0\.1:[0-9]+))\s*$/i.exec(record.command || '');
18
+ value = match?.[1] || match?.[2];
19
+ }
20
+ const url = managedEndpointUrl(value);
21
+ return url && (!record.url || record.url === url) ? url : null;
22
+ }
23
+ export function readManagedEndpoint(stateRoot) {
24
+ return managedProcessUrl(readRecord(path.join(stateRoot, 'managed-app-server.json')));
25
+ }
@@ -8,7 +8,7 @@ import { runtimePaths } from '../runtime-paths.mjs';
8
8
  import { readRecord } from '../../service-lifecycle.mjs';
9
9
  export { residentStartup } from './resident-startup.mjs';
10
10
 
11
- const managedUrl = 'ws://127.0.0.1:45839';
11
+ import { managedEndpointUrl, readManagedEndpoint } from '../../managed-endpoint.mjs';
12
12
  export function createDesktopHost({ request = systemRequest, wait = delay, now = Date.now, timeout = 10000,
13
13
  stateRoot = () => runtimePaths().stateRoot, fingerprint = file => {
14
14
  const stat = fs.statSync(path.join(file, 'resources/linux-package-metadata.json'));
@@ -17,20 +17,24 @@ export function createDesktopHost({ request = systemRequest, wait = delay, now =
17
17
  return {
18
18
  desktopIntegration: true,
19
19
  async inspect() {
20
- const info = await request({ operation: 'desktop-info' });
21
- return { ...info, desktopIdentity: info.desktop ? `${info.desktop}:${info.version}:${info.build}:${fingerprint(info.desktop)}` : null };
20
+ const url = readManagedEndpoint(stateRoot());
21
+ const info = await request({ operation: 'desktop-info', url });
22
+ return { ...info, managedUrl: url, desktopIdentity: info.desktop ? `${info.desktop}:${info.version}:${info.build}:${fingerprint(info.desktop)}` : null };
22
23
  },
23
24
  process: pid => request({ operation: 'process', pid }),
25
+ ownsListener: (expected, url) => request({ operation: 'owns-listener', expected, url }),
24
26
  start: startBackground,
25
27
  async stop(pid, expected) {
26
28
  if (!expected || expected.pid !== pid) throw new Error('停止内核前缺少进程身份,未结束任何进程。');
27
29
  return request({ operation: 'stop', expected });
28
30
  },
29
31
  async sharedServiceUrl() {
30
- return (await this.inspect()).connectedToManagedServer ? managedUrl : '';
32
+ const info = await this.inspect();
33
+ return info.connectedToManagedServer ? info.managedUrl : '';
31
34
  },
32
35
  async startDesktop(bundlePath, environment) {
33
- if (environment.CODEX_APP_SERVER_WS_URL !== managedUrl) throw new Error('Desktop 内核地址与工作站不一致。');
36
+ const managedUrl = managedEndpointUrl(environment.CODEX_APP_SERVER_WS_URL);
37
+ if (!managedUrl || managedUrl !== readManagedEndpoint(stateRoot())) throw new Error('Desktop 内核地址与工作站不一致。');
34
38
  const runtime = readRecord(path.join(stateRoot(), 'codex-runtime.json'));
35
39
  const codexHome = environment.CODEX_HOME || runtime?.codexHome;
36
40
  if (!codexHome || !path.isAbsolute(codexHome)) throw new Error('缺少已选内核的 CODEX_HOME,请先运行 relax setup。');
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import { managedEndpointUrl } from '../../managed-endpoint.mjs';
2
3
 
3
4
  const gone = error => ['ENOENT', 'ESRCH'].includes(error.code);
4
5
  let bootId;
@@ -27,7 +28,10 @@ export function stopProcess(pid, expected) {
27
28
  process.kill(pid, 'SIGTERM');
28
29
  return { stopped: true };
29
30
  }
30
- export function connectedToManagedServer(pid) {
31
+ function hasManagedSocket(pid, url, listener) {
32
+ const endpoint = managedEndpointUrl(url);
33
+ if (!endpoint) return false;
34
+ const port = Number(endpoint.slice(endpoint.lastIndexOf(':') + 1)).toString(16).toUpperCase().padStart(4, '0');
31
35
  try {
32
36
  const sockets = new Set(fs.readdirSync(`/proc/${pid}/fd`).flatMap(fd => {
33
37
  try { return /^socket:\[(\d+)\]$/.exec(fs.readlinkSync(`/proc/${pid}/fd/${fd}`))?.slice(1) || []; }
@@ -35,8 +39,15 @@ export function connectedToManagedServer(pid) {
35
39
  }));
36
40
  return ['tcp', 'tcp6'].some(table => fs.readFileSync(`/proc/${pid}/net/${table}`, 'utf8').trim().split('\n').slice(1).some(line => {
37
41
  const fields = line.trim().split(/\s+/);
38
- return fields[3] === '01' && sockets.has(fields[9])
39
- && ['0100007F:B30F', '0000000000000000FFFF00000100007F:B30F'].includes(fields[2]);
42
+ return fields[3] === (listener ? '0A' : '01') && sockets.has(fields[9])
43
+ && [`0100007F:${port}`, `0000000000000000FFFF00000100007F:${port}`].includes(fields[listener ? 1 : 2]);
40
44
  }));
41
45
  } catch (error) { if (gone(error)) return false; throw error; }
42
46
  }
47
+
48
+ export const connectedToManagedServer = (pid, url) => hasManagedSocket(pid, url, false);
49
+ export function ownsManagedListener(expected, url) {
50
+ return !!expected && sameProcess(expected, processInfo(expected.pid))
51
+ && hasManagedSocket(expected.pid, url, true)
52
+ && sameProcess(expected, processInfo(expected.pid));
53
+ }
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { installedDesktop, verifyDesktopPackage } from './desktop-installation.mjs';
4
- import { processInfo, sameProcess, stopProcess, connectedToManagedServer } from './process.mjs';
4
+ import { processInfo, sameProcess, stopProcess, connectedToManagedServer, ownsManagedListener } from './process.mjs';
5
5
  import { startBackground } from '../../background-process.mjs';
6
6
 
7
7
  // Package upgrades unlink the old executable while its process stays alive.
@@ -10,6 +10,7 @@ import { startBackground } from '../../background-process.mjs';
10
10
  const desktopExecutableMatches = (actual, installed) => actual === installed || actual === `${installed} (deleted)`;
11
11
 
12
12
  export async function systemRequest(request) {
13
+ if (request.operation === 'owns-listener') return ownsManagedListener(request.expected, request.url);
13
14
  if (request.operation === 'process') return processInfo(request.pid);
14
15
  if (request.operation === 'stop') return stopProcess(request.expected?.pid, request.expected);
15
16
  const installed = installedDesktop();
@@ -25,7 +26,7 @@ export async function systemRequest(request) {
25
26
  }
26
27
  if (running.length > 1) throw new Error('发现多个 ChatGPT 桌面实例,请先关闭多余实例。');
27
28
  const info = running[0] || null;
28
- const connected = info ? connectedToManagedServer(info.pid) : false;
29
+ const connected = info ? connectedToManagedServer(info.pid, request.url) : false;
29
30
  if (info && !sameProcess(info, processInfo(info.pid))) throw new Error('核对期间桌面进程发生变化。');
30
31
  return { ...(installed || { desktop: null, version: '', build: '' }), running: !!info,
31
32
  finishedLaunching: connected, connectedToManagedServer: connected, process: info };
@@ -1,7 +1,9 @@
1
+ import { managedEndpointUrl } from '../../managed-endpoint.mjs';
1
2
  // Only the new Desktop process receives the managed connection settings.
2
3
  // Do not mutate shell profiles or the login session's global environment.
3
- export async function configureManagedEnvironment() {
4
+ export async function configureManagedEnvironment(stateRoot, url) {
5
+ if (!managedEndpointUrl(url)) throw new Error('未确认 Codex 内核地址。');
4
6
  return { configured: true, changed: false, owned: false, persistent: false,
5
- scope: 'application-launch', value: 'ws://127.0.0.1:45839' };
7
+ scope: 'application-launch', value: url };
6
8
  }
7
9
  export async function clearManagedEnvironment() { return { removed: false, preserved: false, reason: 'application-launch-only' }; }
@@ -8,7 +8,7 @@ import { runtimePaths } from '../runtime-paths.mjs';
8
8
  import { readRecord } from '../../service-lifecycle.mjs';
9
9
  export { residentStartup } from './resident-startup.mjs';
10
10
 
11
- const managedUrl = 'ws://127.0.0.1:45839';
11
+ import { managedEndpointUrl, readManagedEndpoint } from '../../managed-endpoint.mjs';
12
12
  export function createDesktopHost({ request = systemRequest, wait = delay, now = Date.now, timeout = 10000,
13
13
  stateRoot = () => runtimePaths().stateRoot, fingerprint = file => {
14
14
  const stat = fs.statSync(path.join(file, 'Contents/Info.plist'));
@@ -17,20 +17,24 @@ export function createDesktopHost({ request = systemRequest, wait = delay, now =
17
17
  return {
18
18
  desktopIntegration: true,
19
19
  async inspect() {
20
- const info = await request({ operation: 'desktop-info' });
21
- return { ...info, desktopIdentity: info.desktop ? `${info.desktop}:${info.version}:${info.build}:${fingerprint(info.desktop)}` : null };
20
+ const url = readManagedEndpoint(stateRoot());
21
+ const info = await request({ operation: 'desktop-info', url });
22
+ return { ...info, managedUrl: url, desktopIdentity: info.desktop ? `${info.desktop}:${info.version}:${info.build}:${fingerprint(info.desktop)}` : null };
22
23
  },
23
24
  process: pid => request({ operation: 'process', pid }),
25
+ ownsListener: (expected, url) => request({ operation: 'owns-listener', expected, url }),
24
26
  start: startBackground,
25
27
  async stop(pid, expected) {
26
28
  if (!expected || expected.pid !== pid) throw new Error('停止内核前缺少进程身份,未结束任何进程。');
27
29
  return request({ operation: 'stop', expected });
28
30
  },
29
31
  async sharedServiceUrl() {
30
- return (await this.inspect()).connectedToManagedServer ? managedUrl : '';
32
+ const info = await this.inspect();
33
+ return info.connectedToManagedServer ? info.managedUrl : '';
31
34
  },
32
35
  async startDesktop(bundlePath, environment) {
33
- if (environment.CODEX_APP_SERVER_WS_URL !== managedUrl) throw new Error('Desktop 内核地址与工作站不一致。');
36
+ const managedUrl = managedEndpointUrl(environment.CODEX_APP_SERVER_WS_URL);
37
+ if (!managedUrl || managedUrl !== readManagedEndpoint(stateRoot())) throw new Error('Desktop 内核地址与工作站不一致。');
34
38
  const runtime = readRecord(path.join(stateRoot(), 'codex-runtime.json'));
35
39
  const codexHome = environment.CODEX_HOME || runtime?.codexHome;
36
40
  if (!codexHome || !path.isAbsolute(codexHome)) throw new Error('缺少已选内核的 CODEX_HOME,请先运行 relax setup。');
@@ -1,7 +1,9 @@
1
+ import { managedEndpointUrl } from '../../managed-endpoint.mjs';
1
2
  // Launch Services receives these settings for one new application instance.
2
3
  // Do not mutate shell profiles or the login session's global environment.
3
- export async function configureManagedEnvironment() {
4
+ export async function configureManagedEnvironment(stateRoot, url) {
5
+ if (!managedEndpointUrl(url)) throw new Error('未确认 Codex 内核地址。');
4
6
  return { configured: true, changed: false, owned: false, persistent: false,
5
- scope: 'application-launch', value: 'ws://127.0.0.1:45839' };
7
+ scope: 'application-launch', value: url };
6
8
  }
7
9
  export async function clearManagedEnvironment() { return { removed: false, preserved: false, reason: 'application-launch-only' }; }
@@ -4,6 +4,7 @@
4
4
  )
5
5
  $ErrorActionPreference = 'Stop'
6
6
  [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
7
+ if ($AppServerUrl -cnotmatch '^ws://127\.0\.0\.1:([1-9][0-9]{0,4})\z' -or [int]$Matches[1] -gt 65535) { throw 'Invalid managed app-server URL.' }
7
8
  $package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
8
9
  if (-not $package -or $Executable -ne (Join-Path $package.InstallLocation 'app/ChatGPT.exe')) {
9
10
  throw 'Desktop executable identity changed; activation stopped.'
@@ -3,6 +3,8 @@ import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import path from 'node:path';
6
+ import { managedEndpointUrl, readManagedEndpoint } from '../../managed-endpoint.mjs';
7
+ import { runtimePaths } from './runtime-paths.mjs';
6
8
  import { sharedServiceUrl } from './service-host.mjs';
7
9
  const exec = promisify(execFile);
8
10
  const directory = path.dirname(fileURLToPath(import.meta.url));
@@ -15,15 +17,20 @@ export const desktopHost = {
15
17
  inspect: async () => JSON.parse(await runScript('desktop-info.ps1')),
16
18
  process: async pid => JSON.parse(await runScript('desktop-info.ps1', ['-ProcessId', String(pid)])),
17
19
  sharedServiceUrl,
20
+ ownsListener: async (expected, url) => JSON.parse(await runScript('desktop-info.ps1', ['-ProcessId', String(expected.pid), '-Expected', JSON.stringify(expected), '-AppServerUrl', url, '-CheckListener'])),
18
21
  start: startWindowsProcess,
19
- startDesktop: (executable, environment) => runScript('activate-desktop.ps1', [
20
- '-Executable', executable, '-AppServerUrl', environment.CODEX_APP_SERVER_WS_URL,
21
- ]),
22
+ startDesktop: (executable, environment) => {
23
+ const url = managedEndpointUrl(environment.CODEX_APP_SERVER_WS_URL);
24
+ if (!url || url !== readManagedEndpoint(runtimePaths().stateRoot)) throw new Error('Desktop 内核地址与工作站不一致。');
25
+ return runScript('activate-desktop.ps1', [
26
+ '-Executable', executable, '-AppServerUrl', url,
27
+ ]);
28
+ },
22
29
  async desktopReady() {
23
30
  return (await this.inspect()).hasWindow === true && Boolean(await this.sharedServiceUrl());
24
31
  },
25
32
  closeDesktop: executable => runScript('stop-desktop.ps1', ['-Executable', executable]),
26
- stop: pid => process.kill(pid),
33
+ stop: (pid, expected) => runScript('desktop-info.ps1', ['-ProcessId', String(pid), '-Expected', JSON.stringify(expected), '-StopManagedProcess']),
27
34
  async prepareKernelRestart() {
28
35
  const info = await this.inspect();
29
36
  if (!info.running || !await this.sharedServiceUrl()) return null;
@@ -1,6 +1,30 @@
1
- param([int]$ProcessId = 0)
1
+ param([int]$ProcessId = 0, [string]$Expected, [string]$AppServerUrl, [switch]$CheckListener, [switch]$StopManagedProcess)
2
2
  $ErrorActionPreference = 'Stop'
3
3
  [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
+ function Read-Identity([int]$Id) {
5
+ $item = Get-CimInstance Win32_Process -Filter "ProcessId=$Id"
6
+ if ($item) { return @{ pid=$item.ProcessId; executable=$item.ExecutablePath; command=$item.CommandLine; created=$item.CreationDate.ToUniversalTime().ToString('o') } }
7
+ return $null
8
+ }
9
+ function Same-Identity($a, $b) {
10
+ return $null -ne $a -and $null -ne $b -and $a.pid -eq $b.pid -and $a.executable -ceq $b.executable -and $a.command -ceq $b.command -and $a.created -ceq $b.created
11
+ }
12
+ if ($CheckListener -or $StopManagedProcess) {
13
+ if ($ProcessId -le 0 -or -not $Expected) { throw 'Missing managed process identity.' }
14
+ $identity = $Expected | ConvertFrom-Json
15
+ if ($identity.pid -ne $ProcessId) { throw 'Process identity mismatch.' }
16
+ $actual = Read-Identity $ProcessId
17
+ if (-not (Same-Identity $identity $actual)) {
18
+ if ($StopManagedProcess) { throw 'Process identity changed; nothing stopped.' }
19
+ 'false'; exit
20
+ }
21
+ if ($StopManagedProcess) { Stop-Process -Id $ProcessId -ErrorAction Stop; '{"stopped":true}'; exit }
22
+ if ($AppServerUrl -cnotmatch '^ws://127\.0\.0\.1:([1-9][0-9]{0,4})\z' -or [int]$Matches[1] -gt 65535) { throw 'Invalid managed app-server URL.' }
23
+ $port = [int]$Matches[1]
24
+ $listeners = @(Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.LocalAddress -eq '127.0.0.1' -and $_.OwningProcess -eq $ProcessId })
25
+ [bool]($listeners.Count -gt 0 -and (Same-Identity $identity (Read-Identity $ProcessId))) | ConvertTo-Json -Compress
26
+ exit
27
+ }
4
28
  if ($ProcessId -gt 0) {
5
29
  $item = Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId"
6
30
  if ($item) { @{ pid=$item.ProcessId; executable=$item.ExecutablePath; command=$item.CommandLine; created=$item.CreationDate.ToUniversalTime().ToString('o') } | ConvertTo-Json -Compress }
@@ -5,19 +5,23 @@ import { fileURLToPath } from 'node:url';
5
5
  import fs from 'node:fs';
6
6
  import { runtimePaths } from './runtime-paths.mjs';
7
7
  import { readRecord } from '../../service-lifecycle.mjs';
8
+ import { readManagedEndpoint } from '../../managed-endpoint.mjs';
8
9
  import { lanNetworks } from './network-info.mjs';
9
10
 
10
11
  const exec = promisify(execFile);
11
12
  const bridgeRoot = fileURLToPath(new URL('../../../', import.meta.url));
12
- async function detect(script) {
13
+ async function detect(script, args = []) {
13
14
  const executable = path.join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe');
14
15
  const { stdout } = await exec(executable, ['-NoProfile', '-NonInteractive', '-File',
15
- path.join(bridgeRoot, script)], { windowsHide: true, encoding: 'utf8' });
16
+ path.join(bridgeRoot, script), ...args], { windowsHide: true, encoding: 'utf8' });
16
17
  return stdout.trim();
17
18
  }
18
19
 
19
20
  // Windows-only network metadata stays here, outside the lifecycle implementation.
20
- export const sharedServiceUrl = () => detect('get-shared-service-url.ps1');
21
+ export const sharedServiceUrl = () => {
22
+ const url = readManagedEndpoint(runtimePaths().stateRoot);
23
+ return url ? detect('get-shared-service-url.ps1', ['-AppServerUrl', url]) : '';
24
+ };
21
25
  export const lanAddress = async () => (await lanNetworks())[0]?.address || '';
22
26
  export { lanNetworks };
23
27
  export async function bridgeEnvironment() {
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
  import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
4
4
  import { runScript } from './desktop-host.mjs';
5
- import { managedAppServerUrl } from '../../managed-app-server.mjs';
5
+ import { managedEndpointUrl } from '../../managed-endpoint.mjs';
6
6
 
7
7
  const ownershipFile = 'managed-environment.json';
8
8
 
@@ -10,22 +10,24 @@ export async function readManagedEnvironment() {
10
10
  return JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Read']));
11
11
  }
12
12
 
13
- export async function configureManagedEnvironment(stateRoot) {
13
+ export async function configureManagedEnvironment(stateRoot, url) {
14
14
  const current = await readManagedEnvironment();
15
+ if (!managedEndpointUrl(url)) throw new Error('未确认 Codex 内核地址,未修改环境变量。');
15
16
  const ownershipPath = path.join(stateRoot, ownershipFile);
16
17
  const ownership = readRecord(ownershipPath);
17
18
  if (current.forceCliUser === '1' || current.forceCliMachine === '1') {
18
19
  throw new Error('CODEX_APP_SERVER_FORCE_CLI=1 会让 Codex 忽略当前内核地址;未修改该配置。');
19
20
  }
20
- if (current.userValue && current.userValue !== managedAppServerUrl) {
21
+ if (current.userValue && current.userValue !== url && current.userValue !== ownership?.value) {
21
22
  throw new Error(`用户已有 CODEX_APP_SERVER_WS_URL=${current.userValue};未覆盖该配置。`);
22
23
  }
23
- if (current.userValue === managedAppServerUrl) {
24
- const owned = ownership?.value === managedAppServerUrl;
24
+ if (current.userValue === url) {
25
+ const owned = ownership?.value === url;
25
26
  return { configured: true, value: current.userValue, changed: false, owned };
26
27
  }
27
- const applied = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Set', '-Value', managedAppServerUrl]));
28
- writeRecord(ownershipPath, { name: 'CODEX_APP_SERVER_WS_URL', value: managedAppServerUrl,
28
+ const applied = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Set', '-Value', url]));
29
+ if (applied.userValue !== url) throw new Error('CODEX_APP_SERVER_WS_URL 写入后读回不一致。');
30
+ writeRecord(ownershipPath, { name: 'CODEX_APP_SERVER_WS_URL', value: url,
29
31
  configuredAt: new Date().toISOString() });
30
32
  return { configured: true, value: applied.userValue, changed: true, owned: true };
31
33
  }
@@ -38,10 +40,10 @@ export async function clearManagedEnvironment(stateRoot) {
38
40
  fs.rmSync(ownershipPath, { force: true });
39
41
  return { removed: false, preserved: false, reason: 'already-empty' };
40
42
  }
41
- if (ownership?.value !== managedAppServerUrl || current.userValue !== managedAppServerUrl) {
43
+ if (!managedEndpointUrl(ownership?.value) || current.userValue !== ownership.value) {
42
44
  fs.rmSync(ownershipPath, { force: true });
43
45
  return { removed: false, preserved: true, value: current.userValue,
44
- reason: ownership?.value === managedAppServerUrl ? 'user-modified' : 'not-owned' };
46
+ reason: managedEndpointUrl(ownership?.value) ? 'user-modified' : 'not-owned' };
45
47
  }
46
48
  const result = JSON.parse(await runScript('user-environment.ps1', ['-Action', 'Clear']));
47
49
  if (result.userValue) throw new Error('CODEX_APP_SERVER_WS_URL 清理后读回仍不为空。');
@@ -29,7 +29,7 @@ export class RuntimeDiscovery {
29
29
  return this.state;
30
30
  }
31
31
  const desktop = await this.host.inspect();
32
- const attached = desktop.running && await this.host.sharedServiceUrl() === this.url;
32
+ const attached = desktop.running && await this.host.sharedServiceUrl() === (typeof this.url === 'function' ? this.url() : this.url);
33
33
  this.state = { desktop: { state: !desktop.running ? 'stopped' : attached ? 'managed' : 'ordinary',
34
34
  installed: Boolean(desktop.desktop), attachedToManagedServer: Boolean(attached) },
35
35
  desktopRestartRecommended: Boolean(desktop.running && !attached), checkedAt: new Date().toISOString() };
@@ -1,7 +1,7 @@
1
1
  export function deviceInfo(value = {}) {
2
2
  const info = {};
3
3
  if (value.platform !== undefined && value.platform !== null) {
4
- if (!['android', 'harmony'].includes(value.platform)) throw new Error('Invalid mobile platform');
4
+ if (!['android', 'harmony', 'ios'].includes(value.platform)) throw new Error('Invalid mobile platform');
5
5
  info.platform = value.platform;
6
6
  }
7
7
  if (value.deviceName !== undefined && value.deviceName !== null) {
@@ -13,7 +13,7 @@ import fileMentions from '../shared/file-mentions.cjs';
13
13
  import fileCitations from '../shared/file-citations.cjs';
14
14
  import { validateInlineImage } from './message-images.mjs';
15
15
  import { ThreadWriterConflictError } from './shared-app-server.mjs';
16
- import { ManagedAppServer, managedAppServerUrl } from './managed-app-server.mjs';
16
+ import { ManagedAppServer } from './managed-app-server.mjs';
17
17
  import { RuntimeDiscovery } from './runtime-discovery.mjs';
18
18
  import { streamSharedThread, threadListNotificationMethods } from './shared-thread-stream.mjs';
19
19
  import appServerEvents from '../shared/app-server-events.cjs';
@@ -118,7 +118,7 @@ const dotsServer = new DotsAppServer(managedAppServer.client, desktopDotsProfile
118
118
  const dotsImages = new DotsMessageImages({ localServer: managedAppServer.client, identity: () => dotsServer.profileIdentity,
119
119
  cache: attachmentCache, capabilityKey: imageCapabilityKey, maxEntries: MAX_CACHED_ATTACHMENTS, maxBytes: MAX_ATTACHMENT_BYTES });
120
120
  const dotsChat = new DotsChatServer(dotsServer, undefined, { uploadsRoot });
121
- const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url: managedAppServerUrl });
121
+ const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url: () => managedAppServer.snapshot().url });
122
122
  const relayManager = createRelayManager(stateRoot);
123
123
  let sharedCatalog = null, executionMode = 'ordinary';
124
124
  function selectSharedExecution(enabled) {
@@ -1892,3 +1892,4 @@ function boundedInteger(value, fallback, minimum, maximum) {
1892
1892
  const parsed = Number.parseInt(value || '', 10);
1893
1893
  return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, parsed)) : fallback;
1894
1894
  }
1895
+
@@ -8,7 +8,7 @@ import { startBackground } from './background-process.mjs';
8
8
  import { desktopHost } from './platform/desktop-host.mjs';
9
9
  import * as serviceHost from './platform/service-host.mjs';
10
10
  import { launchDesktop, portAvailable } from './desktop-launcher.mjs';
11
- import { configureManagedEnvironment } from './platform/user-environment.mjs';
11
+ import { sameManagedProcess } from './managed-app-server.mjs';
12
12
 
13
13
  const script = fileURLToPath(import.meta.url);
14
14
  const recordPath = root => path.join(root, 'shared-recovery.json');
@@ -45,10 +45,9 @@ export async function planRecovery(stateRoot, host, available = portAvailable) {
45
45
  if (!info.desktop) throw new Error('未找到 Codex Desktop;CLI 用户无需执行 Desktop 接入。');
46
46
  const record = readRecord(path.join(stateRoot, 'managed-app-server.json'));
47
47
  const actual = record ? await host.process(record.pid) : null;
48
- if (actual && (!sameProcess(record, actual) || !actual.command?.includes('app-server') || !actual.command.includes('ws://127.0.0.1:45839'))) {
48
+ if (actual && !sameManagedProcess(record, actual)) {
49
49
  throw new Error('Codex 内核进程身份与记录不符,未结束任何进程。');
50
50
  }
51
- if (!actual && !await available()) throw new Error('45839 被未识别进程占用,未关闭 Desktop 或结束任何进程。');
52
51
  return { desktop: info.desktop, action };
53
52
  }
54
53
 
@@ -102,7 +101,6 @@ export async function runRecovery(stateRoot, operationId, dependencies = {}) {
102
101
  try {
103
102
  stage('checking');
104
103
  const planned = await planRecovery(stateRoot, host, available);
105
- await configureManagedEnvironment(stateRoot);
106
104
  record = { ...record, ...planned };
107
105
  if (fs.existsSync(path.join(stateRoot, 'desktop-launch.lock'))) throw new Error('Desktop 正在启动,未与启动操作并行恢复。');
108
106
  stage('stopping-desktop'); await host.closeDesktop(record.desktop);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.42",
3
+ "version": "0.1.0-beta.43",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -41,8 +41,8 @@
41
41
  "tag": "beta"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.42",
45
- "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.42",
46
- "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.42"
44
+ "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.43",
45
+ "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.43",
46
+ "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.43"
47
47
  }
48
48
  }