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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +105 -105
  2. package/dist/bin/cleanup-accounts.mjs +63 -63
  3. package/dist/bin/codex-remote.mjs +403 -403
  4. package/dist/bin/publish-android-release.mjs +63 -63
  5. package/dist/bin/release-upload-server.mjs +190 -190
  6. package/dist/bin/set-relay-admin-password.mjs +11 -0
  7. package/dist/build-platform.json +1 -1
  8. package/dist/get-shared-service-url.ps1 +14 -14
  9. package/dist/shared/code-browser.cjs +159 -159
  10. package/dist/shared/file-changes.cjs +31 -31
  11. package/dist/shared/file-citations.cjs +117 -117
  12. package/dist/shared/file-mentions.cjs +92 -92
  13. package/dist/shared/message-delivery.cjs +115 -115
  14. package/dist/shared/p2p-file-channel.cjs +87 -87
  15. package/dist/shared/release-version.cjs +31 -0
  16. package/dist/shared/server-requests.cjs +311 -311
  17. package/dist/shared/timeline-sync.cjs +173 -173
  18. package/dist/skills/code-relax/SKILL.md +22 -22
  19. package/dist/src/bounded-process-log.mjs +63 -63
  20. package/dist/src/celsius-client.mjs +107 -107
  21. package/dist/src/dots-approval-timeline.mjs +99 -99
  22. package/dist/src/dots-avatar.mjs +42 -42
  23. package/dist/src/dots-chat-projection.mjs +50 -50
  24. package/dist/src/dots-chat-server.mjs +483 -483
  25. package/dist/src/dots-message-images.mjs +319 -319
  26. package/dist/src/dots-profile.mjs +77 -77
  27. package/dist/src/dots-room-activity.mjs +172 -172
  28. package/dist/src/dots-room-client.mjs +137 -135
  29. package/dist/src/dots-room-projection.mjs +94 -94
  30. package/dist/src/dots-schedules.mjs +66 -66
  31. package/dist/src/dots-summary-tasks.mjs +32 -32
  32. package/dist/src/dots-task-catalog.mjs +430 -430
  33. package/dist/src/file-transfer-range.mjs +22 -22
  34. package/dist/src/file-upload-session.mjs +146 -130
  35. package/dist/src/hosted-history-app-server.mjs +103 -103
  36. package/dist/src/hosted-history-cache.mjs +113 -113
  37. package/dist/src/lan-socket-server.mjs +338 -266
  38. package/dist/src/managed-app-server.mjs +373 -373
  39. package/dist/src/managed-endpoint.mjs +25 -25
  40. package/dist/src/message-upload-files.mjs +109 -109
  41. package/dist/src/onboarding.mjs +12 -12
  42. package/dist/src/platform/README.md +1 -1
  43. package/dist/src/platform/codex-protocol.mjs +46 -46
  44. package/dist/src/platform/desktop-project-store.mjs +219 -208
  45. package/dist/src/platform/linux/desktop-host.mjs +87 -87
  46. package/dist/src/platform/linux/process.mjs +53 -53
  47. package/dist/src/platform/linux/system.mjs +50 -50
  48. package/dist/src/platform/linux/user-environment.mjs +9 -9
  49. package/dist/src/platform/macos/codex-runtime.mjs +125 -125
  50. package/dist/src/platform/macos/desktop-host.mjs +86 -86
  51. package/dist/src/platform/macos/user-environment.mjs +9 -9
  52. package/dist/src/platform/native-package.mjs +58 -58
  53. package/dist/src/platform/windows/activate-desktop.ps1 +49 -49
  54. package/dist/src/platform/windows/codex-runtime.mjs +215 -215
  55. package/dist/src/platform/windows/credential-store.mjs +27 -27
  56. package/dist/src/platform/windows/credential-store.ps1 +49 -49
  57. package/dist/src/platform/windows/desktop-host.mjs +65 -65
  58. package/dist/src/platform/windows/desktop-info.ps1 +38 -38
  59. package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
  60. package/dist/src/platform/windows/desktop-shortcuts.ps1 +14 -14
  61. package/dist/src/platform/windows/dots-profile.mjs +49 -49
  62. package/dist/src/platform/windows/launch-worker.mjs +41 -41
  63. package/dist/src/platform/windows/project-locations.mjs +16 -16
  64. package/dist/src/platform/windows/service-host.mjs +35 -35
  65. package/dist/src/platform/windows/user-environment.mjs +52 -52
  66. package/dist/src/project-management.mjs +58 -58
  67. package/dist/src/runtime-discovery.mjs +47 -47
  68. package/dist/src/self-relay/account-cleanup.mjs +30 -30
  69. package/dist/src/self-relay/admin-auth.mjs +59 -0
  70. package/dist/src/self-relay/admin-login-page.mjs +7 -0
  71. package/dist/src/self-relay/admin-page.mjs +87 -80
  72. package/dist/src/self-relay/admin-server.mjs +49 -18
  73. package/dist/src/self-relay/admin-style.mjs +58 -14
  74. package/dist/src/self-relay/admin-workstations-page.mjs +95 -0
  75. package/dist/src/self-relay/admin-workstations.mjs +143 -0
  76. package/dist/src/self-relay/connection-reporting.mjs +38 -0
  77. package/dist/src/self-relay/connector.mjs +444 -439
  78. package/dist/src/self-relay/demo.mjs +31 -31
  79. package/dist/src/self-relay/diagnostics.mjs +54 -54
  80. package/dist/src/self-relay/email-accounts.mjs +183 -183
  81. package/dist/src/self-relay/file-wire.mjs +13 -13
  82. package/dist/src/self-relay/lifecycle.mjs +11 -0
  83. package/dist/src/self-relay/p2p-probe.mjs +168 -168
  84. package/dist/src/self-relay/phone-connections.mjs +55 -0
  85. package/dist/src/self-relay/privacy-page.mjs +26 -26
  86. package/dist/src/self-relay/public-releases.mjs +67 -67
  87. package/dist/src/self-relay/rtc-file-bridge.mjs +46 -41
  88. package/dist/src/self-relay/server.mjs +63 -3
  89. package/dist/src/self-relay/usage-meter.mjs +141 -141
  90. package/dist/src/server.mjs +23 -9
  91. package/dist/src/service-doctor.mjs +148 -148
  92. package/dist/src/service-install.mjs +97 -97
  93. package/dist/src/service-lifecycle.mjs +139 -139
  94. package/dist/src/service-update.mjs +6 -3
  95. package/dist/src/shared-app-server.mjs +683 -666
  96. package/dist/src/shared-catalog.mjs +132 -132
  97. package/dist/src/shared-thread-stream.mjs +165 -165
  98. package/dist/src/usage-reset.mjs +17 -17
  99. package/dist/src/workstation-downloads.mjs +120 -120
  100. package/package.json +4 -4
  101. package/tools/postinstall.mjs +186 -186
