clideck 1.31.11 → 1.31.13

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
@@ -51,20 +51,28 @@ clideck --port 4001
51
51
 
52
52
  **Session resume** - close the lid, reopen tomorrow, pick up where things left off. each agent's session ID is captured automatically.
53
53
 
54
- **Autopilot** - enable autopilot on a project, walk away. it watches for one agent to finish, hands the output to the next one, and keeps going until the work is done or blocked. this is the part that makes sleep possible. routes content verbatim, no rewriting or summarizing. fingerprints each output and tracks handoff history to guard against repeat loops. ~50 output tokens per routing decision. supports Anthropic, OpenAI, Google, Groq, xAI, Mistral, OpenRouter, Cerebras.
54
+ **Ask another session** - from inside any CliDeck session, an agent can consult another session and get the answer back as command output:
55
55
 
56
56
  <p align="center">
57
- <img src="assets/autopilot.gif" width="720" alt="Autopilot routing work between agents">
57
+ <img src="assets/clideck-ask.png" width="720" alt="One agent asking another session and getting findings back">
58
58
  </p>
59
59
 
60
- **Ask another session** - from inside any CliDeck session, an agent can consult another session in the same project and get the answer back as command output:
61
-
62
60
  ```bash
61
+ clideck agents
63
62
  clideck ask --session "Reviewer" --message "Review this output and return findings." --timeout 10m
64
63
  ```
65
64
 
66
65
  CliDeck injects the message into the real target terminal, submits it, waits for the target session to finish, then returns the latest response to the caller.
67
66
 
67
+ By default, target lookup is limited to the caller's project. For cross-project asks, discover the full address first:
68
+
69
+ ```bash
70
+ clideck agents --all
71
+ clideck ask "@website/Docs Writer" "Check if the docs mention the new CLI flags." --timeout 15m
72
+ ```
73
+
74
+ If project or session names contain spaces, quote the whole target. The target is another LLM agent, not a fast CLI command, so callers should set both `clideck ask --timeout` and their own shell/tool timeout high enough. If the target session is busy, CliDeck does not queue the message; the caller gets a clear busy response and can retry later or ask another idle session.
75
+
68
76
  **Mobile remote** - the agents keep running on the local machine. status, prompts, history, and replies stay available from a phone while away. E2E encrypted, no account needed.
69
77
 
70
78
  **Native terminals** - each session opens into its real terminal. keys go straight to the agent, nothing sits in the middle.
