@walkhi/code-relax 0.1.0-beta.45 → 0.1.0-beta.50

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.
@@ -290,7 +290,6 @@ async function main() {
290
290
  let stoppedForUpdate = false;
291
291
  const installed = await installService(packageRoot(path.dirname(fileURLToPath(import.meta.url))), serviceRoot, executable, physicalStateRoot, {
292
292
  beforeActivate: async () => {
293
- if (desktopNeedsRestart) await desktopHost.closeDesktop(desktopBeforeSetup.desktop);
294
293
  stoppedForUpdate = resumeAfterUpdate;
295
294
  if (previousRelay.running && legacyRelaySession) {
296
295
  await controlRequest(legacyRelaySession, 'POST');
@@ -12,6 +12,7 @@ import { fileURLToPath } from 'node:url';
12
12
  import { verifyReleaseIdentity, createUploadSigner } from '../relay-admin/release-oidc.mjs';
13
13
  import { androidReleaseStorage } from '../relay-admin/android-release-storage.mjs';
14
14
  import { readAndroidCatalog } from '../src/self-relay/android-releases.mjs';
15
+ import { updatePublicWorkstations } from '../src/self-relay/public-releases.mjs';
15
16
 
16
17
  import { readHarmonyConfig, validateHarmonyMetadata, checkHarmonyVersion, verifyHarmony, activateHarmony } from '../relay-admin/harmony-release.mjs';
17
18
 
@@ -56,8 +57,23 @@ let busy = false;
56
57
  const server = http.createServer(async (req, res) => {
57
58
  const reply = (code, data) => { if (!res.destroyed) { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(data)); } };
58
59
  let claims;
59
- try { claims = await verifyReleaseIdentity((req.headers.authorization || '').replace(/^Bearer /, '')); }
60
+ const npmStatus = req.url === '/code-relax/publish/npm/status';
61
+ try { claims = await verifyReleaseIdentity((req.headers.authorization || '').replace(/^Bearer /, ''), npmStatus ? 'npm' : 'mobile'); }
60
62
  catch { reply(401, { ok: false, error: 'OIDC_REJECTED' }); return; }
63
+ if (npmStatus) {
64
+ try {
65
+ if (req.method !== 'POST') { reply(405, { ok: false, error: 'METHOD_NOT_ALLOWED' }); return; }
66
+ const { version } = await readJson(req);
67
+ const response = await fetch('https://registry.npmjs.org/@walkhi%2fcode-relax', { signal: AbortSignal.timeout(15000) });
68
+ if (!response.ok) throw Error('Registry unavailable');
69
+ const metadata = await response.json();
70
+ if (metadata['dist-tags']?.latest !== version || metadata['dist-tags']?.beta !== version
71
+ || !metadata.versions?.[version]) { reply(409, { ok: false, error: 'NPM_VERSION_MISMATCH' }); return; }
72
+ updatePublicWorkstations(version);
73
+ reply(200, { ok: true, version });
74
+ } catch { reply(500, { ok: false, error: 'NPM_STATUS_SYNC_FAILED' }); }
75
+ return;
76
+ }
61
77
  const target = /^\/code-relax\/publish\/(android|hap)\/intents(?:\/|$)/.exec(req.url || '')?.[1];
62
78
  if (!target) { reply(404, { ok: false, error: 'NOT_FOUND' }); return; }
63
79
  const prefix = `/code-relax/publish/${target}/intents`;
@@ -1 +1 @@
1
- {"layout":"npm-platform-packages","version":"0.1.0-beta.45"}
1
+ {"layout":"npm-platform-packages","version":"0.1.0-beta.50"}
@@ -335,27 +335,37 @@
335
335
  }
336
336
  return optionsCache.context(threadId, contexts.get(threadId));
337
337
  }
338
- async function createThread(request, target, input, settings, additionalContext) {
339
- const { thread } = await request('thread/start', target);
340
- const result = started => ({ threadId:thread.id, turnId:started?.turn?.id || null });
341
- try {
342
- const started = await request('turn/start', { threadId:thread.id, input,
343
- ...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput),
344
- ...(additionalContext ? { additionalContext: { ...additionalContext,
345
- ...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput).additionalContext } } : {}),
346
- ...(settings.clientUserMessageId ? { clientUserMessageId:settings.clientUserMessageId } : {}),
347
- ...(settings.model ? { model:settings.model } : {}),
348
- ...(settings.thinking ? { effort:settings.thinking } : {}),
349
- ...(settings.serviceTier !== undefined ? { serviceTier:settings.serviceTier } : {}) });
350
- return result(started);
351
- } catch (error) {
352
- // The known thread and the late first turn together form one creation receipt.
353
- if (error.deliveryResult) error.deliveryResult = error.deliveryResult.then(result, lateError => {
354
- // The thread already exists even if its first turn was rejected.
355
- throw Object.assign(new Error(lateError.message || '新对话首条消息未确认'), { cause: lateError, deliveryUnknown: true });
356
- });
338
+ async function createThread(request, target, input, settings, additionalContext, onThreadCreated = () => {}) {
339
+ let response;
340
+ try { response = await request('thread/start', target); }
341
+ catch (error) {
342
+ if (error.deliveryResult) error.deliveryResult = error.deliveryResult.then(result => startFirstTurn(result.thread))
343
+ .catch(lateError => { if (lateError.deliveryResult) return lateError.deliveryResult; throw lateError; });
357
344
  throw error;
358
345
  }
346
+ return startFirstTurn(response.thread);
347
+ async function startFirstTurn(thread) {
348
+ onThreadCreated(thread.id);
349
+ const result = started => ({ threadId:thread.id, turnId:started?.turn?.id || null });
350
+ try {
351
+ const started = await request('turn/start', { threadId:thread.id, input,
352
+ ...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput),
353
+ ...(additionalContext ? { additionalContext: { ...additionalContext,
354
+ ...mobileTurnSource(settings.clientUserMessageId, settings.containsVoiceInput).additionalContext } } : {}),
355
+ ...(settings.clientUserMessageId ? { clientUserMessageId:settings.clientUserMessageId } : {}),
356
+ ...(settings.model ? { model:settings.model } : {}),
357
+ ...(settings.thinking ? { effort:settings.thinking } : {}),
358
+ ...(settings.serviceTier !== undefined ? { serviceTier:settings.serviceTier } : {}) });
359
+ return result(started);
360
+ } catch (error) {
361
+ // The known thread and the late first turn together form one creation receipt.
362
+ if (error.deliveryResult) error.deliveryResult = error.deliveryResult.then(result, lateError => {
363
+ // The thread already exists even if its first turn was rejected.
364
+ throw Object.assign(new Error(lateError.message || '新对话首条消息未确认'), { cause: lateError, deliveryUnknown: true });
365
+ });
366
+ throw error;
367
+ }
368
+ }
359
369
  }
360
370
  function projectThreadTarget(project) {
361
371
  const roots = project.roots.map(root => root.path);
@@ -21,9 +21,24 @@
21
21
  class Store {
22
22
  constructor() { this.entries = new Map(); }
23
23
  key(scope, clientId) { return JSON.stringify([scope, clientId]); }
24
+ beginCreation(scope, clientId) {
25
+ const entry = this.entries.get(this.key(scope, clientId));
26
+ let finish;
27
+ entry.creationReady = new Promise(resolve => { finish = resolve; });
28
+ return threadId => {
29
+ if (threadId) entry.createdThreadId = threadId;
30
+ finish();
31
+ };
32
+ }
33
+ async creationBindings() {
34
+ await Promise.all([...this.entries.values()].map(entry => entry.creationReady));
35
+ return new Map([...this.entries].filter(([, entry]) => entry.createdThreadId)
36
+ .map(([key, entry]) => [entry.createdThreadId, JSON.parse(key)[1]]));
37
+ }
24
38
  status(scope, clientId) {
25
39
  const entry = this.entries.get(this.key(scope, clientId));
26
40
  return entry ? { state: entry.state, ...(entry.result ? { result: entry.result } : {}),
41
+ ...(entry.createdThreadId ? { createdThreadId: entry.createdThreadId } : {}),
27
42
  ...(entry.error ? { error: entry.error.message } : {}) } : { state: 'unknown' };
28
43
  }
29
44
  async reconcile(scope, clientId, inspect) {
@@ -6,6 +6,7 @@
6
6
  const file = 'item/fileChange/requestApproval';
7
7
  const input = 'item/tool/requestUserInput';
8
8
  const elicitation = 'mcpServer/elicitation/request';
9
+ const permissions = 'item/permissions/requestApproval';
9
10
  const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
10
11
  const approvalId = request => 'approval:' + (request.params.turnId || '') + ':' + typeof request.id + ':' + request.id;
11
12
  function confirmation(request) {
@@ -98,10 +99,86 @@
98
99
  _meta: decision === 'allowAlways' ? { persist: 'always' }
99
100
  : decision === 'allowSession' ? { persist: 'session' } : null };
100
101
  }
102
+ function externalAction(request) {
103
+ const p = request.params || {};
104
+ if (request.method !== elicitation || p.mode !== 'url') return null;
105
+ try {
106
+ const url = new URL(p.url);
107
+ if (url.protocol !== 'https:' || url.username || url.password) return null;
108
+ if (p.serverName !== 'codex_apps') return { url: url.href, connector: null };
109
+ const connector = p._meta?._codex_apps?.connector_auth_failure;
110
+ const trusted = host => ['chatgpt.com', 'chatgpt-staging.com'].some(domain => host === domain || host.endsWith('.' + domain));
111
+ if (!trusted(url.hostname) || connector?.is_auth_failure !== true
112
+ || typeof connector.connector_id !== 'string' || typeof connector.connector_name !== 'string'
113
+ || typeof connector.install_url !== 'string' || connector.connection_consent != null) return null;
114
+ return { url: url.href, connector };
115
+ } catch { return null; }
116
+ }
117
+ function elicitationResult(action, content = null) { return { action, content: action === 'accept' ? content : null, _meta: null }; }
118
+ function fieldOptions(field) {
119
+ const schema = field.type === 'array' ? field.items : field;
120
+ if (Array.isArray(schema?.enum)) return schema.enum.map((value, index) => ({ value, label: schema.enumNames?.[index] || value }));
121
+ const options = schema?.oneOf || schema?.anyOf;
122
+ return Array.isArray(options) ? options.map(option => ({ value: option.const, label: option.title || option.const })) : null;
123
+ }
124
+ function formSchema(request) {
125
+ const p = request.params || {}, meta = p._meta || {}, schema = p.requestedSchema;
126
+ // Specialized approvals, credentials and OpenAI forms have their own response contracts.
127
+ if (request.method !== elicitation || p.mode !== 'form' || meta.codex_approval_kind
128
+ || meta['openai/elicitation'] || meta['openai/confirmation'] || schema?.type !== 'object'
129
+ || ['browser', 'browser-use'].includes(p.serverName)
130
+ || !schema.properties || typeof schema.properties !== 'object' || Array.isArray(schema.properties)
131
+ || Object.keys(schema).some(key => !['$schema', 'type', 'properties', 'required'].includes(key))) return null;
132
+ const known = ['type', 'title', 'description', 'default', 'minLength', 'maxLength', 'format', 'minimum', 'maximum', 'enum', 'enumNames', 'oneOf', 'items', 'minItems', 'maxItems'];
133
+ for (const field of Object.values(schema.properties)) {
134
+ if (!field || Object.keys(field).some(key => !known.includes(key))
135
+ || !['string', 'number', 'integer', 'boolean', 'array'].includes(field.type)) return null;
136
+ const options = fieldOptions(field);
137
+ if ((field.type === 'array' || field.enum != null || field.oneOf != null) && !options
138
+ || options && (!options.length || options.some(option => typeof option.value !== 'string' || typeof option.label !== 'string'))) return null;
139
+ if (field.format && !['email', 'uri', 'date', 'date-time'].includes(field.format)) return null;
140
+ }
141
+ if (schema.required != null && (!Array.isArray(schema.required) || schema.required.some(key => !Object.hasOwn(schema.properties, key)))) return null;
142
+ return schema;
143
+ }
144
+ function validateForm(schema, values) {
145
+ if (!values || typeof values !== 'object' || Array.isArray(values)
146
+ || Object.keys(values).some(key => !Object.hasOwn(schema.properties, key))) throw new Error('表单内容无效');
147
+ for (const [key, field] of Object.entries(schema.properties)) {
148
+ const value = values[key], label = field.title || key;
149
+ if (value === undefined) { if (schema.required?.includes(key)) throw new Error(`请填写${label}`); continue; }
150
+ let valid = field.type === 'array' ? Array.isArray(value) : typeof value === (field.type === 'integer' ? 'number' : field.type);
151
+ if (typeof value === 'number') valid &&= Number.isFinite(value) && (field.type !== 'integer' || Number.isInteger(value))
152
+ && (field.minimum == null || value >= field.minimum) && (field.maximum == null || value <= field.maximum);
153
+ if (typeof value === 'string') {
154
+ valid &&= (field.minLength == null || [...value].length >= field.minLength) && (field.maxLength == null || [...value].length <= field.maxLength);
155
+ if (field.format === 'email') valid &&= /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
156
+ if (field.format === 'uri') { try { new URL(value); } catch { valid = false; } }
157
+ if (field.format === 'date') valid &&= /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value));
158
+ if (field.format === 'date-time') valid &&= /^\d{4}-\d{2}-\d{2}T.+(?:Z|[+-]\d{2}:\d{2})$/.test(value) && !Number.isNaN(Date.parse(value));
159
+ }
160
+ const options = fieldOptions(field);
161
+ if (Array.isArray(value)) valid &&= new Set(value).size === value.length && (field.minItems == null || value.length >= field.minItems)
162
+ && (field.maxItems == null || value.length <= field.maxItems) && value.every(item => options?.some(option => option.value === item));
163
+ else if (options) valid &&= options.some(option => option.value === value);
164
+ if (!valid) throw new Error(`${label}的内容不符合请求要求`);
165
+ }
166
+ }
167
+ function permissionResult(request, decision) {
168
+ return { permissions: decision === 'decline' ? {} : request.params.permissions, scope: decision === 'acceptForSession' ? 'session' : 'turn' };
169
+ }
170
+ function blocksComposer(request) {
171
+ if (externalAction(request)) return false;
172
+ return request.method === input || Boolean(formSchema(request))
173
+ || request.method !== elicitation && decisions(request).length > 0
174
+ || request.method === elicitation && decisions(request).some(value => value !== 'cancel');
175
+ }
101
176
  function decisions(request) {
102
177
  if (confirmation(request)) return ['decline', 'accept'];
103
178
  if (request.method === command) return request.params.availableDecisions ?? ['accept', 'acceptForSession', 'decline', 'cancel'];
104
179
  if (request.method === file) return ['accept', 'acceptForSession', 'decline', 'cancel'];
180
+ if (request.method === permissions && request.params.permissions) return ['accept', 'acceptForSession', 'decline'];
181
+ if (externalAction(request)) return ['decline', 'accept'];
105
182
  const action = browserAction(request);
106
183
  if (action) return ['allowOnce', ...(action.session ? ['allowSession'] : []), ...(action.persistent ? ['allowAlways'] : []), 'cancel'];
107
184
  const app = computerAppAccess(request);
@@ -110,9 +187,24 @@
110
187
  if (browser) return browser.persistent ? ['allowOnce', 'allowSite', 'allowAllSites', 'decline'] : ['allowOnce', 'decline'];
111
188
  const tool = mcpToolApproval(request);
112
189
  if (tool) return ['decline', 'allowOnce', ...(tool.session ? ['allowSession'] : []), ...(tool.persistent ? ['allowAlways'] : [])];
113
- return [];
190
+ return request.method === elicitation ? ['cancel'] : [];
114
191
  }
115
192
  function validate(request, result) {
193
+ if (request.method === elicitation && equal(result, elicitationResult('cancel'))) return;
194
+ if (externalAction(request)) {
195
+ if (!['accept', 'decline'].some(action => equal(result, elicitationResult(action)))) throw new Error('外部操作结果无效');
196
+ return;
197
+ }
198
+ const schema = formSchema(request);
199
+ if (schema) {
200
+ if (equal(result, elicitationResult('decline'))) return;
201
+ if (result?.action !== 'accept' || result._meta !== null || Object.keys(result).length !== 3) throw new Error('表单响应格式无效');
202
+ validateForm(schema, result.content); return;
203
+ }
204
+ if (request.method === permissions) {
205
+ if (!decisions(request).some(decision => equal(result, permissionResult(request, decision)))) throw new Error('权限决定不属于当前请求');
206
+ return;
207
+ }
116
208
  if (confirmation(request)) {
117
209
  if (!decisions(request).some(decision => equal(confirmationResult(request, decision), result))) throw new Error('确认结果不属于当前请求');
118
210
  return;
@@ -215,5 +307,5 @@
215
307
  return { submitted: true };
216
308
  }
217
309
  }
218
- return { Store, decisions, validate, command, file, input, elicitation, browserAccess, browserAction, browserActionResult, computerAppAccess, computerAppResult, mcpToolApproval, mcpToolResult, confirmation, confirmationResult };
310
+ return { Store, decisions, validate, command, file, input, elicitation, permissions, externalAction, elicitationResult, formSchema, fieldOptions, validateForm, permissionResult, blocksComposer, browserAccess, browserAction, browserActionResult, computerAppAccess, computerAppResult, mcpToolApproval, mcpToolResult, confirmation, confirmationResult };
219
311
  });
@@ -1,16 +1,36 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { setTimeout as delay } from 'node:timers/promises';
4
- import { readRecord } from './service-lifecycle.mjs';
5
- import { ManagedAppServer, managedPortAvailable as portAvailable } from './managed-app-server.mjs';
4
+ import { readRecord, lifecycle } from './service-lifecycle.mjs';
5
+ import * as serviceHost from './platform/service-host.mjs';
6
+ import { managedPortAvailable as portAvailable } from './managed-app-server.mjs';
7
+ import { readManagedEndpoint } from './managed-endpoint.mjs';
6
8
  export { portAvailable };
7
- export async function launchDesktop({ stateRoot, host, environment = process.env, available = portAvailable, wait = delay, timeout = 30000, recoveryId, progress = () => {} }) {
9
+ function desktopEnvironment(stateRoot, environment) {
10
+ const childEnv = { ...environment };
11
+ for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnv[key];
12
+ const runtime = readRecord(path.join(stateRoot, 'codex-runtime.json'));
13
+ if (runtime?.codexHome) childEnv.CODEX_HOME = runtime.codexHome;
14
+ childEnv.CODEX_APP_SERVER_WS_URL = readManagedEndpoint(stateRoot);
15
+ return childEnv;
16
+ }
17
+
18
+ // Opening the optional UI does not start, restart or supervise the kernel.
19
+ export async function startDesktop({ stateRoot, host, environment = process.env, restart = false }) {
20
+ const info = await host.inspect();
21
+ if (!info.desktop) throw new Error('未安装 Codex 桌面端。');
22
+ if (info.running && !restart) return { status: 'running', message: 'Codex 桌面端已启动。' };
23
+ if (info.running) await host.closeDesktop(info.desktop);
24
+ await host.startDesktop(info.desktop, desktopEnvironment(stateRoot, environment));
25
+ return { status: 'accepted', message: restart ? '已提交 Codex 桌面端重启请求。' : '已提交 Codex 桌面端启动请求。' };
26
+ }
27
+
28
+ export async function launchDesktop({ stateRoot, host, environment = process.env, wait = delay, timeout = 30000, recoveryId, progress = () => {}, service = lifecycle({ stateRoot, host: serviceHost }) }) {
8
29
  fs.mkdirSync(stateRoot, { recursive: true });
9
30
  const recovery = readRecord(path.join(stateRoot, 'shared-recovery.lock'));
10
31
  if (recovery && recovery.operationId !== recoveryId) throw new Error('Codex 内核正在启动,请用 relax doctor 查看恢复状态。');
11
32
  const lock = path.join(stateRoot, 'desktop-launch.lock');
12
33
  let descriptor;
13
- let managed;
14
34
  try { descriptor = fs.openSync(lock, 'wx'); }
15
35
  catch (error) {
16
36
  if (error.code === 'EEXIST') throw new Error('另一个 Desktop 启动操作正在进行;若上次异常退出,请确认没有启动器运行后移除 desktop-launch.lock。');
@@ -24,14 +44,10 @@ export async function launchDesktop({ stateRoot, host, environment = process.env
24
44
  if (connected) return { reused: true };
25
45
  throw new Error('请先保存任务并完全退出 Codex Desktop,再接入 Codex 内核;不会自动结束正在运行的任务。');
26
46
  }
27
- const childEnv = { ...environment };
28
- for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnv[key];
29
47
  progress('starting-app-server');
30
- managed = new ManagedAppServer({ stateRoot, host, environment, available, wait, timeout });
31
- if (!await managed.start()) throw new Error(managed.snapshot().error || 'Codex 内核未就绪。');
32
- const runtime = readRecord(path.join(stateRoot, 'codex-runtime.json'));
33
- if (runtime?.codexHome) childEnv.CODEX_HOME = runtime.codexHome;
34
- childEnv.CODEX_APP_SERVER_WS_URL = managed.snapshot().url;
48
+ const health = await service.status();
49
+ if (!health.running || !health.managedAppServer?.ready) throw new Error(health.managedAppServer?.error || '工作站 Codex 内核未就绪。');
50
+ const childEnv = desktopEnvironment(stateRoot, environment);
35
51
  progress('starting-desktop');
36
52
  await host.startDesktop(info.desktop, childEnv);
37
53
  const deadline = Date.now() + timeout;
@@ -42,5 +58,5 @@ export async function launchDesktop({ stateRoot, host, environment = process.env
42
58
  await wait(500);
43
59
  }
44
60
  return { reused: false };
45
- } finally { managed?.close(); fs.closeSync(descriptor); fs.rmSync(lock, { force: true }); }
61
+ } finally { fs.closeSync(descriptor); fs.rmSync(lock, { force: true }); }
46
62
  }
@@ -426,7 +426,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
426
426
  const current = await this.rooms.request(`${route}?inspect=true`), request = current.elicitation;
427
427
  if (epoch !== this.epoch || this.bindings.get(threadId) !== room || request?.request_id !== body.id
428
428
  || request.params?.threadId !== request.thread_id || typeof current.request_fingerprint !== 'string') throw new Error('确认请求已变化,请重新核对。');
429
- if (!serverRequests.confirmation({ method: serverRequests.elicitation, params: request.params })) throw new Error('此房间请求尚未支持');
429
+ if (!request.params || typeof request.params.mode !== 'string') throw new Error('此房间请求格式无效');
430
430
  const key = JSON.stringify([room.id, body.roomMessageId, body.id]);
431
431
  return { requestId: request.request_id, params: request.params, fingerprint: current.request_fingerprint,
432
432
  status: current.runtime_status, canRespond: current.can_respond === true && current.runtime_status === 'pending',
@@ -137,6 +137,8 @@ export class DotsRoomActivity extends EventEmitter {
137
137
  await this.server.rooms.request(`/messaging/rooms/${encodeURIComponent(room.id)}/live`, {
138
138
  method: 'POST', signal: controller.signal, onEvent: value => {
139
139
  if (!current()) return;
140
+ // The live SSE sends {} keepalives after ready; they carry no identity.
141
+ if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) return;
140
142
  if (value?.type !== 'calpico-room-live-ready' || value.version !== 1 || value.room_id !== room.id
141
143
  || typeof value.subscription_id !== 'string' || watch.subscriptionId && watch.subscriptionId !== value.subscription_id) {
142
144
  throw new Error('点点活动订阅身份无效。');
@@ -35,4 +35,4 @@ $package = Get-AppxPackage OpenAI.Codex | Select-Object -First 1
35
35
  if (-not $package) { @{ desktop=$null; running=$false } | ConvertTo-Json -Compress; exit }
36
36
  $desktop = Join-Path $package.InstallLocation 'app/ChatGPT.exe'
37
37
  $running = @(Get-Process ChatGPT -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $desktop })
38
- @{ desktop=$desktop; running=($running.Count -gt 0); hasWindow=(@($running | Where-Object { $_.MainWindowHandle -ne 0 }).Count -gt 0) } | ConvertTo-Json -Compress
38
+ @{ desktop=$desktop; version=$package.Version.ToString(); running=($running.Count -gt 0); hasWindow=(@($running | Where-Object { $_.MainWindowHandle -ne 0 }).Count -gt 0) } | ConvertTo-Json -Compress
@@ -31,7 +31,7 @@ export class RuntimeDiscovery {
31
31
  const desktop = await this.host.inspect();
32
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
- installed: Boolean(desktop.desktop), attachedToManagedServer: Boolean(attached) },
34
+ installed: Boolean(desktop.desktop), version: desktop.version || '', attachedToManagedServer: Boolean(attached) },
35
35
  desktopRestartRecommended: Boolean(desktop.running && !attached), checkedAt: new Date().toISOString() };
36
36
  } catch (error) {
37
37
  errors.push(error.message);
@@ -26,10 +26,7 @@ export function updatePublicHarmony(release, file = publicManifestFile) {
26
26
  }
27
27
 
28
28
  function updateMobileManifest(platform, release, file) {
29
- const lock = file + '.lock', temporary = file + '.new';
30
- const handle = fs.openSync(lock, 'wx', 0o600);
31
- try {
32
- const manifest = readPublicManifest(file);
29
+ return updatePublicManifest(manifest => {
33
30
  if (manifest[platform].versionCode > release.versionCode) throw new Error('Public Android version would go backwards');
34
31
  manifest[platform] = { status: 'published', statusText: { zh: '', en: '' }, version: release.versionName,
35
32
  versionCode: release.versionCode, size: release.size, sha256: release.sha256,
@@ -39,6 +36,24 @@ function updateMobileManifest(platform, release, file) {
39
36
  manifest.harmonyos.url = 'https://www.coderelax.cn/code-relax/install';
40
37
  manifest.harmonyos.manifestUrl = release.manifestUrl;
41
38
  }
39
+ }, file);
40
+ }
41
+
42
+ export function updatePublicWorkstations(version, file = publicManifestFile) {
43
+ if (!/^\d+\.\d+\.\d+-beta\.\d+$/.test(version)) throw Error('Invalid npm version');
44
+ return updatePublicManifest(manifest => {
45
+ for (const platform of ['windows', 'macos', 'linux']) {
46
+ if (manifest[platform].status === 'published') manifest[platform].version = version;
47
+ }
48
+ }, file);
49
+ }
50
+
51
+ export function updatePublicManifest(update, file = publicManifestFile) {
52
+ const lock = file + '.lock', temporary = file + '.new';
53
+ const handle = fs.openSync(lock, 'wx', 0o600);
54
+ try {
55
+ const manifest = readPublicManifest(file);
56
+ update(manifest);
42
57
  const owner = fs.statSync(file);
43
58
  fs.writeFileSync(temporary, JSON.stringify(manifest, null, 2) + '\n', { mode: owner.mode & 0o777 });
44
59
  fs.chmodSync(temporary, owner.mode & 0o777);
@@ -989,7 +989,7 @@ function controlPayload(value) {
989
989
  const message = JSON.parse(value);
990
990
  if (message.type === 'unsubscribe') return true;
991
991
  return ['request', 'subscribe'].includes(message.type) && typeof message.path === 'string' &&
992
- (message.path === '/api/health' || message.path === '/api/desktop/start' || message.path.startsWith('/api/shared-recovery'));
992
+ (message.path === '/api/health' || ['/api/desktop/start', '/api/desktop/restart'].includes(message.path) || message.path.startsWith('/api/shared-recovery'));
993
993
  } catch { return false; }
994
994
  }
995
995
 
@@ -38,6 +38,7 @@ import { DotsMessageImages } from './dots-message-images.mjs';
38
38
  import { desktopDotsProfiles } from './platform/windows/dots-profile.mjs';
39
39
  import { readPlanPricing } from './plan-pricing.mjs';
40
40
  import { requestRecovery, recoveryStatus } from './shared-recovery.mjs';
41
+ import { startDesktop } from './desktop-launcher.mjs';
41
42
  import { requestServiceRestart } from './service-restart.mjs';
42
43
  import { checkServiceUpdate, requestServiceUpdate, serviceUpdateStatus } from './service-update.mjs';
43
44
  import { TurnModelHistory } from './turn-model-history.mjs';
@@ -261,10 +262,17 @@ async function handleRequest(request, response) {
261
262
  if (request.method === 'GET' && url.pathname === '/api/shared-recovery') {
262
263
  sendJson(response, 200, await recoveryStatus(stateRoot)); return;
263
264
  }
264
- if (request.method === 'POST' && ['/api/shared-recovery', '/api/desktop/start'].includes(url.pathname)) {
265
+ if (request.method === 'POST' && ['/api/desktop/start', '/api/desktop/restart'].includes(url.pathname)) {
266
+ const body = await readJsonBody(request);
267
+ if (body.confirmed !== true) throw new RequestError(400, '请确认启动桌面端。');
268
+ const result = await startDesktop({ stateRoot, host: desktopHost, restart: url.pathname === '/api/desktop/restart' });
269
+ await runtimeDiscovery.sample();
270
+ sendJson(response, 202, result); return;
271
+ }
272
+ if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
265
273
  const body = await readJsonBody(request);
266
274
  if (body.confirmed !== true || typeof body.operationId !== 'string') throw new RequestError(400, '请确认重新打开当前 Codex 并接入 Codex 内核后再继续。');
267
- sendJson(response, 202, await requestRecovery(stateRoot, { ...body, startOnly: url.pathname === '/api/desktop/start' })); return;
275
+ sendJson(response, 202, await requestRecovery(stateRoot, body)); return;
268
276
  }
269
277
  if (url.pathname === '/api/service/update' && request.method === 'GET') {
270
278
  sendJson(response, 200, await checkServiceUpdate(stateRoot)); return;
@@ -494,7 +502,13 @@ async function handleRequest(request, response) {
494
502
  const archived = url.searchParams.get('archived') === 'true';
495
503
  const scope = url.searchParams.get('scope') || 'all';
496
504
  if (!['all', 'local', 'durable'].includes(scope)) { sendJson(response, 400, { error: '无效的会话列表范围' }); return; }
497
- sendJson(response, 200, await sharedCatalog.threads(limit, archived, scope, url.searchParams.get('refresh') === '1'));
505
+ const catalog = await sharedCatalog.threads(limit, archived, scope, url.searchParams.get('refresh') === '1');
506
+ const creations = await messageReceipts.creationBindings();
507
+ for (const thread of [...(catalog.threads || []), ...(catalog.pinnedThreads || [])]) {
508
+ const clientId = creations.get(thread.id);
509
+ if (clientId && (thread.hostId || 'local') === 'local') thread.creationClientId = clientId;
510
+ }
511
+ sendJson(response, 200, catalog);
498
512
  return;
499
513
  }
500
514
 
@@ -708,13 +722,23 @@ async function handleRequest(request, response) {
708
722
 
709
723
  const createStatusMatch = /^\/api\/projects\/([^/]+)\/threads\/([a-f0-9]{32})\/status$/.exec(url.pathname);
710
724
  if (request.method === 'GET' && createStatusMatch) {
711
- sendJson(response, 200, messageReceipts.status(['project', decodeURIComponent(createStatusMatch[1])], createStatusMatch[2])); return;
725
+ const creationScope = ['project', decodeURIComponent(createStatusMatch[1])], clientId = createStatusMatch[2];
726
+ let status = messageReceipts.status(creationScope, clientId);
727
+ if (status.state === 'unknown' && status.createdThreadId) {
728
+ const threadId = status.createdThreadId;
729
+ status = await messageReceipts.reconcile(creationScope, clientId, async () => {
730
+ const found = await sharedServer.findSentMessage(threadId, clientId);
731
+ return found ? { threadId, hostId: 'local', turnId: found.data?.turnId } : null;
732
+ });
733
+ }
734
+ sendJson(response, 200, status); return;
712
735
  }
713
736
  const createThreadMatch = /^\/api\/projects\/([^/]+)\/threads$/.exec(url.pathname);
714
737
  if (request.method === 'POST' && createThreadMatch) {
715
738
  const projectId = decodeURIComponent(createThreadMatch[1]);
716
739
  const body = await readJsonBody(request);
717
740
  const result = await messageReceipts.submit(['project', projectId], body.clientUserMessageId, async () => {
741
+ const bindCreation = messageReceipts.beginCreation(['project', projectId], body.clientUserMessageId);
718
742
  try {
719
743
  await requireExecutionWritable();
720
744
  if (body.transport === 'app-server' || (body.hostId && body.hostId !== 'local')) {
@@ -735,7 +759,7 @@ async function handleRequest(request, response) {
735
759
  }
736
760
  const { input } = appServerTurnArguments('', body);
737
761
  messageSubmissionStarted = true;
738
- const created = await appServerEvents.createThread(rpc, target, input, body);
762
+ const created = await appServerEvents.createThread(rpc, target, input, body, undefined, bindCreation);
739
763
  const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
740
764
  void generateAndSetThreadTitle({
741
765
  request: rpc,
@@ -749,8 +773,10 @@ async function handleRequest(request, response) {
749
773
  }).catch(error => console.error(`生成对话标题失败:${error.message}`));
750
774
  return { ...created, hostId:'local' };
751
775
  } catch (error) {
752
- error.deliveryUnknown = messageSubmissionStarted;
753
- if (error.deliveryResult) error.deliveryResult = error.deliveryResult.then(created => ({ ...created, hostId: 'local' }));
776
+ error.deliveryUnknown = Boolean(messageReceipts.status(['project', projectId], body.clientUserMessageId).createdThreadId)
777
+ || messageSubmissionStarted && !messageDelivery.rejectedBeforeSubmission(error);
778
+ if (error.deliveryResult) error.deliveryResult = error.deliveryResult.then(created => ({ ...created, hostId: 'local' })).finally(() => bindCreation());
779
+ else bindCreation();
754
780
  throw error;
755
781
  }
756
782
  });
@@ -59,7 +59,18 @@ export async function doctor({ paths, host, service, skillTarget, ensureAutostar
59
59
  : `已安装版本 ${installedVersion},但无法确认运行服务版本;请保留诊断结果核查,不能据此认定旧进程或自动重启。`);
60
60
  if (state.running) add('execution', state.execution?.ready === true && state.execution?.canWrite === true ? 'ok' : 'error',
61
61
  state.execution?.ready === true && state.execution?.canWrite === true
62
- ? 'Codex 完整控制已就绪且可写。' : 'Codex 完整控制尚未确认就绪且可写;请查看内核和连接诊断,不能仅凭服务在线判定健康。');
62
+ ? 'Codex 完整控制通道已就绪;单个对话的写入权限需在打开该对话时确认。' : 'Codex 完整控制尚未确认就绪且可写;请查看内核和连接诊断,不能仅凭服务在线判定健康。');
63
+ if (state.running && state.desktop) {
64
+ const descriptions = {
65
+ managed: '桌面端已接入 Code Relax 内核。',
66
+ ordinary: '桌面端正在独立运行,未接入 Code Relax 内核;若同一对话报写入冲突,请核对桌面端占用。',
67
+ stopped: '桌面端未启动;手机可独立使用已就绪的内核。',
68
+ unsupported: '当前平台未提供桌面端接入。',
69
+ };
70
+ add('desktop', 'ok', state.desktop.installed === false && state.desktop.state !== 'unsupported'
71
+ ? '未安装桌面端;手机可独立使用已就绪的内核。'
72
+ : descriptions[state.desktop.state] || '桌面端接入状态暂未确认。');
73
+ }
63
74
  const currentVersion = state.managedAppServer?.version || null;
64
75
  const selectedVersion = state.managedAppServer?.selectedVersion || null;
65
76
  if (state.managedAppServer) kernelUpdate = { pending: state.managedAppServer.updatePending === true,
@@ -91,13 +102,20 @@ export async function doctor({ paths, host, service, skillTarget, ensureAutostar
91
102
  collectedAt: new Date().toISOString(), platform: process.platform, arch: process.arch, osRelease: os.release(),
92
103
  nodeVersion: process.version, nodeExecutable: process.execPath, serviceRoot: paths.serviceRoot,
93
104
  servicePid: serviceState?.pid || null, port: serviceState?.port || null, serviceVersion: serviceState?.version || null,
105
+ kernel: serviceState?.managedAppServer ? {
106
+ version: serviceState.managedAppServer.version || null,
107
+ executable: serviceState.managedAppServer.executable || null,
108
+ selectedVersion: serviceState.managedAppServer.selectedVersion || null,
109
+ preferredExecutable: serviceState.managedAppServer.preferredExecutable || null,
110
+ selectedSource: serviceState.managedAppServer.source || null,
111
+ } : null,
94
112
  } } : {}) };
95
113
  }
96
114
 
97
115
  export function formatDoctorOutput(result, detail = false) {
98
116
  const labels = { 'keep-awake': '保持唤醒', installation: '安装文件', 'bridge-runtime': '运行环境', network: '局域网',
99
117
  autostart: '登录自启动', service: '工作站服务', 'managed-app-server': 'Codex 内核',
100
- execution: '完整控制', 'package-version': '安装包版本', 'service-version': '服务版本', 'managed-app-server-update': 'Codex 更新',
118
+ execution: '完整控制', desktop: 'Codex 桌面端', 'package-version': '安装包版本', 'service-version': '服务版本', 'managed-app-server-update': 'Codex 更新',
101
119
  'managed-app-server-launcher': '工作站更新', skill: 'Codex Skill' };
102
120
  const byId = id => result.checks.find(item => item.id === id);
103
121
  const summary = (id, success) => {
@@ -106,7 +124,7 @@ export function formatDoctorOutput(result, detail = false) {
106
124
  };
107
125
  const issues = result.checks.filter(item => item.status !== 'ok');
108
126
  const lines = [result.ready ? '工作站检查通过。' : '工作站检查发现问题。',
109
- summary('installation', '完整'), summary('package-version', 'CLI 与固定服务一致'), summary('service', '正在运行'), summary('managed-app-server', '已就绪'), summary('execution', '已就绪且可写')];
127
+ summary('installation', '完整'), summary('package-version', 'CLI 与固定服务一致'), summary('service', '正在运行'), summary('managed-app-server', '已就绪'), summary('execution', '通道已就绪(不代表每个对话均可写)')];
110
128
  if (issues.length) lines.push('', '需要处理或留意:', ...issues.map(item => `- ${labels[item.id] || item.id}:${item.message}`));
111
129
  if (detail) {
112
130
  const info = result.diagnostics || {};
@@ -115,6 +133,9 @@ export function formatDoctorOutput(result, detail = false) {
115
133
  `Node:${info.nodeVersion || process.version};${info.nodeExecutable || process.execPath}`,
116
134
  `服务目录:${info.serviceRoot || '未知'}`, `服务进程:${info.servicePid || '未运行'};端口:${info.port || '未知'}`, '');
117
135
  lines.push('', '检查详情:', ...result.checks.filter(item => item.status === 'ok').map(item => `- ${labels[item.id] || item.id}:${item.message}`));
136
+ if (info.kernel) lines.push('',
137
+ `运行内核:${info.kernel.version || '未知'};${info.kernel.executable || '路径未知'}`,
138
+ `候选内核:${info.kernel.selectedVersion || '未知'};来源 ${info.kernel.selectedSource || '未知'};${info.kernel.preferredExecutable || '路径未知'}`);
118
139
  const recovery = result.desktopRecovery;
119
140
  if (recovery && !['none', 'unsupported'].includes(recovery.status)) {
120
141
  const status = { failed: '未完成', completed: '已完成', succeeded: '已完成', running: '进行中', unknown: '无法读取' }[recovery.status] || recovery.status;
@@ -20,12 +20,12 @@ export async function requestServiceRestart(stateRoot) {
20
20
  const lock = path.join(stateRoot, 'service-restart.lock');
21
21
  const operationId = randomUUID();
22
22
  const owner = await processIdentity(process.pid);
23
- if (!owner) throw new Error('无法确认服务重启请求进程。');
23
+ if (!owner) throw new Error('无法确认桌面服务重启请求进程。');
24
24
  const previous = readRecord(lock);
25
25
  if (previous) {
26
26
  const actual = previous.owner?.pid ? await processIdentity(previous.owner.pid) : null;
27
27
  if (sameProcess(previous.owner, actual) || readRecord(lock)?.operationId !== previous.operationId) {
28
- throw new Error('工作站服务已在重启中。');
28
+ throw new Error('桌面服务已在重启中。');
29
29
  }
30
30
  fs.rmSync(lock);
31
31
  }
@@ -36,18 +36,18 @@ export async function requestServiceRestart(stateRoot) {
36
36
  // Publish a complete record exclusively; a crash cannot leave a half-written lock.
37
37
  fs.linkSync(temporary, lock);
38
38
  } catch (error) {
39
- if (error.code === 'EEXIST') throw new Error('工作站服务已在重启中。');
39
+ if (error.code === 'EEXIST') throw new Error('桌面服务已在重启中。');
40
40
  throw error;
41
41
  } finally { fs.rmSync(temporary, { force: true }); }
42
42
  try {
43
43
  const log = path.join(stateRoot, 'service-restart.log');
44
44
  const pid = await startBackground(process.execPath, [script, '--worker', stateRoot, operationId], process.env, [log, log]);
45
45
  const worker = await processIdentity(pid);
46
- if (!worker) throw new Error('服务重启进程未启动。');
46
+ if (!worker) throw new Error('桌面服务重启进程未启动。');
47
47
  const current = readRecord(lock);
48
- if (current?.operationId !== operationId) throw new Error('服务重启实例已失效。');
48
+ if (current?.operationId !== operationId) throw new Error('桌面服务重启实例已失效。');
49
49
  writeRecord(lock, { ...current, owner: worker });
50
- return { operationId, status: 'scheduled', message: '工作站服务重启已安排。' };
50
+ return { operationId, status: 'scheduled', message: '桌面服务重启已安排。' };
51
51
  } catch (error) {
52
52
  const current = readRecord(lock);
53
53
  if (current?.operationId === operationId && sameProcess(current.owner, owner)) fs.rmSync(lock, { force: true });
@@ -61,9 +61,9 @@ async function run(stateRoot, operationId) {
61
61
  const deadline = Date.now() + 30000;
62
62
  while (true) {
63
63
  const record = readRecord(lock);
64
- if (record?.operationId !== operationId) throw new Error('服务重启实例已失效。');
64
+ if (record?.operationId !== operationId) throw new Error('桌面服务重启实例已失效。');
65
65
  if (record.owner?.pid === process.pid) break;
66
- if (Date.now() >= deadline) throw new Error('服务重启进程交接超时。');
66
+ if (Date.now() >= deadline) throw new Error('桌面服务重启进程交接超时。');
67
67
  await delay(100);
68
68
  }
69
69
  const service = lifecycle({ stateRoot, host });
@@ -31,9 +31,9 @@ try {
31
31
  const code = await new Promise((resolve, reject) => {
32
32
  child.once('error', reject); child.once('exit', (code, signal) => resolve(code ?? `signal ${signal}`));
33
33
  });
34
- if (code !== 0) throw new Error(`npm 安装失败(${code}),请查看工作站更新日志。`);
34
+ if (code !== 0) throw new Error(`npm 安装失败(${code}),请查看桌面服务更新日志。`);
35
35
  const installed = JSON.parse(fs.readFileSync(path.join(job.serviceRoot, 'installation.json'), 'utf8'));
36
- if (installed.version !== job.targetVersion) throw new Error('安装结束,但工作站版本与目标版本不一致。');
36
+ if (installed.version !== job.targetVersion) throw new Error('安装结束,但桌面服务版本与目标版本不一致。');
37
37
  update({ status: 'completed', finishedAt: new Date().toISOString(), installedVersion: installed.version });
38
38
  } catch (error) {
39
39
  console.error(error.stack || error.message);
@@ -13,7 +13,7 @@ export function publicUpdateOperation(record, installedVersion) {
13
13
  if (!record) return null;
14
14
  return { operationId: record.operationId, targetVersion: record.targetVersion,
15
15
  status: record.status, targetInstalled: Boolean(installedVersion && installedVersion === record.targetVersion),
16
- error: record.status === 'failed' ? '上次更新未完整完成,请查看工作站更新日志。' : null,
16
+ error: record.status === 'failed' ? '上次更新未完整完成,请查看桌面服务更新日志。' : null,
17
17
  installedVersion: record.installedVersion || null };
18
18
  }
19
19
  export function serviceUpdateStatus(root) {
@@ -25,7 +25,7 @@ export function serviceUpdateStatus(root) {
25
25
  }
26
26
  export async function checkServiceUpdate(root) {
27
27
  const result = await fetch('https://registry.npmjs.org/@walkhi%2fcode-relax/latest', { signal: AbortSignal.timeout(12000) });
28
- if (!result.ok) throw new Error(`查询工作站版本失败:HTTP ${result.status}`);
28
+ if (!result.ok) throw new Error(`查询桌面服务版本失败:HTTP ${result.status}`);
29
29
  const latest = await result.json();
30
30
  if (!versionPattern.test(latest.version || '')) throw new Error('npm 返回的版本号无效');
31
31
  return { ...serviceUpdateStatus(root), latestVersion: latest.version };
@@ -33,9 +33,9 @@ export async function checkServiceUpdate(root) {
33
33
  let scheduling = false;
34
34
  export async function requestServiceUpdate(root, body) {
35
35
  if (body.confirmed !== true || !/^[0-9a-f-]{36}$/i.test(body.operationId || '') || !versionPattern.test(body.targetVersion || '')) {
36
- throw new Error('请确认工作站更新及目标版本后再继续。');
36
+ throw new Error('请确认桌面服务更新及目标版本后再继续。');
37
37
  }
38
- if (scheduling) throw new Error('工作站更新正在安排。');
38
+ if (scheduling) throw new Error('桌面服务更新正在安排。');
39
39
  scheduling = true;
40
40
  try {
41
41
  const previous = readRecord(statusPath(root));
@@ -43,14 +43,14 @@ export async function requestServiceUpdate(root, body) {
43
43
  if (previous && ['scheduled', 'running'].includes(previous.status)) {
44
44
  const actual = previous.owner?.pid ? await desktopHost.process(previous.owner.pid) : null;
45
45
  if (actual && actual.created === previous.owner.created && actual.executable === previous.owner.executable) {
46
- throw new Error('工作站已有更新正在执行。');
46
+ throw new Error('桌面服务已有更新正在执行。');
47
47
  }
48
48
  writeRecord(statusPath(root), { ...previous, status: 'failed', error: '更新进程已退出,未确认安装完成。' });
49
49
  }
50
50
  const latest = await checkServiceUpdate(root);
51
51
  if (!latest.supported) throw new Error('当前安装缺少 npm 路径,请先安装支持手机更新的过渡版。');
52
52
  if (latest.latestVersion !== body.targetVersion) throw new Error('最新版本已变化,请重新检查后确认。');
53
- if (latest.currentVersion === body.targetVersion) throw new Error('工作站已是此版本。');
53
+ if (latest.currentVersion === body.targetVersion) throw new Error('桌面服务已是此版本。');
54
54
  const installed = installation();
55
55
  const directory = path.join(root, 'service-updates', body.operationId);
56
56
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
@@ -70,7 +70,7 @@ export async function requestServiceUpdate(root, body) {
70
70
  pid: owner.pid, created: owner.created, executable: owner.executable } });
71
71
  } catch (error) {
72
72
  writeRecord(statusPath(root), { ...readRecord(statusPath(root)), status: 'failed', error: error.message });
73
- throw new Error('工作站更新启动未完成,请查看工作站更新日志;结果确认前不要重复提交。');
73
+ throw new Error('桌面服务更新启动未完成,请查看桌面服务更新日志;结果确认前不要重复提交。');
74
74
  }
75
75
  return serviceUpdateStatus(root);
76
76
  } finally { scheduling = false; }
@@ -24,12 +24,20 @@ export const recoveryWarning = 'Codex Desktop 需要重新打开,才能接入
24
24
 
25
25
  export async function recoveryStatus(stateRoot, host = desktopHost) {
26
26
  if (host.desktopIntegration === false) return { status: 'unsupported', message: '此平台尚不支持 Desktop 自动接入。' };
27
- const record = readRecord(recordPath(stateRoot));
27
+ let record = readRecord(recordPath(stateRoot));
28
28
  if (!record) return { status: 'none', message: '尚未执行 Desktop 接入流程。' };
29
29
  const lostWorker = active(record) && (record.worker
30
30
  ? !sameProcess(record.worker, await host.process(record.worker.pid))
31
31
  : Date.now() - Date.parse(record.startedAt) > 30000);
32
32
  if (lostWorker) {
33
+ // Process lookup is asynchronous: the worker may have committed its result
34
+ // while it was running. Never replace that result with a stale failure.
35
+ const latest = readRecord(recordPath(stateRoot));
36
+ if (latest?.operationId !== record.operationId || !active(latest)) {
37
+ const { worker, oldService, desktop, ...publicRecord } = latest || {};
38
+ return publicRecord;
39
+ }
40
+ record = latest;
33
41
  record.status = 'failed'; record.error = '独立恢复进程已退出,恢复未完成。';
34
42
  record.steps = record.steps?.map(step => ({ ...step, status: step.status === 'running' ? 'failed' : step.status }));
35
43
  writeRecord(recordPath(stateRoot), record);
@@ -51,7 +59,7 @@ export async function planRecovery(stateRoot, host, available = portAvailable) {
51
59
  return { desktop: info.desktop, action };
52
60
  }
53
61
 
54
- export async function requestRecovery(stateRoot, { confirmed, operationId = randomUUID(), startOnly = false } = {}, host = desktopHost) {
62
+ export async function requestRecovery(stateRoot, { confirmed, operationId = randomUUID() } = {}, host = desktopHost) {
55
63
  if (host.desktopIntegration === false) throw new Error('此平台尚不支持 Desktop 自动接入。');
56
64
  if (confirmed !== true) throw new Error(recoveryWarning + ' 请明确确认后再执行。');
57
65
  if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(operationId)) throw new Error('无效的恢复操作编号。');
@@ -60,13 +68,12 @@ export async function requestRecovery(stateRoot, { confirmed, operationId = rand
60
68
  if (previous.operationId === operationId) return previous;
61
69
  if (active(previous)) throw new Error('已有恢复操作正在执行,请查看恢复状态。');
62
70
  const planned = await planRecovery(stateRoot, host);
63
- if (startOnly && (await host.inspect()).running) throw new Error('Codex 桌面端已经启动,未执行重启。');
64
71
  let lock;
65
72
  try { lock = fs.openSync(lockPath(stateRoot), 'wx'); }
66
73
  catch (error) { if (error.code === 'EEXIST') throw new Error('已有恢复操作,请先查看恢复状态。'); throw error; }
67
74
  fs.writeFileSync(lock, JSON.stringify({ operationId })); fs.closeSync(lock);
68
75
  const record = { operationId, status: 'scheduled', stage: 'prepared', startedAt: new Date().toISOString(),
69
- message: 'Codex 连接请求已接受,电脑管理连接保持运行。', startOnly, steps: recoverySteps().filter(step => !startOnly || step.id !== 'stopping-desktop'), ...planned };
76
+ message: 'Codex 连接请求已接受,电脑管理连接保持运行。', steps: recoverySteps(), ...planned };
70
77
  try {
71
78
  writeRecord(recordPath(stateRoot), record);
72
79
  const log = path.join(stateRoot, 'shared-recovery.log');
@@ -104,9 +111,7 @@ export async function runRecovery(stateRoot, operationId, dependencies = {}) {
104
111
  const planned = await planRecovery(stateRoot, host, available);
105
112
  record = { ...record, ...planned };
106
113
  if (fs.existsSync(path.join(stateRoot, 'desktop-launch.lock'))) throw new Error('Desktop 正在启动,未与启动操作并行恢复。');
107
- if (record.startOnly) {
108
- if ((await host.inspect()).running) throw new Error('Codex 桌面端已经启动,未执行重启。');
109
- } else { stage('stopping-desktop'); await host.closeDesktop(record.desktop); }
114
+ stage('stopping-desktop'); await host.closeDesktop(record.desktop);
110
115
  await waitFor(async () => !(await host.inspect()).running, '当前 Codex Desktop 未退出,未继续接入 Codex 内核。');
111
116
  stage('starting-app-server'); await start({ stateRoot, host, recoveryId: operationId, progress: stage });
112
117
  stage('checking-execution');
@@ -126,10 +126,11 @@ export async function streamSharedThread(response, threadId, client, normalize,
126
126
  const delivered = subscription.snapshot ? normalized : catchup ? await normalize(catchup) : null;
127
127
  const sent = Boolean(delivered) && !closed;
128
128
  if (sent) await emit('update', delivered);
129
- // A timestamp-confirmed cache only needs the new subscription boundary,
130
- // never an empty replacement window or a repeated body snapshot.
131
- if (subscription.confirmed && !closed) await emit('sync-ready', {
132
- thread: normalized.thread, sync: normalized.sync,
129
+ // Confirm every handoff after its catchup/snapshot has been delivered.
130
+ // This also releases a cached selection with a valid, unchanged token;
131
+ // transport subscription alone does not confirm the message boundary.
132
+ if (!closed) await emit('sync-ready', {
133
+ thread: { ...normalized.thread, turns: [] }, sync: normalized.sync,
133
134
  });
134
135
  client.traceSync?.('stream-initial-sent', threadId, { ms: Math.round(performance.now() - started), closed,
135
136
  snapshot: subscription.snapshot, catchup: Boolean(catchup), sent, seq: normalized.sync?.seq,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.45",
3
+ "version": "0.1.0-beta.50",
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.45",
45
- "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.45",
46
- "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.45"
44
+ "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.50",
45
+ "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.50",
46
+ "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.50"
47
47
  }
48
48
  }