@@ -1,41 +1,46 @@
1
- import WebSocket from 'ws';
2
- import { loopbackUrl } from './wire.mjs';
3
-
4
- // The RTC peer belongs to an authenticated relay chat session. Its teardown
5
- // closes this channel on logout, expiry, revocation or signaling disconnect.
6
- export function attachRtcFile(channel, connection, authorized) {
7
- let local, authenticated = false;
8
- const timer = setTimeout(() => channel.close(1008), 5000);
9
- channel.addEventListener('close', () => { clearTimeout(timer); local?.terminate(); });
10
- channel.onmessage = event => {
11
- try {
12
- if (!authorized()) throw new Error('File authorization expired');
13
- if (!local) {
14
- if (typeof event.data !== 'string') throw new Error('File authentication required');
15
- const auth = JSON.parse(event.data);
16
- if (auth.type !== 'auth' || !['upload', 'download'].includes(auth.purpose)) throw new Error('Invalid file purpose');
17
- const url = new URL(connection.url); url.pathname = '/api/file-transfer';
18
- loopbackUrl(url.href, '/api/file-transfer');
19
- local = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
20
- local.on('open', () => {
21
- if (!authorized() || channel.readyState !== 1) { local.terminate(); return; }
22
- local.send(JSON.stringify({ type: 'auth', token: connection.token, purpose: auth.purpose }));
23
- });
24
- local.on('message', (bytes, binary) => {
25
- if (!authorized() || channel.readyState !== 1) { channel.close(4401); return; }
26
- try {
27
- if (!authenticated) {
28
- if (binary || JSON.parse(bytes.toString()).type !== 'ready') throw new Error('File authentication failed');
29
- authenticated = true; clearTimeout(timer);
30
- }
31
- channel.send(binary ? bytes : bytes.toString());
32
- } catch { channel.close(1011); }
33
- });
34
- local.on('close', () => channel.close(1011)); local.on('error', () => channel.close(1011));
35
- return;
36
- }
37
- if (!authenticated || local.readyState !== WebSocket.OPEN || local.bufferedAmount > 1024 * 1024) throw new Error('File channel unavailable');
38
- local.send(typeof event.data === 'string' ? event.data : Buffer.from(event.data));
39
- } catch { channel.close(1008); }
40
- };
41
- }
1
+ import WebSocket from 'ws';
2
+ import { loopbackUrl } from './wire.mjs';
3
+
4
+ // The RTC peer belongs to an authenticated relay chat session. Its teardown
5
+ // closes this channel on logout, expiry, revocation or signaling disconnect.
6
+ export function attachRtcFile(channel, connection, authorized, clientId) {
7
+ let local, authenticated = false;
8
+ const timer = setTimeout(() => channel.close(1008), 5000);
9
+ channel.addEventListener('close', () => { clearTimeout(timer); local?.terminate(); });
10
+ channel.onmessage = event => {
11
+ try {
12
+ if (!authorized()) throw new Error('File authorization expired');
13
+ if (!local) {
14
+ if (typeof event.data !== 'string') throw new Error('File authentication required');
15
+ const auth = JSON.parse(event.data);
16
+ if (auth.type !== 'auth' || !['upload', 'download'].includes(auth.purpose)) throw new Error('Invalid file purpose');
17
+ const url = new URL(connection.url); url.pathname = '/api/file-transfer';
18
+ loopbackUrl(url.href, '/api/file-transfer');
19
+ local = new WebSocket(url.href, { maxPayload: 512 * 1024, perMessageDeflate: false });
20
+ local.on('open', () => {
21
+ if (!authorized() || channel.readyState !== 1) { local.terminate(); return; }
22
+ local.send(JSON.stringify({ type: 'auth', token: connection.token, purpose: auth.purpose,
23
+ uploadSessionId: auth.uploadSessionId, resumeUpload: auth.resumeUpload, relayClientId: clientId }));
24
+ });
25
+ local.on('message', (bytes, binary) => {
26
+ if (!authorized() || channel.readyState !== 1) { channel.close(4401); return; }
27
+ try {
28
+ if (!authenticated) {
29
+ const response = binary ? null : JSON.parse(bytes.toString());
30
+ if (response?.type === 'error') {
31
+ channel.send(bytes.toString()); clearTimeout(timer); return;
32
+ }
33
+ if (response?.type !== 'ready') throw new Error('File authentication failed');
34
+ authenticated = true; clearTimeout(timer);
35
+ }
36
+ channel.send(binary ? bytes : bytes.toString());
37
+ } catch { channel.close(1011); }
38
+ });
39
+ local.on('close', () => channel.close(1011)); local.on('error', () => channel.close(1011));
40
+ return;
41
+ }
42
+ if (!authenticated || local.readyState !== WebSocket.OPEN || local.bufferedAmount > 1024 * 1024) throw new Error('File channel unavailable');
43
+ local.send(typeof event.data === 'string' ? event.data : Buffer.from(event.data));
44
+ } catch { channel.close(1008); }
45
+ };
46
+ }
@@ -19,6 +19,8 @@ import { accountConnections } from './account-connections.mjs';
19
19
  import { usageMeter, usageReport } from './usage-meter.mjs';
