claude-code-kanban 7.7.0 → 7.9.0
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 +1 -1
- package/cli.js +23 -49
- package/hub-app.json +2 -2
- package/install.js +11 -9
- package/lib/live-sessions.js +17 -2
- package/lib/retention.js +10 -1
- package/lib/terminal-client.js +6 -0
- package/lib/terminal-host.js +8 -1
- package/lib/terminal.js +98 -22
- package/lib/user-groups.js +86 -66
- package/package.json +2 -1
- package/public/app.js +716 -255
- package/public/group-tree.js +21 -0
- package/public/index.html +35 -14
- package/public/style.css +147 -10
- package/public/sw.js +1 -1
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +80 -44
- package/skill-guides/dispatch.md +1 -1
- package/lib/dispatch-groups.js +0 -88
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ Run `claude` in any project. You do not configure anything per project. Claude C
|
|
|
108
108
|
With no subcommand, `claude-code-kanban` starts the server. Subcommands talk to a server that already runs:
|
|
109
109
|
|
|
110
110
|
- `session list|search|open|view|plan|agents|pin|group|ungroup` to read, focus and group sessions. `session list` shows the active sessions, as the sidebar's Active view does; `--all` shows every session.
|
|
111
|
-
- `group list` to list the session groups,
|
|
111
|
+
- `group list` to list the session groups, temporary ones included, with the members the sidebar's Active view shows (active, pinned or sticky); `--all` for every member.
|
|
112
112
|
- `task list` and `project list` to read tasks and projects.
|
|
113
113
|
- `doc link|unlink|list|preview` to link a file or URL to a session, or show it in the preview.
|
|
114
114
|
- `pane add|rm|list` to add live panes to a session's view.
|
package/cli.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
const fs = require('node:fs');
|
|
2
2
|
const path = require('node:path');
|
|
3
3
|
const { getClaudeDir, displayPath } = require('./lib/claude-dir');
|
|
4
|
-
const { isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
5
4
|
const { linkUrl } = require('./public/link-url');
|
|
6
5
|
const { sessionProjectKey } = require('./public/project-match');
|
|
7
|
-
const { DISPATCH_PREFIX, placeSession } = require('./lib/user-groups');
|
|
8
6
|
// Help is auto-generated from this table — keep flags/usage in sync with `run` behavior.
|
|
9
7
|
const SESSION_FLAG = '--session <id>';
|
|
10
8
|
const SESSION_FLAG_HELP = 'Session, full id or unique prefix (default: $PREVIEW_SESSION, else $CLAUDE_CODE_SESSION_ID)';
|
|
@@ -180,7 +178,7 @@ const COMMANDS = {
|
|
|
180
178
|
usage: 'claude-code-kanban session pin <id> [--sticky] [--unpin]',
|
|
181
179
|
flags: {
|
|
182
180
|
'<id>': 'Full session id, or a unique prefix',
|
|
183
|
-
'--sticky': 'Set sticky state (always shown
|
|
181
|
+
'--sticky': 'Set sticky state (always shown in the list)',
|
|
184
182
|
'--unpin': 'Clear pin/sticky state',
|
|
185
183
|
},
|
|
186
184
|
examples: [
|
|
@@ -217,7 +215,7 @@ const COMMANDS = {
|
|
|
217
215
|
'<id>': 'Full session id, or a unique prefix',
|
|
218
216
|
'--json': 'Output JSON ({left, rev})',
|
|
219
217
|
},
|
|
220
|
-
notes: '
|
|
218
|
+
notes: 'A session whose project is in a group still shows there; move the project on the board.',
|
|
221
219
|
examples: ['claude-code-kanban session ungroup $CLAUDE_SESSION_ID'],
|
|
222
220
|
run: runSessionUngroupCli,
|
|
223
221
|
},
|
|
@@ -231,17 +229,18 @@ const COMMANDS = {
|
|
|
231
229
|
usage: 'claude-code-kanban group list [--all] [--json]',
|
|
232
230
|
flags: {
|
|
233
231
|
'--all': 'Every member, active or not',
|
|
234
|
-
'--json': 'Output JSON ({rev, groups: [{id, name, parent, visible, total, members}]}); a
|
|
232
|
+
'--json': 'Output JSON ({rev, groups: [{id, name, parent, transient?, visible, total, members}]}); a session member has title, branch, status, pinned and age, or missing: true; a project member has sessions (its shown sessions)',
|
|
235
233
|
},
|
|
236
|
-
notes: 'A project member shows when it has a session the Active view shows. visible/total counts the members shown. A
|
|
234
|
+
notes: 'A project member shows when it has a session the Active view shows. visible/total counts the members shown. A group marked (transient) goes when the board\'s Active view shows none of its sessions; Keep or a rename on the board, or `group rename`, keeps it. `dispatch start --group` and `group create --transient` make transient groups. Pass a group\'s name, path (parent/child) or id to `session group`.',
|
|
237
235
|
examples: ['claude-code-kanban group list', 'claude-code-kanban group list --all --json'],
|
|
238
236
|
run: runGroupListCli,
|
|
239
237
|
},
|
|
240
238
|
create: {
|
|
241
239
|
summary: 'Make a group, and the missing parents of a path',
|
|
242
|
-
usage: 'claude-code-kanban group create <name|path> [--json]',
|
|
240
|
+
usage: 'claude-code-kanban group create <name|path> [--transient] [--json]',
|
|
243
241
|
flags: {
|
|
244
|
-
'<name|path>': 'Name of a new top-level group, or a path from the top (parent/child)
|
|
242
|
+
'<name|path>': 'Name of a new top-level group, or a path from the top (parent/child)',
|
|
243
|
+
'--transient': 'The groups it makes go when the Active view shows none of their sessions',
|
|
245
244
|
'--json': 'Output JSON ({group, name, path, rev})',
|
|
246
245
|
},
|
|
247
246
|
notes: 'Fails when the group exists.',
|
|
@@ -266,7 +265,7 @@ const COMMANDS = {
|
|
|
266
265
|
'<group>': 'Group name (any case), path from the top (parent/child) or id, from `group list`',
|
|
267
266
|
'--json': 'Output JSON ({removed: {id, name, path}, ungrouped, rev})',
|
|
268
267
|
},
|
|
269
|
-
notes: 'Its members leave the group and show as with no group: in their project\'s group
|
|
268
|
+
notes: 'Its members leave the group and show as with no group: in their project\'s group or the top level. Its child groups move up one level.',
|
|
270
269
|
examples: ['claude-code-kanban group rm presales-cloud'],
|
|
271
270
|
run: runGroupRmCli,
|
|
272
271
|
},
|
|
@@ -329,7 +328,7 @@ const COMMANDS = {
|
|
|
329
328
|
'--spec <text>': 'The task, self-contained; sent as the first message',
|
|
330
329
|
'--spec-file <path>': 'Read the task from a file',
|
|
331
330
|
'--name <n>': 'Session name; also its peer name for SendMessage',
|
|
332
|
-
'--group <g>': '
|
|
331
|
+
'--group <g>': 'Add it to this group: a name, path or id. The groups it makes are transient (see `help group list`)',
|
|
333
332
|
'--model <m>': 'fable, opus, sonnet or haiku',
|
|
334
333
|
'--worktree [name]': 'Run in a new git worktree',
|
|
335
334
|
'--task-list [id]': 'Use this task list instead of its own (default: this session\'s list), so its cards show on one board with yours. Off unless given',
|
|
@@ -638,11 +637,14 @@ async function runPreviewCli(args) {
|
|
|
638
637
|
if (url) return previewUrlCli(url, args);
|
|
639
638
|
// No $CLAUDE_CODE_SESSION_ID fallback: a session here switches the board's focus, and the
|
|
640
639
|
// agent must not move the board unless asked.
|
|
641
|
-
const
|
|
640
|
+
const sessionArg = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
|
|
641
|
+
const resolved = sessionArg && await resolveSessionByIdOrPrefix(sessionArg);
|
|
642
|
+
if (sessionArg && !resolved) return 1;
|
|
643
|
+
const sessionId = resolved?.id || null;
|
|
642
644
|
const abs = cliPath(filePathArg);
|
|
643
645
|
try {
|
|
644
646
|
if (!await cliPostJson('/api/preview', { path: abs, sessionId }, 'Preview')) return 1;
|
|
645
|
-
console.log(`Preview opened: ${abs}${sessionId ? ` (session ${sessionId})` : ''}`);
|
|
647
|
+
console.log(`Preview opened: ${abs}${sessionId ? ` (session ${sessionId.slice(0, 8)})` : ''}`);
|
|
646
648
|
return 0;
|
|
647
649
|
} catch (e) { reportCliError(e); return 1; }
|
|
648
650
|
}
|
|
@@ -1023,7 +1025,8 @@ async function runGroupCreateCli(args, entry) {
|
|
|
1023
1025
|
printLeafHelp(entry);
|
|
1024
1026
|
return 1;
|
|
1025
1027
|
}
|
|
1026
|
-
|
|
1028
|
+
const transient = args.includes('--transient');
|
|
1029
|
+
return groupVerb(args, '/api/groups/path', { path: pathArg, transient }, 'Create', (r) => `Created group "${r.path}" (${r.group})${transient ? ', transient' : ''}`);
|
|
1027
1030
|
}
|
|
1028
1031
|
|
|
1029
1032
|
async function runGroupRenameCli(args, entry) {
|
|
@@ -1089,37 +1092,20 @@ function describeGroupMembers(groups, sessions, activeOnly) {
|
|
|
1089
1092
|
});
|
|
1090
1093
|
}
|
|
1091
1094
|
|
|
1092
|
-
// Dispatch groups are listed after the user groups, as in the sidebar.
|
|
1093
|
-
function placeDispatched(groups, sessions, released = []) {
|
|
1094
|
-
const dispatchGroups = new Map();
|
|
1095
|
-
for (const s of sessions) {
|
|
1096
|
-
if (!s.dispatchGroup) continue;
|
|
1097
|
-
const { named, dispatch } = placeSession(groups, released, { ref: s.id, project: sessionProjectKey(s), dispatchGroup: s.dispatchGroup });
|
|
1098
|
-
if (dispatch && !dispatchGroups.has(dispatch)) {
|
|
1099
|
-
dispatchGroups.set(dispatch, { id: `${DISPATCH_PREFIX}${dispatch}`, name: dispatch, parent: null, dispatch: true, members: [] });
|
|
1100
|
-
}
|
|
1101
|
-
(named || dispatchGroups.get(dispatch))?.members.push({ type: 'session', ref: s.id });
|
|
1102
|
-
}
|
|
1103
|
-
return [...groups, ...dispatchGroups.values()];
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
1095
|
async function runGroupListCli(args) {
|
|
1107
1096
|
const activeOnly = !args.includes('--all');
|
|
1108
1097
|
let rev;
|
|
1109
1098
|
let groups;
|
|
1110
1099
|
try {
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
({ rev, groups, released, dispatch } = await cliGetJson('/api/groups?dispatch=1', 'Group list'));
|
|
1114
|
-
const memberRefs = groups.flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref));
|
|
1115
|
-
const sessionRefs = [...new Set([...memberRefs, ...Object.keys(dispatch || {})])];
|
|
1100
|
+
({ rev, groups } = await cliGetJson('/api/groups', 'Group list'));
|
|
1101
|
+
const sessionRefs = [...new Set(groups.flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref)))];
|
|
1116
1102
|
// An inactive session held by id is still listed, so only the active list needs the id lookups.
|
|
1117
1103
|
const lists = await Promise.all([
|
|
1118
1104
|
fetchSessionsList({ limit: null, activeOnly, lite: true }, 'Group list'),
|
|
1119
1105
|
activeOnly ? fetchSessionsByIds(sessionRefs, 'Group list', { lite: true }) : [],
|
|
1120
1106
|
]);
|
|
1121
1107
|
const sessions = uniqueById(lists.flat());
|
|
1122
|
-
groups = describeGroupMembers(
|
|
1108
|
+
groups = describeGroupMembers(groups, sessions, activeOnly);
|
|
1123
1109
|
} catch (e) { reportCliError(e); return 1; }
|
|
1124
1110
|
if (args.includes('--json')) {
|
|
1125
1111
|
console.log(JSON.stringify({ rev, groups }, null, 2));
|
|
@@ -1133,7 +1119,7 @@ async function runGroupListCli(args) {
|
|
|
1133
1119
|
const add = (parent, depth) => {
|
|
1134
1120
|
const pad = ' '.repeat(depth);
|
|
1135
1121
|
for (const g of groups.filter((x) => x.parent === parent)) {
|
|
1136
|
-
rows.push([g.
|
|
1122
|
+
rows.push([g.id, '', '', '', `${pad}${g.name}${g.transient ? ' (transient)' : ''} (${g.visible}/${g.total})`]);
|
|
1137
1123
|
for (const m of g.members) {
|
|
1138
1124
|
if (m.type === 'project') rows.push(['project', '', '', '', `${pad} ${m.ref} (${m.sessions} sessions)`]);
|
|
1139
1125
|
else if (m.missing) rows.push([m.ref.slice(0, 8), 'gone', '-', '', `${pad} (no such session)`]);
|
|
@@ -1420,10 +1406,7 @@ async function runDispatchStartCli(argv) {
|
|
|
1420
1406
|
}
|
|
1421
1407
|
const hasGroup = args.some(a => a === '--group' || a.startsWith('--group='));
|
|
1422
1408
|
const group = hasGroup ? getArgValue(args, 'group') || '' : null;
|
|
1423
|
-
if (hasGroup && !
|
|
1424
|
-
const hint = suggestGroupName(group);
|
|
1425
|
-
return usageError(COMMANDS.dispatch.verbs.start, `Group names are kebab-case${hint ? `: try --group ${hint}` : ', e.g. auth-refactor'}`);
|
|
1426
|
-
}
|
|
1409
|
+
if (hasGroup && !group.trim()) return usageError(COMMANDS.dispatch.verbs.start, '--group needs a group name, path or id');
|
|
1427
1410
|
const worktree = args.includes('--worktree') ? getArgValue(args, 'worktree') || true : false;
|
|
1428
1411
|
const hasTaskList = args.some(a => a === '--task-list' || a.startsWith('--task-list='));
|
|
1429
1412
|
const ownList = process.env.CLAUDE_CODE_TASK_LIST_ID || process.env.CLAUDE_CODE_SESSION_ID;
|
|
@@ -1446,8 +1429,8 @@ async function runDispatchStartCli(argv) {
|
|
|
1446
1429
|
const out = await cliPostJson('/api/dispatch', body, 'Dispatch', { 'x-terminal-token': token });
|
|
1447
1430
|
if (!out) return 1;
|
|
1448
1431
|
if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
|
|
1449
|
-
else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}]` : ''}`);
|
|
1450
|
-
if (out.
|
|
1432
|
+
else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}${out.transient ? ', transient' : ''}]` : ''}`);
|
|
1433
|
+
if (out.groupError) console.error(`Not grouped: ${out.groupError}`);
|
|
1451
1434
|
return 0;
|
|
1452
1435
|
} catch (e) { reportCliError(e); return 1; }
|
|
1453
1436
|
}
|
|
@@ -1458,15 +1441,6 @@ async function fetchDispatches(all) {
|
|
|
1458
1441
|
return (await cliGetJson(`/api/dispatch?${q}`, 'Dispatch list')).running.sort((a, b) => b.startedAt - a.startedAt);
|
|
1459
1442
|
}
|
|
1460
1443
|
|
|
1461
|
-
// A user group named like the dispatch group takes the session in; say so, or the board looks wrong.
|
|
1462
|
-
async function noteJoinedGroup(session, dispatchGroup) {
|
|
1463
|
-
try {
|
|
1464
|
-
const { groups, released } = await cliGetJson('/api/groups', 'Group list');
|
|
1465
|
-
const { named } = placeSession(groups, released, { ref: session, dispatchGroup });
|
|
1466
|
-
if (named) console.error(`Joins user group "${named.name}" (${named.id}), which has the same name.`);
|
|
1467
|
-
} catch (_) { /* the session started; the note is optional */ }
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
1444
|
const dispatchLine = (r) => `${r.session}${r.name ? ` ${r.name}` : ''}${r.group ? ` [${r.group}]` : ''}`;
|
|
1471
1445
|
|
|
1472
1446
|
async function runDispatchListCli(args) {
|
package/hub-app.json
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"publishes": ["session.changed"],
|
|
8
8
|
"keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s", "ctrl+shift+k", "ctrl+shift+p", "ctrl+shift+z"] },
|
|
9
9
|
"plugin": {
|
|
10
|
-
"
|
|
11
|
-
"
|
|
10
|
+
"marketplace": "plugin",
|
|
11
|
+
"name": "claude-code-kanban",
|
|
12
12
|
"install": "claude-code-kanban --install --yes"
|
|
13
13
|
},
|
|
14
14
|
"provides": {
|
package/install.js
CHANGED
|
@@ -163,15 +163,7 @@ async function runInstall({ yes = false } = {}) {
|
|
|
163
163
|
// on stdout — so a re-copied plugin keeps running from the old cached version
|
|
164
164
|
// until `update` pulls the new one in. That is how a config dir ends up serving
|
|
165
165
|
// a plugin older than the board it talks to.
|
|
166
|
-
if (alreadyInstalled)
|
|
167
|
-
const upgrade = runCLI('claude plugin update claude-code-kanban', ['already at the latest']);
|
|
168
|
-
if (upgrade.ok) {
|
|
169
|
-
const line = (upgrade.output || '').split('\n').find(l => l.includes('updated from'));
|
|
170
|
-
console.log(` ${green('✓')} ${line ? line.replace(/^[^A-Za-z]*/, '') : 'Already at the latest version'}`);
|
|
171
|
-
} else {
|
|
172
|
-
console.log(` ${yellow('⚠')} Plugin update failed: ${upgrade.error}`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
166
|
+
if (alreadyInstalled) updatePlugin(PLUGIN_ID, 'Plugin');
|
|
175
167
|
} else {
|
|
176
168
|
console.log(` ${dim('Skipped')}`);
|
|
177
169
|
}
|
|
@@ -181,6 +173,16 @@ async function runInstall({ yes = false } = {}) {
|
|
|
181
173
|
printSummary(failed);
|
|
182
174
|
}
|
|
183
175
|
|
|
176
|
+
function updatePlugin(id, label) {
|
|
177
|
+
const upgrade = runCLI(`claude plugin update ${id}`, ['already at the latest']);
|
|
178
|
+
if (upgrade.ok) {
|
|
179
|
+
const line = (upgrade.output || '').split('\n').find(l => l.includes('updated from'));
|
|
180
|
+
console.log(` ${green('✓')} ${label}: ${line ? line.replace(/^[^A-Za-z]*/, '') : 'already at the latest version'}`);
|
|
181
|
+
} else {
|
|
182
|
+
console.log(` ${yellow('⚠')} ${label} update failed: ${upgrade.error}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
184
186
|
function printSummary(failed = false) {
|
|
185
187
|
if (failed) {
|
|
186
188
|
console.log(`\n ${red('Setup incomplete — see the errors above.')}\n`);
|
package/lib/live-sessions.js
CHANGED
|
@@ -16,7 +16,7 @@ function readLiveSessions(sessionsDir) {
|
|
|
16
16
|
try {
|
|
17
17
|
const s = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf8'));
|
|
18
18
|
if (s?.sessionId && s.kind === 'interactive') {
|
|
19
|
-
sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null, name: s.name || null });
|
|
19
|
+
sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null, statusUpdatedAt: s.statusUpdatedAt || null, name: s.name || null });
|
|
20
20
|
}
|
|
21
21
|
} catch { /* skip invalid */ }
|
|
22
22
|
}
|
|
@@ -36,4 +36,19 @@ function isSessionLive(sessions, sessionId, exceptPid = null) {
|
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// /clear and /resume keep claude's process and registry file and swap its sessionId, so the id
|
|
40
|
+
// that pid held before is finished even though its transcript was written moments ago. `replaced`
|
|
41
|
+
// maps that id to when the swap was seen; an id that shows up live again leaves it.
|
|
42
|
+
function noteReplacedSessions(prev, next, replaced, now, ttlMs) {
|
|
43
|
+
for (const [id, at] of replaced) if (now - at > ttlMs) replaced.delete(id);
|
|
44
|
+
if (prev) {
|
|
45
|
+
const byPid = new Map(next.filter((s) => s.pid).map((s) => [s.pid, s.sessionId]));
|
|
46
|
+
for (const s of prev) {
|
|
47
|
+
const id = s.pid && byPid.get(s.pid);
|
|
48
|
+
if (id && id !== s.sessionId) replaced.set(s.sessionId, now);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const s of next) replaced.delete(s.sessionId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { readLiveSessions, isPidAlive, isSessionLive, noteReplacedSessions };
|
package/lib/retention.js
CHANGED
|
@@ -78,6 +78,15 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
78
78
|
persist();
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
function move(from, to) {
|
|
82
|
+
const e = entries.get(from);
|
|
83
|
+
if (!e || entries.has(to)) return false;
|
|
84
|
+
entries.delete(from);
|
|
85
|
+
entries.set(to, e);
|
|
86
|
+
persist();
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
81
90
|
// Returns how many entries went; writes only when one did.
|
|
82
91
|
function prune({ known, maxAgeMs }) {
|
|
83
92
|
const removed = pruneMap(entries, (e) => e.at, { known, maxAgeMs, now: now() });
|
|
@@ -95,7 +104,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
95
104
|
return out;
|
|
96
105
|
}
|
|
97
106
|
|
|
98
|
-
return { record, prune, running, get: (id) => entries.get(id) || null, entries: () => entries.entries() };
|
|
107
|
+
return { record, move, prune, running, get: (id) => entries.get(id) || null, entries: () => entries.entries() };
|
|
99
108
|
}
|
|
100
109
|
|
|
101
110
|
// Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
|
package/lib/terminal-client.js
CHANGED
|
@@ -33,6 +33,7 @@ function hostError(message) {
|
|
|
33
33
|
* @param {(data: {sessions: string[]}) => void} [o.save]
|
|
34
34
|
* @param {() => void} [o.onChange]
|
|
35
35
|
* @param {(id: string) => void} [o.onExit]
|
|
36
|
+
* @param {(from: string, to: string) => void} [o.onRekey]
|
|
36
37
|
*/
|
|
37
38
|
function createTerminalClient(o) {
|
|
38
39
|
const { config, net } = o;
|
|
@@ -42,6 +43,7 @@ function createTerminalClient(o) {
|
|
|
42
43
|
let hostReason = null;
|
|
43
44
|
let ids = [];
|
|
44
45
|
let terminalIds = [];
|
|
46
|
+
let awaitingTrust = [];
|
|
45
47
|
let serverUrl = null;
|
|
46
48
|
let restoreAsked = false;
|
|
47
49
|
let stopped = false;
|
|
@@ -78,9 +80,11 @@ function createTerminalClient(o) {
|
|
|
78
80
|
hostReason = msg.reason;
|
|
79
81
|
ids = msg.ids;
|
|
80
82
|
terminalIds = msg.terminalIds || [];
|
|
83
|
+
awaitingTrust = msg.awaitingTrust || [];
|
|
81
84
|
settle(hostReason);
|
|
82
85
|
o.onChange?.();
|
|
83
86
|
} else if (msg.t === 'exit') o.onExit?.(msg.id);
|
|
87
|
+
else if (msg.t === 'rekey') o.onRekey?.(msg.from, msg.to);
|
|
84
88
|
else if (msg.t === 'save') o.save?.(msg.data);
|
|
85
89
|
else if (msg.t === 'reply') {
|
|
86
90
|
const pending = calls.get(msg.id);
|
|
@@ -121,6 +125,7 @@ function createTerminalClient(o) {
|
|
|
121
125
|
const lost = ids;
|
|
122
126
|
ids = [];
|
|
123
127
|
terminalIds = [];
|
|
128
|
+
awaitingTrust = [];
|
|
124
129
|
for (const id of lost) o.onExit?.(id);
|
|
125
130
|
o.onChange?.();
|
|
126
131
|
const now = Date.now();
|
|
@@ -228,6 +233,7 @@ function createTerminalClient(o) {
|
|
|
228
233
|
token, handleUpgrade, clientConfig, unavailableReason, authorized, isRunning, hasTerminal, sessionOfTerminal, startNew, end, paste, restore,
|
|
229
234
|
setServerUrl, sessions, stats, shutdown, started,
|
|
230
235
|
ids: () => [...ids],
|
|
236
|
+
awaitingTrust: () => [...awaitingTrust],
|
|
231
237
|
};
|
|
232
238
|
}
|
|
233
239
|
|
package/lib/terminal-host.js
CHANGED
|
@@ -28,7 +28,13 @@ function ask(op, arg) {
|
|
|
28
28
|
// The service reports every attach and detach too; cck needs only a change of ids or reason.
|
|
29
29
|
function pushState() {
|
|
30
30
|
const live = service.list();
|
|
31
|
-
const state = {
|
|
31
|
+
const state = {
|
|
32
|
+
t: 'state',
|
|
33
|
+
reason: service.unavailableReason(),
|
|
34
|
+
ids: live.map((s) => s.id),
|
|
35
|
+
terminalIds: live.map((s) => s.terminalId),
|
|
36
|
+
awaitingTrust: live.filter((s) => s.awaitingTrust).map((s) => s.id),
|
|
37
|
+
};
|
|
32
38
|
const key = JSON.stringify(state);
|
|
33
39
|
if (key === lastState) return;
|
|
34
40
|
lastState = key;
|
|
@@ -55,6 +61,7 @@ function init(msg) {
|
|
|
55
61
|
save: (data) => send({ t: 'save', data }),
|
|
56
62
|
onChange: pushState,
|
|
57
63
|
onExit: (id) => send({ t: 'exit', id }),
|
|
64
|
+
onRekey: (from, to) => send({ t: 'rekey', from, to }),
|
|
58
65
|
});
|
|
59
66
|
pushState();
|
|
60
67
|
}
|
package/lib/terminal.js
CHANGED
|
@@ -34,6 +34,7 @@ const WORKTREE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
|
34
34
|
// Claude Code keeps a list in tasks/<id>/, so the id must stay one path segment.
|
|
35
35
|
const TASK_LIST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
36
36
|
const MODELS = new Set(['fable', 'opus', 'sonnet', 'haiku']);
|
|
37
|
+
const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
37
38
|
// Extra claude args reach the command line inside plain quotes (quoteArg), so a character
|
|
38
39
|
// that ends or expands those quotes in any of the shells is refused.
|
|
39
40
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: refuses control characters on purpose
|
|
@@ -47,6 +48,12 @@ const OWNED_FLAGS = new Set([
|
|
|
47
48
|
]);
|
|
48
49
|
const MAX_PROMPT = 32 * 1024;
|
|
49
50
|
const PROMPT_QUIET_MS = 400;
|
|
51
|
+
const SUBMIT_POLL_MS = 1000;
|
|
52
|
+
const SUBMIT_CHECKS = 5;
|
|
53
|
+
const SUBMIT_RETRIES = 2;
|
|
54
|
+
// The answer lines of Claude Code's folder-trust dialog, current and earlier wording. They tell the
|
|
55
|
+
// board the dialog is up; the looser onTrustScreen only holds the prompt back, where a miss is worse.
|
|
56
|
+
const TRUST_DIALOG = /Yes, I trust this folder|Do you trust the files in this folder\?/;
|
|
50
57
|
// Watermarks from https://xtermjs.org/docs/guides/flowcontrol/ — pause the PTY while a
|
|
51
58
|
// client lags, so a runaway process cannot grow the socket buffer without bound.
|
|
52
59
|
const HIGH_WATER = 128 * 1024;
|
|
@@ -187,6 +194,7 @@ function claudeArgsFor(mode, id, spec) {
|
|
|
187
194
|
const args = [...fork, ...own, ...(spec.extraArgs || [])];
|
|
188
195
|
if (spec.name) args.push('--name', spec.name);
|
|
189
196
|
if (spec.model) args.push('--model', spec.model);
|
|
197
|
+
if (spec.effort) args.push('--effort', spec.effort);
|
|
190
198
|
// Last, because its value is optional and a following flag must not be taken for it.
|
|
191
199
|
if (spec.worktree === true) args.push('-w');
|
|
192
200
|
else if (spec.worktree) args.push('-w', spec.worktree);
|
|
@@ -218,6 +226,8 @@ function parseNewSpec(msg) {
|
|
|
218
226
|
if (typeof worktree !== 'boolean' && !(typeof worktree === 'string' && WORKTREE_RE.test(worktree))) return 'worktree name';
|
|
219
227
|
const model = msg.model || null;
|
|
220
228
|
if (model && !MODELS.has(model)) return 'model';
|
|
229
|
+
const effort = msg.effort || null;
|
|
230
|
+
if (effort && !EFFORTS.has(effort)) return 'effort';
|
|
221
231
|
const taskList = msg.taskList || null;
|
|
222
232
|
if (taskList && !(typeof taskList === 'string' && TASK_LIST_RE.test(taskList))) return 'task list id';
|
|
223
233
|
const edit = msg.edit === true;
|
|
@@ -232,7 +242,7 @@ function parseNewSpec(msg) {
|
|
|
232
242
|
if (bad) return bad;
|
|
233
243
|
if (forkOf && worktree) return 'worktree: a fork runs in its parent session folder';
|
|
234
244
|
const restore = msg.restore !== false;
|
|
235
|
-
return { cwd: forkOf ? null : msg.cwd, forkOf, resume, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs, edit, restore };
|
|
245
|
+
return { cwd: forkOf ? null : msg.cwd, forkOf, resume, name: name || null, worktree, model, effort, taskList, prompt: prompt || null, extraArgs, edit, restore };
|
|
236
246
|
}
|
|
237
247
|
|
|
238
248
|
function samePath(a, b) {
|
|
@@ -341,6 +351,7 @@ function clientConfigFor(config, available) {
|
|
|
341
351
|
* @param {(cmd: string) => string|null} o.which
|
|
342
352
|
* @param {string} [o.token]
|
|
343
353
|
* @param {() => void} [o.onChange] a terminal started, ended or moved to another session id
|
|
354
|
+
* @param {(from: string, to: string) => void} [o.onRekey] a terminal moved to another session id
|
|
344
355
|
* @param {() => {sessions?: string[], taskLists?: Object<string, string>}|null} [o.load] the saved list, read when config.restore is on
|
|
345
356
|
* @param {(data: {sessions: string[], taskLists?: Object<string, string>}) => void} [o.save] the session ids to resume on the next start, and the shared task list of each that has one
|
|
346
357
|
* @param {object} [o.pty] stands in for @lydell/node-pty in tests
|
|
@@ -362,6 +373,7 @@ function createTerminalService(o) {
|
|
|
362
373
|
const saveDelayMs = o.saveDelayMs ?? SAVE_DELAY_MS;
|
|
363
374
|
const restoreGapMs = o.restoreGapMs ?? RESTORE_GAP_MS;
|
|
364
375
|
const exitPollMs = o.exitPollMs ?? CLAUDE_EXIT_POLL_MS;
|
|
376
|
+
const submitPollMs = o.submitPollMs ?? SUBMIT_POLL_MS;
|
|
365
377
|
const pidAlive = o.isPidAlive || isPidAlive;
|
|
366
378
|
const exitTimer = setInterval(checkExits, exitPollMs);
|
|
367
379
|
exitTimer.unref();
|
|
@@ -484,13 +496,24 @@ function createTerminalService(o) {
|
|
|
484
496
|
|
|
485
497
|
// Waits for claude to turn on bracketed paste (its input box is live), then for the
|
|
486
498
|
// screen to settle. A folder-trust question also takes input, and Enter there would
|
|
487
|
-
// answer it, so the prompt waits for as long as that question is on screen.
|
|
499
|
+
// answer it, so the prompt waits for as long as that question is on screen. Once it is
|
|
500
|
+
// answered, claude turns paste off and redraws for seconds; text sent then is lost.
|
|
488
501
|
function bracketed(text) {
|
|
489
502
|
return `\x1b[200~${text.replace(/\r?\n/g, '\r')}\x1b[201~`;
|
|
490
503
|
}
|
|
491
504
|
|
|
505
|
+
// The folder and session names show on claude's main screen, so a "trust" in them is not the dialog.
|
|
492
506
|
function onTrustScreen(s) {
|
|
493
|
-
|
|
507
|
+
let text = screenText(s.term);
|
|
508
|
+
for (const own of [path.basename(s.cwd), s.name]) if (own) text = text.split(own).join('');
|
|
509
|
+
return /\btrust\b/i.test(text);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Checked only while the session's first prompt waits, so it ends for good once the prompt is sent.
|
|
513
|
+
function setAwaitingTrust(s, on) {
|
|
514
|
+
if (s.awaitingTrust === on) return;
|
|
515
|
+
s.awaitingTrust = on;
|
|
516
|
+
changed();
|
|
494
517
|
}
|
|
495
518
|
|
|
496
519
|
// `submit` is claude's prompt: wait for its input box, then press Enter. Without it the text
|
|
@@ -502,18 +525,47 @@ function createTerminalService(o) {
|
|
|
502
525
|
let timer = null;
|
|
503
526
|
const text = bracketed(raw);
|
|
504
527
|
s.onOutput = (data) => {
|
|
505
|
-
if (
|
|
506
|
-
|
|
528
|
+
if (submit) {
|
|
529
|
+
const on = data.lastIndexOf('\x1b[?2004h');
|
|
530
|
+
const off = data.lastIndexOf('\x1b[?2004l');
|
|
531
|
+
if (on !== off) armed = on > off;
|
|
532
|
+
}
|
|
507
533
|
clearTimeout(timer);
|
|
534
|
+
if (!armed) return;
|
|
508
535
|
timer = setTimeout(() => {
|
|
509
|
-
if (s.exited
|
|
536
|
+
if (s.exited) return;
|
|
537
|
+
if (submit && onTrustScreen(s)) return setAwaitingTrust(s, TRUST_DIALOG.test(screenText(s.term)));
|
|
538
|
+
setAwaitingTrust(s, false);
|
|
510
539
|
s.onOutput = null;
|
|
511
540
|
s.pty.write(text);
|
|
512
|
-
if (submit) setTimeout(() =>
|
|
541
|
+
if (submit) setTimeout(() => submitPrompt(s), 100);
|
|
513
542
|
}, PROMPT_QUIET_MS);
|
|
514
543
|
};
|
|
515
544
|
}
|
|
516
545
|
|
|
546
|
+
// claude sometimes drops the Enter after the paste and leaves the prompt typed but not sent.
|
|
547
|
+
// It bumps statusUpdatedAt in its registry entry when a turn starts, so while the entry stays
|
|
548
|
+
// idle and older than the Enter, Enter is pressed again. Only an idle entry gets it: a turn
|
|
549
|
+
// that started can show a permission dialog, and Enter there would pick its default.
|
|
550
|
+
function submitPrompt(s) {
|
|
551
|
+
if (s.exited) return;
|
|
552
|
+
let at = Date.now();
|
|
553
|
+
let checks = 0;
|
|
554
|
+
let retries = 0;
|
|
555
|
+
s.pty.write('\r');
|
|
556
|
+
s.submitTimer = setInterval(() => {
|
|
557
|
+
const entry = ownEntry(o.liveSessions(), s, s.startedAt);
|
|
558
|
+
const unsent = !s.exited && entry?.status === 'idle' && typeof entry.statusUpdatedAt === 'number' && entry.statusUpdatedAt < at;
|
|
559
|
+
if (!unsent || (++checks === SUBMIT_CHECKS && retries === SUBMIT_RETRIES)) return clearInterval(s.submitTimer);
|
|
560
|
+
if (checks < SUBMIT_CHECKS) return;
|
|
561
|
+
checks = 0;
|
|
562
|
+
retries++;
|
|
563
|
+
at = Date.now();
|
|
564
|
+
s.pty.write('\r');
|
|
565
|
+
}, submitPollMs);
|
|
566
|
+
s.submitTimer.unref();
|
|
567
|
+
}
|
|
568
|
+
|
|
517
569
|
// The process a viewer types into: claude, found through its registry entry, or the shell.
|
|
518
570
|
function inputPid(s, registry) {
|
|
519
571
|
if (s.mode === 'shell') return s.pty.pid;
|
|
@@ -545,7 +597,12 @@ function createTerminalService(o) {
|
|
|
545
597
|
const pid = inputPid(s, live);
|
|
546
598
|
// An edit terminal starts claude whenever the user presses Enter, after syncBoost stopped looking.
|
|
547
599
|
if (pid && pid !== had) syncBoost(s);
|
|
548
|
-
if (!pid
|
|
600
|
+
if (!pid) continue;
|
|
601
|
+
const entry = live.find((l) => l.pid === pid);
|
|
602
|
+
if (entry && pidAlive(pid)) {
|
|
603
|
+
if (entry.sessionId !== s.id && s.mode !== 'pick' && !s.resumeOf) followSession(s, entry.sessionId);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
549
606
|
s.claudeExitedAt = Date.now();
|
|
550
607
|
s.claudePid = null;
|
|
551
608
|
moved = true;
|
|
@@ -605,7 +662,7 @@ function createTerminalService(o) {
|
|
|
605
662
|
term.loadAddon(serializer);
|
|
606
663
|
const s = {
|
|
607
664
|
id, terminalId, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
|
|
608
|
-
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, edit: !!spec?.edit,
|
|
665
|
+
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, awaitingTrust: false, edit: !!spec?.edit,
|
|
609
666
|
forkOf: spec?.forkOf || null, resumeOf: spec?.resume || null, restore: spec?.restore !== false,
|
|
610
667
|
};
|
|
611
668
|
if (s.edit) queueText(s, claudeCommand(shell, claudeArgs, spec.prompt), false);
|
|
@@ -624,6 +681,7 @@ function createTerminalService(o) {
|
|
|
624
681
|
s.exited = true;
|
|
625
682
|
clearInterval(s.pickTimer);
|
|
626
683
|
clearInterval(s.boostTimer);
|
|
684
|
+
clearInterval(s.submitTimer);
|
|
627
685
|
if (sessions.get(s.id) === s) sessions.delete(s.id);
|
|
628
686
|
o.onExit?.(s.id);
|
|
629
687
|
if (!s.ended) changed();
|
|
@@ -644,7 +702,7 @@ function createTerminalService(o) {
|
|
|
644
702
|
// is found by folder and start time, and the pick shows as an id with a transcript behind it.
|
|
645
703
|
function watchPick(s) {
|
|
646
704
|
s.pickTimer = setInterval(async () => {
|
|
647
|
-
if (s.
|
|
705
|
+
if (s.resolving) return;
|
|
648
706
|
const live = o.liveSessions();
|
|
649
707
|
const entry = s.claudePid && live.find((l) => l.pid === s.claudePid);
|
|
650
708
|
if (!entry) {
|
|
@@ -656,10 +714,7 @@ function createTerminalService(o) {
|
|
|
656
714
|
}
|
|
657
715
|
const picked = entry.sessionId;
|
|
658
716
|
if (picked === s.id) return;
|
|
659
|
-
s.
|
|
660
|
-
const known = await o.resolveCwd(picked);
|
|
661
|
-
s.pickChecking = false;
|
|
662
|
-
if (known === null || s.exited || s.mode !== 'pick') return;
|
|
717
|
+
if (!(await hasTranscript(s, picked)) || s.mode !== 'pick') return;
|
|
663
718
|
clearInterval(s.pickTimer);
|
|
664
719
|
// Two claude processes on one session write the same transcript. When cck already runs it,
|
|
665
720
|
// the pick is ended and its viewers move to that terminal; the sockets leave first so no
|
|
@@ -671,16 +726,37 @@ function createTerminalService(o) {
|
|
|
671
726
|
for (const ws of sockets) send(ws, { t: 'rekey', id: picked, duplicate: true });
|
|
672
727
|
return;
|
|
673
728
|
}
|
|
674
|
-
|
|
675
|
-
sessions.delete(s.id);
|
|
676
|
-
s.id = picked;
|
|
677
|
-
s.mode = 'resume';
|
|
678
|
-
sessions.set(picked, s);
|
|
679
|
-
changed();
|
|
680
|
-
for (const ws of s.sockets) send(ws, { t: 'rekey', id: picked, elsewhere });
|
|
729
|
+
rekey(s, picked);
|
|
681
730
|
}, PICK_POLL_MS);
|
|
682
731
|
}
|
|
683
732
|
|
|
733
|
+
// /clear and /resume swap the session id under the same claude process. The terminal follows
|
|
734
|
+
// once the new id has a transcript, so the new session's card holds it and the old one can end.
|
|
735
|
+
async function followSession(s, id) {
|
|
736
|
+
if (s.resolving || sessions.has(id)) return;
|
|
737
|
+
if (!(await hasTranscript(s, id)) || s.ended || sessions.has(id)) return;
|
|
738
|
+
rekey(s, id);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function hasTranscript(s, id) {
|
|
742
|
+
s.resolving = true;
|
|
743
|
+
const known = await o.resolveCwd(id);
|
|
744
|
+
s.resolving = false;
|
|
745
|
+
return known !== null && !s.exited;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function rekey(s, id) {
|
|
749
|
+
const elsewhere = o.isLiveElsewhere(id, s.claudePid);
|
|
750
|
+
const from = s.id;
|
|
751
|
+
sessions.delete(from);
|
|
752
|
+
s.id = id;
|
|
753
|
+
s.mode = 'resume';
|
|
754
|
+
sessions.set(id, s);
|
|
755
|
+
o.onRekey?.(from, id);
|
|
756
|
+
changed();
|
|
757
|
+
for (const ws of s.sockets) send(ws, { t: 'rekey', id, elsewhere });
|
|
758
|
+
}
|
|
759
|
+
|
|
684
760
|
function attach(ws, s, attached) {
|
|
685
761
|
ws.unacked = 0;
|
|
686
762
|
s.sockets.add(ws);
|
|
@@ -817,7 +893,7 @@ function createTerminalService(o) {
|
|
|
817
893
|
function list() {
|
|
818
894
|
return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
|
|
819
895
|
id: s.id, terminalId: s.terminalId, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
|
|
820
|
-
name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf, claudeExited: !!s.claudeExitedAt,
|
|
896
|
+
name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf, claudeExited: !!s.claudeExitedAt, awaitingTrust: s.awaitingTrust,
|
|
821
897
|
}));
|
|
822
898
|
}
|
|
823
899
|
|