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.
- package/dist/client/assets/{ModelPreview-DCC_cgBs.js → ModelPreview-Drqv0OTg.js} +165 -165
- package/dist/client/assets/{abnfDiagram-VRR7QNED-ByotYhVU.js → abnfDiagram-VRR7QNED-Cl8CT45X.js} +1 -1
- package/dist/client/assets/{arc-oaeK9Fn4.js → arc-2Sc638oe.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-CnxQD5DT.js → architectureDiagram-ZJ3FMSHR-CNH9w9eN.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-Ds7-Gi7B.js → blockDiagram-677ZJIJ3-ClvE_yP1.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-BdPTvWqP.js → c4Diagram-LMCZKHZV-CxIMU8l9.js} +1 -1
- package/dist/client/assets/channel-DPt818Jx.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-BHB6J78_.js → chunk-2Q5K7J3B-CdvXmc6Z.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-hzsoKeck.js → chunk-32BRIVSS-B1dA4l5A.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-MGUgwiHw.js → chunk-5VM5RSS4-CVk_pzpf.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-BKnPJAiI.js → chunk-EX3LRPZG-B46zhCa0.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-Dfdv_575.js → chunk-JWPE2WC7-1wRPE3eO.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-CKEHojX9.js → chunk-MOJQB5TN-BzPrhNIS.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-DqWvLIet.js → chunk-RYQCIY6F-D0sDGnBf.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-BtutDi_L.js → chunk-V7JOEXUC-BRo0103H.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-B2cuDdQf.js → chunk-VR4S4FIN-CW-wSkDE.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-udLcfG1V.js → chunk-XXDRQBXY-BuWiQ0uq.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-C2hZbTnO.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-C2hZbTnO.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-DqOCMKxN.js → cose-bilkent-JH36ORCC-WzKVd03Q.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-Drck5nNi.js → cynefin-VYW2F7L2-CfXYAqnq.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHbsvV_H.js → cynefinDiagram-TSTJHNR4-BnJGle_X.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-27UzxTIz.js → dagre-VKFMJZFB-CDDlGCQy.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-yrQuBDwj.js → diagram-FQU43EPY-t8DNZS7P.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-BZiJ5Qye.js → diagram-G47NLZAW-CZpQd91k.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-BRLMfHoI.js → diagram-NH7WQ7WH-OET0EFlt.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-ChR7fBMa.js → diagram-OA4YK3LP-COgju4NX.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-beJeXBUS.js → diagram-WEI45ONY-6gNSoIek.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-BEnzKQgq.js → ebnfDiagram-CCIWWBDH-CTlIMc8W.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-abMcC3O5.js → erDiagram-Q63AITRT-BDsEvZvq.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-Cf_z-brB.js → flowDiagram-23GEKE2U-ggl4zaY9.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-CLGNuYtO.js → ganttDiagram-NO4QXBWP-D9Ca5oWS.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DAO69bU6.js → gitGraphDiagram-IHSO6WYX-THPUH6Bg.js} +1 -1
- package/dist/client/assets/index-BuMMW0wS.js +451 -0
- package/dist/client/assets/index-RO2b079_.css +1 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-ByWatnBT.js → infoDiagram-FWYZ7A6U-CKF-vSTz.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-Bxt5Yqg4.js → ishikawaDiagram-FXEZZL3T-CBFlmgI3.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-Dafr7Sfu.js → journeyDiagram-5HDEW3XC-lbXIqX5a.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-D1Kng2jH.js → kanban-definition-HUTT4EX6-BUuO_e54.js} +1 -1
- package/dist/client/assets/{linear-BDTQPYjb.js → linear-D_uoNzR8.js} +1 -1
- package/dist/client/assets/{mermaid.core-BqsgBUDL.js → mermaid.core-D4Yy5OxZ.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-WlWlxPeq.js → mindmap-definition-LN4V7U3C-B0Ml-jaq.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-CeuVjVf0.js → pegDiagram-2B236MQR-CKF9cuqx.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-DdLAXZnA.js → pieDiagram-ENE6RG2P-DL3XisAD.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BSI4gC6Y.js → quadrantDiagram-ABIIQ3AL-DrsHXXqd.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-DHGWP4uI.js → railroadDiagram-RFXS5EU6-DMAz8hLg.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE--sP3n7Ar.js → requirementDiagram-TGXJPOKE-CCHretnQ.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-CTBy3q73.js → sankeyDiagram-HTMAVEWB-Btb7ZNwU.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-D2p6MaPI.js → sequenceDiagram-DBY2YBRQ-DcZk1PdX.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-C-8i892J.js → sizeCapture-X5ZJPWSS-vMYb_VZU.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-BfWCgCA0.js → stateDiagram-2N3HPSRC-CltexqJj.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BslbFNw3.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-Dnftro8u.js → swimlanes-5IMT3BWC-DPnwlpXX.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-B0V1XVQb.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-D7l7Wf7T.js → timeline-definition-FHXFAJF6-l78vfKln.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-ClDuwHNb.js → vennDiagram-L72KCM5P-BggNEe_f.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-BZ3yiEXX.js → wardleyDiagram-EHGQE667-v04USz2e.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-BGZ_BXST.js → xychartDiagram-FW5EYKEG-CInHBOpK.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/sw-notifications.js +7 -3
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/automationStore.js +151 -0
- package/dist/server/agent/collaborationStore.js +145 -0
- package/dist/server/agent/installers.js +50 -12
- package/dist/server/agent/plugins.js +9 -0
- package/dist/server/agent/registry.js +1 -0
- package/dist/server/agent/resumeHistory.js +100 -0
- package/dist/server/agent/resumePersistence.js +35 -0
- package/dist/server/agent/sessionSearchStore.js +96 -0
- package/dist/server/cli.js +139 -0
- package/dist/server/routes/terminal.js +503 -16
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
- package/dist/client/assets/channel-Cpdbh5br.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-DWre1xlG.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-DWre1xlG.js +0 -1
- package/dist/client/assets/index-BYhY8AKn.css +0 -1
- package/dist/client/assets/index-CshWxisE.js +0 -451
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DzD8Rob4.js +0 -1
- 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
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
"
|
|
392
|
-
await
|
|
409
|
+
"chat.message": async ({ sessionID }) => {
|
|
410
|
+
await ensureSession(sessionID)
|
|
411
|
+
if (!childSessions.has(sessionID)) await emit("prompt-submit", sessionID)
|
|
393
412
|
},
|
|
394
|
-
"
|
|
395
|
-
await
|
|
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
|
+
}
|