termdock 1.4.97 → 1.4.98

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 (80) hide show
  1. package/dist/client/assets/{ModelPreview-DCC_cgBs.js → ModelPreview-Drqv0OTg.js} +165 -165
  2. package/dist/client/assets/{abnfDiagram-VRR7QNED-ByotYhVU.js → abnfDiagram-VRR7QNED-Cl8CT45X.js} +1 -1
  3. package/dist/client/assets/{arc-oaeK9Fn4.js → arc-2Sc638oe.js} +1 -1
  4. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-CnxQD5DT.js → architectureDiagram-ZJ3FMSHR-CNH9w9eN.js} +1 -1
  5. package/dist/client/assets/{blockDiagram-677ZJIJ3-Ds7-Gi7B.js → blockDiagram-677ZJIJ3-ClvE_yP1.js} +1 -1
  6. package/dist/client/assets/{c4Diagram-LMCZKHZV-BdPTvWqP.js → c4Diagram-LMCZKHZV-CxIMU8l9.js} +1 -1
  7. package/dist/client/assets/channel-DPt818Jx.js +1 -0
  8. package/dist/client/assets/{chunk-2Q5K7J3B-BHB6J78_.js → chunk-2Q5K7J3B-CdvXmc6Z.js} +1 -1
  9. package/dist/client/assets/{chunk-32BRIVSS-hzsoKeck.js → chunk-32BRIVSS-B1dA4l5A.js} +1 -1
  10. package/dist/client/assets/{chunk-5VM5RSS4-MGUgwiHw.js → chunk-5VM5RSS4-CVk_pzpf.js} +1 -1
  11. package/dist/client/assets/{chunk-EX3LRPZG-BKnPJAiI.js → chunk-EX3LRPZG-B46zhCa0.js} +1 -1
  12. package/dist/client/assets/{chunk-JWPE2WC7-Dfdv_575.js → chunk-JWPE2WC7-1wRPE3eO.js} +1 -1
  13. package/dist/client/assets/{chunk-MOJQB5TN-CKEHojX9.js → chunk-MOJQB5TN-BzPrhNIS.js} +1 -1
  14. package/dist/client/assets/{chunk-RYQCIY6F-DqWvLIet.js → chunk-RYQCIY6F-D0sDGnBf.js} +1 -1
  15. package/dist/client/assets/{chunk-V7JOEXUC-BtutDi_L.js → chunk-V7JOEXUC-BRo0103H.js} +1 -1
  16. package/dist/client/assets/{chunk-VR4S4FIN-B2cuDdQf.js → chunk-VR4S4FIN-CW-wSkDE.js} +1 -1
  17. package/dist/client/assets/{chunk-XXDRQBXY-udLcfG1V.js → chunk-XXDRQBXY-BuWiQ0uq.js} +1 -1
  18. package/dist/client/assets/classDiagram-OUVF2IWQ-C2hZbTnO.js +1 -0
  19. package/dist/client/assets/classDiagram-v2-EOCWNBFH-C2hZbTnO.js +1 -0
  20. package/dist/client/assets/{cose-bilkent-JH36ORCC-DqOCMKxN.js → cose-bilkent-JH36ORCC-WzKVd03Q.js} +1 -1
  21. package/dist/client/assets/{cynefin-VYW2F7L2-Drck5nNi.js → cynefin-VYW2F7L2-CfXYAqnq.js} +1 -1
  22. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHbsvV_H.js → cynefinDiagram-TSTJHNR4-BnJGle_X.js} +1 -1
  23. package/dist/client/assets/{dagre-VKFMJZFB-27UzxTIz.js → dagre-VKFMJZFB-CDDlGCQy.js} +1 -1
  24. package/dist/client/assets/{diagram-FQU43EPY-yrQuBDwj.js → diagram-FQU43EPY-t8DNZS7P.js} +1 -1
  25. package/dist/client/assets/{diagram-G47NLZAW-BZiJ5Qye.js → diagram-G47NLZAW-CZpQd91k.js} +1 -1
  26. package/dist/client/assets/{diagram-NH7WQ7WH-BRLMfHoI.js → diagram-NH7WQ7WH-OET0EFlt.js} +1 -1
  27. package/dist/client/assets/{diagram-OA4YK3LP-ChR7fBMa.js → diagram-OA4YK3LP-COgju4NX.js} +1 -1
  28. package/dist/client/assets/{diagram-WEI45ONY-beJeXBUS.js → diagram-WEI45ONY-6gNSoIek.js} +1 -1
  29. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-BEnzKQgq.js → ebnfDiagram-CCIWWBDH-CTlIMc8W.js} +1 -1
  30. package/dist/client/assets/{erDiagram-Q63AITRT-abMcC3O5.js → erDiagram-Q63AITRT-BDsEvZvq.js} +1 -1
  31. package/dist/client/assets/{flowDiagram-23GEKE2U-Cf_z-brB.js → flowDiagram-23GEKE2U-ggl4zaY9.js} +1 -1
  32. package/dist/client/assets/{ganttDiagram-NO4QXBWP-CLGNuYtO.js → ganttDiagram-NO4QXBWP-D9Ca5oWS.js} +1 -1
  33. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DAO69bU6.js → gitGraphDiagram-IHSO6WYX-THPUH6Bg.js} +1 -1
  34. package/dist/client/assets/index-BuMMW0wS.js +451 -0
  35. package/dist/client/assets/index-RO2b079_.css +1 -0
  36. package/dist/client/assets/{infoDiagram-FWYZ7A6U-ByWatnBT.js → infoDiagram-FWYZ7A6U-CKF-vSTz.js} +1 -1
  37. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Bxt5Yqg4.js → ishikawaDiagram-FXEZZL3T-CBFlmgI3.js} +1 -1
  38. package/dist/client/assets/{journeyDiagram-5HDEW3XC-Dafr7Sfu.js → journeyDiagram-5HDEW3XC-lbXIqX5a.js} +1 -1
  39. package/dist/client/assets/{kanban-definition-HUTT4EX6-D1Kng2jH.js → kanban-definition-HUTT4EX6-BUuO_e54.js} +1 -1
  40. package/dist/client/assets/{linear-BDTQPYjb.js → linear-D_uoNzR8.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-BqsgBUDL.js → mermaid.core-D4Yy5OxZ.js} +4 -4
  42. package/dist/client/assets/{mindmap-definition-LN4V7U3C-WlWlxPeq.js → mindmap-definition-LN4V7U3C-B0Ml-jaq.js} +1 -1
  43. package/dist/client/assets/{pegDiagram-2B236MQR-CeuVjVf0.js → pegDiagram-2B236MQR-CKF9cuqx.js} +1 -1
  44. package/dist/client/assets/{pieDiagram-ENE6RG2P-DdLAXZnA.js → pieDiagram-ENE6RG2P-DL3XisAD.js} +1 -1
  45. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BSI4gC6Y.js → quadrantDiagram-ABIIQ3AL-DrsHXXqd.js} +1 -1
  46. package/dist/client/assets/{railroadDiagram-RFXS5EU6-DHGWP4uI.js → railroadDiagram-RFXS5EU6-DMAz8hLg.js} +1 -1
  47. package/dist/client/assets/{requirementDiagram-TGXJPOKE--sP3n7Ar.js → requirementDiagram-TGXJPOKE-CCHretnQ.js} +1 -1
  48. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-CTBy3q73.js → sankeyDiagram-HTMAVEWB-Btb7ZNwU.js} +1 -1
  49. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-D2p6MaPI.js → sequenceDiagram-DBY2YBRQ-DcZk1PdX.js} +1 -1
  50. package/dist/client/assets/{sizeCapture-X5ZJPWSS-C-8i892J.js → sizeCapture-X5ZJPWSS-vMYb_VZU.js} +1 -1
  51. package/dist/client/assets/{stateDiagram-2N3HPSRC-BfWCgCA0.js → stateDiagram-2N3HPSRC-CltexqJj.js} +1 -1
  52. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BslbFNw3.js +1 -0
  53. package/dist/client/assets/{swimlanes-5IMT3BWC-Dnftro8u.js → swimlanes-5IMT3BWC-DPnwlpXX.js} +2 -2
  54. package/dist/client/assets/swimlanesDiagram-G3AALYLV-B0V1XVQb.js +8 -0
  55. package/dist/client/assets/{timeline-definition-FHXFAJF6-D7l7Wf7T.js → timeline-definition-FHXFAJF6-l78vfKln.js} +1 -1
  56. package/dist/client/assets/{vennDiagram-L72KCM5P-ClDuwHNb.js → vennDiagram-L72KCM5P-BggNEe_f.js} +1 -1
  57. package/dist/client/assets/{wardleyDiagram-EHGQE667-BZ3yiEXX.js → wardleyDiagram-EHGQE667-v04USz2e.js} +1 -1
  58. package/dist/client/assets/{xychartDiagram-FW5EYKEG-BGZ_BXST.js → xychartDiagram-FW5EYKEG-CInHBOpK.js} +1 -1
  59. package/dist/client/index.html +2 -2
  60. package/dist/client/sw-notifications.js +7 -3
  61. package/dist/client/sw.js +1 -1
  62. package/dist/server/agent/automationStore.js +151 -0
  63. package/dist/server/agent/collaborationStore.js +145 -0
  64. package/dist/server/agent/installers.js +50 -12
  65. package/dist/server/agent/plugins.js +9 -0
  66. package/dist/server/agent/registry.js +1 -0
  67. package/dist/server/agent/resumeHistory.js +100 -0
  68. package/dist/server/agent/resumePersistence.js +35 -0
  69. package/dist/server/agent/sessionSearchStore.js +96 -0
  70. package/dist/server/cli.js +139 -0
  71. package/dist/server/routes/terminal.js +503 -16
  72. package/package.json +1 -1
  73. package/runtime-manifest.json +2 -2
  74. package/dist/client/assets/channel-Cpdbh5br.js +0 -1
  75. package/dist/client/assets/classDiagram-OUVF2IWQ-DWre1xlG.js +0 -1
  76. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DWre1xlG.js +0 -1
  77. package/dist/client/assets/index-BYhY8AKn.css +0 -1
  78. package/dist/client/assets/index-CshWxisE.js +0 -451
  79. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DzD8Rob4.js +0 -1
  80. package/dist/client/assets/swimlanesDiagram-G3AALYLV-D8QuXUOK.js +0 -8
