clideck 1.27.0 → 1.29.0

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
@@ -72,6 +72,13 @@ clideck auto-detects whether each agent is working or idle:
72
72
 
73
73
  Claude Code works out of the box. Other agents need a one-time setup that clideck walks you through.
74
74
 
75
+ Minimum supported agent versions:
76
+
77
+ - Gemini CLI `v0.36.0+`
78
+ - OpenAI Codex `v0.118.0+`
79
+ - Claude Code `v2.1.90+`
80
+ - OpenCode `v1.2.26+`
81
+
75
82
  ## How It Works
76
83
 
77
84
  Each agent runs in a real terminal (PTY) on your machine. clideck receives lightweight status signals via OpenTelemetry — it knows *that* an agent is working, not *what* it's working on.
@@ -4,6 +4,7 @@
4
4
  "name": "Claude Code",
5
5
  "icon": "/img/claude-code.png",
6
6
  "command": "claude",
7
+ "minVersion": "2.1.90",
7
8
  "installCmd": "npm install -g @anthropic-ai/claude-code",
8
9
  "isAgent": true,
9
10
  "canResume": true,
@@ -28,6 +29,7 @@
28
29
  "name": "Codex",
29
30
  "icon": "/img/codex.png",
30
31
  "command": "codex --no-alt-screen",
32
+ "minVersion": "0.118.0",
31
33
  "installCmd": "npm install -g @openai/codex",
32
34
  "isAgent": true,
33
35
  "canResume": true,
@@ -41,7 +43,7 @@
41
43
  "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:{{port}}",
42
44
  "OTEL_LOGS_EXPORT_INTERVAL": "2000"
43
45
  },
44
- "telemetrySetup": "For best experience, add to ~/.codex/config.toml:\n\n[otel]\nexporter = { otlp-http = { endpoint = \"http://localhost:{{port}}/v1/logs\", protocol = \"json\" } }",
46
+ "telemetrySetup": "For best experience, add to ~/.codex/config.toml:\n\n[otel]\nexporter = { otlp-http = { endpoint = \"http://localhost:{{port}}\", protocol = \"json\" } }",
45
47
  "telemetryAutoSetup": {
46
48
  "label": "Configure automatically"
47
49
  }
@@ -51,6 +53,7 @@
51
53
  "name": "Gemini CLI",
52
54
  "icon": "/img/gemini.png",
53
55
  "command": "gemini",
56
+ "minVersion": "0.36.0",
54
57
  "installCmd": "npm install -g @google/gemini-cli",
55
58
  "isAgent": true,
56
59
  "canResume": true,
@@ -58,15 +61,9 @@
58
61
  "sessionIdPattern": "Session ID:\\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
59
62
  "outputMarker": "\u2726",
60
63
  "telemetryConfigPath": "~/.gemini/settings.json",
61
- "telemetryEnv": {
62
- "GEMINI_TELEMETRY_ENABLED": "true",
63
- "GEMINI_TELEMETRY_TARGET": "local",
64
- "GEMINI_TELEMETRY_OTLP_ENDPOINT": "http://localhost:{{port}}",
65
- "GEMINI_TELEMETRY_OTLP_PROTOCOL": "http"
66
- },
67
- "telemetrySetup": "For best experience, add to ~/.gemini/settings.json:\n\n{\n \"telemetry\": {\n \"enabled\": true,\n \"target\": \"local\",\n \"otlpEndpoint\": \"http://localhost:{{port}}/v1/logs\",\n \"otlpProtocol\": \"http\",\n \"logPrompts\": true\n }\n}",
64
+ "telemetrySetup": "Required for working/idle status, resume, Autopilot, notifications, and mobile remote.\n\nCliDeck will add BeforeAgent/AfterAgent/SessionEnd/BeforeTool hooks to ~/.gemini/settings.json.",
68
65
  "telemetryAutoSetup": {
69
- "label": "Configure automatically"
66
+ "label": "Patch Gemini"
70
67
  }
71
68
  },
