clideck 1.30.4 → 1.30.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,14 +45,20 @@ Or just run it once with `npx clideck`. Works on macOS and Windows. Node 18+. Li
45
45
 
46
46
  **Session resume** - close the lid, reopen tomorrow, pick up where things left off. each agent's session ID is captured automatically.
47
47
 
48
- **Roles** - give agents reusable identities like programmer, reviewer, or product manager. prompts are injected automatically when a session starts.
49
-
50
48
  **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.
51
49
 
52
50
  <p align="center">
53
51
  <img src="assets/autopilot.gif" width="720" alt="Autopilot routing work between agents">
54
52
  </p>
55
53
 
54
+ **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:
55
+
56
+ ```bash
57
+ clideck ask --session "Reviewer" --message "Review this output and return findings." --timeout 10m
58
+ ```
59
+
60
+ 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.
61
+
56
62
  **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.
57
63
 
58
64
  **Native terminals** - each session opens into its real terminal. keys go straight to the agent, nothing sits in the middle.
@@ -90,6 +90,7 @@
90
90
  "name": "Clideck Agent",
91
91
  "icon": "/img/clideck-agent.svg",
92
92
  "command": "clideck-agent",
93
+ "enabledIfEnv": "CLIDECK_ENABLE_CLIDECK_AGENT",
93
94
  "isAgent": true,
94
95
  "canResume": true,
95
96
  "resumeCommand": "clideck-agent --resume {{sessionId}}",
package/bin/clideck.js CHANGED
@@ -1,2 +1,8 @@
1
1
  #!/usr/bin/env node
