@walkhi/code-relax 0.1.0-beta.71 → 0.1.0-beta.76

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.
@@ -1 +1 @@
1
- {"layout":"npm-platform-packages","version":"0.1.0-beta.71"}
1
+ {"layout":"npm-platform-packages","version":"0.1.0-beta.76"}
@@ -17,12 +17,21 @@ function desktopEnvironment(stateRoot, environment) {
17
17
 
18
18
  // Opening the optional UI does not start, restart or supervise the kernel.
19
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 桌面端启动请求。' };
20
+ let stage = 'inspect';
21
+ try {
22
+ const info = await host.inspect();
23
+ if (!info.desktop) throw new Error('未安装 Codex 桌面端。');
24
+ if (info.running && !restart) return { status: 'running', message: 'Codex 桌面端已启动。' };
25
+ if (info.running) { stage = 'close'; await host.closeDesktop(info.desktop); }
26
+ stage = 'activate';
27
+ await host.startDesktop(info.desktop, desktopEnvironment(stateRoot, environment));
28
+ return { status: 'accepted', message: restart ? '已提交 Codex 桌面端重启请求。' : '已提交 Codex 桌面端启动请求。' };
29
+ } catch (error) {
30
+ console.error(JSON.stringify({ time: new Date().toISOString(), event: 'desktop-operation-failed', restart, stage,
31
+ message: error.message, code: error.code, stderr: error.stderr }));
32
+ throw new Error(stage === 'close' ? '桌面端关闭失败,请查看工作站诊断日志。'
33
+ : stage === 'activate' ? '桌面端启动失败,请查看工作站诊断日志。' : '无法检查桌面端,请查看工作站诊断日志。', { cause: error });
34
+ }
26
35
  }
27
36
 
