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 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 demo roles (Programmer, Reviewer, Product Manager) and 3 starter prompts in the prompt library to get going quickly.
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. Works with any LLM provider (Anthropic, OpenAI, Google, Groq, xAI, Mistral, and more). Notifies you when work is complete or blocked.
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
@@ -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": "Built-in (no configuration needed)",
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
- detected = true;
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
- const choices = require('./transcript').detectMenu(msg.lines, sess.presetId);
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
- if (content.includes('[otel]')) return { success: true, message: 'Already configured' };
386
- const section = `\n[otel]\nexporter = { otlp-http = { endpoint = "http://localhost:${port}/v1/logs", protocol = "json" } }\n`;
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.trimEnd() + '\n' + section);
389
- return { success: true, message: 'Added [otel] section to ~/.codex/config.toml' };
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 [otel] section from ~/.codex/config.toml' };
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.26.3",
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
- const entryFile = join(dir, 'index.js');
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
- if (plugins.has(manifest.id)) {
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
- const state = { manifest, dir, shutdownFns: [], actions: [], dynamicOptions: {} };
108
- plugins.set(manifest.id, state);
109
-
110
- try {
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 a package from the app's node_modules. Intended for bundled
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 pkgDir = join(__dirname, 'node_modules', ...parts);
241
- const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'));
242
- const entry = typeof pkg.exports === 'string' ? pkg.exports
243
- : pkg.exports?.['.']?.import || pkg.exports?.['.']?.default || pkg.exports?.['.']
244
- || pkg.module || pkg.main || 'index.js';
245
- return join(pkgDir, entry);
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
- return [...plugins.values()].map(p => ({
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.14.4",
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",
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "clideck-plugin-autopilot",
3
+ "private": true,
4
+ "dependencies": {
5
+ "@mariozechner/pi-ai": ">=0.62.0"
6
+ }
7
+ }
@@ -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="5">5 seconds</option>
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">