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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clideck",
3
- "version": "1.27.1",
3
+ "version": "1.29.1",
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/plugin-loader.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync } = require('fs');
2
+ const { createHash } = require('crypto');
2
3
  const { join, sep } = require('path');
3
4
  const { execFile: _execFile } = require('child_process');
4
5
  // Windows needs shell:true for npm (it's npm.cmd, not a binary)
@@ -11,7 +12,7 @@ mkdirSync(PLUGINS_DIR, { recursive: true });
11
12
 
12
13
  // Seed bundled plugins — copy if missing, update if bundled version is newer
13
14
  const BUNDLED_DIR = join(__dirname, 'plugins');
14
- const updatedBundled = new Set(); // plugins updated during seedinstall state must be cleared
15
+ const depsChanged = new Set(); // plugins whose install inputs changed need reinstall
15
16
  if (existsSync(BUNDLED_DIR)) {
16
17
  for (const entry of readdirSync(BUNDLED_DIR, { withFileTypes: true })) {
17
18
  if (!entry.isDirectory()) continue;
@@ -25,8 +26,20 @@ if (existsSync(BUNDLED_DIR)) {
25
26
  const installedManifestFile = existsSync(join(target, 'clideck-plugin.json')) ? join(target, 'clideck-plugin.json') : join(target, 'termix-plugin.json');
26
27
  const installedManifest = JSON.parse(readFileSync(installedManifestFile, 'utf8'));
27
28
  if (bundledManifest.version !== installedManifest.version) {
29
+ // Check if install inputs changed before copying
30
+ let needsReinstall = false;
31
+ if (bundledManifest.install) {
32
+ const installHash = (dir) => {
33
+ const h = createHash('sha256');
34
+ for (const f of ['package.json', 'package-lock.json']) {
35
+ try { h.update(readFileSync(join(dir, f))); } catch {}
36
+ }
37
+ return h.digest('hex');
38
+ };
39
+ needsReinstall = installHash(target) !== installHash(join(BUNDLED_DIR, entry.name));
40
+ }
28
41
  cpSync(join(BUNDLED_DIR, entry.name), target, { recursive: true });
29
- if (bundledManifest.install) updatedBundled.add(bundledManifest.id || entry.name);
42
+ if (needsReinstall) depsChanged.add(bundledManifest.id || entry.name);
30
43
  console.log(`[plugin] updated ${entry.name} ${installedManifest.version} → ${bundledManifest.version}`);
31
44
  }
32
45
  } catch {}
@@ -41,6 +54,7 @@ const outputHooks = [];
41
54
  const statusHooks = [];
42
55
  const transcriptHooks = [];
43
56
  const menuHooks = [];
57
+ const configHooks = [];
44
58
  const sessionStatus = new Map(); // sessionId → boolean (dedup multi-client reports)
45
59
  const autoApproveMenus = new Set(); // sessionIds where menus should be auto-approved
46
60
  const frontendHandlers = new Map();
@@ -55,7 +69,7 @@ const settingsChangeHandlers = new Map(); // pluginId → [fn]
55
69
  const sessionPills = new Map(); // pillId → { pluginId, id, title, projectId, working, statusText, icon, logs[] }
56
70
 
57
71
  function removeHooks(pluginId) {
58
- for (const arr of [inputHooks, outputHooks, statusHooks, transcriptHooks, menuHooks]) {
72
+ for (const arr of [inputHooks, outputHooks, statusHooks, transcriptHooks, menuHooks, configHooks]) {
59
73
  for (let i = arr.length - 1; i >= 0; i--) {
60
74
  if (arr[i].pluginId === pluginId) arr.splice(i, 1);
61
75
  }
@@ -132,14 +146,14 @@ function init(broadcast, getSessions, getConfig, saveConfig, sessionInput, creat
132
146
  createSessionFn = createProgrammatic;
133
147
  closeSessionFn = closeSession;
134
148
 
135
- // Clear install state for bundled plugins that were updated during seed
136
- if (updatedBundled.size) {
149
+ // Clear install state only for bundled plugins whose dependencies changed
150
+ if (depsChanged.size) {
137
151
  const cfg = getConfig();
138
152
  if (cfg?.pluginInstalled) {
139
- for (const id of updatedBundled) {
153
+ for (const id of depsChanged) {
140
154
  if (cfg.pluginInstalled[id]) {
141
155
  delete cfg.pluginInstalled[id];
142
- console.log(`[plugin] cleared install state for updated ${id}`);
156
+ console.log(`[plugin] install inputs changed, cleared install state for ${id}`);
143
157
  }
144
158
  }
145
159
  saveConfig(cfg);
@@ -180,6 +194,7 @@ function buildApi(pluginId, pluginDir, state) {
180
194
  onStatusChange(fn) { statusHooks.push({ pluginId, fn }); },
181
195
  onTranscriptEntry(fn) { transcriptHooks.push({ pluginId, fn }); },
182
196
  onMenuDetected(fn) { menuHooks.push({ pluginId, fn }); },
197
+ onConfigChange(fn) { configHooks.push({ pluginId, fn }); },
183
198
 
184
199
  sendToFrontend(event, data = {}) {
185
200
  broadcastFn?.({ ...data, type: `plugin.${pluginId}.${event}` });
@@ -191,13 +206,14 @@ function buildApi(pluginId, pluginDir, state) {
191
206
  getSession(id) {
192
207
  const s = sessionsFn?.()?.get(id);
193
208
  if (!s) return null;
194
- return { id, name: s.name, cwd: s.cwd, commandId: s.commandId, presetId: s.presetId || 'shell', themeId: s.themeId, projectId: s.projectId, roleName: s.roleName || null, working: !!sessionStatus.get(id) };
209
+ const state = sessionStatus.get(id) || '';
210
+ return { id, name: s.name, cwd: s.cwd, commandId: s.commandId, presetId: s.presetId || 'shell', themeId: s.themeId, projectId: s.projectId, roleName: s.roleName || null, working: state.startsWith('1:') };
195
211
  },
196
212
  getSessions() {
197
213
  const sessions = sessionsFn?.();
198
214
  if (!sessions) return [];
199
215
  return [...sessions].map(([id, s]) => ({
200
- id, name: s.name, cwd: s.cwd, commandId: s.commandId, presetId: s.presetId || 'shell', themeId: s.themeId, projectId: s.projectId, roleName: s.roleName || null, working: !!sessionStatus.get(id),
216
+ id, name: s.name, cwd: s.cwd, commandId: s.commandId, presetId: s.presetId || 'shell', themeId: s.themeId, projectId: s.projectId, roleName: s.roleName || null, working: (sessionStatus.get(id) || '').startsWith('1:'),
201
217
  }));
202
218
  },
203
219
 
@@ -219,8 +235,6 @@ function buildApi(pluginId, pluginDir, state) {
219
235
  getRoles() { return JSON.parse(JSON.stringify(getConfigFn?.()?.roles || [])); },
220
236
  getProjects() { return JSON.parse(JSON.stringify(getConfigFn?.()?.projects || [])); },
221
237
  getTranscript(id, n) { return transcript.getLastTurns(id, n || 20); },
222
- getScreenTurns(id, agent, opts) { return transcript.getScreenTurns(id, agent, opts); },
223
- getScreen(id) { return transcript.getScreen(id); },
224
238
  detectMenu(lines, presetId) { return transcript.detectMenu(lines, presetId); },
225
239
 
226
240
  addToolbarAction(opts) { state.actions.push({ ...opts, pluginId, slot: 'toolbar' }); },
@@ -321,8 +335,9 @@ function notifyOutput(id, data) {
321
335
  }
322
336
 
323
337
  function notifyStatus(id, working, source) {
324
- if (sessionStatus.get(id) === working) return;
325
- sessionStatus.set(id, working);
338
+ const next = `${working ? 1 : 0}:${source || ''}`;
339
+ if (sessionStatus.get(id) === next) return;
340
+ sessionStatus.set(id, next);
326
341
  for (const h of statusHooks) {
327
342
  try { h.fn(id, working, source); }
328
343
  catch (e) { console.error(`[plugin:${h.pluginId}] status error: ${e.message}`); }
@@ -343,6 +358,13 @@ function notifyMenu(id, choices) {
343
358
  }
344
359
  }
345
360
 
361
+ function notifyConfig(cfg) {
362
+ for (const h of configHooks) {
363
+ try { h.fn(cfg); }
364
+ catch (e) { console.error(`[plugin:${h.pluginId}] config error: ${e.message}`); }
365
+ }
366
+ }
367
+
346
368
 
347
369
  function updateSetting(pluginId, key, value) {
348
370
  // Validate plugin exists (also prevents __proto__ pollution — Map lookup returns undefined)
@@ -539,7 +561,7 @@ function removePlugin(pluginId) {
539
561
  module.exports = {
540
562
  PLUGINS_DIR, BUNDLED_IDS,
541
563
  init, shutdown,
542
- transformInput, notifyOutput, notifyStatus, notifyTranscript, notifyMenu, clearStatus, isWorking, shouldAutoApproveMenu,
564
+ transformInput, notifyOutput, notifyStatus, notifyTranscript, notifyMenu, notifyConfig, clearStatus, isWorking, shouldAutoApproveMenu,
543
565
  handleMessage, updateSetting, getInfo, resolveFile, installPlugin, removePlugin,
544
566
  getPills, getPillLogs,
545
567
  };
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "id": "autopilot",
3
3
  "name": "Autopilot",
4
- "version": "0.15.0",
4
+ "version": "0.16.0",
5
5
  "author": "CliDeck",
6
- "description": "Multi-agent orchestration — routes output between agents automatically",
6
+ "description": "Multi-agent orchestration — routes output between agents automatically. Uses ~50 output tokens per routing decision.",
7
7
  "icon": "<svg class=\"w-4 h-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 6v6l4 2\"/></svg>",
8
8
  "install": "npm",
9
9
  "settings": [
@@ -33,7 +33,7 @@
33
33
  "key": "model",
34
34
  "label": "Model",
35
35
  "type": "dynamic-select",
36
- "default": "claude-haiku-4-5"
36
+ "default": "claude-opus-4-6"
37
37
  },
38
38
  {
39
39
  "key": "apiKey",
@@ -75,6 +75,12 @@ function addTokens(pid, usage) {
75
75
  api.sendToFrontend('tokens', { projectId: pid, ...u });
76
76
  }
77
77
 
78
+ function latestAgentOutput(id) {
79
+ const turns = api.getTranscript(id, 20);
80
+ const last = [...turns].reverse().find(t => t.role === 'agent');
81
+ return last?.text?.trim().slice(0, 8000) || null;
82
+ }
83
+
78
84
  // --- Consumed state (persisted per-project: role → boolean) ---
79
85
 
80
86
  function captureIdleOutput(id, pid, proj) {
@@ -82,9 +88,8 @@ function captureIdleOutput(id, pid, proj) {
82
88
  if (proj.status.get(id)) return;
83
89
  const w = proj.workers.get(id);
84
90
  if (w) {
85
- const turns = api.getScreenTurns(id, w.presetId, { raw: true });
86
- if (turns?.length && turns[turns.length - 1].role === 'agent') {
87
- const out = turns[turns.length - 1].text.trim().slice(0, 8000);
91
+ const out = latestAgentOutput(id);
92
+ if (out) {
88
93
  const oid = outputId(out);
89
94
  const prev = proj.lastOutput.get(id);
90
95
  const isNew = !prev || prev.outputId !== oid;
@@ -200,13 +205,9 @@ function refreshWorkers(pid, proj) {
200
205
  proj.workers.set(sid, w);
201
206
  proj.status.set(sid, liveStatus.get(sid));
202
207
  api.setAutoApproveMenu(sid, true);
203
- // Seed output for idle new workers from .screen
204
208
  if (!liveStatus.get(sid)) {
205
- const turns = api.getScreenTurns(sid, w.presetId, { raw: true });
206
- if (turns?.length && turns[turns.length - 1].role === 'agent') {
207
- const text = turns[turns.length - 1].text.trim().slice(0, 8000);
208
- proj.lastOutput.set(sid, { text, capturedAt: Date.now(), outputId: outputId(text) });
209
- }
209
+ const text = latestAgentOutput(sid);
210
+ if (text) proj.lastOutput.set(sid, { text, capturedAt: Date.now(), outputId: outputId(text) });
210
211
  }
211
212
  }
212
213
  }
@@ -398,7 +399,7 @@ async function consult(pid, proj) {
398
399
  }
399
400
 
400
401
  const provider = api.getSetting('provider') || 'anthropic';
401
- const modelId = api.getSetting('model') || 'claude-haiku-4-5';
402
+ const modelId = api.getSetting('model') || 'claude-opus-4-6';
402
403
  const apiKey = api.getSetting('apiKey') || m.getEnvApiKey(provider) || '';
403
404
 
404
405
  if (!apiKey) {
@@ -430,11 +431,8 @@ async function consult(pid, proj) {
430
431
  let capturedAt = stored?.capturedAt || 0;
431
432
  let oid = stored?.outputId || null;
432
433
  if (!text) {
433
- const turns = api.getScreenTurns(sid, w.presetId, { raw: true });
434
- if (turns?.length) {
435
- const last = [...turns].reverse().find(t => t.role === 'agent');
436
- if (last) { text = last.text.trim().slice(0, 8000); capturedAt = capturedAt || Date.now(); oid = outputId(text); }
437
- }
434
+ text = latestAgentOutput(sid);
435
+ if (text) { capturedAt = capturedAt || Date.now(); oid = outputId(text); }
438
436
  }
439
437
  entries.push({ sid, role: w.role, text, capturedAt, outputId: oid });
440
438
  }
@@ -554,14 +552,8 @@ function executeAction(pid, proj, action, args, pillId) {
554
552
  let out = stored?.text || null;
555
553
  let oid = stored?.outputId || null;
556
554
  if (!out && src) {
557
- const w = proj.workers.get(src);
558
- if (w) {
559
- const turns = api.getScreenTurns(src, w.presetId, { raw: true });
560
- if (turns?.length && turns[turns.length - 1].role === 'agent') {
561
- out = turns[turns.length - 1].text.trim().slice(0, 8000);
562
- oid = outputId(out);
563
- }
564
- }
555
+ out = latestAgentOutput(src);
556
+ if (out) oid = outputId(out);
565
557
  }
566
558
  if (!out) return `"${args.from}" has no output to route`;
567
559
 
@@ -616,10 +608,14 @@ function executeAction(pid, proj, action, args, pillId) {
616
608
 
617
609
  // --- Lifecycle ---
618
610
 
619
- function start(pid) {
611
+ async function start(pid) {
620
612
  if (projects.has(pid)) return { error: 'Already running' };
621
613
  if (!enabled()) return { error: 'Autopilot disabled' };
622
614
 
615
+ const provider = api.getSetting('provider') || 'anthropic';
616
+ const apiKey = api.getSetting('apiKey') || (await ai()).getEnvApiKey(provider) || '';
617
+ if (!apiKey) return { error: 'Set the API key in Autopilot settings (Plugins panel)' };
618
+
623
619
  const { workers, status } = discoverWorkers(pid);
624
620
  if (workers.size < 1) return { error: 'No agents with roles in this project' };
625
621
 
@@ -649,12 +645,10 @@ function start(pid) {
649
645
  // Flag all workers for core menu auto-approve
650
646
  for (const [sid] of workers) api.setAutoApproveMenu(sid, true);
651
647
 
652
- // Seed lastOutput from .screen for idle workers
653
648
  for (const [sid, w] of workers) {
654
649
  if (status.get(sid)) continue;
655
- const turns = api.getScreenTurns(sid, w.presetId, { raw: true });
656
- if (!turns?.length || turns[turns.length - 1].role !== 'agent') continue;
657
- const text = turns[turns.length - 1].text.trim().slice(0, 8000);
650
+ const text = latestAgentOutput(sid);
651
+ if (!text) continue;
658
652
  proj.lastOutput.set(sid, { text, capturedAt: Date.now(), outputId: outputId(text) });
659
653
  }
660
654
 
@@ -700,13 +694,13 @@ module.exports.init = function (pluginApi) {
700
694
  });
701
695
 
702
696
  // Toggle on/off
703
- api.onFrontendMessage('autopilot-toggle', (msg) => {
697
+ api.onFrontendMessage('autopilot-toggle', async (msg) => {
704
698
  if (projects.has(msg.projectId)) {
705
699
  stop(msg.projectId);
706
700
  } else {
707
701
  // Remove lingering pill from a previous finished run
708
702
  api.removeSessionPill(`autopilot-${msg.projectId}`);
709
- const r = start(msg.projectId);
703
+ const r = await start(msg.projectId);
710
704
  if (r.error) api.sendToFrontend('error', { msg: r.error });
711
705
  }
712
706
  });
package/public/index.html CHANGED
@@ -324,8 +324,8 @@
324
324
  <div class="mb-5">
325
325
  <label class="block text-xs text-slate-400 mb-1.5 ml-6">Minimum working time before notifying</label>
326
326
  <select id="cfg-notify-min-work" class="ml-6 px-3 py-1.5 text-sm bg-slate-800 border border-slate-600 rounded-md text-slate-200 outline-none focus:border-blue-500 transition-colors cursor-pointer">
327
- <option value="0">Always</option>
328
- <option value="10" selected>10 seconds</option>
327
+ <option value="0" selected>Always</option>
328
+ <option value="10">10 seconds</option>
329
329
  <option value="30">30 seconds</option>
330
330
  </select>
331
331
  </div>
package/public/js/app.js CHANGED
@@ -14,6 +14,8 @@ import { registerHotkey, unregisterHotkey, unregisterAllForPlugin } from './hotk
14
14
  import { renderPrompts } from './prompts.js';
15
15
  import { renderRoles } from './roles.js';
16
16
 
17
+ const shownAgentHealthToasts = new Set();
18
+
17
19
  function connect() {
18
20
  state.ws = new WebSocket(`ws://${location.host}`);
19
21
 
@@ -50,6 +52,12 @@ function connect() {
50
52
  state.presets = msg.presets;
51
53
  renderSettings();
52
54
  refreshCreator();
55
+ for (const p of state.presets) {
56
+ if (p.available && p.health && !p.health.ok && p.health.reason !== 'Not installed' && !shownAgentHealthToasts.has(p.presetId)) {
57
+ shownAgentHealthToasts.add(p.presetId);
58
+ showToast(`${p.name}: ${p.health.reason}`, { id: `agent-health-${p.presetId}`, type: p.versionOk === false ? 'error' : 'warn', duration: 0, title: 'Agent Attention' });
59
+ }
60
+ }
53
61
  break;
54
62
  case 'sessions.resumable':
55
63
  state.resumable = msg.list;
@@ -83,17 +91,23 @@ function connect() {
83
91
  case 'session.status':
84
92
  setStatus(msg.id, msg.working);
85
93
  break;
86
- // Server requests screen capture (e.g. after PermissionRequest hook)
87
- case 'screen.capture': {
94
+ // Server requests terminal capture (e.g. after PermissionRequest hook)
95
+ case 'terminal.capture': {
88
96
  const ce = state.terms.get(msg.id);
89
97
  if (ce?.term) {
90
98
  const buf = ce.term.buffer.active;
91
99
  const lines = [];
92
100
  for (let i = 0; i < buf.length; i++) { const line = buf.getLine(i); if (line) lines.push(line.translateToString(true)); }
93
- send({ type: 'terminal.buffer', id: msg.id, lines });
101
+ send({ type: 'terminal.buffer', id: msg.id, lines, menuVersion: msg.menuVersion });
94
102
  }
95
103
  break;
96
104
  }
105
+ case 'session.history': {
106
+ const entry = state.terms.get(msg.id);
107
+ if (entry && !entry.queue(msg.text + '\n')) entry.term.write(msg.text + '\n');
108
+ updatePreview(msg.id);
109
+ break;
110
+ }
97
111
  // Bridge preview text (OpenCode plugin)
98
112
  case 'session.preview': {
99
113
  const pe = state.terms.get(msg.id);
@@ -193,7 +207,9 @@ function connect() {
193
207
  if (!toast) break;
194
208
  const actionsEl = toast.querySelector('.setup-actions');
195
209
  if (msg.success) {
196
- const sid = toast.dataset.sessionId;
210
+ const sid = (toast.dataset.sessionId && toast.dataset.sessionId !== 'null' && toast.dataset.sessionId !== 'undefined')
211
+ ? toast.dataset.sessionId
212
+ : '';
197
213
  actionsEl.innerHTML = `
198
214
  <div class="flex-1 flex items-center gap-1.5 text-xs text-emerald-400">
199
215
  <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path d="M5 13l4 4L19 7"/></svg>
@@ -433,7 +449,7 @@ function showTelemetrySetup(commandId, sessionId) {
433
449
 
434
450
  const toast = document.createElement('div');
435
451
  toast.dataset.setupPreset = preset.presetId;
436
- toast.dataset.sessionId = sessionId;
452
+ if (sessionId) toast.dataset.sessionId = sessionId;
437
453
  toast.dataset.commandId = commandId;
438
454
  toast.className = 'fixed bottom-5 right-5 z-[500] w-[360px] bg-slate-800/95 backdrop-blur-sm border border-slate-700/60 rounded-xl shadow-2xl shadow-black/60';
439
455
  toast.style.opacity = '0';
@@ -35,11 +35,15 @@ function isPresetMissing(p) {
35
35
  return cmd.command === p.command;
36
36
  }
37
37
 
38
+ function isPresetOutdated(p) {
39
+ return p.available !== false && p.versionOk === false;
40
+ }
41
+
38
42
  // True if preset binary exists but telemetry/hooks are not configured yet
39
43
  function isPresetUnpatched(p) {
40
- if (p.available === false || !p.telemetryAutoSetup) return false;
44
+ if (p.available === false || p.versionOk === false || !p.telemetryAutoSetup) return false;
41
45
  const cmd = findCommandForPreset(p);
42
- return !cmd || !cmd.telemetryEnabled;
46
+ return !cmd || !cmd.telemetryStatus?.ok;
43
47
  }
44
48
 
45
49
  function renderPresetButtons() {
@@ -52,6 +56,14 @@ function renderPresetButtons() {
52
56
  <button class="install-btn px-2.5 py-1 text-[11px] font-medium text-blue-400 hover:text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 rounded-md transition-colors" data-preset="${p.presetId}">Add</button>
53
57
  </div>`;
54
58
  }
59
+ if (isPresetOutdated(p)) {
60
+ return `
61
+ <div class="w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-left text-slate-500">
62
+ <span class="opacity-40">${agentIcon(p.icon, 24)}</span>
63
+ <span class="flex-1 min-w-0">${esc(p.name)}</span>
64
+ <button class="install-btn px-2.5 py-1 text-[11px] font-medium text-rose-400 hover:text-rose-300 bg-rose-500/10 hover:bg-rose-500/20 rounded-md transition-colors" data-preset="${p.presetId}">Update</button>
65
+ </div>`;
66
+ }
55
67
  if (isPresetUnpatched(p)) {
56
68
  return `
57
69
  <div class="w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-left text-slate-500">
@@ -111,10 +111,12 @@ function integrationSection(c) {
111
111
  const preset = telemetryPreset(c);
112
112
  if (!preset) return '';
113
113
  if (!preset.telemetryAutoSetup && !preset.bridge) return '';
114
- const configured = !!c.telemetryEnabled;
115
- const detail = configured
116
- ? `<span class="text-emerald-400/80">Configured</span> &mdash; ${esc(preset.telemetryConfigPath || '')}`
117
- : `<span class="text-slate-500">Not configured</span> &mdash; enable agent to set up`;
114
+ const configured = !!c.telemetryStatus?.ok;
115
+ const detail = preset.versionOk === false
116
+ ? `<span class="text-rose-400/80">Update required</span> &mdash; need ${esc(preset.minVersion)}+ (found ${esc(preset.version || 'unknown')})`
117
+ : configured
118
+ ? `<span class="text-emerald-400/80">Configured</span> &mdash; ${esc(preset.telemetryConfigPath || '')}`
119
+ : `<span class="text-amber-400/80">${esc(c.telemetryStatus?.error || 'Needs setup')}</span> &mdash; ${esc(preset.telemetryConfigPath || '')}`;
118
120
  return `
119
121
  <div class="mt-3 pt-3 border-t border-slate-700/50">
120
122
  <div class="text-[11px] text-slate-500">${detail}</div>
@@ -383,7 +385,7 @@ function renderThemeSection() {
383
385
  function renderNotifications() {
384
386
  const enabled = !!state.cfg.notifyIdle;
385
387
  document.getElementById('cfg-notify-idle').checked = enabled;
386
- document.getElementById('cfg-notify-min-work').value = state.cfg.notifyMinWork ?? 10;
388
+ document.getElementById('cfg-notify-min-work').value = state.cfg.notifyMinWork ?? 0;
387
389
 
388
390
  const permStatus = document.getElementById('notify-permission-status');
389
391
  if (enabled && 'Notification' in window) {
@@ -461,7 +463,7 @@ function saveConfig() {
461
463
  state.cfg.defaultPath = document.getElementById('cfg-default-path').value.trim();
462
464
  state.cfg.confirmClose = document.getElementById('cfg-confirm-close').checked;
463
465
  state.cfg.notifyIdle = document.getElementById('cfg-notify-idle').checked;
464
- state.cfg.notifyMinWork = parseInt(document.getElementById('cfg-notify-min-work').value, 10) ?? 10;
466
+ state.cfg.notifyMinWork = parseInt(document.getElementById('cfg-notify-min-work').value, 10) || 0;
465
467
  state.cfg.notifySoundEnabled = document.getElementById('cfg-notify-sound').checked;
466
468
  state.cfg.notifySound = document.getElementById('cfg-notify-sound-pick').value;
467
469
  // Preserve fields not managed by this form
@@ -1,5 +1,5 @@
1
1
  import { state, send } from './state.js';
2
- import { esc, resolveIconPath } from './utils.js';
2
+ import { esc, miniMarkdown, resolveIconPath } from './utils.js';
3
3
  import { resolveTheme, resolveAccent, applyTheme } from './profiles.js';
4
4
  import { attachToTerminal, registerHotkey } from './hotkeys.js';
5
5
  import { closeDropdown } from './prompts.js';
@@ -338,25 +338,56 @@ 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
- const _hasServerStatus = cmd?.presetId === 'claude-code' || cmd?.presetId === 'codex' || cmd?.presetId === 'gemini-cli' || cmd?.presetId === 'opencode';
344
- let _screenTimer = null, _renderSilent = false;
345
- function _tryScreenCapture() {
341
+ // [TRANSCRIPT-CAPTURE] initial settled capture plus one delayed idle save
342
+ let _captureTimer = null, _renderSilent = false, _lastTyping = 0, _initialCaptureDone = false, _idleSaveTimer = null;
343
+ function _sendCapture() {
346
344
  const entry = state.terms.get(id);
347
- if (!entry?.pendingScreenCapture || (!_renderSilent && !_hasServerStatus) || !entry.term) return;
348
- entry.pendingScreenCapture = false;
345
+ if (!entry?.term) return;
349
346
  const buf = entry.term.buffer.active;
350
347
  const lines = [];
351
348
  for (let i = 0; i < buf.length; i++) { const line = buf.getLine(i); if (line) lines.push(line.translateToString(true)); }
352
349
  send({ type: 'terminal.buffer', id, lines });
353
350
  }
354
- let _lastTyping = 0;
355
- term.onData(() => { _lastTyping = Date.now(); });
351
+ function _isChrome(t) {
352
+ return !t
353
+ || /^[─━═\u2500-\u257f]+$/.test(t)
354
+ || /^[▀▄█▌▐░▒▓╭╮╰╯│╔╗╚╝║]+$/.test(t)
355
+ || (/[█▀▄▌▐░▒▓]/.test(t) && /^[█▀▄▌▐░▒▓\s]+$/.test(t))
356
+ || /^[❯>$%#]\s*$/.test(t)
357
+ || /^(esc to interrupt|\? for shortcuts)$/i.test(t);
358
+ }
359
+ function _hasContent() {
360
+ const entry = state.terms.get(id);
361
+ if (!entry?.term) return false;
362
+ const buf = entry.term.buffer.active;
363
+ for (let i = 0; i < buf.length; i++) {
364
+ const text = buf.getLine(i)?.translateToString(true).trim();
365
+ if (!_isChrome(text)) return true;
366
+ }
367
+ return false;
368
+ }
369
+ function _tryCapture() {
370
+ const entry = state.terms.get(id);
371
+ if (!_renderSilent || Date.now() - _lastTyping < 2000) return;
372
+ // Initial capture: first time render settles with real content, capture regardless of working/idle
373
+ if (!_initialCaptureDone) {
374
+ if (!_hasContent()) return; // retry on next silence
375
+ _initialCaptureDone = true;
376
+ _sendCapture();
377
+ return;
378
+ }
379
+ }
380
+ term.onData(() => {
381
+ _lastTyping = Date.now();
382
+ // User typing invalidates pending capture — will re-try after silence
383
+ _renderSilent = false;
384
+ clearTimeout(_captureTimer);
385
+ _captureTimer = setTimeout(() => { _renderSilent = true; _tryCapture(); }, 2000);
386
+ });
356
387
  term.onRender(() => {
357
388
  _renderSilent = false;
358
- clearTimeout(_screenTimer);
359
- _screenTimer = setTimeout(() => { _renderSilent = true; _tryScreenCapture(); }, 2000);
389
+ clearTimeout(_captureTimer);
390
+ _captureTimer = setTimeout(() => { _renderSilent = true; _tryCapture(); }, 2000);
360
391
  });
361
392
  term.onWriteParsed(() => {
362
393
  if (Date.now() - _lastTyping < 500) return;
@@ -364,8 +395,23 @@ export function addTerminal(id, name, themeId, commandId, projectId, muted, last
364
395
  if (entry) entry.lastRenderAt = Date.now();
365
396
  });
366
397
 
367
- // Expose capture function so setStatus can trigger it when idle arrives after render silence
368
- setTimeout(() => { const e = state.terms.get(id); if (e) e.tryScreenCapture = _tryScreenCapture; }, 0);
398
+ // Expose capture function so setStatus can schedule a retry
399
+ setTimeout(() => {
400
+ const e = state.terms.get(id);
401
+ if (e) {
402
+ e.tryCapture = _tryCapture;
403
+ e.sendCaptureNow = _sendCapture;
404
+ e.scheduleIdleCapture = () => {
405
+ clearTimeout(_idleSaveTimer);
406
+ _idleSaveTimer = setTimeout(() => {
407
+ const entry = state.terms.get(id);
408
+ if (!entry || entry.working) return;
409
+ _sendCapture();
410
+ }, 300);
411
+ };
412
+ e.cancelIdleCapture = () => clearTimeout(_idleSaveTimer);
413
+ }
414
+ }, 0);
369
415
 
370
416
  term.open(el);
371
417
  attachToTerminal(term);
@@ -394,8 +440,21 @@ export function addTerminal(id, name, themeId, commandId, projectId, muted, last
394
440
  fitRaf = requestAnimationFrame(() => { fitRaf = 0; doFit(); });
395
441
  });
396
442
  ro.observe(el);
397
- // Safety: if RO hasn't fired within 500ms, flush anyway to avoid unbounded queue
398
- setTimeout(() => { if (!fitted) { fitted = true; for (const chunk of pending) term.write(chunk); pending = null; updatePreview(id); } }, 500);
443
+ // Safety: if RO hasn't fired within 500ms, flush anyway to avoid unbounded queue.
444
+ // If the element is hidden (background tab), force a reasonable default size so the PTY
445
+ // doesn't stay at a tiny default and produce garbled output.
446
+ setTimeout(() => {
447
+ if (!fitted) {
448
+ fitted = true;
449
+ if (!el.offsetWidth) {
450
+ term.resize(120, 30);
451
+ send({ type: 'resize', id, cols: 120, rows: 30 });
452
+ }
453
+ for (const chunk of pending) term.write(chunk);
454
+ pending = null;
455
+ updatePreview(id);
456
+ }
457
+ }, 500);
399
458
  const cancelFitRaf = () => { if (fitRaf) { cancelAnimationFrame(fitRaf); fitRaf = 0; } };
400
459
  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: '' });
401
460
  document.getElementById('empty').style.display = 'none';
@@ -526,7 +585,7 @@ function setStatus(id, working) {
526
585
 
527
586
  // Notify on working → idle transition
528
587
  if (wasWorking && !working && !entry.muted) {
529
- const minWork = state.cfg.notifyMinWork ?? 10;
588
+ const minWork = state.cfg.notifyMinWork ?? 0;
530
589
  const workDuration = (Date.now() - (entry.workStartedAt || 0)) / 1000;
531
590
  if (workDuration >= minWork) {
532
591
  entry.workStartedAt = null;
@@ -546,11 +605,15 @@ function setStatus(id, working) {
546
605
  }
547
606
  }
548
607
 
549
- // Mark idle so the onRender silence watcher can capture .screen
550
- // Also try immediately — renders may already be silent
551
- if (wasWorking && !working) { entry.pendingScreenCapture = true; entry.tryScreenCapture?.(); }
608
+ // Save once shortly after idle unless the agent resumes first.
609
+ if (wasWorking && !working) {
610
+ entry.scheduleIdleCapture?.();
611
+ }
552
612
 
553
- if (working && !entry.workStartedAt) entry.workStartedAt = Date.now();
613
+ if (working) {
614
+ entry.cancelIdleCapture?.();
615
+ if (!entry.workStartedAt) entry.workStartedAt = Date.now();
616
+ }
554
617
 
555
618
  const el = document.querySelector(`.group[data-id="${id}"] .session-status`);
556
619
  if (!el) return;
@@ -1112,7 +1175,7 @@ function openPillLog(id) {
1112
1175
  <span class="flex-1"></span>
1113
1176
  <button class="pill-log-clear text-[11px] text-slate-600 hover:text-slate-400 transition-colors">Clear</button>
1114
1177
  </div>
1115
- <div class="pill-log-body flex-1 overflow-y-auto p-4 font-mono text-xs leading-relaxed tmx-scroll"></div>
1178
+ <div class="pill-log-body flex-1 overflow-y-auto p-4 text-xs leading-relaxed tmx-scroll"></div>
1116
1179
  </div>`;
1117
1180
  document.getElementById('terminals').appendChild(panel);
1118
1181
  panel.querySelector('.pill-log-clear').addEventListener('click', () => {
@@ -1147,9 +1210,45 @@ function appendLogLine(entry) {
1147
1210
  const body = document.querySelector('#pill-log-panel .pill-log-body');
1148
1211
  if (!body) return;
1149
1212
  const line = document.createElement('div');
1150
- line.className = 'flex gap-3 py-0.5';
1151
1213
  const time = new Date(entry.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
1152
- line.innerHTML = `<span class="text-slate-600 flex-shrink-0">${time}</span><span class="text-slate-400">${esc(entry.text)}</span>`;
1214
+ const t = entry.text;
1215
+
1216
+ // Categorize log entries for visual treatment
1217
+ let color = 'text-slate-400';
1218
+ let icon = '';
1219
+ let content = esc(t);
1220
+ if (/^Started with/.test(t)) {
1221
+ color = 'text-emerald-400';
1222
+ icon = '<span class="text-emerald-500">&#9654;</span>';
1223
+ } else if (/^Routed /.test(t)) {
1224
+ color = 'text-indigo-400';
1225
+ icon = '<span class="text-indigo-500">&#8594;</span>';
1226
+ } else if (/^Notify:/.test(t)) {
1227
+ color = 'text-amber-300';
1228
+ icon = '<span class="text-amber-500">&#9679;</span>';
1229
+ content = '<strong class="text-amber-300">Notify:</strong> ' + miniMarkdown(t.replace(/^Notify:\s*/, ''));
1230
+ } else if (/^Consulting /.test(t)) {
1231
+ color = 'text-slate-500';
1232
+ icon = '<span class="text-slate-600">&#8230;</span>';
1233
+ } else if (/→ working$/.test(t)) {
1234
+ color = 'text-blue-400';
1235
+ icon = '<span class="text-blue-500">&#9679;</span>';
1236
+ } else if (/→ idle$/.test(t)) {
1237
+ color = 'text-slate-500';
1238
+ icon = '<span class="text-slate-600">&#9675;</span>';
1239
+ } else if (/^Completed$/.test(t)) {
1240
+ color = 'text-emerald-400';
1241
+ icon = '<span class="text-emerald-500">&#10003;</span>';
1242
+ } else if (/^Stopped$/.test(t)) {
1243
+ color = 'text-slate-500';
1244
+ icon = '<span class="text-slate-600">&#9632;</span>';
1245
+ } else if (/^Paused/.test(t)) {
1246
+ color = 'text-amber-400';
1247
+ icon = '<span class="text-amber-500">&#9646;&#9646;</span>';
1248
+ }
1249
+
1250
+ line.className = 'flex gap-3 py-1 items-start';
1251
+ line.innerHTML = `<span class="text-slate-600 flex-shrink-0 tabular-nums">${time}</span><span class="w-4 flex-shrink-0 text-center">${icon}</span><span class="${color} leading-relaxed">${content}</span>`;
1153
1252
  body.appendChild(line);
1154
1253
  body.scrollTop = body.scrollHeight;
1155
1254
  }