28
37
  export async function launchDesktop({ stateRoot, host, environment = process.env, wait = delay, timeout = 30000, recoveryId, progress = () => {}, service = lifecycle({ stateRoot, host: serviceHost }) }) {
@@ -33,11 +33,13 @@ export class DotsChatServer extends HostedHistoryAppServer {
33
33
  this.observedThreads = new Set(); this.reconnectAttempts = 0;
34
34
  this.approvalTimeline = new DotsApprovalTimeline(this);
35
35
  this.chatActivity = new DotsRoomActivity(this);
36
+ this.backgroundActivity = new Map();
36
37
  this.chatActivity.on('unavailable', error => this.emit('activity-error', error));
37
38
  this.rooms.on('message', (topic, value) => this.roomNotification(topic, value));
38
39
  this.rooms.on('disconnected', reason => { this.invalidate(reason); this.scheduleReconnect(); });
39
- this.hostDisconnected = () => { this.rooms.close(); this.invalidate({ source: 'hosted-app-server' }); };
40
- this.hostConnected = () => { void this.restoreObservers().catch(error => this.emit('subscription-error', error)); };
40
+ this.hostDisconnected = () => { this.rooms.close(); this.invalidate({ source: 'hosted-app-server' }); this.scheduleReconnect(); };
41
+ this.hostConnected = () => { this.syncBackgroundActivity(); void this.restoreObservers().catch(error => this.emit('subscription-error', error)); };
42
+ this.profilesChanged = () => this.syncBackgroundActivity();
41
43
  this.hostNotification = ({ method, params }) => this.executionNotification(method, params);
42
44
  this.hostRequestsChanged = id => {
43
45
  this.emit('requests-changed', id);
@@ -51,6 +53,31 @@ export class DotsChatServer extends HostedHistoryAppServer {
51
53
  };
52
54
  transport.on('disconnected', this.hostDisconnected); transport.on('connected', this.hostConnected);
53
55
  transport.on('host-notification', this.hostNotification); transport.on('requests-changed', this.hostRequestsChanged);
56
+ transport.on('profiles-changed', this.profilesChanged);
57
+ }
58
+
59
+ startBackgroundActivity() {
60
+ if (this.closed || this.backgroundStarting) return;
61
+ this.backgroundEnabled = true;
62
+ this.backgroundStarting = this.transport.connect().then(() => {
63
+ if (this.closed) return;
64
+ this.syncBackgroundActivity();
65
+ this.transport.refreshLiveProfiles();
66
+ }).catch(error => { this.emit('activity-error', error); this.scheduleReconnect(); })
67
+ .finally(() => { this.backgroundStarting = null; });
68
+ }
69
+ syncBackgroundActivity() {
70
+ if (this.closed || !this.backgroundEnabled || !this.transport.ready) return;
71
+ const roots = new Set([...this.transport.profileNames.values()].map(profile => profile.activeRootThreadId).filter(Boolean));
72
+ for (const [id, release] of this.backgroundActivity) if (!roots.has(id)) {
73
+ release(); this.backgroundActivity.delete(id);
74
+ }
75
+ for (const id of roots) if (!this.backgroundActivity.has(id)) {
76
+ this.backgroundActivity.set(id, this.chatActivity.acquire(id, () => {}));
77
+ // Establish the existing bounded message window and durable cache without
78
+ // a phone consumer. Subsequent pushes use the normal incremental path.
79
+ void this.timeline(id).catch(error => this.emit('subscription-error', error));
80
+ }
54
81
  }
55
82
 
56
83
  get ready() { return this.transport?.ready === true && this.rooms?.ready === true; }
@@ -80,6 +107,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
80
107
  this.traceSync('invalidated', null, reason);
81
108
  clearTimeout(this.reconnectTimer);
82
109
  this.epoch = randomUUID(); this.chatActivity.close(); this.approvalTimeline.clear();
110
+ this.backgroundActivity.clear();
83
111
  this.roomApprovalResponses.clear();
84
112
  for (const record of this.threads.values()) { record.needsReload = true; record.tokens.clear(); record.refresh = null; }
85
113
  for (const timer of this.windowSaves.values()) clearTimeout(timer); this.windowSaves.clear();
@@ -87,7 +115,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
87
115
  this.windowLoads.clear(); this.historyLoads.clear(); this.emit('disconnected');
88
116
  }
89
117
  scheduleReconnect() {
90
- if (this.closed || !this.transport.ready || !this.observedThreads.size || this.reconnectAttempts >= 5) return;
118
+ if (this.closed || (!this.observedThreads.size && !this.backgroundEnabled) || this.reconnectAttempts >= 5) return;
91
119
  clearTimeout(this.reconnectTimer);
92
120
  this.reconnectTimer = setTimeout(() => {
93
121
  this.reconnectTimer = null;
@@ -96,8 +124,10 @@ export class DotsChatServer extends HostedHistoryAppServer {
96
124
  this.reconnectTimer.unref();
97
125
  }
98
126
  async restoreObservers() {
99
- if (!this.observedThreads.size || this.closed) return;
127
+ if ((!this.observedThreads.size && !this.backgroundEnabled) || this.closed) return;
100
128
  await this.connect();
129
+ this.syncBackgroundActivity();
130
+ if (this.backgroundEnabled) this.transport.refreshLiveProfiles();
101
131
  for (const id of this.observedThreads) await this.timeline(id);
102
132
  this.reconnectAttempts = 0;
103
133
  }
@@ -409,7 +439,8 @@ export class DotsChatServer extends HostedHistoryAppServer {
409
439
  if (epoch !== this.epoch) throw new Error('点点连接已变化,请核对后发送。');
410
440
  const result = await this.rooms.request(`/messaging/rooms/${encodeURIComponent(room.id)}/messages`, {
411
441
  method: 'POST', body: { content: { text: args.prompt, ...(attachments.length ? { attachments } : {}) },
412
- request_id: requestId, idempotency_token: requestId } });
442
+ request_id: requestId, idempotency_token: requestId,
443
+ timezone_offset_min: new Date().getTimezoneOffset() } });
413
444
  if (epoch !== this.epoch || !result?.id) throw Object.assign(new Error('发送结果未确认,请核对消息。'), { deliveryUnknown: true });
414
445
  record.dirty++;
415
446
  void this.refreshMessages(args.threadId, record).catch(error => {
@@ -482,6 +513,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
482
513
  }
483
514
  }
484
515
  acquireActivity(threadId, listener) { return this.chatActivity.acquire(threadId, listener); }
516
+ retryActivity(threadId) { return this.chatActivity.retryUnavailable(threadId); }
485
517
  acquirePanelActivity(threadId, listener) { return this.chatActivity.acquire(threadId, listener); }
486
518
  isHistorySyncing() { return false; }
487
519
  async readContext() { return { available: false }; }
@@ -493,6 +525,7 @@ export class DotsChatServer extends HostedHistoryAppServer {
493
525
  this.closed = true; clearTimeout(this.reconnectTimer); this.rooms.close(); this.invalidate(); this.chatActivity.dispose();
494
526
  this.transport.off('disconnected', this.hostDisconnected); this.transport.off('connected', this.hostConnected);
495
527
  this.transport.off('host-notification', this.hostNotification); this.transport.off('requests-changed', this.hostRequestsChanged);
528
+ this.transport.off('profiles-changed', this.profilesChanged);
496
529
  for (const timer of this.windowSaves.values()) clearTimeout(timer); this.windowSaves.clear();
497
530
  }
498
531
  }
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
 
3
- // Desktop 26.1007.9446.0: room-live activity and responding heartbeats are
3
+ // Desktop 26.1016.6573.0: room-live activity and responding heartbeats are
4
4
  // independent. Heartbeats expire after 10s; Dot reply handoff lasts at most 5s.
5
5
  const typingTimeout = 10000, handoffTimeout = 5000;
6
6
  const statusLabels = {
@@ -8,6 +8,7 @@ const statusLabels = {
8
8
  payment: '等待支付', 'needs input': '等待你的输入', paused: '已暂停',
9
9
  };
10
10
  const waitingStates = new Set(['payment', 'needs input', 'paused']);
11
+ const retryableStatus = status => status == null || status === 408 || status === 429 || status >= 500;
11
12
 
12
13
  export class DotsRoomActivity extends EventEmitter {
13
14
  constructor(server) {
@@ -41,6 +42,13 @@ export class DotsRoomActivity extends EventEmitter {
41
42
  if (!watch.listeners.size) { this.watches.delete(threadId); this.stop(watch); }
42
43
  };
43
44
  }
45
+ retryUnavailable(threadId) {
46
+ const watch = this.watches.get(threadId);
47
+ if (!watch?.unavailable || !watch.listeners.size || watch.controller || watch.timer) return false;
48
+ watch.attempts = 0;
49
+ void this.start(watch);
50
+ return true;
51
+ }
44
52
  publish(watch, activity) {
45
53
  if (JSON.stringify(watch.activity) === JSON.stringify(activity)) return;
46
54
  watch.activity = activity;
@@ -100,14 +108,17 @@ export class DotsRoomActivity extends EventEmitter {
100
108
  }
101
109
  apply(watch, value) {
102
110
  if (value.subscription_id !== watch.subscriptionId || !Number.isSafeInteger(value.revision) || value.revision <= watch.revision) return;
103
- watch.revision = value.revision;
104
111
  if (value.type === 'calpico-room-live-error') {
112
+ watch.revision = value.revision;
105
113
  this.fail(watch, new Error('点点活动暂不可用。'), value.code === 'temporarily_unavailable'); return;
106
114
  }
107
115
  if (value.type !== 'calpico-room-live-state' || !Array.isArray(value.threads)) return;
116
+ watch.revision = value.revision;
117
+ clearTimeout(watch.initialTimer); watch.initialTimer = null;
108
118
  const current = value.threads[0];
109
119
  watch.idle = value.threads.length === 0;
110
- watch.attempts = 0; watch.unavailable = false; watch.liveActivity = null;
120
+ if (value.threads.some(thread => thread.stream_state === 'attached')) watch.attempts = 0;
121
+ watch.unavailable = false; watch.liveActivity = null;
111
122
  if (current?.stream_state !== 'attached') { this.refresh(watch); return; }
112
123
  if (current.turn_status === 'failed') {
113
124
  this.clearReply(watch); watch.liveActivity = { kind: 'error', label: '回复出错', spinning: false };
@@ -133,6 +144,11 @@ export class DotsRoomActivity extends EventEmitter {
133
144
  const generation = ++watch.generation, controller = new AbortController();
134
145
  watch.controller = controller; watch.pending = new Map(); watch.subscriptionId = null; watch.revision = -1;
135
146
  const current = () => this.watches.get(watch.threadId) === watch && generation === watch.generation && !controller.signal.aborted;
147
+ // Ready/keepalive on SSE does not establish delivery on the Celsius topic.
148
+ watch.initialTimer = setTimeout(() => {
149
+ if (current()) this.fail(watch, new Error('点点活动首次状态等待超时。'), true);
150
+ }, 30000);
151
+ watch.initialTimer.unref();
136
152
  try {
137
153
  await this.server.connect();
138
154
  const room = this.server.bindings.get(watch.threadId) || await this.server.room(watch.threadId);
@@ -155,9 +171,13 @@ export class DotsRoomActivity extends EventEmitter {
155
171
  },
156
172
  });
157
173
  if (current()) this.fail(watch, new Error('点点活动连接已结束。'), true);
158
- } catch (error) { if (current()) this.fail(watch, error, ![401, 403, 404].includes(error.status)); }
174
+ } catch (error) { if (current()) this.fail(watch, error, retryableStatus(error.status)); }
159
175
  }
160
176
  fail(watch, error, retry) {
177
+ console.error(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-activity',
178
+ threadId: watch.threadId, roomId: watch.roomId, status: error.status || 0,
179
+ stage: watch.subscriptionId ? 'state' : 'subscribe', retry, attempts: watch.attempts,
180
+ responseKind: error.details?.responseKind || 'none' }));
161
181
  this.stop(watch, true); watch.unavailable = true; this.refresh(watch); this.emit('unavailable', error);
162
182
  if (retry && watch.listeners.size && this.server.ready && watch.attempts < 5) {
163
183
  watch.timer = setTimeout(() => { watch.timer = null; void this.start(watch); }, Math.min(30000, 1000 * 2 ** watch.attempts++));
@@ -166,6 +186,7 @@ export class DotsRoomActivity extends EventEmitter {
166
186
  }
167
187
  stop(watch, keepReply = false) {
168
188
  watch.generation++; clearTimeout(watch.timer); watch.timer = null;
189
+ clearTimeout(watch.initialTimer); watch.initialTimer = null;
169
190
  watch.controller?.abort(); watch.controller = null; watch.pending?.clear(); watch.liveActivity = null;
170
191
  watch.subscriptionId = null; watch.revision = -1; watch.idle = false;
171
192
  if (!keepReply) { this.clearReply(watch); watch.unavailable = false; watch.roomId = null; }
@@ -4,7 +4,7 @@ import { CelsiusClient } from './celsius-client.mjs';
4
4
  const topic = 'calpico-chatgpt-messaging';
5
5
  const identityKey = identity => JSON.stringify([identity?.accountId, identity?.userId]);
6
6
 
7
- // Desktop 26.1002.7124.0: the DM API and Celsius share the kernel account,
7
+ // Desktop 26.1016.6573.0: the DM API and Celsius share the kernel account,
8
8
  // but are independent of the hosted execution socket. Never replay a POST.
9
9
  export class DotsRoomClient extends CelsiusClient {
10
10
  constructor(transport) {
@@ -30,7 +30,9 @@ export class DotsRoomClient extends CelsiusClient {
30
30
  const cancelled = () => controller.abort(signal.reason);
31
31
  signal?.addEventListener('abort', cancelled, { once: true });
32
32
  if (signal?.aborted) cancelled();
33
- let sent = false, status, timer;
33
+ let sent = false, status, timer, responseKind = 'none';
34
+ const started = Date.now();
35
+ const clientId = method === 'POST' && /^[a-f0-9-]{36}$/i.test(body?.request_id || '') ? body.request_id : undefined;
34
36
  try {
35
37
  return await new Promise((resolve, reject) => {
36
38
  const session = http2.connect('https://chatgpt.com');
@@ -42,6 +44,10 @@ export class DotsRoomClient extends CelsiusClient {
42
44
  clearTimeout(timer); controller.signal.removeEventListener('abort', abort);
43
45
  request?.destroy(); session.destroy();
44
46
  if (error) {
47
+ error.details = { ...error.details, ...(status ? { upstreamStatus: status } : {}), responseKind };
48
+ if (!controller.signal.aborted) console.error(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-request', method,
49
+ route: route.split('?')[0], clientId, status: status || 0, responseKind,
50
+ ms: Date.now() - started, outcome: 'failed' }));
45
51
  if (method !== 'GET' && !onEvent) error.deliveryUnknown = sent && !(status >= 400 && status < 500 && status !== 408);
46
52
  reject(error);
47
53
  } else resolve(value);
@@ -50,7 +56,7 @@ export class DotsRoomClient extends CelsiusClient {
50
56
  controller.signal.addEventListener('abort', abort, { once: true });
51
57
  session.on('error', error => finish(error));
52
58
  if (controller.signal.aborted) { abort(); return; }
53
- const roomRoute = /^\/messaging\/rooms\/([^/]+)\/messages$/.exec(route)?.[1];
59
+ const roomRoute = method === 'POST' ? /^\/messaging\/rooms\/([^/]+)\/messages$/.exec(route)?.[1] : null;
54
60
  request = session.request({ ':method': method, ':path': `/backend-api${route}`, ...auth.headers,
55
61
  accept: onEvent ? 'text/event-stream' : 'application/json',
56
62
  ...(body != null ? { 'content-type': contentType || 'application/json' } : {}),
@@ -59,13 +65,27 @@ export class DotsRoomClient extends CelsiusClient {
59
65
  request.setEncoding('utf8');
60
66
  request.on('response', headers => {
61
67
  status = headers[':status'];
62
- if (roomRoute && typeof headers['x-openai-thread-route'] === 'string' && headers['x-openai-thread-route'].length <= 1024 && current()) this.routes.set(roomRoute, headers['x-openai-thread-route']);
68
+ const type = String(headers['content-type'] || '').split(';')[0].trim().toLowerCase();
69
+ responseKind = headers['cf-mitigated'] === 'challenge' ? 'challenge'
70
+ : type === 'application/json' ? 'json' : type === 'text/html' ? 'html' : type === 'text/event-stream' ? 'sse' : 'other';
71
+ if (roomRoute && current()) {
72
+ // Desktop replaces the route on every send response, including removal.
73
+ this.routes.delete(roomRoute);
74
+ const routeHint = headers['x-openai-thread-route'];
75
+ if (typeof routeHint === 'string' && routeHint && routeHint.length <= 1024) {
76
+ if (this.routes.size >= 128) this.routes.clear();
77
+ this.routes.set(roomRoute, routeHint);
78
+ }
79
+ }
63
80
  if (status < 200 || status >= 300) {
64
- console.error(JSON.stringify({ event: 'dots-room-request', method, status, route: route.split('?')[0] }));
65
81
  const message = status === 401 ? '点点登录状态需要恢复,请重新连接。'
66
- : status === 403 || status === 404 ? '点点消息暂不可用,请稍后重新同步。'
82
+ : status === 403 ? '点点服务拒绝访问(HTTP 403),请稍后重试。'
83
+ : status === 404 ? '点点消息暂不可用,请稍后重新同步。'
67
84
  : status === 429 ? '点点暂时繁忙,请稍后再试。' : '点点请求尚未完成,请稍后核对。';
68
85
  finish(Object.assign(new Error(message), { status }));
86
+ } else if (roomRoute || onEvent) {
87
+ console.log(JSON.stringify({ time: new Date().toISOString(), event: 'dots-room-request', method,
88
+ route: route.split('?')[0], clientId, status, responseKind, ms: Date.now() - started, outcome: 'response' }));
69
89
  }
70
90
  });
71
91
  request.on('data', chunk => {
@@ -346,7 +346,10 @@ export class DotsAppServer extends HostedHistoryAppServer {
346
346
  const previous = this.profileNames.get(id);
347
347
  if (previous?.avatar && previous.avatarFileId === profile.avatarFileId) profile.avatar = previous.avatar;
348
348
  }
349
- if (identity === this.profileIdentity) this.profileNames = names;
349
+ if (identity === this.profileIdentity) {
350
+ this.profileNames = names;
351
+ this.emit('profiles-changed');
352
+ }
350
353
  }
351
354
 
352
355
  refreshLiveProfiles() {
@@ -48,7 +48,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
48
48
  target.handleUpgrade(request, socket, head, ws => target.emit('connection', ws));
49
49
  });
50
50
  uploadWss.on('connection', ws => {
51
- let authenticated = false, stopped = false, identity = '', upload, purpose, chunk, uploadEntry, uploadId;
51
+ let authenticated = false, stopped = false, identity = '', upload, purpose, chunk, uploadEntry, uploadId, fileRelay = false;
52
52
  let release, discardUpload = false;
53
53
  const released = new Promise(resolve => { release = resolve; });
54
54
  let queued = 0;
@@ -98,6 +98,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
98
98
  if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) {
99
99
  identity = typeof message.relayClientId === 'string' && message.relayClientId.length <= 200
100
100
  ? `relay:${message.relayClientId}` : 'local-token';
101
+ fileRelay = message.fileRelay === true;
101
102
  }
102
103
  }
103
104
  if (identity && purpose === 'upload') {
@@ -146,6 +147,7 @@ export function attachLanSocket(server, { token, dispatch, authorizeDevice = ()
146
147
  return;
147
148
  }
148
149
  if (purpose === 'download') {
150
+ if (fileRelay && /^\/api\/desktop\/frames\//.test(message.path || '')) throw new Error('桌面画面仅支持直连');
149
151
  if (message.type !== 'read' || typeof message.path !== 'string' || message.path.length > 12000 ||
150
152
  !/^\/api\/(?:images\/[a-f0-9]{64}|files\/(?:local|[a-f0-9]{64})|code\/(?:image|binary)|desktop\/frames\/[a-f0-9]{32})(?:\?|$)/.test(message.path) || message.path.includes('#')) throw new Error('无效的文件读取请求');
151
153
  await dispatch({ method: 'GET', url: message.path, headers: {}, socketRequest: true, fileTransfer: true }, {
@@ -35,7 +35,8 @@ export function availableManagedPort() {
35
35
  export class ManagedAppServer extends EventEmitter {
36
36
  constructor({ stateRoot, host, environment = process.env, available = managedPortAvailable,
37
37
  wait = delay, timeout = 30_000, now = Date.now, client = new SharedAppServer(`ws://127.0.0.1:${defaultManagedPort}`),
38
- selectPort = availableManagedPort, configureEnvironment = configureManagedEnvironment, resolveRuntime = resolveCodexRuntime }) {
38
+ selectPort = availableManagedPort, configureEnvironment = configureManagedEnvironment, resolveRuntime = resolveCodexRuntime,
39
+ diagnose = () => {} }) {
39
40
  super();
40
41
  this.stateRoot = stateRoot;
41
42
  this.host = host;
@@ -44,6 +45,7 @@ export class ManagedAppServer extends EventEmitter {
44
45
  this.selectPort = selectPort;
45
46
  this.configureEnvironment = configureEnvironment;
46
47
  this.resolveRuntime = resolveRuntime;
48
+ this.diagnose = diagnose;
47
49
  this.wait = wait;
48
50
  this.timeout = timeout;
49
51
  this.now = now;
@@ -111,6 +113,13 @@ export class ManagedAppServer extends EventEmitter {
111
113
  throw new Error(`运行中的 Codex 内核目录已被移除或缺少配套 code-mode-host:${actual.executable};请确认重启内核。`);
112
114
  }
113
115
  if (!actual) {
116
+ const selection = { reason: this.restarting ? 'confirmed-restart' : record?.pid ? 'previous-process-exited' : 'no-running-kernel',
117
+ version: runtime.version, source: runtime.source, executable: runtime.executable };
118
+ const selectionSignature = JSON.stringify(selection);
119
+ if (selectionSignature !== this.diagnosticSelectionSignature) {
120
+ this.diagnosticSelectionSignature = selectionSignature;
121
+ this.diagnose('kernel-start-selected', selection);
122
+ }
114
123
  fs.rmSync(this.pendingPath, { force: true });
115
124
  const childEnvironment = { ...this.environment, CODEX_HOME: runtime.codexHome, RUST_LOG: 'warn' };
116
125
  for (const key of ['CODEX_APP_SERVER_WS_URL', 'CODEX_APP_SERVER_FORCE_CLI', 'CODEX_APP_TOOLS_PIPE_PATH']) delete childEnvironment[key];
@@ -128,6 +137,8 @@ export class ManagedAppServer extends EventEmitter {
128
137
  writeRecord(this.pendingPath, { phase: 'spawning', url, executable: runtime.executable, startedAt: new Date().toISOString() });
129
138
  const pid = await this.host.start(runtime.executable, ['app-server', '--listen', url], childEnvironment,
130
139
  [path.join(this.stateRoot, 'managed-app-server.stdout.log'), path.join(this.stateRoot, 'managed-app-server.stderr.log')]);
140
+ this.diagnose('kernel-spawned', { pid, executable: runtime.executable, selectedVersion: runtime.version,
141
+ source: runtime.source, attempt: attempt + 1 });
131
142
  const started = await this.host.process(pid);
132
143
  if (!started) {
133
144
  fs.rmSync(this.pendingPath, { force: true });
@@ -355,6 +366,20 @@ export class ManagedAppServer extends EventEmitter {
355
366
  const changed = JSON.stringify(value) !== JSON.stringify(this.state);
356
367
  this.state = value;
357
368
  if (changed) this.emit('state', this.snapshot());
369
+ // Retry transitions remain visible to health consumers; diagnostic output
370
+ // records a repeated failure once until it changes or the kernel recovers.
371
+ if (value.state === 'starting' && this.diagnosticState?.state === 'failed') return;
372
+ const details = { state: value.state, ready: value.ready, pid: value.pid ?? null, created: value.created ?? null,
373
+ version: value.ready && this.client.ready ? this.client.serverVersion || null : null,
374
+ executable: value.executable || null, selectedVersion: value.selectedVersion || null,
375
+ preferredExecutable: value.preferredExecutable || null, source: value.source || null,
376
+ updatePending: value.updatePending === true, error: value.error || null };
377
+ const signature = JSON.stringify(details);
378
+ if (signature === this.diagnosticStateSignature) return;
379
+ this.diagnosticStateSignature = signature;
380
+ this.diagnosticState = details;
381
+ if (value.ready) this.diagnosticSelectionSignature = null;
382
+ this.diagnose('kernel-state', { ...details, retryAt: value.retryAt || null });
358
383
  }
359
384
 
360
385
  close() {
@@ -3,8 +3,8 @@ import { discoverCodex, resolveCodexRuntime, runtimeFingerprint, runtimeInstalla
3
3
  // Advisory discovery is independent of execution health. Failure here must never
4
4
  // disable an already running app-server or cause an automatic process restart.
5
5
  export class RuntimeDiscovery {
6
- constructor({ stateRoot, host, url, interval = 60000 }) {
7
- Object.assign(this, { stateRoot, host, url, interval });
6
+ constructor({ stateRoot, host, url, interval = 60000, diagnose = () => {} }) {
7
+ Object.assign(this, { stateRoot, host, url, interval, diagnose });
8
8
  this.state = {};
9
9
  }
10
10
  start() { this.stopped = false; void this.tick(); }
@@ -13,19 +13,29 @@ export class RuntimeDiscovery {
13
13
  const errors = [];
14
14
  try {
15
15
  const candidates = discoverCodex();
16
- const signature = JSON.stringify([await runtimeInstallationFingerprint(), candidates.map(candidate => {
16
+ const installation = await runtimeInstallationFingerprint();
17
+ const fingerprints = candidates.map(candidate => {
17
18
  try { return [candidate.executable, runtimeFingerprint(candidate.executable)]; }
18
19
  catch (error) { return [candidate.executable, error.message]; }
19
- })]);
20
+ });
21
+ const signature = JSON.stringify([installation, fingerprints]);
22
+ if (signature !== this.diagnosticCandidateSignature) {
23
+ this.diagnosticCandidateSignature = signature;
24
+ this.diagnose('runtime-candidates', { installation, candidates: candidates.map((candidate, index) => ({
25
+ executable: candidate.executable, source: candidate.source, fingerprint: fingerprints[index][1] })) });
26
+ }
20
27
  if (signature !== this.signature) {
21
- await resolveCodexRuntime(this.stateRoot, { refresh: true });
28
+ const selected = await resolveCodexRuntime(this.stateRoot, { refresh: true });
22
29
  this.signature = signature;
30
+ this.diagnose('runtime-selected', { version: selected.version, source: selected.source,
31
+ executable: selected.executable, runningKernelPreserved: true });
23
32
  }
24
33
  } catch (error) { errors.push(error.message); }
25
34
  try {
26
35
  if (this.host.desktopIntegration === false) {
27
36
  this.state = { desktop: { state: 'unsupported', integrationSupported: false }, checkedAt: new Date().toISOString() };
28
37
  if (errors.length) this.state.discoveryError = errors.join('\n');
38
+ this.diagnoseState();
29
39
  return this.state;
30
40
  }
31
41
  const desktop = await this.host.inspect();
@@ -38,8 +48,18 @@ export class RuntimeDiscovery {
38
48
  this.state = { checkedAt: new Date().toISOString() };
39
49
  }
40
50
  if (errors.length) this.state.discoveryError = errors.join('\n');
51
+ this.diagnoseState();
41
52
  return this.state;
42
53
  }
54
+ diagnoseState() {
55
+ const details = { desktop: this.state.desktop || null,
56
+ desktopRestartRecommended: this.state.desktopRestartRecommended === true,
57
+ discoveryError: this.state.discoveryError || null, runningKernelPreserved: true };
58
+ const signature = JSON.stringify(details);
59
+ if (signature === this.diagnosticStateSignature) return;
60
+ this.diagnosticStateSignature = signature;
61
+ this.diagnose('runtime-discovery', details);
62
+ }
43
63
  async tick() {
44
64
  await this.sample();
45
65
  if (!this.stopped) { this.timer = setTimeout(() => this.tick(), this.interval); this.timer.unref?.(); }
@@ -3,7 +3,7 @@ import { EventEmitter } from 'node:events';
3
3
  import fs from 'node:fs';
4
4
  import WebSocket from 'ws';
5
5
  import { decode, loopbackUrl, relayAddress, send } from './wire.mjs';
6
- import { routedFileBytes, parseFileBytes } from './file-wire.mjs';
6
+ import { routedFileBytes, parseFileBytes, sendFileFrame } from './file-wire.mjs';
7
7
  import { attachRtcFile } from './rtc-file-bridge.mjs';
8
8
  import { createP2pProbe, validProbeSignal } from './p2p-probe.mjs';
9
9
  import { createWindowsRouteHelper } from './windows-route-helper.mjs';
@@ -217,23 +217,26 @@ export function connectBridgeRelay({ relayUrl, hostKey, bridgeUrl, bridgeToken,
217
217
  const message = decode(bytes, binary);
218
218
  if (message.type === 'file-host-ready') return;
219
219
  if (message.type === 'file-open') {
220
+ if (typeof message.clientId !== 'string' || !/^[\w-]{16,128}$/.test(message.clientId)) throw new Error('Invalid file client identity');
220
221
  const connection = bridgeConnection?.() || { url: bridgeUrl, token: bridgeToken };
221
222
  const url = new URL(connection.url); url.pathname = '/api/file-transfer';
222
223
  loopbackUrl(url.href, '/api/file-transfer');
223
224
  const socket = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
224
225
  fileLocals.set(message.session, socket);
225
- socket.on('open', () => send(socket, { type: 'auth', token: connection.token, purpose: message.purpose }));
226
+ socket.on('open', () => send(socket, { type: 'auth', token: connection.token, purpose: message.purpose,
227
+ uploadSessionId: message.uploadSessionId, resumeUpload: message.resumeUpload,
228
+ relayClientId: message.clientId, fileRelay: true }));
226
229
  socket.on('error', error => events.emit('diagnostic', `Local file receiver: ${error.message}`));
227
230
  socket.on('close', () => {
228
231
  if (fileLocals.get(message.session) !== socket) return;
229
232
  fileLocals.delete(message.session);
230
- send(receiver, { type: 'file-close', session: message.session });
233
+ sendFileFrame(receiver, { type: 'file-close', session: message.session });
231
234
  });
232
235
  socket.on('message', (data, isBinary) => {
233
236
  if (fileLocals.get(message.session) !== socket) return socket.terminate();
234
- if (receiver.readyState !== WebSocket.OPEN || receiver.bufferedAmount > 1024 * 1024) return socket.close(1013, 'File receiver too slow');
235
- if (isBinary) receiver.send(routedFileBytes(message.session, data));
236
- else send(receiver, { type: 'file-frame', session: message.session, payload: data.toString() });
237
+ if (receiver.readyState !== WebSocket.OPEN) return socket.close(1011, 'File receiver unavailable');
238
+ if (isBinary) sendFileFrame(receiver, routedFileBytes(message.session, data));
239
+ else sendFileFrame(receiver, { type: 'file-frame', session: message.session, payload: data.toString() });
237
240
  });
238
241
  } else if (message.type === 'file-frame') {
239
242
  const socket = fileLocals.get(message.session);
@@ -11,3 +11,66 @@ export function parseFileBytes(bytes) {
11
11
  if (!/^[a-f0-9-]{36}$/.test(session)) throw new Error('Invalid file session');
12
12
  return { session, data: bytes.subarray(36) };
13
13
  }
14
+
15
+ const senders = new WeakMap();
16
+ // A workstation multiplexes up to eight three-chunk windows. Keep that burst
17
+ // bounded without dropping every transfer when one shared socket gets busy.
18
+ export function sendFileFrame(socket, data, { bytesPerSecond = 0, session = data?.session } = {}) {
19
+ if (!socket || socket.readyState !== socket.OPEN) return false;
20
+ let state = senders.get(socket);
21
+ if (!state) {
22
+ state = { queue: [], bytes: 0, active: false, nextAt: 0, timer: null, closed: false, bytesPerSecond };
23
+ senders.set(socket, state);
24
+ socket.once('close', () => {
25
+ state.closed = true; clearTimeout(state.timer); state.queue.length = 0; state.bytes = 0;
26
+ });
27
+ }
28
+ if (state.closeAfter) return false;
29
+ const payload = typeof data === 'string' || Buffer.isBuffer(data) ? data : JSON.stringify(data);
30
+ const size = Buffer.byteLength(payload);
31
+ if (size > 512 * 1024 || state.bytes + size > 8 * 1024 * 1024 || state.queue.length >= 128) {
32
+ socket.close(1013, 'File receiver too slow'); return false;
33
+ }
34
+ state.queue.push({ payload, size, session }); state.bytes += size;
35
+ const pump = () => {
36
+ if (state.active || state.closed || socket.readyState !== socket.OPEN) return;
37
+ if (!state.queue.length) {
38
+ if (state.closeAfter) socket.close(state.closeAfter.code, state.closeAfter.reason);
39
+ return;
40
+ }
41
+ const wait = state.nextAt - performance.now();
42
+ if (wait > 1) {
43
+ if (!state.timer) state.timer = setTimeout(() => { state.timer = null; pump(); }, wait);
44
+ return;
45
+ }
46
+ const frame = state.queue.shift(); state.active = true;
47
+ if (state.bytesPerSecond) state.nextAt = performance.now() + frame.size * 1000 / state.bytesPerSecond;
48
+ socket.send(frame.payload, error => {
49
+ if (state.closed) return;
50
+ state.bytes -= frame.size; state.active = false;
51
+ if (error) { socket.close(1011, 'File delivery failed'); return; }
52
+ pump();
53
+ });
54
+ };
55
+ state.pump = pump;
56
+ pump();
57
+ return true;
58
+ }
59
+
60
+ // Preserve an error/ack already queued before ending this file connection.
61
+ // Revocation and account expiry deliberately use immediate socket.close instead.
62
+ export function closeFileSocket(socket, code, reason) {
63
+ const state = senders.get(socket);
64
+ if (!state) { socket.close(code, reason); return; }
65
+ state.closeAfter = { code, reason };
66
+ state.pump();
67
+ }
68
+
69
+ export function discardFileSession(socket, session) {
70
+ const state = senders.get(socket);
71
+ if (!state) return;
72
+ state.queue = state.queue.filter(frame => {
73
+ if (frame.session !== session) return true;
74
+ state.bytes -= frame.size; return false;
75
+ });
76
+ }
@@ -9,7 +9,7 @@ import { deviceStore, validId } from './device-store.mjs';
9
9
  import { randomUUID, timingSafeEqual } from 'node:crypto';
10
10
  import { WebSocketServer } from 'ws';
11
11
  import { secret, digest, matches, validSecret, send, decode } from './wire.mjs';
12
- import { routedFileBytes, parseFileBytes } from './file-wire.mjs';
12
+ import { routedFileBytes, parseFileBytes, sendFileFrame, closeFileSocket, discardFileSession } from './file-wire.mjs';
13
13
  import { readDebugAllowlist, debugAllowed } from './debug-allowlist.mjs';
14
14
  import { validProbeSignal } from './p2p-probe.mjs';
15
15
  import { MAX_ADMIN_REQUEST_BYTES, relayAdminState } from './admin-state.mjs';
@@ -137,7 +137,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
137
137
  observer?.ws.close(4401, 'Device authorization revoked');
138
138
  for (const [key, upload] of room.uploadClients || []) if (upload.clientId === message.id) {
139
139
  room.uploadClients.delete(key);
140
- if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
140
+ if (room.uploadHost) {
141
+ discardFileSession(room.uploadHost, upload.session);
142
+ send(room.uploadHost, { type: 'file-close', session: upload.session });
143
+ }
141
144
  upload.ws.close(4401, 'Device authorization revoked');
142
145
  }
143
146
  for (const [socket, id] of room.deviceManagers || []) if (id === message.id) {
@@ -245,7 +248,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
245
248
  for (const [clientId, upload] of room.uploadClients) {
246
249
  if (upload.userId !== userId) continue;
247
250
  room.uploadClients.delete(clientId);
248
- if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session: upload.session });
251
+ if (room.uploadHost) {
252
+ discardFileSession(room.uploadHost, upload.session);
253
+ send(room.uploadHost, { type: 'file-close', session: upload.session });
254
+ }
249
255
  upload.ws.close(4431, 'Account cancelled');
250
256
  }
251
257
  }
@@ -438,7 +444,10 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
438
444
  if (room.host) send(room.host, { type: 'detach', session });
439
445
  } else if (role === 'file-client' && room?.uploadClients.get(session)?.ws === ws) {
440
446
  room.uploadClients.delete(session);
441
- if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
447
+ if (room.uploadHost) {
448
+ discardFileSession(room.uploadHost, session);
449
+ send(room.uploadHost, { type: 'file-close', session });
450
+ }
442
451
  }
443
452
  ws.close(4431, 'Account session invalid');
444
453
  }
@@ -491,7 +500,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
491
500
  for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'File receiver inbound queue limit exceeded' : 'File receiver offline');
492
501
  } else if (role === 'file-client' && room?.uploadClients?.get(session)?.ws === ws) {
493
502
  room.uploadClients.delete(session);
494
- if (room.uploadHost) send(room.uploadHost, { type: 'file-close', session });
503
+ if (room.uploadHost) sendFileFrame(room.uploadHost, { type: 'file-close', session }, { bytesPerSecond: 4 * 1024 * 1024 });
495
504
  }
496
505
  });
497
506
  let messageTail = Promise.resolve();
@@ -533,13 +542,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
533
542
  const frame = parseFileBytes(bytes);
534
543
  const client = room.uploadClients.get(frame.session);
535
544
  if (!client || client.ws.readyState !== ws.OPEN) return;
536
- if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
537
- client.ws.send(frame.data); return;
545
+ sendFileFrame(client.ws, frame.data, { bytesPerSecond: 1024 * 1024 }); return;
538
546
  }
539
547
  if (role === 'file-client' && room.uploadClients?.get(session)?.ws === ws) {
540
548
  if (room.uploadHost?.readyState !== ws.OPEN) return fail(4410, 'File receiver offline');
541
- if (room.uploadHost.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
542
- room.uploadHost.send(routedFileBytes(session, bytes)); return;
549
+ sendFileFrame(room.uploadHost, routedFileBytes(session, bytes), { bytesPerSecond: 4 * 1024 * 1024, session }); return;
543
550
  }
544
551
  return fail(1008, 'Invalid binary frame');
545
552
  }
@@ -577,16 +584,31 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
577
584
  !matches(message.credential, client.hash)) return fail();
578
585
  room.uploadClients ||= new Map();
579
586
  if (!['upload', 'download'].includes(message.purpose)) return fail(1008, 'Invalid file purpose');
580
- const same = [...room.uploadClients.values()].filter(item => item.clientId === message.clientId && item.purpose === message.purpose);
581
- const all = [...rooms.values()].flatMap(item => [...(item.uploadClients?.values() || [])]);
587
+ if (message.purpose === 'upload' && (typeof message.uploadSessionId !== 'string' ||
588
+ !/^[a-f0-9-]{36}$/.test(message.uploadSessionId) || typeof message.resumeUpload !== 'boolean')) {
589
+ return fail(1008, 'Invalid upload session');
590
+ }
591
+ if (!await recordConnection(room, message.clientId, new Date().toISOString(), deviceInfo(message))) return fail(4401, 'Device authorization revoked');
592
+ if (ws.readyState !== ws.OPEN || !accountConnection.valid()) return;
593
+ if (room.host?.readyState !== ws.OPEN || room.uploadHost?.readyState !== ws.OPEN) return fail(4410, 'File receiver offline');
594
+ if (room.status !== 'active' || !room.clients.some(item => item.id === message.clientId && matches(message.credential, item.hash))) return fail(4401, 'Device authorization revoked');
595
+ const replacing = message.purpose === 'upload' ? [...room.uploadClients.values()].filter(item =>
596
+ item.clientId === message.clientId && item.uploadSessionId === message.uploadSessionId) : [];
597
+ const same = [...room.uploadClients.values()].filter(item => item.clientId === message.clientId && item.purpose === message.purpose && !replacing.includes(item));
598
+ const all = [...rooms.values()].flatMap(item => [...(item.uploadClients?.values() || [])]).filter(item => !replacing.includes(item));
582
599
  if (same.length >= (message.purpose === 'upload' ? 1 : 3) ||
583
600
  all.length >= 8 || (message.purpose === 'upload' && all.filter(item => item.purpose === 'upload').length >= 2)) {
584
601
  return fail(4429, 'Too many file transfers');
585
602
  }
603
+ for (const old of replacing) {
604
+ room.uploadClients.delete(old.session);
605
+ sendFileFrame(room.uploadHost, { type: 'file-close', session: old.session }, { bytesPerSecond: 4 * 1024 * 1024 });
606
+ old.ws.close(4409, 'Upload connection replaced');
607
+ }
586
608
  role = 'file-client'; clientId = message.clientId; session = randomUUID(); clearTimeout(authTimer);
587
- if (!await recordConnection(room, clientId, new Date().toISOString(), deviceInfo(message))) return fail(4401, 'Device authorization revoked');
588
- room.uploadClients.set(session, { ws, session, clientId, userId, purpose: message.purpose });
589
- send(room.uploadHost, { type: 'file-open', session, purpose: message.purpose }); return;
609
+ room.uploadClients.set(session, { ws, session, clientId, userId, purpose: message.purpose, uploadSessionId: message.uploadSessionId });
610
+ sendFileFrame(room.uploadHost, { type: 'file-open', session, purpose: message.purpose, clientId,
611
+ uploadSessionId: message.uploadSessionId, resumeUpload: message.resumeUpload }, { bytesPerSecond: 4 * 1024 * 1024 }); return;
590
612
  }
591
613
  if (message.type === 'host') {
592
614
  const durable = message.deviceId !== undefined;
@@ -881,7 +903,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
881
903
  connection.ws.close(4401, 'Device authorization revoked');
882
904
  }
883
905
  room.connections.clear();
884
- for (const client of room.uploadClients?.values() || []) client.ws.close(4401, 'Device authorization revoked');
906
+ for (const client of room.uploadClients?.values() || []) {
907
+ if (room.uploadHost) {
908
+ discardFileSession(room.uploadHost, client.session);
909
+ send(room.uploadHost, { type: 'file-close', session: client.session });
910
+ }
911
+ client.ws.close(4401, 'Device authorization revoked');
912
+ }
885
913
  room.uploadClients?.clear();
886
914
  for (const observer of room.observers.values()) observer.ws.close(4401, 'Device authorization revoked');
887
915
  room.observers.clear();
@@ -909,14 +937,13 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
909
937
  const client = room.uploadClients.get(message.session);
910
938
  if (!client) return;
911
939
  if (message.type === 'file-frame' && typeof message.payload === 'string' && message.payload.length <= 512 * 1024) {
912
- if (client.ws.bufferedAmount > 1024 * 1024) return client.ws.close(1013, 'File receiver too slow');
913
- send(client.ws, { type: 'file-frame', payload: message.payload });
914
- } else if (message.type === 'file-close') client.ws.close(1011, 'File receiver unavailable');
940
+ sendFileFrame(client.ws, { type: 'file-frame', payload: message.payload }, { bytesPerSecond: 1024 * 1024 });
941
+ } else if (message.type === 'file-close') closeFileSocket(client.ws, 1011, 'File receiver unavailable');
915
942
  else fail(1008, 'Invalid file frame');
916
943
  } else if (role === 'file-client' && room.uploadClients?.get(session)?.ws === ws) {
917
944
  if (message.type !== 'file-frame' || typeof message.payload !== 'string' || message.payload.length > 512 * 1024) return fail(1008, 'Invalid file frame');
918
- if (room.uploadHost?.bufferedAmount > 1024 * 1024) return fail(1013, 'File receiver too slow');
919
- send(room.uploadHost, { type: 'file-frame', session, payload: message.payload });
945
+ if (room.uploadHost?.readyState !== ws.OPEN) return fail(4410, 'File receiver offline');
946
+ sendFileFrame(room.uploadHost, { type: 'file-frame', session, payload: message.payload }, { bytesPerSecond: 4 * 1024 * 1024 });
920
947
  } else if (role === 'observer' && room.observers.get(clientId)?.ws === ws && message.type === 'official-computer') {
921
948
  const computer = normalizeOfficialComputer(message);
922
949
  await commit(room, { officialComputer: computer });
@@ -95,6 +95,11 @@ const options = parseOptions(process.argv.slice(2));
95
95
  const runtimeVersion = readRuntimeVersion();
96
96
  const serviceInstanceId = crypto.randomUUID();
97
97
 
98
+ function runtimeDiagnostic(event, details) {
99
+ console.log('[CodeRelax:runtime]', JSON.stringify({ at: new Date().toISOString(),
100
+ serviceVersion: runtimeVersion, servicePid: process.pid, serviceInstanceId, event, ...details }));
101
+ }
102
+
98
103
  const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
99
104
  const projectRoot = os.homedir();
100
105
  const { codexDataRoot, stateRoot, sessionsRoot, uploadsRoot } = runtimePaths();
@@ -115,13 +120,15 @@ const sessionPathCache = new Map();
115
120
  const sessionSettingsCache = new Map();
116
121
  const liveActivityCache = new Map();
117
122
  let sharedServer = null;
118
- const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost });
123
+ const managedAppServer = new ManagedAppServer({ stateRoot, host: desktopHost, diagnose: runtimeDiagnostic });
119
124
  const hostedHistoryCache = new HostedHistoryCache(stateRoot);
120
125
  const dotsServer = new DotsAppServer(managedAppServer.client, desktopDotsProfiles(codexDataRoot, stateRoot), hostedHistoryCache, { uploadsRoot });
121
126
  const dotsImages = new DotsMessageImages({ localServer: managedAppServer.client, identity: () => dotsServer.profileIdentity,
122
127
  cache: attachmentCache, capabilityKey: imageCapabilityKey, maxEntries: MAX_CACHED_ATTACHMENTS, maxBytes: MAX_ATTACHMENT_BYTES });
123
128
  const dotsChat = new DotsChatServer(dotsServer, undefined, { uploadsRoot });
124
- const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost, url: () => managedAppServer.snapshot().url });
129
+ managedAppServer.on('state', state => { if (state.ready) dotsChat.startBackgroundActivity(); });
130
+ const runtimeDiscovery = new RuntimeDiscovery({ stateRoot, host: desktopHost,
131
+ url: () => managedAppServer.snapshot().url, diagnose: runtimeDiagnostic });
125
132
  const relayManager = createRelayManager(stateRoot);
126
133
  let sharedCatalog = null, executionMode = 'ordinary';
127
134
  function selectSharedExecution(enabled) {
@@ -286,8 +293,9 @@ async function handleRequest(request, response) {
286
293
  if (request.method === 'POST' && ['/api/desktop/start', '/api/desktop/restart'].includes(url.pathname)) {
287
294
  const body = await readJsonBody(request);
288
295
  if (body.confirmed !== true) throw new RequestError(400, '请确认启动桌面端。');
289
- const result = await startDesktop({ stateRoot, host: desktopHost, restart: url.pathname === '/api/desktop/restart' });
290
- const observed = await runtimeDiscovery.sample();
296
+ let result, observed;
297
+ try { result = await startDesktop({ stateRoot, host: desktopHost, restart: url.pathname === '/api/desktop/restart' }); }
298
+ finally { observed = await runtimeDiscovery.sample(); }
291
299
  sendJson(response, 202, { ...result, desktop: observed.desktop, checkedAt: observed.checkedAt }); return;
292
300
  }
293
301
  if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
@@ -483,6 +491,12 @@ async function handleRequest(request, response) {
483
491
  return;
484
492
  }
485
493
 
494
+ const dotsActivityRetryMatch = /^\/api\/threads\/([^/]+)\/dots-activity\/retry$/.exec(url.pathname);
495
+ if (request.method === 'POST' && dotsActivityRetryMatch) {
496
+ if (!rootChat) throw new RequestError(405, '只有点点主聊天提供活动状态。');
497
+ sendJson(response, 200, { started: dotsChat.retryActivity(decodeURIComponent(dotsActivityRetryMatch[1])) });
498
+ return;
499
+ }
486
500
  const dotsActivityMatch = /^\/api\/threads\/([^/]+)\/dots-activity-events$/.exec(url.pathname);
487
501
  if (request.method === 'GET' && dotsActivityMatch) {
488
502
  if (!rootChat) throw new RequestError(405, '只有点点主聊天提供此活动面板。');
@@ -1077,6 +1091,7 @@ const lanSocket = attachLanSocket(server, { token: options.token, dispatch: hand
1077
1091
 
1078
1092
  let control;
1079
1093
  const powerManager = createPowerManager(stateRoot);
1094
+ runtimeDiagnostic('service.starting', { platform: process.platform, nodeVersion: process.version });
1080
1095
  void managedAppServer.start();
1081
1096
  executionHealth.start();
1082
1097
  runtimeDiscovery.start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.71",
3
+ "version": "0.1.0-beta.76",
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.71",
45
- "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.71",
46
- "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.71"
44
+ "@walkhi/code-relax-native-win32-x64": "0.1.0-beta.76",
45
+ "@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.76",
46
+ "@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.76"
47
47
  }
48
48
  }