@walkhi/code-relax 0.1.0-beta.16 → 0.1.0-beta.18

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 (33) hide show
  1. package/README.md +14 -14
  2. package/dist/bin/codex-remote.mjs +47 -37
  3. package/dist/bin/manage-relay-admin.mjs +20 -0
  4. package/dist/bin/migrate-relay-db.mjs +3 -1
  5. package/dist/bin/self-relay-demo.mjs +1 -1
  6. package/dist/shared/app-server-events.cjs +22 -9
  7. package/dist/skills/code-relax/SKILL.md +6 -5
  8. package/dist/src/desktop-launcher.mjs +1 -1
  9. package/dist/src/lan-socket-server.mjs +1 -1
  10. package/dist/src/onboarding.mjs +6 -6
  11. package/dist/src/platform/windows/resident-start.js +12 -0
  12. package/dist/src/platform/windows/resident-startup.ps1 +21 -8
  13. package/dist/src/platform/windows/user-environment.mjs +1 -1
  14. package/dist/src/self-relay/admin-page.mjs +13 -5
  15. package/dist/src/self-relay/admin-server.mjs +13 -8
  16. package/dist/src/self-relay/admin-style.mjs +9 -0
  17. package/dist/src/self-relay/admin-usage-page.mjs +28 -0
  18. package/dist/src/self-relay/connector.mjs +158 -20
  19. package/dist/src/self-relay/demo.mjs +1 -1
  20. package/dist/src/self-relay/email-accounts.mjs +17 -1
  21. package/dist/src/self-relay/lifecycle.mjs +5 -4
  22. package/dist/src/self-relay/mysql-state.mjs +1 -1
  23. package/dist/src/self-relay/server.mjs +105 -4
  24. package/dist/src/self-relay/usage-meter.mjs +125 -0
  25. package/dist/src/server.mjs +47 -9
  26. package/dist/src/service-control.mjs +4 -4
  27. package/dist/src/service-doctor.mjs +4 -4
  28. package/dist/src/service-health.mjs +2 -2
  29. package/dist/src/service-lifecycle.mjs +8 -8
  30. package/dist/src/shared-app-server.mjs +7 -4
  31. package/dist/src/shared-recovery.mjs +1 -1
  32. package/package.json +1 -1
  33. package/tools/postinstall.mjs +3 -3
@@ -10,6 +10,7 @@ import { relayAdminState } from './admin-state.mjs';
10
10
  import { openRelayMysql } from './mysql-state.mjs';
11
11
  import { adminPage } from './admin-page.mjs';
12
12
  import { openEmailAccounts } from './email-accounts.mjs';
13
+ import { usageMeter, usageReport } from './usage-meter.mjs';
13
14
 
14
15
  // Always loopback; a TLS reverse proxy supplies the private-trial Internet endpoint.
15
16
  export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlMs = 120000,
@@ -25,8 +26,9 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
25
26
  if (appDownloadUrl && !/^https:\/\/[^\s]+$/.test(appDownloadUrl)) throw new Error('Invalid app download URL');
26
27
  const hostHash = digest(hostKey), rooms = new Map();
27
28
  const database = mysqlConfigFile ? await openRelayMysql(mysqlConfigFile, maxRelaySessions) : null;
28
- if (accountConfigFile && !database?.schemaVersions.includes('002_email_accounts')) throw new Error('Email account schema is required');
29
+ if (accountConfigFile && !database?.schemaVersions.includes('003_relay_usage')) throw new Error('Relay account and usage schema is required');
29
30
  const accounts = accountConfigFile ? openEmailAccounts(database.pool, accountConfigFile) : null;
31
+ const meter = accounts ? usageMeter(database.pool) : null;
30
32
  const store = database?.devices || deviceStore(stateFile);
31
33
  const debugAllowlist = readDebugAllowlist(debugAllowlistFile);
32
34
  const adminState = database?.admin || relayAdminState(adminStateFile, maxRelaySessions);
@@ -117,7 +119,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
117
119
  }
118
120
  const bearer = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
119
121
  if (target.pathname === '/account/me' && request.method === 'GET') {
120
- accountReply(response, 200, { user: await accounts.current(bearer) }); return;
122
+ const user = await accounts.current(bearer);
123
+ accountReply(response, 200, { user, usage: await accounts.usage(user.id) }); return;
121
124
  }
