clideck 1.31.16 → 1.31.18

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
@@ -18,9 +18,9 @@
18
18
  <img src="assets/clideck-themes.jpg" width="720" alt="clideck dashboard">
19
19
  </p>
20
20
 
21
- clideck is a local app for running multiple AI coding agents without juggling terminals. Claude Code, Codex, Gemini CLI, and OpenCode all live in one browser window with a chat-style sidebar, live status, message previews, session resume, and projects to keep things organized. an autopilot routes work between agents automatically, and an E2E encrypted mobile relay gives full control over all agents from a phone.
21
+ clideck is a local app for running multiple AI coding agents without juggling terminals. Claude Code, Codex, Gemini CLI, OpenCode, and Pi all live in one browser window with a chat-style sidebar, live status, message previews, session resume, and projects to keep things organized. an autopilot routes work between agents automatically, and an E2E encrypted mobile relay gives full control over all agents from a phone.
22
22
 
23
- the main problem with using multiple agents is not starting them. it is managing them. terminals pile up, finished work gets missed, good sessions disappear after a restart. clideck does not sit in the middle rewriting prompts or output - it only watches lightweight status signals (OpenTelemetry) so it can tell which agent is working, which is idle, and which is waiting. everything runs locally, no data leaves your machine.
23
+ the main problem with using multiple agents is not starting them. it is managing them. terminals pile up, finished work gets missed, good sessions disappear after a restart. clideck does not sit in the middle rewriting prompts or output - it only watches lightweight status signals from each agent so it can tell which agent is working, which is idle, and which is waiting. everything runs locally, no data leaves your machine.
24
24
 
25
25
  ## Why this exists
26
26
 
@@ -47,7 +47,7 @@ clideck --port 4001
47
47
 
48
48
  ## What makes it useful
49
49
 
50
- **Live status** - see which agent is working and which is waiting. Status detection for Claude Code, Codex, Gemini CLI, and OpenCode.
50
+ **Live status** - see which agent is working and which is waiting. Status detection for Claude Code, Codex, Gemini CLI, OpenCode, and Pi.
51
51
 
52
52
  **Session resume** - close the lid, reopen tomorrow, pick up where things left off. each agent's session ID is captured automatically.
53
53
 
@@ -79,7 +79,7 @@ If project or session names contain spaces, quote the whole target. The target i
79
79
 
80
80
  ## Supported agents
81
81
 
82
- Claude Code, Codex, Gemini CLI, OpenCode, Shell, and any other terminal tool.
82
+ Claude Code, Codex, Gemini CLI, OpenCode, Pi, Shell, and any other terminal tool.
83
83
 
84
84
  ## Also
85
85
 
package/activity.js CHANGED
@@ -1,77 +1,22 @@
1
- // Per-session I/O tracking and burst detection.
2
- // Broadcasts rate + burst data so the frontend can derive working/idle status.
3
-
4
- let interval = null;
5
- const net = {}; // id → byte counters
6
- const stream = {}; // id → burst timing
1
+ // Lightweight per-session I/O counters. The old per-second stats broadcast was
2
+ // removed after frontend status tracking moved away from I/O heuristics.
3
+ const net = {}; // id -> byte counters
7
4
 
8
5
  function ensure(id) {
9
- if (!net[id]) net[id] = { in: 0, out: 0, prevIn: 0, prevOut: 0 };
10
- if (!stream[id]) stream[id] = { lastOutAt: 0, burstStart: 0 };
6
+ if (!net[id]) net[id] = { in: 0, out: 0 };
7
+ return net[id];
11
8
  }
12
9
 
13
10
  function trackIn(id, bytes) {
14
- ensure(id);
15
- net[id].in += bytes;
16
- stream[id].lastInAt = Date.now();
11
+ const n = ensure(id);
12
+ n.in += bytes;
17
13
  }
18
14
 
19
- function lastInputAt(id) { return stream[id]?.lastInAt || 0; }
20
-
21
15
  function trackOut(id, data) {
22
- ensure(id);
23
- const now = Date.now();
24
- const s = stream[id];
25
- net[id].out += data.length;
26
- if (now - s.lastOutAt > 2000) s.burstStart = now;
27
- s.lastOutAt = now;
28
- s.lastChunk = data;
16
+ const n = ensure(id);
17
+ n.out += data.length;
29
18
  }
30
19
 
