clideck 1.25.3 → 1.25.5

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.3",
3
+ "version": "1.25.5",
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": {
package/public/js/app.js CHANGED
@@ -90,6 +90,7 @@ function connect() {
90
90
  }
91
91
  break;
92
92
  }
93
+ /* [OLD-STATUS] I/O burst heuristic — replaced by onRender detection in terminals.js
93
94
  case 'stats': {
94
95
  for (const [sid, st] of Object.entries(msg.stats)) {
95
96
  const entry = state.terms.get(sid);
@@ -101,12 +102,9 @@ function connect() {
101
102
  const userTyping = (st.rawRateIn || 0) > 0 && (st.rawRateIn || 0) < 50;
102
103
  entry.prevBurst = st.burstMs || 0;
103
104
 
104
- // Working: burst increasing + net >= 800B + no typing
105
105
  const isWorking = burstUp && net >= 800 && !userTyping;
106
- // Idle: burst not increasing + net < 800B
107
106
  const isIdle = !burstUp && net < 800;
108
107
 
109
- // Sustain for ~1.5s (2 ticks)
110
108
  if (isWorking) entry.workTicks = (entry.workTicks || 0) + 1;
111
109
  else entry.workTicks = 0;
112
110
  if (isIdle) entry.idleTicks = (entry.idleTicks || 0) + 1;
@@ -122,6 +120,7 @@ function connect() {
122
120
  }
123
121
  break;
124
122
  }
123
+ [OLD-STATUS] */
125
124
  case 'transcript.cache':
126
125
  state.transcriptCache = msg.cache;
127
126
  for (const [id, text] of Object.entries(msg.cache)) {
@@ -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,6 +338,30 @@ 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
+ // [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
+ let _screenTimer = null, _renderSilent = false;
344
+ function _tryScreenCapture() {
345
+ const entry = state.terms.get(id);
346
+ if (!entry?.pendingScreenCapture || !_renderSilent || !entry.term) return;
347
+ entry.pendingScreenCapture = false;
348
+ const buf = entry.term.buffer.active;
349
+ const lines = [];
350
+ for (let i = 0; i < buf.length; i++) { const line = buf.getLine(i); if (line) lines.push(line.translateToString(true)); }
351
+ send({ type: 'terminal.buffer', id, lines });
352
+ }
353
+ term.onRender(() => {
354
+ _renderSilent = false;
355
+ clearTimeout(_screenTimer);
356
+ _screenTimer = setTimeout(() => { _renderSilent = true; _tryScreenCapture(); }, 2000);
357
+ });
358
+ let _idleTimer = null, _workTimer = null, _lastTyping = 0;
359
+ term.onData(() => { _lastTyping = Date.now(); });
360
+ term.onWriteParsed(() => { if (Date.now() - _lastTyping < 500) return; const entry = state.terms.get(id); if (entry) entry.lastRenderAt = Date.now(); if (!_workTimer) _workTimer = setTimeout(() => { _workTimer = null; setStatus(id, true); }, 1000); clearTimeout(_idleTimer); _idleTimer = setTimeout(() => { clearTimeout(_workTimer); _workTimer = null; setStatus(id, false); send({ type: 'session.statusReport', id, working: false }); }, 1500); });
361
+
362
+ // Expose capture function so setStatus can trigger it when idle arrives after render silence
363
+ setTimeout(() => { const e = state.terms.get(id); if (e) e.tryScreenCapture = _tryScreenCapture; }, 0);
364
+
341
365
  term.open(el);
342
366
  attachToTerminal(term);
343
367
  let fitted = false, pending = [];
@@ -368,7 +392,7 @@ export function addTerminal(id, name, themeId, commandId, projectId, muted, last
368
392
  // Safety: if RO hasn't fired within 500ms, flush anyway to avoid unbounded queue
369
393
  setTimeout(() => { if (!fitted) { fitted = true; for (const chunk of pending) term.write(chunk); pending = null; updatePreview(id); } }, 500);
370
394
  const cancelFitRaf = () => { if (fitRaf) { cancelAnimationFrame(fitRaf); fitRaf = 0; } };
371
- 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: '' });
395
+ 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: '' });
372
396
  document.getElementById('empty').style.display = 'none';
373
397
  document.getElementById('terminals').style.pointerEvents = '';
374
398
  if (muted) requestAnimationFrame(() => updateMuteIndicator(id));
@@ -514,16 +538,9 @@ function setStatus(id, working) {
514
538
  }
515
539
  }
516
540
 
517
- // Extract terminal buffer on working→idle for clean transcript
518
- if (wasWorking && !working && entry.term) {
519
- const buf = entry.term.buffer.active;
520
- const lines = [];
521
- for (let i = 0; i < buf.length; i++) {
522
- const line = buf.getLine(i);
523
- if (line) lines.push(line.translateToString(true));
524
- }
525
- send({ type: 'terminal.buffer', id, lines });
526
- }
541
+ // Mark idle so the onRender silence watcher can capture .screen
542
+ // Also try immediately renders may already be silent
543
+ if (wasWorking && !working) { entry.pendingScreenCapture = true; entry.tryScreenCapture?.(); }
527
544
 
528
545
  if (working) entry.workStartedAt = Date.now();
529
546
 
@@ -68,6 +68,16 @@ 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 (eventName) console.log(`[telemetry] ${resolvedId?.slice(0,8)} ${eventName}`);
73
+
74
+ // Telemetry: only used for working=true (user prompt). Idle is handled by frontend write-silence.
75
+ const startEvents = new Set(['user_prompt', 'gemini_cli.user_prompt', 'codex.user_prompt']);
76
+
77
+ if (startEvents.has(eventName)) {
78
+ broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
79
+ }
80
+
71
81
  const agentSessionId = attrs['session.id'] || attrs['conversation.id'];
