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 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, dispatch groups included, with the members the sidebar's Active view shows (active, pinned or sticky); `--all` for every member.
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, top of list)',
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: 'Also takes it out of its dispatch group. A session whose project is in a group still shows there; move the project on the board.',
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 dispatch group has dispatch: true and id dispatch:<name>; a session member has title, branch, status, pinned and age, or missing: true; a project member has sessions (its shown sessions)',
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 session started with `dispatch start --group` shows in that dispatch group, marked (dispatch) and listed after the user groups, unless a user group holds it or its project; a user group with the same name takes it in. Dispatch groups go when their sessions end. Pass a user group\'s name, path (parent/child) or id to `session group`; dispatch:<name> there makes the user group <name>, which keeps the sessions after they end.',
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); dispatch:<name> makes a user group that takes in that dispatch group',
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, their dispatch group or the top level. Its child groups move up one level. A dispatch group cannot be removed: it goes when its sessions end.',
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>': 'Show it with this session in a kebab-case group (default: this session\'s group)',
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 sessionId = getArgValue(args, 'session') || process.env.PREVIEW_SESSION || null;
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
- return groupVerb(args, '/api/groups/path', { path: pathArg }, 'Create', (r) => `Created group "${r.path}" (${r.group})`);
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
- let released;
1112
- let dispatch;
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(placeDispatched(groups, sessions, released), sessions, activeOnly);
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.dispatch ? '-' : g.id, '', '', '', `${pad}${g.name}${g.dispatch ? ' (dispatch)' : ''} (${g.visible}/${g.total})`]);
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 && !isGroupName(group)) {
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.group) await noteJoinedGroup(out.session, out.group);
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
- "id": "claude-code-kanban@claude-code-kanban",
11
- "path": "plugin/plugins/claude-code-kanban",
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`);
@@ -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
- module.exports = { readLiveSessions, isPidAlive, isSessionLive };
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.
@@ -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
 
@@ -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 = { t: 'state', reason: service.unavailableReason(), ids: live.map((s) => s.id), terminalIds: live.map((s) => s.terminalId) };
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
- return /\btrust\b/i.test(screenText(s.term));
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 (!armed && data.includes('\x1b[?2004h')) armed = true;
506
- if (!armed) return;
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 || (submit && onTrustScreen(s))) return;
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(() => { if (!s.exited) s.pty.write('\r'); }, 100);
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 || (live.some((l) => l.pid === pid) && pidAlive(pid))) continue;
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.pickChecking) return;
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.pickChecking = true;
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
- const elsewhere = o.isLiveElsewhere(picked, s.claudePid);
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