claude-code-kanban 7.7.0 → 7.8.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 +18 -47
- package/hub-app.json +2 -2
- package/install.js +11 -9
- package/lib/live-sessions.js +1 -1
- package/lib/terminal-client.js +4 -0
- package/lib/terminal-host.js +7 -1
- package/lib/terminal.js +61 -9
- package/lib/user-groups.js +86 -66
- package/package.json +2 -1
- package/public/app.js +486 -204
- package/public/group-tree.js +21 -0
- package/public/index.html +34 -13
- package/public/style.css +118 -6
- package/public/sw.js +1 -1
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +72 -43
- 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',
|
|
@@ -1023,7 +1022,8 @@ async function runGroupCreateCli(args, entry) {
|
|
|
1023
1022
|
printLeafHelp(entry);
|
|
1024
1023
|
return 1;
|
|
1025
1024
|
}
|
|
1026
|
-
|
|
1025
|
+
const transient = args.includes('--transient');
|
|
1026
|
+
return groupVerb(args, '/api/groups/path', { path: pathArg, transient }, 'Create', (r) => `Created group "${r.path}" (${r.group})${transient ? ', transient' : ''}`);
|
|
1027
1027
|
}
|
|
1028
1028
|
|
|
1029
1029
|
async function runGroupRenameCli(args, entry) {
|
|
@@ -1089,37 +1089,20 @@ function describeGroupMembers(groups, sessions, activeOnly) {
|
|
|
1089
1089
|
});
|
|
1090
1090
|
}
|
|
1091
1091
|
|
|
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
1092
|
async function runGroupListCli(args) {
|
|
1107
1093
|
const activeOnly = !args.includes('--all');
|
|
1108
1094
|
let rev;
|
|
1109
1095
|
let groups;
|
|
1110
1096
|
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 || {})])];
|
|
1097
|
+
({ rev, groups } = await cliGetJson('/api/groups', 'Group list'));
|
|
1098
|
+
const sessionRefs = [...new Set(groups.flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref)))];
|
|
1116
1099
|
// An inactive session held by id is still listed, so only the active list needs the id lookups.
|
|
1117
1100
|
const lists = await Promise.all([
|
|
1118
1101
|
fetchSessionsList({ limit: null, activeOnly, lite: true }, 'Group list'),
|
|
1119
1102
|
activeOnly ? fetchSessionsByIds(sessionRefs, 'Group list', { lite: true }) : [],
|
|
1120
1103
|
]);
|
|
1121
1104
|
const sessions = uniqueById(lists.flat());
|
|
1122
|
-
groups = describeGroupMembers(
|
|
1105
|
+
groups = describeGroupMembers(groups, sessions, activeOnly);
|
|
1123
1106
|
} catch (e) { reportCliError(e); return 1; }
|
|
1124
1107
|
if (args.includes('--json')) {
|
|
1125
1108
|
console.log(JSON.stringify({ rev, groups }, null, 2));
|
|
@@ -1133,7 +1116,7 @@ async function runGroupListCli(args) {
|
|
|
1133
1116
|
const add = (parent, depth) => {
|
|
1134
1117
|
const pad = ' '.repeat(depth);
|
|
1135
1118
|
for (const g of groups.filter((x) => x.parent === parent)) {
|
|
1136
|
-
rows.push([g.
|
|
1119
|
+
rows.push([g.id, '', '', '', `${pad}${g.name}${g.transient ? ' (transient)' : ''} (${g.visible}/${g.total})`]);
|
|
1137
1120
|
for (const m of g.members) {
|
|
1138
1121
|
if (m.type === 'project') rows.push(['project', '', '', '', `${pad} ${m.ref} (${m.sessions} sessions)`]);
|
|
1139
1122
|
else if (m.missing) rows.push([m.ref.slice(0, 8), 'gone', '-', '', `${pad} (no such session)`]);
|
|
@@ -1420,10 +1403,7 @@ async function runDispatchStartCli(argv) {
|
|
|
1420
1403
|
}
|
|
1421
1404
|
const hasGroup = args.some(a => a === '--group' || a.startsWith('--group='));
|
|
1422
1405
|
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
|
-
}
|
|
1406
|
+
if (hasGroup && !group.trim()) return usageError(COMMANDS.dispatch.verbs.start, '--group needs a group name, path or id');
|
|
1427
1407
|
const worktree = args.includes('--worktree') ? getArgValue(args, 'worktree') || true : false;
|
|
1428
1408
|
const hasTaskList = args.some(a => a === '--task-list' || a.startsWith('--task-list='));
|
|
1429
1409
|
const ownList = process.env.CLAUDE_CODE_TASK_LIST_ID || process.env.CLAUDE_CODE_SESSION_ID;
|
|
@@ -1446,8 +1426,8 @@ async function runDispatchStartCli(argv) {
|
|
|
1446
1426
|
const out = await cliPostJson('/api/dispatch', body, 'Dispatch', { 'x-terminal-token': token });
|
|
1447
1427
|
if (!out) return 1;
|
|
1448
1428
|
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.
|
|
1429
|
+
else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}${out.transient ? ', transient' : ''}]` : ''}`);
|
|
1430
|
+
if (out.groupError) console.error(`Not grouped: ${out.groupError}`);
|
|
1451
1431
|
return 0;
|
|
1452
1432
|
} catch (e) { reportCliError(e); return 1; }
|
|
1453
1433
|
}
|
|
@@ -1458,15 +1438,6 @@ async function fetchDispatches(all) {
|
|
|
1458
1438
|
return (await cliGetJson(`/api/dispatch?${q}`, 'Dispatch list')).running.sort((a, b) => b.startedAt - a.startedAt);
|
|
1459
1439
|
}
|
|
1460
1440
|
|
|
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
1441
|
const dispatchLine = (r) => `${r.session}${r.name ? ` ${r.name}` : ''}${r.group ? ` [${r.group}]` : ''}`;
|
|
1471
1442
|
|
|
1472
1443
|
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
|
}
|
package/lib/terminal-client.js
CHANGED
|
@@ -42,6 +42,7 @@ function createTerminalClient(o) {
|
|
|
42
42
|
let hostReason = null;
|
|
43
43
|
let ids = [];
|
|
44
44
|
let terminalIds = [];
|
|
45
|
+
let awaitingTrust = [];
|
|
45
46
|
let serverUrl = null;
|
|
46
47
|
let restoreAsked = false;
|
|
47
48
|
let stopped = false;
|
|
@@ -78,6 +79,7 @@ function createTerminalClient(o) {
|
|
|
78
79
|
hostReason = msg.reason;
|
|
79
80
|
ids = msg.ids;
|
|
80
81
|
terminalIds = msg.terminalIds || [];
|
|
82
|
+
awaitingTrust = msg.awaitingTrust || [];
|
|
81
83
|
settle(hostReason);
|
|
82
84
|
o.onChange?.();
|
|
83
85
|
} else if (msg.t === 'exit') o.onExit?.(msg.id);
|
|
@@ -121,6 +123,7 @@ function createTerminalClient(o) {
|
|
|
121
123
|
const lost = ids;
|
|
122
124
|
ids = [];
|
|
123
125
|
terminalIds = [];
|
|
126
|
+
awaitingTrust = [];
|
|
124
127
|
for (const id of lost) o.onExit?.(id);
|
|
125
128
|
o.onChange?.();
|
|
126
129
|
const now = Date.now();
|
|
@@ -228,6 +231,7 @@ function createTerminalClient(o) {
|
|
|
228
231
|
token, handleUpgrade, clientConfig, unavailableReason, authorized, isRunning, hasTerminal, sessionOfTerminal, startNew, end, paste, restore,
|
|
229
232
|
setServerUrl, sessions, stats, shutdown, started,
|
|
230
233
|
ids: () => [...ids],
|
|
234
|
+
awaitingTrust: () => [...awaitingTrust],
|
|
231
235
|
};
|
|
232
236
|
}
|
|
233
237
|
|
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;
|
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) {
|
|
@@ -362,6 +372,7 @@ function createTerminalService(o) {
|
|
|
362
372
|
const saveDelayMs = o.saveDelayMs ?? SAVE_DELAY_MS;
|
|
363
373
|
const restoreGapMs = o.restoreGapMs ?? RESTORE_GAP_MS;
|
|
364
374
|
const exitPollMs = o.exitPollMs ?? CLAUDE_EXIT_POLL_MS;
|
|
375
|
+
const submitPollMs = o.submitPollMs ?? SUBMIT_POLL_MS;
|
|
365
376
|
const pidAlive = o.isPidAlive || isPidAlive;
|
|
366
377
|
const exitTimer = setInterval(checkExits, exitPollMs);
|
|
367
378
|
exitTimer.unref();
|
|
@@ -484,13 +495,24 @@ function createTerminalService(o) {
|
|
|
484
495
|
|
|
485
496
|
// Waits for claude to turn on bracketed paste (its input box is live), then for the
|
|
486
497
|
// 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.
|
|
498
|
+
// answer it, so the prompt waits for as long as that question is on screen. Once it is
|
|
499
|
+
// answered, claude turns paste off and redraws for seconds; text sent then is lost.
|
|
488
500
|
function bracketed(text) {
|
|
489
501
|
return `\x1b[200~${text.replace(/\r?\n/g, '\r')}\x1b[201~`;
|
|
490
502
|
}
|
|
491
503
|
|
|
504
|
+
// The folder and session names show on claude's main screen, so a "trust" in them is not the dialog.
|
|
492
505
|
function onTrustScreen(s) {
|
|
493
|
-
|
|
506
|
+
let text = screenText(s.term);
|
|
507
|
+
for (const own of [path.basename(s.cwd), s.name]) if (own) text = text.split(own).join('');
|
|
508
|
+
return /\btrust\b/i.test(text);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Checked only while the session's first prompt waits, so it ends for good once the prompt is sent.
|
|
512
|
+
function setAwaitingTrust(s, on) {
|
|
513
|
+
if (s.awaitingTrust === on) return;
|
|
514
|
+
s.awaitingTrust = on;
|
|
515
|
+
changed();
|
|
494
516
|
}
|
|
495
517
|
|
|
496
518
|
// `submit` is claude's prompt: wait for its input box, then press Enter. Without it the text
|
|
@@ -502,18 +524,47 @@ function createTerminalService(o) {
|
|
|
502
524
|
let timer = null;
|
|
503
525
|
const text = bracketed(raw);
|
|
504
526
|
s.onOutput = (data) => {
|
|
505
|
-
if (
|
|
506
|
-
|
|
527
|
+
if (submit) {
|
|
528
|
+
const on = data.lastIndexOf('\x1b[?2004h');
|
|
529
|
+
const off = data.lastIndexOf('\x1b[?2004l');
|
|
530
|
+
if (on !== off) armed = on > off;
|
|
531
|
+
}
|
|
507
532
|
clearTimeout(timer);
|
|
533
|
+
if (!armed) return;
|
|
508
534
|
timer = setTimeout(() => {
|
|
509
|
-
if (s.exited
|
|
535
|
+
if (s.exited) return;
|
|
536
|
+
if (submit && onTrustScreen(s)) return setAwaitingTrust(s, TRUST_DIALOG.test(screenText(s.term)));
|
|
537
|
+
setAwaitingTrust(s, false);
|
|
510
538
|
s.onOutput = null;
|
|
511
539
|
s.pty.write(text);
|
|
512
|
-
if (submit) setTimeout(() =>
|
|
540
|
+
if (submit) setTimeout(() => submitPrompt(s), 100);
|
|
513
541
|
}, PROMPT_QUIET_MS);
|
|
514
542
|
};
|
|
515
543
|
}
|
|
516
544
|
|
|
545
|
+
// claude sometimes drops the Enter after the paste and leaves the prompt typed but not sent.
|
|
546
|
+
// It bumps statusUpdatedAt in its registry entry when a turn starts, so while the entry stays
|
|
547
|
+
// idle and older than the Enter, Enter is pressed again. Only an idle entry gets it: a turn
|
|
548
|
+
// that started can show a permission dialog, and Enter there would pick its default.
|
|
549
|
+
function submitPrompt(s) {
|
|
550
|
+
if (s.exited) return;
|
|
551
|
+
let at = Date.now();
|
|
552
|
+
let checks = 0;
|
|
553
|
+
let retries = 0;
|
|
554
|
+
s.pty.write('\r');
|
|
555
|
+
s.submitTimer = setInterval(() => {
|
|
556
|
+
const entry = ownEntry(o.liveSessions(), s, s.startedAt);
|
|
557
|
+
const unsent = !s.exited && entry?.status === 'idle' && typeof entry.statusUpdatedAt === 'number' && entry.statusUpdatedAt < at;
|
|
558
|
+
if (!unsent || (++checks === SUBMIT_CHECKS && retries === SUBMIT_RETRIES)) return clearInterval(s.submitTimer);
|
|
559
|
+
if (checks < SUBMIT_CHECKS) return;
|
|
560
|
+
checks = 0;
|
|
561
|
+
retries++;
|
|
562
|
+
at = Date.now();
|
|
563
|
+
s.pty.write('\r');
|
|
564
|
+
}, submitPollMs);
|
|
565
|
+
s.submitTimer.unref();
|
|
566
|
+
}
|
|
567
|
+
|
|
517
568
|
// The process a viewer types into: claude, found through its registry entry, or the shell.
|
|
518
569
|
function inputPid(s, registry) {
|
|
519
570
|
if (s.mode === 'shell') return s.pty.pid;
|
|
@@ -605,7 +656,7 @@ function createTerminalService(o) {
|
|
|
605
656
|
term.loadAddon(serializer);
|
|
606
657
|
const s = {
|
|
607
658
|
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,
|
|
659
|
+
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, awaitingTrust: false, edit: !!spec?.edit,
|
|
609
660
|
forkOf: spec?.forkOf || null, resumeOf: spec?.resume || null, restore: spec?.restore !== false,
|
|
610
661
|
};
|
|
611
662
|
if (s.edit) queueText(s, claudeCommand(shell, claudeArgs, spec.prompt), false);
|
|
@@ -624,6 +675,7 @@ function createTerminalService(o) {
|
|
|
624
675
|
s.exited = true;
|
|
625
676
|
clearInterval(s.pickTimer);
|
|
626
677
|
clearInterval(s.boostTimer);
|
|
678
|
+
clearInterval(s.submitTimer);
|
|
627
679
|
if (sessions.get(s.id) === s) sessions.delete(s.id);
|
|
628
680
|
o.onExit?.(s.id);
|
|
629
681
|
if (!s.ended) changed();
|
|
@@ -817,7 +869,7 @@ function createTerminalService(o) {
|
|
|
817
869
|
function list() {
|
|
818
870
|
return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
|
|
819
871
|
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,
|
|
872
|
+
name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf, claudeExited: !!s.claudeExitedAt, awaitingTrust: s.awaitingTrust,
|
|
821
873
|
}));
|
|
822
874
|
}
|
|
823
875
|
|