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 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',
@@ -1023,7 +1022,8 @@ async function runGroupCreateCli(args, entry) {
1023
1022
  printLeafHelp(entry);
1024
1023
  return 1;
1025
1024
  }
1026
- return groupVerb(args, '/api/groups/path', { path: pathArg }, 'Create', (r) => `Created group "${r.path}" (${r.group})`);
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
- 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 || {})])];
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(placeDispatched(groups, sessions, released), sessions, activeOnly);
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.dispatch ? '-' : g.id, '', '', '', `${pad}${g.name}${g.dispatch ? ' (dispatch)' : ''} (${g.visible}/${g.total})`]);
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 && !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
- }
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.group) await noteJoinedGroup(out.session, out.group);
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
- "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
  }
@@ -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
 
@@ -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;
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
- return /\btrust\b/i.test(screenText(s.term));
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 (!armed && data.includes('\x1b[?2004h')) armed = true;
506
- if (!armed) return;
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 || (submit && onTrustScreen(s))) return;
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(() => { if (!s.exited) s.pty.write('\r'); }, 100);
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