122
125
  if (target.pathname === '/account/logout' && request.method === 'POST') {
123
126
  accountReply(response, 200, await accounts.logout(bearer)); return;
@@ -142,6 +145,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
142
145
  if (request.method === 'GET' && target.pathname === '/admin') {
143
146
  adminReply(response, 200, adminPage(), 'text/html; charset=utf-8'); return;
144
147
  }
148
+ if (target.pathname.startsWith('/admin/api/')) {
149
+ const token = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(request.headers.authorization || '')?.[1];
150
+ let email;
151
+ try { email = (await accounts.current(token)).email; }
152
+ catch { adminReply(response, 401, { error: '请先登录管理员账号' }); return; }
153
+ const [allowed] = await database.pool.execute('SELECT email FROM relay_admin_emails WHERE email=?', [email]);
154
+ if (!allowed.length) { adminReply(response, 403, { error: '此账号没有后台管理权限' }); return; }
155
+ if (target.pathname === '/admin/api/session' && request.method === 'GET') {
156
+ adminReply(response, 200, { email }); return;
157
+ }
158
+ }
145
159
  if (request.method === 'GET' && target.pathname === '/admin/api/status') {
146
160
  const state = adminState.read();
147
161
  adminReply(response, 200, { maxRelaySessions: state.maxRelaySessions, activeRelaySessions,
@@ -151,6 +165,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
151
165
  processRssBytes: process.memoryUsage().rss, processUptimeSeconds: process.uptime(),
152
166
  announcement: state.announcement, announcements: state.announcements }); return;
153
167
  }
168
+ if (request.method === 'GET' && target.pathname === '/admin/api/usage' && meter) {
169
+ const page = Number(target.searchParams.get('page') || '1');
170
+ const user = target.searchParams.get('user') || '';
171
+ if (!Number.isSafeInteger(page) || page < 1 || page > 100000 || (user && !/^[0-9a-f-]{36}$/.test(user))) {
172
+ adminReply(response, 400, { error: 'Invalid usage query' }); return;
173
+ }
174
+ adminReply(response, 200, await usageReport(database.pool, page, user)); return;
175
+ }
154
176
  if (!['PUT', 'DELETE'].includes(request.method || '') || request.headers['content-type'] !== 'application/json' ||
155
177
  request.headers['x-code-relax-admin'] !== '1') { adminReply(response, 405, { error: 'Unsupported admin request' }); return; }
156
178
  try {
@@ -245,10 +267,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
245
267
  }).catch(() => connection.ws.close(1011, 'Relay delivery failed'));
246
268
  };
247
269
  ws.on('error', () => {}); // close owns routing cleanup.
