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.
Files changed (73) hide show
  1. package/dist/client/assets/{abnfDiagram-VRR7QNED-G_YxqZwv.js → abnfDiagram-VRR7QNED-DWXqvEjR.js} +1 -1
  2. package/dist/client/assets/{arc-BuvKiJCC.js → arc-B5joCHka.js} +1 -1
  3. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-BvkwEVZw.js → architectureDiagram-ZJ3FMSHR-CJQE6w7r.js} +1 -1
  4. package/dist/client/assets/{blockDiagram-677ZJIJ3-C3bxox0Z.js → blockDiagram-677ZJIJ3-C1TGBe5x.js} +1 -1
  5. package/dist/client/assets/{c4Diagram-LMCZKHZV-DLORcr8m.js → c4Diagram-LMCZKHZV-DGd2HHf1.js} +1 -1
  6. package/dist/client/assets/channel-DTPJjYD6.js +1 -0
  7. package/dist/client/assets/{chunk-2Q5K7J3B-Dnqi-8g0.js → chunk-2Q5K7J3B-n9gfTHAy.js} +1 -1
  8. package/dist/client/assets/{chunk-32BRIVSS-D70B4HxL.js → chunk-32BRIVSS-C2hRZ-BH.js} +1 -1
  9. package/dist/client/assets/{chunk-5VM5RSS4-Cl7t39aR.js → chunk-5VM5RSS4-DSSrc2sZ.js} +1 -1
  10. package/dist/client/assets/{chunk-EX3LRPZG-DKMD4DfE.js → chunk-EX3LRPZG-DID8XrnC.js} +1 -1
  11. package/dist/client/assets/{chunk-JWPE2WC7-CsN_pMOw.js → chunk-JWPE2WC7-Da3QhSKj.js} +1 -1
  12. package/dist/client/assets/{chunk-MOJQB5TN-D8BHaQpE.js → chunk-MOJQB5TN-DXZjOrxN.js} +1 -1
  13. package/dist/client/assets/{chunk-RYQCIY6F-B8BtUiHF.js → chunk-RYQCIY6F-mrNVFK82.js} +1 -1
  14. package/dist/client/assets/{chunk-V7JOEXUC-CdN7tlvn.js → chunk-V7JOEXUC-DJR67KDW.js} +1 -1
  15. package/dist/client/assets/{chunk-VR4S4FIN-UPjL_wl4.js → chunk-VR4S4FIN-nkbsGz1_.js} +1 -1
  16. package/dist/client/assets/{chunk-XXDRQBXY--eX5Na_l.js → chunk-XXDRQBXY-j9PgGxJy.js} +1 -1
  17. package/dist/client/assets/classDiagram-OUVF2IWQ-2YuDtvgU.js +1 -0
  18. package/dist/client/assets/classDiagram-v2-EOCWNBFH-2YuDtvgU.js +1 -0
  19. package/dist/client/assets/{cose-bilkent-JH36ORCC-CEAcZx3N.js → cose-bilkent-JH36ORCC-Dur7GuvH.js} +1 -1
  20. package/dist/client/assets/{cynefin-VYW2F7L2-up8zIMDo.js → cynefin-VYW2F7L2-5FtFnipB.js} +1 -1
  21. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-DZ2SQpCq.js → cynefinDiagram-TSTJHNR4-Bl8p8LMV.js} +1 -1
  22. package/dist/client/assets/{dagre-VKFMJZFB-DOoLtq9O.js → dagre-VKFMJZFB-BlhiJve1.js} +1 -1
  23. package/dist/client/assets/{diagram-FQU43EPY-x8umATxt.js → diagram-FQU43EPY-Cvwc7nAt.js} +1 -1
  24. package/dist/client/assets/{diagram-G47NLZAW-Kle7Toxb.js → diagram-G47NLZAW-Cpi_bvL3.js} +1 -1
  25. package/dist/client/assets/{diagram-NH7WQ7WH-CezkkMqP.js → diagram-NH7WQ7WH-B7ariBtD.js} +1 -1
  26. package/dist/client/assets/{diagram-OA4YK3LP-CaU0u6UQ.js → diagram-OA4YK3LP-BRC3pwSV.js} +1 -1
  27. package/dist/client/assets/{diagram-WEI45ONY-B4m53TVj.js → diagram-WEI45ONY-qrT03hI1.js} +1 -1
  28. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-Dpwjo8_v.js → ebnfDiagram-CCIWWBDH-BDwvSKrA.js} +1 -1
  29. package/dist/client/assets/{erDiagram-Q63AITRT-BU4R1QVk.js → erDiagram-Q63AITRT-B7sW79AM.js} +1 -1
  30. package/dist/client/assets/{flowDiagram-23GEKE2U-BFtDsA0k.js → flowDiagram-23GEKE2U-OGMG83Xx.js} +1 -1
  31. package/dist/client/assets/{ganttDiagram-NO4QXBWP-DtSEfub5.js → ganttDiagram-NO4QXBWP-DR7axkaq.js} +1 -1
  32. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-BGukYlT3.js → gitGraphDiagram-IHSO6WYX-C_tNm0YG.js} +1 -1
  33. package/dist/client/assets/index-BktcGINa.js +408 -0
  34. package/dist/client/assets/index-DF1hQ7NF.css +1 -0
  35. package/dist/client/assets/{infoDiagram-FWYZ7A6U-Bx1NQNsL.js → infoDiagram-FWYZ7A6U-gtqUM7v8.js} +1 -1
  36. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-DHlxYJjl.js → ishikawaDiagram-FXEZZL3T-Dt_GdmMN.js} +1 -1
  37. package/dist/client/assets/{journeyDiagram-5HDEW3XC-BOZzlR6d.js → journeyDiagram-5HDEW3XC-jb5z-0p5.js} +1 -1
  38. package/dist/client/assets/{kanban-definition-HUTT4EX6-C2nWuUXX.js → kanban-definition-HUTT4EX6-OJixE6rE.js} +1 -1
  39. package/dist/client/assets/{linear-DtblW0g4.js → linear-BhufR60-.js} +1 -1
  40. package/dist/client/assets/{mermaid.core-BKSuG721.js → mermaid.core-BCzoNRDZ.js} +4 -4
  41. package/dist/client/assets/{mindmap-definition-LN4V7U3C-Co3LLQGt.js → mindmap-definition-LN4V7U3C-Bytw0Q4_.js} +1 -1
  42. package/dist/client/assets/{pegDiagram-2B236MQR-DSUG1nB1.js → pegDiagram-2B236MQR-3Mhqh7uj.js} +1 -1
  43. package/dist/client/assets/{pieDiagram-ENE6RG2P-DvEr0UPf.js → pieDiagram-ENE6RG2P-CkBKIVEt.js} +1 -1
  44. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-C2diLBZ8.js → quadrantDiagram-ABIIQ3AL-D6ogfZv2.js} +1 -1
  45. package/dist/client/assets/{railroadDiagram-RFXS5EU6-CqYZHCvf.js → railroadDiagram-RFXS5EU6-SQbw572c.js} +1 -1
  46. package/dist/client/assets/{requirementDiagram-TGXJPOKE-CFAKdPQp.js → requirementDiagram-TGXJPOKE-DO1wNYeJ.js} +1 -1
  47. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-DV95NA9H.js → sankeyDiagram-HTMAVEWB-B-QG2oOv.js} +1 -1
  48. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-cbv0xzG-.js → sequenceDiagram-DBY2YBRQ-gJDETC-I.js} +1 -1
  49. package/dist/client/assets/{sizeCapture-X5ZJPWSS-DpkzYB1F.js → sizeCapture-X5ZJPWSS-h3hWKe_0.js} +1 -1
  50. package/dist/client/assets/{stateDiagram-2N3HPSRC-BnAwPP0V.js → stateDiagram-2N3HPSRC-C-7j4jgP.js} +1 -1
  51. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-cGyWnCe2.js +1 -0
  52. package/dist/client/assets/{swimlanes-5IMT3BWC-kObRIKfj.js → swimlanes-5IMT3BWC-Bktms0sT.js} +2 -2
  53. package/dist/client/assets/swimlanesDiagram-G3AALYLV-BCoFgyBb.js +8 -0
  54. package/dist/client/assets/{timeline-definition-FHXFAJF6-BbnAJ8pO.js → timeline-definition-FHXFAJF6-DOScKd5g.js} +1 -1
  55. package/dist/client/assets/{vennDiagram-L72KCM5P-sIu_hWKx.js → vennDiagram-L72KCM5P-BWPRTzpI.js} +1 -1
  56. package/dist/client/assets/{wardleyDiagram-EHGQE667-DpGdJtdb.js → wardleyDiagram-EHGQE667-Dwh2JB3n.js} +1 -1
  57. package/dist/client/assets/{xychartDiagram-FW5EYKEG-C5F9faN2.js → xychartDiagram-FW5EYKEG-DmDUfhQl.js} +1 -1
  58. package/dist/client/index.html +2 -2
  59. package/dist/client/sw.js +1 -1
  60. package/dist/server/agent/installers.js +194 -3
  61. package/dist/server/agent/plugins.js +271 -0
  62. package/dist/server/agent/registry.js +82 -20
  63. package/dist/server/cli.js +122 -2
  64. package/dist/server/routes/filesystem.js +10 -8
  65. package/dist/server/routes/terminal.js +83 -6
  66. package/package.json +1 -1
  67. package/dist/client/assets/channel-BnGIwxUi.js +0 -1
  68. package/dist/client/assets/classDiagram-OUVF2IWQ-DMigd9mj.js +0 -1
  69. package/dist/client/assets/classDiagram-v2-EOCWNBFH-DMigd9mj.js +0 -1
  70. package/dist/client/assets/index-CP3_puZd.js +0 -395
  71. package/dist/client/assets/index-CxBkmASY.css +0 -1
  72. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DtPm_oHq.js +0 -1
  73. 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
+ }
@@ -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
- Read a natural-language explanation from stdin and inject
149
- it for the specified Termdock hunk ID.
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
- // Default sidebar diffs should represent every changed file from the list:
2799
- // staged-only changes, unstaged changes, and untracked files. Plain
2800
- // `git diff` only shows unstaged tracked edits, which made staged-only
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