72
82
  if (agentSessionId && sess) {
73
83
  // Prefer interactive session ID (Gemini sends non-interactive init events first)
package/transcript.js CHANGED
@@ -47,12 +47,26 @@ function store(id, role, text) {
47
47
  }
48
48
 
49
49
  function trackInput(id, data) {
50
- if (!inputBuf[id]) inputBuf[id] = { text: '', esc: false };
50
+ if (!inputBuf[id]) inputBuf[id] = { text: '', esc: false, osc: false };
51
51
  const buf = inputBuf[id];
52
52
  for (const ch of data) {
53
+ // OSC sequence: ESC ] ... (terminated by BEL or ESC \)
54
+ if (buf.osc) {
55
+ if (ch === '\x07') { buf.osc = false; continue; } // BEL terminator
56
+ if (ch === '\\' && buf.escPending) { buf.osc = false; buf.escPending = false; continue; } // ESC \ terminator
57
+ buf.escPending = (ch === '\x1b');
58
+ continue;
59
+ }
53
60
  if (ch === '\x1b') { buf.esc = true; continue; }
54
61
  if (buf.esc) {
55
- if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '~') buf.esc = false;
62
+ buf.esc = false;
63
+ if (ch === ']') { buf.osc = true; continue; } // Start OSC
64
+ if (ch === '[') { buf.csi = true; continue; } // Start CSI
65
+ continue; // Simple ESC + char
66
+ }
67
+ // CSI sequence: ESC [ ... (terminated by letter or ~)
68
+ if (buf.csi) {
69
+ if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || ch === '~') buf.csi = false;
56
70
  continue;
57
71
  }
58
72
  if (ch === '\r' || ch === '\n') {
@@ -91,11 +105,17 @@ function flush(id) {
91
105
  // xterm buffer as rendered by the browser. No diffing, no JSONL — just the
92
106
  // clean screen content. Mobile reads this for "last agent message".
93
107
  function storeBuffer(id, lines) {
94
- const isChrome = t => !t || /^[─━═\u2500-\u257f]+$/.test(t) || /^[❯>$%#]\s*$/.test(t) || t === 'esc to interrupt' || t === '? for shortcuts';
108
+ const isChrome = t => !t
109
+ || /^[─━═\u2500-\u257f]+$/.test(t) // box-drawing horizontal lines
110
+ || /^[▀▄█▌▐░▒▓╭╮╰╯│╔╗╚╝║]+$/.test(t) // block elements, box corners, vertical bars
111
+ || (/[█▀▄▌▐░▒▓]/.test(t) && /^[█▀▄▌▐░▒▓\s]+$/.test(t)) // ASCII art (blocks + whitespace, e.g. logos)
112
+ || /^[❯>$%#]\s*$/.test(t) // bare prompt markers
113
+ || /^(esc to interrupt|\? for shortcuts)$/i.test(t); // Claude Code chrome
95
114
  const filtered = lines.filter(l => !isChrome(l.trim()));
96
115
  while (filtered.length && !filtered[filtered.length - 1].trim()) filtered.pop();
97
116
  const screenPath = join(DIR, `${id}.screen`);
98
117
  if (filtered.length) writeFileSync(screenPath, filtered.join('\n'));
118
+ else try { unlinkSync(screenPath); } catch {}
99
119
  }
100
120
 
101
121
  // Read the clean screen snapshot for a session (if available).
@@ -111,7 +131,7 @@ const agentParsers = {
111
131
  const turns = [];
112
132
  let current = null;
113
133
  for (const line of lines) {
114
- const m = line.match(/^(?:[│ ]\s*)?([❯›]|[⏺•])\s(.*)$/);
134
+ const m = line.match(/^(?:[│ ]\s*)?([❯›]|[⏺•●])\s(.*)$/);
115
135
  if (m) {
116
136
  if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
117
137
  current = { role: m[1] === '❯' || m[1] === '›' ? 'user' : 'agent', text: m[2] };
@@ -146,9 +166,20 @@ const agentParsers = {
146
166
  return turns.length >= 2 ? turns : null;
147
167
  },
148
168
  'gemini-cli': (lines) => {
169
+ const geminiChrome = t => {
170
+ const s = t.trim();
171
+ return /^shift\+tab to accept/i.test(s)
172
+ || /^(Type your message|@path\/to\/)/i.test(s)
173
+ || /^(\/\w+ |no sandbox|\/model )/i.test(s)
174
+ || /^[~\/\\].*\(main[*]?\)\s*$/i.test(s)
175
+ || /^(Logged in with|Plan:|Tips for getting started)/i.test(s)
176
+ || /^\d+\.\s+(Ask questions|Be specific|Create GEMINI)/i.test(s)
177
+ || /^ℹ\s/.test(s);
178
+ };
149
179
  const turns = [];
150
180
  let current = null;
151
181
  for (const line of lines) {
182
+ if (geminiChrome(line)) continue;
152
183
  const isUser = line.startsWith(' > ');
153
184
  const isAgent = line.startsWith('✦ ');
154
185
  if (isUser || isAgent) {
@@ -212,7 +243,10 @@ function getScreenTurns(id, agent) {
212
243
  if (!screen) return null;
213
244
  const lines = screen.split('\n');
214
245
  const parser = agentParsers[agent];
215
- return parser ? parser(lines) : anchorParse(id, lines);
246
+ const turns = parser ? parser(lines) : anchorParse(id, lines);
247
+ // Drop trailing user turn — it's the empty prompt or unanswered input
248
+ if (turns?.length && turns[turns.length - 1].role === 'user') turns.pop();
249
+ return turns?.length >= 2 ? turns : null;
216
250
  }
217
251
 
218
252
  function getLastTurns(id, n) {