clideck 1.31.11 → 1.31.12
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 +11 -1
- package/bin/clideck.js +10 -2
- package/clideck-agents-cli.js +14 -8
- package/clideck-ask-cli.js +15 -4
- package/handlers.js +32 -19
- package/opencode-plugin/clideck-bridge.js +3 -1
- package/package.json +1 -1
- 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
|
@@ -57,14 +57,24 @@ clideck --port 4001
|
|
|
57
57
|
<img src="assets/autopilot.gif" width="720" alt="Autopilot routing work between agents">
|
|
58
58
|
</p>
|
|
59
59
|
|
|
60
|
-
**Ask another session** - from inside any CliDeck session, an agent can consult another session
|
|
60
|
+
**Ask another session** - from inside any CliDeck session, an agent can consult another session and get the answer back as command output:
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
|
+
clideck agents
|
|
63
64
|
clideck ask --session "Reviewer" --message "Review this output and return findings." --timeout 10m
|
|
64
65
|
```
|
|
65
66
|
|
|
66
67
|
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
68
|
|
|
69
|
+
By default, target lookup is limited to the caller's project. For cross-project asks, discover the full address first:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
clideck agents --all
|
|
73
|
+
clideck ask "@website/Docs Writer" "Check if the docs mention the new CLI flags." --timeout 15m
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
68
78
|
**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
79
|
|
|
70
80
|
**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
|
@@ -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
|
@@ -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' });
|
|
@@ -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/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 };
|