72
69
  {
@@ -74,6 +71,7 @@
74
71
  "name": "OpenCode",
75
72
  "icon": "/img/opencode.png",
76
73
  "command": "opencode",
74
+ "minVersion": "1.2.26",
77
75
  "installCmd": "npm install -g opencode-ai",
78
76
  "isAgent": true,
79
77
  "canResume": true,
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ const http = require('http');
4
+
5
+ const port = parseInt(process.argv[2], 10);
6
+ const route = process.argv[3];
7
+ if (!port || !route) process.exit(0);
8
+
9
+ let stdin = '';
10
+ process.stdin.setEncoding('utf8');
11
+ process.stdin.on('data', (chunk) => { stdin += chunk; });
12
+ process.stdin.on('end', () => {
13
+ let hook = {};
14
+ try { hook = stdin ? JSON.parse(stdin) : {}; } catch {}
15
+ const body = JSON.stringify({
16
+ clideck_id: process.env.CLIDECK_SESSION_ID || '',
17
+ session_id: hook.session_id || '',
18
+ payload: stdin.trim() || undefined,
19
+ });
20
+ const req = http.request({
21
+ hostname: 'localhost',
22
+ port,
23
+ path: `/hook/claude/${route}`,
24
+ method: 'POST',
25
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
26
+ timeout: 2000,
27
+ });
28
+ req.on('error', () => {});
29
+ req.end(body);
30
+ });
31
+ process.stdin.resume();
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ const http = require('http');
4
+
5
+ const port = parseInt(process.argv[2], 10);
6
+ const route = process.argv[3];
7
+ if (!port || !route) process.exit(0);
8
+
9
+ let stdin = '';
10
+ process.stdin.setEncoding('utf8');
11
+ process.stdin.on('data', (chunk) => { stdin += chunk; });
12
+ process.stdin.on('end', () => {
13
+ let hook = {};
14
+ try { hook = stdin ? JSON.parse(stdin) : {}; } catch {}
15
+ const body = JSON.stringify({
16
+ clideck_id: process.env.CLIDECK_SESSION_ID || '',
17
+ session_id: hook.session_id || process.env.GEMINI_SESSION_ID || '',
18
+ payload: stdin.trim() || undefined,
19
+ });
20
+ const req = http.request({
21
+ hostname: 'localhost',
22
+ port,
23
+ path: `/hook/gemini/${route}`,
24
+ method: 'POST',
25
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
26
+ timeout: 2000,
27
+ });
28
+ req.on('error', () => {});
29
+ req.end(body);
30
+ });
31
+ process.stdin.resume();
@@ -5,8 +5,15 @@
5
5
  // Port is passed as the first argument by the notify config.
6
6
 
7
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);
8
+ const raw = process.argv[process.argv.length - 1];
9
+ const clideckId = process.env.CLIDECK_SESSION_ID || '';
10
+ if (!port || !raw || raw === String(port)) process.exit(0);
11
+
12
+ let payload = raw;
13
+ try {
14
+ const parsed = JSON.parse(raw);
15
+ payload = JSON.stringify({ ...parsed, clideck_id: clideckId || undefined });
16
+ } catch {}
10
17
 
11
18
  const http = require('http');
