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 +12 -4
- package/assets/clideck-ask.png +0 -0
- package/bin/clideck.js +10 -2
- package/clideck-agents-cli.js +14 -8
- package/clideck-ask-cli.js +15 -4
- package/handlers.js +66 -23
- package/opencode-plugin/clideck-bridge.js +3 -1
- package/package.json +1 -1
- package/plugin-loader.js +21 -1
- package/plugins/voice-input/index.js +28 -18
- package/public/js/app.js +38 -22
- package/server.js +5 -15
- package/session-agents.js +28 -13
- package/session-ask.js +59 -14
- package/transcript.js +50 -9
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
|
-
**
|
|
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/
|
|
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
|
-
'
|
|
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.',
|
package/clideck-agents-cli.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/clideck-ask-cli.js
CHANGED
|
@@ -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
|
-
'
|
|
13
|
-
'
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
311
|
-
|
|
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
|
-
|
|
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
|
|
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
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
|
-
|
|
253
|
-
|
|
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
|
|
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
|
-
|
|
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 &&
|
|
1372
|
-
|
|
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 &&
|
|
1391
|
-
|
|
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
|
|
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 +=
|
|
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
|
-
|
|
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
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
283
|
+
let footerLineIdx = -1;
|
|
271
284
|
for (let i = lines.length - 1; i >= scanStart; i--) {
|
|
272
|
-
if (/\besc\b|\(esc\)/i.test(lines[i])) {
|
|
285
|
+
if (/\besc\b|\(esc\)/i.test(lines[i])) { footerLineIdx = i; break; }
|
|
273
286
|
}
|
|
274
|
-
if (
|
|
287
|
+
if (footerLineIdx < 0) return null;
|
|
275
288
|
const choices = [];
|
|
276
|
-
|
|
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]
|
|
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
|
-
|
|
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 };
|