20
20
  import { relayDiagnostics } from './diagnostics.mjs';
21
21
  import { connectionInfo, deviceInfo } from './device-info.mjs';
22
+ import { listWorkstations, workstationDetail, workstationSummary, resolveWorkstationAccounts, resolveWorkstationConnections } from './admin-workstations.mjs';
23
+ import { phoneConnectionEvents, validPhoneConnection } from './phone-connections.mjs';
22
24
 
23
25
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
24
26
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
@@ -57,6 +59,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
57
59
  const diagnosticKey = usageDiagnosticKeyFile ? fs.readFileSync(usageDiagnosticKeyFile, 'utf8').trim() : '';
58
60
  if (diagnosticKey && !/^[A-Za-z0-9_-]{43}$/.test(diagnosticKey)) throw new Error('Invalid usage diagnostic key');
59
61
  const store = database?.devices || deviceStore(stateFile);
62
+ const phoneEvents = phoneConnectionEvents(database?.pool);
60
63
  const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
61
64
  const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
62
65
  let activeRelaySessions = 0;
@@ -295,6 +298,21 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
295
298
  const diagnosticAccess = diagnosticRoute && diagnosticKey && token &&
296
299
  timingSafeEqual(Buffer.from(token), Buffer.from(diagnosticKey));
297
300
  if (!internalAccess && !diagnosticAccess) { adminReply(response, 404, { error: 'Not found' }); return; }
301
+ if (request.method === 'GET' && target.pathname === '/admin/api/workstations') {
302
+ const workstations = await resolveWorkstationAccounts(listWorkstations(rooms, { details: true }), database?.pool);
303
+ await resolveWorkstationConnections(workstations, phoneEvents);
304
+ adminReply(response, 200, { sampledAt: new Date().toISOString(), workstations: workstations.map(workstationSummary) }); return;
305
+ }
306
+ if (request.method === 'GET' && target.pathname.startsWith('/admin/api/workstations/')) {
307
+ const id = target.pathname.slice('/admin/api/workstations/'.length);
308
+ const room = validId(id) && rooms.get(id);
309
+ if (!room) { adminReply(response, 404, { error: '未找到工作站' }); return; }
310
+ const [workstation] = await resolveWorkstationAccounts([
311
+ workstationDetail(room, debugAllowlist.devices.some(entry => entry.deviceId === room.id))], database?.pool);
312
+ await resolveWorkstationConnections([workstation], phoneEvents);
313
+ workstation.connectionEvents = await phoneEvents.list(room.id);
314
+ adminReply(response, 200, { sampledAt: new Date().toISOString(), workstation }); return;
315
+ }
298
316
  if (request.method === 'GET' && target.pathname === '/admin/api/status') {
299
317
  const state = adminState.read();
300
318
  adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
@@ -402,10 +420,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
402
420
  if (wss.clients.size >= maxConnections || ++admissions > 240) {
403
421
  socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n'); return;
404
422
  }
405
- wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws));
423
+ wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws, request));
406
424
  });
