clideck 1.25.5 → 1.25.7

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 CHANGED
@@ -1,79 +1,81 @@
1
- # CliDeck
1
+ <img src="public/img/clideck-logo-icon.png" width="48" alt="clideck logo">
2
2
 
3
- One screen for all your AI coding agents.
3
+ # clideck
4
+
5
+ > **Formerly `termix-cli`** — if you arrived here from an old link, you're in the right place. The project has been renamed to **CliDeck**. Update your install: `npm install -g clideck`
6
+
7
+ Manage your AI agents like WhatsApp chats.
4
8
 
5
9
  [Documentation](https://docs.clideck.dev/) | [Video Demo](https://youtu.be/hICrtjGAeDk) | [Website](https://clideck.dev/)
6
10
 
7
- ![CliDeck dashboard](assets/clideck-themes.jpg)
11
+ ![clideck dashboard](assets/clideck-themes.jpg)
8
12
 
9
- You're running Claude Code, Codex, Gemini CLI, and OpenCode in separate terminals. You switch between them constantly, forget which one finished, and lose sessions when you close the lid. CliDeck puts them all on one screen with live status, so you always know what's happening.
13
+ You run Claude Code, Codex, Gemini CLI in separate terminals. You alt-tab between them, forget which one finished, lose sessions when you close the lid.
10
14
 
11
- CliDeck is a local dashboard that runs all your CLI agents in one browser tab. It tracks which agents are working, which are idle, and notifies you when they need attention. Everything runs on your machine nothing leaves localhost.
12
- Switch between agents as easily as switching between chats.
15
+ clideck puts all your agents in one screen a sidebar with every session, live status, last message preview, and timestamps. Click a session, you're in its terminal. Exactly like switching between chats.
16
+
17
+ Native terminals. Your keystrokes go straight to the agent, nothing in between. clideck never reads your prompts or output.
13
18
 
14
19
  ## Quick Start
15
20
 
16
21
  ```bash
17
- npm install -g clideck
18
- clideck
22
+ npx clideck
19
23
  ```
20
24
 
21
25
  Open [http://localhost:4000](http://localhost:4000). Click **+**, pick an agent, start working.
22
26
 
23
- Or run directly without installing:
27
+ Or install globally:
24
28
 
25
29
  ```bash
26
- npx clideck
30
+ npm install -g clideck
31
+ clideck
27
32
  ```
28
33
 
29
34
  ## What You Get
30
35
 
31
- - **Live status** — see which agents are working and which are done, without checking each terminal
32
- - **Session resume** — close CliDeck, reopen it tomorrow, resume your Claude Code conversation where you left off
33
- - **Notifications** — browser and sound alerts the moment an agent finishes or needs input
36
+ - **Live working/idle status** — see which agent is thinking and which is waiting for you, without checking each terminal
37
+ - **Session resume** — close clideck, reopen it tomorrow, pick up where you left off
38
+ - **Notifications** — browser and sound alerts when an agent finishes or needs input
34
39
  - **Message previews** — latest output from each agent, right in the sidebar
35
40
  - **Projects** — group sessions by project with drag-and-drop
36
41
  - **Search** — find any session by name or scroll back through transcript content
37
- - **Prompt Library** — save reusable prompts and paste them into any terminal by typing `//`
38
- - **Plugins** — ships with Voice Input and Trim Clip. Build your own with the plugin API
39
- - **15 themes** — dark and light built-in, plus custom theme support
40
- - **Zero interference** — native PTY terminals, your keystrokes go straight to the agent, nothing in between
42
+ - **Prompt Library** — save reusable prompts, type `//` in any terminal to paste them
43
+ - **Plugins** — ships with Voice Input and Trim Clip, or build your own
44
+ - **15 themes** — dark and light, plus custom theme support
45
+
46
+ ## Mobile Access
47
+
48
+ Check on your agents from your phone. Start a task, walk away, glance at your phone — see who's done, who's working, who needs input. Pair with one QR scan, no account needed. E2E encrypted — the relay cannot read your code.
41
49
 
42
50
  ## Supported Agents
43
51
 
44
- CliDeck auto-detects whether each agent is working or idle:
52
+ clideck auto-detects whether each agent is working or idle:
45
53
 
46
54
  | Agent | Status detection | Setup |
47
55
  |-------|-----------------|-------|
48
56
  | **Claude Code** | Automatic | Nothing to configure |
49
- | **Codex** | Automatic | One-click setup in CliDeck |
50
- | **Gemini CLI** | Automatic | One-click setup in CliDeck |
51
- | **OpenCode** | Via plugin bridge | One-click setup in CliDeck |
57
+ | **Codex** | Automatic | One-click setup in clideck |
58
+ | **Gemini CLI** | Automatic | One-click setup in clideck |
59
+ | **OpenCode** | Via plugin bridge | One-click setup in clideck |
52
60
  | **Shell** | I/O activity only | None |
53
61
 
54
- Claude Code works out of the box. Other agents need a one-time configuration that CliDeck walks you through.
62
+ Claude Code works out of the box. Other agents need a one-time setup that clideck walks you through.
55
63
 
56
64
  ## How It Works
57
65
 
58
- Each agent runs in a native terminal (PTY). CliDeck receives lightweight status signals from agents via OpenTelemetry — it sees *that* an agent is working, not *what* it's working on. Your prompts and responses are never read or stored by CliDeck.
59
-
60
- OpenTelemetry runs locally between the agent and CliDeck. No data is collected, transmitted, or stored outside your machine.
61
-
62
- ## Prompt Library
63
-
64
- Save prompts you use often and paste them into any terminal session instantly. Open the Prompts panel from the sidebar, click **+** to add a prompt with a name and text.
66
+ Each agent runs in a real terminal (PTY) on your machine. clideck receives lightweight status signals via OpenTelemetry — it knows *that* an agent is working, not *what* it's working on.
65
67
 
66
- To use a prompt, type `//` in any terminal — an autocomplete dropdown appears. Type a few letters to filter, arrow keys to navigate, Enter to paste. The prompt text is sent directly to the active terminal.
68
+ Everything runs locally. No data is collected, transmitted, or stored outside your machine.
67
69
 
68
70
  ## Platform Support
69
71
 
70
- Tested on **macOS** and **Windows**. Works in any modern browser. Linux: untested — if you try it, open an issue and let me know.
72
+ Tested on **macOS** and **Windows**. Works in any modern browser. Linux: untested — if you try it, open an issue.
71
73
 
72
74
  ## Documentation
73
75
 
74
76
  Full setup guides, agent configuration, and plugin development:
75
77
 
76
- **[Documentation](https://docs.clideck.dev/)**
78
+ **[docs.clideck.dev](https://docs.clideck.dev/)**
77
79
 
78
80
  ## Acknowledgments
79
81
 
package/handlers.js CHANGED
@@ -134,10 +134,21 @@ function onConnection(ws) {
134
134
  plugins.notifyStatus(msg.id, !!msg.working);
135
135
  }
136
136
  break;
137
- case 'terminal.buffer':
137
+ case 'terminal.buffer': {
138
138
  require('./transcript').storeBuffer(msg.id, msg.lines);
139
139
  sessions.broadcast({ type: 'screen.updated', id: msg.id });
140
+ const sess = sessions.getSessions().get(msg.id);
141
+ if (sess) {
142
+ const choices = require('./transcript').detectMenu(msg.lines, sess.presetId);
143
+ const key = choices ? JSON.stringify(choices) : '';
144
+ if (key !== (sess._menuKey || '')) {
145
+ sess._menuKey = key;
146
+ sessions.broadcast({ type: 'session.menu', id: msg.id, choices: choices || [] });
147
+ if (choices) sessions.broadcast({ type: 'session.status', id: msg.id, working: false, source: 'menu' });
148
+ }
149
+ }
140
150
  break;
151
+ }
141
152
  case 'resize': sessions.resize(msg); break;
142
153
  case 'rename': sessions.rename(msg); break;
143
154
  case 'close': sessions.close(msg, cfg); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clideck",
3
- "version": "1.25.5",
3
+ "version": "1.25.7",
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": {
@@ -340,24 +340,26 @@ export function addTerminal(id, name, themeId, commandId, projectId, muted, last
340
340
 
341
341
  // [SCREEN-CAPTURE] extract terminal buffer when BOTH idle AND render-silent (2s)
342
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';
343
344
  let _screenTimer = null, _renderSilent = false;
344
345
  function _tryScreenCapture() {
345
346
  const entry = state.terms.get(id);
346
- if (!entry?.pendingScreenCapture || !_renderSilent || !entry.term) return;
347
+ if (!entry?.pendingScreenCapture || (!_renderSilent && !_telemetryOnly) || !entry.term) return;
347
348
  entry.pendingScreenCapture = false;
348
349
  const buf = entry.term.buffer.active;
349
350
  const lines = [];
350
351
  for (let i = 0; i < buf.length; i++) { const line = buf.getLine(i); if (line) lines.push(line.translateToString(true)); }
351
352
  send({ type: 'terminal.buffer', id, lines });
352
353
  }
354
+ let _idleTimer = null, _workTimer = null, _lastTyping = 0, _lastRender = 0;
355
+ term.onData(() => { _lastTyping = Date.now(); });
353
356
  term.onRender(() => {
357
+ _lastRender = Date.now();
354
358
  _renderSilent = false;
355
359
  clearTimeout(_screenTimer);
356
360
  _screenTimer = setTimeout(() => { _renderSilent = true; _tryScreenCapture(); }, 2000);
357
361
  });
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); });
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); });
361
363
 
