clideck 1.27.1 → 1.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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();
@@ -67,43 +101,95 @@ checkAvailability();
67
101
  let cfg = config.load();
68
102
  if (detectTelemetryConfig(cfg)) config.save(cfg);
69
103
 
104
+ function extractQuotedPath(command, needle) {
105
+ if (!command || !needle) return '';
106
+ const parts = String(command).match(/"([^"]+)"/g) || [];
107
+ for (const part of parts) {
108
+ const value = part.slice(1, -1);
109
+ if (value.includes(needle)) return value;
110
+ }
111
+ return '';
112
+ }
113
+
114
+ function hasExistingHook(arr, hookFile, route) {
115
+ return !!arr?.some(h => h.hooks?.some(x => {
116
+ if (!x.command?.includes(hookFile) || !x.command?.includes(` ${route}`)) return false;
117
+ const hookPath = extractQuotedPath(x.command, hookFile);
118
+ return !!hookPath && existsSync(hookPath);
119
+ }));
120
+ }
121
+
122
+ function codexConfigLooksHealthy(content, port) {
123
+ if (!content.includes('[otel]') || !content.includes(`localhost:${port}`)) return false;
124
+ const notifyLine = content.match(/^\s*notify\s*=\s*\[(.+)\]\s*$/m)?.[1] || '';
125
+ if (!notifyLine.includes('notify-helper')) return false;
126
+ const quoted = [...notifyLine.matchAll(/"([^"]+)"/g)].map(m => m[1]);
127
+ const helperPath = quoted.find(v => v.includes('notify-helper'));
128
+ return !!helperPath && existsSync(helperPath);
129
+ }
130
+
70
131
  function detectTelemetryConfig(c) {
71
132
  const home = os.homedir();
72
133
  const port = '4000';
73
134
  let changed = false;
135
+ let repairedAny = false;
74
136
  for (const cmd of c.commands || []) {
75
137
  const bin = binName(cmd.command);
76
138
  const preset = presets.find(p => binName(p.command) === bin);
77
139
  if (!preset) continue;
78
140
  let detected = false;
141
+ let reason = '';
79
142
  if (preset.presetId === 'claude-code') {
80
143
  try {
81
144
  const s = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf8'));
82
145
  const hooks = s.hooks || {};
83
- const has = (arr, path) => arr?.some(h => h.hooks?.some(x => x.url?.includes('/hook/claude/' + path)));
84
- detected = has(hooks.UserPromptSubmit, 'start') && has(hooks.Stop, 'stop') && has(hooks.StopFailure, 'stop')
85
- && has(hooks.PreToolUse, 'menu')
86
- && hooks.Notification?.some(h => h.matcher === 'idle_prompt' && h.hooks?.some(x => x.url?.includes('/hook/claude/idle')));
146
+ detected = hasExistingHook(hooks.UserPromptSubmit, 'claude-hook.js', 'start')
147
+ && hasExistingHook(hooks.Stop, 'claude-hook.js', 'stop')
148
+ && hasExistingHook(hooks.StopFailure, 'claude-hook.js', 'stop')
149
+ && hasExistingHook(hooks.PreToolUse, 'claude-hook.js', 'menu')
150
+ && hooks.Notification?.some(h => h.matcher === 'idle_prompt' && hasExistingHook([h], 'claude-hook.js', 'idle'));
151
+ if (!detected) reason = 'Needs re-patch';
87
152
  } catch {}
88
153
  } else if (preset.presetId === 'codex') {
89
154
  try {
90
155
  const content = readFileSync(join(home, '.codex', 'config.toml'), 'utf8');
91
- detected = content.includes('[otel]') && content.includes(`localhost:${port}`) && content.includes('notify-helper');
156
+ detected = codexConfigLooksHealthy(content, port);
157
+ if (!detected) reason = 'Needs re-patch';
92
158
  } catch {}
93
159
  } else if (preset.presetId === 'gemini-cli') {
94
160
  try {
95
161
  const s = JSON.parse(readFileSync(join(home, '.gemini', 'settings.json'), 'utf8'));
96
- detected = !!s.telemetry?.enabled && (s.telemetry?.otlpEndpoint || '').includes(`localhost:${port}`);
162
+ const hooks = s.hooks || {};
163
+ detected = hasExistingHook(hooks.BeforeAgent, 'gemini-hook.js', 'start')
164
+ && hasExistingHook(hooks.AfterAgent, 'gemini-hook.js', 'stop')
165
+ && hasExistingHook(hooks.SessionEnd, 'gemini-hook.js', 'stop')
166
+ && hasExistingHook(hooks.BeforeTool, 'gemini-hook.js', 'menu');
167
+ if (!detected) reason = 'Needs re-patch';
97
168
  } catch {}
98
169
  } else if (preset.presetId === 'opencode') {
99
170
  detected = existsSync(join(opencodePluginDir, 'clideck-bridge.js')) || existsSync(join(opencodePluginDir, 'termix-bridge.js'));
171
+ if (!detected) reason = 'Needs re-patch';
100
172
  } else { continue; }
101
- if (detected !== !!cmd.telemetryEnabled) {
102
- cmd.telemetryEnabled = detected;
103
- cmd.telemetryStatus = detected ? { ok: true } : null;
173
+ if (preset.available && preset.minVersion && !preset.versionOk) {
174
+ detected = false;
175
+ reason = `Update required (${preset.minVersion}+)`;
176
+ } else if (!detected && cmd.telemetryEnabled && preset.telemetryAutoSetup && preset.available && preset.versionOk) {
177
+ const repaired = applyTelemetryConfig(preset);
178
+ if (repaired.success) {
179
+ repairedAny = true;
180
+ continue;
181
+ }
182
+ }
183
+ const nextEnabled = detected || (!!cmd.telemetryEnabled && !reason.startsWith('Update required'));
184
+ const nextStatus = detected ? { ok: true } : { ok: false, error: reason || 'Needs setup' };
185
+ if (cmd.telemetryEnabled !== nextEnabled || JSON.stringify(cmd.telemetryStatus || null) !== JSON.stringify(nextStatus)) {
186
+ cmd.telemetryEnabled = nextEnabled;
187
+ cmd.telemetryStatus = nextStatus;
104
188
  changed = true;
105
189
  }
190
+ preset.health = detected ? { ok: true } : { ok: false, reason: reason || 'Needs setup' };
106
191
  }
192
+ if (repairedAny) return detectTelemetryConfig(c) || true;
107
193
  if (changed) console.log('Config: synced telemetry/plugin state from detected config files');
108
194
  return changed;
109
195
  }
@@ -142,10 +228,16 @@ function onConnection(ws) {
142
228
  }
143
229
  break;
144
230
  case 'terminal.buffer': {
145
- require('./transcript').storeBuffer(msg.id, msg.lines);
146
- sessions.broadcast({ type: 'screen.updated', id: msg.id });
231
+ const transcript = require('./transcript');
147
232
  const sess = sessions.getSessions().get(msg.id);
148
233
  if (sess) {
234
+ if (!sess.working && sess._finalizeOnIdle) {
235
+ sess._finalizeOnIdle = false;
236
+ // if (sess.presetId === 'claude-code') {
237
+ // console.log(`[claude] terminal.buffer finalize session=${msg.id.slice(0,8)} lines=${msg.lines?.length || 0}`);
238
+ // }
239
+ transcript.captureAgentTurn(msg.id, sess.presetId, msg.lines);
240
+ }
149
241
  let choices = require('./transcript').detectMenu(msg.lines, sess.presetId);
150
242
  // Codex: only trust menu detection if last OTEL event was response.completed
151
243
  if (choices && sess.presetId === 'codex') {
@@ -157,15 +249,31 @@ function onConnection(ws) {
157
249
  console.log(`[codex] menu accepted session=${msg.id.slice(0,8)}`);
158
250
  }
159
251
  }
252
+ if (choices && sess.presetId === 'claude-code' && msg.menuVersion && (sess._menuConsumedVersion || 0) >= msg.menuVersion) {
253
+ // console.log(`[claude] menu ignored stale version=${msg.menuVersion} consumed=${sess._menuConsumedVersion || 0} session=${msg.id.slice(0,8)}`);
254
+ choices = null;
255
+ }
256
+ let key = choices ? JSON.stringify(choices) : '';
257
+ // Claude can keep rendering the same approval menu briefly after Enter.
258
+ // Once that exact menu was approved, ignore repeated detections of the
259
+ // same signature until the next real turn starts.
260
+ if (choices && sess.presetId === 'claude-code' && key === (sess._resolvedMenuKey || '')) {
261
+ // console.log(`[claude] menu ignored resolved key session=${msg.id.slice(0,8)}`);
262
+ choices = null;
263
+ key = '';
264
+ }
160
265
  // Auto-approve: send Enter immediately when menu detected
161
266
  if (choices && plugins.shouldAutoApproveMenu(msg.id)) {
162
- sessions.input({ id: msg.id, data: '\r' });
267
+ setTimeout(() => sessions.input({ id: msg.id, data: '\r' }), 500);
163
268
  }
164
- const key = choices ? JSON.stringify(choices) : '';
165
269
  if (key !== (sess._menuKey || '')) {
166
270
  sess._menuKey = key;
167
271
  sessions.broadcast({ type: 'session.menu', id: msg.id, choices: choices || [] });
168
272
  if (choices) {
273
+ if (sess.presetId === 'claude-code' && msg.menuVersion) sess._menuActiveVersion = msg.menuVersion;
274
+ // if (sess.presetId === 'claude-code') {
275
+ // console.log(`[claude] menu detected session=${msg.id.slice(0,8)} choices=${choices.length} version=${msg.menuVersion || 0}`);
276
+ // }
169
277
  plugins.notifyMenu(msg.id, choices);
170
278
  if (sess.presetId === 'codex') require('./telemetry-receiver').cancelCodexMenuPoll(msg.id);
171
279
  sessions.broadcast({ type: 'session.status', id: msg.id, working: false, source: 'menu' });
@@ -184,6 +292,7 @@ function onConnection(ws) {
184
292
 
185
293
  case 'checkAvailability':
186
294
  checkAvailability();
295
+ if (detectTelemetryConfig(cfg)) config.save(cfg);
187
296
  ws.send(JSON.stringify({ type: 'presets', presets }));
188
297
  break;
189
298
 
@@ -275,6 +384,7 @@ function onConnection(ws) {
275
384
  }
276
385
  cfg.projects = cfg.projects.filter(p => p.id !== msg.id);
277
386
  config.save(cfg);
387
+ plugins.notifyConfig(cfg);
278
388
  sessions.broadcast({ type: 'config', config: configForClient() });
279
389
  break;
280
390
  }
@@ -369,8 +479,7 @@ function onConnection(ws) {
369
479
  }
370
480
 
371
481
  case 'remote.getHistory': {
372
- const turns = transcript.getScreenTurns(msg.id, sessions.getSessions().get(msg.id)?.presetId);
373
- ws.send(JSON.stringify({ type: 'remote.history', id: msg.id, turns: turns || [] }));
482
+ ws.send(JSON.stringify({ type: 'remote.history', id: msg.id, turns: transcript.getLastTurns(msg.id, 20) }));
374
483
  break;
375
484
  }
376
485
 
@@ -409,17 +518,23 @@ function applyTelemetryConfig(preset) {
409
518
  try { settings = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
410
519
  }
411
520
  const hooks = settings.hooks || {};
412
- const endpoint = `http://localhost:${port}/hook/claude`;
413
- const clideckHook = (url) => ({ hooks: [{ type: 'http', url }] });
414
- const hasClideck = (arr, path) => arr?.some(h => h.hooks?.some(x => x.url?.includes('/hook/claude/' + path)));
415
- if (hasClideck(hooks.UserPromptSubmit, 'start') && hasClideck(hooks.Stop, 'stop') && hasClideck(hooks.StopFailure, 'stop') && hasClideck(hooks.PreToolUse, 'menu') && hooks.Notification?.some(h => h.matcher === 'idle_prompt' && h.hooks?.some(x => x.url?.includes('/hook/claude/idle')))) {
521
+ const hookCmd = (route) => `"${process.execPath.replace(/\\/g, '/')}" "${join(__dirname, 'bin', 'claude-hook.js').replace(/\\/g, '/')}" ${port} ${route}`;
522
+ const clideckHook = (route) => ({ hooks: [{ type: 'command', command: hookCmd(route) }] });
523
+ const hasClideck = (arr, path) => arr?.some(h => h.hooks?.some(x => x.command === hookCmd(path)));
524
+ 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')))) {
416
525
  return { success: true, message: 'Already configured' };
417
526
  }
418
- if (!hasClideck(hooks.UserPromptSubmit, 'start')) hooks.UserPromptSubmit = [...(hooks.UserPromptSubmit || []), clideckHook(`${endpoint}/start`)];
419
- if (!hasClideck(hooks.Stop, 'stop')) hooks.Stop = [...(hooks.Stop || []), clideckHook(`${endpoint}/stop`)];
420
- if (!hasClideck(hooks.StopFailure, 'stop')) hooks.StopFailure = [...(hooks.StopFailure || []), clideckHook(`${endpoint}/stop`)];
421
- if (!hasClideck(hooks.Notification, 'idle')) hooks.Notification = [...(hooks.Notification || []), { matcher: 'idle_prompt', ...clideckHook(`${endpoint}/idle`) }];
422
- if (!hasClideck(hooks.PreToolUse, 'menu')) hooks.PreToolUse = [...(hooks.PreToolUse || []), clideckHook(`${endpoint}/menu`)];
527
+ const stripOld = (arr) => (arr || []).filter(h => !h.hooks?.some(x => x.url?.includes('/hook/claude/') || x.command?.includes('claude-hook.js')));
528
+ hooks.UserPromptSubmit = stripOld(hooks.UserPromptSubmit);
529
+ hooks.Stop = stripOld(hooks.Stop);
530
+ hooks.StopFailure = stripOld(hooks.StopFailure);
531
+ hooks.PreToolUse = stripOld(hooks.PreToolUse);
532
+ hooks.Notification = stripOld(hooks.Notification);
533
+ if (!hasClideck(hooks.UserPromptSubmit, 'start')) hooks.UserPromptSubmit = [...(hooks.UserPromptSubmit || []), clideckHook('start')];
534
+ if (!hasClideck(hooks.Stop, 'stop')) hooks.Stop = [...(hooks.Stop || []), clideckHook('stop')];
535
+ if (!hasClideck(hooks.StopFailure, 'stop')) hooks.StopFailure = [...(hooks.StopFailure || []), clideckHook('stop')];
536
+ if (!hasClideck(hooks.Notification, 'idle')) hooks.Notification = [...(hooks.Notification || []), { matcher: 'idle_prompt', ...clideckHook('idle') }];
537
+ if (!hasClideck(hooks.PreToolUse, 'menu')) hooks.PreToolUse = [...(hooks.PreToolUse || []), clideckHook('menu')];
423
538
  settings.hooks = hooks;
424
539
  mkdirSync(dirname(configPath), { recursive: true });
425
540
  writeFileSync(configPath, JSON.stringify(settings, null, 2) + '\n');
@@ -428,27 +543,22 @@ function applyTelemetryConfig(preset) {
428
543
 
429
544
  if (preset.presetId === 'codex') {
430
545
  const configPath = join(home, '.codex', 'config.toml');
546
+ const hooksPath = join(home, '.codex', 'hooks.json');
431
547
  let content = '';
432
548
  if (existsSync(configPath)) content = readFileSync(configPath, 'utf8');
433
549
  const hasOtel = content.includes('[otel]');
434
- const hasNotify = content.includes('notify-helper');
435
- if (hasOtel && hasNotify) return { success: true, message: 'Already configured' };
436
- if (!hasNotify) {
437
- const helperPath = join(__dirname, 'bin', 'notify-helper.js').replace(/\\/g, '/');
438
- const notifyLine = `notify = ["${process.execPath.replace(/\\/g, '/')}", "${helperPath}", "${port}"]\n`;
439
- // Insert before the first [section] so it stays top-level
440
- const firstSection = content.search(/^\[/m);
441
- if (firstSection >= 0) {
442
- content = content.slice(0, firstSection) + notifyLine + '\n' + content.slice(firstSection);
443
- } else {
444
- content = content + '\n' + notifyLine;
445
- }
446
- }
447
- if (!hasOtel) {
448
- content = content.trimEnd() + `\n\n[otel]\nexporter = { otlp-http = { endpoint = "http://localhost:${port}/v1/logs", protocol = "json" } }\n`;
550
+ const hasNotify = /^\s*notify\s*=.*notify-helper/m.test(content);
551
+ const hasWrongOtel = content.includes(`endpoint = "http://localhost:${port}/v1/logs"`);
552
+ if (hasOtel && hasNotify && !hasWrongOtel && !existsSync(hooksPath) && !/(^|\n)\[features\][\s\S]*?codex_hooks\s*=/.test(content)) {
553
+ return { success: true, message: 'Already configured' };
449
554
  }
555
+ const notifyHelperPath = join(__dirname, 'bin', 'notify-helper.js').replace(/\\/g, '/');
556
+ const nextContent = upsertCodexConfig(content, process.execPath.replace(/\\/g, '/'), notifyHelperPath, port);
557
+ const valid = validateCodexConfigToml(nextContent);
558
+ if (!valid.ok) return { success: false, message: valid.error };
450
559
  mkdirSync(dirname(configPath), { recursive: true });
451
- writeFileSync(configPath, content);
560
+ writeFileSync(configPath, nextContent);
561
+ if (existsSync(hooksPath)) try { unlinkSync(hooksPath); } catch {}
452
562
  return { success: true, message: 'Added otel + notify to ~/.codex/config.toml' };
453
563
  }
454
564
 
@@ -458,20 +568,26 @@ function applyTelemetryConfig(preset) {
458
568
  if (existsSync(configPath)) {
459
569
  try { settings = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
460
570
  }
461
- if (settings.telemetry?.enabled && settings.telemetry?.otlpEndpoint) {
571
+ const hooks = settings.hooks || {};
572
+ const helperPath = join(__dirname, 'bin', 'gemini-hook.js').replace(/\\/g, '/');
573
+ const nodePath = process.execPath.replace(/\\/g, '/');
574
+ const geminiHook = (route) => ({
575
+ matcher: '*',
576
+ hooks: [{ type: 'command', command: `"${nodePath}" "${helperPath}" ${port} ${route}`, name: `clideck-${route}`, timeout: 5000 }],
577
+ });
578
+ const has = (arr, route) => arr?.some(h => h.hooks?.some(x => x.command?.includes('gemini-hook.js') && x.command?.includes(` ${route}`)));
579
+ if (has(hooks.BeforeAgent, 'start') && has(hooks.AfterAgent, 'stop') && has(hooks.SessionEnd, 'stop') && has(hooks.BeforeTool, 'menu')) {
462
580
  return { success: true, message: 'Already configured' };
463
581
  }
464
- settings.telemetry = {
465
- ...settings.telemetry,
466
- enabled: true,
467
- target: 'local',
468
- otlpEndpoint: `http://localhost:${port}/v1/logs`,
469
- otlpProtocol: 'http',
470
- logPrompts: true,
471
- };
582
+ if (!has(hooks.BeforeAgent, 'start')) hooks.BeforeAgent = [...(hooks.BeforeAgent || []), geminiHook('start')];
583
+ if (!has(hooks.AfterAgent, 'stop')) hooks.AfterAgent = [...(hooks.AfterAgent || []), geminiHook('stop')];
584
+ if (!has(hooks.SessionEnd, 'stop')) hooks.SessionEnd = [...(hooks.SessionEnd || []), geminiHook('stop')];
585
+ if (!has(hooks.BeforeTool, 'menu')) hooks.BeforeTool = [...(hooks.BeforeTool || []), geminiHook('menu')];
586
+ settings.hooks = hooks;
587
+ if (settings.telemetry?.target === 'local' && String(settings.telemetry?.otlpEndpoint || '').includes(`localhost:${port}`)) delete settings.telemetry;
472
588
  mkdirSync(dirname(configPath), { recursive: true });
473
589
  writeFileSync(configPath, JSON.stringify(settings, null, 2) + '\n');
474
- return { success: true, message: 'Added telemetry section to ~/.gemini/settings.json' };
590
+ return { success: true, message: 'Added CliDeck hooks to ~/.gemini/settings.json' };
475
591
  }
476
592
 
477
593
  if (preset.presetId === 'opencode') {
@@ -503,7 +619,7 @@ function removeTelemetryConfig(preset) {
503
619
  for (const event of ['UserPromptSubmit', 'Stop', 'StopFailure', 'Notification', 'PreToolUse']) {
504
620
  const arr = settings.hooks[event];
505
621
  if (!arr) continue;
506
- settings.hooks[event] = arr.filter(h => !h.hooks?.some(x => x.url?.includes('/hook/claude/')));
622
+ settings.hooks[event] = arr.filter(h => !h.hooks?.some(x => x.url?.includes('/hook/claude/') || x.command?.includes('claude-hook.js')));
507
623
  if (!settings.hooks[event].length) delete settings.hooks[event];
508
624
  }
509
625
  if (!Object.keys(settings.hooks).length) delete settings.hooks;
@@ -517,8 +633,11 @@ function removeTelemetryConfig(preset) {
517
633
  let content = readFileSync(configPath, 'utf8');
518
634
  content = content.replace(/\n?\[otel\][^\[]*/, '');
519
635
  content = content.replace(/\n?notify\s*=\s*\[.*?notify-helper.*?\]\s*/g, '');
636
+ content = content.replace(/\n?codex_hooks\s*=\s*(true|false)\s*/g, '\n');
520
637
  writeFileSync(configPath, content.trimEnd() + '\n');
521
- return { success: true, message: 'Removed otel + notify from ~/.codex/config.toml' };
638
+ const hooksPath = join(home, '.codex', 'hooks.json');
639
+ if (existsSync(hooksPath)) try { unlinkSync(hooksPath); } catch {}
640
+ return { success: true, message: 'Removed otel + notify from ~/.codex config' };
522
641
  }
523
642
 
524
643
  if (preset.presetId === 'gemini-cli') {
@@ -526,9 +645,16 @@ function removeTelemetryConfig(preset) {
526
645
  if (!existsSync(configPath)) return { success: true, message: 'No config file to clean' };
527
646
  let settings = {};
528
647
  try { settings = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
529
- delete settings.telemetry;
648
+ for (const event of ['BeforeAgent', 'AfterAgent', 'SessionEnd', 'BeforeTool']) {
649
+ const arr = settings.hooks?.[event];
650
+ if (!arr) continue;
651
+ settings.hooks[event] = arr.filter(h => !h.hooks?.some(x => x.command?.includes('gemini-hook.js')));
652
+ if (!settings.hooks[event].length) delete settings.hooks[event];
653
+ }
654
+ if (settings.hooks && !Object.keys(settings.hooks).length) delete settings.hooks;
655
+ if (settings.telemetry?.target === 'local' && String(settings.telemetry?.otlpEndpoint || '').includes('localhost:4000')) delete settings.telemetry;
530
656
  writeFileSync(configPath, JSON.stringify(settings, null, 2) + '\n');
531
- return { success: true, message: 'Removed telemetry section from ~/.gemini/settings.json' };
657
+ return { success: true, message: 'Removed CliDeck hooks from ~/.gemini/settings.json' };
532
658
  }
533
659
 
534
660
  if (preset.presetId === 'opencode') {