termdock 1.3.1 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/{abnfDiagram-VRR7QNED-VDfhcxCs.js → abnfDiagram-VRR7QNED-C7YJqmLC.js} +1 -1
- package/dist/client/assets/{arc-CK3Ni_GA.js → arc-B07DueP1.js} +1 -1
- package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-QKnogVlc.js → architectureDiagram-ZJ3FMSHR-DRKN8EGo.js} +1 -1
- package/dist/client/assets/{blockDiagram-677ZJIJ3-B6KYTMUm.js → blockDiagram-677ZJIJ3-BwR-JbpT.js} +1 -1
- package/dist/client/assets/{c4Diagram-LMCZKHZV-CempTpbh.js → c4Diagram-LMCZKHZV-DyRja9hI.js} +1 -1
- package/dist/client/assets/channel-DMZ6Nll6.js +1 -0
- package/dist/client/assets/{chunk-2Q5K7J3B-BIfNQQhi.js → chunk-2Q5K7J3B-Cl0vEDWK.js} +1 -1
- package/dist/client/assets/{chunk-32BRIVSS-BjGUsxBo.js → chunk-32BRIVSS-D-cTwhqE.js} +1 -1
- package/dist/client/assets/{chunk-5VM5RSS4-YOIRo4TG.js → chunk-5VM5RSS4-D0eRXduK.js} +1 -1
- package/dist/client/assets/{chunk-EX3LRPZG-DYok0zQS.js → chunk-EX3LRPZG-J_Nw8oeX.js} +1 -1
- package/dist/client/assets/{chunk-JWPE2WC7-jJpFBvnD.js → chunk-JWPE2WC7-D73gFLxs.js} +1 -1
- package/dist/client/assets/{chunk-MOJQB5TN-Cfv8l9NT.js → chunk-MOJQB5TN-DZtboEtR.js} +1 -1
- package/dist/client/assets/{chunk-RYQCIY6F-DezUuSQG.js → chunk-RYQCIY6F-D7BwVcOs.js} +1 -1
- package/dist/client/assets/{chunk-V7JOEXUC-DubthR3B.js → chunk-V7JOEXUC-B63BJcBW.js} +1 -1
- package/dist/client/assets/{chunk-VR4S4FIN-Br17FuD9.js → chunk-VR4S4FIN-C502LPuE.js} +1 -1
- package/dist/client/assets/{chunk-XXDRQBXY-BEp2DDT5.js → chunk-XXDRQBXY-C9puam3W.js} +1 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-C7oNngQj.js +1 -0
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-C7oNngQj.js +1 -0
- package/dist/client/assets/{cose-bilkent-JH36ORCC-De1yyf-M.js → cose-bilkent-JH36ORCC-CZ24meVw.js} +1 -1
- package/dist/client/assets/{cynefin-VYW2F7L2-DESao676.js → cynefin-VYW2F7L2-Y2fr06hq.js} +1 -1
- package/dist/client/assets/{cynefinDiagram-TSTJHNR4-BHRUq34L.js → cynefinDiagram-TSTJHNR4-OF_nmz5v.js} +1 -1
- package/dist/client/assets/{dagre-VKFMJZFB-q7dQXBcZ.js → dagre-VKFMJZFB-svS4Qj1B.js} +1 -1
- package/dist/client/assets/{diagram-FQU43EPY-DK8KUQgs.js → diagram-FQU43EPY-Bim50nBZ.js} +1 -1
- package/dist/client/assets/{diagram-G47NLZAW-CttMqM35.js → diagram-G47NLZAW-BDGzIPU6.js} +1 -1
- package/dist/client/assets/{diagram-NH7WQ7WH-DdPuemYp.js → diagram-NH7WQ7WH-DP8vmwhI.js} +1 -1
- package/dist/client/assets/{diagram-OA4YK3LP-BbRCM1am.js → diagram-OA4YK3LP-CVwGpEzy.js} +1 -1
- package/dist/client/assets/{diagram-WEI45ONY-Bsx5Tgzq.js → diagram-WEI45ONY-nS0Aj3Pp.js} +1 -1
- package/dist/client/assets/{ebnfDiagram-CCIWWBDH-B_t6Kh6T.js → ebnfDiagram-CCIWWBDH-BogvM--v.js} +1 -1
- package/dist/client/assets/{erDiagram-Q63AITRT-C9R1g3WF.js → erDiagram-Q63AITRT-pBjz5SjG.js} +1 -1
- package/dist/client/assets/{flowDiagram-23GEKE2U-Cb9bCkNG.js → flowDiagram-23GEKE2U-BINUCOud.js} +1 -1
- package/dist/client/assets/{ganttDiagram-NO4QXBWP-BiDqptF0.js → ganttDiagram-NO4QXBWP-DITnKqUR.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-Bb5ly04l.js → gitGraphDiagram-IHSO6WYX-C_C74_pU.js} +1 -1
- package/dist/client/assets/index-DgG9nafK.css +1 -0
- package/dist/client/assets/index-VDGjYWHR.js +408 -0
- package/dist/client/assets/{infoDiagram-FWYZ7A6U-CE_9WW2K.js → infoDiagram-FWYZ7A6U-D4WLtM02.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-D6Xq4-fM.js → ishikawaDiagram-FXEZZL3T-tgP7oAl9.js} +1 -1
- package/dist/client/assets/{journeyDiagram-5HDEW3XC-BR9hIye8.js → journeyDiagram-5HDEW3XC-sK0S0EXp.js} +1 -1
- package/dist/client/assets/{kanban-definition-HUTT4EX6-CuCbS_Ki.js → kanban-definition-HUTT4EX6-BZVK0aeo.js} +1 -1
- package/dist/client/assets/{linear-SWu8Le4S.js → linear-CHubENPf.js} +1 -1
- package/dist/client/assets/{mermaid.core-BBUMzzeH.js → mermaid.core-2dLzSZxi.js} +4 -4
- package/dist/client/assets/{mindmap-definition-LN4V7U3C-CQ0baFfF.js → mindmap-definition-LN4V7U3C-C28O3edf.js} +1 -1
- package/dist/client/assets/{pegDiagram-2B236MQR-ttatanL5.js → pegDiagram-2B236MQR-DaTkErYV.js} +1 -1
- package/dist/client/assets/{pieDiagram-ENE6RG2P-CPfxzHzu.js → pieDiagram-ENE6RG2P-CoUX2zjE.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-BXaRswdp.js → quadrantDiagram-ABIIQ3AL-DdidrEKm.js} +1 -1
- package/dist/client/assets/{railroadDiagram-RFXS5EU6-DrjoYMhE.js → railroadDiagram-RFXS5EU6-B4ua4pek.js} +1 -1
- package/dist/client/assets/{requirementDiagram-TGXJPOKE-DhVxD_8O.js → requirementDiagram-TGXJPOKE-C7GkSVWe.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-HTMAVEWB-COK6i599.js → sankeyDiagram-HTMAVEWB-D2qKgrSf.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-PVFMoTPN.js → sequenceDiagram-DBY2YBRQ-BKpN5Rg1.js} +1 -1
- package/dist/client/assets/{sizeCapture-X5ZJPWSS-DOVi8Y5x.js → sizeCapture-X5ZJPWSS-CdOyl0Mu.js} +1 -1
- package/dist/client/assets/{stateDiagram-2N3HPSRC-Bn0vvGOe.js → stateDiagram-2N3HPSRC-Pig7diHZ.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-DaioXNnb.js +1 -0
- package/dist/client/assets/{swimlanes-5IMT3BWC-D5QZ4K6L.js → swimlanes-5IMT3BWC-PS6bSiiJ.js} +2 -2
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-B9vE1I0i.js +8 -0
- package/dist/client/assets/{timeline-definition-FHXFAJF6-DUhdUNzM.js → timeline-definition-FHXFAJF6-BjVebJ90.js} +1 -1
- package/dist/client/assets/{vennDiagram-L72KCM5P-p95xTZY4.js → vennDiagram-L72KCM5P-BtjZHsBe.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-EHGQE667-DjIHqD-A.js → wardleyDiagram-EHGQE667-DmmuPtgr.js} +1 -1
- package/dist/client/assets/{xychartDiagram-FW5EYKEG-DMeL47EZ.js → xychartDiagram-FW5EYKEG-CPh2RG2Y.js} +1 -1
- package/dist/client/icons/agents/amp.svg +12 -0
- package/dist/client/icons/agents/claude.svg +10 -0
- package/dist/client/icons/agents/codex.svg +10 -0
- package/dist/client/icons/agents/copilot.svg +12 -0
- package/dist/client/icons/agents/cursor.svg +3 -0
- package/dist/client/icons/agents/droid.svg +10 -0
- package/dist/client/icons/agents/gemini.svg +5 -0
- package/dist/client/icons/agents/goose.svg +10 -0
- package/dist/client/icons/agents/grok.svg +5 -0
- package/dist/client/icons/agents/opencode.svg +3 -0
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +1 -1
- package/dist/server/agent/installers.js +742 -0
- package/dist/server/agent/plugins.js +271 -0
- package/dist/server/agent/registry.js +306 -0
- package/dist/server/agent/session.js +162 -0
- package/dist/server/agentHook.js +174 -0
- package/dist/server/cli.js +122 -2
- package/dist/server/ptyHost.js +261 -0
- package/dist/server/ptyhost/manager.js +396 -0
- package/dist/server/ptyhost/protocol.js +80 -0
- package/dist/server/routes/filesystem.js +10 -8
- package/dist/server/routes/terminal.js +798 -390
- package/dist/server/utils/gitStatus.js +114 -0
- package/package.json +1 -1
- package/dist/client/assets/channel-B_qG4XEr.js +0 -1
- package/dist/client/assets/classDiagram-OUVF2IWQ-DsBZBOre.js +0 -1
- package/dist/client/assets/classDiagram-v2-EOCWNBFH-DsBZBOre.js +0 -1
- package/dist/client/assets/index-BeJrvXOJ.js +0 -394
- package/dist/client/assets/index-iK4yVqAA.css +0 -1
- package/dist/client/assets/stateDiagram-v2-6OUMAXLB-D_c5gxfu.js +0 -1
- package/dist/client/assets/swimlanesDiagram-G3AALYLV-DsiBwV8W.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
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// Registry order matters only for iteration stability; lookup is by alias.
|
|
2
|
+
// flexoki-allow-file — vendor brand accents are deliberately theme-independent.
|
|
3
|
+
const AGENTS = [
|
|
4
|
+
{ slug: 'claude', displayName: 'Claude Code', aliases: ['claude', 'claude-code'], accentColor: '#D97757', icon: 'claude' },
|
|
5
|
+
{ slug: 'codex', displayName: 'Codex', aliases: ['codex', 'codex-cli'], accentColor: '#000000', icon: 'codex' },
|
|
6
|
+
{ slug: 'gemini', displayName: 'Gemini', aliases: ['gemini', 'gemini-cli'], accentColor: '#4285F4', icon: 'gemini' },
|
|
7
|
+
{ slug: 'aider', displayName: 'Aider', aliases: ['aider', 'aider-chat'], accentColor: '#14B8A6', icon: null },
|
|
8
|
+
{ slug: 'amp', displayName: 'Amp', aliases: ['amp'], accentColor: '#F34E3F', icon: 'amp' },
|
|
9
|
+
{ slug: 'opencode', displayName: 'OpenCode', aliases: ['opencode'], accentColor: '#6E56CF', icon: 'opencode' },
|
|
10
|
+
{ slug: 'copilot', displayName: 'Copilot', aliases: ['copilot'], accentColor: '#8957E5', icon: 'copilot' },
|
|
11
|
+
{ slug: 'cursor', displayName: 'Cursor', aliases: ['cursor-agent'], accentColor: '#9AA0A6', icon: 'cursor' },
|
|
12
|
+
{ slug: 'goose', displayName: 'Goose', aliases: ['goose'], accentColor: '#9A8CFF', icon: 'goose' },
|
|
13
|
+
{ slug: 'droid', displayName: 'Droid', aliases: ['droid'], accentColor: '#F59E0B', icon: 'droid' },
|
|
14
|
+
{ slug: 'pi', displayName: 'Pi', aliases: ['pi'], accentColor: '#0EA5E9', icon: null },
|
|
15
|
+
{ slug: 'auggie', displayName: 'Auggie', aliases: ['auggie'], accentColor: '#16A34A', icon: null },
|
|
16
|
+
{ slug: 'hermes', displayName: 'Hermes', aliases: ['hermes'], accentColor: '#8B5CF6', icon: null },
|
|
17
|
+
{ slug: 'vibe', displayName: 'Vibe', aliases: ['vibe', 'vibe-acp'], accentColor: '#FF7000', icon: null },
|
|
18
|
+
{ slug: 'antigravity', displayName: 'Antigravity', aliases: ['agy', 'antigravity'], accentColor: '#2563EB', icon: null },
|
|
19
|
+
{ slug: 'grok', displayName: 'Grok', aliases: ['grok'], accentColor: '#000000', icon: 'grok' },
|
|
20
|
+
{ slug: 'qwen', displayName: 'Qwen Code', aliases: ['qwen', 'qwen-code'], accentColor: '#7C3AED', icon: null },
|
|
21
|
+
];
|
|
22
|
+
const BY_ALIAS = new Map();
|
|
23
|
+
const BY_SLUG = new Map();
|
|
24
|
+
for (const info of AGENTS) {
|
|
25
|
+
BY_SLUG.set(info.slug, info);
|
|
26
|
+
for (const alias of info.aliases)
|
|
27
|
+
BY_ALIAS.set(alias, info);
|
|
28
|
+
}
|
|
29
|
+
export function agentBySlug(slug) {
|
|
30
|
+
if (!slug)
|
|
31
|
+
return null;
|
|
32
|
+
return BY_SLUG.get(slug.trim().toLowerCase()) ?? null;
|
|
33
|
+
}
|
|
34
|
+
export function listAgents() {
|
|
35
|
+
return AGENTS.slice();
|
|
36
|
+
}
|
|
37
|
+
function matchToken(token) {
|
|
38
|
+
return BY_ALIAS.get(token) ?? null;
|
|
39
|
+
}
|
|
40
|
+
/** A `KEY=value` shell environment assignment prefix (`FOO=bar cmd`). */
|
|
41
|
+
function isEnvAssignment(token) {
|
|
42
|
+
const eq = token.indexOf('=');
|
|
43
|
+
if (eq <= 0)
|
|
44
|
+
return false;
|
|
45
|
+
const key = token.slice(0, eq);
|
|
46
|
+
if (!/^[A-Za-z_]/.test(key))
|
|
47
|
+
return false;
|
|
48
|
+
return /^[A-Za-z0-9_]+$/.test(key);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The final path component with trailing script extension stripped.
|
|
52
|
+
* `/usr/bin/claude` → `claude`, `cli.js` → `cli`, `claude.cmd` → `claude`.
|
|
53
|
+
* Splits on both separators so Windows paths resolve the same on Unix.
|
|
54
|
+
*/
|
|
55
|
+
function baseStem(token) {
|
|
56
|
+
const trimmed = token.replace(/[/\\]+$/, '');
|
|
57
|
+
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
|
|
58
|
+
const name = idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
|
|
59
|
+
for (const ext of ['.js', '.mjs', '.cjs', '.ts', '.py', '.rb', '.sh', '.exe', '.cmd', '.bat', '.ps1']) {
|
|
60
|
+
if (name.endsWith(ext))
|
|
61
|
+
return name.slice(0, -ext.length);
|
|
62
|
+
}
|
|
63
|
+
return name;
|
|
64
|
+
}
|
|
65
|
+
/** Whether a launcher basename is an interpreter whose arguments name the real program. */
|
|
66
|
+
function isInterpreter(stem) {
|
|
67
|
+
return ['node', 'nodejs', 'bun', 'deno', 'npx', 'pnpm', 'yarn',
|
|
68
|
+
'python', 'python3', 'ruby', 'uv', 'uvx', 'env'].includes(stem.toLowerCase());
|
|
69
|
+
}
|
|
70
|
+
function tokenNamesAgent(token, agent) {
|
|
71
|
+
return token.split(/[/\\]/).some((seg) => matchToken(baseStem(seg).toLowerCase()) === agent);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Identify the coding agent a foreground argv is running, or null.
|
|
75
|
+
* `customRules` maps a command basename to an agent slug (personal wrappers);
|
|
76
|
+
* applies to the launcher only and loses to a built-in match on the same name.
|
|
77
|
+
*/
|
|
78
|
+
export function detectAgentFromArgv(argv, customRules = {}) {
|
|
79
|
+
let i = 0;
|
|
80
|
+
while (i < argv.length && isEnvAssignment(argv[i]))
|
|
81
|
+
i++;
|
|
82
|
+
const launcher = argv[i];
|
|
83
|
+
if (!launcher)
|
|
84
|
+
return null;
|
|
85
|
+
const launcherStem = baseStem(launcher);
|
|
86
|
+
// Native binary (or built-in match wins over custom rules on the same name)
|
|
87
|
+
const direct = matchToken(launcherStem.toLowerCase());
|
|
88
|
+
if (direct)
|
|
89
|
+
return direct;
|
|
90
|
+
const custom = customRules[launcherStem.toLowerCase()];
|
|
91
|
+
if (custom) {
|
|
92
|
+
const agent = agentBySlug(custom);
|
|
93
|
+
if (agent)
|
|
94
|
+
return agent;
|
|
95
|
+
}
|
|
96
|
+
// Interpreter wrapper: scan the script path / package args it runs
|
|
97
|
+
if (isInterpreter(launcherStem)) {
|
|
98
|
+
for (let j = i + 1; j < argv.length; j++) {
|
|
99
|
+
const arg = argv[j];
|
|
100
|
+
if (arg.startsWith('-'))
|
|
101
|
+
continue;
|
|
102
|
+
for (const seg of arg.split(/[/\\]/)) {
|
|
103
|
+
const hit = matchToken(baseStem(seg).toLowerCase());
|
|
104
|
+
if (hit)
|
|
105
|
+
return hit;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Shell-split a raw command line (as captured from ps args) into argv, then
|
|
113
|
+
* detect. Naive tokenization: whitespace split, quotes trimmed — enough for
|
|
114
|
+
* the dominant shapes (`claude --model opus`, `node /path/cli.js --flag`).
|
|
115
|
+
*/
|
|
116
|
+
export function detectAgentFromCommand(command, customRules = {}) {
|
|
117
|
+
const argv = command
|
|
118
|
+
.split(/\s+/)
|
|
119
|
+
.map((t) => t.replace(/^["']+|["']+$/g, ''))
|
|
120
|
+
.filter((t) => t.length > 0);
|
|
121
|
+
return detectAgentFromArgv(argv, customRules);
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Resume: rebuild the shell command that resumes a previous agent session by
|
|
125
|
+
// its native session id, carrying the user's original launch flags.
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
/** Per-agent resume command templates; null = no known resume flag. */
|
|
128
|
+
function resumeCommandFor(agent, sessionId, flags) {
|
|
129
|
+
const f = flags.length > 0 ? ' ' + flags.join(' ') : '';
|
|
130
|
+
switch (agent.slug) {
|
|
131
|
+
case 'claude': return `claude${f} --resume ${sessionId}`;
|
|
132
|
+
// Codex resumes via a subcommand; interactive options follow the positional id
|
|
133
|
+
case 'codex': return `codex resume ${sessionId}${f}`;
|
|
134
|
+
case 'gemini': return `gemini${f} --resume ${sessionId}`;
|
|
135
|
+
case 'opencode': return `opencode${f} --session ${sessionId}`;
|
|
136
|
+
case 'amp': return `amp threads continue ${sessionId}${f}`;
|
|
137
|
+
case 'cursor': return `cursor-agent${f} --resume ${sessionId}`;
|
|
138
|
+
case 'copilot': return `copilot${f} --resume ${sessionId}`;
|
|
139
|
+
case 'grok': return `grok${f} --resume ${sessionId}`;
|
|
140
|
+
default: return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Session-targeting flags whose old value must not survive onto a resume. */
|
|
144
|
+
const STALE_SESSION_FLAGS = {
|
|
145
|
+
claude: ['--resume', '-r', '--continue', '-c', '--session-id', '--from-pr'],
|
|
146
|
+
gemini: ['--resume', '-r'],
|
|
147
|
+
cursor: ['--resume', '-r'],
|
|
148
|
+
copilot: ['--resume', '-r', '--continue', '-c'],
|
|
149
|
+
opencode: ['--session', '-s', '--continue', '-c'],
|
|
150
|
+
codex: ['--last'],
|
|
151
|
+
grok: ['--resume', '-r', '--load', '--continue', '-c', '--session-id', '-s',
|
|
152
|
+
'--fork-session', '--worktree', '-w', '--worktree-ref', '--ref'],
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* The launch-flag tail of argv worth replaying on a resume command, or null
|
|
156
|
+
* to resume bare. Deliberately conservative: anything ambiguous falls back to
|
|
157
|
+
* no flags rather than a corrupted command line.
|
|
158
|
+
* - The tail is everything after the token that names this agent; leading
|
|
159
|
+
* `VAR=value` env assignments are skipped first. No naming token → null.
|
|
160
|
+
* - Stale session-targeting flags are stripped (the new id must win).
|
|
161
|
+
* - Every surviving token must be shell-safe, the first must be a `-` flag,
|
|
162
|
+
* and no two bare words may run consecutively (a bare word is only valid as
|
|
163
|
+
* the value directly behind a flag — anything else is a positional prompt
|
|
164
|
+
* that must not re-submit itself into the resumed session).
|
|
165
|
+
*/
|
|
166
|
+
function replayFlags(agent, argv) {
|
|
167
|
+
let start = 0;
|
|
168
|
+
while (start < argv.length && isEnvAssignment(argv[start]))
|
|
169
|
+
start++;
|
|
170
|
+
let named = -1;
|
|
171
|
+
for (let j = start; j < argv.length; j++) {
|
|
172
|
+
if (tokenNamesAgent(argv[j], agent)) {
|
|
173
|
+
named = j;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (named < 0)
|
|
178
|
+
return null;
|
|
179
|
+
const tail = argv.slice(named + 1);
|
|
180
|
+
// A relaunched `codex resume <old-id>`: drop the subcommand and its id
|
|
181
|
+
if (agent.slug === 'codex' && tail[0] === 'resume') {
|
|
182
|
+
tail.shift();
|
|
183
|
+
if (tail.length > 0 && !tail[0].startsWith('-'))
|
|
184
|
+
tail.shift();
|
|
185
|
+
}
|
|
186
|
+
const stale = STALE_SESSION_FLAGS[agent.slug] ?? [];
|
|
187
|
+
for (let j = 0; j < tail.length;) {
|
|
188
|
+
const t = tail[j];
|
|
189
|
+
const isStale = stale.includes(t)
|
|
190
|
+
|| stale.some((f) => f.length > 2 && t.startsWith(`${f}=`));
|
|
191
|
+
if (isStale) {
|
|
192
|
+
tail.splice(j, 1);
|
|
193
|
+
if (j < tail.length && !tail[j].startsWith('-'))
|
|
194
|
+
tail.splice(j, 1);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
j++;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Safety gate: plain tokens only, flag-shaped tail
|
|
201
|
+
const safe = (t) => t.length > 0 && /^[A-Za-z0-9_=./,:@+~-]+$/.test(t);
|
|
202
|
+
if (!tail.every(safe))
|
|
203
|
+
return null;
|
|
204
|
+
let prevWasFlag = false;
|
|
205
|
+
for (const t of tail) {
|
|
206
|
+
const isFlag = t.startsWith('-');
|
|
207
|
+
if (!isFlag && !prevWasFlag)
|
|
208
|
+
return null;
|
|
209
|
+
prevWasFlag = isFlag;
|
|
210
|
+
}
|
|
211
|
+
return tail;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The shell command resuming a session, or null when the agent has no known
|
|
215
|
+
* resume flag or the id/flags aren't shell-safe. Ids come from the agent's own
|
|
216
|
+
* hook events but still land on a shell command line — refuse anything that
|
|
217
|
+
* isn't a plain token.
|
|
218
|
+
*/
|
|
219
|
+
export function buildResumeCommand(agent, sessionId, launchArgv) {
|
|
220
|
+
if (!sessionId || !/^[A-Za-z0-9._-]+$/.test(sessionId))
|
|
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
|
+
}
|
|
237
|
+
const flags = launchArgv ? replayFlags(agent, launchArgv) ?? [] : [];
|
|
238
|
+
return resumeCommandFor(agent, sessionId, flags);
|
|
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
|
+
}
|