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

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 (62) hide show
  1. package/README.md +5 -5
  2. package/dist/bin/self-relay-server.mjs +5 -2
  3. package/dist/shared/app-server-events.cjs +45 -19
  4. package/dist/shared/p2p-data-channel.cjs +50 -0
  5. package/dist/src/app-server-tasks.mjs +28 -25
  6. package/dist/src/lan-socket-server.mjs +8 -2
  7. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  8. package/dist/src/platform/windows/background-process.mjs +7 -2
  9. package/dist/src/platform/windows/launch-worker.ps1 +13 -6
  10. package/dist/src/platform/windows/start-hidden-console.ps1 +11 -6
  11. package/dist/src/self-relay/admin-state.mjs +61 -0
  12. package/dist/src/self-relay/client.mjs +2 -0
  13. package/dist/src/self-relay/connector.mjs +23 -8
  14. package/dist/src/self-relay/demo.mjs +2 -2
  15. package/dist/src/self-relay/lifecycle.mjs +1 -1
  16. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  17. package/dist/src/self-relay/server.mjs +137 -11
  18. package/dist/src/server.mjs +337 -255
  19. package/dist/src/shared-app-server.mjs +180 -24
  20. package/dist/src/shared-catalog.mjs +4 -14
  21. package/dist/src/thread-catalog.mjs +12 -7
  22. package/dist/web/activity-view.js +283 -0
  23. package/dist/web/capabilities.js +2 -2
  24. package/dist/web/chat-transport.js +15 -9
  25. package/dist/web/chat.css +139 -93
  26. package/dist/web/chat.js +1451 -2770
  27. package/dist/web/community-view.js +20 -0
  28. package/dist/web/community.css +77 -0
  29. package/dist/web/community.html +37 -0
  30. package/dist/web/composer-controller.js +101 -0
  31. package/dist/web/conversation-controller.js +99 -0
  32. package/dist/web/disclosure-state-controller.js +95 -0
  33. package/dist/web/draft-controller.js +103 -0
  34. package/dist/web/harmony-platform.js +3 -2
  35. package/dist/web/history-cache.js +112 -18
  36. package/dist/web/history-controller.js +167 -0
  37. package/dist/web/index.html +141 -38
  38. package/dist/web/link-action-controller.js +212 -0
  39. package/dist/web/message-send-controller.js +177 -0
  40. package/dist/web/message-view.js +98 -0
  41. package/dist/web/p2p-data-channel.js +50 -0
  42. package/dist/web/p2p-probe.js +67 -27
  43. package/dist/web/page-resume.js +28 -0
  44. package/dist/web/pending-message-store.js +108 -0
  45. package/dist/web/queue-controller.js +82 -0
  46. package/dist/web/resources.json +1 -1
  47. package/dist/web/self-relay-session.js +28 -18
  48. package/dist/web/station-connection-controller.js +75 -0
  49. package/dist/web/task-list-view.js +296 -0
  50. package/dist/web/thread-attention-controller.js +124 -0
  51. package/dist/web/thread-context-controller.js +30 -0
  52. package/dist/web/thread-list-controller.js +61 -0
  53. package/dist/web/thread-list-sync.js +86 -0
  54. package/dist/web/thread-title-controller.js +57 -0
  55. package/dist/web/timeline-formatters.js +249 -0
  56. package/dist/web/timeline-reducer.js +81 -0
  57. package/dist/web/timeline-renderer.js +161 -0
  58. package/dist/web/timeline-scroll-controller.js +50 -0
  59. package/dist/web/usage-controller.js +258 -0
  60. package/dist/web/vendor/lucide.LICENSE.txt +17 -0
  61. package/package.json +1 -1
  62. package/tools/postinstall.mjs +66 -2
@@ -5,7 +5,8 @@ import net from 'node:net';
5
5
  import path from 'node:path';
6
6
  import os from 'node:os';
7
7
  import { fileURLToPath } from 'node:url';
8
- import vm from 'node:vm';
8
+ import vm from 'node:vm';
9
+ import sharp from 'sharp';
9
10
  import { attachLanSocket } from './lan-socket-server.mjs';
10
11
  import { validateInlineImage, persistLegacyImage } from './message-images.mjs';
11
12
  import { ThreadWriterConflictError } from './shared-app-server.mjs';
@@ -33,7 +34,9 @@ const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024;
33
34
  const MAX_CACHED_ATTACHMENTS = 32;
34
35
  const MAX_CACHED_FILES = 64;
35
36
  const MAX_CONTEXT_TAIL_BYTES = 4 * 1024 * 1024;
36
- const MAX_LIVE_ACTIVITY_TAIL_BYTES = 1024 * 1024;
37
+ const MAX_LIVE_ACTIVITY_TAIL_BYTES = 1024 * 1024;
38
+ const SHARED_HISTORY_PAGE_BYTES = 500 * 1024;
39
+ const SHARED_HISTORY_PAGE_TURNS = 8;
37
40
  const MAX_SETTINGS_SCAN_BYTES = 64 * 1024 * 1024;
