termdock 1.4.0 → 1.4.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.
- package/dist/client/assets/{abnfDiagram-VRR7QNED-G_YxqZwv.js → abnfDiagram-VRR7QNED-DWXqvEjR.js} +1 -1
- package/dist/client/assets/{arc-BuvKiJCC.js → arc-B5joCHka.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-BvkwEVZw.js → architectureDiagram-ZJ3FMSHR-CJQE6w7r.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-C3bxox0Z.js → blockDiagram-677ZJIJ3-C1TGBe5x.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-DLORcr8m.js → c4Diagram-LMCZKHZV-DGd2HHf1.js} +1 -1
- package/dist/client/assets/channel-DTPJjYD6.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-Dnqi-8g0.js → chunk-2Q5K7J3B-n9gfTHAy.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-D70B4HxL.js → chunk-32BRIVSS-C2hRZ-BH.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-Cl7t39aR.js → chunk-5VM5RSS4-DSSrc2sZ.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-DKMD4DfE.js → chunk-EX3LRPZG-DID8XrnC.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-CsN_pMOw.js → chunk-JWPE2WC7-Da3QhSKj.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-D8BHaQpE.js → chunk-MOJQB5TN-DXZjOrxN.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-B8BtUiHF.js → chunk-RYQCIY6F-mrNVFK82.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-CdN7tlvn.js → chunk-V7JOEXUC-DJR67KDW.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-UPjL_wl4.js → chunk-VR4S4FIN-nkbsGz1_.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY--eX5Na_l.js → chunk-XXDRQBXY-j9PgGxJy.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-2YuDtvgU.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-2YuDtvgU.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-CEAcZx3N.js → cose-bilkent-JH36ORCC-Dur7GuvH.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-up8zIMDo.js → cynefin-VYW2F7L2-5FtFnipB.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-DZ2SQpCq.js → cynefinDiagram-TSTJHNR4-Bl8p8LMV.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-DOoLtq9O.js → dagre-VKFMJZFB-BlhiJve1.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-x8umATxt.js → diagram-FQU43EPY-Cvwc7nAt.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-Kle7Toxb.js → diagram-G47NLZAW-Cpi_bvL3.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-CezkkMqP.js → diagram-NH7WQ7WH-B7ariBtD.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-CaU0u6UQ.js → diagram-OA4YK3LP-BRC3pwSV.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-B4m53TVj.js → diagram-WEI45ONY-qrT03hI1.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-Dpwjo8_v.js → ebnfDiagram-CCIWWBDH-BDwvSKrA.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-BU4R1QVk.js → erDiagram-Q63AITRT-B7sW79AM.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-BFtDsA0k.js → flowDiagram-23GEKE2U-OGMG83Xx.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-DtSEfub5.js → ganttDiagram-NO4QXBWP-DR7axkaq.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BGukYlT3.js → gitGraphDiagram-IHSO6WYX-C_tNm0YG.js} +1 -1
- package/dist/client/assets/index-BktcGINa.js +408 -0
- package/dist/client/assets/index-DF1hQ7NF.css +1 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-Bx1NQNsL.js → infoDiagram-FWYZ7A6U-gtqUM7v8.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-DHlxYJjl.js → ishikawaDiagram-FXEZZL3T-Dt_GdmMN.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-BOZzlR6d.js → journeyDiagram-5HDEW3XC-jb5z-0p5.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-C2nWuUXX.js → kanban-definition-HUTT4EX6-OJixE6rE.js} +1 -1
- package/dist/client/assets/{linear-DtblW0g4.js → linear-BhufR60-.js} +1 -1
- package/dist/client/assets/{mermaid.core-BKSuG721.js → mermaid.core-BCzoNRDZ.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-Co3LLQGt.js → mindmap-definition-LN4V7U3C-Bytw0Q4_.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-DSUG1nB1.js → pegDiagram-2B236MQR-3Mhqh7uj.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-DvEr0UPf.js → pieDiagram-ENE6RG2P-CkBKIVEt.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-C2diLBZ8.js → quadrantDiagram-ABIIQ3AL-D6ogfZv2.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-CqYZHCvf.js → railroadDiagram-RFXS5EU6-SQbw572c.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-CFAKdPQp.js → requirementDiagram-TGXJPOKE-DO1wNYeJ.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-DV95NA9H.js → sankeyDiagram-HTMAVEWB-B-QG2oOv.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-cbv0xzG-.js → sequenceDiagram-DBY2YBRQ-gJDETC-I.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-DpkzYB1F.js → sizeCapture-X5ZJPWSS-h3hWKe_0.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-BnAwPP0V.js → stateDiagram-2N3HPSRC-C-7j4jgP.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-cGyWnCe2.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-kObRIKfj.js → swimlanes-5IMT3BWC-Bktms0sT.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-BCoFgyBb.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-BbnAJ8pO.js → timeline-definition-FHXFAJF6-DOScKd5g.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-sIu_hWKx.js → vennDiagram-L72KCM5P-BWPRTzpI.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-DpGdJtdb.js → wardleyDiagram-EHGQE667-Dwh2JB3n.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-C5F9faN2.js → xychartDiagram-FW5EYKEG-DmDUfhQl.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/installers.js +194 -3
- package/dist/server/agent/plugins.js +271 -0
- package/dist/server/agent/registry.js +82 -20
- package/dist/server/cli.js +122 -2
- package/dist/server/routes/filesystem.js +10 -8
- package/dist/server/routes/terminal.js +83 -6
- package/package.json +1 -1
- package/dist/client/assets/channel-BnGIwxUi.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-DMigd9mj.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-DMigd9mj.js +0 -1
- package/dist/client/assets/index-CP3_puZd.js +0 -395
- package/dist/client/assets/index-CxBkmASY.css +0 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DtPm_oHq.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-yeb4EUGj.js +0 -8
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent plugin system: user-defined agents loaded from
|
|
3
|
+
* `~/.termdock/agent-plugins/<slug>/manifest.json`.
|
|
4
|
+
*
|
|
5
|
+
* Each plugin can define:
|
|
6
|
+
* 1. Agent identity (display name, aliases, accent color, icon)
|
|
7
|
+
* 2. Hook installation config (target file + event mappings)
|
|
8
|
+
* 3. Resume command support
|
|
9
|
+
*
|
|
10
|
+
* Plugins are merged into the built-in agent registry and hook installer
|
|
11
|
+
* list at startup. Slugs that collide with built-in agents are skipped.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
const PLUGINS_DIR = path.join(os.homedir(), '.termdock', 'agent-plugins');
|
|
17
|
+
const MANIFEST_FILE = 'manifest.json';
|
|
18
|
+
const ICON_FILE = 'icon.svg';
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Manifest schema
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
export const PLUGIN_MANIFEST_VERSION = 1;
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Known sentinel event kinds — the set of events our state machine accepts
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const VALID_EVENT_KINDS = new Set([
|
|
27
|
+
'session-start',
|
|
28
|
+
'prompt-submit',
|
|
29
|
+
'permission-request',
|
|
30
|
+
'question-asked',
|
|
31
|
+
'tool-complete',
|
|
32
|
+
'notification',
|
|
33
|
+
'stop',
|
|
34
|
+
'session-end',
|
|
35
|
+
]);
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Slug validation (alphanumeric + hyphens, non-empty)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
const SLUG_RE = /^[a-z][a-z0-9-]*$/;
|
|
40
|
+
function isValidSlug(s) {
|
|
41
|
+
return SLUG_RE.test(s) && s.length <= 40;
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Hex color validation
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Path resolution: ~ → home directory
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
function resolveHome(p) {
|
|
51
|
+
if (p.startsWith('~')) {
|
|
52
|
+
return path.join(os.homedir(), p.slice(1));
|
|
53
|
+
}
|
|
54
|
+
return p;
|
|
55
|
+
}
|
|
56
|
+
function validateManifest(raw, dir) {
|
|
57
|
+
const errors = [];
|
|
58
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
59
|
+
return { error: { slug: path.basename(dir), errors: ['manifest.json is not a JSON object'] } };
|
|
60
|
+
}
|
|
61
|
+
const m = raw;
|
|
62
|
+
const version = m.version;
|
|
63
|
+
if (typeof version !== 'number' || version !== PLUGIN_MANIFEST_VERSION) {
|
|
64
|
+
errors.push(`version must be ${PLUGIN_MANIFEST_VERSION}`);
|
|
65
|
+
}
|
|
66
|
+
const slug = m.slug;
|
|
67
|
+
if (typeof slug !== 'string' || !isValidSlug(slug)) {
|
|
68
|
+
errors.push('slug must be a lowercase alphanumeric string with hyphens (max 40 chars)');
|
|
69
|
+
}
|
|
70
|
+
const displayName = m.displayName;
|
|
71
|
+
if (typeof displayName !== 'string' || displayName.trim().length === 0) {
|
|
72
|
+
errors.push('displayName is required and must be a non-empty string');
|
|
73
|
+
}
|
|
74
|
+
const aliases = m.aliases;
|
|
75
|
+
if (!Array.isArray(aliases) || aliases.length === 0 || !aliases.every((a) => typeof a === 'string' && a.trim().length > 0)) {
|
|
76
|
+
errors.push('aliases must be a non-empty array of strings');
|
|
77
|
+
}
|
|
78
|
+
const accentColor = m.accentColor;
|
|
79
|
+
if (typeof accentColor !== 'string' || !HEX_COLOR_RE.test(accentColor)) {
|
|
80
|
+
errors.push('accentColor must be a 6-digit hex color (e.g. "#4385BE")');
|
|
81
|
+
}
|
|
82
|
+
// Optional hooks
|
|
83
|
+
let hooks;
|
|
84
|
+
if (m.hooks !== undefined) {
|
|
85
|
+
if (typeof m.hooks !== 'object' || Array.isArray(m.hooks) || m.hooks === null) {
|
|
86
|
+
errors.push('hooks must be an object if present');
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const h = m.hooks;
|
|
90
|
+
const target = h.target;
|
|
91
|
+
if (typeof target !== 'string' || target.trim().length === 0) {
|
|
92
|
+
errors.push('hooks.target is required and must be a file path (~-abbreviated ok)');
|
|
93
|
+
}
|
|
94
|
+
const events = h.events;
|
|
95
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
96
|
+
errors.push('hooks.events must be a non-empty array');
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const validEvents = [];
|
|
100
|
+
for (let i = 0; i < events.length; i++) {
|
|
101
|
+
const e = events[i];
|
|
102
|
+
if (!e || typeof e !== 'object') {
|
|
103
|
+
errors.push(`hooks.events[${i}] is not an object`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const evt = e;
|
|
107
|
+
const hook = evt.hook;
|
|
108
|
+
const event = evt.event;
|
|
109
|
+
if (typeof hook !== 'string' || hook.trim().length === 0) {
|
|
110
|
+
errors.push(`hooks.events[${i}].hook is required`);
|
|
111
|
+
}
|
|
112
|
+
if (typeof event !== 'string' || !VALID_EVENT_KINDS.has(event)) {
|
|
113
|
+
errors.push(`hooks.events[${i}].event must be one of: ${[...VALID_EVENT_KINDS].join(', ')}`);
|
|
114
|
+
}
|
|
115
|
+
const matcher = evt.matcher;
|
|
116
|
+
if (matcher !== undefined && typeof matcher !== 'string') {
|
|
117
|
+
errors.push(`hooks.events[${i}].matcher must be a string if provided`);
|
|
118
|
+
}
|
|
119
|
+
const timeout = evt.timeout;
|
|
120
|
+
if (timeout !== undefined && (typeof timeout !== 'number' || timeout <= 0)) {
|
|
121
|
+
errors.push(`hooks.events[${i}].timeout must be a positive number if provided`);
|
|
122
|
+
}
|
|
123
|
+
validEvents.push({
|
|
124
|
+
hook: hook,
|
|
125
|
+
event: event,
|
|
126
|
+
matcher: typeof matcher === 'string' ? matcher : undefined,
|
|
127
|
+
timeout: typeof timeout === 'number' ? timeout : undefined,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (validEvents.length > 0) {
|
|
131
|
+
hooks = { target: target, events: validEvents };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Optional resume
|
|
137
|
+
let resumeConfig;
|
|
138
|
+
if (m.resume !== undefined) {
|
|
139
|
+
if (typeof m.resume !== 'object' || Array.isArray(m.resume) || m.resume === null) {
|
|
140
|
+
errors.push('resume must be an object if present');
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
const r = m.resume;
|
|
144
|
+
const command = r.command;
|
|
145
|
+
if (typeof command !== 'string' || !command.includes('{sessionId}')) {
|
|
146
|
+
errors.push('resume.command must contain the {sessionId} placeholder');
|
|
147
|
+
}
|
|
148
|
+
const staleFlags = r.staleFlags;
|
|
149
|
+
if (staleFlags !== undefined && (!Array.isArray(staleFlags) || !staleFlags.every((f) => typeof f === 'string'))) {
|
|
150
|
+
errors.push('resume.staleFlags must be an array of strings if provided');
|
|
151
|
+
}
|
|
152
|
+
resumeConfig = {
|
|
153
|
+
command: command,
|
|
154
|
+
staleFlags: Array.isArray(staleFlags) ? staleFlags : undefined,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (errors.length > 0) {
|
|
159
|
+
return { error: { slug: typeof slug === 'string' ? slug : path.basename(dir), errors } };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
manifest: {
|
|
163
|
+
version: version,
|
|
164
|
+
slug: slug,
|
|
165
|
+
displayName: displayName.trim(),
|
|
166
|
+
aliases: aliases.map((a) => a.trim().toLowerCase()),
|
|
167
|
+
accentColor: accentColor,
|
|
168
|
+
hooks,
|
|
169
|
+
resume: resumeConfig,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Scan ~/.termdock/agent-plugins/ for plugin directories, validate each
|
|
175
|
+
* manifest, and return the loaded plugins + any validation errors.
|
|
176
|
+
*/
|
|
177
|
+
export function loadPlugins() {
|
|
178
|
+
const plugins = [];
|
|
179
|
+
const errors = [];
|
|
180
|
+
let entries;
|
|
181
|
+
try {
|
|
182
|
+
entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return { plugins, errors };
|
|
186
|
+
}
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
if (!entry.isDirectory())
|
|
189
|
+
continue;
|
|
190
|
+
const dir = path.join(PLUGINS_DIR, entry.name);
|
|
191
|
+
const manifestPath = path.join(dir, MANIFEST_FILE);
|
|
192
|
+
let raw;
|
|
193
|
+
try {
|
|
194
|
+
raw = fs.readFileSync(manifestPath, 'utf8');
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
let parsed;
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(raw);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
errors.push({ slug: entry.name, errors: ['manifest.json is not valid JSON'] });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const result = validateManifest(parsed, dir);
|
|
208
|
+
if ('error' in result) {
|
|
209
|
+
errors.push(result.error);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
const iconPath = path.join(dir, ICON_FILE);
|
|
213
|
+
plugins.push({
|
|
214
|
+
manifest: result.manifest,
|
|
215
|
+
dir,
|
|
216
|
+
iconPath: fs.existsSync(iconPath) ? iconPath : null,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return { plugins, errors };
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Save a plugin manifest to disk, creating the plugin directory.
|
|
223
|
+
*/
|
|
224
|
+
export function savePlugin(manifest) {
|
|
225
|
+
const dir = path.join(PLUGINS_DIR, manifest.slug);
|
|
226
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
227
|
+
const manifestPath = path.join(dir, MANIFEST_FILE);
|
|
228
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
229
|
+
return dir;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Remove a plugin directory from disk.
|
|
233
|
+
*/
|
|
234
|
+
export function removePlugin(slug) {
|
|
235
|
+
const dir = path.join(PLUGINS_DIR, slug);
|
|
236
|
+
if (!fs.existsSync(dir)) {
|
|
237
|
+
throw new Error(`Plugin "${slug}" not found`);
|
|
238
|
+
}
|
|
239
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* List all plugin slugs (both currently loaded and directories on disk).
|
|
243
|
+
*/
|
|
244
|
+
export function listPluginDirectories() {
|
|
245
|
+
try {
|
|
246
|
+
return fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
|
|
247
|
+
.filter((e) => e.isDirectory())
|
|
248
|
+
.map((e) => e.name);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Resolve a ~-abbreviated path from a plugin's hooks.target to an absolute path.
|
|
256
|
+
*/
|
|
257
|
+
export function resolveHookTarget(hooksTargetPath) {
|
|
258
|
+
return resolveHome(hooksTargetPath);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Read an icon SVG from a plugin directory as a string.
|
|
262
|
+
*/
|
|
263
|
+
export function readPluginIcon(slug) {
|
|
264
|
+
const iconPath = path.join(PLUGINS_DIR, slug, ICON_FILE);
|
|
265
|
+
try {
|
|
266
|
+
return fs.readFileSync(iconPath, 'utf8');
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Third-party CLI coding-agent registry + detection.
|
|
3
|
-
* (设计移植自 tty7 的 core/cli_agent.rs,Apache-2.0;此处为 TypeScript 重写)
|
|
4
|
-
*
|
|
5
|
-
* termdock recognizes when a pane is running a known coding agent (Claude
|
|
6
|
-
* Code, Codex, Gemini CLI, …) so the tab can brand it and notifications can
|
|
7
|
-
* say *which* agent finished or needs you. Detection is a pure function over
|
|
8
|
-
* the foreground process's argv (which the server already polls for program
|
|
9
|
-
* labels), so it works identically in shell mode and tmux mode.
|
|
10
|
-
*
|
|
11
|
-
* Strategy:
|
|
12
|
-
* 1. Strip leading `VAR=value` environment assignments.
|
|
13
|
-
* 2. If the launcher's own basename matches a known agent, that's it.
|
|
14
|
-
* 3. Otherwise only if the launcher is a script *interpreter* (node, bun,
|
|
15
|
-
* python, npx, …), scan remaining path-like arguments for a segment that
|
|
16
|
-
* names an agent — the npm/pip-wrapped case (`node …/claude-code/cli.js`).
|
|
17
|
-
* The interpreter gate keeps `cat codex.md` from false-matching.
|
|
18
|
-
* 4. User-defined rules (`agentCommands` in program-detection config): a map
|
|
19
|
-
* from command basename to agent slug, for personal wrappers (`cc`).
|
|
20
|
-
*/
|
|
21
1
|
// Registry order matters only for iteration stability; lookup is by alias.
|
|
22
2
|
// flexoki-allow-file — vendor brand accents are deliberately theme-independent.
|
|
23
3
|
const AGENTS = [
|
|
@@ -239,6 +219,88 @@ function replayFlags(agent, argv) {
|
|
|
239
219
|
export function buildResumeCommand(agent, sessionId, launchArgv) {
|
|
240
220
|
if (!sessionId || !/^[A-Za-z0-9._-]+$/.test(sessionId))
|
|
241
221
|
return null;
|
|
222
|
+
// Plugin-defined resume: use the command template
|
|
223
|
+
if (agent.isPlugin) {
|
|
224
|
+
const pluginResume = getPluginResumeConfig(agent.slug);
|
|
225
|
+
if (pluginResume) {
|
|
226
|
+
const safe = (t) => /^[A-Za-z0-9._-]+$/.test(t);
|
|
227
|
+
if (!safe(sessionId))
|
|
228
|
+
return null;
|
|
229
|
+
const cmd = pluginResume.command.replace('{sessionId}', sessionId);
|
|
230
|
+
// Shell-safety: refuse anything non-alphanumeric
|
|
231
|
+
if (!/^[A-Za-z0-9_[\]{},./:;@+=~'"!?#&()<>*|$ -]+$/.test(cmd))
|
|
232
|
+
return null;
|
|
233
|
+
return cmd;
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
242
237
|
const flags = launchArgv ? replayFlags(agent, launchArgv) ?? [] : [];
|
|
243
238
|
return resumeCommandFor(agent, sessionId, flags);
|
|
244
239
|
}
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Plugin agent integration
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
/** Plugin-defined resume configs, keyed by slug. */
|
|
244
|
+
const pluginResumeConfigs = new Map();
|
|
245
|
+
/**
|
|
246
|
+
* Register plugin agents into the lookup maps. Plugins whose slug or aliases
|
|
247
|
+
* collide with built-in agents are skipped. Call `clearPluginAgents()` first
|
|
248
|
+
* when reloading.
|
|
249
|
+
*/
|
|
250
|
+
export function registerPluginAgents(plugins) {
|
|
251
|
+
let registered = 0;
|
|
252
|
+
const skipped = [];
|
|
253
|
+
for (const p of plugins) {
|
|
254
|
+
const { manifest } = p;
|
|
255
|
+
if (BY_SLUG.has(manifest.slug)) {
|
|
256
|
+
skipped.push(`${manifest.slug} (slug conflicts with built-in)`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
let aliasConflict = false;
|
|
260
|
+
for (const alias of manifest.aliases) {
|
|
261
|
+
if (BY_ALIAS.has(alias)) {
|
|
262
|
+
skipped.push(`${manifest.slug} (alias "${alias}" conflicts with built-in)`);
|
|
263
|
+
aliasConflict = true;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (aliasConflict)
|
|
268
|
+
continue;
|
|
269
|
+
const info = {
|
|
270
|
+
slug: manifest.slug,
|
|
271
|
+
displayName: manifest.displayName,
|
|
272
|
+
aliases: manifest.aliases,
|
|
273
|
+
accentColor: manifest.accentColor,
|
|
274
|
+
icon: manifest.slug, // plugins use slug as icon key; frontend checks /agent-plugin-icon/<slug>
|
|
275
|
+
isPlugin: true,
|
|
276
|
+
};
|
|
277
|
+
BY_SLUG.set(info.slug, info);
|
|
278
|
+
for (const alias of info.aliases)
|
|
279
|
+
BY_ALIAS.set(alias, info);
|
|
280
|
+
if (manifest.resume) {
|
|
281
|
+
pluginResumeConfigs.set(manifest.slug, {
|
|
282
|
+
command: manifest.resume.command,
|
|
283
|
+
staleFlags: manifest.resume.staleFlags ?? [],
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
registered++;
|
|
287
|
+
}
|
|
288
|
+
return { registered, skipped };
|
|
289
|
+
}
|
|
290
|
+
export function clearPluginAgents() {
|
|
291
|
+
for (const [slug, info] of BY_SLUG) {
|
|
292
|
+
if (info.isPlugin)
|
|
293
|
+
BY_SLUG.delete(slug);
|
|
294
|
+
}
|
|
295
|
+
for (const [alias, info] of BY_ALIAS) {
|
|
296
|
+
if (info.isPlugin)
|
|
297
|
+
BY_ALIAS.delete(alias);
|
|
298
|
+
}
|
|
299
|
+
pluginResumeConfigs.clear();
|
|
300
|
+
}
|
|
301
|
+
export function getPluginResumeConfig(slug) {
|
|
302
|
+
return pluginResumeConfigs.get(slug);
|
|
303
|
+
}
|
|
304
|
+
export function isPluginAgent(slug) {
|
|
305
|
+
return BY_SLUG.get(slug)?.isPlugin === true;
|
|
306
|
+
}
|
package/dist/server/cli.js
CHANGED
|
@@ -145,8 +145,11 @@ Options:
|
|
|
145
145
|
--change-audit-show <id> [cwd]
|
|
146
146
|
Print one hunk's diff/context by Termdock hunk ID.
|
|
147
147
|
--change-audit-explain <id> [cwd]
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
Read a natural-language explanation from stdin and inject
|
|
149
|
+
it for the specified Termdock hunk ID.
|
|
150
|
+
--agent-plugin-init <slug>
|
|
151
|
+
Scaffold a new agent plugin with a manifest template and
|
|
152
|
+
guidance. Creates ~/.termdock/agent-plugins/<slug>/.
|
|
150
153
|
-h, --help Show this help message
|
|
151
154
|
|
|
152
155
|
Short commands:
|
|
@@ -178,6 +181,7 @@ Short commands:
|
|
|
178
181
|
pc Same as --clear-password
|
|
179
182
|
pwc Same as --clear-password
|
|
180
183
|
https Same as --setup-local-https
|
|
184
|
+
ap <slug> Same as --agent-plugin-init <slug>
|
|
181
185
|
|
|
182
186
|
Password examples:
|
|
183
187
|
${c.dim('# Set password interactively (recommended)')}
|
|
@@ -465,6 +469,8 @@ function parseArgs(argv) {
|
|
|
465
469
|
let changeAuditList;
|
|
466
470
|
let changeAuditShow;
|
|
467
471
|
let injectChangeAuditHunk;
|
|
472
|
+
let agentPluginInit = false;
|
|
473
|
+
let agentPluginSlug;
|
|
468
474
|
// Short command aliases for the common path. Keep these positional-only so
|
|
469
475
|
// long-form flags remain the single source of truth for option semantics.
|
|
470
476
|
if (argv[0] && !argv[0].startsWith('-')) {
|
|
@@ -560,6 +566,11 @@ function parseArgs(argv) {
|
|
|
560
566
|
injectChangeAuditHunk = { id: next, cwd };
|
|
561
567
|
argv = argv.slice(cwd ? 3 : 2);
|
|
562
568
|
}
|
|
569
|
+
else if (command === 'ap' && next && !next.startsWith('-')) {
|
|
570
|
+
agentPluginInit = true;
|
|
571
|
+
agentPluginSlug = next;
|
|
572
|
+
argv = argv.slice(2);
|
|
573
|
+
}
|
|
563
574
|
}
|
|
564
575
|
for (let index = 0; index < argv.length; index += 1) {
|
|
565
576
|
const arg = argv[index];
|
|
@@ -732,6 +743,17 @@ function parseArgs(argv) {
|
|
|
732
743
|
index += cwd ? 2 : 1;
|
|
733
744
|
continue;
|
|
734
745
|
}
|
|
746
|
+
if (arg === '--agent-plugin-init') {
|
|
747
|
+
agentPluginInit = true;
|
|
748
|
+
const slug = argv[index + 1];
|
|
749
|
+
if (!slug || slug.startsWith('-')) {
|
|
750
|
+
console.error(`${ICON.err} ${c.red('--agent-plugin-init requires a slug')}`);
|
|
751
|
+
process.exit(1);
|
|
752
|
+
}
|
|
753
|
+
agentPluginSlug = slug;
|
|
754
|
+
index += 1;
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
735
757
|
if (!arg.startsWith('-')) {
|
|
736
758
|
if (attachTmux && !attachTmuxName) {
|
|
737
759
|
attachTmuxName = arg;
|
|
@@ -773,6 +795,8 @@ function parseArgs(argv) {
|
|
|
773
795
|
changeAuditList,
|
|
774
796
|
changeAuditShow,
|
|
775
797
|
injectChangeAuditHunk,
|
|
798
|
+
agentPluginInit,
|
|
799
|
+
agentPluginSlug,
|
|
776
800
|
};
|
|
777
801
|
}
|
|
778
802
|
// Reads a single line from stdin without echoing keystrokes. Used for password
|
|
@@ -2311,6 +2335,98 @@ async function refreshDefaultHttpsCertificateSafely() {
|
|
|
2311
2335
|
return false;
|
|
2312
2336
|
}
|
|
2313
2337
|
}
|
|
2338
|
+
// ---------------------------------------------------------------------------
|
|
2339
|
+
// Agent plugin scaffolding
|
|
2340
|
+
// ---------------------------------------------------------------------------
|
|
2341
|
+
const VALID_PLUGIN_SLUG_RE = /^[a-z][a-z0-9-]*$/;
|
|
2342
|
+
async function runAgentPluginInit(slug) {
|
|
2343
|
+
if (!VALID_PLUGIN_SLUG_RE.test(slug) || slug.length > 40) {
|
|
2344
|
+
console.error(`${ICON.err} ${c.red(`Invalid slug: "${slug}"`)}`);
|
|
2345
|
+
console.error(' Slug must be lowercase alphanumeric with hyphens, 1–40 chars (e.g. "my-agent").');
|
|
2346
|
+
process.exit(1);
|
|
2347
|
+
}
|
|
2348
|
+
const pluginsDir = path.join(os.homedir(), '.termdock', 'agent-plugins');
|
|
2349
|
+
const pluginDir = path.join(pluginsDir, slug);
|
|
2350
|
+
if (fs.existsSync(pluginDir)) {
|
|
2351
|
+
console.error(`${ICON.err} ${c.red(`Plugin "${slug}" already exists at ${pluginDir}`)}`);
|
|
2352
|
+
console.error(' Remove it first or use a different slug.');
|
|
2353
|
+
process.exit(1);
|
|
2354
|
+
}
|
|
2355
|
+
const manifest = {
|
|
2356
|
+
version: 1,
|
|
2357
|
+
slug,
|
|
2358
|
+
displayName: slug, // user should rename
|
|
2359
|
+
aliases: [slug],
|
|
2360
|
+
accentColor: '#4385BE',
|
|
2361
|
+
hooks: {
|
|
2362
|
+
target: `~/.${slug}/settings.json`,
|
|
2363
|
+
events: [
|
|
2364
|
+
{ hook: 'SessionStart', event: 'session-start' },
|
|
2365
|
+
{ hook: 'UserPromptSubmit', event: 'prompt-submit' },
|
|
2366
|
+
{ hook: 'Notification', event: 'notification' },
|
|
2367
|
+
{ hook: 'PostToolUse', event: 'tool-complete' },
|
|
2368
|
+
{ hook: 'Stop', event: 'stop' },
|
|
2369
|
+
{ hook: 'SessionEnd', event: 'session-end' },
|
|
2370
|
+
],
|
|
2371
|
+
},
|
|
2372
|
+
resume: {
|
|
2373
|
+
command: `${slug} --resume {sessionId}`,
|
|
2374
|
+
staleFlags: ['--resume', '-r', '--session'],
|
|
2375
|
+
},
|
|
2376
|
+
};
|
|
2377
|
+
const iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2378
|
+
<circle cx="12" cy="12" r="10"/>
|
|
2379
|
+
<path d="M12 6v6l4 2"/>
|
|
2380
|
+
</svg>
|
|
2381
|
+
`;
|
|
2382
|
+
fs.mkdirSync(pluginDir, { recursive: true });
|
|
2383
|
+
fs.writeFileSync(path.join(pluginDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
|
2384
|
+
fs.writeFileSync(path.join(pluginDir, 'icon.svg'), iconSvg, 'utf8');
|
|
2385
|
+
console.log(`${ICON.ok} Plugin scaffolded at ${c.cyan(pluginDir)}`);
|
|
2386
|
+
console.log('');
|
|
2387
|
+
console.log(` ${c.bold('manifest.json')} — agent identity, aliases, hook events, resume command`);
|
|
2388
|
+
console.log(` ${c.bold('icon.svg')} — brand icon (SVG, used as CSS mask)`);
|
|
2389
|
+
console.log('');
|
|
2390
|
+
console.log(c.bold('Next steps:'));
|
|
2391
|
+
console.log('');
|
|
2392
|
+
console.log(` 1. Edit ${c.cyan(`"displayName"`)} — the human-readable label shown in the UI.`);
|
|
2393
|
+
console.log(` 2. Check ${c.cyan(`"aliases"`)} — add all command names your agent uses.`);
|
|
2394
|
+
console.log(` 3. Set ${c.cyan(`"accentColor"`)} to your agent's brand color (hex, e.g. "#FF6600").`);
|
|
2395
|
+
console.log(` 4. Verify ${c.cyan(`"hooks.target"`)} — the path to your agent's own hooks config file.`);
|
|
2396
|
+
console.log(` Use ${c.dim('~')}-abbreviated absolute paths (e.g. ${c.dim('~/.claude/settings.json')}).`);
|
|
2397
|
+
console.log(` 5. Map ${c.cyan(`"hooks.events"`)} — left side is your agent's event name,`);
|
|
2398
|
+
console.log(` right side is one of our sentinel events:`);
|
|
2399
|
+
console.log('');
|
|
2400
|
+
console.log(' ' + c.bold('Sentinel events (what they mean):'));
|
|
2401
|
+
console.log(` ${c.green('session-start')} — agent session begins (resets state)`);
|
|
2402
|
+
console.log(` ${c.green('prompt-submit')} — user sent a prompt (agent starts working)`);
|
|
2403
|
+
console.log(` ${c.green('permission-request')} — agent is blocked, needs your approval`);
|
|
2404
|
+
console.log(` ${c.green('question-asked')} — agent is blocked, needs your answer`);
|
|
2405
|
+
console.log(` ${c.green('notification')} — agent nudged you (only escalates mid-turn)`);
|
|
2406
|
+
console.log(` ${c.green('tool-complete')} — a tool finished (recovery from waiting)`);
|
|
2407
|
+
console.log(` ${c.green('stop')} — turn finished, agent idle`);
|
|
2408
|
+
console.log(` ${c.green('session-end')} — agent session ended (keeps resume id)`);
|
|
2409
|
+
console.log('');
|
|
2410
|
+
console.log(' Optional per-event fields:');
|
|
2411
|
+
console.log(` ${c.dim('"matcher"')} — narrow the event by type (e.g. ${c.dim('"elicitation_dialog"')})`);
|
|
2412
|
+
console.log(` ${c.dim('"timeout"')} — hook timeout in seconds (default: no limit)`);
|
|
2413
|
+
console.log('');
|
|
2414
|
+
console.log(` 6. Replace ${c.cyan('icon.svg')} with your agent's brand icon (SVG, 24×24 viewBox).`);
|
|
2415
|
+
console.log(` The icon is rendered white-on-brand-color as a CSS mask.`);
|
|
2416
|
+
console.log('');
|
|
2417
|
+
console.log(` 7. Set the ${c.cyan('"resume.command"')} to your agent's session resume command.`);
|
|
2418
|
+
console.log(` Must contain ${c.dim('{sessionId}')} — replaced with the real id at runtime.`);
|
|
2419
|
+
console.log('');
|
|
2420
|
+
console.log(' After editing, restart termdock to load the plugin:');
|
|
2421
|
+
console.log(` ${c.cyan('td --stop && td')}`);
|
|
2422
|
+
console.log('');
|
|
2423
|
+
console.log(' Then go to Settings → Agent Hooks to install hooks for your agent.');
|
|
2424
|
+
console.log('');
|
|
2425
|
+
console.log(' For manual plugins from JSON, use the "Add Plugin" panel in Settings.');
|
|
2426
|
+
console.log('');
|
|
2427
|
+
console.log(c.dim(' Built-in agents with hook support live in src/server/agent/installers.ts'));
|
|
2428
|
+
console.log(c.dim(' as a reference for more complex integrations (owned JS/TS files, etc.).'));
|
|
2429
|
+
}
|
|
2314
2430
|
async function main() {
|
|
2315
2431
|
if (options.setupLocalHttps) {
|
|
2316
2432
|
try {
|
|
@@ -2362,6 +2478,10 @@ async function main() {
|
|
|
2362
2478
|
await runInjectChangeAuditHunk(options.injectChangeAuditHunk);
|
|
2363
2479
|
process.exit(0);
|
|
2364
2480
|
}
|
|
2481
|
+
if (options.agentPluginInit) {
|
|
2482
|
+
await runAgentPluginInit(options.agentPluginSlug);
|
|
2483
|
+
process.exit(0);
|
|
2484
|
+
}
|
|
2365
2485
|
if (options.setPassword) {
|
|
2366
2486
|
await runSetPassword();
|
|
2367
2487
|
process.exit(0);
|
|
@@ -2795,10 +2795,9 @@ router.get('/diff', async (req, res) => {
|
|
|
2795
2795
|
args.push('--', pathspec);
|
|
2796
2796
|
return args;
|
|
2797
2797
|
};
|
|
2798
|
-
//
|
|
2799
|
-
// staged
|
|
2800
|
-
//
|
|
2801
|
-
// additions/deletions/renames look empty in the UI.
|
|
2798
|
+
// Use `git diff HEAD` to present a merged view of all worktree changes
|
|
2799
|
+
// (staged + unstaged) against HEAD. Untracked files are handled separately
|
|
2800
|
+
// with `--no-index` below.
|
|
2802
2801
|
let truncatedByGit = false;
|
|
2803
2802
|
const readLimitedDiff = async (args, maxBytes = MAX_DIFF_BYTES) => {
|
|
2804
2803
|
const gitResult = await execGitLimited(args, gitCwd, maxBytes, false, controller.signal, { id: requestId, action, op: 'git.diff', path: requestedPath, extra: { traceId, interactionId, requestSlotId } });
|
|
@@ -2812,12 +2811,15 @@ router.get('/diff', async (req, res) => {
|
|
|
2812
2811
|
truncatedByGit = true;
|
|
2813
2812
|
return gitResult.stdout;
|
|
2814
2813
|
};
|
|
2814
|
+
const buildHeadDiffArgs = () => {
|
|
2815
|
+
const args = ['diff', '-M', ...diffOptionArgs, 'HEAD'];
|
|
2816
|
+
if (pathspec)
|
|
2817
|
+
args.push('--', pathspec);
|
|
2818
|
+
return args;
|
|
2819
|
+
};
|
|
2815
2820
|
let diff = cached
|
|
2816
2821
|
? await readLimitedDiff(buildDiffArgs(true))
|
|
2817
|
-
:
|
|
2818
|
-
await readLimitedDiff(buildDiffArgs(true)),
|
|
2819
|
-
await readLimitedDiff(buildDiffArgs(false)),
|
|
2820
|
-
].filter(Boolean).join('\n');
|
|
2822
|
+
: await readLimitedDiff(buildHeadDiffArgs());
|
|
2821
2823
|
let totalBytes = getDiffByteLength(diff);
|
|
2822
2824
|
// If git diff produced no output for a specific file, it might be an
|
|
2823
2825
|
// untracked (new) file. Use `git diff --no-index /dev/null <path>` to
|