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/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 wss = new WebSocketServer({ server });
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
- if (data === '\x1b') {
281
- const s = sessions.get(msg.id);
282
- // console.log(`[esc] session=${msg.id.slice(0,8)} working=${s?.working}`);
283
- if (s?.working) telemetry.startEscIdle(msg.id);
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) { console.log('[restart] FAIL: session not found'); ws.send(JSON.stringify({ type: 'session.restarted', id, error: 'not found' })); return; }
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) { console.log('[restart] FAIL: command not found, commandId=', s.commandId); ws.send(JSON.stringify({ type: 'session.restarted', id, error: 'command missing' })); return; }
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] FAIL spawn:', err.message);
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
 
@@ -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)} working=${sessionsFn?.()?.get(resolvedId)?.working}`);
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
- // Status: user_prompt working + start PTY silence monitor for idle
81
- const startEvents = new Set(['user_prompt', 'gemini_cli.user_prompt', 'codex.user_prompt']);
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
- startPendingIdle(resolvedId, serviceName);
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
- const agentSessionId = attrs['session.id'] || attrs['conversation.id'];
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 (isClaude && CLAUDE_WORKING_RE.test(chunk)) silent = false;
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 >= 0; 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 >= 0; 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
  }