31
- function start(sessions, broadcast) {
32
- if (interval) return;
33
- interval = setInterval(() => {
34
- if (!sessions.size) return;
35
- const now = Date.now();
36
- const stats = {};
37
- for (const [id] of sessions) {
38
- ensure(id);
39
- const n = net[id];
40
- const s = stream[id];
41
- const rawRateOut = n.out - n.prevOut;
42
- const rawRateIn = n.in - n.prevIn;
43
- n.prevOut = n.out;
44
- n.prevIn = n.in;
45
- const silence = s.lastOutAt ? now - s.lastOutAt : 0;
46
- const burstMs = s.burstStart && silence < 2000 ? now - s.burstStart : 0;
47
- stats[id] = { rawRateOut, rawRateIn, burstMs };
48
- }
49
- if (Object.keys(stats).length) broadcast({ type: 'stats', stats });
50
- // Debug: PTY active state + last output chars per session (1s tick)
51
- const active = [];
52
- for (const [id] of sessions) {
53
- const s = stream[id]; if (!s?.lastOutAt) continue;
54
- const state = now - s.lastOutAt < 2000 ? 'ACTIVE' : 'SILENT';
55
- const last = (s.lastChunk || '').replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\].*?(?:\x07|\x1b\\)|\x1b./g, '').replace(/[\r\n]+/g, ' ').trim().slice(-80);
56
- active.push(`${id.slice(0,8)}=${state} [${last}]`);
57
- }
58
- // if (active.length) console.log(`[pty] ${active.join(' | ')}`);
59
- }, 1000);
60
- }
61
-
62
- function stop() {
63
- clearInterval(interval);
64
- interval = null;
65
- }
66
-
67
- function isActive(id) {
68
- const s = stream[id];
69
- return s ? (Date.now() - s.lastOutAt < 2000) : false;
70
- }
71
-
72
- function lastOutputAt(id) { return stream[id]?.lastOutAt || 0; }
73
- function lastChunk(id) { return stream[id]?.lastChunk || ''; }
74
-
75
- function clear(id) { delete net[id]; delete stream[id]; }
20
+ function clear(id) { delete net[id]; }
76
21
 
77
- module.exports = { start, stop, trackIn, trackOut, isActive, lastOutputAt, lastInputAt, lastChunk, clear };
22
+ module.exports = { trackIn, trackOut, clear };
@@ -12,7 +12,7 @@
12
12
  "sessionIdPattern": "Session ID:\\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
13
13
  "outputMarker": "⏺",
14
14
  "telemetryConfigPath": "~/.claude/settings.json",
15
- "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
+ "telemetrySetup": "Required for working/idle status, Autopilot, notifications, and mobile remote.\n\nCliDeck will add lifecycle hooks to ~/.claude/settings.json. Claude will ask for one-time approval on next launch.",
16
16
  "telemetryAutoSetup": {
17
17
  "label": "Patch Claude"
18
18
  },
@@ -85,6 +85,24 @@
85
85
  "label": "Install plugin"
86
86
  }
87
87
  },
