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 +7 -0
- package/agent-presets.json +7 -9
- package/bin/claude-hook.js +31 -0
- package/bin/gemini-hook.js +31 -0
- package/bin/notify-helper.js +9 -2
- package/codex-config.js +77 -0
- package/config.js +2 -0
- package/handlers.js +184 -58
- package/package.json +1 -1
- package/plugin-loader.js +36 -14
- package/plugins/autopilot/clideck-plugin.json +3 -3
- package/plugins/autopilot/index.js +24 -30
- package/public/index.html +2 -2
- package/public/js/app.js +21 -5
- package/public/js/creator.js +14 -2
- package/public/js/settings.js +8 -6
- package/public/js/terminals.js +123 -24
- package/public/js/toast.js +2 -17
- package/public/js/utils.js +9 -0
- package/public/tailwind.css +1 -1
- package/server.js +87 -23
- package/sessions.js +69 -19
- package/telemetry-receiver.js +83 -80
- package/tools/merge-jsonl-roles.mjs +182 -0
- package/transcript-builder.js +53 -0
- package/transcript-parser.js +135 -0
- package/transcript.js +115 -181
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.
|
package/agent-presets.json
CHANGED
|
@@ -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}}
|
|
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
|
-
"
|
|
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": "
|
|
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();
|
package/bin/notify-helper.js
CHANGED
|
@@ -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
|
|
9
|
-
|
|
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({
|
package/codex-config.js
ADDED
|
@@ -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
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
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
&&
|
|
86
|
-
&& hooks.
|
|
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
|
|
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
|
-
|
|
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 (
|
|
102
|
-
|
|
103
|
-
|
|
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')
|
|
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
|
-
|
|
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
|
|
413
|
-
const clideckHook = (
|
|
414
|
-
const hasClideck = (arr, path) => arr?.some(h => h.hooks?.some(x => x.
|
|
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.
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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 =
|
|
435
|
-
|
|
436
|
-
if (!
|
|
437
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
657
|
+
return { success: true, message: 'Removed CliDeck hooks from ~/.gemini/settings.json' };
|
|
532
658
|
}
|
|
533
659
|
|
|
534
660
|
if (preset.presetId === 'opencode') {
|