claude-code-kanban 5.4.0 → 6.1.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
@@ -21,25 +21,35 @@ Watch the tour on YouTube: [light](https://youtu.be/QbvDBFyfC7s), [dark](https:/
21
21
 
22
22
  You need Node.js 20 or later and the `claude` CLI.
23
23
 
24
- ### 1. Install the integration (one time)
24
+ ### 1. Install the command
25
25
 
26
26
  ```bash
27
- npx claude-code-kanban --install
27
+ npm install -g claude-code-kanban
28
+ ```
29
+
30
+ To update, run the same command again.
31
+
32
+ To try it without an install, run `npx claude-code-kanban --open`. npx keeps a copy of each version in its cache and can run an old copy, so use the global install to keep the board and to update it.
33
+
34
+ ### 2. Install the integration (one time)
35
+
36
+ ```bash
37
+ claude-code-kanban --install
28
38
  ```
29
39
 
30
40
  The installer adds a Claude Code plugin with hooks, skills and a mod for context use and cost (Claude Code 2.1.287 or later). It asks before it installs and keeps your other settings. Without the hooks, the board shows tasks only: no agent log, no live activity, no waiting prompts.
31
41
 
32
- To remove it, run `npx claude-code-kanban --uninstall`. For another Claude config dir, pass the same `--dir=<path>` (or set `CLAUDE_CONFIG_DIR`) to `--install`, `--uninstall` and the server. See [Getting started](https://nikiforovall.blog/claude-code-kanban/getting-started/) for each install step.
42
+ To remove it, run `claude-code-kanban --uninstall`. For another Claude config dir, pass the same `--dir=<path>` (or set `CLAUDE_CONFIG_DIR`) to `--install`, `--uninstall` and the server. See [Getting started](https://nikiforovall.blog/claude-code-kanban/getting-started/) for each install step.
33
43
 
34
- ### 2. Start the board
44
+ ### 3. Start the board
35
45
 
36
46
  ```bash
37
- npx claude-code-kanban --open
47
+ claude-code-kanban --open
38
48
  ```
39
49
 
40
- The board runs at `http://localhost:3541`. To install the command globally, run `npm install -g claude-code-kanban`, then `claude-code-kanban --open`.
50
+ The board runs at `http://localhost:3541`.
41
51
 
42
- ### 3. Use Claude Code as usual
52
+ ### 4. Use Claude Code as usual
43
53
 
44
54
  Run `claude` in any project. You do not configure anything per project. Claude Code writes task files and transcripts to the config dir, and the board watches them and sends each change to the browser.
45
55
 
@@ -71,7 +81,7 @@ Run `claude` in any project. You do not configure anything per project. Claude C
71
81
  - **Answer prompts from the board.** When Claude asks for permission, asks a question or waits for plan approval, the session gets an amber highlight and the ask shows with Allow and Deny buttons or an answer form. The terminal prompt stays open, and the first answer wins. [Answer prompts from the board](https://nikiforovall.blog/claude-code-kanban/guides/waiting-prompts/)
72
82
  - **Embedded terminal.** Run a real Claude Code process for any session next to its board (<kbd>Ctrl</kbd>+<kbd>&#96;</kbd>). <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>R</kbd> resumes a past session and <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> swaps to the previous one. The terminal is off by default when the board runs alone. Start it with `--enable-terminal` and open the `#t=<token>` link the server prints. [Embedded terminal](https://nikiforovall.blog/claude-code-kanban/guides/embedded-terminal/)
73
83
  - **New session.** <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>N</kbd> opens a dialog to pick a folder, a name, a model, an optional git worktree and a first prompt. Needs the terminal.
74
- - **Dispatch.** Hand a written task to a new session with `claude-code-kanban dispatch start`, or ask Claude to do it with the `dispatch` skill. Add `--report` to get the outcome back. Needs the terminal. [Dispatch tasks to other sessions](https://nikiforovall.blog/claude-code-kanban/guides/dispatch/)
84
+ - **Dispatch.** Hand a written task to a new session with `claude-code-kanban dispatch start`, or ask Claude to do it with the `dispatch` skill. Args after `--` go to `claude` as they are, and the started session reports back with `SendMessage`. Needs the terminal. [Dispatch tasks to other sessions](https://nikiforovall.blog/claude-code-kanban/guides/dispatch/)
75
85
  - **Steer with card moves.** Run `/claude-code-kanban:follow` in a session, then drag its cards. Claude starts, parks or stops the task. [Claude Code plugin skills](https://nikiforovall.blog/claude-code-kanban/guides/plugin-skills/)
76
86
  - **Review comments.** Select text in a previewed file or the plan, add comments and send them to the session in one step. [Review comments](https://nikiforovall.blog/claude-code-kanban/guides/review-comments/)
77
87
 
@@ -100,17 +110,17 @@ With no subcommand, `claude-code-kanban` starts the server. Subcommands talk to
100
110
  - `session list|search|open|view|plan|agents|pin|pins|peek` to read and focus sessions.
101
111
  - `task list` and `project list` to read tasks and projects.
102
112
  - `preview-doc` and `link-doc` to show or link a file on the board.
103
- - `dispatch start|done|wait|list` to start sessions with a task and collect their reports.
113
+ - `dispatch start|list` to start sessions with a task in the board's terminal.
104
114
 
105
115
  Run `claude-code-kanban --help` or see the [CLI reference](https://nikiforovall.blog/claude-code-kanban/reference/cli/).
106
116
 
107
117
  ## Configuration
108
118
 
109
119
  ```bash
110
- PORT=8080 npx claude-code-kanban # Custom port. If it is busy, the server uses a random free port.
111
- npx claude-code-kanban --dir=~/.claude-work # Another Claude config dir (or CLAUDE_CONFIG_DIR)
112
- npx claude-code-kanban --enable-terminal # Turn on the embedded terminal
113
- EDITOR="code -w" npx claude-code-kanban # Command for Open in editor (default: code)
120
+ PORT=8080 claude-code-kanban # Custom port. If it is busy, the server uses a random free port.
121
+ claude-code-kanban --dir=~/.claude-work # Another Claude config dir (or CLAUDE_CONFIG_DIR)
122
+ claude-code-kanban --enable-terminal # Turn on the embedded terminal
123
+ EDITOR="code -w" claude-code-kanban # Command for Open in editor (default: code)
114
124
  ```
115
125
 
116
126
  - The server listens on `127.0.0.1` only and has no authentication. To reach it from another machine, use `--host` and `--allowed-hosts`, and do it only on a network you trust.
package/cli.js CHANGED
@@ -180,58 +180,30 @@ const COMMANDS = {
180
180
  },
181
181
  },
182
182
  dispatch: {
183
- summary: 'Start a Claude Code session for a task in cck and collect its report',
183
+ summary: 'Start a Claude Code session for a task in cck\'s terminal',
184
184
  verbs: {
185
185
  start: {
186
- summary: 'Start a session with a task; prints the dispatch id',
187
- usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--report] [--peer <name>] [--model <m>] [--worktree [name]] [--json]',
186
+ summary: 'Start claude in cck\'s terminal with a task; prints the session id',
187
+ usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--model <m>] [--worktree [name]] [--json] [-- <claude args>...]',
188
188
  flags: {
189
189
  '--cwd <dir>': 'Folder to run in (a known project, default: current dir)',
190
- '--spec <text>': 'The task, self-contained',
190
+ '--spec <text>': 'The task, self-contained; sent as the first message',
191
191
  '--spec-file <path>': 'Read the task from a file',
192
- '--name <n>': 'Session name',
192
+ '--name <n>': 'Session name; also its peer name for SendMessage',
193
193
  '--group <g>': 'Show it with this session in a kebab-case group (default: this session\'s group)',
194
- '--report': 'Ask it to report its outcome back to this session',
195
- '--peer <name>': 'This session\'s peer name; it sends questions and findings there with SendMessage',
196
194
  '--model <m>': 'fable, opus, sonnet or haiku',
197
195
  '--worktree [name]': 'Run in a new git worktree',
198
196
  '--json': 'Output JSON',
197
+ '-- <claude args>': 'Passed to claude as they are, e.g. --permission-mode auto. No quotes, % or control characters; cck sets --session-id, --name, --model and --worktree',
199
198
  },
200
- notes: 'Needs the terminal token, so it runs on the machine of the cck server. Run `claude-code-kanban skills get dispatch` for how to write the spec.',
199
+ notes: 'Needs the terminal token, so it runs on the machine of the cck server. cck sends no report: say in the spec how the session reports back. Run `claude-code-kanban skills get dispatch` for how to write the spec.',
201
200
  examples: [
202
- 'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --group auth-refactor --peer my-peer --report --json',
201
+ 'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --group auth-refactor --model sonnet -- --permission-mode auto',
203
202
  ],
204
203
  run: runDispatchStartCli,
205
204
  },
206
- done: {
207
- summary: 'Report the outcome of a dispatch (run by the started session)',
208
- usage: 'claude-code-kanban dispatch done <id> --cap <cap> --outcome succeeded|failed (--summary <text> | --summary-file <path>)',
209
- flags: {
210
- '<id>': 'Dispatch id from the preamble',
211
- '--cap <cap>': 'Capability from the preamble',
212
- '--outcome <o>': 'succeeded or failed',
213
- '--summary <text>': 'What changed, what was found, what remains',
214
- '--summary-file <path>': 'Read the summary from a file',
215
- },
216
- examples: [
217
- 'claude-code-kanban dispatch done d_1a2b3c --cap <cap> --outcome succeeded --summary-file summary.md',
218
- ],
219
- run: runDispatchDoneCli,
220
- },
221
- wait: {
222
- summary: 'Wait until a dispatch settles; a timeout is a checkpoint, not a failure',
223
- usage: 'claude-code-kanban dispatch wait [<id>...] [--timeout <dur>] [--json]',
224
- flags: {
225
- '<id>': 'Dispatches to wait on (default: all started by this session)',
226
- '--timeout <dur>': 'How long to wait, e.g. 90s, 15m, 1h (default: 10m)',
227
- '--json': 'Output JSON',
228
- },
229
- notes: 'Returns as soon as any watched dispatch settles, with settled, running and timeout.',
230
- examples: ['claude-code-kanban dispatch wait --timeout 15m --json'],
231
- run: runDispatchWaitCli,
232
- },
233
205
  list: {
234
- summary: 'List dispatches started by this session',
206
+ summary: 'List sessions this session started that still run in cck\'s terminal',
235
207
  usage: 'claude-code-kanban dispatch list [--all] [--json]',
236
208
  flags: {
237
209
  '--all': 'Every dispatch on this board',
@@ -1042,19 +1014,10 @@ function textArg(args, name) {
1042
1014
  return file ? fs.readFileSync(path.resolve(file), 'utf8') : getArgValue(args, name);
1043
1015
  }
1044
1016
 
1045
- function parseDuration(raw, fallbackSec) {
1046
- if (!raw) return fallbackSec;
1047
- const m = /^(\d+(?:\.\d+)?)(s|m|h)?$/.exec(raw);
1048
- if (!m) return null;
1049
- return Number(m[1]) * ({ s: 1, m: 60, h: 3600 }[m[2] || 's']);
1050
- }
1051
-
1052
- function printDispatch(r) {
1053
- const head = `${r.id} ${r.status.padEnd(9)} session=${r.session}${r.name ? ` ${r.name}` : ''}`;
1054
- console.log(r.summary ? `${head}\n ${r.summary}` : head);
1055
- }
1056
-
1057
- async function runDispatchStartCli(args) {
1017
+ async function runDispatchStartCli(argv) {
1018
+ const sep = argv.indexOf('--');
1019
+ const args = sep === -1 ? argv : argv.slice(0, sep);
1020
+ const claudeArgs = sep === -1 ? [] : argv.slice(sep + 1);
1058
1021
  const port = cliTargetPort();
1059
1022
  if (port === null) {
1060
1023
  console.error(unreachable());
@@ -1085,73 +1048,27 @@ async function runDispatchStartCli(args) {
1085
1048
  model: getArgValue(args, 'model'),
1086
1049
  worktree,
1087
1050
  group,
1088
- report: args.includes('--report'),
1089
- peer: getArgValue(args, 'peer') || null,
1051
+ claudeArgs,
1090
1052
  parent: process.env.CLAUDE_CODE_SESSION_ID || null,
1091
1053
  };
1092
1054
  try {
1093
1055
  const out = await cliPostJson('/api/dispatch', body, 'Dispatch', { 'x-terminal-token': token });
1094
1056
  if (!out) return 1;
1095
1057
  if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
1096
- else console.log(`Started ${out.dispatch} (session ${out.session}) in ${out.cwd}${out.group ? ` [${out.group}]` : ''}`);
1097
- return 0;
1098
- } catch (e) { reportCliError(e); return 1; }
1099
- }
1100
-
1101
- async function runDispatchDoneCli(args) {
1102
- const [id] = positionals(args, ['--cap', '--outcome', '--summary', '--summary-file']);
1103
- let summary;
1104
- try { summary = textArg(args, 'summary'); } catch (e) { console.error(e.message); return 1; }
1105
- const body = { cap: getArgValue(args, 'cap'), outcome: getArgValue(args, 'outcome'), summary };
1106
- if (!id || !body.cap || !body.outcome) {
1107
- printLeafHelp(COMMANDS.dispatch.verbs.done);
1108
- return 1;
1109
- }
1110
- try {
1111
- if (!await cliPostJson(`/api/dispatch/${encodeURIComponent(id)}/done`, body, 'Report')) return 1;
1112
- console.log(`Reported ${id}: ${body.outcome}`);
1113
- return 0;
1114
- } catch (e) { reportCliError(e); return 1; }
1115
- }
1116
-
1117
- function dispatchQuery(ids, all = false) {
1118
- const q = new URLSearchParams();
1119
- if (ids.length) q.set('ids', ids.join(','));
1120
- else if (!all && process.env.CLAUDE_CODE_SESSION_ID) q.set('parent', process.env.CLAUDE_CODE_SESSION_ID);
1121
- return q;
1122
- }
1123
-
1124
- async function runDispatchWaitCli(args) {
1125
- const timeoutRaw = getArgValue(args, 'timeout');
1126
- const timeoutSec = parseDuration(timeoutRaw, 600);
1127
- if (timeoutSec === null) return usageError(COMMANDS.dispatch.verbs.wait, `Invalid --timeout value: ${timeoutRaw}`);
1128
- const q = dispatchQuery(positionals(args, ['--timeout']));
1129
- const deadline = Date.now() + timeoutSec * 1000;
1130
- try {
1131
- let out;
1132
- do {
1133
- q.set('wait', String(Math.max(1, Math.min(120, Math.ceil((deadline - Date.now()) / 1000)))));
1134
- const res = await cliFetch(`/api/dispatch?${q}`);
1135
- out = await res.json();
1136
- } while (out.timeout && Date.now() < deadline);
1137
- if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
1138
- else {
1139
- for (const r of out.settled) printDispatch(r);
1140
- if (out.running.length) console.log(`${out.timeout ? 'Timed out; still running' : 'Still running'}: ${out.running.map(r => r.id).join(' ')}`);
1141
- if (!out.settled.length && !out.running.length) console.log('No dispatches to wait on.');
1142
- }
1058
+ else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}]` : ''}`);
1143
1059
  return 0;
1144
1060
  } catch (e) { reportCliError(e); return 1; }
1145
1061
  }
1146
1062
 
1147
1063
  async function runDispatchListCli(args) {
1064
+ const q = new URLSearchParams();
1065
+ if (!args.includes('--all') && process.env.CLAUDE_CODE_SESSION_ID) q.set('parent', process.env.CLAUDE_CODE_SESSION_ID);
1148
1066
  try {
1149
- const res = await cliFetch(`/api/dispatch?${dispatchQuery([], args.includes('--all'))}`);
1150
- const { settled, running } = await res.json();
1151
- const rows = [...running, ...settled].sort((a, b) => b.startedAt - a.startedAt);
1067
+ const res = await cliFetch(`/api/dispatch?${q}`);
1068
+ const rows = (await res.json()).running.sort((a, b) => b.startedAt - a.startedAt);
1152
1069
  if (args.includes('--json')) console.log(JSON.stringify(rows, null, 2));
1153
1070
  else if (!rows.length) console.log('No dispatches.');
1154
- else rows.forEach(printDispatch);
1071
+ else for (const r of rows) console.log(`${r.session}${r.name ? ` ${r.name}` : ''}${r.group ? ` [${r.group}]` : ''}`);
1155
1072
  return 0;
1156
1073
  } catch (e) { reportCliError(e); return 1; }
1157
1074
  }
@@ -1,6 +1,5 @@
1
- // Transient session groups: `dispatch start --group` (or the starter's own group) puts
2
- // the started session in a group. The starter is not moved: it is only remembered, so its
3
- // later dispatches default to the same group. A group lives while any member's claude
1
+ // Transient session groups: `dispatch start --group` puts the started session in a group,
2
+ // and only an explicit `--group` does. A group lives while any member's claude
4
3
  // runs, and for a grace period after; once none runs, only pinned members stay in it.
5
4
  // The map is on disk so a pinned group outlives a server restart.
6
5
 
@@ -25,39 +24,28 @@ function suggestGroupName(s) {
25
24
 
26
25
  /**
27
26
  * @param {object} o
28
- * @param {() => object|null} o.load returns `{sessions: {[id]: group}, starters?: {[id]: starterId}}` or null
27
+ * @param {() => object|null} o.load returns `{sessions: {[id]: group}}` or null
29
28
  * @param {(data: object) => void} o.save
30
29
  * @param {(id: string) => boolean} o.isAlive
31
30
  * @param {() => Set<string>} o.pinnedIds
32
31
  */
33
32
  function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
34
33
  const members = new Map();
35
- const starterOf = new Map();
36
34
  const liveAt = new Map();
37
35
  const saved = load();
38
36
  if (saved?.sessions && typeof saved.sessions === 'object') {
39
37
  for (const [id, g] of Object.entries(saved.sessions)) if (isGroupName(g)) members.set(id, g);
40
38
  }
41
- if (saved?.starters && typeof saved.starters === 'object') {
42
- for (const [id, s] of Object.entries(saved.starters)) if (members.has(id) && typeof s === 'string') starterOf.set(id, s);
43
- }
44
39
  for (const g of members.values()) liveAt.set(g, now());
45
40
 
46
- const persist = () =>
47
- save({ version: 1, sessions: Object.fromEntries(members), starters: Object.fromEntries(starterOf) });
41
+ const persist = () => save({ version: 1, sessions: Object.fromEntries(members) });
48
42
 
49
- function join(group, ids, starter) {
43
+ function join(group, ids) {
50
44
  let changed = false;
51
45
  for (const id of ids) {
52
- if (!id) continue;
53
- if (members.get(id) !== group) {
54
- members.set(id, group);
55
- changed = true;
56
- }
57
- if (starter && starterOf.get(id) !== starter) {
58
- starterOf.set(id, starter);
59
- changed = true;
60
- }
46
+ if (!id || members.get(id) === group) continue;
47
+ members.set(id, group);
48
+ changed = true;
61
49
  }
62
50
  liveAt.set(group, now());
63
51
  if (changed) persist();
@@ -82,7 +70,6 @@ function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
82
70
  for (const id of ids) {
83
71
  if (pinned.has(id)) continue;
84
72
  members.delete(id);
85
- starterOf.delete(id);
86
73
  changed = true;
87
74
  }
88
75
  if (!ids.some((id) => pinned.has(id))) liveAt.delete(g);
@@ -90,21 +77,12 @@ function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
90
77
  if (changed) persist();
91
78
  }
92
79
 
93
- // A starter is not a member; it defaults to the group of a session it started.
94
- function groupOf(id) {
95
- refresh();
96
- if (members.has(id)) return members.get(id);
97
- let group = null;
98
- for (const [child, starter] of starterOf) if (starter === id) group = members.get(child);
99
- return group;
100
- }
101
-
102
80
  function snapshot() {
103
81
  refresh();
104
82
  return members;
105
83
  }
106
84
 
107
- return { join, groupOf, snapshot };
85
+ return { join, snapshot };
108
86
  }
109
87
 
110
88
  module.exports = { createGroupStore, isGroupName, suggestGroupName, GRACE_MS };
package/lib/dispatch.js CHANGED
@@ -1,179 +1,32 @@
1
- // Dispatch: one Claude Code session started by another through cck, and the report it
2
- // settles with. In memory on purpose: the PTYs die with the server, so nothing a
3
- // restart drops could still settle.
4
- //
5
- // The child holds a per-dispatch capability, never the terminal token, so it can
6
- // report its own outcome and nothing else. A stale or duplicate child is refused
7
- // because a record settles once.
1
+ // Dispatch: a Claude Code session one session started in cck's terminal. In memory on
2
+ // purpose: an entry lives as long as its PTY, which dies with the server.
8
3
 
9
- const crypto = require('node:crypto');
10
- const { tokenMatches } = require('./terminal');
11
- const { clampWait, EVENT_PREFIX } = require('./session-events');
4
+ function createDispatchRegistry({ now = Date.now } = {}) {
5
+ const entries = new Map();
12
6
 
13
- const OUTCOMES = new Set(['succeeded', 'failed']);
14
- const MAX_SUMMARY = 4000;
15
- // Settled records outlive their session long enough for a parent to collect them.
16
- const KEEP_SETTLED_MS = 24 * 60 * 60 * 1000;
17
- const ID_RE = /^d_[0-9a-f]{12}$/;
18
- // The peer name lands verbatim in the started session's prompt.
19
- const PEER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
20
-
21
- const isPeerName = (v) => typeof v === 'string' && PEER_RE.test(v);
22
-
23
- // With neither --report nor --peer the started session gets the task alone, like one the user typed.
24
- function formatPreamble(record, cli = 'claude-code-kanban') {
25
- if (!record.report && !record.peer) return record.spec;
26
- const lines = [`[cck dispatch ${record.id}] Another Claude Code session started you through claude-code-kanban to do the task below.`];
27
- if (record.peer) {
28
- lines.push(
29
- `That session is the peer "${record.peer}". Ask it, not the user: when you need a decision, or find something that changes the task, send a short message with the SendMessage tool to "${record.peer}".`,
30
- 'Keep working on what does not depend on the answer. The reply arrives as a new message.',
31
- );
32
- }
33
- if (record.report) {
34
- lines.push(
35
- 'When the task is done, or you cannot finish it, report exactly once with this command, then stop:',
36
- `${cli} dispatch done ${record.id} --cap ${record.cap} --outcome succeeded --summary "<what changed, what you found, what remains>"`,
37
- record.peer
38
- ? 'Use --outcome failed when the task cannot be finished. If the command refuses because the dispatch already ended, send the summary to the peer instead.'
39
- : 'Use --outcome failed when the task is not done. Do not ask the user questions the other session must answer; report failed with the question instead.',
40
- );
41
- }
42
- lines.push('', 'Task:', record.spec);
43
- return lines.join('\n');
44
- }
45
-
46
- // The line reaches the parent through the postman at hook trust level; the summary is
47
- // last so no summary text can pose as a further field.
48
- const DISPATCH_OUTCOME = {
49
- succeeded: 'reported success',
50
- failed: 'reported failure',
51
- exited: 'ended without a report',
52
- };
53
-
54
- function formatDispatchLine(r) {
55
- const outcome = DISPATCH_OUTCOME[r.status] || r.status;
56
- const head = `${EVENT_PREFIX} Dispatch ${r.id} (session ${r.session}) ${outcome}.`;
57
- return r.summary ? `${head} Summary: ${r.summary}` : head;
58
- }
59
-
60
- function publicView(r) {
61
- const { cap: _cap, ...rest } = r;
62
- return rest;
63
- }
64
-
65
- function createDispatchRegistry({ onSettle, now = Date.now } = {}) {
66
- const records = new Map();
67
- const waiters = new Set();
68
-
69
- function prune() {
70
- const cutoff = now() - KEEP_SETTLED_MS;
71
- for (const [id, r] of records) if (r.settledAt && r.settledAt < cutoff) records.delete(id);
72
- }
73
-
74
- function create({ parent, spec, name, report, peer, group, worktree }) {
75
- prune();
76
- const r = {
77
- id: `d_${crypto.randomBytes(6).toString('hex')}`,
78
- cap: crypto.randomBytes(16).toString('hex'),
7
+ function add({ session, parent, cwd, name, group, worktree }) {
8
+ entries.set(session, {
9
+ session,
79
10
  parent: parent || null,
80
- session: null,
81
- cwd: null,
11
+ cwd,
82
12
  name: name || null,
83
- report: !!report,
84
- peer: peer || null,
85
13
  group: group || null,
86
14
  worktree: worktree || null,
87
- spec,
88
- status: 'running',
89
- summary: null,
90
15
  startedAt: now(),
91
- settledAt: null,
92
- };
93
- records.set(r.id, r);
94
- return r;
95
- }
96
-
97
- // The preamble carries the id, so the record exists before its session does.
98
- function attach(id, { session, cwd }) {
99
- const r = records.get(id);
100
- if (r) Object.assign(r, { session, cwd });
101
- }
102
-
103
- function discard(id) {
104
- records.delete(id);
105
- }
106
-
107
- function finish(r, status, summary) {
108
- r.status = status;
109
- r.summary = summary;
110
- r.settledAt = now();
111
- onSettle?.(r);
112
- for (const wake of [...waiters]) wake();
113
- }
114
-
115
- // Returns null when settled, or {status, error} naming the refusal.
116
- function settle(id, cap, outcome, summary) {
117
- const r = typeof id === 'string' && ID_RE.test(id) ? records.get(id) : null;
118
- if (!r) return { status: 404, error: 'no such dispatch' };
119
- if (!tokenMatches(r.cap, cap)) return { status: 403, error: 'wrong capability' };
120
- if (r.status !== 'running') return { status: 409, error: `already ${r.status}` };
121
- if (!OUTCOMES.has(outcome)) return { status: 400, error: 'outcome must be succeeded or failed' };
122
- // biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
123
- const text = typeof summary === 'string' ? summary.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, MAX_SUMMARY) : '';
124
- finish(r, outcome, text || null);
125
- return null;
126
- }
127
-
128
- // The PTY ended with no report: the parent must not wait on it forever.
129
- function sessionExited(sessionId) {
130
- for (const r of records.values()) if (r.session === sessionId && r.status === 'running') finish(r, 'exited', null);
131
- }
132
-
133
- function select({ ids, parent } = {}) {
134
- prune();
135
- let out = [...records.values()];
136
- if (ids?.length) out = out.filter((r) => ids.includes(r.id));
137
- else if (parent) out = out.filter((r) => r.parent === parent);
138
- return out;
139
- }
140
-
141
- function list(filter) {
142
- return select(filter).map(publicView);
16
+ });
143
17
  }
144
18
 
145
- // Resolves once any selected record is settled, or after `waitSec`. Stateless for the
146
- // caller: it passes the ids still running on the next call. `onClose(stop)` returns an
147
- // unsubscribe, called once the wait ends.
148
- function wait(filter, waitSec, onClose) {
149
- const sec = clampWait(waitSec);
150
- const snapshot = () => {
151
- const rows = select(filter);
152
- return {
153
- settled: rows.filter((r) => r.status !== 'running').map(publicView),
154
- running: rows.filter((r) => r.status === 'running').map(publicView),
155
- };
156
- };
157
- const first = snapshot();
158
- if (first.settled.length || !first.running.length || !sec) return Promise.resolve({ ...first, timeout: false });
159
- return new Promise((resolve) => {
160
- let unsubscribe;
161
- const done = (timeout) => {
162
- if (!waiters.delete(wake)) return;
163
- clearTimeout(timer);
164
- unsubscribe?.();
165
- resolve({ ...snapshot(), timeout });
166
- };
167
- const wake = () => {
168
- if (select(filter).some((r) => r.status !== 'running')) done(false);
169
- };
170
- const timer = setTimeout(() => done(true), sec * 1000);
171
- waiters.add(wake);
172
- unsubscribe = onClose?.(() => done(true));
173
- });
19
+ function list({ parent } = {}) {
20
+ const out = [...entries.values()];
21
+ return parent ? out.filter((e) => e.parent === parent) : out;
174
22
  }
175
23
 
176
- return { create, attach, discard, settle, sessionExited, list, wait };
24
+ return {
25
+ add,
26
+ list,
27
+ has: (session) => entries.has(session),
28
+ remove: (session) => entries.delete(session),
29
+ };
177
30
  }
178
31
 
179
- module.exports = { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName, DISPATCH_OUTCOME };
32
+ module.exports = { createDispatchRegistry };
package/lib/git-branch.js CHANGED
@@ -44,4 +44,19 @@ function readGitBranch(cwd, readFile = (f) => fs.readFileSync(f, 'utf8')) {
44
44
  }
45
45
  }
46
46
 
47
- module.exports = { readGitBranch };
47
+ /**
48
+ * Reads HEAD only where the transcript's `gitBranch` is wrong: cwd moved away from the launch
49
+ * project, or the project is a worktree (a `claude -w` session records the main checkout's
50
+ * branch on every line).
51
+ * @param {{cwd?: string, project?: string, gitBranch?: string}} meta
52
+ * @param {boolean} isWorktree whether `meta.project` is a linked worktree
53
+ * @param {(cwd: string) => string|null} branchAt
54
+ */
55
+ function sessionGitBranch(meta, isWorktree, branchAt) {
56
+ if (meta.cwd && meta.project && (meta.cwd !== meta.project || isWorktree)) {
57
+ return branchAt(meta.cwd) || meta.gitBranch || null;
58
+ }
59
+ return meta.gitBranch || null;
60
+ }
61
+
62
+ module.exports = { readGitBranch, sessionGitBranch };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ // Claude Code's live-session registry: one <pid>.json per interactive claude.
7
+ function readLiveSessions(sessionsDir) {
8
+ const sessions = [];
9
+ let files;
10
+ try {
11
+ files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
12
+ } catch {
13
+ return sessions;
14
+ }
15
+ for (const file of files) {
16
+ try {
17
+ const s = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf8'));
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 });
20
+ }
21
+ } catch { /* skip invalid */ }
22
+ }
23
+ return sessions;
24
+ }
25
+
26
+ // EPERM means the process exists but belongs to someone else.
27
+ function isPidAlive(pid) {
28
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
29
+ }
30
+
31
+ // A registry file outlives a crashed claude, so the pid is probed rather than trusted.
32
+ function isSessionLive(sessions, sessionId, exceptPid = null) {
33
+ return sessions.some((s) => {
34
+ if (s.sessionId !== sessionId || !s.pid || s.pid === exceptPid) return false;
35
+ return isPidAlive(s.pid);
36
+ });
37
+ }
38
+
39
+ module.exports = { readLiveSessions, isPidAlive, isSessionLive };