clideck 1.26.3 → 1.27.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 +2 -2
- package/agent-presets.json +5 -1
- package/bin/notify-helper.js +18 -0
- package/handlers.js +97 -17
- package/package.json +1 -2
- package/plugin-loader.js +149 -44
- package/plugins/autopilot/clideck-plugin.json +3 -1
- package/plugins/autopilot/package.json +7 -0
- package/plugins/trim-clip/clideck-plugin.json +1 -0
- package/plugins/voice-input/clideck-plugin.json +1 -0
- package/public/index.html +1 -3
- package/public/js/app.js +58 -5
- package/public/js/creator.js +33 -10
- package/public/js/settings.js +33 -43
- package/public/js/terminals.js +13 -9
- package/public/tailwind.css +1 -1
- package/server.js +71 -1
- package/sessions.js +14 -13
- package/telemetry-receiver.js +52 -14
- package/transcript.js +5 -3
package/server.js
CHANGED
|
@@ -88,6 +88,65 @@ const server = http.createServer((req, res) => {
|
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// Codex notify hook endpoint — deterministic turn-complete signal
|
|
92
|
+
if (req.method === 'POST' && req.url === '/hook/codex/stop') {
|
|
93
|
+
let body = '';
|
|
94
|
+
req.on('data', chunk => { body += chunk; if (body.length > 1e5) req.destroy(); });
|
|
95
|
+
req.on('end', () => {
|
|
96
|
+
try {
|
|
97
|
+
const payload = JSON.parse(body);
|
|
98
|
+
const threadId = payload['thread-id'];
|
|
99
|
+
// console.log(`[codex] hook received thread=${threadId ? threadId.slice(0,8) : 'none'}`);
|
|
100
|
+
if (threadId) {
|
|
101
|
+
const allSessions = sessions.getSessions();
|
|
102
|
+
for (const [id, s] of allSessions) {
|
|
103
|
+
if (s.sessionToken === threadId) {
|
|
104
|
+
// console.log(`[codex] hook stop session=${id.slice(0,8)} thread=${threadId.slice(0,8)}`);
|
|
105
|
+
sessions.broadcast({ type: 'session.status', id, working: false, source: 'hook' });
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// if (!matched) console.log(`[codex] hook no session match for thread=${threadId.slice(0,8)}`);
|
|
110
|
+
}
|
|
111
|
+
} catch {}
|
|
112
|
+
res.writeHead(200).end('{}');
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Claude Code hook endpoints — deterministic start/stop/idle signals
|
|
118
|
+
if (req.method === 'POST' && req.url.startsWith('/hook/claude/')) {
|
|
119
|
+
let body = '';
|
|
120
|
+
req.on('data', chunk => { body += chunk; if (body.length > 1e5) req.destroy(); });
|
|
121
|
+
req.on('end', () => {
|
|
122
|
+
try {
|
|
123
|
+
const payload = JSON.parse(body);
|
|
124
|
+
const route = req.url.slice('/hook/claude/'.length);
|
|
125
|
+
const sessionId = payload.session_id;
|
|
126
|
+
// console.log(`[claude] hook ${route} session=${sessionId?.slice(0,8) || '?'}`);
|
|
127
|
+
if (sessionId) {
|
|
128
|
+
const allSessions = sessions.getSessions();
|
|
129
|
+
let clideckId = null;
|
|
130
|
+
for (const [id, s] of allSessions) {
|
|
131
|
+
if (s.sessionToken === sessionId) { clideckId = id; break; }
|
|
132
|
+
}
|
|
133
|
+
if (clideckId) {
|
|
134
|
+
if (route === 'start') {
|
|
135
|
+
sessions.broadcast({ type: 'session.status', id: clideckId, working: true, source: 'hook' });
|
|
136
|
+
} else if (route === 'stop' || route === 'idle') {
|
|
137
|
+
sessions.broadcast({ type: 'session.status', id: clideckId, working: false, source: 'hook' });
|
|
138
|
+
} else if (route === 'menu') {
|
|
139
|
+
// PreToolUse: trigger screen capture — detectMenu will set idle if a choice menu is visible
|
|
140
|
+
setTimeout(() => sessions.broadcast({ type: 'screen.capture', id: clideckId }), 500);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {}
|
|
145
|
+
res.writeHead(200).end('{}');
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
91
150
|
// OpenCode plugin bridge events
|
|
92
151
|
if (req.method === 'POST' && req.url === '/opencode-events') {
|
|
93
152
|
let body = '';
|
|
@@ -125,7 +184,18 @@ const server = http.createServer((req, res) => {
|
|
|
125
184
|
} catch { res.writeHead(500).end(); }
|
|
126
185
|
});
|
|
127
186
|
|
|
128
|
-
const
|
|
187
|
+
const allowedOrigins = new Set([
|
|
188
|
+
`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`,
|
|
189
|
+
`http://[::1]:${PORT}`, `http://${HOST}:${PORT}`,
|
|
190
|
+
]);
|
|
191
|
+
const wss = new WebSocketServer({
|
|
192
|
+
server,
|
|
193
|
+
verifyClient: ({ req }) => {
|
|
194
|
+
const origin = req.headers.origin;
|
|
195
|
+
if (!origin) return true; // non-browser clients (curl, etc.)
|
|
196
|
+
return allowedOrigins.has(origin);
|
|
197
|
+
},
|
|
198
|
+
});
|
|
129
199
|
wss.on('connection', onConnection);
|
|
130
200
|
|
|
131
201
|
const activity = require('./activity');
|
package/sessions.js
CHANGED
|
@@ -277,10 +277,16 @@ function input(msg) {
|
|
|
277
277
|
activity.trackIn(msg.id, data.length);
|
|
278
278
|
transcript.trackInput(msg.id, data);
|
|
279
279
|
sessions.get(msg.id)?.pty.write(data);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
280
|
+
const s = sessions.get(msg.id);
|
|
281
|
+
if (!s) return;
|
|
282
|
+
// Menu choice selected → back to working (Enter or digit keys only)
|
|
283
|
+
if (s._menuKey && !s.working && (data === '\r' || /^[1-9]$/.test(data))) {
|
|
284
|
+
s._menuKey = '';
|
|
285
|
+
broadcast({ type: 'session.menu', id: msg.id, choices: [] });
|
|
286
|
+
broadcast({ type: 'session.status', id: msg.id, working: true, source: 'menu-input' });
|
|
287
|
+
}
|
|
288
|
+
if (data === '\x1b' && s.working) {
|
|
289
|
+
telemetry.startEscIdle(msg.id);
|
|
284
290
|
}
|
|
285
291
|
}
|
|
286
292
|
function resize(msg) { sessions.get(msg.id)?.pty.resize(msg.cols, msg.rows); }
|
|
@@ -315,15 +321,14 @@ function close(msg, cfg) {
|
|
|
315
321
|
// Uses resume command if available, otherwise re-launches the original command.
|
|
316
322
|
function restart(msg, ws, cfg) {
|
|
317
323
|
const id = msg.id;
|
|
318
|
-
console.log('[restart] received', { id, themeId: msg.themeId });
|
|
324
|
+
// console.log('[restart] received', { id, themeId: msg.themeId });
|
|
319
325
|
const s = sessions.get(id);
|
|
320
|
-
if (!s) {
|
|
326
|
+
if (!s) { ws.send(JSON.stringify({ type: 'session.restarted', id, error: 'not found' })); return; }
|
|
321
327
|
const cmd = cfg.commands.find(c => c.id === s.commandId);
|
|
322
|
-
if (!cmd) {
|
|
328
|
+
if (!cmd) { ws.send(JSON.stringify({ type: 'session.restarted', id, error: 'command missing' })); return; }
|
|
323
329
|
|
|
324
330
|
const themeId = msg.themeId || s.themeId;
|
|
325
331
|
const canResume = cmd.canResume && cmd.resumeCommand && s.sessionToken;
|
|
326
|
-
console.log('[restart] canResume=', canResume, 'token=', s.sessionToken?.slice(0,12), 'cmd=', cmd.command);
|
|
327
332
|
|
|
328
333
|
let parts;
|
|
329
334
|
if (canResume) {
|
|
@@ -331,7 +336,6 @@ function restart(msg, ws, cfg) {
|
|
|
331
336
|
} else {
|
|
332
337
|
parts = parseCommand(cmd.command);
|
|
333
338
|
}
|
|
334
|
-
console.log('[restart] parts=', parts);
|
|
335
339
|
|
|
336
340
|
const savedToken = s.sessionToken;
|
|
337
341
|
const { name, cwd, commandId, projectId } = s;
|
|
@@ -341,19 +345,16 @@ function restart(msg, ws, cfg) {
|
|
|
341
345
|
opencodeBridge.clear(id);
|
|
342
346
|
transcript.clear(id);
|
|
343
347
|
|
|
344
|
-
console.log('[restart] killing old pty');
|
|
345
348
|
s.pty.kill();
|
|
346
349
|
sessions.delete(id);
|
|
347
350
|
|
|
348
|
-
console.log('[restart] spawning new pty, themeId=', themeId, 'cwd=', cwd);
|
|
349
351
|
const err = spawnSession(id, cmd, parts, cwd, name, themeId, commandId, savedToken, projectId, msg.cols, msg.rows);
|
|
350
352
|
if (err) {
|
|
351
|
-
console.error('[restart]
|
|
353
|
+
console.error('[restart] spawn failed:', err.message);
|
|
352
354
|
broadcast({ type: 'session.restarted', id, error: err.message });
|
|
353
355
|
return;
|
|
354
356
|
}
|
|
355
357
|
|
|
356
|
-
console.log('[restart] SUCCESS, broadcasting session.restarted');
|
|
357
358
|
broadcast({ type: 'session.restarted', id, resumed: !!canResume });
|
|
358
359
|
}
|
|
359
360
|
|
package/telemetry-receiver.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
const ioActivity = require('./activity');
|
|
6
6
|
const activity = new Map(); // sessionId → has received events
|
|
7
|
+
const lastEvent = new Map(); // sessionId → last OTEL event name (+ kind)
|
|
7
8
|
const pendingSetup = new Map(); // sessionId → timer (waiting for first event)
|
|
8
9
|
const pendingIdle = new Map(); // sessionId → timer (PTY silence → idle)
|
|
10
|
+
const codexMenuPoll = new Map(); // sessionId → interval (polling for menu after response.completed)
|
|
9
11
|
const escPendingIdle = new Map(); // sessionId → timer (Esc interrupt → confirm idle after output silence)
|
|
10
12
|
const escSuppressUntil = new Map(); // sessionId → ts (briefly ignore telemetry reassertions after Esc)
|
|
11
13
|
let broadcastFn = null;
|
|
@@ -74,18 +76,40 @@ function handleLogs(req, res) {
|
|
|
74
76
|
|
|
75
77
|
// Debug telemetry logs — uncomment as needed, do not delete
|
|
76
78
|
// if (serviceName === 'claude-code' && eventName) console.log(`[telemetry:claude] ${eventName}`);
|
|
77
|
-
// if (serviceName === 'codex_cli_rs' && eventName) console.log(`[telemetry:codex] ${eventName} session=${resolvedId.slice(0,8)}
|
|
79
|
+
// if (serviceName === 'codex_cli_rs' && eventName) console.log(`[telemetry:codex] ${eventName} ${attrs['event.kind'] ? 'kind=' + attrs['event.kind'] : ''} ${attrs['tool'] ? 'tool=' + attrs['tool'] : ''} session=${resolvedId.slice(0,8)}`);
|
|
78
80
|
// if (serviceName === 'gemini-cli' && eventName) console.log(`[telemetry:gemini] ${eventName}`);
|
|
79
81
|
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
+
// Track last event per session (used by menu detection validation)
|
|
83
|
+
if (eventName) lastEvent.set(resolvedId, eventName + (attrs['event.kind'] ? ':' + attrs['event.kind'] : ''));
|
|
84
|
+
|
|
85
|
+
// Status: user_prompt → working
|
|
86
|
+
// Claude uses hooks; Codex uses notify hook; Gemini uses PTY heuristic
|
|
87
|
+
const startEvents = new Set(['gemini_cli.user_prompt', 'codex.user_prompt']);
|
|
82
88
|
if (startEvents.has(eventName)) {
|
|
83
89
|
cancelPendingIdle(resolvedId);
|
|
84
90
|
broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
|
|
85
|
-
|
|
91
|
+
// Gemini uses PTY silence heuristic for idle; Codex idle comes from notify hook
|
|
92
|
+
if (serviceName !== 'codex_cli_rs') startPendingIdle(resolvedId, serviceName);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Codex: response.completed → poll for menu until found or timeout
|
|
96
|
+
if (eventName === 'codex.sse_event' && attrs['event.kind'] === 'response.completed') {
|
|
97
|
+
startCodexMenuPoll(resolvedId);
|
|
98
|
+
}
|
|
99
|
+
// Codex: tool_decision → user approved, cancel menu poll, back to working
|
|
100
|
+
if (eventName === 'codex.tool_decision') {
|
|
101
|
+
cancelCodexMenuPoll(resolvedId);
|
|
102
|
+
broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
|
|
103
|
+
}
|
|
104
|
+
// Codex: user_prompt or next sse_event cancels menu poll
|
|
105
|
+
if ((eventName === 'codex.user_prompt' || (eventName === 'codex.sse_event' && attrs['event.kind'] !== 'response.completed'))) {
|
|
106
|
+
cancelCodexMenuPoll(resolvedId);
|
|
86
107
|
}
|
|
87
108
|
|
|
88
|
-
|
|
109
|
+
// Codex: use conversation.id (maps to thread-id in notify hook)
|
|
110
|
+
const agentSessionId = serviceName === 'codex_cli_rs'
|
|
111
|
+
? attrs['conversation.id']
|
|
112
|
+
: (attrs['session.id'] || attrs['conversation.id']);
|
|
89
113
|
if (agentSessionId && sess) {
|
|
90
114
|
// Prefer interactive session ID (Gemini sends non-interactive init events first)
|
|
91
115
|
const dominated = sess.sessionToken && attrs['interactive'] === true;
|
|
@@ -124,16 +148,12 @@ function cancelPendingSetup(sessionId) {
|
|
|
124
148
|
}
|
|
125
149
|
|
|
126
150
|
// PTY activity monitor: 2s silent → idle, 2s active or user_prompt → working.
|
|
151
|
+
// Used by Gemini only — Claude uses hooks, Codex uses sse_event completion.
|
|
127
152
|
// Agent working indicators in PTY output.
|
|
128
|
-
const CLAUDE_WORKING_RE = /[✳✽✢✻·]|Working…|thinking/;
|
|
129
|
-
const CODEX_WORKING_RE = /Working|•/;
|
|
130
153
|
const GEMINI_WORKING_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
|
|
131
154
|
|
|
132
155
|
function startPendingIdle(id, agent) {
|
|
133
156
|
if (pendingIdle.has(id)) return; // already monitoring
|
|
134
|
-
const isClaude = agent === 'claude-code';
|
|
135
|
-
const isCodex = agent === 'codex_cli_rs';
|
|
136
|
-
const isGemini = agent === 'gemini-cli';
|
|
137
157
|
let isIdle = false;
|
|
138
158
|
let activeStart = 0;
|
|
139
159
|
const check = setInterval(() => {
|
|
@@ -145,9 +165,7 @@ function startPendingIdle(id, agent) {
|
|
|
145
165
|
// Agent override: if recent output has spinner/working chars, not silent
|
|
146
166
|
if (silent && (Date.now() - lastOut) < 2000) {
|
|
147
167
|
const chunk = ioActivity.lastChunk(id);
|
|
148
|
-
if (
|
|
149
|
-
if (isCodex && CODEX_WORKING_RE.test(chunk)) silent = false;
|
|
150
|
-
if (isGemini && GEMINI_WORKING_RE.test(chunk)) silent = false;
|
|
168
|
+
if (GEMINI_WORKING_RE.test(chunk)) silent = false;
|
|
151
169
|
}
|
|
152
170
|
if (silent && !isIdle) {
|
|
153
171
|
isIdle = true;
|
|
@@ -171,6 +189,22 @@ function cancelPendingIdle(id) {
|
|
|
171
189
|
if (timer) { clearInterval(timer); pendingIdle.delete(id); }
|
|
172
190
|
}
|
|
173
191
|
|
|
192
|
+
// Codex: after response.completed, poll screen capture every 500ms for up to 3s
|
|
193
|
+
function startCodexMenuPoll(id) {
|
|
194
|
+
cancelCodexMenuPoll(id);
|
|
195
|
+
const started = Date.now();
|
|
196
|
+
const poll = setInterval(() => {
|
|
197
|
+
if (Date.now() - started > 3000) { cancelCodexMenuPoll(id); return; }
|
|
198
|
+
broadcastFn?.({ type: 'screen.capture', id });
|
|
199
|
+
}, 500);
|
|
200
|
+
codexMenuPoll.set(id, poll);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function cancelCodexMenuPoll(id) {
|
|
204
|
+
const timer = codexMenuPoll.get(id);
|
|
205
|
+
if (timer) { clearInterval(timer); codexMenuPoll.delete(id); }
|
|
206
|
+
}
|
|
207
|
+
|
|
174
208
|
function startEscIdle(id) {
|
|
175
209
|
cancelEscIdle(id);
|
|
176
210
|
const started = Date.now();
|
|
@@ -202,16 +236,20 @@ function cancelEscIdle(id) {
|
|
|
202
236
|
|
|
203
237
|
function clear(id) {
|
|
204
238
|
activity.delete(id);
|
|
239
|
+
lastEvent.delete(id);
|
|
205
240
|
cancelPendingIdle(id);
|
|
241
|
+
cancelCodexMenuPoll(id);
|
|
206
242
|
cancelEscIdle(id);
|
|
207
243
|
escSuppressUntil.delete(id);
|
|
208
244
|
const pending = pendingSetup.get(id);
|
|
209
245
|
if (pending) { clearTimeout(pending.timer); pendingSetup.delete(id); }
|
|
210
246
|
}
|
|
211
247
|
|
|
248
|
+
function getLastEvent(id) { return lastEvent.get(id) || ''; }
|
|
249
|
+
|
|
212
250
|
// Returns true if we've received telemetry events for this session
|
|
213
251
|
function hasEvents(id) {
|
|
214
252
|
return activity.has(id);
|
|
215
253
|
}
|
|
216
254
|
|
|
217
|
-
module.exports = { init, handleLogs, clear, hasEvents, watchSession, startPendingIdle, startEscIdle };
|
|
255
|
+
module.exports = { init, handleLogs, clear, hasEvents, getLastEvent, cancelCodexMenuPoll, watchSession, startPendingIdle, startEscIdle };
|
package/transcript.js
CHANGED
|
@@ -321,16 +321,18 @@ const MENU_CHOICE_RE = /^\s*(?:[│❯›●•]\s+)*(\d+)\.\s+(.+)$/;
|
|
|
321
321
|
function detectMenu(lines, presetId) {
|
|
322
322
|
const marker = MENU_MARKERS[presetId];
|
|
323
323
|
if (!marker) return null;
|
|
324
|
+
// Only scan the bottom 40 lines — menus are always near the visible area
|
|
325
|
+
const scanStart = Math.max(0, lines.length - 40);
|
|
324
326
|
let footerIdx = -1;
|
|
325
|
-
for (let i = lines.length - 1; i >=
|
|
327
|
+
for (let i = lines.length - 1; i >= scanStart; i--) {
|
|
326
328
|
if (/\besc\b|\(esc\)/i.test(lines[i])) { footerIdx = MENU_CHOICE_RE.test(lines[i]) ? i + 1 : i; break; }
|
|
327
329
|
}
|
|
328
330
|
if (footerIdx < 0) return null;
|
|
329
331
|
const choices = [];
|
|
330
|
-
for (let i = footerIdx - 1; i >=
|
|
332
|
+
for (let i = footerIdx - 1; i >= scanStart; i--) {
|
|
331
333
|
if (!lines[i].trim() || /^[│\s]+$/.test(lines[i])) continue;
|
|
332
334
|
const m = lines[i].match(MENU_CHOICE_RE);
|
|
333
|
-
if (!m) break;
|
|
335
|
+
if (!m) { if (/^\s{2,}\S/.test(lines[i])) continue; break; }
|
|
334
336
|
if (choices.length && +m[1] >= +choices[0].value) break;
|
|
335
337
|
choices.unshift({ value: m[1], label: m[2].trim(), selected: marker.test(lines[i]) });
|
|
336
338
|
}
|