clideck 1.25.4 → 1.25.6

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/handlers.js CHANGED
@@ -130,6 +130,7 @@ function onConnection(ws) {
130
130
  case 'input': sessions.input(msg); break;
131
131
  case 'session.statusReport':
132
132
  if (sessions.getSessions().has(msg.id)) {
133
+ sessions.broadcast({ type: 'session.status', id: msg.id, working: !!msg.working });
133
134
  plugins.notifyStatus(msg.id, !!msg.working);
134
135
  }
135
136
  break;
@@ -306,8 +307,7 @@ function onConnection(ws) {
306
307
  }
307
308
 
308
309
  case 'remote.getHistory': {
309
- const turns = transcript.getScreenTurns(msg.id, sessions.getSessions().get(msg.id)?.presetId)
310
- || transcript.getLastTurns(msg.id, msg.limit || 50);
310
+ const turns = transcript.getScreenTurns(msg.id, sessions.getSessions().get(msg.id)?.presetId);
311
311
  ws.send(JSON.stringify({ type: 'remote.history', id: msg.id, turns: turns || [] }));
312
312
  break;
313
313
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clideck",
3
- "version": "1.25.4",
3
+ "version": "1.25.6",
4
4
  "description": "One screen for all your AI coding agents — run, monitor, and manage multiple CLI agents from a single browser tab",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -318,7 +318,7 @@ export function addTerminal(id, name, themeId, commandId, projectId, muted, last
318
318
  const statusEl = item.querySelector('.session-status');
319
319
  const cmd = state.cfg.commands.find(c => c.id === commandId);
320
320
  const hasBridge = !!cmd?.bridge;
321
- const stopBounce = hasBridge ? null : startBounce(statusEl);
321
+ const stopBounce = null;
322
322
 
323
323
  const el = document.createElement('div');
324
324
  el.className = 'term-wrap';
@@ -338,20 +338,31 @@ export function addTerminal(id, name, themeId, commandId, projectId, muted, last
338
338
  term.loadAddon(fit);
339
339
  term.onData(data => send({ type: 'input', id, data }));
340
340
 
341
- // [RENDER-STATUS] agent working/idle detection via onRender + onWriteParsed
342
- let _lastTyping = 0, _renderWorking = false, _renderTimer = null, _hasRender = false, _hasParsed = false;
343
- term.onData(() => { _lastTyping = Date.now(); });
344
- function _statusTick() {
345
- if (Date.now() - _lastTyping < 500) return;
346
- const cmd = state.cfg.commands.find(c => c.id === commandId);
347
- if (cmd?.bridge) return;
348
- if (_hasRender && _hasParsed && !_renderWorking) {
349
- _renderWorking = true;
350
- send({ type: 'session.statusReport', id, working: true }); setStatus(id, true);
351
- }
341
+ // [SCREEN-CAPTURE] extract terminal buffer when BOTH idle AND render-silent (2s)
342
+ // Decoupled from status: telemetry knows when agent is done, onRender knows when terminal is done
343
+ const _telemetryOnly = cmd?.presetId === 'claude-code' || cmd?.presetId === 'codex' || cmd?.presetId === 'gemini-cli';
344
+ let _screenTimer = null, _renderSilent = false;
345
+ function _tryScreenCapture() {
346
+ const entry = state.terms.get(id);
347
+ if (!entry?.pendingScreenCapture || (!_renderSilent && !_telemetryOnly) || !entry.term) return;
348
+ entry.pendingScreenCapture = false;
349
+ const buf = entry.term.buffer.active;
350
+ const lines = [];
351
+ for (let i = 0; i < buf.length; i++) { const line = buf.getLine(i); if (line) lines.push(line.translateToString(true)); }
352
+ send({ type: 'terminal.buffer', id, lines });
352
353
  }
353
- term.onWriteParsed(() => { _hasParsed = true; _statusTick(); });
354
- term.onRender(() => { _hasRender = true; _statusTick(); clearTimeout(_renderTimer); _renderTimer = setTimeout(() => { _renderWorking = false; _hasRender = false; _hasParsed = false; send({ type: 'session.statusReport', id, working: false }); setStatus(id, false); }, 2000); });
354
+ let _idleTimer = null, _workTimer = null, _lastTyping = 0, _lastRender = 0;
355
+ term.onData(() => { _lastTyping = Date.now(); });
356
+ term.onRender(() => {
357
+ _lastRender = Date.now();
358
+ _renderSilent = false;
359
+ clearTimeout(_screenTimer);
360
+ _screenTimer = setTimeout(() => { _renderSilent = true; _tryScreenCapture(); }, 2000);
361
+ });
362
+ term.onWriteParsed(() => { if (Date.now() - _lastTyping < 500) return; const entry = state.terms.get(id); if (entry) entry.lastRenderAt = Date.now(); if (_telemetryOnly) return; if (!_workTimer) _workTimer = setTimeout(() => { _workTimer = null; if (Date.now() - _lastRender < 500) setStatus(id, true); }, 1500); clearTimeout(_idleTimer); _idleTimer = setTimeout(() => { clearTimeout(_workTimer); _workTimer = null; setStatus(id, false); send({ type: 'session.statusReport', id, working: false }); }, 1500); });
363
+
364
+ // Expose capture function so setStatus can trigger it when idle arrives after render silence
365
+ setTimeout(() => { const e = state.terms.get(id); if (e) e.tryScreenCapture = _tryScreenCapture; }, 0);
355
366
 
356
367
  term.open(el);
357
368
  attachToTerminal(term);
@@ -383,7 +394,7 @@ export function addTerminal(id, name, themeId, commandId, projectId, muted, last
383
394
  // Safety: if RO hasn't fired within 500ms, flush anyway to avoid unbounded queue
384
395
  setTimeout(() => { if (!fitted) { fitted = true; for (const chunk of pending) term.write(chunk); pending = null; updatePreview(id); } }, 500);
385
396
  const cancelFitRaf = () => { if (fitRaf) { cancelAnimationFrame(fitRaf); fitRaf = 0; } };
386
- state.terms.set(id, { term, fit, el, ro, cancelFitRaf, themeId, commandId, projectId: projectId || null, muted: !!muted, working: !hasBridge, workStartedAt: hasBridge ? null : Date.now(), stopBounce, queue: (data) => { if (!fitted) { pending.push(data); return true; } return false; }, lastActivityAt: Date.now(), unread: false, lastPreviewText: lastPreview || '', searchText: '' });
397
+ state.terms.set(id, { term, fit, el, ro, cancelFitRaf, themeId, commandId, projectId: projectId || null, muted: !!muted, working: false, workStartedAt: null, stopBounce, queue: (data) => { if (!fitted) { pending.push(data); return true; } return false; }, lastActivityAt: Date.now(), unread: false, lastPreviewText: lastPreview || '', searchText: '' });
387
398
  document.getElementById('empty').style.display = 'none';
388
399
  document.getElementById('terminals').style.pointerEvents = '';
389
400
  if (muted) requestAnimationFrame(() => updateMuteIndicator(id));
@@ -529,16 +540,9 @@ function setStatus(id, working) {
529
540
  }
530
541
  }
531
542
 
532
- // Extract terminal buffer on working→idle for clean transcript
533
- if (wasWorking && !working && entry.term) {
534
- const buf = entry.term.buffer.active;
535
- const lines = [];
536
- for (let i = 0; i < buf.length; i++) {
537
- const line = buf.getLine(i);
538
- if (line) lines.push(line.translateToString(true));
539
- }
540
- send({ type: 'terminal.buffer', id, lines });
541
- }
543
+ // Mark idle so the onRender silence watcher can capture .screen
544
+ // Also try immediately renders may already be silent
545
+ if (wasWorking && !working) { entry.pendingScreenCapture = true; entry.tryScreenCapture?.(); }
542
546
 
543
547
  if (working) entry.workStartedAt = Date.now();
544
548
 
@@ -32,7 +32,7 @@ function handleLogs(req, res) {
32
32
  const resAttrs = parseAttrs(rl.resource?.attributes);
33
33
  const sessionId = resAttrs['clideck.session_id'];
34
34
 
35
- // DEBUG: log all incoming telemetry with resource attributes
35
+ // service.name values: claude-code, codex_cli_rs, gemini-cli
36
36
  const serviceName = resAttrs['service.name'] || 'unknown';
37
37
  let resolvedId = sessionId;
38
38
 
@@ -68,6 +68,41 @@ function handleLogs(req, res) {
68
68
  for (const lr of sl.logRecords || []) {
69
69
  const attrs = parseAttrs(lr.attributes);
70
70
 
71
+ const eventName = attrs['event.name'];
72
+ // if (serviceName === 'codex_cli_rs' && eventName) console.log(`[telemetry:codex] ${eventName}`);
73
+ // if (serviceName === 'gemini-cli' && eventName) console.log(`[telemetry:gemini] ${eventName}`);
74
+
75
+ // Telemetry-based status
76
+ const startEvents = new Set(['user_prompt', 'gemini_cli.user_prompt', 'codex.user_prompt']);
77
+ if (startEvents.has(eventName)) {
78
+ broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
79
+ }
80
+ // Claude: telemetry-only status. user_prompt/any event → working, api_request → idle.
81
+ if (serviceName === 'claude-code' && eventName) {
82
+ if (eventName === 'api_request') {
83
+ broadcastFn?.({ type: 'session.status', id: resolvedId, working: false, source: 'telemetry' });
84
+ } else if (eventName !== 'user_prompt') {
85
+ broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
86
+ }
87
+ }
88
+ // Codex: telemetry-only status. codex.user_prompt/any event → working, codex.sse_event → idle.
89
+ if (serviceName === 'codex_cli_rs' && eventName) {
90
+ if (eventName === 'codex.sse_event') {
91
+ broadcastFn?.({ type: 'session.status', id: resolvedId, working: false, source: 'telemetry' });
92
+ } else if (eventName !== 'codex.user_prompt') {
93
+ broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
94
+ }
95
+ }
96
+ // Gemini: telemetry-only status. Whitelisted events → working, api_response (role=main) → idle.
97
+ if (serviceName === 'gemini-cli' && eventName) {
98
+ if (eventName === 'gemini_cli.api_response' && attrs['role'] === 'main') {
99
+ broadcastFn?.({ type: 'session.status', id: resolvedId, working: false, source: 'telemetry' });
100
+ } else if (eventName === 'gemini_cli.api_request' || eventName === 'gemini_cli.model_routing'
101
+ || (eventName === 'gemini_cli.api_response' && attrs['role'] !== 'main')) {
102
+ broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
103
+ }
104
+ }
105
+
71
106
  const agentSessionId = attrs['session.id'] || attrs['conversation.id'];
72
107
  if (agentSessionId && sess) {
73
108
  // Prefer interactive session ID (Gemini sends non-interactive init events first)
package/transcript.js CHANGED
@@ -10,6 +10,7 @@ const inputBuf = {};
10
10
  const outputBuf = {};
11
11
  const cache = {};
12
12
  const prefixes = {};
13
+ const userTexts = {}; // sessionId → [text, ...] — user prompts for parser matching
13
14
  let broadcast = null;
14
15
  let notifyPlugin = null;
15
16
 
@@ -47,17 +48,31 @@ function store(id, role, text) {
47
48
  }
48
49
 
49
50
  function trackInput(id, data) {
50
- if (!inputBuf[id]) inputBuf[id] = { text: '', esc: false };
51
+ if (!inputBuf[id]) inputBuf[id] = { text: '', esc: false, osc: false };
51
52
  const buf = inputBuf[id];
52
53
  for (const ch of data) {
54
+ // OSC sequence: ESC ] ... (terminated by BEL or ESC \)
55
+ if (buf.osc) {
56
+ if (ch === '\x07') { buf.osc = false; continue; } // BEL terminator
57
+ if (ch === '\\' && buf.escPending) { buf.osc = false; buf.escPending = false; continue; } // ESC \ terminator
58
+ buf.escPending = (ch === '\x1b');
59
+ continue;
60
+ }
53
61
  if (ch === '\x1b') { buf.esc = true; continue; }
54
62
  if (buf.esc) {
55
- if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '~') buf.esc = false;
63
+ buf.esc = false;
64
+ if (ch === ']') { buf.osc = true; continue; } // Start OSC
65
+ if (ch === '[') { buf.csi = true; continue; } // Start CSI
66
+ continue; // Simple ESC + char
67
+ }
68
+ // CSI sequence: ESC [ ... (terminated by letter or ~)
69
+ if (buf.csi) {
70
+ if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '~') buf.csi = false;
56
71
  continue;
57
72
  }
58
73
  if (ch === '\r' || ch === '\n') {
59
74
  const line = buf.text.trim();
60
- if (line) store(id, 'user', line);
75
+ if (line) { store(id, 'user', line); if (!userTexts[id]) userTexts[id] = []; userTexts[id].push(line); }
61
76
  buf.text = '';
62
77
  } else if (ch === '\x7f' || ch === '\x08') {
63
78
  const chars = Array.from(buf.text);
@@ -113,14 +128,21 @@ function getScreen(id) {
113
128
 
114
129
  // Per-agent screen parsers. Each returns [{role, text}] from .screen content.
115
130
  const agentParsers = {
116
- 'claude-code': (lines) => {
131
+ 'claude-code': (lines, id) => {
132
+ const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
117
133
  const turns = [];
118
134
  let current = null;
119
135
  for (const line of lines) {
120
- const m = line.match(/^(?:[│ ]\s*)?([❯›]|[⏺•●])\s(.*)$/);
121
- if (m) {
136
+ const agent = line.match(/^(?:[│ ]\s*)?[⏺•●]\s(.*)$/);
137
+ if (agent) {
122
138
  if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
123
- current = { role: m[1] === '❯' || m[1] === '›' ? 'user' : 'agent', text: m[2] };
139
+ current = { role: 'agent', text: agent[1] };
140
+ continue;
141
+ }
142
+ const userM = line.match(/^(?:[│ ]\s*)?[❯›]\s(.*)$/);
143
+ if (userM && (known ? known.has(userM[1].trim()) : true)) {
144
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
145
+ current = { role: 'user', text: userM[1] };
124
146
  continue;
125
147
  }
126
148
  if (!current) continue;
@@ -132,14 +154,21 @@ const agentParsers = {
132
154
  if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
133
155
  return turns.length >= 2 ? turns : null;
134
156
  },
135
- 'codex': (lines) => {
157
+ 'codex': (lines, id) => {
158
+ const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
136
159
  const turns = [];
137
160
  let current = null;
138
161
  for (const line of lines) {
139
- const m = line.match(/^(?:│\s*)?([›•])\s(.*)$/);
140
- if (m) {
162
+ const agent = line.match(/^(?:│\s*)?•\s(.*)$/);
163
+ if (agent) {
164
+ if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
165
+ current = { role: 'agent', text: agent[1] };
166
+ continue;
167
+ }
168
+ const userM = line.match(/^(?:│\s*)?›\s(.*)$/);
169
+ if (userM && (known ? known.has(userM[1].trim()) : true)) {
141
170
  if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
142
- current = { role: m[1] === '›' ? 'user' : 'agent', text: m[2] };
171
+ current = { role: 'user', text: userM[1] };
143
172
  continue;
144
173
  }
145
174
  if (!current) continue;
@@ -151,7 +180,8 @@ const agentParsers = {
151
180
  if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
152
181
  return turns.length >= 2 ? turns : null;
153
182
  },
154
- 'gemini-cli': (lines) => {
183
+ 'gemini-cli': (lines, id) => {
184
+ const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
155
185
  const geminiChrome = t => {
156
186
  const s = t.trim();
157
187
  return /^shift\+tab to accept/i.test(s)
@@ -166,11 +196,12 @@ const agentParsers = {
166
196
  let current = null;
167
197
  for (const line of lines) {
168
198
  if (geminiChrome(line)) continue;
169
- const isUser = line.startsWith(' > ');
170
199
  const isAgent = line.startsWith('✦ ');
200
+ const userM = line.startsWith(' > ') ? line.slice(3) : null;
201
+ const isUser = userM && (known ? known.has(userM.trim()) : true);
171
202
  if (isUser || isAgent) {
172
203
  if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
173
- current = { role: isUser ? 'user' : 'agent', text: isUser ? line.slice(3) : line.slice(2) };
204
+ current = { role: isUser ? 'user' : 'agent', text: isUser ? userM : line.slice(2) };
174
205
  continue;
175
206
  }
176
207
  if (!current) continue;
@@ -229,7 +260,7 @@ function getScreenTurns(id, agent) {
229
260
  if (!screen) return null;
230
261
  const lines = screen.split('\n');
231
262
  const parser = agentParsers[agent];
232
- const turns = parser ? parser(lines) : anchorParse(id, lines);
263
+ const turns = parser ? parser(lines, id) : anchorParse(id, lines);
233
264
  // Drop trailing user turn — it's the empty prompt or unanswered input
234
265
  if (turns?.length && turns[turns.length - 1].role === 'user') turns.pop();
235
266
  return turns?.length >= 2 ? turns : null;
@@ -267,6 +298,7 @@ function clear(id) {
267
298
  }
268
299
  delete cache[id];
269
300
  delete prefixes[id];
301
+ delete userTexts[id];
270
302
  try { unlinkSync(join(DIR, `${id}.screen`)); } catch {}
271
303
  }
272
304