407
- wss.on('connection', ws => {
425
+ wss.on('connection', (ws, request) => {
426
+ const address = request.headers['x-real-ip'];
427
+ ws.adminInfo = { connectedAt: new Date().toISOString(),
428
+ address: typeof address === 'string' && isIP(address) ? address : request.socket.remoteAddress };
408
429
  let role, room, session, clientId, alive = true;
430
+ const phoneEventIds = new Set();
409
431
  let accountConnection;
410
432
  function invalidateAccountConnection() {
411
433
  if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
@@ -533,6 +555,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
533
555
  const user = await accounts.current(message.accountToken);
534
556
  if (!accountConnection.activate(user.expiresAt)) return;
535
557
  userId = user.id;
558
+ ws.adminInfo.account = { id: user.id, email: user.email };
536
559
  }
537
560
  catch { return fail(4431, 'Account session invalid'); }
538
561
  if (ws.readyState !== ws.OPEN) return;
@@ -591,6 +614,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
591
614
  room.usageTasks?.clear(); room.usageArms?.clear();
592
615
  }
593
616
  room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
617
+ room.adminPhoneConnections = undefined;
618
+ room.adminLastOnlineAt = ws.adminInfo.connectedAt; room.adminLastHostName = hostName;
594
619
  room.usageTasks ||= new Map(); room.usageArms ||= new Map();
595
620
  if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
596
621
  room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
@@ -682,7 +707,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
682
707
  if (room.lanCandidates?.length) send(ws, { type: 'lan-candidates', candidates: room.lanCandidates });
683
708
  const announcement = adminState.read().announcement;
684
709
  if (announcement) send(ws, { type: 'announcement', announcement });
685
- send(room.host, { type: 'attach', session, clientId, p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
710
+ send(room.host, { type: 'attach', session, clientId, chat: Boolean(userId),
711
+ p2pProbe: room.connections.get(clientId).p2pProbe ? 1 : 0 }); return;
686
712
  }
687
713
  if (role === 'device-manager') {
688
714
  if (!room.clients.some(item => item.id === clientId)) return fail(4401, 'Device authorization revoked');
@@ -690,12 +716,44 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
690
716
  await manageDevices(room, ws, message, clientId); return;
691
717
  }
692
718
  if (role === 'host') {
719
+ if (['phone-connection', 'phone-connections'].includes(message.type)) {
720
+ if (room.host !== ws) return;
721
+ if (message.type === 'phone-connections') {
722
+ if (!Array.isArray(message.active) || message.active.length > 128 ||
723
+ message.active.some(item => !validPhoneConnection(item))) return fail(1008, 'Invalid phone connection snapshot');
724
+ room.adminPhoneConnections = new Map(message.active.filter(item => room.clients.some(client => client.id === item.clientId))
725
+ .map(item => [item.connectionId, { connectionId: item.connectionId, clientId: item.clientId, mode: item.mode }]));
726
+ return;
727
+ }
728
+ const event = message.event;
729
+ if (!validPhoneConnection(event)) return fail(1008, 'Invalid phone connection event');
730
+ // Revocation can overtake the workstation's final close event.
731
+ if (!room.clients.some(client => client.id === event.clientId)) {
732
+ send(ws, { type: 'phone-connection-ack', id: event.id }); return;
733
+ }
734
+ try { await phoneEvents.record(room.id, event); }
735
+ catch (error) { log({ event: 'phone-connection-error', message: error.message }); return; }
736
+ if (room.host !== ws) return;
737
+ if (phoneEventIds.has(event.id)) { send(ws, { type: 'phone-connection-ack', id: event.id }); return; }
738
+ phoneEventIds.add(event.id);
739
+ if (phoneEventIds.size > 256) phoneEventIds.delete(phoneEventIds.values().next().value);
740
+ room.adminPhoneConnections ||= new Map();
741
+ if (event.state === 'connected') room.adminPhoneConnections.set(event.connectionId,
742
+ { connectionId: event.connectionId, clientId: event.clientId, mode: event.mode });
743
+ else room.adminPhoneConnections.delete(event.connectionId);
744
+ send(ws, { type: 'phone-connection-ack', id: event.id }); return;
745
+ }
693
746
  if (room.host !== ws) return;
694
747
  if (['devices-list', 'device-revoke'].includes(message.type)) { await manageDevices(room, ws, message); return; }
695
748
  if (message.type === 'device-connected') {
696
749
  const at = new Date(Math.min(Date.parse(message.at), Date.now())).toISOString();
697
750
  if (!validId(message.clientId)) return fail(1008, 'Invalid mobile identity');
698
751
  await recordConnection(room, message.clientId, at, deviceInfo(message));
752
+ room.adminLanConnections ||= new Map();
753
+ if (room.clients.some(client => client.id === message.clientId) &&
754
+ (!room.adminLanConnections.has(message.clientId) || room.adminLanConnections.get(message.clientId) < at)) {
755
+ room.adminLanConnections.set(message.clientId, at);
756
+ }
699
757
  return;
700
758
  }
701
759
  if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
@@ -874,12 +932,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
874
932
  if (diagnostic && message.kind === 'close') connection.diagnosticProbeId = undefined;
875
933
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'p2p-route' && message.direct === 1) {
876
934
  releaseRelay(room.connections.get(clientId));
935
+ room.connections.get(clientId).adminRoute = 'direct';
877
936
  } else if (room.connections.get(clientId)?.ws === ws && message.type === 'data' && typeof message.payload === 'string') {
878
937
  const connection = room.connections.get(clientId);
879
938
  if (!connection.userId && !managementPayload(message.payload)) return fail(4431, 'Account login required');
880
939
  if (connection.userId && !controlPayload(message.payload) && !admitRelay(connection, room)) {
881
940
  ws.close(4428, 'Server busy'); return;
882
941
  }
942
+ if (connection.userId && !controlPayload(message.payload)) connection.adminRoute = 'relay';
883
943
  send(room.host, { type: 'data', session, payload: message.payload });
884
944
  } else fail();
885
945
  } catch (error) { log({ event: 'request-error', role, message: 'Relay operation failed' }); ws.close(1011, 'Relay operation failed'); }
@@ -1,141 +1,141 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { recordDailyUsage } from './usage-daily.mjs';
3
-
4
- const epoch = value => new Date(`${value.replace(' ', 'T')}Z`).getTime();
5
-
6
- // Count confirmed intervals only; a lost connection never creates estimated time.
7
- export function usageMeter(pool) {
8
- async function action(connection, computerId, clientId, kind, key) {
9
- if (!connection.userId || !['message', 'turn'].includes(kind) || typeof key !== 'string' || !key || key.length > 128) return;
10
- const column = kind === 'message' ? 'message_count' : 'turn_count';
11
- const db = await pool.getConnection();
12
- try {
13
- await db.beginTransaction();
14
- const [users] = await db.execute("SELECT id FROM users WHERE id=? AND status='active' FOR UPDATE", [connection.userId]);
15
- if (!users.length) { await db.commit(); return false; }
16
- const [result] = await db.execute(`INSERT IGNORE INTO relay_usage_actions
17
- (computer_id,kind,action_key,user_id,client_id,occurred_at)
18
- VALUES (?,?,?,?,?,UTC_TIMESTAMP(3))`,
19
- [computerId, kind, key, connection.userId, clientId]);
20
- if (result.affectedRows === 1) {
21
- await db.execute(`INSERT INTO relay_usage_totals (user_id,${column},updated_at)
22
- VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
23
- updated_at=VALUES(updated_at)`, [connection.userId]);
24
- await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,${column},updated_at)
25
- VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
26
- updated_at=VALUES(updated_at)`, [computerId]);
27
- }
28
- await db.commit();
29
- return result.affectedRows === 1;
30
- } catch (error) { await db.rollback(); throw error; }
31
- finally { db.release(); }
32
- }
33
- async function turnOrigin(computerId, key) {
34
- const [rows] = await pool.execute(`SELECT a.user_id,a.client_id FROM relay_usage_actions a JOIN users u ON u.id=a.user_id
35
- WHERE a.computer_id=? AND a.kind='turn' AND a.action_key=? AND u.status='active'`, [computerId, key]);
36
- return rows[0] ? { userId: rows[0].user_id, clientId: rows[0].client_id } : null;
37
- }
38
- async function pulse(connection, active, computerId, clientId, externalKey = null) {
39
- if (!connection.userId) return;
40
- if (!active && !connection.usageSessionId) return;
41
- if (active && !connection.usageSessionId) {
42
- const id = randomUUID();
43
- const [inserted] = await pool.execute(`INSERT INTO relay_usage_sessions
44
- (id,kind,external_key,user_id,computer_id,client_id,started_at,confirmed_until,charged_seconds)
45
- SELECT ?,'task',?,?,?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3),0 FROM users WHERE id=? AND status='active'`, [id, externalKey, connection.userId, computerId, clientId, connection.userId]);
46
- if (!inserted.affectedRows) return;
47
- connection.usageSessionId = id;
48
- return;
49
- }
50
- const id = connection.usageSessionId;
51
- const db = await pool.getConnection();
52
- try {
53
- await db.beginTransaction();
54
- const [users] = await db.execute("SELECT id FROM users WHERE id=? AND status='active' FOR UPDATE", [connection.userId]);
55
- if (!users.length) { await db.commit(); connection.usageSessionId = undefined; return; }
56
- const [rows] = await db.execute('SELECT user_id,computer_id,confirmed_until,ended_at FROM relay_usage_sessions WHERE id=? FOR UPDATE', [id]);
57
- const row = rows[0];
58
- if (!row || row.ended_at) throw new Error('Invalid relay usage session');
59
- const [clock] = await db.query('SELECT UTC_TIMESTAMP(3) AS now');
60
- const now = clock[0].now;
61
- const nowMs = epoch(now);
62
- const chargeStart = Math.max(epoch(row.confirmed_until), nowMs - 20000);
63
- const seconds = Math.max(0, nowMs - chargeStart) / 1000;
64
- const [plans] = await db.execute('SELECT tier,valid_until FROM relay_usage_entitlements WHERE user_id=?', [row.user_id]);
65
- const subscribed = plans[0]?.tier === 'subscriber' && plans[0].valid_until && epoch(plans[0].valid_until) > epoch(now);
66
- const freeSeconds = subscribed ? 0 : seconds;
67
- await db.execute(`UPDATE relay_usage_sessions SET confirmed_until=?,ended_at=?,charged_seconds=charged_seconds+?,
68
- free_charged_seconds=free_charged_seconds+? WHERE id=?`, [now, active ? null : now, seconds, freeSeconds, id]);
69
- if (seconds > 0) {
70
- await recordDailyUsage(db, row.user_id, row.computer_id, !subscribed,
71
- chargeStart, nowMs, now);
72
- await db.execute(`INSERT INTO relay_usage_totals (user_id,task_seconds,updated_at)
73
- VALUES (?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
74
- updated_at=VALUES(updated_at)`, [row.user_id, seconds, now]);
75
- await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,task_seconds,free_task_seconds,updated_at)
76
- VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
77
- free_task_seconds=free_task_seconds+VALUES(free_task_seconds),updated_at=VALUES(updated_at)`,
78
- [row.computer_id, seconds, freeSeconds, now]);
79
- }
80
- await db.commit();
81
- if (!active) connection.usageSessionId = undefined;
82
- } catch (error) { await db.rollback(); throw error; }
83
- finally { db.release(); }
84
- }
85
-
86
- async function lost(connection) {
87
- const id = connection.usageSessionId;
88
- connection.usageSessionId = undefined;
89
- if (!id) return;
90
- await pool.execute(`UPDATE relay_usage_sessions SET ended_at=confirmed_until
91
- WHERE id=? AND ended_at IS NULL`, [id]);
92
- }
93
-
94
- return { action, turnOrigin, pulse, lost };
95
- }
96
-
97
- export async function usageReport(pool, page, view = 'user') {
98
- if (view === 'computer') {
99
- const offset = (page - 1) * 30;
100
- const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM relay_usage_computer_days
101
- WHERE usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND task_seconds>0`);
102
- const [rows] = await pool.execute(`SELECT c.id,
103
- d.task_seconds AS today_task_seconds,
104
- d.free_task_seconds AS today_free_task_seconds,
105
- COALESCE(a.message_count,0) AS message_count,
106
- COALESCE(a.turn_count,0) AS turn_count,
107
- COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
108
- FROM relay_usage_computer_days d JOIN computers c ON c.id=d.computer_id
109
- LEFT JOIN (SELECT computer_id,SUM(kind='message') AS message_count,
110
- SUM(kind='turn') AS turn_count,MAX(occurred_at) AS last_used_at FROM relay_usage_actions
111
- WHERE occurred_at >= DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) - INTERVAL 8 HOUR
112
- AND occurred_at < DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) + INTERVAL 16 HOUR
113
- GROUP BY computer_id) a ON a.computer_id=c.id
114
- LEFT JOIN (SELECT computer_id,MAX(confirmed_until) AS last_used_at
115
- FROM relay_usage_sessions GROUP BY computer_id) s ON s.computer_id=c.id
116
- WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0
117
- ORDER BY d.task_seconds DESC,c.id LIMIT ? OFFSET ?`, [30, offset]);
118
- return { page, pageSize: 30, total: counts[0].total, rows };
119
- }
120
- const offset = (page - 1) * 30;
121
- const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM relay_usage_user_days d
122
- JOIN user_login_identities i ON i.user_id=d.user_id AND i.kind='email'
123
- WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0`);
124
- const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
125
- d.task_seconds AS today_task_seconds,
126
- COALESCE(a.message_count,0) AS message_count,
127
- COALESCE(a.turn_count,0) AS turn_count,
128
- COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
129
- FROM relay_usage_user_days d JOIN users u ON u.id=d.user_id
130
- JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
131
- LEFT JOIN (SELECT user_id,SUM(kind='message') AS message_count,
132
- SUM(kind='turn') AS turn_count,MAX(occurred_at) AS last_used_at FROM relay_usage_actions
133
- WHERE occurred_at >= DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) - INTERVAL 8 HOUR
134
- AND occurred_at < DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) + INTERVAL 16 HOUR
135
- GROUP BY user_id) a ON a.user_id=u.id
136
- LEFT JOIN (SELECT user_id,MAX(confirmed_until) AS last_used_at
137
- FROM relay_usage_sessions GROUP BY user_id) s ON s.user_id=u.id
138
- WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0
139
- ORDER BY d.task_seconds DESC,u.id LIMIT ? OFFSET ?`, [30, offset]);
140
- return { page, pageSize: 30, total: counts[0].total, rows };
141
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import { recordDailyUsage } from './usage-daily.mjs';
3
+
4
+ const epoch = value => new Date(`${value.replace(' ', 'T')}Z`).getTime();
5
+
6
+ // Count confirmed intervals only; a lost connection never creates estimated time.
7
+ export function usageMeter(pool) {
8
+ async function action(connection, computerId, clientId, kind, key) {
9
+ if (!connection.userId || !['message', 'turn'].includes(kind) || typeof key !== 'string' || !key || key.length > 128) return;
10
+ const column = kind === 'message' ? 'message_count' : 'turn_count';
11
+ const db = await pool.getConnection();
12
+ try {
13
+ await db.beginTransaction();
14
+ const [users] = await db.execute("SELECT id FROM users WHERE id=? AND status='active' FOR UPDATE", [connection.userId]);
15
+ if (!users.length) { await db.commit(); return false; }
16
+ const [result] = await db.execute(`INSERT IGNORE INTO relay_usage_actions
17
+ (computer_id,kind,action_key,user_id,client_id,occurred_at)
18
+ VALUES (?,?,?,?,?,UTC_TIMESTAMP(3))`,
19
+ [computerId, kind, key, connection.userId, clientId]);
20
+ if (result.affectedRows === 1) {
21
+ await db.execute(`INSERT INTO relay_usage_totals (user_id,${column},updated_at)
22
+ VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
23
+ updated_at=VALUES(updated_at)`, [connection.userId]);
24
+ await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,${column},updated_at)
25
+ VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
26
+ updated_at=VALUES(updated_at)`, [computerId]);
27
+ }
28
+ await db.commit();
29
+ return result.affectedRows === 1;
30
+ } catch (error) { await db.rollback(); throw error; }
31
+ finally { db.release(); }
32
+ }
33
+ async function turnOrigin(computerId, key) {
34
+ const [rows] = await pool.execute(`SELECT a.user_id,a.client_id FROM relay_usage_actions a JOIN users u ON u.id=a.user_id
35
+ WHERE a.computer_id=? AND a.kind='turn' AND a.action_key=? AND u.status='active'`, [computerId, key]);
36
+ return rows[0] ? { userId: rows[0].user_id, clientId: rows[0].client_id } : null;
37
+ }
38
+ async function pulse(connection, active, computerId, clientId, externalKey = null) {
39
+ if (!connection.userId) return;
40
+ if (!active && !connection.usageSessionId) return;
41
+ if (active && !connection.usageSessionId) {
42
+ const id = randomUUID();
43
+ const [inserted] = await pool.execute(`INSERT INTO relay_usage_sessions
44
+ (id,kind,external_key,user_id,computer_id,client_id,started_at,confirmed_until,charged_seconds)
45
+ SELECT ?,'task',?,?,?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3),0 FROM users WHERE id=? AND status='active'`, [id, externalKey, connection.userId, computerId, clientId, connection.userId]);
46
+ if (!inserted.affectedRows) return;
47
+ connection.usageSessionId = id;
48
+ return;
49
+ }
50
+ const id = connection.usageSessionId;
51
+ const db = await pool.getConnection();
52
+ try {
53
+ await db.beginTransaction();
54
+ const [users] = await db.execute("SELECT id FROM users WHERE id=? AND status='active' FOR UPDATE", [connection.userId]);
55
+ if (!users.length) { await db.commit(); connection.usageSessionId = undefined; return; }
56
+ const [rows] = await db.execute('SELECT user_id,computer_id,confirmed_until,ended_at FROM relay_usage_sessions WHERE id=? FOR UPDATE', [id]);
57
+ const row = rows[0];
58
+ if (!row || row.ended_at) throw new Error('Invalid relay usage session');
59
+ const [clock] = await db.query('SELECT UTC_TIMESTAMP(3) AS now');
60
+ const now = clock[0].now;
61
+ const nowMs = epoch(now);
62
+ const chargeStart = Math.max(epoch(row.confirmed_until), nowMs - 20000);
63
+ const seconds = Math.max(0, nowMs - chargeStart) / 1000;
64
+ const [plans] = await db.execute('SELECT tier,valid_until FROM relay_usage_entitlements WHERE user_id=?', [row.user_id]);
65
+ const subscribed = plans[0]?.tier === 'subscriber' && plans[0].valid_until && epoch(plans[0].valid_until) > epoch(now);
66
+ const freeSeconds = subscribed ? 0 : seconds;
67
+ await db.execute(`UPDATE relay_usage_sessions SET confirmed_until=?,ended_at=?,charged_seconds=charged_seconds+?,
68
+ free_charged_seconds=free_charged_seconds+? WHERE id=?`, [now, active ? null : now, seconds, freeSeconds, id]);
69
+ if (seconds > 0) {
70
+ await recordDailyUsage(db, row.user_id, row.computer_id, !subscribed,
71
+ chargeStart, nowMs, now);
72
+ await db.execute(`INSERT INTO relay_usage_totals (user_id,task_seconds,updated_at)
73
+ VALUES (?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
74
+ updated_at=VALUES(updated_at)`, [row.user_id, seconds, now]);
75
+ await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,task_seconds,free_task_seconds,updated_at)
76
+ VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
77
+ free_task_seconds=free_task_seconds+VALUES(free_task_seconds),updated_at=VALUES(updated_at)`,
78
+ [row.computer_id, seconds, freeSeconds, now]);
79
+ }
80
+ await db.commit();
81
+ if (!active) connection.usageSessionId = undefined;
82
+ } catch (error) { await db.rollback(); throw error; }
83
+ finally { db.release(); }
84
+ }
85
+
86
+ async function lost(connection) {
87
+ const id = connection.usageSessionId;
88
+ connection.usageSessionId = undefined;
89
+ if (!id) return;
90
+ await pool.execute(`UPDATE relay_usage_sessions SET ended_at=confirmed_until
91
+ WHERE id=? AND ended_at IS NULL`, [id]);
92
+ }
93
+
94
+ return { action, turnOrigin, pulse, lost };
95
+ }
96
+
97
+ export async function usageReport(pool, page, view = 'user') {
98
+ if (view === 'computer') {
99
+ const offset = (page - 1) * 30;
100
+ const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM relay_usage_computer_days
101
+ WHERE usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND task_seconds>0`);
102
+ const [rows] = await pool.execute(`SELECT c.id,
103
+ d.task_seconds AS today_task_seconds,
104
+ d.free_task_seconds AS today_free_task_seconds,
105
+ COALESCE(a.message_count,0) AS message_count,
106
+ COALESCE(a.turn_count,0) AS turn_count,
107
+ COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
108
+ FROM relay_usage_computer_days d JOIN computers c ON c.id=d.computer_id
109
+ LEFT JOIN (SELECT computer_id,SUM(kind='message') AS message_count,
110
+ SUM(kind='turn') AS turn_count,MAX(occurred_at) AS last_used_at FROM relay_usage_actions
111
+ WHERE occurred_at >= DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) - INTERVAL 8 HOUR
112
+ AND occurred_at < DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) + INTERVAL 16 HOUR
113
+ GROUP BY computer_id) a ON a.computer_id=c.id
114
+ LEFT JOIN (SELECT computer_id,MAX(confirmed_until) AS last_used_at
115
+ FROM relay_usage_sessions GROUP BY computer_id) s ON s.computer_id=c.id
116
+ WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0
117
+ ORDER BY d.task_seconds DESC,c.id LIMIT ? OFFSET ?`, [30, offset]);
118
+ return { page, pageSize: 30, total: counts[0].total, rows };
119
+ }
120
+ const offset = (page - 1) * 30;
121
+ const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM relay_usage_user_days d
122
+ JOIN user_login_identities i ON i.user_id=d.user_id AND i.kind='email'
123
+ WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0`);
124
+ const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
125
+ d.task_seconds AS today_task_seconds,
126
+ COALESCE(a.message_count,0) AS message_count,
127
+ COALESCE(a.turn_count,0) AS turn_count,
128
+ COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
129
+ FROM relay_usage_user_days d JOIN users u ON u.id=d.user_id
130
+ JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
131
+ LEFT JOIN (SELECT user_id,SUM(kind='message') AS message_count,
132
+ SUM(kind='turn') AS turn_count,MAX(occurred_at) AS last_used_at FROM relay_usage_actions
133
+ WHERE occurred_at >= DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) - INTERVAL 8 HOUR
134
+ AND occurred_at < DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) + INTERVAL 16 HOUR
135
+ GROUP BY user_id) a ON a.user_id=u.id
136
+ LEFT JOIN (SELECT user_id,MAX(confirmed_until) AS last_used_at
137
+ FROM relay_usage_sessions GROUP BY user_id) s ON s.user_id=u.id
138
+ WHERE d.usage_date=DATE(UTC_TIMESTAMP() + INTERVAL 8 HOUR) AND d.task_seconds>0
139
+ ORDER BY d.task_seconds DESC,u.id LIMIT ? OFFSET ?`, [30, offset]);
140
+ return { page, pageSize: 30, total: counts[0].total, rows };
141
+ }
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
9
9
  import vm from 'node:vm';
