clideck 1.22.7 → 1.23.2

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.
@@ -0,0 +1,27 @@
1
+ ## What changed
2
+
3
+ <!-- One or two sentences. What does this PR do? -->
4
+
5
+ ## Why
6
+
7
+ <!-- What problem does this solve? Link to an issue or discussion if one exists. -->
8
+
9
+ ## How to verify
10
+
11
+ <!--
12
+ Steps to test this locally:
13
+ 1. Run `node server.js`
14
+ 2. Open http://localhost:4000
15
+ 3. ...
16
+ -->
17
+
18
+ ## What's out of scope
19
+
20
+ <!-- What did you intentionally NOT change? Helps reviewers stay focused. -->
21
+
22
+ ## Checklist
23
+
24
+ - [ ] Tested locally with `node server.js`
25
+ - [ ] One focused change, not bundled with unrelated fixes
26
+ - [ ] No new external service dependencies
27
+ - [ ] Does not read, store, or intercept agent prompts/responses
@@ -0,0 +1,53 @@
1
+ # Contributing to CliDeck
2
+
3
+ This document explains what we accept, what needs discussion first, and how to structure a PR so it gets reviewed fast.
4
+
5
+ ## What to contribute
6
+
7
+ **Bug fixes and small improvements** — open a PR directly.
8
+ Crashes, rendering glitches, typos, broken links, edge cases, performance fixes.
9
+
10
+ **New features, architecture changes, or new agent presets** — [start a Discussion](https://github.com/rustykuntz/clideck/discussions) first.
11
+ Describe the problem, your proposed approach, and any trade-offs. If the idea gets a thumbs up, then open a PR.
12
+
13
+ **Plugins** — the best way to contribute new functionality.
14
+ CliDeck has a plugin system so features can be added without touching core code. If your idea can be a plugin, it should be. See the [documentation](https://docs.clideck.dev/) for how to build one.
15
+
16
+ ## What we will reject
17
+
18
+ - PRs that change multiple unrelated things. One change per PR.
19
+ - PRs with no description of what changed or why.
20
+ - Formatting-only changes (whitespace, semicolons, reordering imports).
21
+ - Features that add external service dependencies or phone home.
22
+ - Changes that break the zero-interference guarantee — CliDeck never reads, stores, or intercepts agent prompts and responses.
23
+
24
+ ## Before you open a PR
25
+
26
+ 1. **Test locally.** Run `node server.js`, open `http://localhost:4000`, and verify your change works.
27
+ 2. **Keep it focused.** If you found a bug while working on a feature, that's two PRs.
28
+ 3. **Match the existing style.** No linter is enforced, but stay consistent with surrounding code.
29
+ 4. **Don't bundle dependency changes** unless your PR specifically requires them.
30
+
31
+ ## PR structure
32
+
33
+ Fill out the PR template. The key fields:
34
+
35
+ - **What changed** — one or two sentences.
36
+ - **Why** — the problem this solves.
37
+ - **How to verify** — steps to confirm it works.
38
+ - **What's out of scope** — what you intentionally did not change.
39
+
40
+ Small PRs get reviewed faster. If your diff is over 300 lines, consider splitting it.
41
+
42
+ ## Built a fork?
43
+
44
+ If you've forked CliDeck and built something — a new agent integration, a workflow improvement, a plugin — open a Discussion in **Show & Tell**. The best fork features get upstreamed.
45
+
46
+ ## Licensing of contributions
47
+
48
+ By submitting a contribution that is accepted into the repository, you agree
49
+ that it will be licensed under the MIT License.
50
+
51
+ ## Conduct
52
+
53
+ Be constructive. Review comments are about the code, not the person. If a PR is rejected, the reason will be explained.
package/README.md CHANGED
@@ -75,6 +75,10 @@ Full setup guides, agent configuration, and plugin development:
75
75
 
76
76
  **[Documentation](https://docs.clideck.dev/)**
77
77
 
78
+ ## Acknowledgments
79
+
80
+ Built with [xterm.js](https://xtermjs.org/).
81
+
78
82
  ## License
79
83
 
80
- MIT
84
+ MIT — see [LICENSE](LICENSE).
package/activity.js CHANGED
@@ -51,6 +51,11 @@ function stop() {
51
51
  interval = null;
52
52
  }
53
53
 
54
+ function isActive(id) {
55
+ const s = stream[id];
56
+ return s ? (Date.now() - s.lastOutAt < 2000) : false;
57
+ }
58
+
54
59
  function clear(id) { delete net[id]; delete stream[id]; }
55
60
 
56
- module.exports = { start, stop, trackIn, trackOut, clear };
61
+ module.exports = { start, stop, trackIn, trackOut, isActive, clear };
package/config.js CHANGED
@@ -44,7 +44,9 @@ function migrate(cfg) {
44
44
  if (!cfg.defaultTheme || cfg.defaultTheme === 'solarized-dark') cfg.defaultTheme = 'catppuccin-mocha';
45
45
  // Backfill and sync fields from presets
46
46
  for (const cmd of cfg.commands) {
47
- const preset = matchPreset(cmd);
47
+ const preset = cmd.presetId ? PRESETS.find(p => p.presetId === cmd.presetId) : matchPreset(cmd);
48
+ // Stamp presetId for reliable lookup
49
+ if (preset && !cmd.presetId) cmd.presetId = preset.presetId;
48
50
  // Icon always syncs from preset — the preset is the source of truth for logos
49
51
  if (preset) cmd.icon = preset.icon;
50
52
  else if (!cmd.icon) cmd.icon = 'terminal';
@@ -67,10 +69,10 @@ function migrate(cfg) {
67
69
  }
68
70
  // Auto-add any shipped presets not yet in the commands list
69
71
  for (const preset of PRESETS) {
70
- const exists = cfg.commands.some(c => matchPreset(c)?.presetId === preset.presetId);
72
+ const exists = cfg.commands.some(c => c.presetId === preset.presetId || matchPreset(c)?.presetId === preset.presetId);
71
73
  if (!exists) {
72
74
  cfg.commands.push({
73
- id: crypto.randomUUID(), label: preset.name, icon: preset.icon,
75
+ id: crypto.randomUUID(), presetId: preset.presetId, label: preset.name, icon: preset.icon,
74
76
  command: preset.command, enabled: true, defaultPath: '',
75
77
  isAgent: preset.isAgent, canResume: preset.canResume,
76
78
  resumeCommand: preset.resumeCommand, sessionIdPattern: preset.sessionIdPattern,
package/handlers.js CHANGED
@@ -11,6 +11,23 @@ for (const p of presets) if (p.presetId === 'shell') p.command = defaultShell;
11
11
  const transcript = require('./transcript');
12
12
  const plugins = require('./plugin-loader');
13
13
 
14
+ const opencodePluginDir = join(
15
+ process.platform === 'win32' ? (process.env.APPDATA || join(os.homedir(), 'AppData', 'Roaming')) : join(os.homedir(), '.config'),
16
+ 'opencode', 'plugins'
17
+ );
18
+ // Resolve opencode preset paths for current platform
19
+ for (const p of presets) {
20
+ if (p.presetId !== 'opencode') continue;
21
+ const bridgePath = join(opencodePluginDir, 'clideck-bridge.js');
22
+ if (p.pluginPath) p.pluginPath = bridgePath;
23
+ if (p.pluginSetup) {
24
+ const copyCmd = process.platform === 'win32'
25
+ ? `copy opencode-plugin\\clideck-bridge.js "${opencodePluginDir}\\"`
26
+ : `cp opencode-plugin/clideck-bridge.js ${opencodePluginDir}/`;
27
+ p.pluginSetup = `Install the CliDeck bridge plugin to enable real-time status and resume.\n\n${copyCmd}`;
28
+ }
29
+ }
30
+
14
31
  // Check which agent binaries are available on PATH
15
32
  const whichCmd = process.platform === 'win32' ? 'where' : 'which';
16
33
  function checkAvailability() {
@@ -47,8 +64,7 @@ function detectTelemetryConfig(c) {
47
64
  detected = !!s.telemetry?.enabled && (s.telemetry?.otlpEndpoint || '').includes(`localhost:${port}`);
48
65
  } catch {}
49
66
  } else if (preset.presetId === 'opencode') {
50
- const ocPlugins = join(home, '.config', 'opencode', 'plugins');
51
- detected = existsSync(join(ocPlugins, 'clideck-bridge.js')) || existsSync(join(ocPlugins, 'termix-bridge.js'));
67
+ detected = existsSync(join(opencodePluginDir, 'clideck-bridge.js')) || existsSync(join(opencodePluginDir, 'termix-bridge.js'));
52
68
  } else { continue; }
53
69
  if (detected !== !!cmd.telemetryEnabled) {
54
70
  cmd.telemetryEnabled = detected;
@@ -60,14 +76,18 @@ function detectTelemetryConfig(c) {
60
76
  return changed;
61
77
  }
62
78
 
79
+ function configForClient() {
80
+ return { ...cfg, pluginsDir: plugins.PLUGINS_DIR };
81
+ }
82
+
63
83
  function onConnection(ws) {
64
84
  sessions.clients.add(ws);
65
85
 
66
- ws.send(JSON.stringify({ type: 'config', config: cfg }));
86
+ ws.send(JSON.stringify({ type: 'config', config: configForClient() }));
67
87
  ws.send(JSON.stringify({ type: 'themes', themes }));
68
88
  ws.send(JSON.stringify({ type: 'presets', presets }));
69
89
  ws.send(JSON.stringify({ type: 'sessions', list: sessions.list() }));
70
- ws.send(JSON.stringify({ type: 'sessions.resumable', list: sessions.getResumable() }));
90
+ ws.send(JSON.stringify({ type: 'sessions.resumable', list: sessions.getResumable(cfg) }));
71
91
  ws.send(JSON.stringify({ type: 'transcript.cache', cache: transcript.getCache() }));
72
92
  ws.send(JSON.stringify({ type: 'plugins', list: plugins.getInfo() }));
73
93
  sessions.sendBuffers(ws);
@@ -82,14 +102,20 @@ function onConnection(ws) {
82
102
  case 'session.restart': console.log('[handler] session.restart', msg.id); sessions.restart(msg, ws, cfg); break;
83
103
  case 'input': sessions.input(msg); break;
84
104
  case 'session.statusReport':
85
- if (sessions.getSessions().has(msg.id)) plugins.notifyStatus(msg.id, !!msg.working);
105
+ if (sessions.getSessions().has(msg.id)) {
106
+ plugins.notifyStatus(msg.id, !!msg.working);
107
+ }
108
+ break;
109
+ case 'terminal.buffer':
110
+ require('./transcript').storeBuffer(msg.id, msg.lines);
111
+ sessions.broadcast({ type: 'screen.updated', id: msg.id });
86
112
  break;
87
113
  case 'resize': sessions.resize(msg); break;
88
114
  case 'rename': sessions.rename(msg); break;
89
- case 'close': sessions.close(msg); break;
115
+ case 'close': sessions.close(msg, cfg); break;
90
116
 
91
117
  case 'config.get':
92
- ws.send(JSON.stringify({ type: 'config', config: cfg }));
118
+ ws.send(JSON.stringify({ type: 'config', config: configForClient() }));
93
119
  break;
94
120
 
95
121
  case 'checkAvailability':
@@ -98,10 +124,11 @@ function onConnection(ws) {
98
124
  break;
99
125
 
100
126
  case 'config.update':
127
+ delete msg.config.pluginsDir;
101
128
  cfg = { ...cfg, ...msg.config };
102
129
  detectTelemetryConfig(cfg);
103
130
  config.save(cfg);
104
- sessions.broadcast({ type: 'config', config: cfg });
131
+ sessions.broadcast({ type: 'config', config: configForClient() });
105
132
  break;
106
133
 
107
134
  case 'session.theme': {
@@ -129,7 +156,7 @@ function onConnection(ws) {
129
156
  }
130
157
  }
131
158
  config.save(cfg);
132
- sessions.broadcast({ type: 'config', config: cfg });
159
+ sessions.broadcast({ type: 'config', config: configForClient() });
133
160
  ws.send(JSON.stringify({
134
161
  type: 'telemetry.autosetup.result',
135
162
  presetId: msg.presetId,
@@ -159,7 +186,7 @@ function onConnection(ws) {
159
186
  }
160
187
  }
161
188
  config.save(cfg);
162
- sessions.broadcast({ type: 'config', config: cfg });
189
+ sessions.broadcast({ type: 'config', config: configForClient() });
163
190
  break;
164
191
  }
165
192
 
@@ -185,11 +212,11 @@ function onConnection(ws) {
185
212
  if (!proj) break;
186
213
  // Kill all sessions in this project
187
214
  for (const s of sessions.list()) {
188
- if (s.projectId === msg.id) sessions.close({ id: s.id });
215
+ if (s.projectId === msg.id) sessions.close({ id: s.id }, cfg);
189
216
  }
190
217
  cfg.projects = cfg.projects.filter(p => p.id !== msg.id);
191
218
  config.save(cfg);
192
- sessions.broadcast({ type: 'config', config: cfg });
219
+ sessions.broadcast({ type: 'config', config: configForClient() });
193
220
  break;
194
221
  }
195
222
 
@@ -207,6 +234,65 @@ function onConnection(ws) {
207
234
  sessions.broadcast({ type: 'plugins', list: plugins.getInfo() });
208
235
  break;
209
236
 
237
+ case 'plugin.delete': {
238
+ const result = plugins.removePlugin(msg.pluginId);
239
+ if (result.success) {
240
+ sessions.broadcast({ type: 'plugins', list: plugins.getInfo() });
241
+ } else {
242
+ ws.send(JSON.stringify({ type: 'plugin.delete.error', pluginId: msg.pluginId, error: result.message }));
243
+ }
244
+ break;
245
+ }
246
+
247
+ case 'remote.status': {
248
+ let installed = false;
249
+ try { execFileSync(whichCmd, ['clideck-remote'], { stdio: 'ignore' }); installed = true; } catch {}
250
+ if (!installed) { ws.send(JSON.stringify({ type: 'remote.status', installed: false })); break; }
251
+ require('child_process').execFile('clideck-remote', ['status', '--json'], { timeout: 5000 }, (err, stdout) => {
252
+ if (err) { ws.send(JSON.stringify({ type: 'remote.status', installed: true })); return; }
253
+ try { ws.send(JSON.stringify({ type: 'remote.status', installed: true, ...JSON.parse(stdout) })); }
254
+ catch { ws.send(JSON.stringify({ type: 'remote.status', installed: true })); }
255
+ });
256
+ break;
257
+ }
258
+
259
+ case 'remote.pair': {
260
+ require('child_process').execFile('clideck-remote', ['pair', '--json'], { timeout: 15000 }, (err, stdout) => {
261
+ if (err) { ws.send(JSON.stringify({ type: 'remote.error', error: err.message })); return; }
262
+ try { ws.send(JSON.stringify({ type: 'remote.paired', ...JSON.parse(stdout) })); }
263
+ catch { ws.send(JSON.stringify({ type: 'remote.error', error: 'Invalid response from clideck-remote' })); }
264
+ });
265
+ break;
266
+ }
267
+
268
+ case 'remote.unpair': {
269
+ require('child_process').execFile('clideck-remote', ['unpair', '--json'], { timeout: 5000 }, (err) => {
270
+ if (err) {
271
+ ws.send(JSON.stringify({ type: 'remote.error', error: err.message }));
272
+ } else {
273
+ sessions.broadcast({ type: 'remote.unpaired' });
274
+ }
275
+ });
276
+ break;
277
+ }
278
+
279
+ case 'remote.getHistory': {
280
+ const turns = transcript.getScreenTurns(msg.id, sessions.getSessions().get(msg.id)?.presetId)
281
+ || transcript.getLastTurns(msg.id, msg.limit || 50);
282
+ ws.send(JSON.stringify({ type: 'remote.history', id: msg.id, turns: turns || [] }));
283
+ break;
284
+ }
285
+
286
+ case 'remote.install': {
287
+ const proc = require('child_process').spawn('npm', ['install', '-g', 'clideck-remote'], {
288
+ shell: true, stdio: ['ignore', 'pipe', 'pipe'],
289
+ });
290
+ proc.stdout.on('data', d => ws.send(JSON.stringify({ type: 'remote.install.progress', text: d.toString() })));
291
+ proc.stderr.on('data', d => ws.send(JSON.stringify({ type: 'remote.install.progress', text: d.toString() })));
292
+ proc.on('close', code => ws.send(JSON.stringify({ type: 'remote.install.done', success: code === 0 })));
293
+ break;
294
+ }
295
+
210
296
  default:
211
297
  if (msg.type?.startsWith('plugin.')) plugins.handleMessage(msg);
212
298
  break;
@@ -256,14 +342,13 @@ function applyTelemetryConfig(preset) {
256
342
  }
257
343
 
258
344
  if (preset.presetId === 'opencode') {
259
- const pluginDir = join(home, '.config', 'opencode', 'plugins');
260
345
  const src = join(__dirname, 'opencode-plugin', 'clideck-bridge.js');
261
- mkdirSync(pluginDir, { recursive: true });
262
- copyFileSync(src, join(pluginDir, 'clideck-bridge.js'));
346
+ mkdirSync(opencodePluginDir, { recursive: true });
347
+ copyFileSync(src, join(opencodePluginDir, 'clideck-bridge.js'));
263
348
  // Remove old termix-bridge.js if present
264
- const old = join(pluginDir, 'termix-bridge.js');
349
+ const old = join(opencodePluginDir, 'termix-bridge.js');
265
350
  if (existsSync(old)) try { unlinkSync(old); } catch {}
266
- return { success: true, message: 'Installed bridge plugin to ~/.config/opencode/plugins/' };
351
+ return { success: true, message: `Installed bridge plugin to ${opencodePluginDir}` };
267
352
  }
268
353
 
269
354
  return { success: false, message: `No auto-setup for ${preset.presetId}` };
@@ -297,9 +382,8 @@ function removeTelemetryConfig(preset) {
297
382
  }
298
383
 
299
384
  if (preset.presetId === 'opencode') {
300
- const dir = join(home, '.config', 'opencode', 'plugins');
301
- try { unlinkSync(join(dir, 'clideck-bridge.js')); } catch {}
302
- try { unlinkSync(join(dir, 'termix-bridge.js')); } catch {}
385
+ try { unlinkSync(join(opencodePluginDir, 'clideck-bridge.js')); } catch {}
386
+ try { unlinkSync(join(opencodePluginDir, 'termix-bridge.js')); } catch {}
303
387
  return { success: true, message: 'Removed bridge plugin' };
304
388
  }
305
389
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clideck",
3
- "version": "1.22.7",
3
+ "version": "1.23.2",
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": {
package/plugin-loader.js CHANGED
@@ -1,4 +1,4 @@
1
- const { readdirSync, readFileSync, existsSync, mkdirSync, cpSync } = require('fs');
1
+ const { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } = require('fs');
2
2
  const { join, sep } = require('path');
3
3
  const { DATA_DIR } = require('./paths');
4
4
 
@@ -32,6 +32,7 @@ const plugins = new Map();
32
32
  const inputHooks = [];
33
33
  const outputHooks = [];
34
34
  const statusHooks = [];
35
+ const transcriptHooks = [];
35
36
  const sessionStatus = new Map(); // sessionId → boolean (dedup multi-client reports)
36
37
  const frontendHandlers = new Map();
37
38
  let broadcastFn = null;
@@ -41,7 +42,7 @@ let saveConfigFn = null;
41
42
  const settingsChangeHandlers = new Map(); // pluginId → [fn]
42
43
 
43
44
  function removeHooks(pluginId) {
44
- for (const arr of [inputHooks, outputHooks, statusHooks]) {
45
+ for (const arr of [inputHooks, outputHooks, statusHooks, transcriptHooks]) {
45
46
  for (let i = arr.length - 1; i >= 0; i--) {
46
47
  if (arr[i].pluginId === pluginId) arr.splice(i, 1);
47
48
  }
@@ -111,6 +112,7 @@ function buildApi(pluginId, pluginDir, state) {
111
112
  onSessionInput(fn) { inputHooks.push({ pluginId, fn }); },
112
113
  onSessionOutput(fn) { outputHooks.push({ pluginId, fn }); },
113
114
  onStatusChange(fn) { statusHooks.push({ pluginId, fn }); },
115
+ onTranscriptEntry(fn) { transcriptHooks.push({ pluginId, fn }); },
114
116
 
115
117
  sendToFrontend(event, data = {}) {
116
118
  broadcastFn?.({ ...data, type: `plugin.${pluginId}.${event}` });
@@ -184,6 +186,13 @@ function notifyStatus(id, working) {
184
186
  }
185
187
  }
186
188
 
189
+ function notifyTranscript(id, role, text) {
190
+ for (const h of transcriptHooks) {
191
+ try { h.fn(id, role, text); }
192
+ catch (e) { console.error(`[plugin:${h.pluginId}] transcript error: ${e.message}`); }
193
+ }
194
+ }
195
+
187
196
  function updateSetting(pluginId, key, value) {
188
197
  // Validate plugin exists (also prevents __proto__ pollution — Map lookup returns undefined)
189
198
  const plugin = plugins.get(pluginId);
@@ -247,6 +256,7 @@ function getInfo() {
247
256
  settingValues: cfg?.pluginSettings?.[p.manifest.id] || {},
248
257
  actions: p.actions,
249
258
  hasClient: existsSync(join(p.dir, 'client.js')),
259
+ bundled: BUNDLED_IDS.has(p.manifest.id),
250
260
  }));
251
261
  }
252
262
 
@@ -280,8 +290,34 @@ function shutdown() {
280
290
 
281
291
  function clearStatus(id) { sessionStatus.delete(id); }
282
292
 
293
+ // Bundled plugin IDs — these ship with CliDeck and must not be deleted
294
+ const BUNDLED_IDS = new Set(
295
+ existsSync(BUNDLED_DIR)
296
+ ? readdirSync(BUNDLED_DIR, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name)
297
+ : []
298
+ );
299
+
300
+ function removePlugin(pluginId) {
301
+ if (BUNDLED_IDS.has(pluginId)) return { success: false, message: 'Cannot remove a built-in plugin' };
302
+ const state = plugins.get(pluginId);
303
+ if (!state) return { success: false, message: 'Plugin not found' };
304
+ // Delete plugin directory first — if this fails, runtime state stays intact
305
+ try {
306
+ rmSync(state.dir, { recursive: true, force: true });
307
+ } catch (e) {
308
+ return { success: false, message: e.message };
309
+ }
310
+ // Filesystem gone — now clean up runtime state
311
+ for (const fn of state.shutdownFns) { try { fn(); } catch {} }
312
+ removeHooks(pluginId);
313
+ plugins.delete(pluginId);
314
+ console.log(`[plugin] removed ${pluginId}`);
315
+ return { success: true };
316
+ }
317
+
283
318
  module.exports = {
319
+ PLUGINS_DIR, BUNDLED_IDS,
284
320
  init, shutdown,
285
- transformInput, notifyOutput, notifyStatus, clearStatus,
286
- handleMessage, updateSetting, getInfo, resolveFile,
321
+ transformInput, notifyOutput, notifyStatus, notifyTranscript, clearStatus,
322
+ handleMessage, updateSetting, getInfo, resolveFile, removePlugin,
287
323
  };
Binary file
package/public/index.html CHANGED
@@ -148,6 +148,9 @@
148
148
  <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/><circle cx="12" cy="12" r="3"/><path d="M12 8V6m0 12v-2M8 12H6m12 0h-2"/></svg>
149
149
  </button>
150
150
  <div class="flex-1"></div>
151
+ <button class="rail-btn w-9 h-9 flex items-center justify-center rounded-lg text-slate-500 hover:text-slate-300 hover:bg-slate-800/50 transition-colors" id="btn-remote" title="Mobile Remote">
152
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
153
+ </button>
151
154
  <button class="theme-toggle rail-btn w-9 h-9 flex items-center justify-center rounded-lg text-slate-500 hover:text-slate-300 hover:bg-slate-800/50 transition-colors" id="btn-theme-toggle" title="Toggle light/dark mode">
152
155
  <svg class="icon-sun w-5 h-5" 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="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
153
156
  <svg class="icon-moon w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
@@ -193,8 +196,9 @@
193
196
  <div id="panel-prompts" class="hidden flex-col flex-1 min-h-0"></div>
194
197
  <!-- Plugins panel -->
195
198
  <div id="panel-plugins" class="hidden flex-col flex-1 min-h-0">
196
- <div class="px-4 py-3 border-b border-slate-700/50">
199
+ <div class="flex items-center px-4 py-3 border-b border-slate-700/50">
197
200
  <span class="text-xs font-semibold uppercase tracking-wider text-slate-500">Plugins</span>
201
+ <a href="https://clideck.dev/plugins" target="_blank" rel="noopener" class="ml-auto text-[11px] text-blue-400 hover:text-blue-300 transition-colors" title="Browse plugins">Get plugins &rarr;</a>
198
202
  </div>
199
203
  <div id="plugins-list" class="flex-1 overflow-y-auto py-2"></div>
200
204
  </div>
@@ -338,6 +342,104 @@
338
342
 
339
343
  </div>
340
344
 
345
+ <!-- Remote modal -->
346
+ <div id="remote-modal" class="absolute inset-0 z-[260] bg-black/60 backdrop-blur-sm hidden items-center justify-center">
347
+ <div style="background:var(--color-dialog);border:1px solid color-mix(in srgb, var(--color-muted) 40%, transparent);box-shadow:0 25px 60px -12px var(--color-shadow)" class="rounded-2xl w-[340px] flex flex-col overflow-hidden">
348
+ <div class="px-5 py-3.5 flex items-center justify-between">
349
+ <span class="text-[13px] font-semibold text-slate-200">Mobile Remote</span>
350
+ <button id="remote-close" class="w-6 h-6 flex items-center justify-center rounded-md text-slate-500 hover:text-slate-300 hover:bg-slate-700/50 transition-colors">
351
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
352
+ </button>
353
+ </div>
354
+
355
+ <!-- Intro (not installed) -->
356
+ <div id="remote-intro" class="hidden px-6 py-6 flex flex-col items-center gap-4">
357
+ <svg class="w-12 h-12 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
358
+ <h3 class="text-[13px] font-semibold text-slate-200">CliDeck Mobile Remote</h3>
359
+ <p class="text-xs text-slate-400 text-center leading-relaxed">Control your AI agents from your phone. See live status, send messages, and get notifications — all end-to-end encrypted.</p>
360
+ <button id="remote-add" class="mt-1 w-full px-4 py-2.5 text-[13px] font-medium bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors">Add to CliDeck</button>
361
+ <p class="text-[11px] text-slate-600 text-center">Installs the <code class="text-slate-500">clideck-remote</code> package via npm</p>
362
+ </div>
363
+
364
+ <!-- Installing -->
365
+ <div id="remote-installing" class="hidden px-5 py-4 flex flex-col gap-3">
366
+ <div class="flex items-center gap-2">
367
+ <svg class="w-4 h-4 animate-spin text-blue-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2a10 10 0 0 1 10 10"/></svg>
368
+ <span class="text-xs text-slate-300">Installing clideck-remote…</span>
369
+ </div>
370
+ <pre id="remote-install-log" class="max-h-[160px] overflow-y-auto px-3 py-2 bg-slate-900/70 rounded-lg border border-slate-700/40 text-[11px] text-slate-400 font-mono leading-relaxed whitespace-pre-wrap"></pre>
371
+ </div>
372
+
373
+ <!-- Connecting -->
374
+ <div id="remote-connecting" class="hidden px-5 py-6 flex flex-col items-center gap-2">
375
+ <svg class="w-6 h-6 animate-spin text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2a10 10 0 0 1 10 10"/></svg>
376
+ <span class="text-xs text-slate-400">Connecting to relay…</span>
377
+ </div>
378
+
379
+ <!-- QR / waiting for phone -->
380
+ <div id="remote-qr" class="hidden flex flex-col items-center w-full">
381
+ <div id="remote-url-box" class="hidden"></div>
382
+ <div class="py-6 px-6 flex flex-col items-center w-full">
383
+ <p class="text-sm font-medium text-slate-300 mb-4">Scan with your phone</p>
384
+ <img id="remote-qr-img" class="w-[180px] h-[180px] rounded-2xl hidden" alt="QR code" style="padding:12px;background:white">
385
+ <p class="mt-4 text-xs text-slate-400">or <button id="remote-copy" class="font-semibold underline underline-offset-2 text-slate-300 hover:text-slate-200 transition-colors">copy the link</button> to open on your device</p>
386
+ <div class="mt-5 w-full px-4 py-3 rounded-lg text-center" style="background:color-mix(in srgb, var(--color-surface) 70%, transparent)">
387
+ <div class="flex items-center justify-center gap-1.5">
388
+ <span id="remote-plan-badge" class="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-px rounded-full bg-slate-700 text-slate-400">Free</span>
389
+ <span class="text-[11px] text-slate-500">&middot; 1 active session</span>
390
+ </div>
391
+ <p class="mt-1 text-[11px] text-slate-500"><a id="remote-upgrade" href="https://clideck.dev/pro" target="_blank" class="text-blue-400 hover:text-blue-300 transition-colors">Upgrade to Pro</a> for unlimited sessions</p>
392
+ </div>
393
+ <button id="remote-disconnect" class="mt-3 w-full py-2 text-[11px] text-slate-500 hover:text-slate-400 transition-colors">Disconnect</button>
394
+ </div>
395
+ <div class="w-full border-t border-slate-700/40 px-6 py-3 flex items-center justify-center gap-1.5 text-slate-400">
396
+ <svg class="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
397
+ <span class="text-[11px] font-medium">Your sessions are end-to-end encrypted</span>
398
+ </div>
399
+ </div>
400
+
401
+ <!-- Paired / active -->
402
+ <div id="remote-active" class="hidden flex flex-col items-center w-full">
403
+ <div class="py-6 px-6 flex flex-col items-center w-full gap-4">
404
+ <div class="flex items-center gap-2 text-emerald-400">
405
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>
406
+ <span class="text-[13px] font-medium">Connected</span>
407
+ </div>
408
+ <p id="remote-device-info" class="text-[11px] text-slate-500 -mt-2"></p>
409
+
410
+ <div id="remote-stats" class="w-full grid grid-cols-2 gap-2 text-center">
411
+ <div class="px-3 py-2.5 rounded-lg" style="background:color-mix(in srgb, var(--color-surface) 70%, transparent)">
412
+ <div id="remote-stat-time" class="text-sm font-mono text-slate-200">0:00</div>
413
+ <div class="text-[10px] text-slate-500 uppercase tracking-wider mt-0.5">Connected</div>
414
+ </div>
415
+ <div class="px-3 py-2.5 rounded-lg" style="background:color-mix(in srgb, var(--color-surface) 70%, transparent)">
416
+ <div id="remote-stat-sessions" class="text-sm font-mono text-slate-200">0</div>
417
+ <div class="text-[10px] text-slate-500 uppercase tracking-wider mt-0.5">Sessions</div>
418
+ </div>
419
+ </div>
420
+
421
+ <button id="remote-disconnect2" class="w-full py-2.5 text-[11px] font-medium text-red-400 hover:text-red-300 rounded-lg transition-colors" style="background:color-mix(in srgb, var(--color-surface) 70%, transparent)">Disconnect</button>
422
+ </div>
423
+ <div class="w-full border-t border-slate-700/40 px-6 py-3 flex items-center justify-between">
424
+ <div class="flex items-center gap-1.5 text-emerald-500/70">
425
+ <svg class="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
426
+ <span class="text-[11px]">End-to-end encrypted</span>
427
+ </div>
428
+ <div id="remote-plan-active" class="flex items-center gap-1.5">
429
+ <span id="remote-plan-badge2" class="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-px rounded-full bg-slate-700 text-slate-400">Free</span>
430
+ <a id="remote-upgrade2" href="https://clideck.dev/pro" target="_blank" class="text-[11px] text-blue-400 hover:text-blue-300 transition-colors">Upgrade</a>
431
+ </div>
432
+ </div>
433
+ </div>
434
+
435
+ <!-- Error -->
436
+ <div id="remote-error" class="hidden px-5 py-4 flex flex-col items-center gap-3">
437
+ <p id="remote-error-text" class="text-xs text-red-400 text-center"></p>
438
+ <button id="remote-error-dismiss" class="px-4 py-2 text-xs text-slate-400 hover:text-slate-300 transition-colors">Close</button>
439
+ </div>
440
+ </div>
441
+ </div>
442
+
341
443
  <!-- Confirmation dialog -->
342
444
  <div id="confirm-close" class="absolute inset-0 z-[250] bg-black/60 backdrop-blur-sm hidden items-center justify-center">
343
445
  <div class="bg-slate-800 border border-slate-600 rounded-xl shadow-2xl shadow-black/50 w-80 flex flex-col">