88
+ {
89
+ "presetId": "pi",
90
+ "name": "Pi",
91
+ "icon": "/img/pi.svg",
92
+ "command": "pi",
93
+ "minVersion": "0.79.3",
94
+ "installCmd": "npm install -g --ignore-scripts @earendil-works/pi-coding-agent",
95
+ "isAgent": true,
96
+ "canResume": true,
97
+ "resumeCommand": "pi --session {{sessionId}}",
98
+ "sessionIdPattern": null,
99
+ "outputMarker": "•",
100
+ "telemetryConfigPath": "~/.pi/agent/extensions/clideck-bridge.ts",
101
+ "telemetrySetup": "Required for working/idle status, resume, Autopilot, notifications, and mobile remote.\n\nCliDeck will install a small Pi extension to ~/.pi/agent/extensions/clideck-bridge.ts.",
102
+ "telemetryAutoSetup": {
103
+ "label": "Install extension"
104
+ }
105
+ },
88
106
  {
89
107
  "presetId": "clideck-agent",
90
108
  "name": "Clideck Agent",
package/ansi-utils.js ADDED
@@ -0,0 +1,7 @@
1
+ const ANSI_RE = /\x1b[\[\]()#;?]*[0-9;?]*[ -/]*[@-~]|\x1b\].*?(?:\x07|\x1b\\)|\x1b.|\r|\x07/g;
2
+
3
+ function stripAnsi(value) {
4
+ return String(value || '').replace(ANSI_RE, '');
5
+ }
6
+
7
+ module.exports = { ANSI_RE, stripAnsi };
@@ -15,6 +15,9 @@ process.stdin.on('end', () => {
15
15
  const body = JSON.stringify({
16
16
  clideck_id: process.env.CLIDECK_SESSION_ID || '',
17
17
  session_id: hook.session_id || '',
18
+ hook_event_name: hook.hook_event_name || '',
19
+ source: hook.source || '',
20
+ reason: hook.reason || '',
18
21
  payload: stdin.trim() || undefined,
19
22
  });
20
23
  const req = http.request({
@@ -0,0 +1,17 @@
1
+ const CLAUDE_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2
+
3
+ function updateClaudeSessionToken(sess, token, clideckId, options = {}) {
4
+ const next = String(token || '').trim();
5
+ if (!sess || sess.presetId !== 'claude-code' || !CLAUDE_SESSION_ID_RE.test(next)) return false;
6
+ if (sess.sessionToken === next) return false;
7
+ const prev = sess.sessionToken;
8
+ sess.sessionToken = next;
9
+
10
+ const label = options.label || 'Claude';
11
+ const source = options.source ? ` via ${options.source}` : '';
12
+ const previous = prev ? `${prev.slice(0, 12)}... -> ` : '';
13
+ console.log(`${label}: updated Claude session ID for ${clideckId.slice(0, 8)}${source}: ${previous}${next.slice(0, 12)}...`);
14
+ return true;
15
+ }
16
+
17
+ module.exports = { CLAUDE_SESSION_ID_RE, updateClaudeSessionToken };
package/config.js CHANGED
@@ -3,7 +3,8 @@ const { join } = require('path');
3
3
  const crypto = require('crypto');
4
4
  const os = require('os');
5
5
  const { DATA_DIR } = require('./paths');
6
- const { defaultShell, binName } = require('./utils');
6
+ const { defaultShell } = require('./utils');
7
+ const { presetForCommand } = require('./preset-utils');
7
8
 
8
9
  const CONFIG_PATH = join(DATA_DIR, 'config.json');
9
10
 
@@ -63,10 +64,7 @@ function isPresetEnabled(preset) {
63
64
  }
64
65
  const EXPOSED_PRESETS = PRESETS.filter(isPresetEnabled);
65
66
 
66
- function matchPreset(cmd) {
67
- const bin = binName(cmd.command);
68
- return PRESETS.find(p => binName(p.command) === bin);
69
- }
67
+ function matchPreset(cmd) { return presetForCommand(cmd, PRESETS, { usePresetId: false }); }
70
68
 
71
69
  function firstCommandToken(command) {
72
70
  const raw = String(command || '').trim();
package/handlers.js CHANGED
@@ -7,6 +7,7 @@ const sessions = require('./sessions');
7
7
  const themes = require('./themes');
8
8
  const presets = JSON.parse(readFileSync(join(__dirname, 'agent-presets.json'), 'utf8'));
9
9
  const { listDirs, binName, defaultShell } = require('./utils');
10
+ const { presetForCommand: findPresetForCommand } = require('./preset-utils');
10
11
  const { PORT } = require('./runtime');
11
12
  for (const p of presets) if (p.presetId === 'shell') p.command = defaultShell;
12
13
  function isPresetEnabled(preset) {
@@ -20,7 +21,11 @@ function clientPresets() {
20
21
  function filterClientCommands(commands) {
21
22
  const allowedPresetIds = new Set(clientPresets().map(p => p.presetId));
22
23
  const knownPresetIds = new Set(presets.map(p => p.presetId));
23
- return (commands || []).filter(cmd => !cmd.presetId || allowedPresetIds.has(cmd.presetId) || !knownPresetIds.has(cmd.presetId));
24
+ return (commands || []).filter(cmd => {
25
+ if (cmd.presetId && !allowedPresetIds.has(cmd.presetId) && knownPresetIds.has(cmd.presetId)) return false;
26
+ const preset = cmd.presetId ? presets.find(p => p.presetId === cmd.presetId) : null;
27
+ return !(preset?.available === false && String(cmd.command || '').trim() === String(preset.command || '').trim());
28
+ });
24
29
  }
25
30
  const transcript = require('./transcript');
26
31
  const plugins = require('./plugin-loader');
@@ -72,15 +77,10 @@ function getInstalledVersion(bin) {
72
77
  }
73
78
 
74
79
  function presetForCommand(cmd) {
75
- if (cmd?.presetId) {
76
- const preset = presets.find(p => p.presetId === cmd.presetId);
77
- if (preset) return preset;
78
- }
79
- const bin = binName(cmd?.command);
80
- return presets.find(p => binName(p.command) === bin);
80
+ return findPresetForCommand(cmd, presets);
81
81
  }
82
82
 
83
- function commandEnv(cmd) {
83
+ function rawCommandEnv(cmd) {
84
84
  return cmd?.env && typeof cmd.env === 'object' && !Array.isArray(cmd.env) ? cmd.env : {};
85
85
  }
86
86
 
@@ -93,10 +93,11 @@ function expandHomePath(value) {
93
93
  }
94
94
 
95
95
  function configRootFor(preset, cmd) {
96
- const env = commandEnv(cmd);
96
+ const env = rawCommandEnv(cmd);
97
97
  if (preset?.presetId === 'claude-code') return expandHomePath(env.CLAUDE_CONFIG_DIR) || join(os.homedir(), '.claude');
98
98
  if (preset?.presetId === 'codex') return expandHomePath(env.CODEX_HOME) || join(os.homedir(), '.codex');
99
99
  if (preset?.presetId === 'gemini-cli') return join(expandHomePath(env.GEMINI_CLI_HOME) || os.homedir(), '.gemini');
100
+ if (preset?.presetId === 'pi') return expandHomePath(env.PI_CODING_AGENT_DIR) || join(os.homedir(), '.pi', 'agent');
100
101
  return os.homedir();
101
102
  }
102
103
 
@@ -177,6 +178,14 @@ function hasExistingHook(arr, hookFile, route) {
177
178
  }));
178
179
  }
179
180
 
181
+ function hasAnyExistingHook(hooks, hookFile) {
182
+ return Object.values(hooks || {}).some(arr => arr?.some(h => h.hooks?.some(x => {
183
+ if (!x.command?.includes(hookFile)) return false;
184
+ const hookPath = extractQuotedPath(x.command, hookFile);
185
+ return !!hookPath && existsSync(hookPath);
186
+ })));
187
+ }
188
+
180
189
  function codexHooksFeatureEnabled(content) {
181
190
  let inFeatures = false;
182
191
  for (const line of String(content || '').split(/\r?\n/)) {
@@ -215,6 +224,23 @@ function opencodeBridgeLooksHealthy() {
215
224
  }
216
225
  }
217
226
 
227
+ function piBridgePath(cmd) {
228
+ return join(configRootFor({ presetId: 'pi' }, cmd), 'extensions', 'clideck-bridge.ts');
229
+ }
230
+
231
+ function piBridgeLooksHealthy(cmd) {
232
+ const bridgePath = piBridgePath(cmd);
233
+ if (!existsSync(bridgePath)) return false;
234
+ try {
235
+ const content = readFileSync(bridgePath, 'utf8');
236
+ return content.includes('/hook/pi')
237
+ && content.includes('CLIDECK_SESSION_ID')
238
+ && content.includes('sessionManager.getSessionId');
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+
218
244
  function detectTelemetryConfig(c) {
219
245
  const port = String(PORT);
220
246
  let changed = false;
@@ -227,15 +253,23 @@ function detectTelemetryConfig(c) {
227
253
  if (!preset) continue;
228
254
  let detected = false;
229
255
  let reason = '';
256
+ let repairAllowed = cmd.telemetrySetupConsent === true;
230
257
  if (preset.presetId === 'claude-code') {
231
258
  try {
232
259
  const s = JSON.parse(readFileSync(join(configRootFor(preset, cmd), 'settings.json'), 'utf8'));
233
260
  const hooks = s.hooks || {};
261
+ repairAllowed = repairAllowed || hasAnyExistingHook(hooks, 'claude-hook.js');
234
262
  detected = hasExistingHook(hooks.UserPromptSubmit, 'claude-hook.js', 'start')
235
263
  && hasExistingHook(hooks.Stop, 'claude-hook.js', 'stop')
236
264
  && hasExistingHook(hooks.StopFailure, 'claude-hook.js', 'stop')
265
+ && hasExistingHook(hooks.SessionStart, 'claude-hook.js', 'session-start')
266
+ && hasExistingHook(hooks.SessionEnd, 'claude-hook.js', 'session-end')
237
267
  && hasExistingHook(hooks.PreToolUse, 'claude-hook.js', 'menu')
238
268
  && hooks.Notification?.some(h => h.matcher === 'idle_prompt' && hasExistingHook([h], 'claude-hook.js', 'idle'));
269
+ if (detected && cmd.telemetrySetupConsent !== true) {
270
+ cmd.telemetrySetupConsent = true;
271
+ changed = true;
272
+ }
239
273
  if (!detected) reason = 'Needs re-patch';
240
274
  } catch {}
241
275
  } else if (preset.presetId === 'codex') {
@@ -258,11 +292,14 @@ function detectTelemetryConfig(c) {
258
292
  } else if (preset.presetId === 'opencode') {
259
293
  detected = opencodeBridgeLooksHealthy();
260
294
  if (!detected) reason = 'Needs re-patch';
295
+ } else if (preset.presetId === 'pi') {
296
+ detected = piBridgeLooksHealthy(cmd);
297
+ if (!detected) reason = 'Needs re-patch';
261
298
  } else { continue; }
262
299
  if (preset.available && preset.minVersion && !preset.versionOk) {
263
300
  detected = false;
264
301
  reason = `Update required (${preset.minVersion}+)`;
265
- } else if (!detected && cmd.telemetryEnabled && preset.telemetryAutoSetup && preset.available && preset.versionOk && !attemptedRepairs.has(cmd.id || preset.presetId)) {
302
+ } else if (!detected && cmd.telemetryEnabled && repairAllowed && preset.telemetryAutoSetup && preset.available && preset.versionOk && !attemptedRepairs.has(cmd.id || preset.presetId)) {
266
303
  attemptedRepairs.add(cmd.id || preset.presetId);
267
304
  const repaired = applyTelemetryConfig(preset, cmd);
268
305
  if (repaired.success) {
@@ -368,6 +405,7 @@ function onConnection(ws) {
368
405
  if (choices) transcript.commitAgentCandidate(msg.id, sess.presetId);
369
406
  if (key !== (sess._menuKey || '')) {
370
407
  sess._menuKey = key;
408
+ sess._menuStartsWork = !(sess.presetId === 'claude-code' && !msg.menuVersion);
371
409
  sessions.broadcast({ type: 'session.menu', id: msg.id, choices: choices || [] });
372
410
  if (choices) {
373
411
  if (sess.presetId === 'claude-code' && msg.menuVersion) sess._menuActiveVersion = msg.menuVersion;
@@ -391,6 +429,7 @@ function onConnection(ws) {
391
429
  checkAvailability();
392
430
  if (detectTelemetryConfig(cfg)) config.save(cfg);
393
431
  ws.send(JSON.stringify({ type: 'presets', presets: clientPresets() }));
432
+ ws.send(JSON.stringify({ type: 'config', config: configForClient() }));
394
433
  break;
395
434
 
396
435
  case 'config.update':
@@ -399,6 +438,7 @@ function onConnection(ws) {
399
438
  cfg = { ...cfg, ...msg.config };
400
439
  detectTelemetryConfig(cfg);
401
440
  config.save(cfg);
441
+ plugins.notifyConfig(cfg);
402
442
  sessions.broadcast({ type: 'config', config: configForClient() });
403
443
  break;
404
444
 
@@ -412,16 +452,28 @@ function onConnection(ws) {
412
452
  const targetCmd = msg.commandId ? cfg.commands.find(c => c.id === msg.commandId) : null;
413
453
  const preset = targetCmd ? presetForCommand(targetCmd) : presets.find(p => p.presetId === msg.presetId);
414
454
  if (!preset?.telemetryAutoSetup) break;
455
+ if (preset.available === false) {
456
+ ws.send(JSON.stringify({
457
+ type: 'telemetry.autosetup.result',
458
+ presetId: preset.presetId,
459
+ commandId: msg.commandId || null,
460
+ success: false,
461
+ output: `${preset.name} is not installed`,
462
+ }));
463
+ break;
464
+ }
415
465
  const result = applyTelemetryConfig(preset, targetCmd);
416
466
  for (const cmd of cfg.commands) {
417
467
  if (targetCmd ? cmd.id === targetCmd.id : presetForCommand(cmd)?.presetId === preset.presetId) {
418
468
  cmd.telemetryEnabled = result.success;
419
469
  cmd.telemetryStatus = result.success ? { ok: true } : { ok: false, error: result.message };
470
+ if (result.success) cmd.telemetrySetupConsent = true;
420
471
  // Enable the agent when setup succeeds, disable if it fails
421
472
  if (result.success) cmd.enabled = true;
422
473
  }
423
474
  }
424
475
  config.save(cfg);
476
+ plugins.notifyConfig(cfg);
425
477
  sessions.broadcast({ type: 'config', config: configForClient() });
426
478
  ws.send(JSON.stringify({
427
479
  type: 'telemetry.autosetup.result',
@@ -448,12 +500,14 @@ function onConnection(ws) {
448
500
  for (const cmd of cfg.commands) {
449
501
  if (targetCmd ? cmd.id === targetCmd.id : presetForCommand(cmd)?.presetId === preset.presetId) {
450
502
  cmd.telemetryEnabled = enable && result.success;
503
+ cmd.telemetrySetupConsent = enable && result.success;
451
504
  cmd.telemetryStatus = enable
452
505
  ? (result.success ? { ok: true } : { ok: false, error: result.message })
453
506
  : null;
454
507
  }
455
508
  }
456
509
  config.save(cfg);
510
+ plugins.notifyConfig(cfg);
457
511
  sessions.broadcast({ type: 'config', config: configForClient() });
458
512
  break;
459
513
  }
@@ -659,6 +713,7 @@ function onConnection(ws) {
659
713
 
660
714
  case 'remote.install': {
661
715
  const update = !!msg.update;
716
+ const restartAfterUpdate = !!msg.restart;
662
717
  const proc = require('child_process').spawn('npm', ['install', '-g', 'clideck-remote'], {
663
718
  shell: true, stdio: ['ignore', 'pipe', 'pipe'],
664
719
  });
@@ -666,8 +721,8 @@ function onConnection(ws) {
666
721
  proc.stderr.on('data', d => ws.send(JSON.stringify({ type: 'remote.install.progress', text: d.toString() })));
667
722
  proc.on('close', code => {
668
723
  remoteUpdateCache = null;
669
- if (code !== 0 || !update) {
670
- ws.send(JSON.stringify({ type: 'remote.install.done', success: code === 0, update }));
724
+ if (code !== 0 || !update || !restartAfterUpdate) {
725
+ ws.send(JSON.stringify({ type: 'remote.install.done', success: code === 0, update, restarted: false }));
671
726
  return;
672
727
  }
673
728
  require('child_process').execFile('clideck-remote', ['restart', '--json'], { timeout: 10000, shell: process.platform === 'win32', env: remoteCliEnv() }, (err, stdout) => {
@@ -707,18 +762,28 @@ function applyTelemetryConfig(preset, cmd = null) {
707
762
  const hookCmd = (route) => `"${process.execPath.replace(/\\/g, '/')}" "${join(__dirname, 'bin', 'claude-hook.js').replace(/\\/g, '/')}" ${port} ${route}`;
708
763
  const clideckHook = (route) => ({ hooks: [{ type: 'command', command: hookCmd(route) }] });
709
764
  const hasClideck = (arr, path) => arr?.some(h => h.hooks?.some(x => x.command === hookCmd(path)));
710
- 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.command === hookCmd('idle')))) {
765
+ if (hasClideck(hooks.UserPromptSubmit, 'start')
766
+ && hasClideck(hooks.Stop, 'stop')
767
+ && hasClideck(hooks.StopFailure, 'stop')
768
+ && hasClideck(hooks.SessionStart, 'session-start')
769
+ && hasClideck(hooks.SessionEnd, 'session-end')
770
+ && hasClideck(hooks.PreToolUse, 'menu')
771
+ && hooks.Notification?.some(h => h.matcher === 'idle_prompt' && h.hooks?.some(x => x.command === hookCmd('idle')))) {
711
772
  return { success: true, message: 'Already configured' };
712
773
  }
713
774
  const stripOld = (arr) => (arr || []).filter(h => !h.hooks?.some(x => x.url?.includes('/hook/claude/') || x.command?.includes('claude-hook.js')));
714
775
  hooks.UserPromptSubmit = stripOld(hooks.UserPromptSubmit);
715
776
  hooks.Stop = stripOld(hooks.Stop);
716
777
  hooks.StopFailure = stripOld(hooks.StopFailure);
778
+ hooks.SessionStart = stripOld(hooks.SessionStart);
779
+ hooks.SessionEnd = stripOld(hooks.SessionEnd);
717
780
  hooks.PreToolUse = stripOld(hooks.PreToolUse);
718
781
  hooks.Notification = stripOld(hooks.Notification);
719
782
  if (!hasClideck(hooks.UserPromptSubmit, 'start')) hooks.UserPromptSubmit = [...(hooks.UserPromptSubmit || []), clideckHook('start')];
720
783
  if (!hasClideck(hooks.Stop, 'stop')) hooks.Stop = [...(hooks.Stop || []), clideckHook('stop')];
721
784
  if (!hasClideck(hooks.StopFailure, 'stop')) hooks.StopFailure = [...(hooks.StopFailure || []), clideckHook('stop')];
785
+ if (!hasClideck(hooks.SessionStart, 'session-start')) hooks.SessionStart = [...(hooks.SessionStart || []), clideckHook('session-start')];
786
+ if (!hasClideck(hooks.SessionEnd, 'session-end')) hooks.SessionEnd = [...(hooks.SessionEnd || []), clideckHook('session-end')];
722
787
  if (!hasClideck(hooks.Notification, 'idle')) hooks.Notification = [...(hooks.Notification || []), { matcher: 'idle_prompt', ...clideckHook('idle') }];
723
788
  if (!hasClideck(hooks.PreToolUse, 'menu')) hooks.PreToolUse = [...(hooks.PreToolUse || []), clideckHook('menu')];
724
789
  settings.hooks = hooks;
@@ -795,6 +860,14 @@ function applyTelemetryConfig(preset, cmd = null) {
795
860
  return { success: true, message: `Installed bridge plugin to ${opencodePluginDir}` };
796
861
  }
797
862
 
863
+ if (preset.presetId === 'pi') {
864
+ const src = join(__dirname, 'pi-extension', 'clideck-bridge.ts');
865
+ const dest = piBridgePath(cmd);
866
+ mkdirSync(dirname(dest), { recursive: true });
867
+ copyFileSync(src, dest);
868
+ return { success: true, message: `Installed Pi extension to ${dest}` };
869
+ }
870
+
798
871
  return { success: false, message: `No auto-setup for ${preset.presetId}` };
799
872
  } catch (err) {
800
873
  return { success: false, message: err.message };
@@ -809,7 +882,7 @@ function removeTelemetryConfig(preset, cmd = null) {
809
882
  let settings = {};
810
883
  try { settings = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
811
884
  if (!settings.hooks) return { success: true, message: 'No hooks to remove' };
812
- for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure', 'Notification', 'PreToolUse']) {
885
+ for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure', 'SessionStart', 'SessionEnd', 'Notification', 'PreToolUse']) {
813
886
  const arr = settings.hooks[event];
814
887
  if (!arr) continue;
815
888
  settings.hooks[event] = arr.filter(h => !h.hooks?.some(x => x.url?.includes('/hook/claude/') || x.command?.includes('claude-hook.js')));
@@ -856,6 +929,11 @@ function removeTelemetryConfig(preset, cmd = null) {
856
929
  return { success: true, message: 'Removed bridge plugin' };
857
930
  }
858
931
 
932
+ if (preset.presetId === 'pi') {
933
+ try { unlinkSync(piBridgePath(cmd)); } catch {}
934
+ return { success: true, message: 'Removed Pi extension' };
935
+ }
936
+
859
937
  return { success: false, message: `No removal logic for ${preset.presetId}` };
860
938
  } catch (err) {
861
939
  return { success: false, message: err.message };
package/http-util.js ADDED
@@ -0,0 +1,20 @@
1
+ function sendJson(res, status, payload) {
2
+ res.writeHead(status, { 'Content-Type': 'application/json' });
3
+ res.end(JSON.stringify(payload));
4
+ }
5
+
6
+ function isLoopback(req) {
7
+ const addr = req.socket?.remoteAddress || '';
8
+ return addr === '::1' || addr === '127.0.0.1' || addr.startsWith('127.') || addr.startsWith('::ffff:127.');
9
+ }
10
+
11
+ function sameProject(a, b) {
12
+ return (a.projectId || null) === (b.projectId || null);
13
+ }
14
+
15
+ function projectName(projects, projectId) {
16
+ if (!projectId) return 'No project';
17
+ return projects.find(p => p.id === projectId)?.name || projectId;
18
+ }
19
+
20
+ module.exports = { sendJson, isLoopback, sameProject, projectName };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clideck",
3
- "version": "1.31.16",
3
+ "version": "1.31.18",
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/pi-bridge.js ADDED
@@ -0,0 +1,51 @@
1
+ // Pi bridge — receives lifecycle events from the CliDeck Pi extension.
2
+
3
+ let broadcastFn = null;
4
+ let sessionsFn = null;
5
+
6
+ function init(broadcast, getSessions) {
7
+ broadcastFn = broadcast;
8
+ sessionsFn = getSessions;
9
+ }
10
+
11
+ function findByToken(token) {
12
+ if (!token) return null;
13
+ for (const [id, session] of sessionsFn?.() || []) {
14
+ if (session.sessionToken === token) return id;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ function capture(id) {
20
+ setTimeout(() => broadcastFn?.({ type: 'terminal.capture', id }), 500);
21
+ }
22
+
23
+ function handleEvent(payload) {
24
+ if (!payload || !payload.event) return;
25
+ const sessions = sessionsFn?.();
26
+ if (!sessions) return;
27
+
28
+ const clideckId = payload.clideck_id && sessions.has(payload.clideck_id)
29
+ ? payload.clideck_id
30
+ : findByToken(payload.session_id);
31
+ if (!clideckId) return;
32
+
33
+ const session = sessions.get(clideckId);
34
+ if (session && payload.session_id && payload.event !== 'session_shutdown') {
35
+ session.sessionToken = payload.session_id;
36
+ }
37
+
38
+ if (payload.event === 'agent_start') {
39
+ broadcastFn?.({ type: 'session.status', id: clideckId, working: true, source: 'hook' });
40
+ return;
41
+ }
42
+
43
+ if (payload.event === 'agent_end' || payload.event === 'session_start' || payload.event === 'session_shutdown') {
44
+ broadcastFn?.({ type: 'session.status', id: clideckId, working: false, source: 'hook' });
45
+ capture(clideckId);
46
+ }
47
+ }
48
+
49
+ function clear() {}
50
+
51
+ module.exports = { init, handleEvent, clear };
@@ -0,0 +1,34 @@
1
+ // CliDeck bridge extension for Pi.
2
+ // Pi auto-loads this file from ~/.pi/agent/extensions/.
3
+
4
+ const env = globalThis.process?.env || {};
5
+ const baseUrl = env.CLIDECK_URL || `http://localhost:${env.CLIDECK_PORT || "4000"}`;
6
+ const endpoint = `${baseUrl.replace(/\/$/, "")}/hook/pi`;
7
+
8
+ function safeCall(fn: (() => string | undefined) | undefined): string | undefined {
9
+ try { return fn?.(); } catch { return undefined; }
10
+ }
11
+
12
+ function post(event: any, ctx: any): void {
13
+ const payload = {
14
+ event: event?.type || "",
15
+ reason: event?.reason || "",
16
+ clideck_id: env.CLIDECK_SESSION_ID || "",
17
+ session_id: safeCall(() => ctx.sessionManager.getSessionId()) || "",
18
+ session_file: safeCall(() => ctx.sessionManager.getSessionFile()) || "",
19
+ cwd: ctx?.cwd || "",
20
+ };
21
+
22
+ fetch(endpoint, {
23
+ method: "POST",
24
+ headers: { "Content-Type": "application/json" },
25
+ body: JSON.stringify(payload),
26
+ }).catch(() => {});
27
+ }
28
+
29
+ export default function (pi: any): void {
30
+ pi.on("session_start", (event: any, ctx: any) => post(event, ctx));
31
+ pi.on("session_shutdown", (event: any, ctx: any) => post(event, ctx));
32
+ pi.on("agent_start", (event: any, ctx: any) => post(event, ctx));
33
+ pi.on("agent_end", (event: any, ctx: any) => post(event, ctx));
34
+ }
@@ -0,0 +1,13 @@
1
+ const { binName } = require('./utils');
2
+
3
+ function presetForCommand(cmd, presets, options = {}) {
4
+ const usePresetId = options.usePresetId !== false;
5
+ if (usePresetId && cmd?.presetId) {
6
+ const preset = presets.find(p => p.presetId === cmd.presetId);
7
+ if (preset) return preset;
8
+ }
9
+ const bin = binName(cmd?.command);
10
+ return presets.find(p => binName(p.command) === bin);
11
+ }
12
+
13
+ module.exports = { presetForCommand };
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Pi">
2
+ <defs>
3
+ <linearGradient id="pi-bg" x1="10" y1="8" x2="56" y2="58" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#2dd4bf"/>
5
+ <stop offset="0.5" stop-color="#60a5fa"/>
6
+ <stop offset="1" stop-color="#a78bfa"/>
7
+ </linearGradient>
8
+ </defs>
9
+ <rect width="64" height="64" rx="18" fill="#0f172a"/>
10
+ <path d="M15 20.5c5.4-2.6 11.5-3.9 18.3-3.9 6.1 0 11.4 1.1 15.9 3.2" fill="none" stroke="url(#pi-bg)" stroke-width="5.5" stroke-linecap="round"/>
11
+ <path d="M24 23.5v22M40 23.5v22" fill="none" stroke="#f8fafc" stroke-width="5.5" stroke-linecap="round"/>
12
+ <path d="M17 45.5h30" fill="none" stroke="url(#pi-bg)" stroke-width="5.5" stroke-linecap="round"/>
13
+ </svg>
package/public/index.html CHANGED
@@ -236,7 +236,10 @@
236
236
  <div class="px-2.5 pb-2.5 flex flex-col gap-2">
237
237
  <div class="relative">
238
238
  <svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
239
- <input id="search-input" type="text" placeholder="Search" class="w-full pl-9 pr-3 py-[7px] text-[13px] bg-slate-800/50 rounded-full text-slate-300 placeholder-slate-600 outline-none border border-transparent focus:border-slate-600/60 focus:bg-slate-800/80 transition-all">
239
+ <input id="search-input" type="text" placeholder="Search" class="w-full pl-9 pr-9 py-[7px] text-[13px] bg-slate-800/50 rounded-full text-slate-300 placeholder-slate-600 outline-none border border-transparent focus:border-slate-600/60 focus:bg-slate-800/80 transition-all">
240
+ <button id="search-clear" class="hidden absolute right-3 top-1/2 -translate-y-1/2 w-6 h-6 rounded-full border border-slate-700/40 bg-slate-800/40 text-slate-500 hover:text-slate-200 hover:bg-slate-700/60 transition-all flex items-center justify-center" title="Clear search" aria-label="Clear search">
241
+ <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
242
+ </button>
240
243
  </div>
241
244
  <div class="flex bg-slate-800/30 rounded-lg p-[3px]">
242
245
  <button class="filter-tab flex-1 text-[11px] font-medium py-[5px] rounded-md transition-all bg-slate-700/60 text-slate-200" data-tab="all">All</button>