10
10
  import sharp from 'sharp';
11
11
  import { attachLanSocket } from './lan-socket-server.mjs';
12
+ import { connectionReporting } from './self-relay/connection-reporting.mjs';
12
13
  import fileMentions from '../shared/file-mentions.cjs';
13
14
  import fileCitations from '../shared/file-citations.cjs';
14
15
  import { validateInlineImage } from './message-images.mjs';
@@ -92,6 +93,7 @@ function readRuntimeVersion() {
92
93
 
93
94
  const options = parseOptions(process.argv.slice(2));
94
95
  const runtimeVersion = readRuntimeVersion();
96
+ const serviceInstanceId = crypto.randomUUID();
95
97
 
96
98
  const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web');
97
99
  const projectRoot = os.homedir();
@@ -266,8 +268,8 @@ async function handleRequest(request, response) {
266
268
  const body = await readJsonBody(request);
267
269
  if (body.confirmed !== true) throw new RequestError(400, '请确认启动桌面端。');
268
270
  const result = await startDesktop({ stateRoot, host: desktopHost, restart: url.pathname === '/api/desktop/restart' });
269
- await runtimeDiscovery.sample();
270
- sendJson(response, 202, result); return;
271
+ const observed = await runtimeDiscovery.sample();
272
+ sendJson(response, 202, { ...result, desktop: observed.desktop, checkedAt: observed.checkedAt }); return;
271
273
  }
272
274
  if (request.method === 'POST' && url.pathname === '/api/shared-recovery') {
273
275
  const body = await readJsonBody(request);
@@ -283,7 +285,7 @@ async function handleRequest(request, response) {
283
285
  if (request.method === 'POST' && url.pathname === '/api/service/restart') {
284
286
  const body = await readJsonBody(request);
285
287
  if (body.confirmed !== true) throw new RequestError(400, '请确认重启工作站服务后再继续。');
286
- sendJson(response, 202, await requestServiceRestart(stateRoot)); return;
288
+ sendJson(response, 202, { ...await requestServiceRestart(stateRoot), serviceInstanceId }); return;
287
289
  }
288
290
  if (request.method === 'POST' && url.pathname === '/api/managed-app-server/restart') {
289
291
  const body = await readJsonBody(request);
@@ -322,7 +324,7 @@ async function handleRequest(request, response) {
322
324
  // must not trigger PowerShell, process-identity, or app-server checks.
323
325
  const executionReady = executionHealth.state.ready && sharedServer?.ready === true;
324
326
  sendJson(response, 200, {
325
- status: 'ok', resident: true, version: runtimeVersion,
327
+ status: 'ok', resident: true, version: runtimeVersion, serviceInstanceId,
326
328
  keepAwake: powerManager.snapshot(),
327
329
  serviceUpdate: serviceUpdateStatus(stateRoot),
328
330
  execution: { ...executionHealth.state, ready: executionReady, canWrite: executionReady,
@@ -392,7 +394,10 @@ async function handleRequest(request, response) {
392
394
  return;
393
395
  }
394
396
  const localPath = url.searchParams.get('path') || '';
395
- if (localFileRequest && !/^[a-z]:[\\/]/i.test(localPath)) throw new RequestError(400, '无效的工作站文件路径。');
397
+ if (localFileRequest && (/[\x00-\x1f]/.test(localPath)
398
+ || !(process.platform === 'win32' ? /^[a-z]:[\\/]/i : /^\/(?!\/)/).test(localPath))) {
399
+ throw new RequestError(400, '无效的工作站文件路径。');
400
+ }
396
401
  const file = localFileRequest ? describeLocalFile(localPath) : fileCache.get(fileMatch[1]);
397
402
  if (!file) throw new RequestError(404, '电脑上的文件不存在或无法读取。');
398
403
  if (file.externalIdentity) await dotsImages.authorization(file.externalIdentity);
@@ -514,11 +519,16 @@ async function handleRequest(request, response) {
514
519
 
515
520
  if (request.method === 'GET' && url.pathname === '/api/file-local-copy') {
516
521
  const value = url.searchParams.get('source') || '';
517
- const id = /^([a-f0-9]{64})(?:\?recovery=[A-Za-z0-9_-]{1,4096})?$/.exec(value)?.[1];
518
- if (id && (fileCache.has(id) || attachmentCache.has(id) && !attachmentCache.get(id).dotsFile)) {
522
+ const match = /^([a-f0-9]{64})(?:\?recovery=([A-Za-z0-9_-]{1,4096}))?$/.exec(value);
523
+ if (!match) throw new RequestError(400, '无效的文件标识。');
524
+ const asset = attachmentCache.get(match[1])
525
+ || dotsImages.restore(match[1], match[2] || url.searchParams.get('recovery') || '');
526
+ // A missing process cache does not make a local image an external file.
527
+ // The actual image endpoint owns recovery and download authorization.
528
+ if (!asset?.dotsFile) {
519
529
  sendJson(response, 200, { external: false, localCopy: null }); return;
520
530
  }
521
- const asset = await externalDownloadAsset(value, url.searchParams.get('recovery'));
531
+ await dotsImages.authorization(asset.identity);
522
532
  const copy = await workstationDownloads.copy(asset.id);
523
533
  sendJson(response, 200, { external: true, localCopy: copy ? workstationCopyReference(copy, asset) : null,
524
534
  cachedContent: asset.buffer ? { remotePath: asset.id, name: asset.name, size: asset.buffer.length, mimeType: asset.mimeType } : null });
@@ -759,7 +769,9 @@ async function handleRequest(request, response) {
759
769
  }
760
770
  const { input } = appServerTurnArguments('', body);
761
771
  messageSubmissionStarted = true;
762
- const created = await appServerEvents.createThread(rpc, target, input, body, undefined, bindCreation);
772
+ const createRpc = (method, params) => method === 'thread/start'
773
+ ? execution.startThread(params) : rpc(method, params);
774
+ const created = await appServerEvents.createThread(createRpc, target, input, body, undefined, bindCreation);
763
775
  const prompt = input.filter(part => part.type === 'text').map(part => part.text).join('\n');
764
776
  void generateAndSetThreadTitle({
765
777
  request: rpc,
@@ -1021,6 +1033,8 @@ const lanSocket = attachLanSocket(server, { token: options.token, dispatch: hand
1021
1033
  uploadsRoot,
1022
1034
  authorizeDevice: (clientId, credential) => authorizeLanDevice(stateRoot, clientId, credential),
1023
1035
  onDeviceAuthorizationsChanged: listener => onLanAuthorizationsChanged(stateRoot, listener),
1036
+ onPhoneConnected: (...args) => connectionReporting(stateRoot).connected(...args),
1037
+ onPhoneDisconnected: id => connectionReporting(stateRoot).disconnected(id),
1024
1038
  onDeviceConnected: (clientId, info) => {
1025
1039
  try { recordLanConnection(stateRoot, clientId, info); }
1026
1040
  catch (error) { console.error('记录局域网设备连接失败:', error.message); }