12
19
  const req = http.request({
@@ -0,0 +1,77 @@
1
+ function normalizeCodexToml(content) {
2
+ return String(content || '')
3
+ .replace(/\r\n/g, '\n')
4
+ .replace(/([^\n])(\[(?:projects|notice|plugins)\.[^\n]*\])/g, '$1\n$2')
5
+ .replace(/([^\n])(\[(?:features|otel)\])/g, '$1\n$2')
6
+ .replace(/([^\n])(\[\[skills\.[^\n]*\]\])/g, '$1\n$2');
7
+ }
8
+
9
+ function splitTomlSections(content) {
10
+ const lines = normalizeCodexToml(content).split('\n');
11
+ const top = [];
12
+ const sections = [];
13
+ let current = null;
14
+ for (const line of lines) {
15
+ if (/^\s*\[.*\]\s*$/.test(line)) {
16
+ current = { header: line.trim(), lines: [] };
17
+ sections.push(current);
18
+ continue;
19
+ }
20
+ if (current) current.lines.push(line);
21
+ else top.push(line);
22
+ }
23
+ return { top, sections };
24
+ }
25
+
26
+ function trimBlankEdges(lines) {
27
+ const out = [...lines];
28
+ while (out.length && !out[0].trim()) out.shift();
29
+ while (out.length && !out[out.length - 1].trim()) out.pop();
30
+ return out;
31
+ }
32
+
33
+ function upsertCodexConfig(content, nodePath, notifyHelperPath, port) {
34
+ const { top, sections } = splitTomlSections(content);
35
+ const notifyLine = `notify = ["${nodePath}", "${notifyHelperPath}", "${port}"]`;
36
+ const cleanedTop = top.filter(line => !/^\s*notify\s*=/.test(line));
37
+ const topOut = trimBlankEdges([...cleanedTop, notifyLine]);
38
+ sections.forEach(section => {
39
+ section.lines = section.lines.filter(line => !/^\s*notify\s*=/.test(line));
40
+ });
41
+
42
+ const keptSections = sections
43
+ .map(section => section.header === '[features]'
44
+ ? { ...section, lines: trimBlankEdges(section.lines.filter(line => !/^\s*codex_hooks\s*=/.test(line))) }
45
+ : section)
46
+ .filter(section => section.header !== '[features]' || section.lines.length);
47
+
48
+ const otelHeader = '[otel]';
49
+ const otelBody = [`exporter = { otlp-http = { endpoint = "http://localhost:${port}", protocol = "json" } }`];
50
+ const withoutOtel = keptSections.filter(s => s.header !== otelHeader);
51
+ withoutOtel.push({ header: otelHeader, lines: otelBody });
52
+
53
+ const out = [];
54
+ if (topOut.length) out.push(...topOut, '');
55
+ withoutOtel.forEach((section, idx) => {
56
+ out.push(section.header, ...trimBlankEdges(section.lines));
57
+ if (idx < withoutOtel.length - 1) out.push('');
58
+ });
59
+ return out.join('\n').trimEnd() + '\n';
60
+ }
61
+
62
+ function validateCodexConfigToml(content) {
63
+ const lines = normalizeCodexToml(content).split('\n');
64
+ for (let i = 0; i < lines.length; i++) {
65
+ const line = lines[i];
66
+ const t = line.trim();
67
+ if (!t || t.startsWith('#')) continue;
68
+ if (/^\[\[.*\]\]$/.test(t) || /^\[.*\]$/.test(t)) continue;
69
+ if (!t.includes('=')) return { ok: false, error: `Invalid TOML line ${i + 1}: ${t}` };
70
+ if (/(?:^|[^\s=])\[(?:projects|notice|plugins)\.|(?:^|[^\s=])\[(?:features|otel)\]|(?:^|[^\s=])\[\[skills\./.test(t)) {
71
+ return { ok: false, error: `Glued TOML headers on line ${i + 1}` };
72
+ }
73
+ }
74
+ return { ok: true };
75
+ }
76
+
77
+ module.exports = { upsertCodexConfig, validateCodexConfigToml };
package/config.js CHANGED
@@ -80,8 +80,10 @@ const DEFAULTS = {
80
80
  },
81
81
  ],
82
82
  confirmClose: true,
83
+ notifyIdle: true,
83
84
  notifySoundEnabled: true,
84
85
  notifySound: 'soft-beep',
86
+ notifyMinWork: 0,
85
87
  defaultTheme: 'catppuccin-mocha',
86
88
  defaultShell,
87
89
  prompts: [],
package/handlers.js CHANGED
@@ -10,6 +10,7 @@ const { listDirs, binName, defaultShell } = require('./utils');
10
10
  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
+ const { upsertCodexConfig, validateCodexConfigToml } = require('./codex-config');
13
14
 
14
15
  const opencodePluginDir = join(
15
16
  process.platform === 'win32' ? (process.env.APPDATA || join(os.homedir(), 'AppData', 'Roaming')) : join(os.homedir(), '.config'),
@@ -33,6 +34,28 @@ let remoteUpdateCache = null;
33
34
  let remoteUpdateCheckedAt = 0;
34
35
  const REMOTE_UPDATE_INTERVAL = 3600000;
35
36
 
37
+ function compareVersions(a, b) {
38
+ const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0);
39
+ const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
40
+ const len = Math.max(pa.length, pb.length);
41
+ for (let i = 0; i < len; i++) {
42
+ const diff = (pa[i] || 0) - (pb[i] || 0);
43
+ if (diff) return diff;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ function parseVersion(text) {
49
+ const m = String(text || '').match(/\b(\d+\.\d+\.\d+)\b/);
50
+ return m ? m[1] : '';
51
+ }
52
+
53
+ function getInstalledVersion(bin) {
54
+ try { return parseVersion(execFileSync(bin, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); } catch {}
55
+ try { return parseVersion(execFileSync(bin, ['-v'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); } catch {}
56
+ return '';
57
+ }
58
+
36
59
  function checkRemoteUpdate(ws) {
37
60
  const now = Date.now();
38
61
  if (remoteUpdateCache && now - remoteUpdateCheckedAt < REMOTE_UPDATE_INTERVAL) {
@@ -46,7 +69,7 @@ function checkRemoteUpdate(ws) {
46
69
  require('child_process').execFile('npm', ['view', 'clideck-remote', 'version'], { shell: shellOpt, timeout: 10000 }, (err2, stdout2) => {
47
70
  if (err2) return;
48
71
  const latest = stdout2.trim();
49
- remoteUpdateCache = { installed, latest, available: latest !== installed };
72
+ remoteUpdateCache = { installed, latest, available: compareVersions(latest, installed) > 0 };
50
73
  remoteUpdateCheckedAt = now;
51
74
  if (remoteUpdateCache.available) ws.send(JSON.stringify({ type: 'remote.update', ...remoteUpdateCache }));
52
75
  });
@@ -57,9 +80,20 @@ function checkRemoteUpdate(ws) {
57
80
  const whichCmd = process.platform === 'win32' ? 'where' : 'which';
58
81
  function checkAvailability() {
59
82
  for (const p of presets) {
60
- if (p.presetId === 'shell') { p.available = true; continue; }
61
- try { execFileSync(whichCmd, [binName(p.command)], { stdio: 'ignore' }); p.available = true; }
62
- catch { p.available = false; }
83
+ if (p.presetId === 'shell') { p.available = true; p.version = ''; p.versionOk = true; p.health = { ok: true }; continue; }
84
+ const bin = binName(p.command);
85
+ try {
86
+ execFileSync(whichCmd, [bin], { stdio: 'ignore' });
87
+ p.available = true;
88
+ p.version = getInstalledVersion(bin);
89
+ p.versionOk = !p.minVersion || (p.version && compareVersions(p.version, p.minVersion) >= 0);
90
+ p.health = p.versionOk ? { ok: true } : { ok: false, reason: `Update required (${p.minVersion}+)` };
91
+ } catch {
92
+ p.available = false;
93
+ p.version = '';
94
+ p.versionOk = true;
95
+ p.health = { ok: false, reason: 'Not installed' };
96
+ }
63
97
  }
64
98
  }
65
99
  checkAvailability();
@@ -71,39 +105,61 @@ function detectTelemetryConfig(c) {
71
105
  const home = os.homedir();
72
106
  const port = '4000';
73
107
  let changed = false;
108
+ let repairedAny = false;
74
109
  for (const cmd of c.commands || []) {
75
110
  const bin = binName(cmd.command);
76
111
  const preset = presets.find(p => binName(p.command) === bin);
77
112
  if (!preset) continue;
78
113
  let detected = false;
114
+ let reason = '';
79
115
  if (preset.presetId === 'claude-code') {
80
116
  try {
81
117
  const s = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf8'));
82
118
  const hooks = s.hooks || {};
83
- const has = (arr, path) => arr?.some(h => h.hooks?.some(x => x.url?.includes('/hook/claude/' + path)));
119
+ const has = (arr, path) => arr?.some(h => h.hooks?.some(x => x.command?.includes('claude-hook.js') && x.command?.includes(` ${path}`)));
84
120
  detected = has(hooks.UserPromptSubmit, 'start') && has(hooks.Stop, 'stop') && has(hooks.StopFailure, 'stop')
85
121
  && has(hooks.PreToolUse, 'menu')
86
- && hooks.Notification?.some(h => h.matcher === 'idle_prompt' && h.hooks?.some(x => x.url?.includes('/hook/claude/idle')));
122
+ && hooks.Notification?.some(h => h.matcher === 'idle_prompt' && h.hooks?.some(x => x.command?.includes('claude-hook.js') && x.command?.includes(' idle')));
123
+ if (!detected) reason = 'Needs re-patch';
87
124
  } catch {}
88
125
  } else if (preset.presetId === 'codex') {
89
126
  try {
90
127
  const content = readFileSync(join(home, '.codex', 'config.toml'), 'utf8');
91
- detected = content.includes('[otel]') && content.includes(`localhost:${port}`) && content.includes('notify-helper');
128
+ detected = content.includes('[otel]') && /^\s*notify\s*=.*notify-helper/m.test(content) && content.includes(`localhost:${port}`);
129
+ if (!detected) reason = 'Needs re-patch';
92
130
  } catch {}
93
131
  } else if (preset.presetId === 'gemini-cli') {
94
132
  try {
95
133
  const s = JSON.parse(readFileSync(join(home, '.gemini', 'settings.json'), 'utf8'));
96
- detected = !!s.telemetry?.enabled && (s.telemetry?.otlpEndpoint || '').includes(`localhost:${port}`);
134
+ const hooks = s.hooks || {};
135
+ const has = (arr, route) => arr?.some(h => h.hooks?.some(x => x.command?.includes('gemini-hook.js') && x.command?.includes(` ${route}`)));
136
+ detected = has(hooks.BeforeAgent, 'start') && has(hooks.AfterAgent, 'stop') && has(hooks.SessionEnd, 'stop') && has(hooks.BeforeTool, 'menu');
137
+ if (!detected) reason = 'Needs re-patch';
97
138
  } catch {}
98
139
  } else if (preset.presetId === 'opencode') {
99
140
  detected = existsSync(join(opencodePluginDir, 'clideck-bridge.js')) || existsSync(join(opencodePluginDir, 'termix-bridge.js'));
141
+ if (!detected) reason = 'Needs re-patch';
100
142
  } else { continue; }
101
- if (detected !== !!cmd.telemetryEnabled) {
102
- cmd.telemetryEnabled = detected;
103
- cmd.telemetryStatus = detected ? { ok: true } : null;
143
+ if (preset.available && preset.minVersion && !preset.versionOk) {
144
+ detected = false;
145
+ reason = `Update required (${preset.minVersion}+)`;
146
+ } else if (!detected && cmd.telemetryEnabled && preset.telemetryAutoSetup && preset.available && preset.versionOk) {
147
+ const repaired = applyTelemetryConfig(preset);
148
+ if (repaired.success) {
149
+ repairedAny = true;
150
+ continue;
151
+ }
152
+ }
153
+ const nextEnabled = detected || (!!cmd.telemetryEnabled && !reason.startsWith('Update required'));
154
+ const nextStatus = detected ? { ok: true } : { ok: false, error: reason || 'Needs setup' };
155
+ if (cmd.telemetryEnabled !== nextEnabled || JSON.stringify(cmd.telemetryStatus || null) !== JSON.stringify(nextStatus)) {
156
+ cmd.telemetryEnabled = nextEnabled;
157
+ cmd.telemetryStatus = nextStatus;
104
158
  changed = true;
105
159
  }
160
+ preset.health = detected ? { ok: true } : { ok: false, reason: reason || 'Needs setup' };
106
161
  }
162
+ if (repairedAny) return detectTelemetryConfig(c) || true;
107
163
  if (changed) console.log('Config: synced telemetry/plugin state from detected config files');
108
164
  return changed;
109
165
  }
@@ -142,10 +198,16 @@ function onConnection(ws) {
142
198
  }
143
199
  break;
144
200
  case 'terminal.buffer': {
145
- require('./transcript').storeBuffer(msg.id, msg.lines);
146
- sessions.broadcast({ type: 'screen.updated', id: msg.id });
201
+ const transcript = require('./transcript');
147
202
  const sess = sessions.getSessions().get(msg.id);
148
203
  if (sess) {
204
+ if (!sess.working && sess._finalizeOnIdle) {
205
+ sess._finalizeOnIdle = false;
206
+ // if (sess.presetId === 'claude-code') {
207
+ // console.log(`[claude] terminal.buffer finalize session=${msg.id.slice(0,8)} lines=${msg.lines?.length || 0}`);
208
+ // }
209
+ transcript.captureAgentTurn(msg.id, sess.presetId, msg.lines);
210
+ }
149
211
  let choices = require('./transcript').detectMenu(msg.lines, sess.presetId);
150
212
  // Codex: only trust menu detection if last OTEL event was response.completed
151
213
  if (choices && sess.presetId === 'codex') {
@@ -157,15 +219,31 @@ function onConnection(ws) {
157
219
  console.log(`[codex] menu accepted session=${msg.id.slice(0,8)}`);
158
220
  }
159
221
  }
222
+ if (choices && sess.presetId === 'claude-code' && msg.menuVersion && (sess._menuConsumedVersion || 0) >= msg.menuVersion) {
223
+ // console.log(`[claude] menu ignored stale version=${msg.menuVersion} consumed=${sess._menuConsumedVersion || 0} session=${msg.id.slice(0,8)}`);
224
+ choices = null;
225
+ }
226
+ let key = choices ? JSON.stringify(choices) : '';
227
+ // Claude can keep rendering the same approval menu briefly after Enter.
228
+ // Once that exact menu was approved, ignore repeated detections of the
229
+ // same signature until the next real turn starts.
230
+ if (choices && sess.presetId === 'claude-code' && key === (sess._resolvedMenuKey || '')) {
231
+ // console.log(`[claude] menu ignored resolved key session=${msg.id.slice(0,8)}`);
232
+ choices = null;
233
+ key = '';
234
+ }
160
235
  // Auto-approve: send Enter immediately when menu detected
161
236
  if (choices && plugins.shouldAutoApproveMenu(msg.id)) {
162
- sessions.input({ id: msg.id, data: '\r' });
237
+ setTimeout(() => sessions.input({ id: msg.id, data: '\r' }), 500);
163
238
  }
164
- const key = choices ? JSON.stringify(choices) : '';
165
239
  if (key !== (sess._menuKey || '')) {
166
240
  sess._menuKey = key;
167
241
  sessions.broadcast({ type: 'session.menu', id: msg.id, choices: choices || [] });
168
242
  if (choices) {
243
+ if (sess.presetId === 'claude-code' && msg.menuVersion) sess._menuActiveVersion = msg.menuVersion;
244
+ // if (sess.presetId === 'claude-code') {
245
+ // console.log(`[claude] menu detected session=${msg.id.slice(0,8)} choices=${choices.length} version=${msg.menuVersion || 0}`);
246
+ // }
169
247
  plugins.notifyMenu(msg.id, choices);
170
248
  if (sess.presetId === 'codex') require('./telemetry-receiver').cancelCodexMenuPoll(msg.id);
171
249
  sessions.broadcast({ type: 'session.status', id: msg.id, working: false, source: 'menu' });
@@ -184,6 +262,7 @@ function onConnection(ws) {
184
262
 
185
263
  case 'checkAvailability':
186
264
  checkAvailability();
265
+ if (detectTelemetryConfig(cfg)) config.save(cfg);
187
266
  ws.send(JSON.stringify({ type: 'presets', presets }));
188
267
  break;
189
268
 
@@ -275,6 +354,7 @@ function onConnection(ws) {
275
354
  }
276
355
  cfg.projects = cfg.projects.filter(p => p.id !== msg.id);
277
356
  config.save(cfg);
357
+ plugins.notifyConfig(cfg);
278
358
  sessions.broadcast({ type: 'config', config: configForClient() });
279
359
  break;
280
360
  }
@@ -309,6 +389,18 @@ function onConnection(ws) {
309
389
  sessions.broadcast({ type: 'plugins', list: plugins.getInfo() });
310
390
  break;
311
391
 
392
+ case 'plugin.install': {
393
+ ws.send(JSON.stringify({ type: 'plugin.install.progress', pluginId: msg.pluginId }));
394
+ plugins.installPlugin(msg.pluginId, (err) => {
395
+ if (err) {
396
+ ws.send(JSON.stringify({ type: 'plugin.install.result', pluginId: msg.pluginId, success: false, error: err.message }));
397
+ } else {
398
+ sessions.broadcast({ type: 'plugins', list: plugins.getInfo() });
399
+ ws.send(JSON.stringify({ type: 'plugin.install.result', pluginId: msg.pluginId, success: true }));
400
+ }
401
+ });
402
+ break;
403
+ }
312
404
  case 'plugin.delete': {
313
405
  const result = plugins.removePlugin(msg.pluginId);
314
406
  if (result.success) {
@@ -357,8 +449,7 @@ function onConnection(ws) {
357
449
  }
358
450
 
359
451
  case 'remote.getHistory': {
360
- const turns = transcript.getScreenTurns(msg.id, sessions.getSessions().get(msg.id)?.presetId);
361
- ws.send(JSON.stringify({ type: 'remote.history', id: msg.id, turns: turns || [] }));
452
+ ws.send(JSON.stringify({ type: 'remote.history', id: msg.id, turns: transcript.getLastTurns(msg.id, 20) }));
362
453
  break;
363
454
  }
364
455
 
@@ -397,17 +488,23 @@ function applyTelemetryConfig(preset) {
397
488
  try { settings = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
398
489
  }
399
490
  const hooks = settings.hooks || {};
400
- const endpoint = `http://localhost:${port}/hook/claude`;
401
- const clideckHook = (url) => ({ hooks: [{ type: 'http', url }] });
402
- const hasClideck = (arr, path) => arr?.some(h => h.hooks?.some(x => x.url?.includes('/hook/claude/' + path)));
403
- 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')))) {
491
+ const hookCmd = (route) => `"${process.execPath.replace(/\\/g, '/')}" "${join(__dirname, 'bin', 'claude-hook.js').replace(/\\/g, '/')}" ${port} ${route}`;
492
+ const clideckHook = (route) => ({ hooks: [{ type: 'command', command: hookCmd(route) }] });
493
+ const hasClideck = (arr, path) => arr?.some(h => h.hooks?.some(x => x.command === hookCmd(path)));
494
+ 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')))) {
404
495
  return { success: true, message: 'Already configured' };
405
496
  }
406
- if (!hasClideck(hooks.UserPromptSubmit, 'start')) hooks.UserPromptSubmit = [...(hooks.UserPromptSubmit || []), clideckHook(`${endpoint}/start`)];
407
- if (!hasClideck(hooks.Stop, 'stop')) hooks.Stop = [...(hooks.Stop || []), clideckHook(`${endpoint}/stop`)];
408
- if (!hasClideck(hooks.StopFailure, 'stop')) hooks.StopFailure = [...(hooks.StopFailure || []), clideckHook(`${endpoint}/stop`)];
409
- if (!hasClideck(hooks.Notification, 'idle')) hooks.Notification = [...(hooks.Notification || []), { matcher: 'idle_prompt', ...clideckHook(`${endpoint}/idle`) }];
410
- if (!hasClideck(hooks.PreToolUse, 'menu')) hooks.PreToolUse = [...(hooks.PreToolUse || []), clideckHook(`${endpoint}/menu`)];
497
+ const stripOld = (arr) => (arr || []).filter(h => !h.hooks?.some(x => x.url?.includes('/hook/claude/') || x.command?.includes('claude-hook.js')));
498
+ hooks.UserPromptSubmit = stripOld(hooks.UserPromptSubmit);
499
+ hooks.Stop = stripOld(hooks.Stop);
500
+ hooks.StopFailure = stripOld(hooks.StopFailure);
501
+ hooks.PreToolUse = stripOld(hooks.PreToolUse);
502
+ hooks.Notification = stripOld(hooks.Notification);
503
+ if (!hasClideck(hooks.UserPromptSubmit, 'start')) hooks.UserPromptSubmit = [...(hooks.UserPromptSubmit || []), clideckHook('start')];
504
+ if (!hasClideck(hooks.Stop, 'stop')) hooks.Stop = [...(hooks.Stop || []), clideckHook('stop')];
505
+ if (!hasClideck(hooks.StopFailure, 'stop')) hooks.StopFailure = [...(hooks.StopFailure || []), clideckHook('stop')];
506
+ if (!hasClideck(hooks.Notification, 'idle')) hooks.Notification = [...(hooks.Notification || []), { matcher: 'idle_prompt', ...clideckHook('idle') }];
507
+ if (!hasClideck(hooks.PreToolUse, 'menu')) hooks.PreToolUse = [...(hooks.PreToolUse || []), clideckHook('menu')];
411
508
  settings.hooks = hooks;
412
509
  mkdirSync(dirname(configPath), { recursive: true });
413
510
  writeFileSync(configPath, JSON.stringify(settings, null, 2) + '\n');
@@ -416,27 +513,22 @@ function applyTelemetryConfig(preset) {
416
513
 
417
514
  if (preset.presetId === 'codex') {
418
515
  const configPath = join(home, '.codex', 'config.toml');
516
+ const hooksPath = join(home, '.codex', 'hooks.json');
419
517
  let content = '';
420
518
  if (existsSync(configPath)) content = readFileSync(configPath, 'utf8');
421
519
  const hasOtel = content.includes('[otel]');
422
- const hasNotify = content.includes('notify-helper');
423
- if (hasOtel && hasNotify) return { success: true, message: 'Already configured' };
424
- if (!hasNotify) {
425
- const helperPath = join(__dirname, 'bin', 'notify-helper.js').replace(/\\/g, '/');
426
- const notifyLine = `notify = ["${process.execPath.replace(/\\/g, '/')}", "${helperPath}", "${port}"]\n`;
427
- // Insert before the first [section] so it stays top-level
428
- const firstSection = content.search(/^\[/m);
429
- if (firstSection >= 0) {
430
- content = content.slice(0, firstSection) + notifyLine + '\n' + content.slice(firstSection);
431
- } else {
432
- content = content + '\n' + notifyLine;
433
- }
434
- }
435
- if (!hasOtel) {
436
- content = content.trimEnd() + `\n\n[otel]\nexporter = { otlp-http = { endpoint = "http://localhost:${port}/v1/logs", protocol = "json" } }\n`;
520
+ const hasNotify = /^\s*notify\s*=.*notify-helper/m.test(content);
521
+ const hasWrongOtel = content.includes(`endpoint = "http://localhost:${port}/v1/logs"`);
522
+ if (hasOtel && hasNotify && !hasWrongOtel && !existsSync(hooksPath) && !/(^|\n)\[features\][\s\S]*?codex_hooks\s*=/.test(content)) {
523
+ return { success: true, message: 'Already configured' };
437
524
  }
525
+ const notifyHelperPath = join(__dirname, 'bin', 'notify-helper.js').replace(/\\/g, '/');
526
+ const nextContent = upsertCodexConfig(content, process.execPath.replace(/\\/g, '/'), notifyHelperPath, port);
527
+ const valid = validateCodexConfigToml(nextContent);
528
+ if (!valid.ok) return { success: false, message: valid.error };
438
529
  mkdirSync(dirname(configPath), { recursive: true });
439
- writeFileSync(configPath, content);
530
+ writeFileSync(configPath, nextContent);
531
+ if (existsSync(hooksPath)) try { unlinkSync(hooksPath); } catch {}
440
532
  return { success: true, message: 'Added otel + notify to ~/.codex/config.toml' };
441
533
  }
442
534
 
@@ -446,20 +538,26 @@ function applyTelemetryConfig(preset) {
446
538
  if (existsSync(configPath)) {
447
539
  try { settings = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
448
540
  }
449
- if (settings.telemetry?.enabled && settings.telemetry?.otlpEndpoint) {
541
+ const hooks = settings.hooks || {};
542
+ const helperPath = join(__dirname, 'bin', 'gemini-hook.js').replace(/\\/g, '/');
543
+ const nodePath = process.execPath.replace(/\\/g, '/');
544
+ const geminiHook = (route) => ({
545
+ matcher: '*',
546
+ hooks: [{ type: 'command', command: `"${nodePath}" "${helperPath}" ${port} ${route}`, name: `clideck-${route}`, timeout: 5000 }],
547
+ });
548
+ const has = (arr, route) => arr?.some(h => h.hooks?.some(x => x.command?.includes('gemini-hook.js') && x.command?.includes(` ${route}`)));
549
+ if (has(hooks.BeforeAgent, 'start') && has(hooks.AfterAgent, 'stop') && has(hooks.SessionEnd, 'stop') && has(hooks.BeforeTool, 'menu')) {
450
550
  return { success: true, message: 'Already configured' };
451
551
  }
452
- settings.telemetry = {
453
- ...settings.telemetry,
454
- enabled: true,
455
- target: 'local',
456
- otlpEndpoint: `http://localhost:${port}/v1/logs`,
457
- otlpProtocol: 'http',
458
- logPrompts: true,
459
- };
552
+ if (!has(hooks.BeforeAgent, 'start')) hooks.BeforeAgent = [...(hooks.BeforeAgent || []), geminiHook('start')];
553
+ if (!has(hooks.AfterAgent, 'stop')) hooks.AfterAgent = [...(hooks.AfterAgent || []), geminiHook('stop')];
554
+ if (!has(hooks.SessionEnd, 'stop')) hooks.SessionEnd = [...(hooks.SessionEnd || []), geminiHook('stop')];
555
+ if (!has(hooks.BeforeTool, 'menu')) hooks.BeforeTool = [...(hooks.BeforeTool || []), geminiHook('menu')];
556
+ settings.hooks = hooks;
557
+ if (settings.telemetry?.target === 'local' && String(settings.telemetry?.otlpEndpoint || '').includes(`localhost:${port}`)) delete settings.telemetry;
460
558
  mkdirSync(dirname(configPath), { recursive: true });
461
559
  writeFileSync(configPath, JSON.stringify(settings, null, 2) + '\n');
462
- return { success: true, message: 'Added telemetry section to ~/.gemini/settings.json' };
560
+ return { success: true, message: 'Added CliDeck hooks to ~/.gemini/settings.json' };
463
561
  }
464
562
 
465
563
  if (preset.presetId === 'opencode') {
@@ -491,7 +589,7 @@ function removeTelemetryConfig(preset) {
491
589
  for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure', 'Notification', 'PreToolUse']) {
492
590
  const arr = settings.hooks[event];
493
591
  if (!arr) continue;
494
- settings.hooks[event] = arr.filter(h => !h.hooks?.some(x => x.url?.includes('/hook/claude/')));
592
+ settings.hooks[event] = arr.filter(h => !h.hooks?.some(x => x.url?.includes('/hook/claude/') || x.command?.includes('claude-hook.js')));
495
593
  if (!settings.hooks[event].length) delete settings.hooks[event];
496
594
  }
497
595
  if (!Object.keys(settings.hooks).length) delete settings.hooks;
@@ -505,8 +603,11 @@ function removeTelemetryConfig(preset) {
505
603
  let content = readFileSync(configPath, 'utf8');
506
604
  content = content.replace(/\n?\[otel\][^\[]*/, '');
507
605
  content = content.replace(/\n?notify\s*=\s*\[.*?notify-helper.*?\]\s*/g, '');
606
+ content = content.replace(/\n?codex_hooks\s*=\s*(true|false)\s*/g, '\n');
508
607
  writeFileSync(configPath, content.trimEnd() + '\n');
509
- return { success: true, message: 'Removed otel + notify from ~/.codex/config.toml' };
608
+ const hooksPath = join(home, '.codex', 'hooks.json');
609
+ if (existsSync(hooksPath)) try { unlinkSync(hooksPath); } catch {}
610
+ return { success: true, message: 'Removed otel + notify from ~/.codex config' };
510
611
  }
511
612
 
512
613
  if (preset.presetId === 'gemini-cli') {
@@ -514,9 +615,16 @@ function removeTelemetryConfig(preset) {
514
615
  if (!existsSync(configPath)) return { success: true, message: 'No config file to clean' };
515
616
  let settings = {};
516
617
  try { settings = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
517
- delete settings.telemetry;
618
+ for (const event of ['BeforeAgent', 'AfterAgent', 'SessionEnd', 'BeforeTool']) {
619
+ const arr = settings.hooks?.[event];
620
+ if (!arr) continue;
621
+ settings.hooks[event] = arr.filter(h => !h.hooks?.some(x => x.command?.includes('gemini-hook.js')));
622
+ if (!settings.hooks[event].length) delete settings.hooks[event];
623
+ }
624
+ if (settings.hooks && !Object.keys(settings.hooks).length) delete settings.hooks;
625
+ if (settings.telemetry?.target === 'local' && String(settings.telemetry?.otlpEndpoint || '').includes('localhost:4000')) delete settings.telemetry;
518
626
  writeFileSync(configPath, JSON.stringify(settings, null, 2) + '\n');
519
- return { success: true, message: 'Removed telemetry section from ~/.gemini/settings.json' };
627
+ return { success: true, message: 'Removed CliDeck hooks from ~/.gemini/settings.json' };
520
628
  }
521
629
 
522
630
  if (preset.presetId === 'opencode') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clideck",
3
- "version": "1.27.0",
3
+ "version": "1.29.0",
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",