clideck 1.26.3 → 1.27.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/README.md +2 -2
- package/agent-presets.json +5 -1
- package/bin/notify-helper.js +18 -0
- package/handlers.js +97 -17
- package/package.json +1 -2
- package/plugin-loader.js +149 -44
- package/plugins/autopilot/clideck-plugin.json +3 -1
- package/plugins/autopilot/package.json +7 -0
- package/plugins/trim-clip/clideck-plugin.json +1 -0
- package/plugins/voice-input/clideck-plugin.json +1 -0
- package/public/index.html +1 -3
- package/public/js/app.js +58 -5
- package/public/js/creator.js +33 -10
- package/public/js/settings.js +33 -43
- package/public/js/terminals.js +13 -9
- package/public/tailwind.css +1 -1
- package/server.js +71 -1
- package/sessions.js +14 -13
- package/telemetry-receiver.js +52 -14
- package/transcript.js +5 -3
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ npx clideck
|
|
|
26
26
|
|
|
27
27
|
Open [http://localhost:4000](http://localhost:4000). Click **+**, pick an agent and optionally a project and role, start working.
|
|
28
28
|
|
|
29
|
-
New users get 3 built-in
|
|
29
|
+
New users get 3 built-in roles (Programmer, Reviewer, Product Manager) and 3 starter prompts in the prompt library.
|
|
30
30
|
|
|
31
31
|
Or install globally:
|
|
32
32
|
|
|
@@ -38,7 +38,7 @@ clideck
|
|
|
38
38
|
## What You Get
|
|
39
39
|
|
|
40
40
|
- **Roles** — define reusable agent identities (Programmer, Reviewer, PM) and assign them when creating sessions. Instructions are injected into the agent automatically.
|
|
41
|
-
- **Autopilot** — project-level workflow routing. Watches your role-assigned agents, waits for them to finish, forwards output to the next specialist. Fingerprints each output, tracks handoff history, and guards against repeat loops.
|
|
41
|
+
- **Autopilot** — project-level workflow routing. Watches your role-assigned agents, waits for them to finish, forwards output to the next specialist. Fingerprints each output, tracks handoff history, and guards against repeat loops. Supports 8 LLM providers (Anthropic, OpenAI, Google, Groq, xAI, Mistral, OpenRouter, Cerebras). Notifies you when work is complete or blocked.
|
|
42
42
|
- **Mobile access** — check on your agents from your phone with a QR scan. E2E encrypted.
|
|
43
43
|
- **Live working/idle status** — see which agent is thinking and which is waiting for you, without checking each terminal
|
|
44
44
|
- **Session resume** — close clideck, reopen it tomorrow, pick up where you left off
|
package/agent-presets.json
CHANGED
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
"resumeCommand": "claude --resume {{sessionId}}",
|
|
11
11
|
"sessionIdPattern": "Session ID:\\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
|
|
12
12
|
"outputMarker": "\u23fa",
|
|
13
|
-
"telemetryConfigPath": "
|
|
13
|
+
"telemetryConfigPath": "~/.claude/settings.json",
|
|
14
|
+
"telemetrySetup": "Required for working/idle status, Autopilot, notifications, and mobile remote.\n\nCliDeck will add start/stop hooks to ~/.claude/settings.json. Claude will ask for one-time approval on next launch.",
|
|
15
|
+
"telemetryAutoSetup": {
|
|
16
|
+
"label": "Patch Claude"
|
|
17
|
+
},
|
|
14
18
|
"telemetryEnv": {
|
|
15
19
|
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
|
|
16
20
|
"OTEL_LOGS_EXPORTER": "otlp",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Tiny helper for Codex notify hook.
|
|
3
|
+
// Usage: node notify-helper.js <port> <json-payload>
|
|
4
|
+
// Codex appends the JSON payload as the last argv argument.
|
|
5
|
+
// Port is passed as the first argument by the notify config.
|
|
6
|
+
|
|
7
|
+
const port = parseInt(process.argv[2], 10);
|
|
8
|
+
const payload = process.argv[process.argv.length - 1];
|
|
9
|
+
if (!port || !payload || payload === String(port)) process.exit(0);
|
|
10
|
+
|
|
11
|
+
const http = require('http');
|
|
12
|
+
const req = http.request({
|
|
13
|
+
hostname: 'localhost', port, path: '/hook/codex/stop',
|
|
14
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
15
|
+
timeout: 2000,
|
|
16
|
+
});
|
|
17
|
+
req.on('error', () => {});
|
|
18
|
+
req.end(payload);
|
package/handlers.js
CHANGED
|
@@ -77,11 +77,18 @@ function detectTelemetryConfig(c) {
|
|
|
77
77
|
if (!preset) continue;
|
|
78
78
|
let detected = false;
|
|
79
79
|
if (preset.presetId === 'claude-code') {
|
|
80
|
-
|
|
80
|
+
try {
|
|
81
|
+
const s = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf8'));
|
|
82
|
+
const hooks = s.hooks || {};
|
|
83
|
+
const has = (arr, path) => arr?.some(h => h.hooks?.some(x => x.url?.includes('/hook/claude/' + path)));
|
|
84
|
+
detected = has(hooks.UserPromptSubmit, 'start') && has(hooks.Stop, 'stop') && has(hooks.StopFailure, 'stop')
|
|
85
|
+
&& has(hooks.PreToolUse, 'menu')
|
|
86
|
+
&& hooks.Notification?.some(h => h.matcher === 'idle_prompt' && h.hooks?.some(x => x.url?.includes('/hook/claude/idle')));
|
|
87
|
+
} catch {}
|
|
81
88
|
} else if (preset.presetId === 'codex') {
|
|
82
89
|
try {
|
|
83
90
|
const content = readFileSync(join(home, '.codex', 'config.toml'), 'utf8');
|
|
84
|
-
detected = content.includes('[otel]') && content.includes(`localhost:${port}`);
|
|
91
|
+
detected = content.includes('[otel]') && content.includes(`localhost:${port}`) && content.includes('notify-helper');
|
|
85
92
|
} catch {}
|
|
86
93
|
} else if (preset.presetId === 'gemini-cli') {
|
|
87
94
|
try {
|
|
@@ -139,7 +146,17 @@ function onConnection(ws) {
|
|
|
139
146
|
sessions.broadcast({ type: 'screen.updated', id: msg.id });
|
|
140
147
|
const sess = sessions.getSessions().get(msg.id);
|
|
141
148
|
if (sess) {
|
|
142
|
-
|
|
149
|
+
let choices = require('./transcript').detectMenu(msg.lines, sess.presetId);
|
|
150
|
+
// Codex: only trust menu detection if last OTEL event was response.completed
|
|
151
|
+
if (choices && sess.presetId === 'codex') {
|
|
152
|
+
const last = require('./telemetry-receiver').getLastEvent(msg.id);
|
|
153
|
+
if (!last.startsWith('codex.sse_event:response.completed')) {
|
|
154
|
+
console.log(`[codex] menu rejected — lastEvent=${last} session=${msg.id.slice(0,8)}`);
|
|
155
|
+
choices = null;
|
|
156
|
+
} else {
|
|
157
|
+
console.log(`[codex] menu accepted session=${msg.id.slice(0,8)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
143
160
|
// Auto-approve: send Enter immediately when menu detected
|
|
144
161
|
if (choices && plugins.shouldAutoApproveMenu(msg.id)) {
|
|
145
162
|
sessions.input({ id: msg.id, data: '\r' });
|
|
@@ -150,6 +167,7 @@ function onConnection(ws) {
|
|
|
150
167
|
sessions.broadcast({ type: 'session.menu', id: msg.id, choices: choices || [] });
|
|
151
168
|
if (choices) {
|
|
152
169
|
plugins.notifyMenu(msg.id, choices);
|
|
170
|
+
if (sess.presetId === 'codex') require('./telemetry-receiver').cancelCodexMenuPoll(msg.id);
|
|
153
171
|
sessions.broadcast({ type: 'session.status', id: msg.id, working: false, source: 'menu' });
|
|
154
172
|
}
|
|
155
173
|
}
|
|
@@ -187,19 +205,13 @@ function onConnection(ws) {
|
|
|
187
205
|
case 'telemetry.autosetup': {
|
|
188
206
|
const preset = presets.find(p => p.presetId === msg.presetId);
|
|
189
207
|
if (!preset?.telemetryAutoSetup) break;
|
|
190
|
-
// Only allow if caller has a live session using this preset's command
|
|
191
|
-
const liveSessions = sessions.list();
|
|
192
|
-
const hasLive = liveSessions.some(s => {
|
|
193
|
-
const cmd = cfg.commands.find(c => c.id === s.commandId);
|
|
194
|
-
return cmd && binName(cmd.command) === binName(preset.command);
|
|
195
|
-
});
|
|
196
|
-
if (!hasLive) break;
|
|
197
208
|
const result = applyTelemetryConfig(preset);
|
|
198
|
-
// Persist telemetry state in config
|
|
199
209
|
for (const cmd of cfg.commands) {
|
|
200
210
|
if (binName(cmd.command) === binName(preset.command)) {
|
|
201
211
|
cmd.telemetryEnabled = result.success;
|
|
202
212
|
cmd.telemetryStatus = result.success ? { ok: true } : { ok: false, error: result.message };
|
|
213
|
+
// Enable the agent when setup succeeds, disable if it fails
|
|
214
|
+
if (result.success) cmd.enabled = true;
|
|
203
215
|
}
|
|
204
216
|
}
|
|
205
217
|
config.save(cfg);
|
|
@@ -297,6 +309,18 @@ function onConnection(ws) {
|
|
|
297
309
|
sessions.broadcast({ type: 'plugins', list: plugins.getInfo() });
|
|
298
310
|
break;
|
|
299
311
|
|
|
312
|
+
case 'plugin.install': {
|
|
313
|
+
ws.send(JSON.stringify({ type: 'plugin.install.progress', pluginId: msg.pluginId }));
|
|
314
|
+
plugins.installPlugin(msg.pluginId, (err) => {
|
|
315
|
+
if (err) {
|
|
316
|
+
ws.send(JSON.stringify({ type: 'plugin.install.result', pluginId: msg.pluginId, success: false, error: err.message }));
|
|
317
|
+
} else {
|
|
318
|
+
sessions.broadcast({ type: 'plugins', list: plugins.getInfo() });
|
|
319
|
+
ws.send(JSON.stringify({ type: 'plugin.install.result', pluginId: msg.pluginId, success: true }));
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
300
324
|
case 'plugin.delete': {
|
|
301
325
|
const result = plugins.removePlugin(msg.pluginId);
|
|
302
326
|
if (result.success) {
|
|
@@ -378,15 +402,54 @@ function applyTelemetryConfig(preset) {
|
|
|
378
402
|
const home = os.homedir();
|
|
379
403
|
|
|
380
404
|
try {
|
|
405
|
+
if (preset.presetId === 'claude-code') {
|
|
406
|
+
const configPath = join(home, '.claude', 'settings.json');
|
|
407
|
+
let settings = {};
|
|
408
|
+
if (existsSync(configPath)) {
|
|
409
|
+
try { settings = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
|
|
410
|
+
}
|
|
411
|
+
const hooks = settings.hooks || {};
|
|
412
|
+
const endpoint = `http://localhost:${port}/hook/claude`;
|
|
413
|
+
const clideckHook = (url) => ({ hooks: [{ type: 'http', url }] });
|
|
414
|
+
const hasClideck = (arr, path) => arr?.some(h => h.hooks?.some(x => x.url?.includes('/hook/claude/' + path)));
|
|
415
|
+
if (hasClideck(hooks.UserPromptSubmit, 'start') && hasClideck(hooks.Stop, 'stop') && hasClideck(hooks.StopFailure, 'stop') && hasClideck(hooks.PreToolUse, 'menu') && hooks.Notification?.some(h => h.matcher === 'idle_prompt' && h.hooks?.some(x => x.url?.includes('/hook/claude/idle')))) {
|
|
416
|
+
return { success: true, message: 'Already configured' };
|
|
417
|
+
}
|
|
418
|
+
if (!hasClideck(hooks.UserPromptSubmit, 'start')) hooks.UserPromptSubmit = [...(hooks.UserPromptSubmit || []), clideckHook(`${endpoint}/start`)];
|
|
419
|
+
if (!hasClideck(hooks.Stop, 'stop')) hooks.Stop = [...(hooks.Stop || []), clideckHook(`${endpoint}/stop`)];
|
|
420
|
+
if (!hasClideck(hooks.StopFailure, 'stop')) hooks.StopFailure = [...(hooks.StopFailure || []), clideckHook(`${endpoint}/stop`)];
|
|
421
|
+
if (!hasClideck(hooks.Notification, 'idle')) hooks.Notification = [...(hooks.Notification || []), { matcher: 'idle_prompt', ...clideckHook(`${endpoint}/idle`) }];
|
|
422
|
+
if (!hasClideck(hooks.PreToolUse, 'menu')) hooks.PreToolUse = [...(hooks.PreToolUse || []), clideckHook(`${endpoint}/menu`)];
|
|
423
|
+
settings.hooks = hooks;
|
|
424
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
425
|
+
writeFileSync(configPath, JSON.stringify(settings, null, 2) + '\n');
|
|
426
|
+
return { success: true, message: 'Added hooks to ~/.claude/settings.json — Claude will ask for one-time approval' };
|
|
427
|
+
}
|
|
428
|
+
|
|
381
429
|
if (preset.presetId === 'codex') {
|
|
382
430
|
const configPath = join(home, '.codex', 'config.toml');
|
|
383
431
|
let content = '';
|
|
384
432
|
if (existsSync(configPath)) content = readFileSync(configPath, 'utf8');
|
|
385
|
-
|
|
386
|
-
const
|
|
433
|
+
const hasOtel = content.includes('[otel]');
|
|
434
|
+
const hasNotify = content.includes('notify-helper');
|
|
435
|
+
if (hasOtel && hasNotify) return { success: true, message: 'Already configured' };
|
|
436
|
+
if (!hasNotify) {
|
|
437
|
+
const helperPath = join(__dirname, 'bin', 'notify-helper.js').replace(/\\/g, '/');
|
|
438
|
+
const notifyLine = `notify = ["${process.execPath.replace(/\\/g, '/')}", "${helperPath}", "${port}"]\n`;
|
|
439
|
+
// Insert before the first [section] so it stays top-level
|
|
440
|
+
const firstSection = content.search(/^\[/m);
|
|
441
|
+
if (firstSection >= 0) {
|
|
442
|
+
content = content.slice(0, firstSection) + notifyLine + '\n' + content.slice(firstSection);
|
|
443
|
+
} else {
|
|
444
|
+
content = content + '\n' + notifyLine;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!hasOtel) {
|
|
448
|
+
content = content.trimEnd() + `\n\n[otel]\nexporter = { otlp-http = { endpoint = "http://localhost:${port}/v1/logs", protocol = "json" } }\n`;
|
|
449
|
+
}
|
|
387
450
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
388
|
-
writeFileSync(configPath, content
|
|
389
|
-
return { success: true, message: 'Added
|
|
451
|
+
writeFileSync(configPath, content);
|
|
452
|
+
return { success: true, message: 'Added otel + notify to ~/.codex/config.toml' };
|
|
390
453
|
}
|
|
391
454
|
|
|
392
455
|
if (preset.presetId === 'gemini-cli') {
|
|
@@ -431,14 +494,31 @@ function removeTelemetryConfig(preset) {
|
|
|
431
494
|
const home = os.homedir();
|
|
432
495
|
|
|
433
496
|
try {
|
|
497
|
+
if (preset.presetId === 'claude-code') {
|
|
498
|
+
const configPath = join(home, '.claude', 'settings.json');
|
|
499
|
+
if (!existsSync(configPath)) return { success: true, message: 'No config file to clean' };
|
|
500
|
+
let settings = {};
|
|
501
|
+
try { settings = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
|
|
502
|
+
if (!settings.hooks) return { success: true, message: 'No hooks to remove' };
|
|
503
|
+
for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure', 'Notification', 'PreToolUse']) {
|
|
504
|
+
const arr = settings.hooks[event];
|
|
505
|
+
if (!arr) continue;
|
|
506
|
+
settings.hooks[event] = arr.filter(h => !h.hooks?.some(x => x.url?.includes('/hook/claude/')));
|
|
507
|
+
if (!settings.hooks[event].length) delete settings.hooks[event];
|
|
508
|
+
}
|
|
509
|
+
if (!Object.keys(settings.hooks).length) delete settings.hooks;
|
|
510
|
+
writeFileSync(configPath, JSON.stringify(settings, null, 2) + '\n');
|
|
511
|
+
return { success: true, message: 'Removed CliDeck hooks from ~/.claude/settings.json' };
|
|
512
|
+
}
|
|
513
|
+
|
|
434
514
|
if (preset.presetId === 'codex') {
|
|
435
515
|
const configPath = join(home, '.codex', 'config.toml');
|
|
436
516
|
if (!existsSync(configPath)) return { success: true, message: 'No config file to clean' };
|
|
437
517
|
let content = readFileSync(configPath, 'utf8');
|
|
438
|
-
// Remove [otel] section and everything until the next section or EOF
|
|
439
518
|
content = content.replace(/\n?\[otel\][^\[]*/, '');
|
|
519
|
+
content = content.replace(/\n?notify\s*=\s*\[.*?notify-helper.*?\]\s*/g, '');
|
|
440
520
|
writeFileSync(configPath, content.trimEnd() + '\n');
|
|
441
|
-
return { success: true, message: 'Removed
|
|
521
|
+
return { success: true, message: 'Removed otel + notify from ~/.codex/config.toml' };
|
|
442
522
|
}
|
|
443
523
|
|
|
444
524
|
if (preset.presetId === 'gemini-cli') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clideck",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.1",
|
|
4
4
|
"description": "One screen for all your AI coding agents — run, monitor, and manage multiple CLI agents from a single browser tab",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
},
|
|
34
34
|
"homepage": "https://clideck.dev/",
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@mariozechner/pi-ai": "^0.62.0",
|
|
37
36
|
"@xterm/addon-fit": "^0.11.0",
|
|
38
37
|
"@xterm/xterm": "^6.0.0",
|
|
39
38
|
"node-pty": "^1.1.0",
|
package/plugin-loader.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
const { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } = require('fs');
|
|
2
2
|
const { join, sep } = require('path');
|
|
3
|
+
const { execFile: _execFile } = require('child_process');
|
|
4
|
+
// Windows needs shell:true for npm (it's npm.cmd, not a binary)
|
|
5
|
+
const npmExec = (args, opts, cb) => _execFile('npm', args, { ...opts, shell: process.platform === 'win32' }, cb);
|
|
3
6
|
const { DATA_DIR } = require('./paths');
|
|
4
7
|
const transcript = require('./transcript');
|
|
5
8
|
|
|
@@ -8,6 +11,7 @@ mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
|
8
11
|
|
|
9
12
|
// Seed bundled plugins — copy if missing, update if bundled version is newer
|
|
10
13
|
const BUNDLED_DIR = join(__dirname, 'plugins');
|
|
14
|
+
const updatedBundled = new Set(); // plugins updated during seed — install state must be cleared
|
|
11
15
|
if (existsSync(BUNDLED_DIR)) {
|
|
12
16
|
for (const entry of readdirSync(BUNDLED_DIR, { withFileTypes: true })) {
|
|
13
17
|
if (!entry.isDirectory()) continue;
|
|
@@ -22,6 +26,7 @@ if (existsSync(BUNDLED_DIR)) {
|
|
|
22
26
|
const installedManifest = JSON.parse(readFileSync(installedManifestFile, 'utf8'));
|
|
23
27
|
if (bundledManifest.version !== installedManifest.version) {
|
|
24
28
|
cpSync(join(BUNDLED_DIR, entry.name), target, { recursive: true });
|
|
29
|
+
if (bundledManifest.install) updatedBundled.add(bundledManifest.id || entry.name);
|
|
25
30
|
console.log(`[plugin] updated ${entry.name} ${installedManifest.version} → ${bundledManifest.version}`);
|
|
26
31
|
}
|
|
27
32
|
} catch {}
|
|
@@ -30,6 +35,7 @@ if (existsSync(BUNDLED_DIR)) {
|
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
const plugins = new Map();
|
|
38
|
+
const uninstalledPlugins = new Map(); // id → { manifest, dir }
|
|
33
39
|
const inputHooks = [];
|
|
34
40
|
const outputHooks = [];
|
|
35
41
|
const statusHooks = [];
|
|
@@ -66,6 +72,57 @@ function removeHooks(pluginId) {
|
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
// Check if a plugin with install: "npm" has been installed.
|
|
76
|
+
// Config is the source of truth, but we self-correct if node_modules is missing.
|
|
77
|
+
function isInstalled(dir, manifest) {
|
|
78
|
+
if (!manifest.install) return true; // no install step declared
|
|
79
|
+
const cfg = getConfigFn?.();
|
|
80
|
+
if (!cfg?.pluginInstalled?.[manifest.id]) return false;
|
|
81
|
+
// Self-correct: config says installed but files are gone
|
|
82
|
+
if (!existsSync(join(dir, 'node_modules'))) {
|
|
83
|
+
console.log(`[plugin] ${manifest.name}: node_modules missing, resetting install state`);
|
|
84
|
+
delete cfg.pluginInstalled[manifest.id];
|
|
85
|
+
saveConfigFn?.(cfg);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readManifest(dir, name) {
|
|
92
|
+
let manifest = { id: name, name, version: '0.0.0' };
|
|
93
|
+
const manifestFile = existsSync(join(dir, 'clideck-plugin.json')) ? join(dir, 'clideck-plugin.json') : join(dir, 'termix-plugin.json');
|
|
94
|
+
if (existsSync(manifestFile)) {
|
|
95
|
+
try { manifest = { ...manifest, ...JSON.parse(readFileSync(manifestFile, 'utf8')) }; }
|
|
96
|
+
catch (e) { console.error(`[plugin:${name}] bad manifest: ${e.message}`); return null; }
|
|
97
|
+
}
|
|
98
|
+
if (manifest.settings != null) {
|
|
99
|
+
if (!Array.isArray(manifest.settings)) {
|
|
100
|
+
console.error(`[plugin:${name}] manifest.settings must be an array, ignoring`);
|
|
101
|
+
manifest.settings = [];
|
|
102
|
+
} else {
|
|
103
|
+
manifest.settings = manifest.settings.filter(s =>
|
|
104
|
+
s && typeof s === 'object' && typeof s.key === 'string' && s.key
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return manifest;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function loadPlugin(manifest, dir) {
|
|
112
|
+
if (plugins.has(manifest.id)) return;
|
|
113
|
+
const state = { manifest, dir, shutdownFns: [], actions: [], dynamicOptions: {} };
|
|
114
|
+
plugins.set(manifest.id, state);
|
|
115
|
+
try {
|
|
116
|
+
const mod = require(join(dir, 'index.js'));
|
|
117
|
+
if (typeof mod.init === 'function') mod.init(buildApi(manifest.id, dir, state));
|
|
118
|
+
console.log(`[plugin] ${manifest.name} v${manifest.version}`);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
console.error(`[plugin:${manifest.id}] init failed: ${e.message}`);
|
|
121
|
+
removeHooks(manifest.id);
|
|
122
|
+
plugins.delete(manifest.id);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
69
126
|
function init(broadcast, getSessions, getConfig, saveConfig, sessionInput, createProgrammatic, closeSession) {
|
|
70
127
|
broadcastFn = broadcast;
|
|
71
128
|
sessionsFn = getSessions;
|
|
@@ -75,47 +132,40 @@ function init(broadcast, getSessions, getConfig, saveConfig, sessionInput, creat
|
|
|
75
132
|
createSessionFn = createProgrammatic;
|
|
76
133
|
closeSessionFn = closeSession;
|
|
77
134
|
|
|
135
|
+
// Clear install state for bundled plugins that were updated during seed
|
|
136
|
+
if (updatedBundled.size) {
|
|
137
|
+
const cfg = getConfig();
|
|
138
|
+
if (cfg?.pluginInstalled) {
|
|
139
|
+
for (const id of updatedBundled) {
|
|
140
|
+
if (cfg.pluginInstalled[id]) {
|
|
141
|
+
delete cfg.pluginInstalled[id];
|
|
142
|
+
console.log(`[plugin] cleared install state for updated ${id}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
saveConfig(cfg);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
78
149
|
for (const entry of readdirSync(PLUGINS_DIR, { withFileTypes: true })) {
|
|
79
150
|
if (!entry.isDirectory()) continue;
|
|
80
151
|
const dir = join(PLUGINS_DIR, entry.name);
|
|
81
|
-
|
|
82
|
-
if (!existsSync(entryFile)) continue;
|
|
83
|
-
|
|
84
|
-
let manifest = { id: entry.name, name: entry.name, version: '0.0.0' };
|
|
85
|
-
const manifestFile = existsSync(join(dir, 'clideck-plugin.json')) ? join(dir, 'clideck-plugin.json') : join(dir, 'termix-plugin.json');
|
|
86
|
-
if (existsSync(manifestFile)) {
|
|
87
|
-
try { manifest = { ...manifest, ...JSON.parse(readFileSync(manifestFile, 'utf8')) }; }
|
|
88
|
-
catch (e) { console.error(`[plugin:${entry.name}] bad manifest: ${e.message}`); continue; }
|
|
89
|
-
}
|
|
90
|
-
// Validate settings shape
|
|
91
|
-
if (manifest.settings != null) {
|
|
92
|
-
if (!Array.isArray(manifest.settings)) {
|
|
93
|
-
console.error(`[plugin:${entry.name}] manifest.settings must be an array, ignoring`);
|
|
94
|
-
manifest.settings = [];
|
|
95
|
-
} else {
|
|
96
|
-
manifest.settings = manifest.settings.filter(s =>
|
|
97
|
-
s && typeof s === 'object' && typeof s.key === 'string' && s.key
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
152
|
+
if (!existsSync(join(dir, 'index.js'))) continue;
|
|
101
153
|
|
|
102
|
-
|
|
154
|
+
const manifest = readManifest(dir, entry.name);
|
|
155
|
+
if (!manifest) continue;
|
|
156
|
+
|
|
157
|
+
if (plugins.has(manifest.id) || uninstalledPlugins.has(manifest.id)) {
|
|
103
158
|
console.error(`[plugin:${manifest.id}] duplicate ID, skipping ${dir}`);
|
|
104
159
|
continue;
|
|
105
160
|
}
|
|
106
161
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const mod = require(entryFile);
|
|
112
|
-
if (typeof mod.init === 'function') mod.init(buildApi(manifest.id, dir, state));
|
|
113
|
-
console.log(`[plugin] ${manifest.name} v${manifest.version}`);
|
|
114
|
-
} catch (e) {
|
|
115
|
-
console.error(`[plugin:${manifest.id}] init failed: ${e.message}`);
|
|
116
|
-
removeHooks(manifest.id);
|
|
117
|
-
plugins.delete(manifest.id);
|
|
162
|
+
if (!isInstalled(dir, manifest)) {
|
|
163
|
+
uninstalledPlugins.set(manifest.id, { manifest, dir });
|
|
164
|
+
console.log(`[plugin] ${manifest.name} v${manifest.version} (not installed)`);
|
|
165
|
+
continue;
|
|
118
166
|
}
|
|
167
|
+
|
|
168
|
+
loadPlugin(manifest, dir);
|
|
119
169
|
}
|
|
120
170
|
}
|
|
121
171
|
|
|
@@ -232,17 +282,19 @@ function buildApi(pluginId, pluginDir, state) {
|
|
|
232
282
|
},
|
|
233
283
|
|
|
234
284
|
resolve(specifier) {
|
|
235
|
-
// Resolve
|
|
236
|
-
// plugins that ship with CliDeck and can rely on app-level dependencies.
|
|
237
|
-
// Third-party plugins should bundle their own deps or be self-contained.
|
|
238
|
-
try { return require.resolve(specifier); } catch {}
|
|
285
|
+
// Resolve from plugin-local node_modules first, then app-level
|
|
239
286
|
const parts = specifier.startsWith('@') ? specifier.split('/').slice(0, 2) : [specifier.split('/')[0]];
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
287
|
+
for (const base of [join(pluginDir, 'node_modules'), join(__dirname, 'node_modules')]) {
|
|
288
|
+
const pkgDir = join(base, ...parts);
|
|
289
|
+
try {
|
|
290
|
+
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
|
|
291
|
+
const entry = typeof pkg.exports === 'string' ? pkg.exports
|
|
292
|
+
: pkg.exports?.['.']?.import || pkg.exports?.['.']?.default || pkg.exports?.['.']
|
|
293
|
+
|| pkg.module || pkg.main || 'index.js';
|
|
294
|
+
return join(pkgDir, entry);
|
|
295
|
+
} catch {}
|
|
296
|
+
}
|
|
297
|
+
return require.resolve(specifier);
|
|
246
298
|
},
|
|
247
299
|
onShutdown(fn) { state.shutdownFns.push(fn); },
|
|
248
300
|
log(msg) { console.log(`[plugin:${pluginId}] ${msg}`); },
|
|
@@ -352,19 +404,37 @@ function handleMessage(msg) {
|
|
|
352
404
|
|
|
353
405
|
function getInfo() {
|
|
354
406
|
const cfg = getConfigFn?.();
|
|
355
|
-
|
|
407
|
+
const installed = [...plugins.values()].map(p => ({
|
|
356
408
|
id: p.manifest.id,
|
|
357
409
|
name: p.manifest.name,
|
|
358
410
|
version: p.manifest.version,
|
|
359
411
|
author: p.manifest.author || '',
|
|
360
412
|
description: p.manifest.description || '',
|
|
413
|
+
icon: p.manifest.icon || '',
|
|
361
414
|
settings: p.manifest.settings || [],
|
|
362
415
|
settingValues: cfg?.pluginSettings?.[p.manifest.id] || {},
|
|
363
416
|
dynamicOptions: p.dynamicOptions || {},
|
|
364
417
|
actions: p.actions,
|
|
365
418
|
hasClient: existsSync(join(p.dir, 'client.js')),
|
|
366
419
|
bundled: BUNDLED_IDS.has(p.manifest.id),
|
|
420
|
+
installed: true,
|
|
421
|
+
}));
|
|
422
|
+
const pending = [...uninstalledPlugins.values()].map(u => ({
|
|
423
|
+
id: u.manifest.id,
|
|
424
|
+
name: u.manifest.name,
|
|
425
|
+
version: u.manifest.version,
|
|
426
|
+
author: u.manifest.author || '',
|
|
427
|
+
description: u.manifest.description || '',
|
|
428
|
+
icon: u.manifest.icon || '',
|
|
429
|
+
settings: [],
|
|
430
|
+
settingValues: {},
|
|
431
|
+
dynamicOptions: {},
|
|
432
|
+
actions: [],
|
|
433
|
+
hasClient: false,
|
|
434
|
+
bundled: BUNDLED_IDS.has(u.manifest.id),
|
|
435
|
+
installed: false,
|
|
367
436
|
}));
|
|
437
|
+
return [...installed, ...pending];
|
|
368
438
|
}
|
|
369
439
|
|
|
370
440
|
function resolveFile(urlPath) {
|
|
@@ -413,6 +483,35 @@ const BUNDLED_IDS = new Set(
|
|
|
413
483
|
: []
|
|
414
484
|
);
|
|
415
485
|
|
|
486
|
+
function installPlugin(pluginId, callback) {
|
|
487
|
+
const entry = uninstalledPlugins.get(pluginId);
|
|
488
|
+
if (!entry) return callback(new Error('Plugin not found or already installed'));
|
|
489
|
+
const { manifest, dir } = entry;
|
|
490
|
+
if (manifest.install !== 'npm') return callback(new Error(`Unknown install type: ${manifest.install}`));
|
|
491
|
+
console.log(`[plugin] installing ${manifest.name}...`);
|
|
492
|
+
npmExec(['install', '--production'], { cwd: dir, timeout: 120000 }, (err) => {
|
|
493
|
+
if (err) {
|
|
494
|
+
console.error(`[plugin:${pluginId}] install failed: ${err.message}`);
|
|
495
|
+
return callback(err);
|
|
496
|
+
}
|
|
497
|
+
uninstalledPlugins.delete(pluginId);
|
|
498
|
+
loadPlugin(manifest, dir);
|
|
499
|
+
// Only persist install state if plugin actually loaded
|
|
500
|
+
if (!plugins.has(pluginId)) {
|
|
501
|
+
uninstalledPlugins.set(pluginId, { manifest, dir });
|
|
502
|
+
return callback(new Error('Plugin installed but failed to load'));
|
|
503
|
+
}
|
|
504
|
+
const cfg = getConfigFn?.();
|
|
505
|
+
if (cfg) {
|
|
506
|
+
if (!cfg.pluginInstalled) cfg.pluginInstalled = {};
|
|
507
|
+
cfg.pluginInstalled[pluginId] = true;
|
|
508
|
+
saveConfigFn?.(cfg);
|
|
509
|
+
}
|
|
510
|
+
console.log(`[plugin] ${manifest.name} installed`);
|
|
511
|
+
callback(null);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
416
515
|
function removePlugin(pluginId) {
|
|
417
516
|
if (BUNDLED_IDS.has(pluginId)) return { success: false, message: 'Cannot remove a built-in plugin' };
|
|
418
517
|
const state = plugins.get(pluginId);
|
|
@@ -427,6 +526,12 @@ function removePlugin(pluginId) {
|
|
|
427
526
|
for (const fn of state.shutdownFns) { try { fn(); } catch {} }
|
|
428
527
|
removeHooks(pluginId);
|
|
429
528
|
plugins.delete(pluginId);
|
|
529
|
+
// Clear persisted install state
|
|
530
|
+
const cfg = getConfigFn?.();
|
|
531
|
+
if (cfg?.pluginInstalled?.[pluginId]) {
|
|
532
|
+
delete cfg.pluginInstalled[pluginId];
|
|
533
|
+
saveConfigFn?.(cfg);
|
|
534
|
+
}
|
|
430
535
|
console.log(`[plugin] removed ${pluginId}`);
|
|
431
536
|
return { success: true };
|
|
432
537
|
}
|
|
@@ -435,6 +540,6 @@ module.exports = {
|
|
|
435
540
|
PLUGINS_DIR, BUNDLED_IDS,
|
|
436
541
|
init, shutdown,
|
|
437
542
|
transformInput, notifyOutput, notifyStatus, notifyTranscript, notifyMenu, clearStatus, isWorking, shouldAutoApproveMenu,
|
|
438
|
-
handleMessage, updateSetting, getInfo, resolveFile, removePlugin,
|
|
543
|
+
handleMessage, updateSetting, getInfo, resolveFile, installPlugin, removePlugin,
|
|
439
544
|
getPills, getPillLogs,
|
|
440
545
|
};
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "autopilot",
|
|
3
3
|
"name": "Autopilot",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.15.0",
|
|
5
5
|
"author": "CliDeck",
|
|
6
6
|
"description": "Multi-agent orchestration — routes output between agents automatically",
|
|
7
|
+
"icon": "<svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 6v6l4 2\"/></svg>",
|
|
8
|
+
"install": "npm",
|
|
7
9
|
"settings": [
|
|
8
10
|
{
|
|
9
11
|
"key": "enabled",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"version": "1.2.0",
|
|
5
5
|
"author": "CliDeck",
|
|
6
6
|
"description": "Copy selected terminal text with trailing whitespace trimmed",
|
|
7
|
+
"icon": "<svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"6\" cy=\"6\" r=\"3\"/><circle cx=\"6\" cy=\"18\" r=\"3\"/><line x1=\"20\" y1=\"4\" x2=\"8.12\" y2=\"15.88\"/><line x1=\"14.47\" y1=\"14.48\" x2=\"20\" y2=\"20\"/><line x1=\"8.12\" y1=\"8.12\" x2=\"12\" y2=\"12\"/></svg>",
|
|
7
8
|
"settings": [
|
|
8
9
|
{
|
|
9
10
|
"key": "enabled",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"version": "1.2.0",
|
|
5
5
|
"author": "CliDeck",
|
|
6
6
|
"description": "Dictate prompts with your voice using Whisper speech-to-text",
|
|
7
|
+
"icon": "<svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z\"/><path d=\"M19 10v2a7 7 0 0 1-14 0v-2\"/><line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"23\"/><line x1=\"8\" y1=\"23\" x2=\"16\" y2=\"23\"/></svg>",
|
|
7
8
|
"settings": [
|
|
8
9
|
{
|
|
9
10
|
"key": "enabled",
|
package/public/index.html
CHANGED
|
@@ -324,11 +324,9 @@
|
|
|
324
324
|
<div class="mb-5">
|
|
325
325
|
<label class="block text-xs text-slate-400 mb-1.5 ml-6">Minimum working time before notifying</label>
|
|
326
326
|
<select id="cfg-notify-min-work" class="ml-6 px-3 py-1.5 text-sm bg-slate-800 border border-slate-600 rounded-md text-slate-200 outline-none focus:border-blue-500 transition-colors cursor-pointer">
|
|
327
|
-
<option value="
|
|
327
|
+
<option value="0">Always</option>
|
|
328
328
|
<option value="10" selected>10 seconds</option>
|
|
329
|
-
<option value="20">20 seconds</option>
|
|
330
329
|
<option value="30">30 seconds</option>
|
|
331
|
-
<option value="60">60 seconds</option>
|
|
332
330
|
</select>
|
|
333
331
|
</div>
|
|
334
332
|
<div class="mt-6 px-4 py-3 rounded-lg bg-slate-800/50 border border-slate-700/40">
|