@@ -0,0 +1,145 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ const MAX_MESSAGES = 2_000;
5
+ const MESSAGE_KINDS = new Set(['message', 'ask', 'reply', 'task', 'handoff', 'done']);
6
+ export class CollaborationStore {
7
+ filePath;
8
+ document = { version: 2, groups: [], messages: [] };
9
+ constructor(filePath) {
10
+ this.filePath = filePath;
11
+ try {
12
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
13
+ if (Array.isArray(parsed.groups)) {
14
+ this.document.groups = parsed.groups.filter((group) => group && typeof group.id === 'string' && Array.isArray(group.sessionIds));
15
+ }
16
+ if (Array.isArray(parsed.messages)) {
17
+ this.document.messages = parsed.messages.filter((message) => message && typeof message.id === 'string' && typeof message.toSessionId === 'string' && MESSAGE_KINDS.has(message.kind)).slice(-MAX_MESSAGES);
18
+ }
19
+ }
20
+ catch { /* first run */ }
21
+ }
22
+ list() {
23
+ return [...this.document.groups].sort((a, b) => b.updatedAt - a.updatedAt);
24
+ }
25
+ getGroup(id) {
26
+ return this.document.groups.find((group) => group.id === id) ?? null;
27
+ }
28
+ save(input) {
29
+ const now = Date.now();
30
+ const existing = input.id ? this.document.groups.find((group) => group.id === input.id) : null;
31
+ const group = {
32
+ id: existing?.id ?? crypto.randomUUID(),
33
+ name: input.name.trim(),
34
+ sessionIds: Array.from(new Set(input.sessionIds.map((id) => id.trim()).filter(Boolean))),
35
+ createdAt: existing?.createdAt ?? now,
36
+ updatedAt: now,
37
+ };
38
+ this.document.groups = existing
39
+ ? this.document.groups.map((candidate) => candidate.id === group.id ? group : candidate)
40
+ : [...this.document.groups, group];
41
+ this.persist();
42
+ return group;
43
+ }
44
+ remove(id) {
45
+ const before = this.document.groups.length;
46
+ this.document.groups = this.document.groups.filter((group) => group.id !== id);
47
+ if (before === this.document.groups.length)
48
+ return false;
49
+ this.document.messages = this.document.messages.filter((message) => message.groupId !== id);
50
+ this.persist();
51
+ return true;
52
+ }
53
+ removeSession(sessionId) {
54
+ const affected = this.document.groups.filter((group) => group.sessionIds.includes(sessionId));
55
+ if (affected.length === 0)
56
+ return { updatedGroups: 0, dissolvedGroups: 0 };
57
+ const dissolvedIds = new Set(affected.filter((group) => group.sessionIds.length <= 2).map((group) => group.id));
58
+ const now = Date.now();
59
+ this.document.groups = this.document.groups.flatMap((group) => {
60
+ if (!group.sessionIds.includes(sessionId))
61
+ return [group];
62
+ if (dissolvedIds.has(group.id))
63
+ return [];
64
+ return [{ ...group, sessionIds: group.sessionIds.filter((id) => id !== sessionId), updatedAt: now }];
65
+ });
66
+ this.document.messages = this.document.messages.filter((message) => !dissolvedIds.has(message.groupId)
67
+ && message.fromSessionId !== sessionId
68
+ && message.toSessionId !== sessionId);
69
+ this.persist();
70
+ return { updatedGroups: affected.length - dissolvedIds.size, dissolvedGroups: dissolvedIds.size };
71
+ }
72
+ clear() {
73
+ if (this.document.groups.length === 0 && this.document.messages.length === 0)
74
+ return;
75
+ this.document = { version: 2, groups: [], messages: [] };
76
+ this.persist();
77
+ }
78
+ groupsForSession(sessionId) {
79
+ return this.document.groups.filter((group) => group.sessionIds.includes(sessionId));
80
+ }
81
+ send(input) {
82
+ const group = this.getGroup(input.groupId);
83
+ if (!group)
84
+ throw new Error('协作组不存在');
85
+ if (!MESSAGE_KINDS.has(input.kind))
86
+ throw new Error('消息类型无效');
87
+ const content = input.content.trim();
88
+ if (!content)
89
+ throw new Error('消息不能为空');
90
+ const recipients = Array.from(new Set(input.toSessionIds)).filter((id) => group.sessionIds.includes(id) && id !== input.fromSessionId);
91
+ if (recipients.length === 0)
92
+ throw new Error('没有有效的接收会话');
93
+ const now = Date.now();
94
+ const threadId = input.threadId?.trim() || crypto.randomUUID();
95
+ const messages = recipients.map((toSessionId) => ({
96
+ id: crypto.randomUUID(), groupId: group.id, fromSessionId: input.fromSessionId,
97
+ toSessionId, kind: input.kind, content, threadId,
98
+ replyTo: input.replyTo?.trim() || null, status: 'pending', createdAt: now,
99
+ deliveredAt: null, readAt: null,
100
+ }));
101
+ this.document.messages = [...this.document.messages, ...messages].slice(-MAX_MESSAGES);
102
+ this.persist();
103
+ return messages;
104
+ }
105
+ listMessages(groupId, limit = 200) {
106
+ return this.document.messages.filter((message) => message.groupId === groupId).slice(-Math.max(1, Math.min(limit, 500)));
107
+ }
108
+ getMessage(id) {
109
+ return this.document.messages.find((message) => message.id === id) ?? null;
110
+ }
111
+ inbox(sessionId, options = {}) {
112
+ return this.document.messages
113
+ .filter((message) => message.toSessionId === sessionId && (!options.pendingOnly || message.status === 'pending'))
114
+ .slice(-Math.max(1, Math.min(options.limit ?? 50, 200)));
115
+ }
116
+ markDelivered(messageIds) {
117
+ return this.updateStatus(messageIds, 'delivered');
118
+ }
119
+ markRead(messageIds) {
120
+ return this.updateStatus(messageIds, 'read');
121
+ }
122
+ updateStatus(messageIds, status) {
123
+ const ids = new Set(messageIds);
124
+ const now = Date.now();
125
+ const changed = [];
126
+ this.document.messages = this.document.messages.map((message) => {
127
+ if (!ids.has(message.id))
128
+ return message;
129
+ const updated = status === 'read'
130
+ ? { ...message, status, deliveredAt: message.deliveredAt ?? now, readAt: now }
131
+ : message.status === 'pending' ? { ...message, status, deliveredAt: now } : message;
132
+ changed.push(updated);
133
+ return updated;
134
+ });
135
+ if (changed.length > 0)
136
+ this.persist();
137
+ return changed;
138
+ }
139
+ persist() {
140
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
141
+ const temporaryPath = `${this.filePath}.${process.pid}.tmp`;
142
+ fs.writeFileSync(temporaryPath, JSON.stringify(this.document, null, 2), { mode: 0o600 });
143
+ fs.renameSync(temporaryPath, this.filePath);
144
+ }
145
+ }
@@ -371,7 +371,7 @@ function grokHooksJson() {
371
371
  * from that directory — so the plugin bridges its events onto the same
372
372
  * emitter. Inert outside termdock (both the JS guard and the emitter check
373
373
  * TERMDOCK). */
374
- function opencodePluginJs() {
374
+ export function opencodePluginJs() {
375
375
  const prefix = JSON.stringify(`"${process.execPath}" "${resolveHookScript().script}" agent-hook opencode `);
376
376
  return `// termdock agent-hook opencode bridge — generated by termdock, do not edit.
377
377
  // Bridges OpenCode plugin events onto the termdock agent-hook emitter,
@@ -379,26 +379,64 @@ function opencodePluginJs() {
379
379
  export const TermdockPresence = async ({ $ }) => {
380
380
  if (!process.env["TERMDOCK"]) return {}
381
381
  const cmd = ${prefix}
382
- const emit = (event) => $\`sh -c \${cmd + event}\`.quiet().nothrow()
383
-
384
- // Plugin load = the agent is running in this pane.
385
- await emit("session-start")
382
+ const announced = new Set()
383
+ const childSessions = new Set()
384
+ const emit = (event, sessionID) => {
385
+ const payload = JSON.stringify(sessionID ? { session_id: sessionID } : {})
386
+ return $\`printf "%s" \${payload} | sh -c \${cmd + event}\`.quiet().nothrow()
387
+ }
388
+ const ensureSession = async (sessionID) => {
389
+ if (!sessionID || childSessions.has(sessionID) || announced.has(sessionID)) return false
390
+ announced.add(sessionID)
391
+ await emit("session-start", sessionID)
392
+ return true
393
+ }
394
+ const observeSessionInfo = async (info) => {
395
+ const sessionID = info?.id
396
+ if (!sessionID) return
397
+ if (info.parentID) {
398
+ childSessions.add(sessionID)
399
+ announced.delete(sessionID)
400
+ return
401
+ }
402
+ await ensureSession(sessionID)
403
+ }
386
404
 
387
405
  return {
388
406
  dispose: async () => {
389
- await emit("session-end")
407
+ await Promise.all([...announced].map((sessionID) => emit("session-end", sessionID)))
390
408
  },
391
- "tool.execute.before": async () => {
392
- await emit("prompt-submit")
409
+ "chat.message": async ({ sessionID }) => {
410
+ await ensureSession(sessionID)
411
+ if (!childSessions.has(sessionID)) await emit("prompt-submit", sessionID)
393
412
  },
394
- "permission.ask": async () => {
395
- await emit("permission-request")
413
+ "tool.execute.before": async ({ sessionID }) => {
414
+ await ensureSession(sessionID)
415
+ if (!childSessions.has(sessionID)) await emit("prompt-submit", sessionID)
416
+ },
417
+ "permission.ask": async ({ sessionID }) => {
418
+ await ensureSession(sessionID)
419
+ if (!childSessions.has(sessionID)) await emit("permission-request", sessionID)
396
420
  },
397
421
  event: async ({ event }) => {
422
+ if (event.type === "session.created" || event.type === "session.updated") {
423
+ await observeSessionInfo(event.properties?.info)
424
+ return
425
+ }
426
+ if (event.type === "session.deleted") {
427
+ const deletedSessionID = event.properties?.info?.id
428
+ if (announced.has(deletedSessionID)) await emit("session-end", deletedSessionID)
429
+ announced.delete(deletedSessionID)
430
+ childSessions.delete(deletedSessionID)
431
+ return
432
+ }
433
+ const sessionID = event.properties?.sessionID
434
+ await ensureSession(sessionID)
435
+ if (!sessionID || childSessions.has(sessionID)) return
398
436
  if (event.type === "session.idle") {
399
- await emit("stop")
437
+ await emit("stop", sessionID)
400
438
  } else if (event.type === "permission.replied") {
401
- await emit("prompt-submit")
439
+ await emit("prompt-submit", sessionID)
402
440
  }
403
441
  },
404
442
  }
@@ -158,6 +158,12 @@ export function validateManifest(raw, dir) {
158
158
  || !aliases.every((a) => typeof a === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(a))) {
159
159
  errors.push('aliases must contain 1-16 safe command names');
160
160
  }
161
+ const capabilities = m.capabilities;
162
+ if (capabilities !== undefined && (!Array.isArray(capabilities) || capabilities.length > 32
163
+ || !capabilities.every((capability) => typeof capability === 'string'
164
+ && /^[A-Za-z0-9\p{L}][A-Za-z0-9\p{L}\p{N}:._ -]{0,63}$/u.test(capability.trim())))) {
165
+ errors.push('capabilities must contain at most 32 short capability names');
166
+ }
161
167
  const accentColor = m.accentColor;
162
168
  if (typeof accentColor !== 'string' || !HEX_COLOR_RE.test(accentColor)) {
163
169
  errors.push('accentColor must be a 6-digit hex color (e.g. "#4385BE")');
@@ -397,6 +403,9 @@ export function validateManifest(raw, dir) {
397
403
  slug: slug,
398
404
  displayName: displayName.trim(),
399
405
  aliases: aliases.map((a) => a.trim().toLowerCase()),
406
+ capabilities: Array.isArray(capabilities)
407
+ ? [...new Set(capabilities.map((capability) => capability.trim()))]
408
+ : undefined,
400
409
  accentColor: accentColor,
401
410
  iconMode: iconMode,
402
411
  statuses,
@@ -273,6 +273,7 @@ export function registerPluginAgents(plugins) {
273
273
  slug: manifest.slug,
274
274
  displayName: manifest.displayName,
275
275
  aliases: manifest.aliases,
276
+ capabilities: manifest.capabilities,
276
277
  accentColor: manifest.accentColor,
277
278
  // Plugins use their slug as the frontend icon key only when icon.svg exists.
278
279
  // Otherwise AgentBrandAvatar must receive null so it can render its Bot fallback.
@@ -0,0 +1,100 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { normalizePersistedAgentResumeInfo } from './resumePersistence.js';
5
+ const MAX_TITLE_LENGTH = 240;
6
+ const MAX_CWD_LENGTH = 8_192;
7
+ function normalizeEntry(input) {
8
+ if (!input || typeof input !== 'object')
9
+ return null;
10
+ const candidate = input;
11
+ const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
12
+ const title = typeof candidate.title === 'string' ? candidate.title.trim().slice(0, MAX_TITLE_LENGTH) : '';
13
+ const cwd = typeof candidate.cwd === 'string' ? candidate.cwd.trim().slice(0, MAX_CWD_LENGTH) : '';
14
+ const agent = normalizePersistedAgentResumeInfo(candidate.agent);
15
+ if (!id || !title || !cwd || !agent)
16
+ return null;
17
+ return {
18
+ id,
19
+ title,
20
+ titleSource: candidate.titleSource === 'custom' || candidate.titleSource === 'auto'
21
+ ? candidate.titleSource
22
+ : 'default',
23
+ agent,
24
+ cwd,
25
+ closedAt: typeof candidate.closedAt === 'number' && Number.isFinite(candidate.closedAt)
26
+ ? Math.floor(candidate.closedAt)
27
+ : Date.now(),
28
+ reason: candidate.reason === 'exited' ? 'exited' : 'closed',
29
+ };
30
+ }
31
+ export class AgentResumeHistoryStore {
32
+ filePath;
33
+ maxEntries;
34
+ entries = [];
35
+ constructor(filePath, maxEntries = 30) {
36
+ this.filePath = filePath;
37
+ this.maxEntries = maxEntries;
38
+ this.load();
39
+ }
40
+ list() {
41
+ return this.entries.map((entry) => ({
42
+ ...entry,
43
+ agent: { ...entry.agent, launchArgv: entry.agent.launchArgv ? [...entry.agent.launchArgv] : null },
44
+ }));
45
+ }
46
+ get(id) {
47
+ return this.entries.find((entry) => entry.id === id) ?? null;
48
+ }
49
+ archive(input) {
50
+ const normalized = normalizeEntry({ ...input, id: randomUUID(), closedAt: input.closedAt ?? Date.now() });
51
+ if (!normalized)
52
+ return null;
53
+ const sameConversation = (entry) => entry.agent.slug === normalized.agent.slug
54
+ && entry.agent.sessionId === normalized.agent.sessionId;
55
+ const previous = this.entries.find(sameConversation);
56
+ const next = previous ? { ...normalized, id: previous.id } : normalized;
57
+ this.entries = [next, ...this.entries.filter((entry) => !sameConversation(entry))]
58
+ .sort((a, b) => b.closedAt - a.closedAt)
59
+ .slice(0, this.maxEntries);
60
+ this.persist();
61
+ return next;
62
+ }
63
+ remove(id) {
64
+ const next = this.entries.filter((entry) => entry.id !== id);
65
+ if (next.length === this.entries.length)
66
+ return false;
67
+ this.entries = next;
68
+ this.persist();
69
+ return true;
70
+ }
71
+ clear() {
72
+ if (this.entries.length === 0)
73
+ return;
74
+ this.entries = [];
75
+ this.persist();
76
+ }
77
+ load() {
78
+ try {
79
+ const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
80
+ this.entries = Array.isArray(parsed.entries)
81
+ ? parsed.entries.map(normalizeEntry).filter((entry) => entry !== null)
82
+ .sort((a, b) => b.closedAt - a.closedAt)
83
+ .slice(0, this.maxEntries)
84
+ : [];
85
+ }
86
+ catch (error) {
87
+ if (error.code !== 'ENOENT') {
88
+ console.warn('[agent-resume-history] failed to load:', error.message);
89
+ }
90
+ this.entries = [];
91
+ }
92
+ }
93
+ persist() {
94
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
95
+ const tempPath = `${this.filePath}.tmp-${process.pid}`;
96
+ const document = { version: 1, entries: this.entries };
97
+ fs.writeFileSync(tempPath, JSON.stringify(document, null, 2), { encoding: 'utf8', mode: 0o600 });
98
+ fs.renameSync(tempPath, this.filePath);
99
+ }
100
+ }
@@ -0,0 +1,35 @@
1
+ const SAFE_AGENT_SLUG = /^[A-Za-z0-9._-]+$/;
2
+ const SAFE_SESSION_ID = /^[A-Za-z0-9._-]+$/;
3
+ /** Normalize the last-known Agent conversation before it reaches disk again. */
4
+ export function normalizePersistedAgentResumeInfo(input) {
5
+ if (!input || typeof input !== 'object')
6
+ return null;
7
+ const candidate = input;
8
+ const slug = typeof candidate.slug === 'string' ? candidate.slug.trim() : '';
9
+ const sessionId = typeof candidate.sessionId === 'string' ? candidate.sessionId.trim() : '';
10
+ if (!slug || !SAFE_AGENT_SLUG.test(slug) || !sessionId || !SAFE_SESSION_ID.test(sessionId)) {
11
+ return null;
12
+ }
13
+ const launchArgv = candidate.launchArgv === null || candidate.launchArgv === undefined
14
+ ? null
15
+ : Array.isArray(candidate.launchArgv)
16
+ && candidate.launchArgv.length <= 256
17
+ && candidate.launchArgv.every((value) => typeof value === 'string' && value.length <= 8_192)
18
+ ? [...candidate.launchArgv]
19
+ : null;
20
+ return {
21
+ slug,
22
+ sessionId,
23
+ launchArgv,
24
+ updatedAt: typeof candidate.updatedAt === 'number' && Number.isFinite(candidate.updatedAt)
25
+ ? Math.floor(candidate.updatedAt)
26
+ : Date.now(),
27
+ };
28
+ }
29
+ /** A dead shell is worth rebuilding only when it can restore an Agent conversation. */
30
+ export function canRestoreDeadAgentShell(input) {
31
+ return typeof input.cwd === 'string'
32
+ && input.cwd.trim().length > 0
33
+ && typeof input.agentResume?.sessionId === 'string'
34
+ && input.agentResume.sessionId.length > 0;
35
+ }
@@ -0,0 +1,96 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const MAX_LOG_BYTES = 2 * 1024 * 1024;
4
+ const ANSI_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d\/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
5
+ function clean(value) {
6
+ return value
7
+ .replace(ANSI_PATTERN, '')
8
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
9
+ .replace(/\r/g, '')
10
+ .replace(/\n{4,}/g, '\n\n\n');
11
+ }
12
+ export class SessionSearchStore {
13
+ directory;
14
+ metadata = new Map();
15
+ pending = new Map();
16
+ flushTimer = null;
17
+ constructor(directory) {
18
+ this.directory = directory;
19
+ this.loadMetadata();
20
+ }
21
+ append(metadata, output) {
22
+ const text = clean(output);
23
+ this.metadata.set(metadata.sessionId, { ...metadata, updatedAt: Date.now() });
24
+ if (text.trim())
25
+ this.pending.set(metadata.sessionId, [...(this.pending.get(metadata.sessionId) ?? []), text]);
26
+ if (!this.flushTimer)
27
+ this.flushTimer = setTimeout(() => this.flush(), 750);
28
+ }
29
+ update(metadata) {
30
+ this.metadata.set(metadata.sessionId, { ...metadata, updatedAt: Date.now() });
31
+ if (!this.flushTimer)
32
+ this.flushTimer = setTimeout(() => this.flush(), 750);
33
+ }
34
+ search(query, limit = 30) {
35
+ this.flush();
36
+ const needle = query.trim().toLocaleLowerCase();
37
+ if (!needle)
38
+ return [];
39
+ const results = [];
40
+ for (const metadata of this.metadata.values()) {
41
+ let text = '';
42
+ try {
43
+ text = fs.readFileSync(this.logPath(metadata.sessionId), 'utf8');
44
+ }
45
+ catch { /* no output yet */ }
46
+ const searchable = `${metadata.title}\n${metadata.cwd}\n${metadata.agentSlug ?? ''}\n${text}`;
47
+ const lower = searchable.toLocaleLowerCase();
48
+ const first = lower.indexOf(needle);
49
+ if (first < 0)
50
+ continue;
51
+ let count = 0;
52
+ let cursor = 0;
53
+ while ((cursor = lower.indexOf(needle, cursor)) >= 0 && count < 999) {
54
+ count += 1;
55
+ cursor += needle.length;
56
+ }
57
+ const sourceStart = Math.max(0, first - 100);
58
+ const sourceEnd = Math.min(searchable.length, first + needle.length + 180);
59
+ results.push({ ...metadata, snippet: searchable.slice(sourceStart, sourceEnd).replace(/\s+/g, ' ').trim(), matchCount: count });
60
+ }
61
+ return results.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, Math.max(1, Math.min(limit, 100)));
62
+ }
63
+ flush() {
64
+ if (this.flushTimer)
65
+ clearTimeout(this.flushTimer);
66
+ this.flushTimer = null;
67
+ if (this.metadata.size === 0 && this.pending.size === 0)
68
+ return;
69
+ fs.mkdirSync(this.directory, { recursive: true });
70
+ for (const [sessionId, chunks] of this.pending) {
71
+ const logPath = this.logPath(sessionId);
72
+ fs.appendFileSync(logPath, chunks.join(''), { mode: 0o600 });
73
+ const stats = fs.statSync(logPath);
74
+ if (stats.size > MAX_LOG_BYTES) {
75
+ const buffer = fs.readFileSync(logPath);
76
+ fs.writeFileSync(logPath, buffer.subarray(buffer.length - MAX_LOG_BYTES), { mode: 0o600 });
77
+ }
78
+ }
79
+ this.pending.clear();
80
+ const temporaryPath = `${this.metadataPath()}.${process.pid}.tmp`;
81
+ fs.writeFileSync(temporaryPath, JSON.stringify({ version: 1, sessions: [...this.metadata.values()] }, null, 2), { mode: 0o600 });
82
+ fs.renameSync(temporaryPath, this.metadataPath());
83
+ }
84
+ loadMetadata() {
85
+ try {
86
+ const parsed = JSON.parse(fs.readFileSync(this.metadataPath(), 'utf8'));
87
+ for (const item of parsed.sessions ?? []) {
88
+ if (item && typeof item.sessionId === 'string')
89
+ this.metadata.set(item.sessionId, item);
90
+ }
91
+ }
92
+ catch { /* first run */ }
93
+ }
94
+ metadataPath() { return path.join(this.directory, 'sessions.json'); }
95
+ logPath(sessionId) { return path.join(this.directory, `${encodeURIComponent(sessionId)}.log`); }
96
+ }