362
364
  // Expose capture function so setStatus can trigger it when idle arrives after render silence
363
365
  setTimeout(() => { const e = state.terms.get(id); if (e) e.tryScreenCapture = _tryScreenCapture; }, 0);
package/sessions.js CHANGED
@@ -282,6 +282,7 @@ function list() {
282
282
  id, name: s.name, themeId: s.themeId, commandId: s.commandId, presetId: s.presetId || 'shell', projectId: s.projectId, muted: !!s.muted,
283
283
  // Last preview text for sidebar display on reconnect
284
284
  lastPreview: s.lastPreview || '', lastActivityAt: s.lastActivityAt || null,
285
+ menu: s._menuKey ? JSON.parse(s._menuKey) : undefined,
285
286
  }));
286
287
  }
287
288
 
@@ -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
 
@@ -69,14 +69,39 @@ function handleLogs(req, res) {
69
69
  const attrs = parseAttrs(lr.attributes);
70
70
 
71
71
  const eventName = attrs['event.name'];
72
- if (eventName) console.log(`[telemetry] ${resolvedId?.slice(0,8)} ${eventName}`);
72
+ // if (serviceName === 'codex_cli_rs' && eventName) console.log(`[telemetry:codex] ${eventName}`);
73
+ // if (serviceName === 'gemini-cli' && eventName) console.log(`[telemetry:gemini] ${eventName}`);
73
74
 
74
- // Telemetry: only used for working=true (user prompt). Idle is handled by frontend write-silence.
75
+ // Telemetry-based status
75
76
  const startEvents = new Set(['user_prompt', 'gemini_cli.user_prompt', 'codex.user_prompt']);
76
-
77
77
  if (startEvents.has(eventName)) {
78
78
  broadcastFn?.({ type: 'session.status', id: resolvedId, working: true, source: 'telemetry' });
79
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
+ }
80
105
 
81
106
  const agentSessionId = attrs['session.id'] || attrs['conversation.id'];
82
107
  if (agentSessionId && sess) {
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
 
@@ -71,7 +72,7 @@ function trackInput(id, data) {
71
72
  }
72
73
  if (ch === '\r' || ch === '\n') {
73
74
  const line = buf.text.trim();
74
- if (line) store(id, 'user', line);
75
+ if (line) { store(id, 'user', line); if (!userTexts[id]) userTexts[id] = []; userTexts[id].push(line); }
75
76
  buf.text = '';
76
77
  } else if (ch === '\x7f' || ch === '\x08') {
77
78
  const chars = Array.from(buf.text);
@@ -127,14 +128,21 @@ function getScreen(id) {
127
128
 
128
129
  // Per-agent screen parsers. Each returns [{role, text}] from .screen content.
129
130
  const agentParsers = {
130
- 'claude-code': (lines) => {
131
+ 'claude-code': (lines, id) => {
132
+ const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
131
133
  const turns = [];
132
134
  let current = null;
133
135
  for (const line of lines) {
134
- const m = line.match(/^(?:[│ ]\s*)?([❯›]|[⏺•●])\s(.*)$/);
135
- if (m) {
136
+ const agent = line.match(/^(?:[│ ]\s*)?[⏺•●]\s(.*)$/);
137
+ if (agent) {
136
138
  if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
137
- 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] };
138
146
  continue;
139
147
  }
140
148
  if (!current) continue;
@@ -146,14 +154,21 @@ const agentParsers = {
146
154
  if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
147
155
  return turns.length >= 2 ? turns : null;
148
156
  },
149
- 'codex': (lines) => {
157
+ 'codex': (lines, id) => {
158
+ const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
150
159
  const turns = [];
151
160
  let current = null;
152
161
  for (const line of lines) {
153
- const m = line.match(/^(?:│\s*)?([›•])\s(.*)$/);
154
- 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)) {
155
170
  if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
156
- current = { role: m[1] === '›' ? 'user' : 'agent', text: m[2] };
171
+ current = { role: 'user', text: userM[1] };
157
172
  continue;
158
173
  }
159
174
  if (!current) continue;
@@ -165,7 +180,8 @@ const agentParsers = {
165
180
  if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
166
181
  return turns.length >= 2 ? turns : null;
167
182
  },
168
- 'gemini-cli': (lines) => {
183
+ 'gemini-cli': (lines, id) => {
184
+ const known = userTexts[id]?.length ? new Set(userTexts[id]) : null;
169
185
  const geminiChrome = t => {
170
186
  const s = t.trim();
171
187
  return /^shift\+tab to accept/i.test(s)
@@ -180,11 +196,12 @@ const agentParsers = {
180
196
  let current = null;
181
197
  for (const line of lines) {
182
198
  if (geminiChrome(line)) continue;
183
- const isUser = line.startsWith(' > ');
184
199
  const isAgent = line.startsWith('✦ ');
200
+ const userM = line.startsWith(' > ') ? line.slice(3) : null;
201
+ const isUser = userM && (known ? known.has(userM.trim()) : true);
185
202
  if (isUser || isAgent) {
186
203
  if (current) { current.text = current.text.replace(/\n+$/, ''); turns.push(current); }
187
- 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) };
188
205
  continue;
189
206
  }
190
207
  if (!current) continue;
@@ -243,7 +260,7 @@ function getScreenTurns(id, agent) {
243
260
  if (!screen) return null;
244
261
  const lines = screen.split('\n');
245
262
  const parser = agentParsers[agent];
246
- const turns = parser ? parser(lines) : anchorParse(id, lines);
263
+ const turns = parser ? parser(lines, id) : anchorParse(id, lines);
247
264
  // Drop trailing user turn — it's the empty prompt or unanswered input
248
265
  if (turns?.length && turns[turns.length - 1].role === 'user') turns.pop();
249
266
  return turns?.length >= 2 ? turns : null;
@@ -281,7 +298,32 @@ function clear(id) {
281
298
  }
282
299
  delete cache[id];
283
300
  delete prefixes[id];
301
+ delete userTexts[id];
284
302
  try { unlinkSync(join(DIR, `${id}.screen`)); } catch {}
285
303
  }
286
304
 
287
- module.exports = { init, trackInput, trackOutput, storeBuffer, getScreen, getScreenTurns, getLastTurns, getCache, clear, setPrefix };
305
+ // Detect interactive menus from raw screen lines. Returns [{value, label, selected}] or null.
306
+ // Finds the footer line, then walks upward collecting only the contiguous menu block.
307
+ const MENU_MARKERS = { 'claude-code': /[❯›]/, codex: /[›❯]/, 'gemini-cli': /●/ };
308
+ const MENU_CHOICE_RE = /^\s*(?:[│❯›●•]\s+)*(\d+)\.\s+(.+)$/;
309
+ function detectMenu(lines, presetId) {
310
+ const marker = MENU_MARKERS[presetId];
311
+ if (!marker) return null;
312
+ let footerIdx = -1;
313
+ for (let i = lines.length - 1; i >= 0; i--) {
314
+ if (/\besc\b|\(esc\)/i.test(lines[i])) { footerIdx = MENU_CHOICE_RE.test(lines[i]) ? i + 1 : i; break; }
315
+ }
316
+ if (footerIdx < 0) return null;
317
+ const choices = [];
318
+ for (let i = footerIdx - 1; i >= 0; i--) {
319
+ if (!lines[i].trim() || /^[│\s]+$/.test(lines[i])) continue;
320
+ const m = lines[i].match(MENU_CHOICE_RE);
321
+ if (!m) break;
322
+ if (choices.length && +m[1] >= +choices[0].value) break;
323
+ choices.unshift({ value: m[1], label: m[2].trim(), selected: marker.test(lines[i]) });
324
+ }
325
+ if (!choices.some(c => c.selected)) return null;
326
+ return choices.length ? choices : null;
327
+ }
328
+
329
+ module.exports = { init, trackInput, trackOutput, storeBuffer, getScreen, getScreenTurns, getLastTurns, getCache, clear, setPrefix, detectMenu };
package/workplan.txt ADDED
@@ -0,0 +1,28 @@
1
+ CliDeck — Future Tasks
2
+ ======================
3
+
4
+ ## Structured menu events
5
+
6
+ Move interactive menu detection from mobile-side parsing to CliDeck desktop.
7
+ Detect menus server-side, broadcast as structured data, render on both desktop and mobile.
8
+
9
+ Why:
10
+ - Single source of truth — detect once, consume everywhere
11
+ - Enables desktop sidebar preview ("please make a choice")
12
+ - Opens the door to raw ANSI-based detection (mobile never sees PTY bytes)
13
+ - Fixes stuck "working" status when agent shows a menu: no telemetry event fires for
14
+ "waiting for user input", so CliDeck never flips to idle and never sends the notification.
15
+ Menu detection can double as an idle signal — if a menu is detected, force status to idle.
16
+
17
+ Steps:
18
+ 1. Add menu detection in CliDeck where screen parsing already happens
19
+ 2. Store per-session menu state (choices, selected, footer)
20
+ 3. Detect menu appearance and disappearance, broadcast session.menu events
21
+ 4. Show "make a choice" preview in desktop session list
22
+ 5. Daemon forwards session.menu to mobile
23
+ 6. Remove mobile-side menu parsing, render from structured state instead
24
+
25
+ Notes:
26
+ - Detection heuristic stays the same (footer + numbered lines) unless ANSI-based approach is pursued
27
+ - Currently mobile detection works fine with regex fix + "Esc to cancel" footer check
28
+ - No agent-side telemetry event exists for menus — heuristic is the only option for now