termdock 1.4.70 → 1.4.72

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 (81) hide show
  1. package/README.md +36 -0
  2. package/dist/client/assets/{ModelPreview-Cs14IQuQ.js → ModelPreview-i9dS1A3I.js} +1 -1
  3. package/dist/client/assets/{abnfDiagram-VRR7QNED-DuDfdv-6.js → abnfDiagram-VRR7QNED-Cs-ty-HA.js} +1 -1
  4. package/dist/client/assets/{arc-CSYzGV_2.js → arc-CIX5k_JD.js} +1 -1
  5. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-YiZ0HXNi.js → architectureDiagram-ZJ3FMSHR-DnPnQoJy.js} +1 -1
  6. package/dist/client/assets/{blockDiagram-677ZJIJ3-IaQMzmYv.js → blockDiagram-677ZJIJ3-Dhew-L5v.js} +1 -1
  7. package/dist/client/assets/{c4Diagram-LMCZKHZV-BwyuIPRL.js → c4Diagram-LMCZKHZV-BSsEMeWn.js} +1 -1
  8. package/dist/client/assets/channel-CASnt1pP.js +1 -0
  9. package/dist/client/assets/{chunk-2Q5K7J3B-BtHQYLBT.js → chunk-2Q5K7J3B-BWm--1Mo.js} +1 -1
  10. package/dist/client/assets/{chunk-32BRIVSS-CXrK07sW.js → chunk-32BRIVSS-D_WFmuvb.js} +1 -1
  11. package/dist/client/assets/{chunk-5VM5RSS4-BfBshn11.js → chunk-5VM5RSS4-CQdzqlQg.js} +1 -1
  12. package/dist/client/assets/{chunk-EX3LRPZG-BJbilNaf.js → chunk-EX3LRPZG-Cm3zLQpt.js} +1 -1
  13. package/dist/client/assets/{chunk-JWPE2WC7-wgcmSRlL.js → chunk-JWPE2WC7-Cmcv5sEN.js} +1 -1
  14. package/dist/client/assets/{chunk-MOJQB5TN-8VZVUeDI.js → chunk-MOJQB5TN-Bam44EwJ.js} +1 -1
  15. package/dist/client/assets/{chunk-RYQCIY6F-B900YBFa.js → chunk-RYQCIY6F-DIVOuE8y.js} +1 -1
  16. package/dist/client/assets/{chunk-V7JOEXUC-BK4_RLz-.js → chunk-V7JOEXUC-BfOTf7yg.js} +1 -1
  17. package/dist/client/assets/{chunk-VR4S4FIN-Kj5_ycjB.js → chunk-VR4S4FIN-DbiXn0dd.js} +1 -1
  18. package/dist/client/assets/{chunk-XXDRQBXY-BolvPLRz.js → chunk-XXDRQBXY-C4pOe4OC.js} +1 -1
  19. package/dist/client/assets/classDiagram-OUVF2IWQ-Da_SQWEq.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-EOCWNBFH-Da_SQWEq.js +1 -0
  21. package/dist/client/assets/{cose-bilkent-JH36ORCC-9_9_SLqv.js → cose-bilkent-JH36ORCC-CCoSPmoc.js} +1 -1
  22. package/dist/client/assets/{cynefin-VYW2F7L2-yEpoi3vG.js → cynefin-VYW2F7L2-B2lYz7sl.js} +1 -1
  23. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-Dy-gvup_.js → cynefinDiagram-TSTJHNR4-Cd7crN9s.js} +1 -1
  24. package/dist/client/assets/{dagre-VKFMJZFB-CNjJ5pRK.js → dagre-VKFMJZFB-q9Fj9HuQ.js} +1 -1
  25. package/dist/client/assets/{diagram-FQU43EPY-uZ73Rj3T.js → diagram-FQU43EPY-iqHy5YHW.js} +1 -1
  26. package/dist/client/assets/{diagram-G47NLZAW-Cxt7TYZT.js → diagram-G47NLZAW-BE-jlh1B.js} +1 -1
  27. package/dist/client/assets/{diagram-NH7WQ7WH-B4TKinuZ.js → diagram-NH7WQ7WH-DmP7_WOC.js} +1 -1
  28. package/dist/client/assets/{diagram-OA4YK3LP-CGSuenHU.js → diagram-OA4YK3LP-CHx_k8Ar.js} +1 -1
  29. package/dist/client/assets/{diagram-WEI45ONY-DqqzDaUZ.js → diagram-WEI45ONY-D0wImpAF.js} +1 -1
  30. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-B2SgXL5w.js → ebnfDiagram-CCIWWBDH-DyCB0rF8.js} +1 -1
  31. package/dist/client/assets/{erDiagram-Q63AITRT-D-ibkr8y.js → erDiagram-Q63AITRT-SRcd1B2Z.js} +1 -1
  32. package/dist/client/assets/{flowDiagram-23GEKE2U-BPeyMqr-.js → flowDiagram-23GEKE2U-D0i9mFwt.js} +1 -1
  33. package/dist/client/assets/{ganttDiagram-NO4QXBWP-DcEqEzzu.js → ganttDiagram-NO4QXBWP-BfwGIYDn.js} +1 -1
  34. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DTZ6UCQY.js → gitGraphDiagram-IHSO6WYX-BXkJQP7O.js} +1 -1
  35. package/dist/client/assets/index-BA-DqJM2.js +451 -0
  36. package/dist/client/assets/{infoDiagram-FWYZ7A6U-BHrzVXeX.js → infoDiagram-FWYZ7A6U-CM-Ebv9R.js} +1 -1
  37. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-CwuoQJku.js → ishikawaDiagram-FXEZZL3T-U-qnP7xi.js} +1 -1
  38. package/dist/client/assets/{journeyDiagram-5HDEW3XC-ZEcy0rDZ.js → journeyDiagram-5HDEW3XC-D4RxvsuL.js} +1 -1
  39. package/dist/client/assets/{kanban-definition-HUTT4EX6-BDBawNbr.js → kanban-definition-HUTT4EX6-Dm_asCYK.js} +1 -1
  40. package/dist/client/assets/{linear-smYQWzDV.js → linear-Zhe8d635.js} +1 -1
  41. package/dist/client/assets/{mermaid.core-eJs7Ouvw.js → mermaid.core-D_NWIuMY.js} +4 -4
  42. package/dist/client/assets/{mindmap-definition-LN4V7U3C-BK1IZ9zg.js → mindmap-definition-LN4V7U3C-DXpNidd5.js} +1 -1
  43. package/dist/client/assets/{pegDiagram-2B236MQR-B3-mVNgX.js → pegDiagram-2B236MQR-DdOwOEgu.js} +1 -1
  44. package/dist/client/assets/{pieDiagram-ENE6RG2P-Bkeeyx5l.js → pieDiagram-ENE6RG2P-D_A9HoY_.js} +1 -1
  45. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DouIklEO.js → quadrantDiagram-ABIIQ3AL-D-Y3cLEF.js} +1 -1
  46. package/dist/client/assets/{railroadDiagram-RFXS5EU6-BB_jXQd2.js → railroadDiagram-RFXS5EU6-DD6eFV82.js} +1 -1
  47. package/dist/client/assets/{requirementDiagram-TGXJPOKE-u56IHfiR.js → requirementDiagram-TGXJPOKE-B59HJgL2.js} +1 -1
  48. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-eDi2cs5E.js → sankeyDiagram-HTMAVEWB-C2BvHMH8.js} +1 -1
  49. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-DnAA2xYa.js → sequenceDiagram-DBY2YBRQ-3004tCRm.js} +1 -1
  50. package/dist/client/assets/{sizeCapture-X5ZJPWSS-BogPui79.js → sizeCapture-X5ZJPWSS-DCBhg8QW.js} +1 -1
  51. package/dist/client/assets/{stateDiagram-2N3HPSRC--EeXDy2M.js → stateDiagram-2N3HPSRC-BVqiKVe0.js} +1 -1
  52. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Z0GHWyoF.js +1 -0
  53. package/dist/client/assets/{swimlanes-5IMT3BWC-DZAw7-kY.js → swimlanes-5IMT3BWC-DS6-sdEW.js} +2 -2
  54. package/dist/client/assets/swimlanesDiagram-G3AALYLV-el4HDX0G.js +8 -0
  55. package/dist/client/assets/{timeline-definition-FHXFAJF6-DkcnpB-Y.js → timeline-definition-FHXFAJF6-wVOEmQwG.js} +1 -1
  56. package/dist/client/assets/{vennDiagram-L72KCM5P-BStcDGEf.js → vennDiagram-L72KCM5P-DMZiSJDk.js} +1 -1
  57. package/dist/client/assets/{wardleyDiagram-EHGQE667-Cj4CQn1M.js → wardleyDiagram-EHGQE667-Dx5Aob4Y.js} +1 -1
  58. package/dist/client/assets/{xychartDiagram-FW5EYKEG-_uBCjai8.js → xychartDiagram-FW5EYKEG-BqMhkhmW.js} +1 -1
  59. package/dist/client/index.html +1 -1
  60. package/dist/client/sw.js +1 -1
  61. package/dist/server/agent/autoTitle.js +16 -36
  62. package/dist/server/agent/installers.js +98 -15
  63. package/dist/server/agent/pluginPackages.js +289 -0
  64. package/dist/server/agent/plugins.js +164 -15
  65. package/dist/server/agent/registry.js +1 -1
  66. package/dist/server/agent/session.js +10 -1
  67. package/dist/server/agent/titleNamerCatalog.js +131 -2
  68. package/dist/server/agentHook.js +47 -0
  69. package/dist/server/cli.js +224 -13
  70. package/dist/server/entry.js +11 -2
  71. package/dist/server/routes/terminal.js +267 -37
  72. package/dist/server/utils/csrfProtection.js +4 -1
  73. package/dist/server/utils/settings.js +18 -5
  74. package/package.json +1 -1
  75. package/runtime-manifest.json +2 -2
  76. package/dist/client/assets/channel-BotxtiNE.js +0 -1
  77. package/dist/client/assets/classDiagram-OUVF2IWQ-CLWlbTSC.js +0 -1
  78. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CLWlbTSC.js +0 -1
  79. package/dist/client/assets/index-ivnGnbVg.js +0 -451
  80. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dc-3HKTs.js +0 -1
  81. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CI1Wlr0V.js +0 -8
