clideck 1.30.5 → 1.30.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
@@ -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.
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
@@ -119,6 +119,9 @@ function migrate(cfg) {
119
119
  // Backfill and sync fields from presets
120
120
  for (const cmd of cfg.commands) {
121
121
  const preset = cmd.presetId ? PRESETS.find(p => p.presetId === cmd.presetId) : matchPreset(cmd);
122
+ if (preset?.presetId === 'shell' && (!cmd.command || (cmd.command === '/bin/zsh' && !existsSync('/bin/zsh')))) {
123
+ cmd.command = defaultShell;
124
+ }
122
125
  // Stamp presetId for reliable lookup
123
126
  if (preset && !cmd.presetId) cmd.presetId = preset.presetId;
124
127
  // Icon always syncs from preset — the preset is the source of truth for logos
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clideck",
3
- "version": "1.30.5",
3
+ "version": "1.30.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": {
package/public/js/app.js CHANGED
@@ -55,6 +55,9 @@ function connect() {
55
55
  state.resumable = msg.list;
56
56
  renderResumable();
57
57
  break;
58
+ case 'error':
59
+ showToast(msg.message || 'CliDeck action failed.', { type: 'error', title: 'CliDeck Error', duration: 5000 });
60
+ break;
58
61
  case 'sessions':
59
62
  {
60
63
  const liveIds = new Set(msg.list.map(s => s.id));
@@ -104,33 +104,63 @@ 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
+
137
+ function ensureShellCommand() {
138
+ let cmd = state.cfg.commands.find(c => c.presetId === 'shell' || (!c.isAgent && !c.presetId));
139
+ if (cmd) return cmd;
140
+ const shellPreset = state.presets.find(p => p.presetId === 'shell');
141
+ const command = shellPreset?.command || state.cfg.defaultShell;
142
+ if (!command) return null;
143
+ cmd = {
144
+ id: crypto.randomUUID(),
145
+ presetId: 'shell',
146
+ label: 'Shell',
147
+ icon: shellPreset?.icon || 'terminal',
148
+ command,
149
+ enabled: true,
150
+ defaultPath: '',
151
+ isAgent: false,
152
+ canResume: false,
153
+ resumeCommand: null,
154
+ sessionIdPattern: null,
155
+ outputMarker: null,
156
+ telemetryEnabled: false,
157
+ telemetryStatus: null,
158
+ };
159
+ state.cfg.commands.push(cmd);
160
+ send({ type: 'config.update', config: state.cfg });
161
+ return cmd;
162
+ }
163
+
134
164
  export function openCreator() {
135
165
  // Toggle off if already open
136
166
  if (document.getElementById('session-creator')) {
@@ -273,12 +303,7 @@ export function openCreator() {
273
303
  if (setupBtn) {
274
304
  const preset = state.presets.find(p => p.presetId === setupBtn.dataset.preset);
275
305
  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
- }
306
+ const cmd = ensureCommandForPreset(preset);
282
307
  document.dispatchEvent(new CustomEvent('clideck:setup', { detail: { commandId: cmd.id } }));
283
308
  return;
284
309
  }
@@ -345,9 +370,16 @@ function showInstallToast(preset) {
345
370
  toast.querySelector('.add-btn').onclick = () => {
346
371
  dismiss();
347
372
  closeCreator();
373
+ ensureCommandForPreset(preset);
374
+ if (preset.telemetryAutoSetup) {
375
+ setTimeout(() => send({ type: 'telemetry.autosetup', presetId: preset.presetId }), 1000);
376
+ }
348
377
  // Find or create the shell command, then spawn a session running the install
349
- const shellCmd = state.cfg.commands.find(c => c.presetId === 'shell' || (!c.isAgent && !c.presetId));
350
- if (!shellCmd) return;
378
+ const shellCmd = ensureShellCommand();
379
+ if (!shellCmd) {
380
+ showToast('Could not find a shell command to run the installer.', { type: 'error', title: 'Install Failed' });
381
+ return;
382
+ }
351
383
  const installId = crypto.randomUUID();
352
384
  send({ type: 'create', commandId: shellCmd.id, name: `Installing ${preset.name}`, installId, ...estimateSize() });
353
385
  const handler = (e) => {
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,14 @@ 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
+
11
+ function openUrlHint() {
12
+ return process.platform === 'darwin' ? 'Cmd+click to open' : 'Ctrl+click to open';
13
+ }
14
+
7
15
  // --- Self-update check (runs before server starts) ---
8
16
  const currentVersion = require('./package.json').version;
9
17
  const { execFile, execSync } = require('child_process');
@@ -222,6 +230,12 @@ const server = http.createServer((req, res) => {
222
230
  return;
223
231
  }
224
232
 
233
+ // Session-to-session ask bridge used by the `clideck ask` CLI command.
234
+ if (req.method === 'POST' && req.url === '/api/session/ask') {
235
+ require('./session-ask').handleHttp(req, res, sessions);
236
+ return;
237
+ }
238
+
225
239
  // DEBUG: log any POST (agents might use /v1/traces, /v1/metrics, or other paths)
226
240
  if (req.method === 'POST') {
227
241
  // console.log(`OTLP: received POST ${req.url} (not handled)`);
@@ -280,6 +294,8 @@ process.on('SIGTERM', onShutdown);
280
294
  server.listen(PORT, HOST, () => {
281
295
  const v = require('./package.json').version;
282
296
  const url = `http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`;
297
+ const clickableUrl = terminalLink(url);
298
+ const urlHint = openUrlHint();
283
299
  console.log(`
284
300
  \x1b[38;5;105m ╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸\x1b[0m
285
301
 
@@ -294,7 +310,7 @@ server.listen(PORT, HOST, () => {
294
310
 
295
311
  \x1b[38;5;245m v${v}\x1b[0m
296
312
 
297
- \x1b[38;5;252m ▸ Ready at \x1b[38;5;44m${url}\x1b[0m
313
+ \x1b[38;5;252m ▸ Ready at \x1b[38;5;44m${clickableUrl}\x1b[38;5;245m (${urlHint})\x1b[0m
298
314
  \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
315
  ${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
316
  });
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);