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.
- package/README.md +36 -0
- package/dist/client/assets/{ModelPreview-Cs14IQuQ.js → ModelPreview-i9dS1A3I.js} +1 -1
- package/dist/client/assets/{abnfDiagram-VRR7QNED-DuDfdv-6.js → abnfDiagram-VRR7QNED-Cs-ty-HA.js} +1 -1
- package/dist/client/assets/{arc-CSYzGV_2.js → arc-CIX5k_JD.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-YiZ0HXNi.js → architectureDiagram-ZJ3FMSHR-DnPnQoJy.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-IaQMzmYv.js → blockDiagram-677ZJIJ3-Dhew-L5v.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-BwyuIPRL.js → c4Diagram-LMCZKHZV-BSsEMeWn.js} +1 -1
- package/dist/client/assets/channel-CASnt1pP.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-BtHQYLBT.js → chunk-2Q5K7J3B-BWm--1Mo.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-CXrK07sW.js → chunk-32BRIVSS-D_WFmuvb.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-BfBshn11.js → chunk-5VM5RSS4-CQdzqlQg.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-BJbilNaf.js → chunk-EX3LRPZG-Cm3zLQpt.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-wgcmSRlL.js → chunk-JWPE2WC7-Cmcv5sEN.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-8VZVUeDI.js → chunk-MOJQB5TN-Bam44EwJ.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-B900YBFa.js → chunk-RYQCIY6F-DIVOuE8y.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-BK4_RLz-.js → chunk-V7JOEXUC-BfOTf7yg.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-Kj5_ycjB.js → chunk-VR4S4FIN-DbiXn0dd.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-BolvPLRz.js → chunk-XXDRQBXY-C4pOe4OC.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-Da_SQWEq.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-Da_SQWEq.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-9_9_SLqv.js → cose-bilkent-JH36ORCC-CCoSPmoc.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-yEpoi3vG.js → cynefin-VYW2F7L2-B2lYz7sl.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-Dy-gvup_.js → cynefinDiagram-TSTJHNR4-Cd7crN9s.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-CNjJ5pRK.js → dagre-VKFMJZFB-q9Fj9HuQ.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-uZ73Rj3T.js → diagram-FQU43EPY-iqHy5YHW.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-Cxt7TYZT.js → diagram-G47NLZAW-BE-jlh1B.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-B4TKinuZ.js → diagram-NH7WQ7WH-DmP7_WOC.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-CGSuenHU.js → diagram-OA4YK3LP-CHx_k8Ar.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-DqqzDaUZ.js → diagram-WEI45ONY-D0wImpAF.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-B2SgXL5w.js → ebnfDiagram-CCIWWBDH-DyCB0rF8.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-D-ibkr8y.js → erDiagram-Q63AITRT-SRcd1B2Z.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-BPeyMqr-.js → flowDiagram-23GEKE2U-D0i9mFwt.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-DcEqEzzu.js → ganttDiagram-NO4QXBWP-BfwGIYDn.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-DTZ6UCQY.js → gitGraphDiagram-IHSO6WYX-BXkJQP7O.js} +1 -1
- package/dist/client/assets/index-BA-DqJM2.js +451 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-BHrzVXeX.js → infoDiagram-FWYZ7A6U-CM-Ebv9R.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-CwuoQJku.js → ishikawaDiagram-FXEZZL3T-U-qnP7xi.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-ZEcy0rDZ.js → journeyDiagram-5HDEW3XC-D4RxvsuL.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-BDBawNbr.js → kanban-definition-HUTT4EX6-Dm_asCYK.js} +1 -1
- package/dist/client/assets/{linear-smYQWzDV.js → linear-Zhe8d635.js} +1 -1
- package/dist/client/assets/{mermaid.core-eJs7Ouvw.js → mermaid.core-D_NWIuMY.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-BK1IZ9zg.js → mindmap-definition-LN4V7U3C-DXpNidd5.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-B3-mVNgX.js → pegDiagram-2B236MQR-DdOwOEgu.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-Bkeeyx5l.js → pieDiagram-ENE6RG2P-D_A9HoY_.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-DouIklEO.js → quadrantDiagram-ABIIQ3AL-D-Y3cLEF.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-BB_jXQd2.js → railroadDiagram-RFXS5EU6-DD6eFV82.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-u56IHfiR.js → requirementDiagram-TGXJPOKE-B59HJgL2.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-eDi2cs5E.js → sankeyDiagram-HTMAVEWB-C2BvHMH8.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-DnAA2xYa.js → sequenceDiagram-DBY2YBRQ-3004tCRm.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-BogPui79.js → sizeCapture-X5ZJPWSS-DCBhg8QW.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC--EeXDy2M.js → stateDiagram-2N3HPSRC-BVqiKVe0.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Z0GHWyoF.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-DZAw7-kY.js → swimlanes-5IMT3BWC-DS6-sdEW.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-el4HDX0G.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-DkcnpB-Y.js → timeline-definition-FHXFAJF6-wVOEmQwG.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-BStcDGEf.js → vennDiagram-L72KCM5P-DMZiSJDk.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-Cj4CQn1M.js → wardleyDiagram-EHGQE667-Dx5Aob4Y.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-_uBCjai8.js → xychartDiagram-FW5EYKEG-BqMhkhmW.js} +1 -1
- package/dist/client/index.html +1 -1
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/autoTitle.js +16 -36
- package/dist/server/agent/installers.js +98 -15
- package/dist/server/agent/pluginPackages.js +289 -0
- package/dist/server/agent/plugins.js +164 -15
- package/dist/server/agent/registry.js +1 -1
- package/dist/server/agent/session.js +10 -1
- package/dist/server/agent/titleNamerCatalog.js +131 -2
- package/dist/server/agentHook.js +47 -0
- package/dist/server/cli.js +224 -13
- package/dist/server/entry.js +11 -2
- package/dist/server/routes/terminal.js +267 -37
- package/dist/server/utils/csrfProtection.js +4 -1
- package/dist/server/utils/settings.js +18 -5
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
- package/dist/client/assets/channel-BotxtiNE.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-CLWlbTSC.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-CLWlbTSC.js +0 -1
- package/dist/client/assets/index-ivnGnbVg.js +0 -451
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-Dc-3HKTs.js +0 -1
- 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 ||
|
|
94
|
-
|
|
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) ||
|
|
231
|
-
|
|
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
|
-
|
|
435
|
+
let iconExists = fs.existsSync(iconPath);
|
|
307
436
|
if (iconExists) {
|
|
308
437
|
try {
|
|
309
|
-
|
|
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
|
|
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
|
|
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 !== '
|
|
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
|
|
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();
|
package/dist/server/agentHook.js
CHANGED
|
@@ -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
|