@@ -13,13 +13,42 @@
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
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';
16
+ export const PLUGINS_DIR = path.join(os.homedir(), '.termdock', 'agent-plugins');
17
+ export const MANIFEST_FILE = 'manifest.json';
18
+ export const ICON_FILE = 'icon.svg';
19
+ export const SOURCE_METADATA_FILE = '.termdock-source.json';
19
20
  // ---------------------------------------------------------------------------
20
21
  // Manifest schema
21
22
  // ---------------------------------------------------------------------------
22
23
  export const PLUGIN_MANIFEST_VERSION = 2;
24
+ function parseSourceMetadata(raw) {
25
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
26
+ return null;
27
+ const value = raw;
28
+ if (value.version !== 1 || !['git', 'local', 'manifest'].includes(String(value.type)))
29
+ return null;
30
+ return {
31
+ version: 1,
32
+ type: value.type,
33
+ source: typeof value.source === 'string' ? value.source : null,
34
+ revision: typeof value.revision === 'string' ? value.revision : null,
35
+ latestRevision: typeof value.latestRevision === 'string' ? value.latestRevision : null,
36
+ installedAt: typeof value.installedAt === 'number' ? value.installedAt : 0,
37
+ updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : 0,
38
+ checkedAt: typeof value.checkedAt === 'number' ? value.checkedAt : null,
39
+ };
40
+ }
41
+ export function readPluginSourceMetadata(dir) {
42
+ try {
43
+ return parseSourceMetadata(JSON.parse(fs.readFileSync(path.join(dir, SOURCE_METADATA_FILE), 'utf8')));
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ export function writePluginSourceMetadata(dir, metadata) {
50
+ fs.writeFileSync(path.join(dir, SOURCE_METADATA_FILE), JSON.stringify(metadata, null, 2), 'utf8');
51
+ }
23
52
  // ---------------------------------------------------------------------------
24
53
  // Known sentinel event kinds — the set of events our state machine accepts
25
54
  // ---------------------------------------------------------------------------
@@ -48,6 +77,14 @@ function isValidSlug(s) {
48
77
  // Hex color validation
49
78
  // ---------------------------------------------------------------------------
50
79
  const HEX_COLOR_RE = /^#[0-9A-Fa-f]{6}$/;
80
+ function isSafeSvgIcon(svg) {
81
+ return /^\s*<svg\b/i.test(svg)
82
+ && !/<(?:script|style|foreignObject|iframe|object|embed)\b/i.test(svg)
83
+ && !/<\?(?:xml|xml-stylesheet)\b|<!DOCTYPE\b/i.test(svg)
84
+ && !/\bon[a-z]+\s*=/i.test(svg)
85
+ && !/(?:href|src)\s*=\s*["']?\s*(?!#)[^"'\s>]+/i.test(svg)
86
+ && !/url\(\s*(?!["']?#)[^)]+\)/i.test(svg);
87
+ }
51
88
  // ---------------------------------------------------------------------------
52
89
  // Path resolution: ~ → home directory
53
90
  // ---------------------------------------------------------------------------
@@ -57,6 +94,33 @@ function resolveHome(p) {
57
94
  }
58
95
  return p;
59
96
  }
97
+ function validateHookTarget(target) {
98
+ if (!target.startsWith('~/') || !target.toLowerCase().endsWith('.json')) {
99
+ return 'hooks.target must be a ~/ path to a JSON file';
100
+ }
101
+ const home = path.resolve(os.homedir());
102
+ const resolved = path.resolve(home, target.slice(2));
103
+ if (resolved === home || !resolved.startsWith(`${home}${path.sep}`)) {
104
+ return 'hooks.target must stay inside the user home directory';
105
+ }
106
+ // Existing symlinks in any target component could redirect a later write
107
+ // outside $HOME. Refuse them instead of relying on a racy realpath check.
108
+ let current = home;
109
+ for (const part of path.relative(home, resolved).split(path.sep)) {
110
+ current = path.join(current, part);
111
+ try {
112
+ if (fs.lstatSync(current).isSymbolicLink()) {
113
+ return 'hooks.target may not traverse symbolic links';
114
+ }
115
+ }
116
+ catch (error) {
117
+ if (error.code !== 'ENOENT') {
118
+ return 'hooks.target could not be safely inspected';
119
+ }
120
+ }
121
+ }
122
+ return null;
123
+ }
60
124
  export const V1_MIGRATION_GUIDE_COMMAND = 'td agent-plugin --json';
61
125
  export const V1_MIGRATION_AI_PROMPT = [
62
126
  'Please migrate the attached Termdock Agent plugin manifest from v1 to v2.',
@@ -86,12 +150,13 @@ export function validateManifest(raw, dir) {
86
150
  errors.push('slug must be a lowercase alphanumeric string with hyphens (max 40 chars)');
87
151
  }
88
152
  const displayName = m.displayName;
89
- if (typeof displayName !== 'string' || displayName.trim().length === 0) {
90
- errors.push('displayName is required and must be a non-empty string');
153
+ if (typeof displayName !== 'string' || displayName.trim().length === 0 || displayName.length > 80) {
154
+ errors.push('displayName is required and must be a non-empty string (max 80 chars)');
91
155
  }
92
156
  const aliases = m.aliases;
93
- if (!Array.isArray(aliases) || aliases.length === 0 || !aliases.every((a) => typeof a === 'string' && a.trim().length > 0)) {
94
- errors.push('aliases must be a non-empty array of strings');
157
+ if (!Array.isArray(aliases) || aliases.length === 0 || aliases.length > 16
158
+ || !aliases.every((a) => typeof a === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(a))) {
159
+ errors.push('aliases must contain 1-16 safe command names');
95
160
  }
96
161
  const accentColor = m.accentColor;
97
162
  if (typeof accentColor !== 'string' || !HEX_COLOR_RE.test(accentColor)) {
@@ -167,6 +232,11 @@ export function validateManifest(raw, dir) {
167
232
  if (typeof target !== 'string' || target.trim().length === 0) {
168
233
  errors.push('hooks.target is required and must be a file path (~-abbreviated ok)');
169
234
  }
235
+ else {
236
+ const targetError = validateHookTarget(target.trim());
237
+ if (targetError)
238
+ errors.push(targetError);
239
+ }
170
240
  const events = h.events;
171
241
  if (!Array.isArray(events) || events.length === 0) {
172
242
  errors.push('hooks.events must be a non-empty array');
@@ -182,15 +252,15 @@ export function validateManifest(raw, dir) {
182
252
  const evt = e;
183
253
  const hook = evt.hook;
184
254
  const event = evt.event;
185
- if (typeof hook !== 'string' || hook.trim().length === 0) {
255
+ if (typeof hook !== 'string' || hook.trim().length === 0 || hook.length > 120) {
186
256
  errors.push(`hooks.events[${i}].hook is required`);
187
257
  }
188
258
  if (typeof event !== 'string' || !VALID_EVENT_KINDS.has(event)) {
189
259
  errors.push(`hooks.events[${i}].event must be one of: ${[...VALID_EVENT_KINDS].join(', ')}`);
190
260
  }
191
261
  const matcher = evt.matcher;
192
- if (matcher !== undefined && typeof matcher !== 'string') {
193
- errors.push(`hooks.events[${i}].matcher must be a string if provided`);
262
+ if (matcher !== undefined && (typeof matcher !== 'string' || matcher.length > 512)) {
263
+ errors.push(`hooks.events[${i}].matcher must be a string (max 512 chars) if provided`);
194
264
  }
195
265
  const timeout = evt.timeout;
196
266
  if (timeout !== undefined && (typeof timeout !== 'number' || timeout <= 0)) {
@@ -226,9 +296,20 @@ export function validateManifest(raw, dir) {
226
296
  if (typeof command !== 'string' || !command.includes('{sessionId}')) {
227
297
  errors.push('resume.command must contain the {sessionId} placeholder');
228
298
  }
299
+ else {
300
+ const tokens = command.trim().split(/\s+/);
301
+ const executable = path.basename(tokens[0] ?? '');
302
+ if (command.length > 1024
303
+ || !tokens.every((token) => /^[A-Za-z0-9_./:@%+,={}-]+$/.test(token))
304
+ || !Array.isArray(aliases)
305
+ || !aliases.includes(executable)) {
306
+ errors.push('resume.command must be a simple argv-like command whose executable is one of aliases; shell syntax is not allowed');
307
+ }
308
+ }
229
309
  const staleFlags = r.staleFlags;
230
- if (staleFlags !== undefined && (!Array.isArray(staleFlags) || !staleFlags.every((f) => typeof f === 'string'))) {
231
- errors.push('resume.staleFlags must be an array of strings if provided');
310
+ if (staleFlags !== undefined && (!Array.isArray(staleFlags) || staleFlags.length > 32
311
+ || !staleFlags.every((f) => typeof f === 'string' && /^--?[A-Za-z0-9][A-Za-z0-9-]{0,79}$/.test(f)))) {
312
+ errors.push('resume.staleFlags must be an array of safe CLI flags if provided');
232
313
  }
233
314
  resumeConfig = {
234
315
  command: command,
@@ -236,6 +317,53 @@ export function validateManifest(raw, dir) {
236
317
  };
237
318
  }
238
319
  }
320
+ // Optional automatic-title provider. Commands are argv arrays rather than
321
+ // shell snippets so a UI-installed manifest cannot gain accidental shell
322
+ // interpolation beyond the executable it explicitly declares.
323
+ let titleNamer;
324
+ if (m.titleNamer !== undefined) {
325
+ if (typeof m.titleNamer !== 'object' || Array.isArray(m.titleNamer) || m.titleNamer === null) {
326
+ errors.push('titleNamer must be an object if present');
327
+ }
328
+ else {
329
+ const n = m.titleNamer;
330
+ const command = n.command;
331
+ const args = n.args;
332
+ if (typeof command !== 'string' || command.trim().length === 0 || command.length > 1024) {
333
+ errors.push('titleNamer.command is required');
334
+ }
335
+ if (!Array.isArray(args) || args.length > 64
336
+ || !args.every((arg) => typeof arg === 'string' && arg.length <= 16_384)
337
+ || !args.some((arg) => arg.includes('{prompt}'))) {
338
+ errors.push('titleNamer.args must be a string array containing {prompt}');
339
+ }
340
+ let models;
341
+ if (n.models !== undefined) {
342
+ if (typeof n.models !== 'object' || Array.isArray(n.models) || n.models === null) {
343
+ errors.push('titleNamer.models must be an object if present');
344
+ }
345
+ else {
346
+ const rawModels = n.models;
347
+ if (typeof rawModels.command !== 'string' || rawModels.command.trim().length === 0 || rawModels.command.length > 1024) {
348
+ errors.push('titleNamer.models.command is required');
349
+ }
350
+ if (rawModels.args !== undefined && (!Array.isArray(rawModels.args) || rawModels.args.length > 64
351
+ || !rawModels.args.every((arg) => typeof arg === 'string' && arg.length <= 16_384))) {
352
+ errors.push('titleNamer.models.args must be a string array if present');
353
+ }
354
+ if (typeof rawModels.command === 'string') {
355
+ models = {
356
+ command: rawModels.command.trim(),
357
+ args: Array.isArray(rawModels.args) ? rawModels.args : undefined,
358
+ };
359
+ }
360
+ }
361
+ }
362
+ if (typeof command === 'string' && Array.isArray(args)) {
363
+ titleNamer = { command: command.trim(), args: args, models };
364
+ }
365
+ }
366
+ }
239
367
  if (errors.length > 0) {
240
368
  return {
241
369
  error: {
@@ -259,6 +387,7 @@ export function validateManifest(raw, dir) {
259
387
  statuses,
260
388
  hooks,
261
389
  resume: resumeConfig,
390
+ titleNamer,
262
391
  },
263
392
  };
264
393
  }
@@ -303,18 +432,27 @@ export function loadPlugins() {
303
432
  }
304
433
  const iconPath = path.join(dir, ICON_FILE);
305
434
  let iconMtime = 0;
306
- const iconExists = fs.existsSync(iconPath);
435
+ let iconExists = fs.existsSync(iconPath);
307
436
  if (iconExists) {
308
437
  try {
309
- iconMtime = fs.statSync(iconPath).mtimeMs;
438
+ if (!isSafeSvgIcon(fs.readFileSync(iconPath, 'utf8'))) {
439
+ errors.push({ slug: result.manifest.slug, errors: ['icon.svg contains unsafe or invalid SVG content'] });
440
+ iconExists = false;
441
+ }
442
+ else {
443
+ iconMtime = fs.statSync(iconPath).mtimeMs;
444
+ }
445
+ }
446
+ catch {
447
+ iconExists = false;
310
448
  }
311
- catch { /* keep 0 */ }
312
449
  }
313
450
  plugins.push({
314
451
  manifest: result.manifest,
315
452
  dir,
316
453
  iconPath: iconExists ? iconPath : null,
317
454
  iconMtime,
455
+ source: readPluginSourceMetadata(dir),
318
456
  });
319
457
  }
320
458
  return { plugins, errors };
@@ -327,6 +465,17 @@ export function savePlugin(manifest) {
327
465
  fs.mkdirSync(dir, { recursive: true });
328
466
  const manifestPath = path.join(dir, MANIFEST_FILE);
329
467
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
468
+ const now = Date.now();
469
+ writePluginSourceMetadata(dir, {
470
+ version: 1,
471
+ type: 'manifest',
472
+ source: null,
473
+ revision: null,
474
+ latestRevision: null,
475
+ installedAt: now,
476
+ updatedAt: now,
477
+ checkedAt: null,
478
+ });
330
479
  return dir;
331
480
  }
332
481
  /**
@@ -33,7 +33,7 @@ export function agentBySlug(slug) {
33
33
  return BY_SLUG.get(slug.trim().toLowerCase()) ?? null;
34
34
  }
35
35
  export function listAgents() {
36
- return AGENTS.slice();
36
+ return [...BY_SLUG.values()];
37
37
  }
38
38
  function matchToken(token) {
39
39
  return BY_ALIAS.get(token) ?? null;
@@ -17,6 +17,13 @@ const EVENT_KINDS = new Set([
17
17
  'session-start', 'prompt-submit', 'permission-request', 'question-asked',
18
18
  'tool-complete', 'notification', 'stop', 'session-end',
19
19
  ]);
20
+ /** A hook may brand an unclaimed pane, but it may not overwrite or decorate
21
+ * a pane already owned by a different detected Agent. */
22
+ export function agentEventMatchesCurrentAgent(current, event) {
23
+ if (event.agentSlug && !event.agent)
24
+ return false;
25
+ return !current || !event.agent || current.slug === event.agent.slug;
26
+ }
20
27
  export function defaultAgentSessionState() {
21
28
  return {
22
29
  status: 'idle',
@@ -140,13 +147,15 @@ export function parseAgentEvent(payload) {
140
147
  const eventName = nonEmpty(w.event);
141
148
  if (!eventName || !EVENT_KINDS.has(eventName))
142
149
  return null;
143
- const agent = agentBySlug(nonEmpty(w.agent));
150
+ const agentSlug = nonEmpty(w.agent);
151
+ const agent = agentBySlug(agentSlug);
144
152
  const statusId = nonEmpty(w.status);
145
153
  const status = statusId
146
154
  ? agent?.statuses?.find((definition) => definition.id === statusId) ?? null
147
155
  : null;
148
156
  return {
149
157
  agent,
158
+ agentSlug,
150
159
  kind: eventName,
151
160
  sessionId: nonEmpty(w.session_id),
152
161
  message: nonEmpty(w.message),
@@ -3,12 +3,30 @@ import fs from 'fs';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
5
  import { promisify } from 'util';
6
+ import { loadPlugins } from './plugins.js';
7
+ import { getAutoRenameAgentsSetting, getAutoRenameNamerSetting } from '../utils/settings.js';
6
8
  const execFileAsync = promisify(execFile);
7
9
  const CACHE_FRESH_MS = 24 * 60 * 60_000;
8
10
  const PROBE_TIMEOUT_MS = 10_000;
9
11
  const CACHE_FILE = path.join(os.homedir(), '.termdock', 'title-namer-catalog.json');
12
+ function pluginCommandEnv() {
13
+ const allowed = ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'TERM', 'TMPDIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME'];
14
+ const env = { NO_COLOR: '1', TERM: 'dumb' };
15
+ for (const key of allowed) {
16
+ if (process.env[key] !== undefined)
17
+ env[key] = process.env[key];
18
+ }
19
+ for (const [key, value] of Object.entries(process.env)) {
20
+ if (key.startsWith('LC_') && value !== undefined)
21
+ env[key] = value;
22
+ }
23
+ return env;
24
+ }
10
25
  let cached = null;
11
26
  let refreshPromise = null;
27
+ export function invalidateTitleNamerCatalog() {
28
+ cached = null;
29
+ }
12
30
  function normalizeCachedCatalog(input) {
13
31
  if (!input || typeof input !== 'object')
14
32
  return null;
@@ -19,7 +37,7 @@ function normalizeCachedCatalog(input) {
19
37
  if (!entry || typeof entry !== 'object')
20
38
  return [];
21
39
  const candidate = entry;
22
- if (candidate.slug !== 'codex' && candidate.slug !== 'claude')
40
+ if (typeof candidate.slug !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(candidate.slug))
23
41
  return [];
24
42
  if (!Array.isArray(candidate.models))
25
43
  return [];
@@ -155,11 +173,106 @@ async function listClaudeModels() {
155
173
  return parseClaudeSupportedModels(`${candidate.stdout ?? ''}\n${candidate.stderr ?? ''}`);
156
174
  }
157
175
  }
176
+ function normalizeDiscoveredModels(input) {
177
+ if (!Array.isArray(input))
178
+ return [];
179
+ return input.flatMap((entry) => {
180
+ if (typeof entry === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,119}$/.test(entry)) {
181
+ return [{ id: entry, displayName: entry, description: '', isDefault: false }];
182
+ }
183
+ if (!entry || typeof entry !== 'object')
184
+ return [];
185
+ const model = entry;
186
+ if (typeof model.id !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,119}$/.test(model.id))
187
+ return [];
188
+ return [{
189
+ id: model.id,
190
+ displayName: typeof model.displayName === 'string' ? model.displayName : model.id,
191
+ description: typeof model.description === 'string' ? model.description : '',
192
+ isDefault: model.isDefault === true,
193
+ }];
194
+ });
195
+ }
196
+ function expandPluginPath(value, pluginDir) {
197
+ return value.replaceAll('{pluginDir}', pluginDir);
198
+ }
199
+ async function listPluginModels(config, pluginDir) {
200
+ if (!config.models)
201
+ return [];
202
+ const { stdout } = await execFileAsync(expandPluginPath(config.models.command, pluginDir), (config.models.args ?? []).map((arg) => expandPluginPath(arg, pluginDir)), {
203
+ timeout: PROBE_TIMEOUT_MS,
204
+ maxBuffer: 256 * 1024,
205
+ cwd: pluginDir,
206
+ env: pluginCommandEnv(),
207
+ });
208
+ return normalizeDiscoveredModels(JSON.parse(stdout));
209
+ }
210
+ function pluginTitleNamers() {
211
+ return loadPlugins().plugins.flatMap((plugin) => plugin.manifest.titleNamer
212
+ ? [{
213
+ slug: plugin.manifest.slug,
214
+ displayName: plugin.manifest.displayName,
215
+ config: plugin.manifest.titleNamer,
216
+ dir: plugin.dir,
217
+ }]
218
+ : []);
219
+ }
220
+ export function shouldRunPluginTitleCommands(slug, selected, enabledAgents) {
221
+ return selected === slug
222
+ || (selected === 'auto' && enabledAgents.includes(slug));
223
+ }
224
+ function isPluginTitleExecutionEnabled(slug) {
225
+ return shouldRunPluginTitleCommands(slug, getAutoRenameNamerSetting(), getAutoRenameAgentsSetting());
226
+ }
227
+ export async function runTitleNamer(slug, prompt, model) {
228
+ let command;
229
+ let args;
230
+ let cwd;
231
+ if (slug === 'codex') {
232
+ command = 'codex';
233
+ args = ['exec', '--ephemeral', '--skip-git-repo-check', '--color', 'never', ...(model ? ['--model', model] : []), prompt];
234
+ }
235
+ else if (slug === 'claude') {
236
+ command = 'claude';
237
+ args = [...(model ? ['--model', model] : []), '--no-session-persistence', '-p', prompt];
238
+ }
239
+ else {
240
+ const provider = pluginTitleNamers().find((entry) => entry.slug === slug);
241
+ if (!provider || !isPluginTitleExecutionEnabled(slug))
242
+ return null;
243
+ cwd = provider.dir;
244
+ command = expandPluginPath(provider.config.command, provider.dir);
245
+ args = provider.config.args.flatMap((arg) => {
246
+ if (arg.includes('{model}') && !model)
247
+ return [];
248
+ return [expandPluginPath(arg, provider.dir).replaceAll('{prompt}', prompt).replaceAll('{model}', model ?? '')];
249
+ });
250
+ }
251
+ try {
252
+ const { stdout } = await execFileAsync(command, args, {
253
+ timeout: 45_000,
254
+ maxBuffer: 256 * 1024,
255
+ cwd,
256
+ env: cwd ? pluginCommandEnv() : { ...process.env, NO_COLOR: '1', TERM: 'dumb' },
257
+ });
258
+ return stdout;
259
+ }
260
+ catch {
261
+ return null;
262
+ }
263
+ }
158
264
  async function refreshTitleNamerCatalog() {
159
265
  if (refreshPromise)
160
266
  return refreshPromise;
161
267
  refreshPromise = (async () => {
162
- const [codex, claude] = await Promise.allSettled([listCodexModels(), listClaudeModels()]);
268
+ const plugins = pluginTitleNamers();
269
+ const [codex, claude, ...pluginResults] = await Promise.allSettled([
270
+ listCodexModels(),
271
+ listClaudeModels(),
272
+ ...plugins.map((provider) => provider.config.models && isPluginTitleExecutionEnabled(provider.slug)
273
+ ? listPluginModels(provider.config, provider.dir)
274
+ : Promise.resolve([])),
275
+ ]);
163
276
  const previousCodex = cached?.value.find((namer) => namer.slug === 'codex');
164
277
  const previousClaude = cached?.value.find((namer) => namer.slug === 'claude');
165
278
  const codexModels = codex.status === 'fulfilled' ? codex.value : previousCodex?.models ?? [];
@@ -179,6 +292,18 @@ async function refreshTitleNamerCatalog() {
179
292
  models: claudeModels,
180
293
  recommendedModel: recommendTitleModel(claudeModels),
181
294
  },
295
+ ...plugins.map((provider, index) => {
296
+ const result = pluginResults[index];
297
+ const previous = cached?.value.find((namer) => namer.slug === provider.slug);
298
+ const models = result?.status === 'fulfilled' ? result.value : previous?.models ?? [];
299
+ return {
300
+ slug: provider.slug,
301
+ displayName: provider.displayName,
302
+ available: provider.config.models ? result?.status === 'fulfilled' || previous?.available === true : true,
303
+ models,
304
+ recommendedModel: recommendTitleModel(models),
305
+ };
306
+ }),
182
307
  ];
183
308
  cached = { version: 1, updatedAt: Date.now(), value };
184
309
  await savePersistentCache(cached).catch(() => undefined);
@@ -195,6 +320,10 @@ export async function getTitleNamerCatalog(force = false) {
195
320
  cached = await loadPersistentCache();
196
321
  if (!cached)
197
322
  return refreshTitleNamerCatalog();
323
+ const expectedSlugs = new Set(['codex', 'claude', ...pluginTitleNamers().map((provider) => provider.slug)]);
324
+ if (cached.value.length !== expectedSlugs.size || cached.value.some((entry) => !expectedSlugs.has(entry.slug))) {
325
+ return refreshTitleNamerCatalog();
326
+ }
198
327
  if (Date.now() - cached.updatedAt > CACHE_FRESH_MS) {
199
328
  // Stale-while-revalidate: callers get the last known catalog immediately.
200
329
  void refreshTitleNamerCatalog();
@@ -103,6 +103,47 @@ function ancestorTtyDevice() {
103
103
  }
104
104
  return null;
105
105
  }
106
+ /**
107
+ * tmux passthrough is version/configuration sensitive and, more importantly,
108
+ * only works while tmux considers the escape sequence pane output. Termdock
109
+ * itself is an attached tmux client, so write the sentinel to that client's
110
+ * tty directly when possible. This also works for detached hook subprocesses
111
+ * whose own `/dev/tty` is unavailable.
112
+ */
113
+ function tmuxClientTtyDevices() {
114
+ const pane = process.env.TMUX_PANE;
115
+ if (!process.env.TMUX || !pane)
116
+ return [];
117
+ try {
118
+ const output = execFileSync('tmux', ['list-clients', '-t', pane, '-F', '#{client_tty}\t#{client_pid}'], {
119
+ encoding: 'utf8',
120
+ stdio: ['ignore', 'pipe', 'ignore'],
121
+ timeout: 2000,
122
+ });
123
+ return [...new Set(output.split(/\r?\n/).flatMap((line) => {
124
+ const [device, pid] = line.trim().split('\t');
125
+ if (!/^\/dev\/(?:pts\/\d+|tty[^/]*)$/.test(device ?? ''))
126
+ return [];
127
+ // On Linux the environment gives us an exact ownership marker. An
128
+ // ordinary `tmux attach` must not steal a Termdock sentinel merely
129
+ // because tmux listed that client first.
130
+ const environ = pid && /^\d+$/.test(pid) ? `/proc/${pid}/environ` : null;
131
+ if (environ && fs.existsSync(environ)) {
132
+ try {
133
+ if (!fs.readFileSync(environ).toString().split('\0').includes('TERMDOCK=1'))
134
+ return [];
135
+ }
136
+ catch {
137
+ return [];
138
+ }
139
+ }
140
+ return [device];
141
+ }))];
142
+ }
143
+ catch {
144
+ return [];
145
+ }
146
+ }
106
147
  /**
107
148
  * Write the sequence to the pane's PTY so the server sniffs it as pane output.
108
149
  * Two routes: /dev/tty (hook attached to its tty), or an ancestor's tty
@@ -116,6 +157,12 @@ function ancestorTtyDevice() {
116
157
  * the termdock server — as the plain inner OSC.
117
158
  */
118
159
  function writeToControllingTty(sequence, inTmuxPane) {
160
+ if (inTmuxPane) {
161
+ const clientTtys = tmuxClientTtyDevices();
162
+ if (clientTtys.length > 0) {
163
+ return clientTtys.some((device) => writeDev(device, sequence));
164
+ }
165
+ }
119
166
  // Only genuine tmux panes (TMUX_PANE is set by tmux itself) get the DCS
120
167
  // passthrough wrap; a bare inherited TMUX just means an ancestor ran tmux.
121
168
  const wire = inTmuxPane