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 +8 -2
- package/agent-presets.json +1 -0
- package/bin/clideck.js +7 -1
- package/clideck-ask-cli.js +110 -0
- package/config.js +7 -1
- package/handlers.js +16 -3
- package/package.json +1 -1
- package/public/js/creator.js +31 -29
- package/public/js/drag.js +1 -0
- package/server.js +12 -1
- package/session-ask.js +171 -0
- package/sessions.js +7 -1
- package/skills/autonomous-session/SKILL.md +211 -0
- package/skills/research-experiment/SKILL.md +350 -163
- package/skills/research-experiment/SKILL.md.bak +224 -0
- package/skills/research-experiment/scripts/init-research-layout.mjs +184 -0
- package/transcript.js +5 -1
- package/skills/research-experiment/agents/openai.yaml +0 -4
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/agent-presets.json
CHANGED
package/bin/clideck.js
CHANGED
|
@@ -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
|
|
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
package/public/js/creator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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${
|
|
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) {
|
|
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);
|