2
- require('../server.js');
2
+ const args = process.argv.slice(2);
3
+
4
+ if (args[0] === 'ask') {
5
+ require('../clideck-ask-cli').run(args.slice(1));
6
+ } else {
7
+ require('../server.js');
8
+ }
@@ -0,0 +1,110 @@
1
+ const http = require('http');
2
+ const https = require('https');
3
+
4
+ function usage() {
5
+ return [
6
+ 'Usage:',
7
+ ' clideck ask --session <name-or-id> --message <text> [--timeout 10m]',
8
+ ' clideck ask <name-or-id> <message> [--timeout 10m]',
9
+ ].join('\n');
10
+ }
11
+
12
+ function parseDuration(value) {
13
+ if (!value) return 10 * 60 * 1000;
14
+ const m = String(value).trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/i);
15
+ if (!m) return null;
16
+ const n = Number(m[1]);
17
+ const unit = (m[2] || 'ms').toLowerCase();
18
+ const scale = unit === 'h' ? 3600000 : unit === 'm' ? 60000 : unit === 's' ? 1000 : 1;
19
+ return Math.max(1, Math.round(n * scale));
20
+ }
21
+
22
+ function parseArgs(args) {
23
+ const out = { timeoutMs: 10 * 60 * 1000, url: process.env.CLIDECK_URL || 'http://127.0.0.1:4000' };
24
+ const positional = [];
25
+ for (let i = 0; i < args.length; i++) {
26
+ const arg = args[i];
27
+ if (arg === '--session' || arg === '-s') out.session = args[++i];
28
+ else if (arg === '--message' || arg === '-m') out.message = args[++i];
29
+ else if (arg === '--timeout' || arg === '-t') {
30
+ const parsed = parseDuration(args[++i]);
31
+ if (!parsed) throw new Error('Invalid timeout value');
32
+ out.timeoutMs = parsed;
33
+ } else if (arg === '--url') out.url = args[++i];
34
+ else if (arg === '--help' || arg === '-h') out.help = true;
35
+ else positional.push(arg);
36
+ }
37
+ if (!out.session && positional.length) out.session = positional.shift();
38
+ if (!out.message && positional.length) out.message = positional.join(' ');
39
+ return out;
40
+ }
41
+
42
+ function readStdinIfAvailable() {
43
+ return new Promise((resolve) => {
44
+ if (process.stdin.isTTY) return resolve('');
45
+ let data = '';
46
+ process.stdin.setEncoding('utf8');
47
+ process.stdin.on('data', chunk => { data += chunk; });
48
+ process.stdin.on('end', () => resolve(data));
49
+ });
50
+ }
51
+
52
+ function postJson(url, payload, timeoutMs) {
53
+ return new Promise((resolve, reject) => {
54
+ const target = new URL('/api/session/ask', url);
55
+ const body = JSON.stringify(payload);
56
+ const client = target.protocol === 'https:' ? https : http;
57
+ const req = client.request(target, {
58
+ method: 'POST',
59
+ headers: {
60
+ 'Content-Type': 'application/json',
61
+ 'Content-Length': Buffer.byteLength(body),
62
+ },
63
+ timeout: timeoutMs + 5000,
64
+ }, (res) => {
65
+ let data = '';
66
+ res.setEncoding('utf8');
67
+ res.on('data', chunk => { data += chunk; });
68
+ res.on('end', () => {
69
+ let parsed = {};
70
+ try { parsed = data ? JSON.parse(data) : {}; } catch {}
71
+ if (res.statusCode >= 400) {
72
+ const err = new Error(parsed.error || `CliDeck ask failed (${res.statusCode})`);
73
+ err.statusCode = res.statusCode;
74
+ return reject(err);
75
+ }
76
+ resolve(parsed);
77
+ });
78
+ });
79
+ req.on('timeout', () => req.destroy(new Error('CliDeck ask timed out')));
80
+ req.on('error', reject);
81
+ req.end(body);
82
+ });
83
+ }
84
+
85
+ async function run(args) {
86
+ try {
87
+ const opts = parseArgs(args);
88
+ if (opts.help) {
89
+ console.log(usage());
90
+ return;
91
+ }
92
+ if (!opts.message) opts.message = (await readStdinIfAvailable()).trim();
93
+ if (!opts.session || !opts.message) throw new Error(usage());
94
+ const callerSessionId = process.env.CLIDECK_SESSION_ID || '';
95
+ if (!callerSessionId) throw new Error('CLIDECK_SESSION_ID is missing. Run this from inside a CliDeck session.');
96
+
97
+ const res = await postJson(opts.url, {
98
+ callerSessionId,
99
+ target: opts.session,
100
+ message: opts.message,
101
+ timeoutMs: opts.timeoutMs,
102
+ }, opts.timeoutMs);
103
+ process.stdout.write((res.response || '').trimEnd() + '\n');
104
+ } catch (e) {
105
+ process.stderr.write(`${e.message}\n`);
106
+ process.exitCode = 1;
107
+ }
108
+ }
109
+
110
+ module.exports = { run, parseArgs, parseDuration };
package/config.js CHANGED
@@ -95,6 +95,12 @@ function deepCopy(obj) { return JSON.parse(JSON.stringify(obj)); }
95
95
 
96
96
  const PRESETS = JSON.parse(readFileSync(join(__dirname, 'agent-presets.json'), 'utf8'));
97
97
  for (const p of PRESETS) if (p.presetId === 'shell') p.command = defaultShell;
98
+ function isPresetEnabled(preset) {
99
+ if (!preset?.enabledIfEnv) return true;
100
+ const value = String(process.env[preset.enabledIfEnv] || '').trim().toLowerCase();
101
+ return value === '1' || value === 'true' || value === 'yes';
102
+ }
103
+ const EXPOSED_PRESETS = PRESETS.filter(isPresetEnabled);
98
104
 
99
105
  function matchPreset(cmd) {
100
106
  const bin = binName(cmd.command);
@@ -140,7 +146,7 @@ function migrate(cfg) {
140
146
  }
141
147
  }
142
148
  // Auto-add any shipped presets not yet in the commands list
