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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +14 -15
  2. package/dist/bin/codex-remote.mjs +81 -26
  3. package/dist/bin/self-relay-admin.mjs +8 -0
  4. package/dist/bin/self-relay-demo.mjs +1 -1
  5. package/dist/bin/self-relay-server.mjs +5 -2
  6. package/dist/shared/app-server-events.cjs +50 -22
  7. package/dist/shared/p2p-data-channel.cjs +50 -0
  8. package/dist/skills/code-relax/SKILL.md +1 -1
  9. package/dist/src/desktop-launcher.mjs +11 -11
  10. package/dist/src/lan-socket-server.mjs +8 -2
  11. package/dist/src/managed-app-server.mjs +27 -7
  12. package/dist/src/message-images.mjs +0 -18
  13. package/dist/src/onboarding.mjs +3 -5
  14. package/dist/src/platform/README.md +1 -1
  15. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  16. package/dist/src/platform/windows/background-process.mjs +7 -2
  17. package/dist/src/platform/windows/codex-runtime.mjs +117 -0
  18. package/dist/src/platform/windows/desktop-host.mjs +16 -1
  19. package/dist/src/platform/windows/desktop-info.ps1 +2 -4
  20. package/dist/src/platform/windows/desktop-shortcut-run.ps1 +1 -1
  21. package/dist/src/platform/windows/desktop-shortcuts.ps1 +1 -1
  22. package/dist/src/platform/windows/launch-worker.mjs +42 -0
  23. package/dist/src/platform/windows/resident-startup.ps1 +58 -18
  24. package/dist/src/platform/windows/service-host.mjs +4 -7
  25. package/dist/src/platform/windows/start-hidden-console.ps1 +18 -8
  26. package/dist/src/platform/windows/stop-desktop.ps1 +7 -4
  27. package/dist/src/runtime-discovery.mjs +43 -0
  28. package/dist/src/self-relay/admin-cloud.mjs +101 -0
  29. package/dist/src/self-relay/admin-page.mjs +71 -0
  30. package/dist/src/self-relay/admin-server.mjs +61 -0
  31. package/dist/src/self-relay/admin-state.mjs +122 -0
  32. package/dist/src/self-relay/client.mjs +2 -0
  33. package/dist/src/self-relay/connector.mjs +23 -8
  34. package/dist/src/self-relay/demo.mjs +4 -5
  35. package/dist/src/self-relay/lifecycle.mjs +53 -49
  36. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  37. package/dist/src/self-relay/server.mjs +157 -17
  38. package/dist/src/server.mjs +484 -1274
  39. package/dist/src/service-doctor.mjs +13 -25
  40. package/dist/src/service-lifecycle.mjs +12 -12
  41. package/dist/src/shared-app-server.mjs +192 -34
  42. package/dist/src/shared-catalog.mjs +4 -14
  43. package/dist/src/shared-recovery.mjs +8 -6
  44. package/dist/src/shared-thread-stream.mjs +1 -1
  45. package/dist/web/activity-view.js +283 -0
  46. package/dist/web/capabilities.js +2 -9
  47. package/dist/web/chat-transport.js +21 -21
  48. package/dist/web/chat.css +148 -106
  49. package/dist/web/chat.js +1454 -2788
  50. package/dist/web/community.css +82 -0
  51. package/dist/web/community.html +36 -0
  52. package/dist/web/composer-controller.js +97 -0
  53. package/dist/web/conversation-controller.js +99 -0
  54. package/dist/web/disclosure-state-controller.js +95 -0
  55. package/dist/web/draft-controller.js +103 -0
  56. package/dist/web/harmony-platform.js +3 -2
  57. package/dist/web/history-cache.js +112 -18
  58. package/dist/web/history-controller.js +167 -0
  59. package/dist/web/index.html +113 -47
  60. package/dist/web/link-action-controller.js +212 -0
  61. package/dist/web/message-send-controller.js +177 -0
  62. package/dist/web/message-view.js +98 -0
  63. package/dist/web/p2p-data-channel.js +50 -0
  64. package/dist/web/p2p-probe.js +67 -27
  65. package/dist/web/page-resume.js +28 -0
  66. package/dist/web/pending-message-store.js +108 -0
  67. package/dist/web/queue-controller.js +82 -0
  68. package/dist/web/resources.json +1 -1
  69. package/dist/web/self-relay-session.js +28 -18
  70. package/dist/web/station-connection-controller.js +75 -0
  71. package/dist/web/task-list-view.js +289 -0
  72. package/dist/web/thread-attention-controller.js +124 -0
  73. package/dist/web/thread-context-controller.js +30 -0
  74. package/dist/web/thread-list-controller.js +61 -0
  75. package/dist/web/thread-list-sync.js +86 -0
  76. package/dist/web/thread-title-controller.js +57 -0
  77. package/dist/web/timeline-formatters.js +249 -0
  78. package/dist/web/timeline-reducer.js +77 -0
  79. package/dist/web/timeline-renderer.js +161 -0
  80. package/dist/web/timeline-scroll-controller.js +50 -0
  81. package/dist/web/usage-controller.js +258 -0
  82. package/dist/web/vendor/lucide.LICENSE.txt +17 -0
  83. package/package.json +16 -18
  84. package/tools/postinstall.mjs +140 -3
  85. package/dist/src/app-server-client.mjs +0 -468
  86. package/dist/src/app-server-tasks.mjs +0 -355
  87. package/dist/src/platform/windows/desktop-monitor.mjs +0 -34
  88. package/dist/src/platform/windows/desktop-tools.mjs +0 -48
  89. package/dist/src/platform/windows/launch-worker.ps1 +0 -13
  90. package/dist/src/thread-catalog.mjs +0 -42
  91. package/dist/tools/find-desktop-pipe.mjs +0 -10
@@ -5,9 +5,11 @@ import { EventEmitter } from 'node:events';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { readRecord, writeRecord } from './service-lifecycle.mjs';
7
7
  import { SharedAppServer } from './shared-app-server.mjs';
8
+ import { resolveCodexRuntime } from './platform/windows/codex-runtime.mjs';
8
9
 