Binary file
package/bin/clideck.js CHANGED
@@ -8,7 +8,7 @@ function usage() {
8
8
  '',
9
9
  'Usage:',
10
10
  ' clideck [--host <host>] [--port <port>]',
11
- ' clideck agents [--json]',
11
+ ' clideck agents [--json] [--all]',
12
12
  ' clideck ask --session <name-or-id> --message <text> [--timeout 10m]',
13
13
  '',
14
14
  'Options:',
@@ -21,24 +21,32 @@ function usage() {
21
21
  ' clideck agents',
22
22
  ' Lists active sessions in the same project as the caller session.',
23
23
  ' Use this first when an agent needs to discover who it can ask.',
24
+ ' Add --all to list cross-project targets with @project/session ask addresses.',
24
25
  '',
25
26
  ' clideck ask',
26
27
  ' Use from inside a CliDeck session when one agent needs an answer from another session.',
27
28
  '',
28
29
  'Ask behavior:',
29
- ' Target lookup is limited to the same project as the caller session.',
30
+ ' Unscoped target lookup is limited to the same project as the caller session.',
31
+ ' Cross-project asks must use an explicit @project/session target.',
30
32
  ' CliDeck sends the message into the real target terminal, presses Enter, waits for the',
31
33
  ' target to finish, then prints the target agent response to stdout.',
34
+ ' The target is another LLM agent. It may need minutes to think, read files, and use tools.',
35
+ ' Set both `--timeout` and your shell/tool-call timeout high enough, or your caller may exit',
36
+ ' before the target agent finishes.',
32
37
  '',
33
38
  'Examples:',
34
39
  ' clideck agents',
35
40
  ' clideck agents --json',
41
+ ' clideck agents --all',
36
42
  ' clideck ask --session "Reviewer" --message "Review my changes and return only findings."',
37
43
  ' clideck ask "research manager" "Check this plan and tell me what is missing." --timeout 15m',
44
+ ' clideck ask "@website/Docs Writer" "Check if the docs mention the new CLI flags." --timeout 15m',
38
45
  ' cat notes.md | clideck ask --session "Docs Writer" --timeout 10m',
39
46
  '',
40
47
  'Notes for agents:',
41
48
  ' Run `clideck agents` to discover available same-project sessions.',
49
+ ' Run `clideck agents --all` before a cross-project ask.',
42
50
  ' Run `clideck ask --help` for the exact ask command contract.',
43
51
  ' If the target name has spaces, quote it.',
44
52
  ' If several sessions have the same name in the same project, use the session id.',
@@ -4,13 +4,15 @@ const https = require('https');
4
4
  function usage() {
5
5
  return [
6
6
  'Usage:',
7
- ' clideck agents [--json]',
7
+ ' clideck agents [--json] [--all]',
8
8
  '',
9
9
  'Lists active CliDeck sessions in the same project as the caller session.',
10
10
  'Use this from inside a CliDeck session before `clideck ask` to discover target names.',
11
+ 'Use --all to discover cross-project targets and their @project/session ask addresses.',
11
12
  '',
12
13
  'Options:',
13
14
  ' --json Print machine-readable JSON.',
15
+ ' --all List sessions across all projects.',
14
16
  ' --url <url> CliDeck server URL. Default: CLIDECK_URL or http://127.0.0.1:<port>.',
15
17
  ' -h, --help Show this help.',
16
18
  ].join('\n');
@@ -18,10 +20,11 @@ function usage() {
18
20
 
19
21
  function parseArgs(args) {
20
22
  const port = process.env.CLIDECK_PORT || process.env.PORT || '4000';
21
- const out = { json: false, url: process.env.CLIDECK_URL || `http://127.0.0.1:${port}` };
23
+ const out = { json: false, all: false, url: process.env.CLIDECK_URL || `http://127.0.0.1:${port}` };
22
24
  for (let i = 0; i < args.length; i++) {
23
25
  const arg = args[i];
24
26
  if (arg === '--json') out.json = true;
27
+ else if (arg === '--all') out.all = true;
25
28
  else if (arg === '--url') out.url = args[++i];
26
29
  else if (arg === '--help' || arg === '-h') out.help = true;
27
30
  else throw new Error(`Unknown argument: ${arg}`);
@@ -29,10 +32,11 @@ function parseArgs(args) {
29
32
  return out;
30
33
  }
31
34
 
32
- function getJson(url, callerSessionId) {
35
+ function getJson(url, callerSessionId, all = false) {
33
36
  return new Promise((resolve, reject) => {
34
37
  const target = new URL('/api/session/agents', url);
35
38
  target.searchParams.set('callerSessionId', callerSessionId);
39
+ if (all) target.searchParams.set('all', '1');
36
40
  const client = target.protocol === 'https:' ? https : http;
37
41
  const req = client.get(target, (res) => {
38
42
  let data = '';
@@ -53,13 +57,15 @@ function getJson(url, callerSessionId) {
53
57
  });
54
58
  }
55
59
 
56
- function formatAgents(agents) {
57
- if (!agents.length) return 'No active sessions found in this project.';
60
+ function formatAgents(agents, opts = {}) {
61
+ if (!agents.length) return opts.all ? 'No active sessions found.' : 'No active sessions found in this project.';
58
62
  return agents.map(a => {
59
63
  const marker = a.caller ? 'self' : 'peer';
60
64
  const status = a.working ? 'working' : 'idle';
61
65
  const preview = a.lastPreview ? ` - ${a.lastPreview}` : '';
62
- return `${a.name} (${marker}, ${a.preset}, ${status}) id=${a.id}${preview}`;
66
+ const address = a.address && a.address !== a.name ? ` ask=${a.address}` : '';
67
+ const project = opts.all && a.project ? ` project="${a.project}"` : '';
68
+ return `${a.name} (${marker}, ${a.preset}, ${status}) id=${a.id}${address}${project}${preview}`;
63
69
  }).join('\n');
64
70
  }
65
71
 
@@ -73,9 +79,9 @@ async function run(args) {
73
79
  const callerSessionId = process.env.CLIDECK_SESSION_ID || '';
74
80
  if (!callerSessionId) throw new Error('CLIDECK_SESSION_ID is missing. Run this from inside a CliDeck session.');
75
81
 
76
- const res = await getJson(opts.url, callerSessionId);
82
+ const res = await getJson(opts.url, callerSessionId, opts.all);
77
83
  if (opts.json) process.stdout.write(JSON.stringify(res.agents || [], null, 2) + '\n');
78
- else process.stdout.write(formatAgents(res.agents || []) + '\n');
84
+ else process.stdout.write(formatAgents(res.agents || [], opts) + '\n');
79
85
  } catch (e) {
80
86
  process.stderr.write(`${e.message}\n`);
81
87
  process.exitCode = 1;
@@ -6,11 +6,19 @@ function usage() {
6
6
  'Usage:',
7
7
  ' clideck ask --session <name-or-id> --message <text> [--timeout 10m]',
8
8
  ' clideck ask <name-or-id> <message> [--timeout 10m]',
9
+ ' clideck ask "@project-name/session-name" <message> [--timeout 10m]',
9
10
  ' cat file.txt | clideck ask --session <name-or-id> [--timeout 10m]',
10
11
  '',
11
12
  'Use from inside a CliDeck session when this agent needs an answer from another active session.',
12
- 'Target lookup is limited to the same project as the caller session.',
13
- 'Run `clideck agents` first to discover available target sessions.',
13
+ 'Unscoped target lookup is limited to the same project as the caller session.',
14
+ 'Use @project/session only when you intentionally need to ask across projects.',
15
+ 'Run `clideck agents` or `clideck agents --all` first to discover available target sessions.',
16
+ '',
17
+ 'Important for agents:',
18
+ ' The target is another LLM agent, not a fast CLI command. It may need minutes to read files,',
19
+ ' think, use tools, and answer. Set BOTH the `clideck ask --timeout` value and your own',
20
+ ' shell/tool-call timeout high enough. If your shell tool kills this process first, the target',
21
+ ' agent may keep working but you will lose the response.',
14
22
  '',
15
23
  'Options:',
16
24
  ' -s, --session <name-or-id> Target session name or id.',
@@ -109,10 +117,12 @@ function findAgent(agents, target) {
109
117
  if (!text) return null;
110
118
  const byId = agents.filter(a => a.id === text);
111
119
  if (byId.length === 1) return byId[0];
120
+ const byAddress = agents.filter(a => a.address === text);
121
+ if (byAddress.length === 1) return byAddress[0];
112
122
  const exact = agents.filter(a => a.name === text);
113
123
  if (exact.length === 1) return exact[0];
114
124
  const lower = text.toLowerCase();
115
- const insensitive = agents.filter(a => String(a.name || '').toLowerCase() === lower);
125
+ const insensitive = agents.filter(a => String(a.name || '').toLowerCase() === lower || String(a.address || '').toLowerCase() === lower);
116
126
  return insensitive.length === 1 ? insensitive[0] : null;
117
127
  }
118
128
 
@@ -133,7 +143,8 @@ function startProgressHints(opts, callerSessionId) {
133
143
  const tick = async () => {
134
144
  if (stopped) return;
135
145
  try {
136
- const path = `/api/session/agents?callerSessionId=${encodeURIComponent(callerSessionId)}`;
146
+ const all = String(opts.session || '').trim().startsWith('@') ? '&all=1' : '';
147
+ const path = `/api/session/agents?callerSessionId=${encodeURIComponent(callerSessionId)}${all}`;
137
148
  const res = await getJson(opts.url, path, 4000);
138
149
  const agent = findAgent(res.agents || [], opts.session);
139
150
  const elapsed = formatDuration(Date.now() - started);
package/handlers.js CHANGED
@@ -100,9 +100,9 @@ function configRootFor(preset, cmd) {
100
100
  return os.homedir();
101
101
  }
102
102
 
103
- function checkRemoteUpdate(ws) {
103
+ function checkRemoteUpdate(ws, force = false) {
104
104
  const now = Date.now();
105
- if (remoteUpdateCache && now - remoteUpdateCheckedAt < REMOTE_UPDATE_INTERVAL) {
105
+ if (!force && remoteUpdateCache && now - remoteUpdateCheckedAt < REMOTE_UPDATE_INTERVAL) {
106
106
  ws.send(JSON.stringify({ type: 'remote.update', checked: true, ...remoteUpdateCache }));
107
107
  return;
108
108
  }
@@ -167,7 +167,13 @@ function hasExistingHook(arr, hookFile, route) {
167
167
  return !!arr?.some(h => h.hooks?.some(x => {
168
168
  if (!x.command?.includes(hookFile) || !x.command?.includes(` ${route}`)) return false;
169
169
  const hookPath = extractQuotedPath(x.command, hookFile);
170
- return !!hookPath && existsSync(hookPath);
170
+ if (!hookPath || !existsSync(hookPath)) return false;
171
+ const command = String(x.command).replace(/\\/g, '/');
172
+ const normalizedPath = hookPath.replace(/\\/g, '/');
173
+ const quotedIdx = command.indexOf(`"${normalizedPath}"`);
174
+ if (quotedIdx < 0) return false;
175
+ const suffix = command.slice(quotedIdx + normalizedPath.length + 2).trim().split(/\s+/);
176
+ return suffix[0] === String(PORT) && suffix[1] === route;
171
177
  }));
172
178
  }
173
179
 
@@ -196,6 +202,19 @@ function codexConfigLooksHealthy(content, port, codexHome) {
196
202
  return !!helperPath && existsSync(helperPath);
197
203
  }
198
204
 
205
+ function opencodeBridgeLooksHealthy() {
206
+ const bridgePath = join(opencodePluginDir, 'clideck-bridge.js');
207
+ if (!existsSync(bridgePath)) return false;
208
+ try {
209
+ const content = readFileSync(bridgePath, 'utf8');
210
+ return content.includes('/opencode-events')
211
+ && content.includes('CLIDECK_URL')
212
+ && content.includes('CLIDECK_PORT');
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
199
218
  function detectTelemetryConfig(c) {
200
219
  const port = String(PORT);
201
220
  let changed = false;
@@ -237,7 +256,7 @@ function detectTelemetryConfig(c) {
237
256
  if (!detected) reason = 'Needs re-patch';
238
257
  } catch {}
239
258
  } else if (preset.presetId === 'opencode') {
240
- detected = existsSync(join(opencodePluginDir, 'clideck-bridge.js')) || existsSync(join(opencodePluginDir, 'termix-bridge.js'));
259
+ detected = opencodeBridgeLooksHealthy();
241
260
  if (!detected) reason = 'Needs re-patch';
242
261
  } else { continue; }
243
262
  if (preset.available && preset.minVersion && !preset.versionOk) {
@@ -307,27 +326,16 @@ function onConnection(ws) {
307
326
  const transcript = require('./transcript');
308
327
  const sess = sessions.getSessions().get(msg.id);
309
328
  if (sess) {
310
- transcript.updateAgentCandidate(msg.id, sess.presetId, msg.lines);
311
- if (!sess.working && sess._finalizeOnIdle) {
312
- sess._finalizeOnIdle = false;
313
- // if (sess.presetId === 'claude-code') {
314
- // console.log(`[claude] terminal.buffer finalize session=${msg.id.slice(0,8)} lines=${msg.lines?.length || 0}`);
315
- // }
316
- transcript.commitAgentCandidate(msg.id, sess.presetId);
317
- }
318
- let choices = require('./transcript').detectMenu(msg.lines, sess.presetId);
329
+ const rawChoices = transcript.detectMenu(msg.lines, sess.presetId);
330
+ let choices = rawChoices;
319
331
  // Codex: only trust menu detection if last OTEL event was response.completed
320
332
  if (choices && sess.presetId === 'codex') {
321
333
  const last = require('./telemetry-receiver').getLastEvent(msg.id);
322
334
  if (!last.startsWith('codex.sse_event:response.completed')) {
323
- // console.log(`[codex] menu rejected — lastEvent=${last} session=${msg.id.slice(0,8)}`);
324
335
  choices = null;
325
- } else {
326
- // console.log(`[codex] menu accepted session=${msg.id.slice(0,8)}`);
327
336
  }
328
337
  }
329
338
  if (choices && sess.presetId === 'claude-code' && msg.menuVersion && (sess._menuConsumedVersion || 0) >= msg.menuVersion) {
330
- // console.log(`[claude] menu ignored stale version=${msg.menuVersion} consumed=${sess._menuConsumedVersion || 0} session=${msg.id.slice(0,8)}`);
331
339
  choices = null;
332
340
  }
333
341
  let key = choices ? JSON.stringify(choices) : '';
@@ -335,22 +343,27 @@ function onConnection(ws) {
335
343
  // Once that exact menu was approved, ignore repeated detections of the
336
344
  // same signature until the next real turn starts.
337
345
  if (choices && sess.presetId === 'claude-code' && key === (sess._resolvedMenuKey || '')) {
338
- // console.log(`[claude] menu ignored resolved key session=${msg.id.slice(0,8)}`);
339
346
  choices = null;
340
347
  key = '';
341
348
  }
349
+ const candidateLines = (choices || (rawChoices && sess.presetId === 'claude-code'))
350
+ ? transcript.stripMenu(msg.lines, sess.presetId)
351
+ : msg.lines;
352
+ transcript.updateAgentCandidate(msg.id, sess.presetId, candidateLines);
353
+ if (!sess.working && sess._finalizeOnIdle) {
354
+ sess._finalizeOnIdle = false;
355
+ transcript.commitAgentCandidate(msg.id, sess.presetId);
356
+ }
342
357
  // Auto-approve: send Enter immediately when menu detected
343
358
  if (choices && plugins.shouldAutoApproveMenu(msg.id)) {
344
359
  setTimeout(() => sessions.input({ id: msg.id, data: '\r' }), 500);
345
360
  }
361
+ if (choices) transcript.commitAgentCandidate(msg.id, sess.presetId);
346
362
  if (key !== (sess._menuKey || '')) {
347
363
  sess._menuKey = key;
348
364
  sessions.broadcast({ type: 'session.menu', id: msg.id, choices: choices || [] });
349
365
  if (choices) {
350
366
  if (sess.presetId === 'claude-code' && msg.menuVersion) sess._menuActiveVersion = msg.menuVersion;
351
- // if (sess.presetId === 'claude-code') {
352
- // console.log(`[claude] menu detected session=${msg.id.slice(0,8)} choices=${choices.length} version=${msg.menuVersion || 0}`);
353
- // }
354
367
  plugins.notifyMenu(msg.id, choices);
355
368
  if (sess.presetId === 'codex') require('./telemetry-receiver').cancelCodexMenuPoll(msg.id);
356
369
  sessions.broadcast({ type: 'session.status', id: msg.id, working: false, source: 'menu' });
@@ -560,7 +573,7 @@ function onConnection(ws) {
560
573
  try { ws.send(JSON.stringify({ type: 'remote.status', installed: true, ...JSON.parse(stdout) })); }
561
574
  catch { ws.send(JSON.stringify({ type: 'remote.status', installed: true })); }
562
575
  });
563
- checkRemoteUpdate(ws);
576
+ checkRemoteUpdate(ws, !!msg.forceUpdate);
564
577
  break;
565
578
  }
566
579
 
@@ -589,7 +602,25 @@ function onConnection(ws) {
589
602
  break;
590
603
  }
591
604
 
605
+ case 'remote.voice.transcribe': {
606
+ const requestId = String(msg.requestId || '');
607
+ const replyError = (error) => ws.send(JSON.stringify({ type: 'remote.voice.error', requestId, error }));
608
+ if (!plugins.hasCapability('voice-input', 'transcribeAudio')) {
609
+ replyError('Install the Voice Input plugin in CliDeck first.');
610
+ break;
611
+ }
612
+ if (typeof msg.audio !== 'string' || !msg.audio) {
613
+ replyError('No audio received.');
614
+ break;
615
+ }
616
+ plugins.invoke('voice-input', 'transcribeAudio', { audio: msg.audio })
617
+ .then(result => ws.send(JSON.stringify({ type: 'remote.voice.result', requestId, ...result })))
618
+ .catch(e => replyError(e.message || 'Voice transcription failed.'));
619
+ break;
620
+ }
621
+
592
622
  case 'remote.install': {
623
+ const update = !!msg.update;
593
624
  const proc = require('child_process').spawn('npm', ['install', '-g', 'clideck-remote'], {
594
625
  shell: true, stdio: ['ignore', 'pipe', 'pipe'],
595
626
  });
@@ -597,7 +628,19 @@ function onConnection(ws) {
597
628
  proc.stderr.on('data', d => ws.send(JSON.stringify({ type: 'remote.install.progress', text: d.toString() })));
598
629
  proc.on('close', code => {
599
630
  remoteUpdateCache = null;
600
- ws.send(JSON.stringify({ type: 'remote.install.done', success: code === 0 }));
631
+ if (code !== 0 || !update) {
632
+ ws.send(JSON.stringify({ type: 'remote.install.done', success: code === 0, update }));
633
+ return;
634
+ }
635
+ require('child_process').execFile('clideck-remote', ['restart', '--json'], { timeout: 10000, shell: process.platform === 'win32', env: remoteCliEnv() }, (err, stdout) => {
636
+ if (err) {
637
+ ws.send(JSON.stringify({ type: 'remote.install.done', success: false, update, error: err.message }));
638
+ return;
639
+ }
640
+ let restart = null;
641
+ try { restart = JSON.parse(stdout); } catch {}
642
+ ws.send(JSON.stringify({ type: 'remote.install.done', success: true, update, restart }));
643
+ });
601
644
  });
602
645
  break;
603
646
  }
@@ -2,7 +2,9 @@
2
2
  // Forwards session events to CliDeck server via HTTP POST.
3
3
  // Install: copy to ~/.config/opencode/plugins/clideck-bridge.js
4
4
 
5
- const CLIDECK_URL = "http://localhost:4000/opencode-events";
5
+ const env = globalThis.process?.env || {};
6
+ const baseUrl = env.CLIDECK_URL || `http://localhost:${env.CLIDECK_PORT || "4000"}`;
7
+ const CLIDECK_URL = `${baseUrl.replace(/\/$/, "")}/opencode-events`;
6
8
 
7
9
  function post(payload) {
8
10
  fetch(CLIDECK_URL, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clideck",
3
- "version": "1.31.11",
3
+ "version": "1.31.13",
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
@@ -67,6 +67,7 @@ let createSessionFn = null;
67
67
  let closeSessionFn = null;
68
68
  const settingsChangeHandlers = new Map(); // pluginId → [fn]
69
69
  const sessionPills = new Map(); // pillId → { pluginId, id, title, projectId, working, statusText, icon, logs[] }
70
+ const backendHandlers = new Map(); // pluginId.name → fn
70
71
 
71
72
  function removeHooks(pluginId) {
72
73
  for (const arr of [inputHooks, outputHooks, statusHooks, transcriptHooks, menuHooks, configHooks]) {
@@ -77,6 +78,9 @@ function removeHooks(pluginId) {
77
78
  for (const key of frontendHandlers.keys()) {
78
79
  if (key.startsWith(`plugin.${pluginId}.`)) frontendHandlers.delete(key);
79
80
  }
81
+ for (const key of backendHandlers.keys()) {
82
+ if (key.startsWith(`${pluginId}.`)) backendHandlers.delete(key);
83
+ }
80
84
  settingsChangeHandlers.delete(pluginId);
81
85
  for (const [id, pill] of sessionPills) {
82
86
  if (pill.pluginId === pluginId) {
@@ -202,6 +206,11 @@ function buildApi(pluginId, pluginDir, state) {
202
206
  onFrontendMessage(event, fn) {
203
207
  frontendHandlers.set(`plugin.${pluginId}.${event}`, fn);
204
208
  },
209
+ expose(name, fn) {
210
+ if (typeof name === 'string' && name && typeof fn === 'function') {
211
+ backendHandlers.set(`${pluginId}.${name}`, fn);
212
+ }
213
+ },
205
214
 
206
215
  getSession(id) {
207
216
  const s = sessionsFn?.()?.get(id);
@@ -423,6 +432,17 @@ function handleMessage(msg) {
423
432
  return true;
424
433
  }
425
434
 
435
+ function hasCapability(pluginId, name) {
436
+ return backendHandlers.has(`${pluginId}.${name}`);
437
+ }
438
+
439
+ async function invoke(pluginId, name, data = {}) {
440
+ const key = `${pluginId}.${name}`;
441
+ const fn = backendHandlers.get(key);
442
+ if (!fn) throw new Error(`Plugin capability not available: ${key}`);
443
+ return await fn(data);
444
+ }
445
+
426
446
  function getInfo() {
427
447
  const cfg = getConfigFn?.();
428
448
  const installed = [...plugins.values()].map(p => ({
@@ -561,6 +581,6 @@ module.exports = {
561
581
  PLUGINS_DIR, BUNDLED_IDS,
562
582
  init, shutdown,
563
583
  transformInput, notifyOutput, notifyStatus, notifyTranscript, notifyMenu, notifyConfig, clearStatus, isWorking, shouldAutoApproveMenu,
564
- handleMessage, updateSetting, getInfo, resolveFile, installPlugin, removePlugin,
584
+ handleMessage, hasCapability, invoke, updateSetting, getInfo, resolveFile, installPlugin, removePlugin,
565
585
  getPills, getPillLogs,
566
586
  };
@@ -244,28 +244,38 @@ module.exports = {
244
244
  return { text: data.text || '', language: data.language || 'unknown', avg_logprob: null };
245
245
  }
246
246
 
247
+ // --- Transcription API ---
248
+
249
+ async function transcribeAudio(audio) {
250
+ if (!api.getSetting('enabled')) {
251
+ throw new Error('Enable the Voice Input plugin in CliDeck first.');
252
+ }
253
+ const backend = api.getSetting('backend');
254
+ let result;
255
+ if (backend === 'local') {
256
+ if (!worker) await startLocal();
257
+ if (!worker) throw new Error('Local model not running. Enable plugin with local backend to start.');
258
+ result = await workerCmd('transcribe', {
259
+ audio,
260
+ lang: api.getSetting('language') || 'auto',
261
+ });
262
+ } else {
263
+ result = await transcribeOpenAI(audio);
264
+ }
265
+
266
+ const text = processText(result.text || '');
267
+ if (!text) return { text: '', skipped: true };
268
+ return { text, language: result.language, inferenceTime: result.inference_time };
269
+ }
270
+
271
+ api.expose('transcribeAudio', ({ audio }) => transcribeAudio(audio));
272
+
247
273
  // --- Message handlers ---
248
274
 
249
275
  api.onFrontendMessage('transcribe', async (msg) => {
250
- const backend = api.getSetting('backend');
251
276
  try {
252
- let result;
253
- if (backend === 'local') {
254
- if (!worker) { api.sendToFrontend('error', { error: 'Local model not running. Enable plugin with local backend to start.' }); return; }
255
- result = await workerCmd('transcribe', {
256
- audio: msg.audio,
257
- lang: api.getSetting('language') || 'auto',
258
- });
259
- } else {
260
- result = await transcribeOpenAI(msg.audio);
261
- }
262
-
263
- const text = processText(result.text || '');
264
- if (!text) {
265
- api.sendToFrontend('result', { text: '', skipped: true, sessionId: msg.sessionId });
266
- return;
267
- }
268
- api.sendToFrontend('result', { text, language: result.language, inferenceTime: result.inference_time, sessionId: msg.sessionId });
277
+ const result = await transcribeAudio(msg.audio);
278
+ api.sendToFrontend('result', { ...result, sessionId: msg.sessionId });
269
279
  } catch (e) {
270
280
  api.log(`transcribe: ${e.message}`);
271
281
  api.sendToFrontend('error', { error: e.message });
package/public/js/app.js CHANGED
@@ -45,7 +45,7 @@ function connect() {
45
45
  reconnectReplaySkip = new Set(state.terms.keys());
46
46
  setServerConnectionState(true);
47
47
  flushQueuedSends();
48
- send({ type: 'remote.status' });
48
+ send({ type: 'remote.status', forceUpdate: true });
49
49
  };
50
50
 
51
51
  state.ws.onmessage = ({ data }) => {
@@ -364,10 +364,13 @@ function connect() {
364
364
  appendInstallLog(msg.text);
365
365
  break;
366
366
  case 'remote.install.done':
367
- handleInstallDone(msg.success);
367
+ handleInstallDone(msg);
368
368
  break;
369
369
  case 'remote.update':
370
370
  remoteUpdateInfo = msg?.available ? msg : null;
371
+ if (remoteUpdateInfo?.available && remoteInstalled && !remoteInstallMode) {
372
+ startRemoteInstall({ update: true, auto: true });
373
+ }
371
374
  if (remotePreflight?.pending) {
372
375
  remotePreflight.updateSeen = true;
373
376
  finishRemotePreflight();
@@ -1161,6 +1164,7 @@ let remoteStatsTimer = null;
1161
1164
  let remoteUpdateInfo = null;
1162
1165
  let remotePreflight = null;
1163
1166
  let remoteLastStatus = null;
1167
+ let remoteInstallMode = null;
1164
1168
 
1165
1169
  function startRemotePoll() {
1166
1170
  stopRemotePoll();
@@ -1192,15 +1196,6 @@ function showRemoteIntro(opts = {}) {
1192
1196
  setRemotePane('intro');
1193
1197
  }
1194
1198
 
1195
- function showRemoteUpdateRequired() {
1196
- showRemoteIntro({
1197
- title: 'Update Required',
1198
- text: `Version ${remoteUpdateInfo.latest} is available. Update CliDeck Remote to continue with mobile pairing on this machine.`,
1199
- foot: `Installed: <code class="text-slate-500">${esc(remoteUpdateInfo.installed)}</code> · Latest: <code class="text-slate-500">${esc(remoteUpdateInfo.latest)}</code>`,
1200
- button: 'Update to Continue',
1201
- });
1202
- }
1203
-
1204
1199
  function finishRemotePreflight() {
1205
1200
  if (!remotePreflight?.pending || !remotePreflight.statusSeen || !remotePreflight.updateSeen) return;
1206
1201
  remotePreflight = null;
@@ -1209,7 +1204,7 @@ function finishRemotePreflight() {
1209
1204
  return;
1210
1205
  }
1211
1206
  if (remoteUpdateInfo?.available) {
1212
- showRemoteUpdateRequired();
1207
+ startRemoteInstall({ update: true, auto: true });
1213
1208
  return;
1214
1209
  }
1215
1210
  if (remoteState === 'idle') {
@@ -1368,8 +1363,9 @@ function handleRemoteStatus(msg) {
1368
1363
  stopRemotePoll();
1369
1364
  if (wasPaired) { stopRemoteStats(); setRemoteLock(false); }
1370
1365
  }
1371
- if (remoteUpdateInfo?.available && remoteModalOpen) {
1372
- showRemoteUpdateRequired();
1366
+ if (remoteUpdateInfo?.available && remoteInstalled && !remoteInstallMode) {
1367
+ startRemoteInstall({ update: true, auto: true });
1368
+ return;
1373
1369
  }
1374
1370
  updateRemoteButton();
1375
1371
  if (remotePreflight?.pending) {
@@ -1387,8 +1383,8 @@ function handleRemotePaired(msg) {
1387
1383
  else qrImg.classList.add('hidden');
1388
1384
  updateRemoteButton();
1389
1385
  startRemotePoll();
1390
- if (remoteUpdateInfo?.available && remoteModalOpen) {
1391
- showRemoteUpdateRequired();
1386
+ if (remoteUpdateInfo?.available && remoteInstalled && !remoteInstallMode) {
1387
+ startRemoteInstall({ update: true, auto: true });
1392
1388
  return;
1393
1389
  }
1394
1390
  if (remotePreflight?.pending) {
@@ -1422,17 +1418,39 @@ function appendInstallLog(text) {
1422
1418
  log.scrollTop = log.scrollHeight;
1423
1419
  }
1424
1420
 
1425
- function handleInstallDone(success) {
1421
+ function startRemoteInstall(opts = {}) {
1422
+ remoteInstallMode = { update: !!opts.update, auto: !!opts.auto };
1423
+ const log = document.getElementById('remote-install-log');
1424
+ log.textContent = '';
1425
+ if (remoteInstallMode.update) {
1426
+ appendInstallLog(`Updating clideck-remote to ${remoteUpdateInfo?.latest || 'latest'}...\n`);
1427
+ }
1428
+ setRemotePane('installing');
1429
+ if (!remoteModalOpen) openRemoteModal();
1430
+ send({ type: 'remote.install', update: remoteInstallMode.update });
1431
+ }
1432
+
1433
+ function handleInstallDone(msg) {
1434
+ const success = !!msg?.success;
1435
+ const wasUpdate = !!msg?.update || !!remoteInstallMode?.update;
1436
+ remoteInstallMode = null;
1426
1437
  if (success) {
1427
1438
  remoteInstalled = true;
1428
1439
  remoteUpdateInfo = null;
1440
+ if (wasUpdate) {
1441
+ remoteState = 'connecting';
1442
+ setRemotePane('connecting');
1443
+ send({ type: 'remote.status', forceUpdate: true });
1444
+ startRemotePoll();
1445
+ return;
1446
+ }
1429
1447
  // Installed — go straight to pairing
1430
1448
  remoteState = 'connecting';
1431
1449
  setRemotePane('connecting');
1432
1450
  send({ type: 'remote.pair' });
1433
1451
  } else {
1434
1452
  const log = document.getElementById('remote-install-log');
1435
- log.textContent += '\n— Install failed. Check permissions or run manually:\n npm install -g clideck-remote\n';
1453
+ log.textContent += `\n— ${msg?.error || 'Install failed'}. Check permissions or run manually:\n npm install -g clideck-remote\n`;
1436
1454
  log.scrollTop = log.scrollHeight;
1437
1455
  }
1438
1456
  }
@@ -1450,14 +1468,12 @@ btnRemote.addEventListener('click', () => {
1450
1468
  remotePreflight = { pending: true, statusSeen: false, updateSeen: false };
1451
1469
  setRemotePane('connecting');
1452
1470
  openRemoteModal();
1453
- send({ type: 'remote.status' });
1471
+ send({ type: 'remote.status', forceUpdate: true });
1454
1472
  });
1455
1473
 
1456
1474
  // Install button
1457
1475
  document.getElementById('remote-add').addEventListener('click', () => {
1458
- document.getElementById('remote-install-log').textContent = '';
1459
- setRemotePane('installing');
1460
- send({ type: 'remote.install' });
1476
+ startRemoteInstall({ update: !!(remoteInstalled && remoteUpdateInfo?.available) });
1461
1477
  });
1462
1478
 
1463
1479
  // Close / disconnect
package/server.js CHANGED
@@ -128,7 +128,6 @@ const server = http.createServer((req, res) => {
128
128
  const route = req.url.slice('/hook/codex/'.length);
129
129
  const clideckId = payload.clideck_id;
130
130
  const threadId = payload['thread-id'] || payload.session_id;
131
- // console.log(`[codex] notify clideck=${clideckId ? clideckId.slice(0,8) : 'none'} thread=${threadId ? threadId.slice(0,8) : 'none'}`);
132
131
  const allSessions = sessions.getSessions();
133
132
  let matchedId = null;
134
133
  if (clideckId && allSessions.has(clideckId)) {
@@ -148,7 +147,6 @@ const server = http.createServer((req, res) => {
148
147
  if (route === 'start') telemetry.markCodexStart(matchedId, 'hook');
149
148
  else if (route === 'stop') telemetry.armCodexStop(matchedId);
150
149
  }
151
- // if (!matchedId) console.log(`[codex] hook ${route} no match clideck=${clideckId ? clideckId.slice(0,8) : 'none'} thread=${threadId ? threadId.slice(0,8) : 'none'}`);
152
150
  } catch {}
153
151
  res.writeHead(200).end('{}');
154
152
  });
@@ -170,31 +168,23 @@ const server = http.createServer((req, res) => {
170
168
  : sessionId
171
169
  ? [...allSessions].find(([, s]) => s.sessionToken === sessionId)?.[0]
172
170
  : null;
173
- // console.log(`[claude] hook ${route} clideck=${payload.clideck_id?.slice(0,8) || 'none'} session=${sessionId?.slice(0,8) || 'none'} match=${clideckId?.slice(0,8) || 'none'}`);
174
171
  if (clideckId) {
175
172
  const sess = allSessions.get(clideckId);
176
173
  if (route === 'start') {
177
- // console.log(`[claude] status working=true source=hook session=${clideckId.slice(0,8)}`);
178
174
  sessions.broadcast({ type: 'session.status', id: clideckId, working: true, source: 'hook' });
179
175
  } else if (route === 'stop' || route === 'idle') {
180
- // console.log(`[claude] status working=false source=hook session=${clideckId.slice(0,8)}`);
181
176
  sessions.broadcast({ type: 'session.status', id: clideckId, working: false, source: 'hook' });
182
- // After an approval menu, Claude can already be idle before the real
183
- // stop hook arrives. In that case there is no new working→idle edge
184
- // on the client, so force one final capture from the true stop signal.
185
- if (route === 'stop' && sess && !sess.working) {
186
- // console.log(`[claude] stop capture session=${clideckId.slice(0,8)} source=claude-stop`);
177
+ // Stop and idle both mean Claude is settled enough to snapshot the
178
+ // visible transcript. Some Claude flows emit only the idle signal.
179
+ if ((route === 'stop' || route === 'idle') && sess && !sess.working) {
187
180
  setTimeout(() => sessions.broadcast({ type: 'terminal.capture', id: clideckId }), 500);
188
181
  }
189
182
  } else if (route === 'menu') {
190
183
  // PreToolUse: trigger terminal capture — detectMenu will set idle if a choice menu is visible
191
184
  const menuVersion = sess ? ((sess._menuVersion || 0) + 1) : 1;
192
185
  if (sess) sess._menuVersion = menuVersion;
193
- // console.log(`[claude] menu capture session=${clideckId.slice(0,8)} source=claude-menu version=${menuVersion}`);
194
186
  setTimeout(() => sessions.broadcast({ type: 'terminal.capture', id: clideckId, menuVersion }), 500);
195
187
  }
196
- } else {
197
- // console.log(`[claude] hook ${route} no-match`);
198
188
  }
199
189
  } catch {}
200
190
  res.writeHead(200).end('{}');
@@ -242,13 +232,13 @@ const server = http.createServer((req, res) => {
242
232
 
243
233
  // Session-to-session ask bridge used by the `clideck ask` CLI command.
244
234
  if (req.method === 'POST' && req.url === '/api/session/ask') {
245
- require('./session-ask').handleHttp(req, res, sessions);
235
+ require('./session-ask').handleHttp(req, res, sessions, () => config.load());
246
236
  return;
247
237
  }
248
238
 
249
239
  // Agent discovery bridge used by the `clideck agents` CLI command.
250
240
  if (req.method === 'GET' && req.url.startsWith('/api/session/agents')) {
251
- require('./session-agents').handleHttp(req, res, sessions);
241
+ require('./session-agents').handleHttp(req, res, sessions, () => config.load());
252
242
  return;
253
243
  }
254
244
 
package/session-agents.js CHANGED
@@ -12,7 +12,28 @@ function sameProject(a, b) {
12
12
  return (a.projectId || null) === (b.projectId || null);
13
13
  }
14
14
 
15
- function listProjectAgents(callerSessionId, sessionsApi) {
15
+ function projectName(projects, projectId) {
16
+ if (!projectId) return 'No project';
17
+ return projects.find(p => p.id === projectId)?.name || projectId;
18
+ }
19
+
20
+ function agentRow(id, s, callerId, projects) {
21
+ const project = projectName(projects, s.projectId);
22
+ return {
23
+ id,
24
+ name: s.name || id.slice(0, 8),
25
+ preset: s.presetId || 'shell',
26
+ projectId: s.projectId || null,
27
+ project,
28
+ address: s.projectId ? `@${project}/${s.name || id.slice(0, 8)}` : s.name || id.slice(0, 8),
29
+ working: !!s.working,
30
+ lastPreview: s.lastPreview || '',
31
+ lastActivityAt: s.lastActivityAt || null,
32
+ caller: id === callerId,
33
+ };
34
+ }
35
+
36
+ function listProjectAgents(callerSessionId, sessionsApi, cfg = {}, all = false) {
16
37
  const sessions = sessionsApi.getSessions();
17
38
  const callerId = String(callerSessionId || '').trim();
18
39
  const caller = sessions.get(callerId);
@@ -22,20 +43,13 @@ function listProjectAgents(callerSessionId, sessionsApi) {
22
43
  throw err;
23
44
  }
24
45
 
46
+ const projects = Array.isArray(cfg.projects) ? cfg.projects : [];
25
47
  return [...sessions]
26
- .filter(([, s]) => sameProject(caller, s))
27
- .map(([id, s]) => ({
28
- id,
29
- name: s.name || id.slice(0, 8),
30
- preset: s.presetId || 'shell',
31
- working: !!s.working,
32
- lastPreview: s.lastPreview || '',
33
- lastActivityAt: s.lastActivityAt || null,
34
- caller: id === callerId,
35
- }));
48
+ .filter(([, s]) => all || sameProject(caller, s))
49
+ .map(([id, s]) => agentRow(id, s, callerId, projects));
36
50
  }
37
51
 
38
- async function handleHttp(req, res, sessionsApi) {
52
+ async function handleHttp(req, res, sessionsApi, getConfig = () => ({})) {
39
53
  try {
40
54
  if (!isLoopback(req)) {
41
55
  const err = new Error('CliDeck agents only accepts local requests');
@@ -43,7 +57,8 @@ async function handleHttp(req, res, sessionsApi) {
43
57
  throw err;
44
58
  }
45
59
  const url = new URL(req.url, 'http://127.0.0.1');
46
- const agents = listProjectAgents(url.searchParams.get('callerSessionId'), sessionsApi);
60
+ const all = url.searchParams.get('all') === '1' || url.searchParams.get('all') === 'true';
61
+ const agents = listProjectAgents(url.searchParams.get('callerSessionId'), sessionsApi, getConfig() || {}, all);
47
62
  sendJson(res, 200, { agents });
48
63
  } catch (e) {
49
64
  sendJson(res, e.status || 500, { error: e.message || 'CliDeck agents failed' });
package/session-ask.js CHANGED
@@ -50,9 +50,62 @@ function sameProject(a, b) {
50
50
  return (a.projectId || null) === (b.projectId || null);
51
51
  }
52
52
 
53
- function findTarget(sessions, callerId, caller, target) {
53
+ function projectName(projects, projectId) {
54
+ if (!projectId) return 'No project';
55
+ return projects.find(p => p.id === projectId)?.name || projectId;
56
+ }
57
+
58
+ function parseScopedTarget(target) {
59
+ const text = String(target || '').trim();
60
+ if (!text.startsWith('@')) return null;
61
+ const slash = text.indexOf('/');
62
+ if (slash <= 1 || slash === text.length - 1) {
63
+ throw jsonError('Cross-project target must use @project/session');
64
+ }
65
+ return { project: text.slice(1, slash).trim(), session: text.slice(slash + 1).trim() };
66
+ }
67
+
68
+ function resolveProject(projects, nameOrId) {
69
+ const text = String(nameOrId || '').trim();
70
+ const byId = projects.filter(p => p.id === text);
71
+ if (byId.length === 1) return byId[0];
72
+ const exact = projects.filter(p => p.name === text);
73
+ if (exact.length === 1) return exact[0];
74
+ if (exact.length > 1) throw jsonError(`Multiple projects named "${text}". Use the project id.`, 409);
75
+ const lower = text.toLowerCase();
76
+ const insensitive = projects.filter(p => String(p.name || '').toLowerCase() === lower);
77
+ if (insensitive.length === 1) return insensitive[0];
78
+ if (insensitive.length > 1) throw jsonError(`Multiple projects named "${text}". Use the project id.`, 409);
79
+ throw jsonError(`No project named "${text}"`, 404);
80
+ }
81
+
82
+ function findInProject(candidates, target, projectLabel) {
83
+ const byId = candidates.filter(([id]) => id === target);
84
+ if (byId.length === 1) return byId[0];
85
+
86
+ const exact = candidates.filter(([, s]) => s.name === target);
87
+ if (exact.length === 1) return exact[0];
88
+ if (exact.length > 1) throw jsonError(`Multiple sessions named "${target}" in ${projectLabel}. Use the session id.`);
89
+
90
+ const lower = target.toLowerCase();
91
+ const insensitive = candidates.filter(([, s]) => String(s.name || '').toLowerCase() === lower);
92
+ if (insensitive.length === 1) return insensitive[0];
93
+ if (insensitive.length > 1) throw jsonError(`Multiple sessions named "${target}" in ${projectLabel}. Use the session id.`);
94
+ throw jsonError(`No session named "${target}" in ${projectLabel}`, 404);
95
+ }
96
+
97
+ function findTarget(sessions, callerId, caller, target, cfg = {}) {
54
98
  const trimmed = String(target || '').trim();
55
99
  if (!trimmed) throw jsonError('Target session is required');
100
+ const projects = Array.isArray(cfg.projects) ? cfg.projects : [];
101
+ const scoped = parseScopedTarget(trimmed);
102
+
103
+ if (scoped) {
104
+ const project = resolveProject(projects, scoped.project);
105
+ const projectSessions = [...sessions]
106
+ .filter(([id, s]) => id !== callerId && (s.projectId || null) === project.id);
107
+ return findInProject(projectSessions, scoped.session, `project "${project.name || project.id}"`);
108
+ }
56
109
 
57
110
  const byId = sessions.get(trimmed);
58
111
  if (byId) {
@@ -63,15 +116,7 @@ function findTarget(sessions, callerId, caller, target) {
63
116
 
64
117
  const sameProjectSessions = [...sessions]
65
118
  .filter(([id, s]) => id !== callerId && sameProject(caller, s));
66
- const exact = sameProjectSessions.filter(([, s]) => s.name === trimmed);
67
- if (exact.length === 1) return exact[0];
68
- if (exact.length > 1) throw jsonError(`Multiple sessions named "${trimmed}" in this project. Use the session id.`);
69
-
70
- const lower = trimmed.toLowerCase();
71
- const insensitive = sameProjectSessions.filter(([, s]) => String(s.name || '').toLowerCase() === lower);
72
- if (insensitive.length === 1) return insensitive[0];
73
- if (insensitive.length > 1) throw jsonError(`Multiple sessions named "${trimmed}" in this project. Use the session id.`);
74
- throw jsonError(`No session named "${trimmed}" in this project`, 404);
119
+ return findInProject(sameProjectSessions, trimmed, `project "${projectName(projects, caller.projectId)}"`);
75
120
  }
76
121
 
77
122
  function latestAgentTextSince(sessionId, sinceTs) {
@@ -167,13 +212,13 @@ function waitForAnswer({ sessionsApi, targetId, sinceTs, timeoutMs }) {
167
212
  });
168
213
  }
169
214
 
170
- async function askSession(payload, sessionsApi) {
215
+ async function askSession(payload, sessionsApi, cfg = {}) {
171
216
  const sessions = sessionsApi.getSessions();
172
217
  const callerId = String(payload.callerSessionId || '').trim();
173
218
  const caller = sessions.get(callerId);
174
219
  if (!caller) throw jsonError('Caller session is not active', 404);
175
220
 
176
- const [targetId, target] = findTarget(sessions, callerId, caller, payload.target);
221
+ const [targetId, target] = findTarget(sessions, callerId, caller, payload.target, cfg);
177
222
  if (target.working) {
178
223
  throw jsonError(`Target session "${target.name}" is busy. CliDeck ask only sends to idle sessions. Try again later, choose another idle session, or ask the user how to proceed.`, 409);
179
224
  }
@@ -193,11 +238,11 @@ async function askSession(payload, sessionsApi) {
193
238
  return { targetSessionId: targetId, targetName: target.name, response };
194
239
  }
195
240
 
196
- async function handleHttp(req, res, sessionsApi) {
241
+ async function handleHttp(req, res, sessionsApi, getConfig = () => ({})) {
197
242
  try {
198
243
  if (!isLoopback(req)) throw jsonError('CliDeck ask only accepts local requests', 403);
199
244
  const payload = await readJson(req);
200
- const result = await askSession(payload, sessionsApi);
245
+ const result = await askSession(payload, sessionsApi, getConfig() || {});
201
246
  sendJson(res, 200, result);
202
247
  } catch (e) {
203
248
  sendJson(res, e.status || 500, { error: e.message || 'CliDeck ask failed' });
package/transcript.js CHANGED
@@ -262,26 +262,67 @@ function clear(id) {
262
262
  // Finds the footer line, then walks upward collecting only the contiguous menu block.
263
263
  const MENU_MARKERS = { 'claude-code': /[❯›]/, codex: /[›❯]/, 'gemini-cli': /●/ };
264
264
  const MENU_CHOICE_RE = /^\s*(?:[│❯›●•]\s+)*(\d+)\.\s+(.+)$/;
265
- function detectMenu(lines, presetId) {
265
+ const MENU_TOP_RE = /^\s*[╭┌┏╔].*[╮┐┓╗]\s*$/;
266
+ const MENU_BOTTOM_RE = /^\s*[╰└┗╚].*[╯┘┛╝]\s*$/;
267
+ const MENU_RULE_RE = /^\s*[─━═-]{5,}\s*$/;
268
+ const TURN_MARKERS = {
269
+ 'claude-code': /^(?:[│ ]\s*)?[⏺•●❯›]\s/,
270
+ codex: /^(?:│\s*)?[•›]\s/,
271
+ 'gemini-cli': /^(?:✦| > )/,
272
+ };
273
+
274
+ function cleanMenuLabel(text) {
275
+ return String(text || '').replace(/[│┃║]\s*$/u, '').trim();
276
+ }
277
+
278
+ function detectMenuBlock(lines, presetId) {
266
279
  const marker = MENU_MARKERS[presetId];
267
280
  if (!marker) return null;
268
281
  // Only scan the bottom 40 lines — menus are always near the visible area
269
282
  const scanStart = Math.max(0, lines.length - 40);
270
- let footerIdx = -1;
283
+ let footerLineIdx = -1;
271
284
  for (let i = lines.length - 1; i >= scanStart; i--) {
272
- if (/\besc\b|\(esc\)/i.test(lines[i])) { footerIdx = MENU_CHOICE_RE.test(lines[i]) ? i + 1 : i; break; }
285
+ if (/\besc\b|\(esc\)/i.test(lines[i])) { footerLineIdx = i; break; }
273
286
  }
274
- if (footerIdx < 0) return null;
287
+ if (footerLineIdx < 0) return null;
275
288
  const choices = [];
276
- for (let i = footerIdx - 1; i >= scanStart; i--) {
289
+ let firstChoiceIdx = -1;
290
+ const searchFrom = MENU_CHOICE_RE.test(lines[footerLineIdx]) ? footerLineIdx : footerLineIdx - 1;
291
+ for (let i = searchFrom; i >= scanStart; i--) {
277
292
  if (!lines[i].trim() || /^[│\s]+$/.test(lines[i])) continue;
293
+ if (MENU_RULE_RE.test(lines[i]) || MENU_BOTTOM_RE.test(lines[i])) continue;
278
294
  const m = lines[i].match(MENU_CHOICE_RE);
279
- if (!m) { if (/^\s{2,}\S/.test(lines[i])) continue; break; }
295
+ if (!m) { if (choices.length && /^\s{2,}\S/.test(lines[i])) continue; break; }
280
296
  if (choices.length && +m[1] >= +choices[0].value) break;
281
- choices.unshift({ value: m[1], label: m[2].trim(), selected: marker.test(lines[i]) });
297
+ choices.unshift({ value: m[1], label: cleanMenuLabel(m[2]), selected: marker.test(lines[i]) });
298
+ firstChoiceIdx = i;
282
299
  }
283
300
  if (!choices.some(c => c.selected)) return null;
284
- return choices.length ? choices : null;
301
+ if (!choices.length) return null;
302
+ let startIdx = firstChoiceIdx;
303
+ const turnMarker = TURN_MARKERS[presetId];
304
+ for (let i = startIdx - 1; i >= scanStart; i--) {
305
+ if (turnMarker?.test(lines[i])) break;
306
+ if (lines[i].trim()) startIdx = i;
307
+ if (MENU_TOP_RE.test(lines[i])) { startIdx = i; break; }
308
+ }
309
+ let endIdx = footerLineIdx;
310
+ if (MENU_CHOICE_RE.test(lines[footerLineIdx])) {
311
+ for (let i = footerLineIdx + 1; i < Math.min(lines.length, footerLineIdx + 6); i++) {
312
+ if (MENU_BOTTOM_RE.test(lines[i])) { endIdx = i; break; }
313
+ }
314
+ }
315
+ return { choices, startIdx, endIdx };
316
+ }
317
+
318
+ function detectMenu(lines, presetId) {
319
+ return detectMenuBlock(lines, presetId)?.choices || null;
320
+ }
321
+
322
+ function stripMenu(lines, presetId) {
323
+ const block = detectMenuBlock(lines, presetId);
324
+ if (!block) return lines;
325
+ return lines.filter((_, i) => i < block.startIdx || i > block.endIdx);
285
326
  }
286
327
 
287
- module.exports = { init, trackInput, recordInjectedInput, trackOutput, updateAgentCandidate, commitAgentCandidate, clearAgentCandidate, parseTurnsFromLines, getTurns, getEntriesSince, getCache, getReplayText, clear, setPrefix, setFinalizeOnIdle, detectMenu };
328
+ module.exports = { init, trackInput, recordInjectedInput, trackOutput, updateAgentCandidate, commitAgentCandidate, clearAgentCandidate, parseTurnsFromLines, getTurns, getEntriesSince, getCache, getReplayText, clear, setPrefix, setFinalizeOnIdle, detectMenu, stripMenu };