143
- for (const preset of PRESETS) {
149
+ for (const preset of EXPOSED_PRESETS) {
144
150
  const exists = cfg.commands.some(c => c.presetId === preset.presetId || matchPreset(c)?.presetId === preset.presetId);
145
151
  if (!exists) {
146
152
  cfg.commands.push({
package/handlers.js CHANGED
@@ -8,6 +8,18 @@ const themes = require('./themes');
8
8
  const presets = JSON.parse(readFileSync(join(__dirname, 'agent-presets.json'), 'utf8'));
9
9
  const { listDirs, binName, defaultShell } = require('./utils');
10
10
  for (const p of presets) if (p.presetId === 'shell') p.command = defaultShell;
11
+ function isPresetEnabled(preset) {
12
+ if (!preset?.enabledIfEnv) return true;
13
+ const value = String(process.env[preset.enabledIfEnv] || '').trim().toLowerCase();
14
+ return value === '1' || value === 'true' || value === 'yes';
15
+ }
16
+ function clientPresets() {
17
+ return presets.filter(isPresetEnabled);
18
+ }
19
+ function filterClientCommands(commands) {
20
+ const allowedPresetIds = new Set(clientPresets().map(p => p.presetId));
21
+ return (commands || []).filter(cmd => !cmd.presetId || allowedPresetIds.has(cmd.presetId));
22
+ }
11
23
  const transcript = require('./transcript');
12
24
  const plugins = require('./plugin-loader');
13
25
  const { upsertCodexConfig, validateCodexConfigToml } = require('./codex-config');
@@ -87,6 +99,7 @@ function checkRemoteUpdate(ws) {
87
99
  const whichCmd = process.platform === 'win32' ? 'where' : 'which';
88
100
  function checkAvailability() {
89
101
  for (const p of presets) {
102
+ if (!isPresetEnabled(p)) continue;
90
103
  if (p.presetId === 'shell') { p.available = true; p.version = ''; p.versionOk = true; p.health = { ok: true }; continue; }
91
104
  const bin = binName(p.command);
92
105
  try {
@@ -209,7 +222,7 @@ function detectTelemetryConfig(c) {
209
222
  const appVersion = require('./package.json').version;
210
223
 
211
224
  function configForClient() {
212
- return { ...cfg, pluginsDir: plugins.PLUGINS_DIR, version: appVersion };
225
+ return { ...cfg, commands: filterClientCommands(cfg.commands), pluginsDir: plugins.PLUGINS_DIR, version: appVersion };
213
226
  }
214
227
 
215
228
  function onConnection(ws) {
@@ -217,7 +230,7 @@ function onConnection(ws) {
217
230
 
218
231
  ws.send(JSON.stringify({ type: 'config', config: configForClient() }));
219
232
  ws.send(JSON.stringify({ type: 'themes', themes }));
220
- ws.send(JSON.stringify({ type: 'presets', presets }));
233
+ ws.send(JSON.stringify({ type: 'presets', presets: clientPresets() }));
221
234
  ws.send(JSON.stringify({ type: 'sessions', list: sessions.list() }));
222
235
  ws.send(JSON.stringify({ type: 'sessions.resumable', list: sessions.getResumable(cfg) }));
223
236
  ws.send(JSON.stringify({ type: 'transcript.cache', cache: transcript.getCache() }));
@@ -306,7 +319,7 @@ function onConnection(ws) {
306
319
  case 'checkAvailability':
307
320
  checkAvailability();
308
321
  if (detectTelemetryConfig(cfg)) config.save(cfg);
309
- ws.send(JSON.stringify({ type: 'presets', presets }));
322
+ ws.send(JSON.stringify({ type: 'presets', presets: clientPresets() }));
310
323
  break;
311
324
 
312
325
  case 'config.update':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clideck",
3
- "version": "1.30.4",
3
+ "version": "1.30.6",
4
4
  "description": "One screen for all your AI coding agents — run, monitor, and manage multiple CLI agents from a single browser tab",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -104,33 +104,36 @@ function sortedPresets() {
104
104
 
105
105
  function createFromPreset(preset, sessionName, cwd, projectId) {
106
106
  // Find existing command matching this preset
107
- let cmd = findCommandForPreset(preset);
108
- // Auto-create the command if it doesn't exist yet
109
- if (!cmd) {
110
- cmd = {
111
- id: crypto.randomUUID(),
112
- presetId: preset.presetId,
113
- label: preset.name,
114
- icon: preset.icon,
115
- command: preset.command,
116
- enabled: true,
117
- defaultPath: '',
118
- isAgent: preset.isAgent,
119
- canResume: preset.canResume,
120
- resumeCommand: preset.resumeCommand,
121
- sessionIdPattern: preset.sessionIdPattern,
122
- outputMarker: preset.outputMarker || null,
123
- telemetryEnabled: telemetryEnabledForPreset(preset),
124
- telemetryStatus: null,
125
- bridge: preset.bridge,
126
- };
127
- state.cfg.commands.push(cmd);
128
- send({ type: 'config.update', config: state.cfg });
129
- }
107
+ const cmd = ensureCommandForPreset(preset);
130
108
  send({ type: 'create', commandId: cmd.id, name: sessionName, cwd, projectId: projectId || undefined, ...estimateSize() });
131
109
  localStorage.setItem(MRU_KEY, preset.presetId);
132
110
  }
133
111
 
112
+ function ensureCommandForPreset(preset) {
113
+ let cmd = findCommandForPreset(preset);
114
+ if (cmd) return cmd;
115
+ cmd = {
116
+ id: crypto.randomUUID(),
117
+ presetId: preset.presetId,
118
+ label: preset.name,
119
+ icon: preset.icon,
120
+ command: preset.command,
121
+ enabled: true,
122
+ defaultPath: '',
123
+ isAgent: preset.isAgent,
124
+ canResume: preset.canResume,
125
+ resumeCommand: preset.resumeCommand,
126
+ sessionIdPattern: preset.sessionIdPattern,
127
+ outputMarker: preset.outputMarker || null,
128
+ telemetryEnabled: telemetryEnabledForPreset(preset),
129
+ telemetryStatus: null,
130
+ bridge: preset.bridge,
131
+ };
132
+ state.cfg.commands.push(cmd);
133
+ send({ type: 'config.update', config: state.cfg });
134
+ return cmd;
135
+ }
136
+
134
137
  export function openCreator() {
135
138
  // Toggle off if already open
136
139
  if (document.getElementById('session-creator')) {
@@ -273,12 +276,7 @@ export function openCreator() {
273
276
  if (setupBtn) {
274
277
  const preset = state.presets.find(p => p.presetId === setupBtn.dataset.preset);
275
278
  if (!preset) return;
276
- let cmd = findCommandForPreset(preset);
277
- if (!cmd) {
278
- cmd = { id: crypto.randomUUID(), presetId: preset.presetId, label: preset.name, icon: preset.icon, command: preset.command, enabled: true, defaultPath: '', isAgent: preset.isAgent, canResume: preset.canResume, resumeCommand: preset.resumeCommand, sessionIdPattern: preset.sessionIdPattern, outputMarker: preset.outputMarker || null, telemetryEnabled: telemetryEnabledForPreset(preset), telemetryStatus: null, bridge: preset.bridge };
279
- state.cfg.commands.push(cmd);
280
- send({ type: 'config.update', config: state.cfg });
281
- }
279
+ const cmd = ensureCommandForPreset(preset);
282
280
  document.dispatchEvent(new CustomEvent('clideck:setup', { detail: { commandId: cmd.id } }));
283
281
  return;
284
282
  }
@@ -345,6 +343,10 @@ function showInstallToast(preset) {
345
343
  toast.querySelector('.add-btn').onclick = () => {
346
344
  dismiss();
347
345
  closeCreator();
346
+ ensureCommandForPreset(preset);
347
+ if (preset.telemetryAutoSetup) {
348
+ setTimeout(() => send({ type: 'telemetry.autosetup', presetId: preset.presetId }), 1000);
349
+ }
348
350
  // Find or create the shell command, then spawn a session running the install
349
351
  const shellCmd = state.cfg.commands.find(c => c.presetId === 'shell' || (!c.isAgent && !c.presetId));
350
352
  if (!shellCmd) return;
package/public/js/drag.js CHANGED
@@ -21,6 +21,7 @@ export function initDrag() {
21
21
  // Project drag — grab by the header
22
22
  const projHeader = e.target.closest('.project-header');
23
23
  if (projHeader) {
24
+ if (document.querySelectorAll('.project-group').length <= 1) return;
24
25
  const group = projHeader.closest('.project-group');
25
26
  const rect = group.getBoundingClientRect();
26
27
  dragState = {
package/server.js CHANGED
@@ -4,6 +4,10 @@ const { join, extname, resolve } = require('path');
4
4
  const { WebSocketServer } = require('ws');
5
5
  const { ensurePtyHelper } = require('./utils');
6
6
 
7
+ function terminalLink(url, text = url) {
8
+ return `\u001B]8;;${url}\u0007${text}\u001B]8;;\u0007`;
9
+ }
10
+
7
11
  // --- Self-update check (runs before server starts) ---
8
12
  const currentVersion = require('./package.json').version;
9
13
  const { execFile, execSync } = require('child_process');
@@ -222,6 +226,12 @@ const server = http.createServer((req, res) => {
222
226
  return;
223
227
  }
224
228
 
229
+ // Session-to-session ask bridge used by the `clideck ask` CLI command.
230
+ if (req.method === 'POST' && req.url === '/api/session/ask') {
231
+ require('./session-ask').handleHttp(req, res, sessions);
232
+ return;
233
+ }
234
+
225
235
  // DEBUG: log any POST (agents might use /v1/traces, /v1/metrics, or other paths)
226
236
  if (req.method === 'POST') {
227
237
  // console.log(`OTLP: received POST ${req.url} (not handled)`);
@@ -280,6 +290,7 @@ process.on('SIGTERM', onShutdown);
280
290
  server.listen(PORT, HOST, () => {
281
291
  const v = require('./package.json').version;
282
292
  const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`;
293
+ const clickableUrl = terminalLink(url);
283
294
  console.log(`
284
295
  \x1b[38;5;105m ╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸\x1b[0m
285
296
 
@@ -294,7 +305,7 @@ server.listen(PORT, HOST, () => {
294
305
 
295
306
  \x1b[38;5;245m v${v}\x1b[0m
296
307
 
297
- \x1b[38;5;252m ▸ Ready at \x1b[38;5;44m${url}\x1b[0m
308
+ \x1b[38;5;252m ▸ Ready at \x1b[38;5;44m${clickableUrl}\x1b[38;5;245m (Cmd+click to open)\x1b[0m
298
309
  \x1b[38;5;245m ▸ Stop with \x1b[38;5;252mCtrl+C\x1b[38;5;245m · Restart anytime with \x1b[38;5;252mclideck\x1b[0m
299
310
  ${HOST !== '127.0.0.1' ? '\x1b[38;5;208m ▸ Warning: listening on ' + HOST + ' — no authentication, anyone on the network can connect\x1b[0m\n' : ''}`);
300
311
  });
package/session-ask.js ADDED
@@ -0,0 +1,171 @@
1
+ const transcript = require('./transcript');
2
+
3
+ const MAX_BODY = 2 * 1024 * 1024;
4
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
5
+ const MAX_TIMEOUT_MS = 60 * 60 * 1000;
6
+
7
+ function sendJson(res, status, payload) {
8
+ res.writeHead(status, { 'Content-Type': 'application/json' });
9
+ res.end(JSON.stringify(payload));
10
+ }
11
+
12
+ function readJson(req) {
13
+ return new Promise((resolve, reject) => {
14
+ let body = '';
15
+ req.on('data', chunk => {
16
+ body += chunk;
17
+ if (body.length > MAX_BODY) {
18
+ req.destroy();
19
+ reject(new Error('Request too large'));
20
+ }
21
+ });
22
+ req.on('end', () => {
23
+ try { resolve(body ? JSON.parse(body) : {}); }
24
+ catch { reject(new Error('Invalid JSON')); }
25
+ });
26
+ req.on('error', reject);
27
+ });
28
+ }
29
+
30
+ function jsonError(message, status = 400) {
31
+ const err = new Error(message);
32
+ err.status = status;
33
+ return err;
34
+ }
35
+
36
+ function isLoopback(req) {
37
+ const addr = req.socket?.remoteAddress || '';
38
+ return addr === '::1' || addr === '127.0.0.1' || addr.startsWith('127.') || addr.startsWith('::ffff:127.');
39
+ }
40
+
41
+ function normalizeTimeout(ms) {
42
+ const n = Number(ms || DEFAULT_TIMEOUT_MS);
43
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_MS;
44
+ return Math.min(Math.round(n), MAX_TIMEOUT_MS);
45
+ }
46
+
47
+ function sameProject(a, b) {
48
+ return (a.projectId || null) === (b.projectId || null);
49
+ }
50
+
51
+ function findTarget(sessions, callerId, caller, target) {
52
+ const trimmed = String(target || '').trim();
53
+ if (!trimmed) throw jsonError('Target session is required');
54
+
55
+ const byId = sessions.get(trimmed);
56
+ if (byId) {
57
+ if (trimmed === callerId) throw jsonError('Target session cannot be the caller session');
58
+ if (!sameProject(caller, byId)) throw jsonError('Target session is not in the caller project', 404);
59
+ return [trimmed, byId];
60
+ }
61
+
62
+ const sameProjectSessions = [...sessions]
63
+ .filter(([id, s]) => id !== callerId && sameProject(caller, s));
64
+ const exact = sameProjectSessions.filter(([, s]) => s.name === trimmed);
65
+ if (exact.length === 1) return exact[0];
66
+ if (exact.length > 1) throw jsonError(`Multiple sessions named "${trimmed}" in this project. Use the session id.`);
67
+
68
+ const lower = trimmed.toLowerCase();
69
+ const insensitive = sameProjectSessions.filter(([, s]) => String(s.name || '').toLowerCase() === lower);
70
+ if (insensitive.length === 1) return insensitive[0];
71
+ if (insensitive.length > 1) throw jsonError(`Multiple sessions named "${trimmed}" in this project. Use the session id.`);
72
+ throw jsonError(`No session named "${trimmed}" in this project`, 404);
73
+ }
74
+
75
+ function latestAgentTextSince(sessionId, sinceTs) {
76
+ const entries = transcript.getEntriesSince(sessionId, sinceTs)
77
+ .filter(e => e.role === 'agent' && String(e.text || '').trim());
78
+ return entries.length ? entries[entries.length - 1].text : '';
79
+ }
80
+
81
+ function waitForAnswer({ sessionsApi, targetId, sinceTs, timeoutMs }) {
82
+ const sessions = sessionsApi.getSessions();
83
+ const target = sessions.get(targetId);
84
+ let sawWorking = !!target?.working;
85
+ let settled = false;
86
+ let quietTimer = null;
87
+ let removeListener = null;
88
+ let timeout = null;
89
+
90
+ return new Promise((resolve, reject) => {
91
+ const cleanup = () => {
92
+ if (timeout) clearTimeout(timeout);
93
+ if (quietTimer) clearTimeout(quietTimer);
94
+ if (removeListener) removeListener();
95
+ };
96
+ const finish = () => {
97
+ if (settled) return;
98
+ const response = latestAgentTextSince(targetId, sinceTs);
99
+ if (!response) return;
100
+ settled = true;
101
+ cleanup();
102
+ resolve(response);
103
+ };
104
+ timeout = setTimeout(() => {
105
+ if (settled) return;
106
+ settled = true;
107
+ cleanup();
108
+ reject(jsonError('Timed out waiting for target session response', 504));
109
+ }, timeoutMs);
110
+
111
+ removeListener = sessionsApi.addBroadcastListener((msg) => {
112
+ if (msg.id !== targetId) return;
113
+ if (msg.type === 'session.status') {
114
+ if (msg.working) {
115
+ sawWorking = true;
116
+ return;
117
+ }
118
+ if (sawWorking) {
119
+ sessionsApi.broadcast({ type: 'terminal.capture', id: targetId });
120
+ setTimeout(finish, 700);
121
+ }
122
+ } else if (msg.type === 'output') {
123
+ if (quietTimer) clearTimeout(quietTimer);
124
+ quietTimer = setTimeout(() => {
125
+ if (!sessions.get(targetId)?.working) {
126
+ sessionsApi.broadcast({ type: 'terminal.capture', id: targetId });
127
+ setTimeout(finish, 700);
128
+ }
129
+ }, 2500);
130
+ }
131
+ });
132
+ });
133
+ }
134
+
135
+ async function askSession(payload, sessionsApi) {
136
+ const sessions = sessionsApi.getSessions();
137
+ const callerId = String(payload.callerSessionId || '').trim();
138
+ const caller = sessions.get(callerId);
139
+ if (!caller) throw jsonError('Caller session is not active', 404);
140
+
141
+ const [targetId, target] = findTarget(sessions, callerId, caller, payload.target);
142
+ if (target.working) throw jsonError(`Target session "${target.name}" is already working`, 409);
143
+
144
+ const message = String(payload.message || '').trim();
145
+ if (!message) throw jsonError('Message is required');
146
+
147
+ const timeoutMs = normalizeTimeout(payload.timeoutMs);
148
+ const sinceTs = Date.now();
149
+ const injected = `[CliDeck ask from ${caller.name || callerId.slice(0, 8)}]\n\n${message}`;
150
+
151
+ console.log(`[ask] ${caller.name || callerId.slice(0, 8)} -> ${target.name || targetId.slice(0, 8)} (${timeoutMs}ms timeout)`);
152
+ sessionsApi.input({ id: targetId, data: injected });
153
+ setTimeout(() => sessionsApi.input({ id: targetId, data: '\r' }), 150);
154
+
155
+ const response = await waitForAnswer({ sessionsApi, targetId, sinceTs, timeoutMs });
156
+ console.log(`[ask] completed ${target.name || targetId.slice(0, 8)} -> ${caller.name || callerId.slice(0, 8)}`);
157
+ return { targetSessionId: targetId, targetName: target.name, response };
158
+ }
159
+
160
+ async function handleHttp(req, res, sessionsApi) {
161
+ try {
162
+ if (!isLoopback(req)) throw jsonError('CliDeck ask only accepts local requests', 403);
163
+ const payload = await readJson(req);
164
+ const result = await askSession(payload, sessionsApi);
165
+ sendJson(res, 200, result);
166
+ } catch (e) {
167
+ sendJson(res, e.status || 500, { error: e.message || 'CliDeck ask failed' });
168
+ }
169
+ }
170
+
171
+ module.exports = { handleHttp, askSession };
package/sessions.js CHANGED
@@ -24,7 +24,13 @@ let resumable = [];
24
24
 
25
25
  const broadcastListeners = [];
26
26
 
27
- function addBroadcastListener(fn) { broadcastListeners.push(fn); }
27
+ function addBroadcastListener(fn) {
28
+ broadcastListeners.push(fn);
29
+ return () => {
30
+ const idx = broadcastListeners.indexOf(fn);
31
+ if (idx >= 0) broadcastListeners.splice(idx, 1);
32
+ };
33
+ }
28
34
 
29
35
  function broadcast(msg) {
30
36
  const raw = JSON.stringify(msg);