248
- ws.on('close', (code, reason) => {
270
+ ws.on('close', (code, reason) => {
249
271
  log({ event: 'closed', role: role || 'unauthenticated', device: room?.id, code, reason: reason.toString() });
250
272
  clearTimeout(authTimer); clearInterval(heartbeat);
251
273
  if (role === 'host' && room?.host === ws) {
274
+ for (const task of room.usageTasks?.values() || []) {
275
+ void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
276
+ }
277
+ room.usageTasks?.clear(); room.usageArms?.clear();
252
278
  room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
253
279
  room.uploadHost?.close(4410, 'Computer offline');
254
280
  for (const client of room.uploadClients?.values() || []) client.ws.close(4410, 'Computer offline');
@@ -256,7 +282,8 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
256
282
  if (!room.durable) rooms.delete(room.id);
257
283
  for (const connection of room.connections.values()) connection.ws.close(4410, 'Computer offline; authorization retained');
258
284
  } else if (role === 'client' && room?.connections.get(clientId)?.ws === ws) {
259
- releaseRelay(room.connections.get(clientId));
285
+ const connection = room.connections.get(clientId);
286
+ releaseRelay(connection);
260
287
  room.connections.delete(clientId);
261
288
  if (room.host) send(room.host, { type: 'detach', session });
262
289
  } else if (role === 'observer' && room?.observers.get(clientId)?.ws === ws) {
@@ -323,7 +350,14 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
323
350
  }
324
351
  const oldHost = room.host;
325
352
  room.observers ||= new Map();
353
+ if (oldHost && oldHost !== ws) {
354
+ for (const task of room.usageTasks?.values() || []) {
355
+ try { await meter?.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
356
+ }
357
+ room.usageTasks?.clear(); room.usageArms?.clear();
358
+ }
326
359
  room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
360
+ room.usageTasks ||= new Map(); room.usageArms ||= new Map();
327
361
  if (oldHost && oldHost !== ws) room.uploadHost?.close(4409, 'Computer connection replaced');
328
362
  room.p2pProbe = message.p2pProbe === 1; room.p2pProbeRepeat = message.p2pProbeRepeat === true;
329
363
  role = 'host'; clearTimeout(authTimer);
@@ -382,6 +416,11 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
382
416
  return;
383
417
  }
384
418
  if (!room.host) return fail(4410, 'Computer offline; authorization retained');
419
+ let userId;
420
+ if (accounts && message.accountToken !== undefined) {
421
+ try { userId = (await accounts.current(message.accountToken)).id; }
422
+ catch { return fail(4431, 'Account session invalid'); }
423
+ }
385
424
  const oldConnection = room.connections.get(clientId);
386
425
  role = 'client'; clearTimeout(authTimer); session = randomUUID();
387
426
  if (oldConnection?.ws && oldConnection.ws !== ws) {
@@ -390,6 +429,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
390
429
  oldConnection.ws.close(4409, 'Phone connection replaced by same identity');
391
430
  }
392
431
  room.connections.set(clientId, { ws, session, p2pProbe: room.p2pProbe && message.p2pProbe === 1,
432
+ userId,
393
433
  repeat: room.p2pProbeRepeat, signals: 0, probeStarted: 0, outbound: Promise.resolve(), queuedBytes: 0,
394
434
  relayAdmitted: false, relayExempt: false,
395
435
  capacityExempt: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
@@ -402,6 +442,67 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
402
442
  }
403
443
  if (role === 'host') {
404
444
  if (room.host !== ws) return;
445
+ if (meter && ['usage-arm', 'usage-message', 'usage-cancel', 'usage-turn', 'usage-observer-lost'].includes(message.type)) {
446
+ if (message.type === 'usage-observer-lost') {
447
+ for (const task of room.usageTasks.values()) {
448
+ try { await meter.lost(task); } catch (error) { log({ event: 'usage-error', message: error.message }); }
449
+ }
450
+ room.usageTasks.clear();
451
+ return;
452
+ }
453
+ const armKey = `${message.session}:${message.requestId}`;
454
+ const connection = [...room.connections.values()].find(item => item.session === message.session);
455
+ if (message.type === 'usage-arm') {
456
+ if (!connection?.userId || !Number.isSafeInteger(message.requestId) || message.requestId < 1 ||
457
+ (message.threadId !== null && (!plainText(message.threadId, 128, false)))) return;
458
+ for (const [key, arm] of room.usageArms) if (Date.now() - arm.at > 120000) room.usageArms.delete(key);
459
+ if (room.usageArms.size >= 256) return;
460
+ room.usageArms.set(armKey, { userId: connection.userId,
461
+ clientId: [...room.connections.entries()].find(([, item]) => item === connection)?.[0],
462
+ session: message.session, at: Date.now() });
463
+ return;
464
+ }
465
+ if (message.type === 'usage-cancel') { room.usageArms.delete(armKey); return; }
466
+ if (message.type === 'usage-message') {
467
+ const arm = room.usageArms.get(armKey);
468
+ room.usageArms.delete(armKey);
469
+ if (!arm || Date.now() - arm.at > 120000 || !plainText(message.messageKey, 128, false)) return;
470
+ try {
471
+ await meter.action(arm, room.id, arm.clientId, 'message', message.messageKey);
472
+ if (message.turnKind === 'start' && plainText(message.threadId, 80, false) && plainText(message.turnId, 80, false)) {
473
+ const key = `${message.threadId}:${message.turnId}`;
474
+ if (key.length <= 128 && !room.usageTasks.has(key)) {
475
+ const task = { userId: arm.userId, clientId: arm.clientId };
476
+ await meter.action(task, room.id, arm.clientId, 'turn', key);
477
+ if (message.observerReady === true) {
478
+ await meter.pulse(task, true, room.id, arm.clientId, key);
479
+ if (message.completed) await meter.pulse(task, false, room.id, arm.clientId);
480
+ else room.usageTasks.set(key, task);
481
+ }
482
+ }
483
+ }
484
+ } catch (error) { log({ event: 'usage-error', message: error.message }); }
485
+ return;
486
+ }
487
+ if (!['turn/started', 'turn/completed', 'heartbeat', 'resume'].includes(message.method) ||
488
+ !plainText(message.threadId, 80, false) || !plainText(message.turnId, 80, false)) return;
489
+ const key = `${message.threadId}:${message.turnId}`;
490
+ if (key.length > 128) return;
491
+ let task = room.usageTasks.get(key);
492
+ try {
493
+ if (message.method === 'resume' && !task) {
494
+ task = await meter.turnOrigin(room.id, key);
495
+ if (!task) return;
496
+ await meter.pulse(task, true, room.id, task.clientId, `${key}:${randomUUID()}`);
497
+ room.usageTasks.set(key, task);
498
+ } else if (task && message.method === 'heartbeat') await meter.pulse(task, true, room.id, task.clientId);
499
+ else if (task && message.method === 'turn/completed') {
500
+ await meter.pulse(task, false, room.id, task.clientId);
501
+ room.usageTasks.delete(key);
502
+ }
503
+ } catch (error) { log({ event: 'usage-error', message: error.message }); }
504
+ return;
505
+ }
405
506
  if (message.type === 'lan-candidates') {
406
507
  room.lanCandidates = normalizeLanCandidates(message.candidates);
407
508
  for (const connection of room.connections.values()) send(connection.ws, { type: 'lan-candidates', candidates: room.lanCandidates });
@@ -0,0 +1,125 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ const epoch = value => new Date(`${value.replace(' ', 'T')}Z`).getTime();
4
+
5
+ // Count confirmed intervals only; a lost connection never creates estimated time.
6
+ export function usageMeter(pool) {
7
+ async function action(connection, computerId, clientId, kind, key) {
8
+ if (!connection.userId || !['message', 'turn'].includes(kind) || typeof key !== 'string' || !key || key.length > 128) return;
9
+ const column = kind === 'message' ? 'message_count' : 'turn_count';
10
+ const db = await pool.getConnection();
11
+ try {
12
+ await db.beginTransaction();
13
+ const [result] = await db.execute(`INSERT IGNORE INTO relay_usage_actions
14
+ (computer_id,kind,action_key,user_id,client_id,occurred_at)
15
+ VALUES (?,?,?,?,?,UTC_TIMESTAMP(3))`,
16
+ [computerId, kind, key, connection.userId, clientId]);
17
+ if (result.affectedRows === 1) {
18
+ await db.execute(`INSERT INTO relay_usage_totals (user_id,${column},updated_at)
19
+ VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
20
+ updated_at=VALUES(updated_at)`, [connection.userId]);
21
+ await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,${column},updated_at)
22
+ VALUES (?,1,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE ${column}=${column}+1,
23
+ updated_at=VALUES(updated_at)`, [computerId]);
24
+ }
25
+ await db.commit();
26
+ return result.affectedRows === 1;
27
+ } catch (error) { await db.rollback(); throw error; }
28
+ finally { db.release(); }
29
+ }
30
+ async function turnOrigin(computerId, key) {
31
+ const [rows] = await pool.execute(`SELECT user_id,client_id FROM relay_usage_actions
32
+ WHERE computer_id=? AND kind='turn' AND action_key=?`, [computerId, key]);
33
+ return rows[0] ? { userId: rows[0].user_id, clientId: rows[0].client_id } : null;
34
+ }
35
+ async function pulse(connection, active, computerId, clientId, externalKey = null) {
36
+ if (!connection.userId) return;
37
+ if (!active && !connection.usageSessionId) return;
38
+ if (active && !connection.usageSessionId) {
39
+ const id = randomUUID();
40
+ await pool.execute(`INSERT INTO relay_usage_sessions
41
+ (id,kind,external_key,user_id,computer_id,client_id,started_at,confirmed_until,charged_seconds)
42
+ VALUES (?,'task',?,?,?,?,UTC_TIMESTAMP(3),UTC_TIMESTAMP(3),0)`, [id, externalKey, connection.userId, computerId, clientId]);
43
+ connection.usageSessionId = id;
44
+ return;
45
+ }
46
+ const id = connection.usageSessionId;
47
+ const db = await pool.getConnection();
48
+ try {
49
+ await db.beginTransaction();
50
+ const [rows] = await db.execute('SELECT user_id,computer_id,confirmed_until,ended_at FROM relay_usage_sessions WHERE id=? FOR UPDATE', [id]);
51
+ const row = rows[0];
52
+ if (!row || row.ended_at) throw new Error('Invalid relay usage session');
53
+ const [clock] = await db.query('SELECT UTC_TIMESTAMP(3) AS now');
54
+ const now = clock[0].now;
55
+ const elapsedMs = Math.max(0, epoch(now) - epoch(row.confirmed_until));
56
+ const seconds = Math.min(elapsedMs, 20000) / 1000;
57
+ const [plans] = await db.execute('SELECT tier,valid_until FROM relay_usage_entitlements WHERE user_id=?', [row.user_id]);
58
+ const subscribed = plans[0]?.tier === 'subscriber' && plans[0].valid_until && epoch(plans[0].valid_until) > epoch(now);
59
+ const freeSeconds = subscribed ? 0 : seconds;
60
+ await db.execute(`UPDATE relay_usage_sessions SET confirmed_until=?,ended_at=?,charged_seconds=charged_seconds+?,
61
+ free_charged_seconds=free_charged_seconds+? WHERE id=?`, [now, active ? null : now, seconds, freeSeconds, id]);
62
+ if (seconds > 0) {
63
+ await db.execute(`INSERT INTO relay_usage_totals (user_id,task_seconds,updated_at)
64
+ VALUES (?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
65
+ updated_at=VALUES(updated_at)`, [row.user_id, seconds, now]);
66
+ await db.execute(`INSERT INTO relay_usage_computer_totals (computer_id,task_seconds,free_task_seconds,updated_at)
67
+ VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE task_seconds=task_seconds+VALUES(task_seconds),
68
+ free_task_seconds=free_task_seconds+VALUES(free_task_seconds),updated_at=VALUES(updated_at)`,
69
+ [row.computer_id, seconds, freeSeconds, now]);
70
+ }
71
+ await db.commit();
72
+ if (!active) connection.usageSessionId = undefined;
73
+ } catch (error) { await db.rollback(); throw error; }
74
+ finally { db.release(); }
75
+ }
76
+
77
+ async function lost(connection) {
78
+ const id = connection.usageSessionId;
79
+ connection.usageSessionId = undefined;
80
+ if (!id) return;
81
+ await pool.execute(`UPDATE relay_usage_sessions SET ended_at=confirmed_until
82
+ WHERE id=? AND ended_at IS NULL`, [id]);
83
+ }
84
+
85
+ return { action, turnOrigin, pulse, lost };
86
+ }
87
+
88
+ export async function usageReport(pool, page, userId = '') {
89
+ if (userId) {
90
+ const [users] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
91
+ COALESCE(t.task_seconds,0) AS task_seconds,
92
+ COALESCE(t.message_count,0) AS message_count,COALESCE(t.turn_count,0) AS turn_count
93
+ FROM users u JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
94
+ LEFT JOIN relay_usage_totals t ON t.user_id=u.id WHERE u.id=?`, [userId]);
95
+ if (!users.length) return null;
96
+ const [computers] = await pool.execute(`SELECT c.id AS computer_id,
97
+ COALESCE(s.task_seconds,0) AS task_seconds,
98
+ COALESCE(a.message_count,0) AS message_count,
99
+ COALESCE(a.turn_count,0) AS turn_count,
100
+ COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
101
+ FROM computers c
102
+ LEFT JOIN (SELECT computer_id,
103
+ SUM(charged_seconds) AS task_seconds,
104
+ MAX(started_at) AS last_used_at FROM relay_usage_sessions WHERE user_id=? GROUP BY computer_id) s ON s.computer_id=c.id
105
+ LEFT JOIN (SELECT computer_id,
106
+ SUM(kind='message') AS message_count,SUM(kind='turn') AS turn_count,
107
+ MAX(occurred_at) AS last_used_at
108
+ FROM relay_usage_actions WHERE user_id=? GROUP BY computer_id) a ON a.computer_id=c.id
109
+ WHERE s.computer_id IS NOT NULL OR a.computer_id IS NOT NULL
110
+ ORDER BY last_used_at DESC`, [userId, userId]);
111
+ return { user: users[0], computers };
112
+ }
113
+ const offset = (page - 1) * 30;
114
+ const [counts] = await pool.query(`SELECT COUNT(*) AS total FROM user_login_identities WHERE kind='email'`);
115
+ const [rows] = await pool.execute(`SELECT u.id,i.normalized_value AS email,
116
+ COALESCE(t.task_seconds,0) AS task_seconds,
117
+ COALESCE(t.message_count,0) AS message_count,COALESCE(t.turn_count,0) AS turn_count,
118
+ COALESCE(GREATEST(s.last_used_at,a.last_used_at),s.last_used_at,a.last_used_at) AS last_used_at
119
+ FROM users u JOIN user_login_identities i ON i.user_id=u.id AND i.kind='email'
120
+ LEFT JOIN relay_usage_totals t ON t.user_id=u.id
121
+ LEFT JOIN (SELECT user_id,MAX(started_at) AS last_used_at FROM relay_usage_sessions GROUP BY user_id) s ON s.user_id=u.id
122
+ LEFT JOIN (SELECT user_id,MAX(occurred_at) AS last_used_at FROM relay_usage_actions GROUP BY user_id) a ON a.user_id=u.id
123
+ ORDER BY task_seconds DESC,last_used_at DESC,u.id LIMIT ? OFFSET ?`, [30, offset]);
124
+ return { page, pageSize: 30, total: counts[0].total, rows };
125
+ }
@@ -283,7 +283,7 @@ async function handleRequest(request, response) {
283
283
  return;
284
284
  }
285
285
 
286
- if (request.method === 'GET' && url.pathname === '/api/usage-events') {
286
+ if (request.method === 'GET' && url.pathname === '/api/usage-events') {
287
287
  const execution = sharedServer;
288
288
  response.startEvents();
289
289
  const emit = value => { if (!response.destroyed) response.event('usage', value); };
@@ -292,8 +292,45 @@ async function handleRequest(request, response) {
292
292
  execution.on('usage', emit); execution.on('usage-error', failed); execution.on('disconnected', disconnected);
293
293
  response.once('close', () => { execution.off('usage', emit); execution.off('usage-error', failed); execution.off('disconnected', disconnected); });
294
294
  try { emit(await execution.usage()); } catch (error) { failed(error); }
295
- return;
296
- }
295
+ return;
296
+ }
297
+
298
+ if (request.method === 'GET' && url.pathname === '/api/turn-events') {
299
+ response.startEvents();
300
+ const listener = ({ method, params }) => {
301
+ if (!['turn/started', 'turn/completed'].includes(method)) return;
302
+ const threadId = params?.threadId, turnId = params?.turn?.id;
303
+ if (typeof threadId === 'string' && typeof turnId === 'string' && !response.destroyed) {
304
+ response.event('turn', { method, threadId, turnId });
305
+ }
306
+ };
307
+ const disconnected = () => response.end();
308
+ sharedServer.on('notification', listener);
309
+ sharedServer.on('disconnected', disconnected);
310
+ response.once('close', () => {
311
+ sharedServer.off('notification', listener);
312
+ sharedServer.off('disconnected', disconnected);
313
+ });
314
+ return;
315
+ }
316
+
317
+ const activeTurnMatch = /^\/api\/threads\/([^/]+)\/active-turn$/.exec(url.pathname);
318
+ if (request.method === 'GET' && activeTurnMatch) {
319
+ const threadId = decodeURIComponent(activeTurnMatch[1]);
320
+ await sharedServer.connect();
321
+ const { thread } = await sharedServer.request('thread/read', { threadId, includeTurns: false });
322
+ const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
323
+ let turnId = null;
324
+ if (status === 'active') {
325
+ const page = await sharedServer.request('thread/turns/list', {
326
+ threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
327
+ });
328
+ const latest = page.data?.[0];
329
+ if (latest?.status === 'inProgress') turnId = latest.id;
330
+ }
331
+ sendJson(response, 200, { turnId });
332
+ return;
333
+ }
297
334
 
298
335
  const createThreadMatch = /^\/api\/projects\/([^/]+)\/threads$/.exec(url.pathname);
299
336
  if (request.method === 'POST' && createThreadMatch) {
@@ -473,7 +510,8 @@ async function handleRequest(request, response) {
473
510
  await requireExecutionWritable(threadId);
474
511
  const body = await readJsonBody(request);
475
512
  if (body.hostId && body.hostId !== 'local') throw new RequestError(410, '此对话的连接方式已停用。', { code: 'unsupported_control' });
476
- sendJson(response, 200, { success: true, data: await sharedServer.send(appServerTurnArguments(threadId, body)) });
513
+ const submitted = await sharedServer.send(appServerTurnArguments(threadId, body));
514
+ sendJson(response, 200, { success: true, data: submitted.result, usage: submitted.usage });
477
515
  return;
478
516
  }
479
517
 
@@ -505,7 +543,7 @@ void managedAppServer.start();
505
543
  executionHealth.start();
506
544
  runtimeDiscovery.start();
507
545
  server.on('error', error => {
508
- console.error('Bridge 监听失败:', error.message);
546
+ console.error('桌面服务监听失败:', error.message);
509
547
  process.exit(1);
510
548
  });
511
549
  server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
@@ -528,10 +566,10 @@ server.listen(options.port, options.lan ? '0.0.0.0' : '127.0.0.1', async () => {
528
566
  pid: process.pid, controlPort: control.port });
529
567
  }
530
568
  } catch (error) {
531
- console.error('Bridge 管理通道启动失败:', error.message);
569
+ console.error('桌面服务管理通道启动失败:', error.message);
532
570
  process.exit(1);
533
571
  }
534
- console.log(`Code Relax Bridge 已启动:http://127.0.0.1:${options.port}/`);
572
+ console.log(`Code Relax 桌面服务已启动:http://127.0.0.1:${options.port}/`);
535
573
  if (options.lan) {
536
574
  console.log('LAN 模式使用鉴权 WebSocket,仅适合受信任局域网测试,不要暴露到公网。');
537
575
  }
@@ -711,12 +749,12 @@ function messagePayload(body) {
711
749
  return payload;
712
750
  }
713
751
 
714
- function appServerTurnArguments(threadId, body) {
752
+ function appServerTurnArguments(threadId, body) {
715
753
  const payload = messagePayload(body);
716
754
  const input = [];
717
755
  if (payload.prompt) input.push({ type: 'text', text: payload.prompt });
718
756
  for (const image of payload.images) input.push(image.startsWith('data:') ? { type: 'image', url: image } : { type: 'localImage', path: image });
719
- const value = { threadId, input };
757
+ const value = { threadId, input, ...appServerEvents.mobileTurnSource(body.clientUserMessageId) };
720
758
  if (typeof body.clientUserMessageId === 'string' && /^[a-f0-9]{32}$/.test(body.clientUserMessageId)) value.clientUserMessageId = body.clientUserMessageId;
721
759
  if (payload.model) value.model = payload.model;
722
760
  if (payload.thinking) value.effort = payload.thinking;
@@ -29,7 +29,7 @@ export async function openControl({ instanceId, secret, shutdown, status = () =>
29
29
  }
30
30
  if (request.method === 'POST') response.once('finish', () => {
31
31
  Promise.resolve(shutdown()).catch(error => {
32
- console.error('Bridge 关闭失败:', error.message);
32
+ console.error('桌面服务关闭失败:', error.message);
33
33
  process.exitCode = 1;
34
34
  });
35
35
  });
@@ -56,16 +56,16 @@ export function controlRequest(session, method = 'GET', action = '') {
56
56
  response.on('end', () => {
57
57
  try {
58
58
  if (response.statusCode === 409) throw new Error(JSON.parse(body).error);
59
- if (response.statusCode !== 200) throw new Error('Bridge 管理通道身份验证失败。');
59
+ if (response.statusCode !== 200) throw new Error('桌面服务管理通道身份验证失败。');
60
60
  const identity = JSON.parse(body);
61
61
  if (identity.instanceId !== session.instanceId || identity.pid !== session.pid) {
62
- throw new Error('Bridge 实例身份不匹配。');
62
+ throw new Error('桌面服务实例身份不匹配。');
63
63
  }
64
64
  resolve(identity);
65
65
  } catch (error) { reject(error); }
66
66
  });
67
67
  });
68
- request.on('timeout', () => request.destroy(new Error('Bridge 管理通道响应超时。')));
68
+ request.on('timeout', () => request.destroy(new Error('桌面服务管理通道响应超时。')));
69
69
  request.on('error', reject);
70
70
  request.end();
71
71
  });
@@ -20,7 +20,7 @@ export async function doctor({ paths, host, service, skillTarget, ensureAutostar
20
20
  });
21
21
  await check('bridge-runtime', async () => {
22
22
  await host.bridgeEnvironment();
23
- add('bridge-runtime', 'ok', 'Bridge 启动环境已就绪;实际任务操作尚未验证。');
23
+ add('bridge-runtime', 'ok', '桌面服务启动环境已就绪;实际任务操作尚未验证。');
24
24
  });
25
25
  await check('network', async () => {
26
26
  const address = await host.lanAddress();
@@ -37,10 +37,10 @@ export async function doctor({ paths, host, service, skillTarget, ensureAutostar
37
37
  ? `本机服务正常:${state.mode},端口 ${state.port},运行版本:${state.version || '未知'}` : '服务未就绪;安装后执行 relax start。');
38
38
  if (state.running && installedVersion && state.version !== installedVersion) add('service-version', 'warning', state.version
39
39
  ? `已安装版本 ${installedVersion},但当前运行版本为 ${state.version};请重新执行安装或重启工作站服务。`
40
- : `已安装版本 ${installedVersion},但当前运行 Bridge 未报告版本,可能仍是旧进程;请重新执行安装或重启工作站服务。`);
40
+ : `已安装版本 ${installedVersion},但当前运行的桌面服务未报告版本,可能仍是旧进程;请重新执行安装或重启桌面服务。`);
41
41
  if (state.running) add('managed-app-server', state.managedAppServer?.state === 'ready' ? 'ok' : 'error',
42
- state.managedAppServer?.state === 'ready' ? '受管 app-server 已就绪。'
43
- : state.managedAppServer?.error || '受管 app-server 尚未就绪。');
42
+ state.managedAppServer?.state === 'ready' ? 'Codex 内核已就绪。'
43
+ : state.managedAppServer?.error || 'Codex 内核尚未就绪。');
44
44
  if (state.managedAppServer?.updatePending) add('managed-app-server-update', 'warning',
45
45
  '已选择另一版本 CLI;当前 app-server 继续完成任务,下次内核启动时采用选定版本。');
46
46
  if (state.managedAppServer?.launcherRestartRequired) add('managed-app-server-launcher', 'warning',
@@ -14,7 +14,7 @@ export function serviceHealth(session) {
14
14
  socket.terminate();
15
15
  if (error) reject(error); else resolve(value);
16
16
  };
17
- const timer = setTimeout(() => finish(new Error('Bridge 健康检查超时。')), 6000);
17
+ const timer = setTimeout(() => finish(new Error('桌面服务健康检查超时。')), 6000);
18
18
  socket.on('open', () => socket.send(JSON.stringify({ type: 'auth', token })));
19
19
  socket.on('message', data => {
20
20
  try {
@@ -28,6 +28,6 @@ export function serviceHealth(session) {
28
28
  });
29
29
  socket.on('error', error => finish(error.code === 'ECONNREFUSED' ? null : error));
30
30
  socket.on('close', code => finish(new Error(code === 1008
31
- ? 'Bridge 健康检查鉴权失败。' : 'Bridge 健康检查连接已断开。')));
31
+ ? '桌面服务健康检查鉴权失败。' : '桌面服务健康检查连接已断开。')));
32
32
  });
33
33
  }
@@ -27,7 +27,7 @@ export function lifecycle({ stateRoot, host }) {
27
27
 
28
28
  async function alive(session) {
29
29
  if (!session?.controlPort || !session.instanceId || !session.controlSecret) {
30
- if (await health(session)) throw new Error('当前为旧版 Bridge,请先使用旧版停止入口结束服务,再启动新版。');
30
+ if (await health(session)) throw new Error('当前为旧版桌面服务,请先使用旧版停止入口结束服务,再启动新版。');
31
31
  return false;
32
32
  }
33
33
  try { await controlRequest(session); return true; }
@@ -35,7 +35,7 @@ export function lifecycle({ stateRoot, host }) {
35
35
  }
36
36
  async function stop() {
37
37
  const session = readRecord(sessionPath);
38
- if (!await alive(session)) return { stopped: true, message: 'Bridge 已停止。' };
38
+ if (!await alive(session)) return { stopped: true, message: '桌面服务已停止。' };
39
39
  await controlRequest(session, 'POST');
40
40
  const deadline = Date.now() + 10000;
41
41
  while (true) {
@@ -45,10 +45,10 @@ export function lifecycle({ stateRoot, host }) {
45
45
  // Verify refusal on the next connection; never resend the shutdown request.
46
46
  if (error.code !== 'ECONNRESET') throw error;
47
47
  }
48
- if (Date.now() > deadline) throw new Error('Bridge 未在 10 秒内退出;未强杀进程。');
48
+ if (Date.now() > deadline) throw new Error('桌面服务未在 10 秒内退出;未强杀进程。');
49
49
  await delay(100);
50
50
  }
51
- return { stopped: true, message: 'Bridge 已正常关闭。' };
51
+ return { stopped: true, message: '桌面服务已正常关闭。' };
52
52
  }
53
53
  async function status() {
54
54
  const session = readRecord(sessionPath);
@@ -78,7 +78,7 @@ export function lifecycle({ stateRoot, host }) {
78
78
  }
79
79
  async function restartManagedAppServer() {
80
80
  const session = readRecord(sessionPath);
81
- if (!await alive(session)) throw new Error('Bridge 尚未运行,无法迁移受管 app-server。');
81
+ if (!await alive(session)) throw new Error('桌面服务尚未运行,无法迁移 Codex 内核。');
82
82
  return controlRequest(session, 'POST', 'restart-managed-app-server');
83
83
  }
84
84
  async function start() {
@@ -110,11 +110,11 @@ export function lifecycle({ stateRoot, host }) {
110
110
  const deadline = Date.now() + 10000;
111
111
  let ready;
112
112
  while (!(ready = readRecord(readyPath))) {
113
- if (!processAlive(pid)) throw new Error('Bridge 启动失败,请查看 bridge.stderr.log。');
114
- if (Date.now() > deadline) throw new Error('Bridge 未在 10 秒内就绪。');
113
+ if (!processAlive(pid)) throw new Error('桌面服务启动失败,请查看 bridge.stderr.log。');
114
+ if (Date.now() > deadline) throw new Error('桌面服务未在 10 秒内就绪。');
115
115
  await delay(100);
116
116
  }
117
- if (ready.instanceId !== instanceId || ready.pid !== pid) throw new Error('Bridge 启动实例不匹配。');
117
+ if (ready.instanceId !== instanceId || ready.pid !== pid) throw new Error('桌面服务启动实例不匹配。');
118
118
  session = { ...ready, controlSecret, executablePath: executable, scriptPath, port: config.port,
119
119
  lan: true, appServer: false, accessUrl: `http://${address}:${config.port}/?token=${config.token}`,
120
120
  startedAt: new Date().toISOString() };
@@ -136,15 +136,18 @@ export class SharedAppServer extends EventEmitter {
136
136
  // Reading the live thread checks this service owns it without resume changing settings.
137
137
  const { thread } = await this.request('thread/read', { threadId: args.threadId, includeTurns: false });
138
138
  const status = typeof thread.status === 'string' ? thread.status : thread.status?.type;
139
- if (status === 'active') {
139
+ if (status === 'active') {
140
140
  const page = await this.request('thread/turns/list', {
141
141
  threadId: args.threadId, limit: 1, sortDirection: 'desc', itemsView: 'summary',
142
142
  });
143
143
  const active = page.data?.[0];
144
144
  if (active?.status !== 'inProgress') throw new Error('活动轮次已变化,请刷新后再发送。');
145
- return this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
146
- }
147
- return this.request('turn/start', args);
145
+ const { turnTrigger, ...source } = events.mobileTurnSource(args.clientUserMessageId);
146
+ const result = await this.request('turn/steer', { threadId: args.threadId, expectedTurnId: active.id, input: args.input, ...source, ...(args.clientUserMessageId ? { clientUserMessageId: args.clientUserMessageId } : {}) });
147
+ return { result, usage: { kind: 'steer', turnId: active.id } };
148
+ }
149
+ const result = await this.request('turn/start', args);
150
+ return { result, usage: { kind: 'start', turnId: result?.turn?.id || null } };
148
151
  }
149
152
 
150
153
  respondApproval(threadId, body) {
@@ -111,7 +111,7 @@ export async function runRecovery(stateRoot, operationId, dependencies = {}) {
111
111
  await waitFor(async () => {
112
112
  const health = await service.status();
113
113
  return health.running && health.mode === 'lan-shared' && health.execution?.ready === true;
114
- }, 'Codex 内核未就绪;Bridge 和中继管理连接仍保留。');
114
+ }, 'Codex 内核未就绪;桌面服务和中继连接仍保留。');
115
115
  record = { ...record, status: 'completed', stage: 'ready', steps: record.steps.map(step => ({ ...step, status: 'completed' })),
116
116
  message: 'Codex Desktop 已接入 Codex 内核;移动端控制保持就绪,原任务未自动重发。' };
117
117
  } catch (error) { record = { ...record, status: 'failed', error: error.message,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@walkhi/code-relax",
3
- "version": "0.1.0-beta.16",
3
+ "version": "0.1.0-beta.18",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -59,9 +59,9 @@ export function formatSetupOutput(output, previous = {}) {
59
59
  ? `未重启(PID ${pid},启动代次 ${migration.launcherRevision ?? '未知'} 已是最新)`
60
60
  : `已启动(PID ${pid},启动代次 ${migration.launcherRevision ?? '未知'})`;
61
61
  return `Code Relax ${result.version || '未知版本'} 安装成功。\n`
62
- + `受管 app-server:${appServer}\n`
63
- + `Bridge:${result.restarted ? '已恢复并健康' : '已启动并健康'}\n`
64
- + `${result.desktopRestarted ? `Codex Desktop:已干净重启并重新接入(${result.desktopRestartReason === 'attach-managed-server' ? '接入受管内核' : 'app-server 迁移'})\n` : ''}`
62
+ + `Codex 内核:${appServer}\n`
63
+ + `桌面服务:${result.restarted ? '已恢复并健康' : '已启动并健康'}\n`
64
+ + `${result.desktopRestarted ? `Codex Desktop:已干净重启并重新接入(${result.desktopRestartReason === 'attach-managed-server' ? '接入 Codex 内核' : '内核迁移'})\n` : ''}`
65
65
  + `${result.desktopRestartRecommended ? 'Codex Desktop:建议完全退出后重新打开。\n' : ''}`;
66
66
  } catch { return output; }
67
67
  }