38
41
  const ALLOWED_MODELS = new Set([
39
42
  'gpt-6-astra',
@@ -49,7 +52,6 @@ const ALLOWED_TOOLS = new Set([
49
52
  'get_usage_limits',
50
53
  'list_projects',
51
54
  'list_threads',
52
- 'navigate_to_codex_page',
53
55
  'read_thread',
54
56
  'send_message_to_thread',
55
57
  'wait_threads',
@@ -68,9 +70,11 @@ const markdownSandbox = { atob };
68
70
  vm.runInNewContext(markdownItScript.toString(), markdownSandbox);
69
71
  const imageMarkdown = markdownSandbox.markdownit({ html: false });
70
72
  let nextRequestId = 0;
71
- const attachmentCache = new Map();
72
- const fileCache = new Map();
73
- const fileCapabilitySecret = crypto.randomBytes(32);
73
+ const attachmentCache = new Map();
74
+ const fileCache = new Map();
75
+ const fileCapabilitySecret = crypto.randomBytes(32);
76
+ const imageCapabilityKey = crypto.createHash('sha256')
77
+ .update('code-relax-image-capability\0').update(options.token || fileCapabilitySecret).digest();
74
78
  const legacyUploadsRoot = path.resolve(webRoot, '..', 'uploads');
75
79
  const allowedUploadsRoots = [uploadsRoot, legacyUploadsRoot,
76
80
  ...JSON.parse(process.env.CODEX_REMOTE_LEGACY_UPLOADS_ROOTS || '[]')];
@@ -88,7 +92,7 @@ function selectSharedExecution(enabled) {
88
92
  const next = enabled ? managedAppServer.client : null;
89
93
  if (sharedServer === next) return;
90
94
  sharedServer = next;
91
- sharedCatalog = sharedServer ? new SharedCatalog(sharedServer, desktopMonitor) : null;
95
+ sharedCatalog = sharedServer ? new SharedCatalog(sharedServer) : null;
92
96
  }
93
97
  const executionHealth = new ExecutionHealth(async () => {
94
98
  await managedAppServer.ensure();
@@ -225,11 +229,23 @@ async function handleRequest(request, response) {
225
229
  }
226
230
 
227
231
 
228
- const imageMatch = /^\/api\/images\/([a-f0-9]{64})$/.exec(url.pathname);
229
- if (request.method === 'GET' && imageMatch) {
230
- const image = attachmentCache.get(imageMatch[1]);
231
- if (!image) throw new RequestError(404, '图片缓存已失效,请重新读取对话。');
232
- sendJson(response, 200, { url: `data:${image.mimeType};base64,${image.buffer.toString('base64')}` });
232
+ const imageMatch = /^\/api\/images\/([a-f0-9]{64})$/.exec(url.pathname);
233
+ if (request.method === 'GET' && imageMatch) {
234
+ let image = attachmentCache.get(imageMatch[1])
235
+ || recoverLocalImage(imageMatch[1], url.searchParams.get('recovery') || '');
236
+ const contextThreadId = url.searchParams.get('threadId') || '';
237
+ const contextTurnId = url.searchParams.get('turnId') || '';
238
+ if (!image && sharedServer && contextThreadId && contextTurnId
239
+ && contextThreadId.length <= 128 && contextTurnId.length <= 128) {
240
+ try {
241
+ normalizeTurn(await sharedServer.turnContext(contextThreadId, contextTurnId));
242
+ image = attachmentCache.get(imageMatch[1]);
243
+ } catch {}
244
+ }
245
+ if (!image) throw new RequestError(404, '这张图片已失效,无法从工作站重新读取。');
246
+ const thumbnail = url.searchParams.get('variant') === 'thumbnail';
247
+ const asset = thumbnail ? await attachmentThumbnail(image) : { buffer: image.buffer, mimeType: image.mimeType };
248
+ sendJson(response, 200, { url: `data:${asset.mimeType};base64,${asset.buffer.toString('base64')}` });
233
249
  return;
234
250
  }
235
251
 
@@ -272,13 +288,14 @@ async function handleRequest(request, response) {
272
288
  projects: catalogProjects };
273
289
  }
274
290
  let appServerThreads = archived ? [] : appServerTasks.store.list().map(record => ({
275
- id: record.id,
276
- kind: 'codex',
277
- hostId: 'app-server',
278
- title: record.title,
279
- cwd: record.cwd,
280
- projectId: record.projectId,
281
- updatedAt: record.updatedAt,
291
+ id: record.id,
292
+ kind: 'codex',
293
+ hostId: 'app-server',
294
+ title: record.title,
295
+ cwd: record.cwd,
296
+ projectId: record.projectId,
297
+ updatedAt: record.updatedAt,
298
+ recencyAt: record.recencyAt ?? record.updatedAt,
282
299
  status: 'unknown',
283
300
  transport: 'app-server',
284
301
  }));
@@ -398,17 +415,20 @@ async function handleRequest(request, response) {
398
415
  if (request.method === 'GET' && timelineMatch) {
399
416
  const threadId = decodeURIComponent(timelineMatch[1]);
400
417
  if (!appServerTasks.owns(threadId) && usesSharedTask({ hostId: url.searchParams.get('hostId') })) {
401
- const timeline = normalizeSharedTimeline(await sharedServer.timeline(threadId));
418
+ const timeline = normalizeSharedTimeline(await sharedServer.timeline(threadId, sharedHistoryPageOptions()));
402
419
  const knownUpdatedAt = Number(url.searchParams.get('knownUpdatedAt'));
403
420
  const knownTurnId = url.searchParams.get('knownTurnId') || '';
404
421
  const knownTurnStatus = url.searchParams.get('knownTurnStatus') || '';
422
+ const knownHistoryView = url.searchParams.get('knownHistoryView') || '';
405
423
  const latest = timeline.turns.at(-1);
406
424
  const knownIndex = knownTurnId ? timeline.turns.findIndex(turn => turn.id === knownTurnId) : -1;
407
- const unchanged = knownIndex === timeline.turns.length - 1
425
+ const sameHistoryView = knownHistoryView === timeline.historyView;
426
+ const unchanged = sameHistoryView
427
+ && knownIndex === timeline.turns.length - 1
408
428
  && timeline.thread.status === 'idle' && latest?.status !== 'inProgress'
409
429
  && knownTurnStatus && knownTurnStatus !== 'inProgress'
410
430
  && (!Number.isFinite(knownUpdatedAt) || knownUpdatedAt <= 0 || timeline.thread.updatedAt === knownUpdatedAt);
411
- const partial = !unchanged && knownIndex >= 0
431
+ const partial = !unchanged && sameHistoryView && knownIndex >= 0
412
432
  ? { ...timeline, turns: timeline.turns.slice(knownIndex), partialFromTurnId: knownTurnId }
413
433
  : timeline;
414
434
  sendJson(response, 200, unchanged ? { ...timeline, turns: [], notModified: true } : partial);
@@ -474,23 +494,6 @@ async function handleRequest(request, response) {
474
494
  return;
475
495
  }
476
496
 
477
- const navigateMatch = /^\/api\/threads\/([^/]+)\/navigate$/.exec(url.pathname);
478
- if (request.method === 'POST' && navigateMatch) {
479
- const threadId = decodeURIComponent(navigateMatch[1]);
480
- if (sharedServer && !desktopMonitor.pipe) {
481
- sendJson(response, 200, { opened: false, reason: 'Desktop 工具通道尚未就绪' });
482
- return;
483
- }
484
- if (appServerTasks.owns(threadId)) {
485
- sendJson(response, 200, { opened: false, transport: 'app-server' });
486
- return;
487
- }
488
- sendToolResult(response, await callTool('navigate_to_codex_page', {
489
- threadId,
490
- }, 15_000));
491
- return;
492
- }
493
-
494
497
  const contextMatch = /^\/api\/threads\/([^/]+)\/context$/.exec(url.pathname);
495
498
  if (request.method === 'GET' && contextMatch) {
496
499
  const threadId = decodeURIComponent(contextMatch[1]);
@@ -502,11 +505,11 @@ async function handleRequest(request, response) {
502
505
  }
503
506
 
504
507
  const sharedAction = /^\/api\/threads\/([^/]+)\/(history|settings|permissions)$/.exec(url.pathname);
505
- if (sharedAction) {
508
+ if (sharedAction) {
506
509
  const id = decodeURIComponent(sharedAction[1]);
507
510
  if (!usesSharedTask({ hostId: url.searchParams.get('hostId') }) || appServerTasks.owns(id)) throw new RequestError(400, '此连接不支持该操作。');
508
511
  if (request.method === 'GET' && sharedAction[2] === 'history') {
509
- sendJson(response, 200, normalizeSharedTimeline(await sharedServer.history(id, url.searchParams.get('cursor'))));
512
+ sendJson(response, 200, normalizeSharedTimeline(await sharedServer.history(id, url.searchParams.get('cursor'), sharedHistoryPageOptions())));
510
513
  return;
511
514
  }
512
515
  if (request.method === 'POST' && sharedAction[2] === 'settings') {
@@ -520,10 +523,24 @@ async function handleRequest(request, response) {
520
523
  const body = await readJsonBody(request);
521
524
  sendJson(response, 200, await sharedServer.permissions(id, body.mode));
522
525
  return;
523
- }
524
- }
525
-
526
- const archiveMatch = /^\/api\/threads\/([^/]+)\/(archive|unarchive)$/.exec(url.pathname);
526
+ }
527
+ }
528
+
529
+ const sharedOperationGroup = /^\/api\/threads\/([^/]+)\/turns\/([^/]+)\/groups\/([^/]+)\/items$/.exec(url.pathname);
530
+ if (request.method === 'GET' && sharedOperationGroup) {
531
+ const threadId = decodeURIComponent(sharedOperationGroup[1]);
532
+ const turnId = decodeURIComponent(sharedOperationGroup[2]);
533
+ const groupId = decodeURIComponent(sharedOperationGroup[3]);
534
+ if (!usesSharedTask({ hostId: url.searchParams.get('hostId') }) || appServerTasks.owns(threadId)) {
535
+ throw new RequestError(400, '此连接不支持操作组分页。');
536
+ }
537
+ const page = await sharedServer.operationGroupItems(threadId, turnId, groupId, url.searchParams.get('offset'));
538
+ sendJson(response, 200, { turnId: page.turnId, groupId: page.groupId,
539
+ entries: page.items.map(normalizeItem).filter(Boolean), nextOffset: page.nextOffset, total: page.total });
540
+ return;
541
+ }
542
+
543
+ const archiveMatch = /^\/api\/threads\/([^/]+)\/(archive|unarchive)$/.exec(url.pathname);
527
544
  if (request.method === 'POST' && archiveMatch) {
528
545
  const threadId = decodeURIComponent(archiveMatch[1]);
529
546
  await requireExecutionWritable(threadId);
@@ -1232,17 +1249,17 @@ function appServerProjectTarget(projectId, projects) {
1232
1249
  function mergeThreadCatalog(catalog, appThreads, limit, sharedSnapshot = Boolean(sharedServer)) {
1233
1250
  const owned = new Map(appThreads.map(thread => [thread.id, thread]));
1234
1251
  const pinnedThreads = (catalog.pinnedThreads || []).map(thread => owned.get(thread.id) || thread);
1235
- const pinnedIds = new Set(pinnedThreads.map(thread => thread.id));
1236
- if (sharedSnapshot) {
1237
- const listed = new Set([...pinnedIds, ...(catalog.threads || []).map(thread => thread.id)]);
1238
- return { ...catalog, pinnedThreads, threads: [
1239
- ...(catalog.threads || []).map(thread => owned.get(thread.id) || thread),
1240
- ...appThreads.filter(thread => !listed.has(thread.id)),
1241
- ].slice(0, limit) };
1242
- }
1252
+ const pinnedIds = new Set(pinnedThreads.map(thread => thread.id));
1253
+ if (sharedSnapshot) {
1254
+ const listed = new Set([...pinnedIds, ...(catalog.threads || []).map(thread => thread.id)]);
1255
+ const threads = (catalog.threads || []).map(thread => owned.get(thread.id) || thread);
1256
+ threads.push(...appThreads.filter(thread => !listed.has(thread.id)));
1257
+ threads.sort((left, right) => threadRecency(right) - threadRecency(left));
1258
+ return { ...catalog, pinnedThreads: [], threads: threads.slice(0, limit) };
1259
+ }
1243
1260
  const desktopThreads = (catalog.threads || []).filter(thread => !owned.has(thread.id));
1244
1261
  const merged = [...desktopThreads, ...appThreads.filter(thread => !pinnedIds.has(thread.id))]
1245
- .sort((left, right) => Number(right.updatedAt || 0) - Number(left.updatedAt || 0))
1262
+ .sort((left, right) => threadRecency(right) - threadRecency(left))
1246
1263
  .slice(0, limit);
1247
1264
  return { ...catalog, pinnedThreads, threads: merged };
1248
1265
  }
@@ -1489,10 +1506,11 @@ function normalizeAppServerTimeline(thread) {
1489
1506
  }, false);
1490
1507
  }
1491
1508
 
1492
- function normalizeSharedTimeline({ thread, nextCursor, sync }) {
1493
- return { ...normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId: 'local', transport: 'shared-app-server' } }, false),
1494
- ...(nextCursor !== undefined ? { nextCursor } : {}), ...(sync ? { sync } : {}) };
1495
- }
1509
+ function normalizeSharedTimeline({ thread, nextCursor, sync }) {
1510
+ return { ...normalizeTimeline({ thread: { ...thread, title: thread.name || thread.title || '', hostId: 'local', transport: 'shared-app-server' } }, false),
1511
+ historyView: 'grouped-lazy-skeleton-v11',
1512
+ ...(nextCursor !== undefined ? { nextCursor } : {}), ...(sync ? { sync } : {}) };
1513
+ }
1496
1514
 
1497
1515
  function restoreEmptyTurnMessages(threadId, turns) {
1498
1516
  const missing = new Map(turns.filter(turn => turn.entries.length === 0).map(turn => [turn.id, turn]));
@@ -1539,6 +1557,7 @@ function normalizeThread(thread) {
1539
1557
  title: source.name || source.title || source.preview || '',
1540
1558
  status: statusText(source.status),
1541
1559
  updatedAt: Number(source.updatedAt) || 0,
1560
+ recencyAt: Number(source.recencyAt ?? source.updatedAt) || 0,
1542
1561
  projectId: source.projectId || '',
1543
1562
  cwd: source.cwd || '',
1544
1563
  hostId: source.hostId || '',
@@ -1548,21 +1567,39 @@ function normalizeThread(thread) {
1548
1567
  };
1549
1568
  }
1550
1569
 
1551
- function normalizeTurn(turn) {
1552
- const source = turn && typeof turn === 'object' ? turn : {};
1553
- return {
1554
- id: source.id || '',
1555
- status: statusText(source.status),
1570
+ function normalizeTurn(turn) {
1571
+ const source = turn && typeof turn === 'object' ? turn : {};
1572
+ const turnId = source.id || '';
1573
+ const entries = Array.isArray(source.items) ? source.items.map(normalizeItem).filter(Boolean) : [];
1574
+ for (const entry of entries) {
1575
+ const images = [...(entry.attachments || []), entry.image, ...Object.values(entry.imageAssets || {})].filter(Boolean);
1576
+ for (const image of images) if (image.remotePath && !image.recoveryTurnId) image.recoveryTurnId = turnId;
1577
+ }
1578
+ return {
1579
+ id: turnId,
1580
+ status: statusText(source.status),
1556
1581
  startedAt: source.startedAt || null,
1557
1582
  completedAt: source.completedAt || null,
1558
1583
  durationMs: source.durationMs ?? null,
1559
- error: source.error ?? null,
1560
- entries: Array.isArray(source.items) ? source.items.map(normalizeItem).filter(Boolean) : [],
1561
- };
1562
- }
1563
-
1564
- function normalizeItem(item) {
1565
- if (!item || typeof item !== 'object') return null;
1584
+ error: source.error ?? null,
1585
+ entries,
1586
+ };
1587
+ }
1588
+
1589
+ function normalizeItem(item) {
1590
+ if (!item || typeof item !== 'object') return null;
1591
+
1592
+ if (item.type === 'lazyOperationGroup') {
1593
+ return {
1594
+ type: 'activity',
1595
+ id: item.id || '',
1596
+ kind: 'lazyOperationGroup',
1597
+ turnId: item.turnId || '',
1598
+ groupId: item.groupId || '',
1599
+ title: item.title || '执行过程',
1600
+ count: Number(item.count) || 0,
1601
+ };
1602
+ }
1566
1603
 
1567
1604
  if (item.type === 'contextCompaction') {
1568
1605
  return {
@@ -1616,132 +1653,147 @@ function normalizeItem(item) {
1616
1653
  };
1617
1654
  }
1618
1655
 
1619
- if (item.type === 'reasoning') {
1620
- const detail = Array.isArray(item.summary)
1621
- ? item.summary.map(summaryText).filter(Boolean).join('\n')
1622
- : summaryText(item.summary);
1623
- return {
1624
- type: 'activity',
1625
- id: item.id || '',
1626
- kind: 'reasoning',
1627
- title: '思考摘要',
1628
- detail,
1629
- };
1630
- }
1631
-
1632
- if (item.type === 'commandExecution') {
1633
- return {
1634
- type: 'activity',
1635
- id: item.id || '',
1636
- kind: 'command',
1637
- title: '命令',
1638
- status: statusText(item.status),
1639
- command: item.command || '',
1640
- cwd: item.cwd || '',
1641
- exitCode: item.exitCode ?? null,
1642
- durationMs: item.durationMs ?? null,
1643
- output: item.aggregatedOutput || item.output?.text || '',
1644
- };
1645
- }
1656
+ if (item.type === 'reasoning') {
1657
+ const detail = Array.isArray(item.summary)
1658
+ ? item.summary.map(summaryText).filter(Boolean).join('\n')
1659
+ : summaryText(item.summary);
1660
+ return {
1661
+ type: 'activity',
1662
+ id: item.id || '',
1663
+ kind: 'reasoning',
1664
+ title: '思考摘要',
1665
+ detail,
1666
+ };
1667
+ }
1646
1668
 
1669
+ if (item.type === 'commandExecution') {
1670
+ return {
1671
+ type: 'activity',
1672
+ id: item.id || '',
1673
+ kind: 'command',
1674
+ title: '命令',
1675
+ titleOnly: true,
1676
+ status: statusText(item.status),
1677
+ command: item.command,
1678
+ cwd: item.cwd,
1679
+ exitCode: item.exitCode,
1680
+ durationMs: item.durationMs,
1681
+ };
1682
+ }
1683
+
1647
1684
  if (item.type === 'mcpToolCall') {
1648
- const result = normalizeToolResult(item.result, item.error);
1649
1685
  return {
1650
1686
  type: 'activity',
1651
1687
  id: item.id || '',
1652
- kind: 'tool',
1653
- title: '工具',
1654
- status: statusText(item.status),
1655
- server: item.server || '',
1656
- tool: item.tool || '',
1657
- arguments: item.arguments || {},
1658
- resultText: result.text,
1659
- errorText: result.error,
1660
- resources: result.resources,
1661
- mediaCount: result.mediaCount,
1662
- durationMs: item.durationMs ?? null,
1688
+ kind: 'tool',
1689
+ title: '工具',
1690
+ titleOnly: true,
1691
+ status: statusText(item.status),
1692
+ server: item.server,
1693
+ tool: item.tool,
1694
+ arguments: item.arguments,
1695
+ durationMs: item.durationMs,
1696
+ error: item.error,
1697
+ };
1698
+ }
1699
+
1700
+ if (item.type === 'dynamicToolCall') {
1701
+ return {
1702
+ type: 'activity',
1703
+ id: item.id || '',
1704
+ kind: 'tool',
1705
+ title: '工具',
1706
+ titleOnly: true,
1707
+ status: statusText(item.status),
1708
+ namespace: item.namespace,
1709
+ tool: item.tool,
1710
+ arguments: item.arguments,
1711
+ durationMs: item.durationMs,
1712
+ success: item.success,
1713
+ };
1714
+ }
1715
+
1716
+ if (item.type === 'fileChange') {
1717
+ return {
1718
+ type: 'activity',
1719
+ id: item.id || '',
1720
+ kind: 'fileChange',
1721
+ title: '文件修改',
1722
+ titleOnly: true,
1723
+ status: statusText(item.status),
1724
+ changes: Array.isArray(item.changes) ? item.changes : [],
1663
1725
  };
1664
1726
  }
1665
1727
 
1666
- if (item.type === 'fileChange') {
1667
- return {
1668
- type: 'activity',
1669
- id: item.id || '',
1670
- kind: 'fileChange',
1671
- title: '文件修改',
1672
- status: statusText(item.status),
1673
- changes: Array.isArray(item.changes) ? item.changes : [],
1674
- };
1675
- }
1676
-
1677
- if (item.type === 'webSearch') {
1678
- return {
1679
- type: 'activity',
1680
- id: item.id || '',
1681
- kind: 'webSearch',
1682
- title: '网页搜索',
1683
- query: item.query || item.action?.query || '',
1684
- };
1685
- }
1686
-
1687
- if (item.type === 'imageView') {
1688
- return {
1689
- type: 'activity',
1690
- id: item.id || '',
1691
- kind: 'imageView',
1692
- title: '查看图片',
1693
- path: item.path || '',
1694
- image: typeof item.path === 'string' && item.path
1695
- ? normalizeAttachment({ type: 'localImage', path: item.path })
1696
- : null,
1697
- };
1698
- }
1699
-
1700
- if (item.type === 'imageGeneration') {
1701
- let image = typeof item.savedPath === 'string' && item.savedPath
1702
- ? normalizeAttachment({ type: 'localImage', path: item.savedPath, name: '生成的图片' })
1703
- : null;
1704
- if ((!image || !image.available) && typeof item.result === 'string' && item.result.trim()) {
1705
- const result = item.result.trim();
1706
- const url = result.startsWith('data:image/') ? result : `data:image/png;base64,${result}`;
1707
- image = normalizeAttachment({ type: 'image', url, name: '生成的图片' });
1708
- }
1709
- return {
1710
- type: 'activity',
1711
- id: item.id || '',
1712
- kind: 'imageGeneration',
1713
- title: '生成图片',
1714
- status: statusText(item.status),
1715
- image,
1716
- failure: item.failure || null,
1717
- transparentBackground: item.transparentBackground === true,
1718
- };
1719
- }
1720
-
1721
- if (item.type === 'subAgentActivity') {
1722
- return {
1723
- type: 'activity',
1724
- id: item.id || '',
1725
- kind: 'subAgent',
1726
- title: '子任务',
1727
- status: statusText(item.kind),
1728
- agentPath: item.agentPath || '',
1729
- agentThreadId: item.agentThreadId || '',
1730
- };
1731
- }
1732
-
1733
- if (item.type === 'collabAgentToolCall') {
1734
- return {
1735
- type: 'activity',
1736
- id: item.id || '',
1737
- kind: 'collaboration',
1738
- title: '任务协作',
1739
- status: statusText(item.status),
1740
- tool: item.tool || '',
1741
- prompt: item.prompt || '',
1742
- receiverThreadIds: Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : [],
1743
- };
1744
- }
1728
+ if (item.type === 'webSearch') {
1729
+ return {
1730
+ type: 'activity',
1731
+ id: item.id || '',
1732
+ kind: 'webSearch',
1733
+ title: '网页搜索',
1734
+ titleOnly: true,
1735
+ query: item.query || item.action?.query || '',
1736
+ ...(item.action && typeof item.action === 'object' ? { action: item.action } : {}),
1737
+ };
1738
+ }
1739
+
1740
+ if (item.type === 'imageView') {
1741
+ return {
1742
+ type: 'activity',
1743
+ id: item.id || '',
1744
+ kind: 'imageView',
1745
+ title: '查看图片',
1746
+ titleOnly: true,
1747
+ path: item.path || '',
1748
+ image: typeof item.path === 'string' && item.path
1749
+ ? normalizeAttachment({ type: 'localImage', path: item.path })
1750
+ : null,
1751
+ };
1752
+ }
1753
+
1754
+ if (item.type === 'imageGeneration') {
1755
+ const image = typeof item.savedPath === 'string' && item.savedPath
1756
+ ? normalizeAttachment({ type: 'localImage', path: item.savedPath, name: '生成的图片' })
1757
+ : null;
1758
+ return {
1759
+ type: 'activity',
1760
+ id: item.id || '',
1761
+ kind: 'imageGeneration',
1762
+ title: '生成图片',
1763
+ titleOnly: true,
1764
+ status: statusText(item.status),
1765
+ image,
1766
+ failure: item.failure || null,
1767
+ transparentBackground: item.transparentBackground === true,
1768
+ };
1769
+ }
1770
+
1771
+ if (item.type === 'subAgentActivity') {
1772
+ return {
1773
+ type: 'activity',
1774
+ id: item.id || '',
1775
+ kind: 'subAgent',
1776
+ title: '子任务',
1777
+ titleOnly: true,
1778
+ status: statusText(item.kind),
1779
+ agentPath: item.agentPath || '',
1780
+ agentThreadId: item.agentThreadId || '',
1781
+ };
1782
+ }
1783
+
1784
+ if (item.type === 'collabAgentToolCall') {
1785
+ return {
1786
+ type: 'activity',
1787
+ id: item.id || '',
1788
+ kind: 'collaboration',
1789
+ title: '任务协作',
1790
+ titleOnly: true,
1791
+ status: statusText(item.status),
1792
+ tool: item.tool || '',
1793
+ prompt: item.prompt || '',
1794
+ receiverThreadIds: Array.isArray(item.receiverThreadIds) ? item.receiverThreadIds : [],
1795
+ };
1796
+ }
1745
1797
 
1746
1798
  return {
1747
1799
  type: 'activity',
@@ -1753,44 +1805,19 @@ function normalizeItem(item) {
1753
1805
  };
1754
1806
  }
1755
1807
 
1756
- function normalizeToolResult(result, fallbackError) {
1757
- const text = [];
1758
- const resources = [];
1759
- let mediaCount = 0;
1760
- const seen = new Set();
1761
- const addText = value => {
1762
- const normalized = typeof value === 'string' ? value.trim() : '';
1763
- if (normalized && !seen.has(normalized)) { seen.add(normalized); text.push(normalized); }
1764
- };
1765
- const visit = (value, depth = 0) => {
1766
- if (value == null || depth > 4) return;
1767
- if (typeof value === 'string') { addText(value); return; }
1768
- if (Array.isArray(value)) { for (const item of value) visit(item, depth + 1); return; }
1769
- if (typeof value !== 'object') return;
1770
- if (value.type === 'image' || value.type === 'audio') { mediaCount += 1; return; }
1771
- if (value.type === 'resource_link') {
1772
- resources.push({ name: value.title || value.name || '资源', uri: value.uri || '', description: value.description || '' });
1773
- return;
1774
- }
1775
- if (value.type === 'resource' && value.resource) {
1776
- const resource = value.resource;
1777
- if (typeof resource.text === 'string') addText(resource.text);
1778
- else if (resource.uri) resources.push({ name: resource.title || resource.name || '资源', uri: resource.uri, description: resource.description || '' });
1779
- return;
1780
- }
1781
- if (typeof value.text === 'string') addText(value.text);
1782
- if (typeof value.message === 'string') addText(value.message);
1783
- if (value.content) visit(value.content, depth + 1);
1784
- if (value.raw) visit(value.raw, depth + 1);
1808
+ function sharedHistoryPageOptions() {
1809
+ return {
1810
+ maxTurns: SHARED_HISTORY_PAGE_TURNS,
1811
+ maxBytes: SHARED_HISTORY_PAGE_BYTES,
1812
+ measure: value => Buffer.byteLength(JSON.stringify(normalizeSharedTimeline(value))),
1785
1813
  };
1786
- visit(result);
1787
- const resultError = result?.type === 'error' ? result.error : null;
1788
- const errorValue = fallbackError || resultError;
1789
- const error = typeof errorValue === 'string' ? errorValue : errorValue?.message || errorValue?.text || '';
1790
- return { text: text.join('\n\n'), error, resources, mediaCount };
1791
1814
  }
1792
-
1793
- function normalizeAttachments(content) {
1815
+
1816
+ function threadRecency(thread) {
1817
+ return Number(thread?.recencyAt ?? thread?.updatedAt) || 0;
1818
+ }
1819
+
1820
+ function normalizeAttachments(content) {
1794
1821
  if (!Array.isArray(content)) return [];
1795
1822
  const items = content.filter(item => item && !['text', 'input_text'].includes(item.type)).map(item => ({ ...item }));
1796
1823
  const text = content.filter(item => ['text', 'input_text'].includes(item?.type)).map(item => item.text || '').join('\n');
@@ -1833,18 +1860,19 @@ function markdownImageAssets(text) {
1833
1860
  return assets;
1834
1861
  }
1835
1862
 
1836
- function markdownFileAssets(text) {
1863
+ function markdownFileAssets(text) {
1837
1864
  const assets = Object.create(null);
1838
1865
  function visit(tokens) {
1839
1866
  for (const token of tokens) {
1840
1867
  if (token.type === 'link_open') {
1841
1868
  const value = token.attrGet('href');
1842
1869
  if (value && !Object.hasOwn(assets, value)) {
1843
- let localPath;
1844
- try { localPath = decodeURIComponent(value); } catch { localPath = ''; }
1845
- if (/^\/?[a-z]:[\\/]/i.test(localPath)) {
1846
- localPath = localPath.replace(/^\/(?=[a-z]:)/i, '');
1847
- const asset = rememberLocalFile(localPath);
1870
+ let localPath;
1871
+ try { localPath = decodeURIComponent(value); } catch { localPath = ''; }
1872
+ if (/^\/?[a-z]:[\\/]/i.test(localPath)) {
1873
+ localPath = localPath.replace(/^\/(?=[a-z]:)/i, '');
1874
+ localPath = localPath.replace(/:(\d+)(?::\d+)?$/, '');
1875
+ const asset = rememberLocalFile(localPath);
1848
1876
  if (asset) assets[value] = { remotePath: asset.id, name: asset.name, size: asset.size };
1849
1877
  }
1850
1878
  }
@@ -1914,18 +1942,23 @@ function normalizeAttachment(item) {
1914
1942
  name,
1915
1943
  mimeType: cached?.mimeType || item.mimeType || '',
1916
1944
  available: Boolean(cached),
1917
- remotePath: cached ? cached.id : '',
1945
+ remotePath: cached ? cached.id : '',
1946
+ recoveryToken: cached?.recoveryToken || '',
1918
1947
  url: '',
1919
1948
  thumbnailUrl: '',
1920
1949
  };
1921
1950
  }
1922
1951
 
1923
- function cacheLocalImage(filePath, name) {
1924
- try {
1925
- const stat = fs.statSync(filePath);
1926
- const mimeType = imageMimeType(filePath);
1927
- if (!stat.isFile() || !mimeType || stat.size > MAX_ATTACHMENT_BYTES) return null;
1928
- return rememberAttachment(fs.readFileSync(filePath), mimeType, name);
1952
+ function cacheLocalImage(filePath, name) {
1953
+ try {
1954
+ const resolved = path.resolve(filePath);
1955
+ const stat = fs.statSync(resolved);
1956
+ const mimeType = imageMimeType(resolved);
1957
+ if (!stat.isFile() || !mimeType || stat.size > MAX_ATTACHMENT_BYTES) return null;
1958
+ const cached = rememberAttachment(fs.readFileSync(resolved), mimeType, name,
1959
+ localImageRecoveryToken(resolved, stat));
1960
+ void attachmentThumbnail(cached).catch(() => {});
1961
+ return cached;
1929
1962
  } catch {
1930
1963
  return null;
1931
1964
  }
@@ -1943,16 +1976,65 @@ function cacheDataImage(dataUrl, name) {
1943
1976
  }
1944
1977
  }
1945
1978
 
1946
- function rememberAttachment(buffer, mimeType, name) {
1947
- const id = crypto.createHash('sha256').update(buffer).digest('hex');
1948
- if (!attachmentCache.has(id)) {
1979
+ function rememberAttachment(buffer, mimeType, name, recoveryToken = '') {
1980
+ const id = crypto.createHash('sha256').update(buffer).digest('hex');
1981
+ if (!attachmentCache.has(id)) {
1949
1982
  while (attachmentCache.size >= MAX_CACHED_ATTACHMENTS) {
1950
1983
  attachmentCache.delete(attachmentCache.keys().next().value);
1951
- }
1952
- attachmentCache.set(id, { id, buffer, mimeType, name });
1953
- }
1954
- return attachmentCache.get(id);
1955
- }
1984
+ }
1985
+ attachmentCache.set(id, { id, buffer, mimeType, name, recoveryToken });
1986
+ } else if (recoveryToken && !attachmentCache.get(id).recoveryToken) {
1987
+ attachmentCache.get(id).recoveryToken = recoveryToken;
1988
+ }
1989
+ return attachmentCache.get(id);
1990
+ }
1991
+
1992
+ function localImageRecoveryToken(filePath, stat) {
1993
+ const iv = crypto.randomBytes(12);
1994
+ const cipher = crypto.createCipheriv('aes-256-gcm', imageCapabilityKey, iv);
1995
+ const payload = Buffer.from(JSON.stringify([filePath, stat.size, stat.mtimeMs]));
1996
+ const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
1997
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64url');
1998
+ }
1999
+
2000
+ function recoverLocalImage(id, token) {
2001
+ if (!token || token.length > 2048) return null;
2002
+ try {
2003
+ const value = Buffer.from(token, 'base64url');
2004
+ if (value.length < 29) return null;
2005
+ const decipher = crypto.createDecipheriv('aes-256-gcm', imageCapabilityKey, value.subarray(0, 12));
2006
+ decipher.setAuthTag(value.subarray(12, 28));
2007
+ const [filePath, size, mtimeMs] = JSON.parse(Buffer.concat([
2008
+ decipher.update(value.subarray(28)), decipher.final(),
2009
+ ]).toString('utf8'));
2010
+ if (typeof filePath !== 'string' || !path.isAbsolute(filePath)
2011
+ || !Number.isSafeInteger(size) || size <= 0 || size > MAX_ATTACHMENT_BYTES
2012
+ || typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return null;
2013
+ const stat = fs.statSync(filePath);
2014
+ const mimeType = imageMimeType(filePath);
2015
+ if (!stat.isFile() || stat.size !== size || stat.mtimeMs !== mtimeMs || !mimeType) return null;
2016
+ const buffer = fs.readFileSync(filePath);
2017
+ if (crypto.createHash('sha256').update(buffer).digest('hex') !== id) return null;
2018
+ return rememberAttachment(buffer, mimeType, path.basename(filePath), token);
2019
+ } catch {
2020
+ return null;
2021
+ }
2022
+ }
2023
+
2024
+ async function attachmentThumbnail(image) {
2025
+ if (!image.thumbnail) {
2026
+ image.thumbnail = sharp(image.buffer, { limitInputPixels: 40_000_000 }).rotate()
2027
+ .resize({ width: 640, height: 640, fit: 'inside', withoutEnlargement: true })
2028
+ .webp({ quality: 78, effort: 4 })
2029
+ .toBuffer()
2030
+ .then(buffer => ({ buffer, mimeType: 'image/webp' }))
2031
+ .catch(error => {
2032
+ image.thumbnail = null;
2033
+ throw error;
2034
+ });
2035
+ }
2036
+ return image.thumbnail;
2037
+ }
1956
2038
 
1957
2039
  function imageMimeType(filePath) {
1958
2040
  switch (path.extname(filePath).toLowerCase()) {
@@ -1999,18 +2081,18 @@ function extractDelegatedInput(value) {
1999
2081
  .trim();
2000
2082
  }
2001
2083
 
2002
- function delegatedOutputText(output) {
2084
+ function delegatedOutputText(output) {
2003
2085
  if (typeof output === 'string') return output;
2004
2086
  return output && typeof output.text === 'string' ? output.text : '';
2005
- }
2006
-
2007
- function summaryText(value) {
2008
- if (typeof value === 'string') return value;
2009
- if (value && typeof value === 'object') return value.text || value.summary || JSON.stringify(value);
2010
- return '';
2011
- }
2012
-
2013
- function statusText(value) {
2087
+ }
2088
+
2089
+ function summaryText(value) {
2090
+ if (typeof value === 'string') return value;
2091
+ if (value && typeof value === 'object') return value.text || value.summary || JSON.stringify(value);
2092
+ return '';
2093
+ }
2094
+
2095
+ function statusText(value) {
2014
2096
  if (typeof value === 'string') return value;
2015
2097
  if (value && typeof value === 'object') return value.type || value.status || 'unknown';
2016
2098
  return value == null ? 'unknown' : String(value);