9
10
  export const managedAppServerUrl = 'ws://127.0.0.1:45839';
10
11
  export const managedAppServerReadyUrl = 'http://127.0.0.1:45839/readyz';
12
+ export const managedAppServerLauncherRevision = 2;
11
13
  const retryDelays = [1_000, 2_000, 5_000, 10_000, 20_000];
12
14
 
13
15
  export function managedPortAvailable() {
@@ -65,36 +67,46 @@ export class ManagedAppServer extends EventEmitter {
65
67
  if (!this.state.ready) this.publish({ state: 'starting', ready: false, error: null });
66
68
  try {
67
69
  fs.mkdirSync(this.stateRoot, { recursive: true });
68
- const info = await this.host.inspect();
69
- if (!info.cli) throw new Error('未找到 Desktop 管理的 codex.exe,请先安装并登录 Codex Desktop。');
70
70
  let record = null, actual = null;
71
71
  for (const candidate of [readRecord(this.recordPath), readRecord(this.legacyRecordPath)].filter(Boolean)) {
72
72
  const process = candidate.pid ? await this.host.process(candidate.pid) : null;
73
73
  if (sameManagedProcess(candidate, process)) { record = candidate; actual = process; break; }
74
74
  }
75
+ const runtime = actual
76
+ ? readRecord(path.join(this.stateRoot, 'codex-runtime.json')) || { executable: actual.executable }
77
+ : await resolveCodexRuntime(this.stateRoot, { environment: this.environment });
75
78
  if (!actual) {
76
79
  if (!await this.available()) throw new Error('45839 被未识别进程占用,未启动或结束任何进程。');
77
80
  const childEnvironment = { ...this.environment };
81
+ childEnvironment.CODEX_HOME = runtime.codexHome;
78
82
  for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnvironment[key];
79
- const pid = await this.host.start(info.cli, ['app-server', '--listen', managedAppServerUrl], childEnvironment,
83
+ for (const key of Object.keys(childEnvironment)) {
84
+ if (/^npm_/iu.test(key) || key.toUpperCase() === 'INIT_CWD') delete childEnvironment[key];
85
+ }
86
+ const pid = await this.host.start(runtime.executable, ['app-server', '--listen', managedAppServerUrl], childEnvironment,
80
87
  [path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
81
88
  const started = await this.host.process(pid);
82
89
  if (!sameManagedProcess(started, started)) throw new Error('Codex 内核启动后进程身份无法确认。');
83
- record = actual = started;
90
+ actual = started;
91
+ record = { ...started, launcherRevision: managedAppServerLauncherRevision };
84
92
  writeRecord(this.recordPath, record);
85
93
  } else if (!readRecord(this.recordPath)) {
86
- writeRecord(this.recordPath, actual);
94
+ record = { ...actual, launcherRevision: Number.isSafeInteger(record?.launcherRevision) ? record.launcherRevision : 0 };
95
+ writeRecord(this.recordPath, record);
87
96
  }
88
97
  await this.waitUntilReady(actual);
89
98
  await this.client.connect();
90
99
  if (!this.client.ready) throw new Error('Codex 内核协议初始化未完成。');
91
- const preferredExecutable = path.resolve(info.cli);
100
+ const preferredExecutable = path.resolve(runtime.executable);
92
101
  const updatePending = path.resolve(actual.executable).toLowerCase() !== preferredExecutable.toLowerCase();
102
+ const launcherRevision = Number.isSafeInteger(record?.launcherRevision) ? record.launcherRevision : 0;
93
103
  this.retryAttempt = 0;
94
104
  this.retryAfter = 0;
95
105
  this.publish({ state: 'ready', ready: true, error: null, pid: actual.pid,
96
106
  created: actual.created, executable: actual.executable, preferredExecutable,
97
- updatePending, url: managedAppServerUrl });
107
+ updatePending, launcherRevision, selectedVersion: runtime.version, source: runtime.source,
108
+ launcherRestartRequired: launcherRevision !== managedAppServerLauncherRevision,
109
+ url: managedAppServerUrl });
98
110
  return true;
99
111
  } catch (error) {
100
112
  this.failed(error instanceof Error ? error.message : String(error));
@@ -138,7 +150,11 @@ export class ManagedAppServer extends EventEmitter {
138
150
  if (actual && !sameManagedProcess(record, actual)) {
139
151
  throw new Error('Codex 内核进程身份已经变化,未结束任何进程。');
140
152
  }
153
+ if (!actual && !await this.available()) throw new Error('45839 被未识别进程占用,未结束任何进程。');
141
154
  this.restartStage('stopping');
155
+ // Resolve the replacement before stopping the current working process.
156
+ const runtime = await resolveCodexRuntime(this.stateRoot, { refresh: true, environment: this.environment });
157
+ const desktop = await this.host.prepareKernelRestart?.();
142
158
  this.client.close();
143
159
  if (actual) {
144
160
  await this.host.stop(actual.pid);
@@ -152,6 +168,10 @@ export class ManagedAppServer extends EventEmitter {
152
168
  this.restartStage('starting');
153
169
  const ready = await this.ensureNow();
154
170
  if (!ready) throw new Error(this.state.error || 'Codex 内核未能重新启动。');
171
+ const desktopEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, CODEX_APP_SERVER_WS_URL: managedAppServerUrl };
172
+ delete desktopEnvironment.CODEX_APP_SERVER_FORCE_CLI;
173
+ delete desktopEnvironment.CODEX_APP_TOOLS_PIPE_PATH;
174
+ await this.host.resumeAfterKernelRestart?.(desktop, desktopEnvironment);
155
175
  this.restartStatus = { ...this.restartStatus, status: 'completed', stage: 'ready',
156
176
  completedAt: new Date(this.now()).toISOString() };
157
177
  this.emit('state', this.snapshot());
@@ -1,8 +1,3 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import crypto from 'node:crypto';
4
- import sharp from 'sharp';
5
-
6
1
  export function validateInlineImage(value) {
7
2
  const data = typeof value === 'string' ? value.slice(value.indexOf(',') + 1) : '';
8
3
  if (typeof value !== 'string' || value.length > Math.ceil(8 * 1024 * 1024 / 3) * 4 + 64 ||
@@ -12,16 +7,3 @@ export function validateInlineImage(value) {
12
7
  }
13
8
  return value;
14
9
  }
15
-
16
- // Only the Desktop tool compatibility path needs a file visible to the host.
17
- export async function persistLegacyImage(value, directory) {
18
- validateInlineImage(value);
19
- const input = Buffer.from(value.slice(value.indexOf(',') + 1), 'base64');
20
- const buffer = await sharp(input, { limitInputPixels: 40_000_000 }).rotate()
21
- .resize({ width: 2560, height: 2560, fit: 'inside', withoutEnlargement: true }).webp({ quality: 88 }).toBuffer();
22
- const id = crypto.createHash('sha256').update(buffer).digest('hex');
23
- await fs.mkdir(directory, { recursive: true });
24
- const file = path.join(directory, `${id}.webp`);
25
- await fs.writeFile(file, buffer);
26
- return file;
27
- }
@@ -1,12 +1,12 @@
1
1
  export const connectionModesText = [
2
2
  '可选连接模式:',
3
3
  '• 局域网:启动时会把当前 Wi-Fi/有线网络候选临时发给已配对设备;App 验证后保存,也可手动输入 IP。',
4
- '• 私有中继:同一设备授权可跨网络长期重连;基础控制/完整控制能力由电脑当前 Bridge 状态决定。',
4
+ '• 私有中继:同一设备授权可跨网络长期重连;Codex 内核就绪后才能进入聊天。',
5
5
  '• OpenAI 官方中继:在已配对电脑内使用官方账号和设备授权;认证仍直连 OpenAI。',
6
6
  ].join('\n');
7
7
 
8
8
  export function phonePairingSteps(transportMode, paired = false) {
9
- const mode = transportMode === 'lan-shared' ? '局域网 · 完整控制' : transportMode === 'lan-legacy' ? '局域网 · 基础控制' : '局域网(按电脑实际控制方式选择)';
9
+ const mode = transportMode === 'lan-shared' ? '局域网 · Codex 内核已就绪' : '局域网(Codex 内核未就绪)';
10
10
  return paired ? [
11
11
  `已有移动设备授权。手机可继续使用私有中继;${mode} 会显示验证成功的当前网络和历史记录。`,
12
12
  '添加另一台设备时执行 relax pair;每台设备只需扫码配对一次。',
@@ -19,10 +19,8 @@ export function phonePairingSteps(transportMode, paired = false) {
19
19
  export const desktopSetupSteps = [
20
20
  'Code Relax Skill 已随 npm 包自动安装。',
21
21
  '安装完成后 Bridge 和受管 app-server 已启动,并默认在当前用户登录时启动。',
22
- '如果安装时 Codex 仍在运行,手机完整控制仍可使用;可选择立即或稍后重启,让 Desktop 也接入同一受管服务。',
23
- '基础控制首次聊天需要在 Codex 对话启动一次 Code Relax 绑定上下文;完整控制不需要。',
22
+ 'CLI 用户无需安装 Desktop;已安装 Desktop 时,安装器负责将运行中的 Desktop 接入同一内核。',
24
23
  '尚无设备授权时执行 relax pair,打开并扫描配对二维码。',
25
- '若工作站提示 Desktop 尚未接入受管服务,可在手机确认重新打开 Desktop,无需电脑脚本或快捷方式。',
26
24
  '统一配对先建立电脑记录;进入该电脑后可另行完成 OpenAI 官方 Remote 配对。',
27
25
  ];
28
26
 
@@ -3,7 +3,7 @@
3
3
  当前只实现 Windows Codex:
4
4
 
5
5
  - `windows/runtime-paths.mjs`:固定服务、用户状态、Codex 数据、附件和长期凭据路径。
6
- - `windows/service-host.mjs`、`desktop-tools.mjs`:网卡、Desktop 工具管道、共享连接检测和旧状态迁移。
6
+ - `windows/service-host.mjs`:网卡、Bridge 进程环境和旧状态迁移。
7
7
  - `windows/desktop-host.mjs`、PowerShell 脚本:普通/共享 Desktop、快捷方式和隐藏控制台启动。
8
8
  - `windows/credential-store.mjs/.ps1`:私有中继电脑凭据的 DPAPI 与 ACL。
9
9
 
@@ -48,8 +48,8 @@ public static class IsolatedProcess {
48
48
  if(handle==new IntPtr(-1)) throw new Win32Exception(Marshal.GetLastWin32Error());
49
49
  return handle;
50
50
  }
51
- public static int Start(string executable,string[] arguments,string stdout,string stderr) {
52
- IntPtr input=IntPtr.Zero,output=IntPtr.Zero,error=IntPtr.Zero,list=IntPtr.Zero,handles=IntPtr.Zero;
51
+ public static int Start(string executable,string[] arguments,string stdout,string stderr,string[] environment) {
52
+ IntPtr input=IntPtr.Zero,output=IntPtr.Zero,error=IntPtr.Zero,list=IntPtr.Zero,handles=IntPtr.Zero,environmentBlock=IntPtr.Zero;
53
53
  bool initialized=false;
54
54
  try {
55
55
  input=File("NUL",true); output=File(String.IsNullOrEmpty(stdout)?"NUL":stdout,false); error=File(String.IsNullOrEmpty(stderr)?"NUL":stderr,false);
@@ -63,12 +63,16 @@ public static class IsolatedProcess {
63
63
  startup.startup.flags=0x101; startup.startup.show=0;
64
64
  startup.startup.input=input; startup.startup.output=output; startup.startup.error=error; startup.attributes=list;
65
65
  var command=new StringBuilder(Quote(executable)); foreach(var argument in arguments) command.Append(' ').Append(Quote(argument));
66
+ Array.Sort(environment,StringComparer.OrdinalIgnoreCase);
67
+ var environmentBytes=Encoding.Unicode.GetBytes(String.Join("\0",environment)+"\0\0");
68
+ environmentBlock=Marshal.AllocHGlobal(environmentBytes.Length); Marshal.Copy(environmentBytes,0,environmentBlock,environmentBytes.Length);
66
69
  ProcessInfo info;
67
- if(!CreateProcessW(executable,command,IntPtr.Zero,IntPtr.Zero,true,0x80010,IntPtr.Zero,null,ref startup,out info)) throw new Win32Exception(Marshal.GetLastWin32Error());
70
+ if(!CreateProcessW(executable,command,IntPtr.Zero,IntPtr.Zero,true,0x80410,environmentBlock,null,ref startup,out info)) throw new Win32Exception(Marshal.GetLastWin32Error());
68
71
  CloseHandle(info.thread); CloseHandle(info.process); return info.pid;
69
72
  } finally {
70
73
  if(initialized) DeleteProcThreadAttributeList(list);
71
74
  if(list!=IntPtr.Zero) Marshal.FreeHGlobal(list); if(handles!=IntPtr.Zero) Marshal.FreeHGlobal(handles);
75
+ if(environmentBlock!=IntPtr.Zero) Marshal.FreeHGlobal(environmentBlock);
72
76
  if(input!=IntPtr.Zero) CloseHandle(input); if(output!=IntPtr.Zero) CloseHandle(output); if(error!=IntPtr.Zero) CloseHandle(error);
73
77
  }
74
78
  }
@@ -5,9 +5,14 @@ import path from 'node:path';
5
5
  export function startWindowsProcess(executable, args, environment, logs = []) {
6
6
  return new Promise((resolve, reject) => {
7
7
  const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
8
+ const helperEnvironment = {};
9
+ for (const name of ['SystemRoot', 'windir', 'LOCALAPPDATA', 'USERPROFILE', 'TEMP', 'TMP', 'PSModulePath']) {
10
+ const key = Object.keys(process.env).find(item => item.toLowerCase() === name.toLowerCase());
11
+ if (key) helperEnvironment[key] = process.env[key];
12
+ }
8
13
  const helper = spawn(powershell, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
9
14
  fileURLToPath(new URL('./start-hidden-console.ps1', import.meta.url))], {
10
- windowsHide: true, env: environment, stdio: ['pipe', 'pipe', 'pipe'],
15
+ windowsHide: true, env: helperEnvironment, stdio: ['pipe', 'pipe', 'pipe'],
11
16
  });
12
17
  let output = '', error = '';
13
18
  helper.stdout.setEncoding('utf8').on('data', chunk => { output += chunk; });
@@ -20,6 +25,6 @@ export function startWindowsProcess(executable, args, environment, logs = []) {
20
25
  if (!Number.isSafeInteger(pid) || pid <= 0) return reject(new Error('后台启动未返回有效 PID'));
21
26
  resolve(pid);
22
27
  });
23
- helper.stdin.end(JSON.stringify({ executable, args, logs }));
28
+ helper.stdin.end(JSON.stringify({ brokerExecutable: process.execPath, executable, args, logs, environment }));
24
29
  });
25
30
  }
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { readRecord, writeRecord } from '../../service-lifecycle.mjs';
8
+
9
+ const exec = promisify(execFile);
10
+ const requiredMethods = ['thread/start', 'thread/resume', 'thread/list', 'thread/turns/list',
11
+ 'thread/items/list', 'turn/start', 'turn/steer', 'turn/interrupt', 'model/list', 'project/list',
12
+ 'thread/settings/update', 'configRequirements/read', 'thread/queue/list', 'thread/queue/add',
13
+ 'thread/queue/delete', 'thread/queue/start', 'thread/read', 'thread/unsubscribe', 'config/read', 'account/rateLimits/read'];
14
+ const fingerprint = file => { const s = fs.statSync(file); return `${s.size}:${s.mtimeMs}`; };
15
+
16
+ // Resolve native binaries, never execute a shell shim from a background task.
17
+ export function discoverCodex(environment = process.env) {
18
+ const found = new Map();
19
+ const add = (file, source) => {
20
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return;
21
+ const executable = fs.realpathSync(file);
22
+ if (!found.has(executable.toLowerCase()) || source === 'desktop') found.set(executable.toLowerCase(), { executable, source });
23
+ };
24
+ if (environment.CODE_RELAX_CODEX_PATH) {
25
+ const file = environment.CODE_RELAX_CODEX_PATH;
26
+ if (!path.isAbsolute(file) || path.basename(file).toLowerCase() !== 'codex.exe') throw new Error('CODE_RELAX_CODEX_PATH 必须是 codex.exe 的绝对路径。');
27
+ add(file, 'explicit');
28
+ if (!found.size) throw new Error('CODE_RELAX_CODEX_PATH 指定的程序不存在。');
29
+ return [...found.values()];
30
+ }
31
+ const local = environment.LOCALAPPDATA || path.join(os.homedir(), 'AppData/Local');
32
+ const home = environment.CODEX_HOME || path.join(os.homedir(), '.codex');
33
+ const directories = [...String(environment.PATH || environment.Path || '').split(';').filter(Boolean),
34
+ environment.CODEX_INSTALL_DIR, path.join(local, 'Programs/OpenAI/Codex/bin'),
35
+ path.join(home, 'packages/standalone/current/bin')].filter(Boolean);
36
+ for (const directory of directories) {
37
+ add(path.join(directory, 'codex.exe'), 'cli');
38
+ const packageFile = path.join(directory, 'node_modules/@openai/codex/package.json');
39
+ if (!fs.existsSync(packageFile)) continue;
40
+ const root = path.dirname(fs.realpathSync(packageFile));
41
+ let vendor = path.join(root, 'vendor');
42
+ try { vendor = path.join(path.dirname(createRequire(packageFile).resolve('@openai/codex-win32-x64/package.json')), 'vendor'); }
43
+ catch (error) { if (error.code !== 'MODULE_NOT_FOUND') throw error; }
44
+ add(path.join(vendor, 'x86_64-pc-windows-msvc/bin/codex.exe'), 'npm');
45
+ }
46
+ const desktopRoot = path.join(local, 'OpenAI/Codex/bin');
47
+ add(path.join(desktopRoot, 'codex.exe'), 'desktop');
48
+ if (fs.existsSync(desktopRoot)) for (const entry of fs.readdirSync(desktopRoot, { withFileTypes: true })) {
49
+ if (entry.isDirectory()) add(path.join(desktopRoot, entry.name, 'codex.exe'), 'desktop');
50
+ }
51
+ return [...found.values()];
52
+ }
53
+
54
+ function compareVersions(a, b) {
55
+ const parse = v => v.split(/[.-]/).map(part => /^\d+$/.test(part) ? Number(part) : part);
56
+ const aa = parse(a), bb = parse(b);
57
+ for (let i = 0; i < Math.max(aa.length, bb.length); i++) {
58
+ if (aa[i] === bb[i]) continue;
59
+ if (aa[i] === undefined) return 1;
60
+ if (bb[i] === undefined) return -1;
61
+ return typeof aa[i] === 'number' && typeof bb[i] === 'number' ? aa[i] - bb[i] : String(aa[i]).localeCompare(String(bb[i]));
62
+ }
63
+ return 0;
64
+ }
65
+
66
+ async function verifyProtocol(executable, stateRoot) {
67
+ const directory = fs.mkdtempSync(path.join(stateRoot, 'cli-schema-'));
68
+ try {
69
+ await exec(executable, ['app-server', 'generate-json-schema', '--experimental', '--out', directory],
70
+ { windowsHide: true, timeout: 30000, maxBuffer: 4 * 1024 * 1024 });
71
+ const request = fs.readFileSync(path.join(directory, 'ClientRequest.json'), 'utf8');
72
+ const missing = requiredMethods.filter(method => !request.includes(`"${method}"`));
73
+ const turns = fs.readFileSync(path.join(directory, 'v2/ThreadTurnsListParams.json'), 'utf8');
74
+ if (!turns.includes('"itemsView"') || !turns.includes('"summary"')) missing.push('turn summary pagination');
75
+ if (missing.length) throw new Error(`缺少协议能力:${missing.join(', ')}`);
76
+ } finally {
77
+ const remove = dir => {
78
+ for (const item of fs.readdirSync(dir, { withFileTypes: true })) {
79
+ const file = path.join(dir, item.name);
80
+ if (item.isDirectory()) remove(file); else fs.unlinkSync(file);
81
+ }
82
+ fs.rmdirSync(dir);
83
+ };
84
+ remove(directory);
85
+ }
86
+ }
87
+
88
+ export async function resolveCodexRuntime(stateRoot, { refresh = false, environment = process.env } = {}) {
89
+ fs.mkdirSync(stateRoot, { recursive: true });
90
+ const recordFile = path.join(stateRoot, 'codex-runtime.json');
91
+ const saved = readRecord(recordFile);
92
+ environment = { ...environment,
93
+ CODEX_HOME: environment.CODEX_HOME || saved?.codexHome || path.join(os.homedir(), '.codex'),
94
+ CODE_RELAX_CODEX_PATH: environment.CODE_RELAX_CODEX_PATH || (saved?.source === 'explicit' ? saved.executable : '') };
95
+ if (!refresh && saved?.executable && fs.existsSync(saved.executable)
96
+ && saved.fingerprint === fingerprint(saved.executable)) return saved;
97
+ const candidates = [], rejected = [];
98
+ for (const candidate of discoverCodex(environment)) {
99
+ try {
100
+ const { stdout } = await exec(candidate.executable, ['--version'], { windowsHide: true, timeout: 10000 });
101
+ const version = /^codex-cli (\d+\.\d+\.\d+(?:-[\w.]+)?)/m.exec(stdout)?.[1];
102
+ if (!version) throw new Error('无法识别版本');
103
+ candidates.push({ ...candidate, version });
104
+ } catch (error) { rejected.push(`${candidate.executable}: ${error.message}`); }
105
+ }
106
+ candidates.sort((a, b) => Number(b.source === 'desktop') - Number(a.source === 'desktop') || compareVersions(b.version, a.version));
107
+ for (const candidate of candidates) {
108
+ try {
109
+ await verifyProtocol(candidate.executable, stateRoot);
110
+ const result = { ...candidate, fingerprint: fingerprint(candidate.executable),
111
+ codexHome: path.resolve(environment.CODEX_HOME || path.join(os.homedir(), '.codex')) };
112
+ writeRecord(recordFile, result);
113
+ return result;
114
+ } catch (error) { rejected.push(`${candidate.executable}: ${error.message}`); }
115
+ }
116
+ throw new Error(`未找到兼容的 Codex CLI,请安装或更新 Codex CLI 后执行 relax setup。${rejected.length ? '\n' + rejected.join('\n') : ''}`);
117
+ }
@@ -17,7 +17,22 @@ export const desktopHost = {
17
17
  sharedServiceUrl,
18
18
  start: startWindowsProcess,
19
19
  closeDesktop: executable => runScript('stop-desktop.ps1', ['-Executable', executable]),
20
- stop: pid => process.kill(pid),
20
+ stop: pid => process.kill(pid),
21
+ async prepareKernelRestart() {
22
+ const info = await this.inspect();
23
+ if (!info.running || !await this.sharedServiceUrl()) return null;
24
+ await this.closeDesktop(info.desktop);
25
+ return info.desktop;
26
+ },
27
+ async resumeAfterKernelRestart(executable, environment) {
28
+ if (!executable) return;
29
+ await this.start(executable, [], environment, []);
30
+ const deadline = Date.now() + 30000;
31
+ while (!await this.sharedServiceUrl()) {
32
+ if (Date.now() >= deadline) throw new Error('Codex 内核已就绪,但 Desktop 未完成重新接入。');
33
+ await new Promise(resolve => setTimeout(resolve, 500));
34
+ }
35
+ },
21
36
  ready: async url => { try { return (await fetch(url, { signal: AbortSignal.timeout(1000) })).ok; } catch { return false; } },
22
37
  };
23
38
  export const resolveStateRoot = stateRoot => runScript('resolve-state-root.ps1', ['-StateRoot', stateRoot]);
@@ -8,9 +8,7 @@ if ($ProcessId -gt 0) {
8
8
  exit
9
9
  }
10
10
  $package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
11
- if (-not $package) { throw '未找到 Codex Desktop,请先安装并登录。' }
11
+ if (-not $package) { @{ desktop=$null; running=$false } | ConvertTo-Json -Compress; exit }
12
12
  $desktop = Join-Path $package.InstallLocation 'app/ChatGPT.exe'
13
13
  $running = @(Get-Process ChatGPT -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $desktop })
14
- $binaryRoot = Join-Path $env:LOCALAPPDATA 'OpenAI/Codex/bin'
15
- $cli = Get-ChildItem -LiteralPath $binaryRoot -Filter codex.exe -Recurse -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1
16
- @{ desktop=$desktop; cli=$cli.FullName; running=($running.Count -gt 0) } | ConvertTo-Json -Compress
14
+ @{ desktop=$desktop; running=($running.Count -gt 0) } | ConvertTo-Json -Compress
@@ -1,4 +1,4 @@
1
- param([string]$NodePath, [string]$Entry, [ValidateSet('shared-start','desktop-start')][string]$Action)
1
+ param([string]$NodePath, [string]$Entry, [ValidateSet('shared-start')][string]$Action)
2
2
  $ErrorActionPreference = 'Stop'
3
3
  [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
4
  & $NodePath $Entry $Action
@@ -4,7 +4,7 @@ $ErrorActionPreference = 'Stop'
4
4
  $shell = New-Object -ComObject WScript.Shell
5
5
  if (-not $Destination) { $Destination = $shell.SpecialFolders.Item('Desktop') }
6
6
  $launcher = Join-Path $PSScriptRoot 'desktop-shortcut-run.ps1'
7
- $items = @(@{Name='Code Relax - 完整控制'; Command='shared-start'}, @{Name='Code Relax - 基础控制'; Command='desktop-start'})
7
+ $items = @(@{Name='Code Relax - Codex 内核'; Command='shared-start'})
8
8
  $results = @()
9
9
  foreach ($item in $items) {
10
10
  $target = Join-Path $Destination ($item.Name + '.lnk')
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+
4
+ const [requestPath, readyPath, instance, diagnosticPath] = process.argv.slice(2);
5
+ let result = { instance, brokerPid: process.pid };
6
+
7
+ function diagnostic(message) {
8
+ if (!diagnosticPath) return;
9
+ fs.appendFileSync(diagnosticPath, `${new Date().toISOString()} ${message}\n`, 'utf8');
10
+ }
11
+
12
+ try {
13
+ const spec = JSON.parse(fs.readFileSync(requestPath, 'utf8').replace(/^\uFEFF/u, ''));
14
+ diagnostic('request loaded');
15
+ if (spec.instance !== instance) throw new Error('Windows process broker request identity mismatch.');
16
+ fs.rmSync(requestPath, { force: true });
17
+ const descriptors = (spec.logs || []).map(file => fs.openSync(file, 'a'));
18
+ try {
19
+ const child = spawn(spec.executable, spec.args || [], {
20
+ windowsHide: true,
21
+ env: spec.environment,
22
+ stdio: ['ignore', descriptors[0] ?? 'ignore', descriptors[1] ?? 'ignore'],
23
+ });
24
+ await new Promise((resolve, reject) => {
25
+ child.once('spawn', resolve);
26
+ child.once('error', reject);
27
+ });
28
+ // Keep this WMI-owned broker alive with the target so Codex tools inherit
29
+ // its hidden console instead of creating a visible console per command.
30
+ result.pid = child.pid;
31
+ diagnostic(`target started (PID ${child.pid})`);
32
+ } finally {
33
+ descriptors.forEach(descriptor => fs.closeSync(descriptor));
34
+ }
35
+ } catch (error) {
36
+ result.error = error?.stack || error?.message || String(error);
37
+ try { diagnostic(`failed: ${result.error}`); } catch {}
38
+ }
39
+
40
+ const temporary = `${readyPath}.tmp`;
41
+ fs.writeFileSync(temporary, JSON.stringify(result), 'utf8');
42
+ fs.renameSync(temporary, readyPath);
@@ -1,27 +1,67 @@
1
1
  param([string]$NodePath, [string]$Entry, [ValidateSet('enable','disable','status')][string]$Action='status')
2
2
  $ErrorActionPreference = 'Stop'
3
3
  [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
+
5
+ $taskName = 'Code Relax Bridge'
6
+ $taskSource = '@walkhi/code-relax'
7
+ $taskPath = '\' + $taskName
8
+ $arguments = '"' + $Entry + '" start --json'
9
+ $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
10
+
11
+ $scheduler = New-Object -ComObject 'Schedule.Service'
12
+ $scheduler.Connect()
13
+ $root = $scheduler.GetFolder('\')
14
+ $registered = @($root.GetTasks(1) | Where-Object { $_.Name -eq $taskName } | Select-Object -First 1)
15
+ if ($registered.Count -gt 0 -and $registered[0].Definition.RegistrationInfo.Source -ne $taskSource) {
16
+ throw 'Scheduled task is not owned by this installation.'
17
+ }
18
+
19
+ # Remove only the legacy shortcut created by this package. A Startup LNK that
20
+ # launches hidden PowerShell with ExecutionPolicy Bypass triggers AV heuristics.
4
21
  $shell = New-Object -ComObject WScript.Shell
5
- $target = Join-Path $shell.SpecialFolders.Item('Startup') 'Code Relax Bridge.lnk'
6
- $launcher = Join-Path $PSScriptRoot 'resident-start.ps1'
7
- $powershell = Join-Path $env:SystemRoot 'System32/WindowsPowerShell/v1.0/powershell.exe'
8
- $exists = Test-Path -LiteralPath $target
9
- if ($exists) {
10
- $existing = $shell.CreateShortcut($target)
11
- if ($existing.TargetPath -ne $powershell -or -not $existing.Arguments.Contains('"' + $launcher + '"')) {
12
- throw 'Startup shortcut is not owned by this installation.'
22
+ $legacyPath = Join-Path $shell.SpecialFolders.Item('Startup') 'Code Relax Bridge.lnk'
23
+ $legacyLauncher = Join-Path $PSScriptRoot 'resident-start.ps1'
24
+ $legacyExists = Test-Path -LiteralPath $legacyPath
25
+ if ($Action -ne 'status' -and $legacyExists) {
26
+ $legacy = $shell.CreateShortcut($legacyPath)
27
+ if (-not $legacy.Arguments.Contains('"' + $legacyLauncher + '"')) {
28
+ throw 'Legacy startup shortcut is not owned by this installation.'
13
29
  }
14
30
  }
31
+
15
32
  if ($Action -eq 'enable') {
16
33
  if (-not (Test-Path -LiteralPath $NodePath) -or -not (Test-Path -LiteralPath $Entry)) { throw 'Installed runtime is missing.' }
17
- $link = $shell.CreateShortcut($target)
18
- $link.TargetPath = $powershell
19
- $link.Arguments = '-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $launcher + '" -NodePath "' + $NodePath + '" -Entry "' + $Entry + '"'
20
- $link.WorkingDirectory = $env:USERPROFILE
21
- $link.WindowStyle = 7
22
- $link.Description = 'Code Relax resident computer connection'
23
- $link.Save()
24
- } elseif ($Action -eq 'disable' -and $exists) {
25
- Remove-Item -LiteralPath $target
34
+ $definition = $scheduler.NewTask(0)
35
+ $definition.RegistrationInfo.Description = 'Start the Code Relax Bridge when this user signs in.'
36
+ $definition.RegistrationInfo.Source = $taskSource
37
+ $definition.Principal.UserId = $currentUser
38
+ $definition.Principal.LogonType = 3 # TASK_LOGON_INTERACTIVE_TOKEN
39
+ $definition.Principal.RunLevel = 0 # TASK_RUNLEVEL_LUA
40
+ $definition.Settings.Enabled = $true
41
+ $definition.Settings.Hidden = $true
42
+ $definition.Settings.StartWhenAvailable = $true
43
+ $definition.Settings.DisallowStartIfOnBatteries = $false
44
+ $definition.Settings.StopIfGoingOnBatteries = $false
45
+ $definition.Settings.ExecutionTimeLimit = 'PT5M'
46
+ $definition.Settings.MultipleInstances = 2 # TASK_INSTANCES_IGNORE_NEW
47
+ $trigger = $definition.Triggers.Create(9) # TASK_TRIGGER_LOGON
48
+ $trigger.UserId = $currentUser
49
+ $trigger.Enabled = $true
50
+ $exec = $definition.Actions.Create(0) # TASK_ACTION_EXEC
51
+ $exec.Path = $NodePath
52
+ $exec.Arguments = $arguments
53
+ $exec.WorkingDirectory = $env:USERPROFILE
54
+ [void]$root.RegisterTaskDefinition($taskName, $definition, 6, $null, $null, 3, $null) # TASK_CREATE_OR_UPDATE
55
+ } elseif ($Action -eq 'disable' -and $registered.Count -gt 0) {
56
+ $root.DeleteTask($taskName, 0)
26
57
  }
27
- @{ enabled=(Test-Path -LiteralPath $target); path=$target; scope='current-user-logon' } | ConvertTo-Json -Compress
58
+ if ($Action -ne 'status' -and $legacyExists) { Remove-Item -LiteralPath $legacyPath }
59
+
60
+ $active = @($root.GetTasks(1) | Where-Object { $_.Name -eq $taskName } | Select-Object -First 1)
61
+ $taskEnabled = $active.Count -gt 0 -and $active[0].Enabled
62
+ @{
63
+ enabled=($taskEnabled -or ($Action -eq 'status' -and $legacyExists))
64
+ path=$(if ($taskEnabled) { 'Task Scheduler:' + $taskPath } elseif ($legacyExists) { $legacyPath } else { 'Task Scheduler:' + $taskPath })
65
+ scope='current-user-logon'
66
+ mechanism=$(if ($taskEnabled) { 'task-scheduler' } elseif ($legacyExists) { 'legacy-startup-shortcut' } else { 'task-scheduler' })
67
+ } | ConvertTo-Json -Compress
@@ -28,16 +28,13 @@ export function migrateState(stateRoot) {
28
28
  if (!fs.existsSync(target) && fs.existsSync(old)) fs.copyFileSync(old, target, fs.constants.COPYFILE_EXCL);
29
29
  }
30
30
  }
31
- export async function desktopEnvironment(context, sharedUrl) {
32
- const environment = { ...process.env };
33
- environment.CODEX_THREAD_ID ||= environment.CODEX_SESSION_ID || context?.sourceThreadId;
34
- if (sharedUrl) environment.CODEX_REMOTE_SHARED_WS_URL = sharedUrl;
35
- else delete environment.CODEX_REMOTE_SHARED_WS_URL;
36
- const installation = readRecord(path.join(runtimePaths(environment).serviceRoot, 'installation.json'));
31
+ export async function bridgeEnvironment() {
32
+ const environment = { ...process.env };
33
+ const installation = readRecord(path.join(runtimePaths(environment).serviceRoot, 'installation.json'));
37
34
  const executable = installation?.nodeExecutable;
38
35
  if (!executable || !fs.existsSync(executable)) throw new Error('安装时选用的 Node 不存在,请重新安装 Code Relax。');
39
36
  environment.CODEX_MCP_NODE_PATH = executable;
40
37
  environment.CODEX_REMOTE_LEGACY_UPLOADS_ROOTS = JSON.stringify(installation.legacyUploadsRoots || []);
41
- if (sharedUrl) delete environment.CODEX_APP_TOOLS_PIPE_PATH;
38
+ delete environment.CODEX_APP_TOOLS_PIPE_PATH;
42
39
  return { executable, environment };
43
40
  }
@@ -10,30 +10,40 @@ try {
10
10
  Add-Type -Path (Join-Path $PSScriptRoot 'IsolatedProcess.cs')
11
11
  $launchRoot = [IsolatedProcess]::ResolvePath($launchRoot)
12
12
  $ready = Join-Path $launchRoot ($instance + '.json')
13
+ $request = Join-Path $launchRoot ($instance + '.request.json')
14
+ $diagnostic = Join-Path $launchRoot ($instance + '.broker.log')
13
15
  $spec | Add-Member -NotePropertyName instance -NotePropertyValue $instance
14
- $spec | Add-Member -NotePropertyName ready -NotePropertyValue $ready
15
- $environment = @(Get-ChildItem Env: | Where-Object { $_.Name -ne 'CODEX_REMOTE_PROCESS_SPEC' } | ForEach-Object { $_.Name + '=' + $_.Value })
16
- $environment += 'CODEX_REMOTE_PROCESS_SPEC=' + ($spec | ConvertTo-Json -Compress -Depth 10)
16
+ $spec | Add-Member -NotePropertyName diagnostic -NotePropertyValue $diagnostic
17
+ $requestTemporary = $request + '.tmp'
18
+ [IO.File]::WriteAllText($requestTemporary, ($spec | ConvertTo-Json -Compress -Depth 10), (New-Object Text.UTF8Encoding($false)))
19
+ [IO.File]::Move($requestTemporary, $request)
17
20
  $startup = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{
18
- ShowWindow=[uint16]0; CreateFlags=[uint32]0x1000010; EnvironmentVariables=[string[]]$environment
21
+ ShowWindow=[uint16]0; CreateFlags=[uint32]0x1000010
19
22
  }
20
- $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
21
- $command = '"' + $powershell + '" -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + (Join-Path $PSScriptRoot 'launch-worker.ps1') + '"'
23
+ $worker = Join-Path $PSScriptRoot 'launch-worker.mjs'
24
+ $command = '"' + [string]$spec.brokerExecutable + '" "' + $worker + '" "' + $request + '" "' + $ready + '" "' + $instance + '" "' + $diagnostic + '"'
22
25
  $broker = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
23
26
  CommandLine=$command; CurrentDirectory=$env:USERPROFILE; ProcessStartupInformation=$startup
24
27
  }
25
28
  if ($broker.ReturnValue -ne 0) { throw ('Windows process broker failed: ' + $broker.ReturnValue) }
26
- $deadline = [DateTime]::UtcNow.AddSeconds(20)
29
+ $deadline = [DateTime]::UtcNow.AddSeconds(30)
27
30
  while (-not (Test-Path -LiteralPath $ready)) {
28
- if ([DateTime]::UtcNow -gt $deadline) { throw 'Windows process broker handoff timed out.' }
31
+ if ([DateTime]::UtcNow -gt $deadline) {
32
+ $detail = if (Test-Path -LiteralPath $diagnostic) { (Get-Content -LiteralPath $diagnostic -Raw -Encoding UTF8).Trim() } else { 'broker did not create a diagnostic log' }
33
+ throw ('Windows process broker handoff timed out (broker PID ' + $broker.ProcessId + '): ' + $detail)
34
+ }
29
35
  Start-Sleep -Milliseconds 50
30
36
  }
31
37
  $result = Get-Content -LiteralPath $ready -Raw -Encoding UTF8 | ConvertFrom-Json
32
38
  Remove-Item -LiteralPath $ready
33
39
  if ($result.instance -ne $instance -or $result.brokerPid -ne $broker.ProcessId) { throw 'Windows process broker identity mismatch.' }
34
40
  if ($result.error) { throw [string]$result.error }
41
+ if (Test-Path -LiteralPath $diagnostic) { Remove-Item -LiteralPath $diagnostic -Force }
35
42
  [Console]::WriteLine([int]$result.pid)
36
43
  } catch {
37
44
  [Console]::Error.WriteLine($_.Exception.Message)
38
45
  exit 1
46
+ } finally {
47
+ if ($request -and (Test-Path -LiteralPath $request)) { Remove-Item -LiteralPath $request -Force }
48
+ if ($requestTemporary -and (Test-Path -LiteralPath $requestTemporary)) { Remove-Item -LiteralPath $requestTemporary -Force }
39
49
  }
@@ -5,15 +5,18 @@ $package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
5
5
  if (-not $package -or $Executable -ne (Join-Path $package.InstallLocation 'app/ChatGPT.exe')) {
6
6
  throw 'Desktop executable identity changed; recovery stopped.'
7
7
  }
8
- # This action is only reached after explicit whole-workstation recovery confirmation.
9
- # Never kill an app-server process tree: the independent recovery worker must survive.
10
- $workers = @(Get-CimInstance Win32_Process -Filter "Name='ChatGPT.exe'" | Where-Object { $_.ExecutablePath -eq $Executable })
8
+ # Never kill another user's Desktop or an app-server process tree. The independent
9
+ # installer/recovery worker must survive and relaunch only in this interactive session.
10
+ $currentSessionId = (Get-Process -Id $PID -ErrorAction Stop).SessionId
11
+ $workers = @(Get-CimInstance Win32_Process -Filter "Name='ChatGPT.exe'" | Where-Object {
12
+ $_.ExecutablePath -eq $Executable -and $_.SessionId -eq $currentSessionId
13
+ })
11
14
  foreach ($worker in $workers) {
12
15
  $actual = Get-CimInstance Win32_Process -Filter "ProcessId=$($worker.ProcessId)"
13
16
  if ($actual -and $actual.ExecutablePath -eq $worker.ExecutablePath -and $actual.CreationDate -eq $worker.CreationDate) {
14
17
  Stop-Process -Id $worker.ProcessId -Force -ErrorAction Stop
15
18
  }
16
19
  }
17
- if (Get-Process ChatGPT -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $Executable }) {
20
+ if (Get-Process ChatGPT -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $Executable -and $_.SessionId -eq $currentSessionId }) {
18
21
  throw 'Desktop did not exit; recovery stopped.'
19
22
  }