@kurrent/kcap 0.9.5 → 0.10.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/bin/kcap.js CHANGED
@@ -48,6 +48,157 @@ function probeArgs(updArgs) {
48
48
  return ["update", "--check", "--no-update-check", ...channelFlags];
49
49
  }
50
50
 
51
+ // ── Windows binary-lock handling ────────────────────────────────────────────
52
+ //
53
+ // Windows locks an executing image against overwrite/delete for the process
54
+ // lifetime, and kcap has long-lived native processes: MCP servers (four per
55
+ // open Claude Code session) and the daemon. A plain `npm install -g` therefore
56
+ // dies with EBUSY whenever any agent session is open. But Windows DOES allow
57
+ // renaming/moving a running image within a volume — so `kcap update` moves the
58
+ // exes into a trash directory first (rename-aside), lets npm lay down fresh
59
+ // files at the real path, and sweeps the trash on later launcher runs once the
60
+ // old processes have exited. Running processes keep executing the moved image
61
+ // untouched and pick up the new binary on their next start.
62
+
63
+ const TRASH_DIR_NAME = ".kcap-trash";
64
+
65
+ // Trash lives next to node_modules (e.g. %APPDATA%\npm\.kcap-trash): renaming
66
+ // a running image requires the destination to be on the same volume, and a
67
+ // sibling of the install tree guarantees that.
68
+ function trashDirFor(globalRoot) {
69
+ return path.join(globalRoot, "..", TRASH_DIR_NAME);
70
+ }
71
+
72
+ // Trash dir as seen from this launcher file, without shelling out to
73
+ // `npm root -g` (too slow for the hook hot path). Returns null when the
74
+ // launcher isn't laid out as a node_modules install.
75
+ function trashDirFromLauncher(launcherDir) {
76
+ // <root>\node_modules\@kurrent\kcap\bin → <root>\.kcap-trash
77
+ const nodeModules = path.resolve(launcherDir, "..", "..", "..");
78
+ if (path.basename(nodeModules) !== "node_modules") return null;
79
+ return path.join(path.dirname(nodeModules), TRASH_DIR_NAME);
80
+ }
81
+
82
+ // Best-effort reclaim of exes parked by earlier updates. Deleting a file whose
83
+ // process is still running fails; it just stays for a later sweep.
84
+ function sweepTrash(trashDir) {
85
+ if (!trashDir) return;
86
+ let entries;
87
+ try { entries = fs.readdirSync(trashDir); } catch { return; }
88
+ for (const f of entries) {
89
+ try { fs.unlinkSync(path.join(trashDir, f)); } catch {}
90
+ }
91
+ }
92
+
93
+ // Move the native exes out of the install tree so npm can replace them.
94
+ // Returns the performed moves so a failed install can restore them. Throws if
95
+ // even a rename is refused (something holds the file exclusively) — after
96
+ // first undoing any move it already made, so a partial failure never leaves
97
+ // the install half-emptied.
98
+ function renameAsideBinaries(binDir, trashDir) {
99
+ const moved = [];
100
+ for (const name of ["kcap.exe", "kcap-daemon.exe"]) {
101
+ const src = path.join(binDir, name);
102
+ if (!fs.existsSync(src)) continue;
103
+ try {
104
+ fs.mkdirSync(trashDir, { recursive: true });
105
+ const dest = path.join(trashDir, `${name}.${process.pid}.${Date.now()}`);
106
+ fs.renameSync(src, dest);
107
+ moved.push({ from: src, to: dest });
108
+ } catch (e) {
109
+ restoreMoved(moved);
110
+ throw e;
111
+ }
112
+ }
113
+ return moved;
114
+ }
115
+
116
+ // Undo rename-aside after a failed install, so the user isn't left without a
117
+ // binary. Skips any path npm already managed to replace.
118
+ function restoreMoved(moved) {
119
+ const failures = [];
120
+ for (const m of moved) {
121
+ try {
122
+ if (!fs.existsSync(m.from)) fs.renameSync(m.to, m.from);
123
+ } catch (e) {
124
+ failures.push({ ...m, error: e });
125
+ }
126
+ }
127
+
128
+ if (failures.length) {
129
+ console.error("Could not restore one or more kcap binaries after a failed update:");
130
+ for (const f of failures) {
131
+ console.error(` ${f.to} -> ${f.from}: ${f.error.message}`);
132
+ }
133
+ console.error("You may need to move the files back manually from the .kcap-trash directory.");
134
+ }
135
+
136
+ return failures;
137
+ }
138
+
139
+ // Pure helper (unit-tested): shape a Win32_Process JSON payload into the kcap
140
+ // processes running from the given install root. ConvertTo-Json unwraps
141
+ // single-element arrays, so `parsed` may be a bare object.
142
+ function filterKcapProcesses(parsed, installRoot) {
143
+ const list = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
144
+ const root = installRoot.toLowerCase();
145
+ return list
146
+ .filter((p) => p && typeof p.ExecutablePath === "string"
147
+ && p.ExecutablePath.toLowerCase().startsWith(root))
148
+ .map((p) => ({
149
+ pid: p.ProcessId,
150
+ name: path.basename(p.ExecutablePath),
151
+ role: describeRole(p.CommandLine || p.ExecutablePath || ""),
152
+ }));
153
+ }
154
+
155
+ // Pure helper (unit-tested): human label for what a kcap process is, from its
156
+ // command line. MCP servers are the common locker — one per MCP entry in the
157
+ // kcap Claude Code plugin, so four per open session.
158
+ function describeRole(commandLine) {
159
+ if (/kcap-daemon/i.test(commandLine)) return "daemon";
160
+ if (/\bmcp\b/i.test(commandLine)) return "MCP server — open Claude Code/agent session";
161
+ if (/\bdaemon\b/i.test(commandLine)) return "daemon";
162
+ return "kcap process";
163
+ }
164
+
165
+ // Enumerate running kcap processes under the install tree (Windows only).
166
+ // Returns null when enumeration itself fails, so callers can degrade to a
167
+ // generic message.
168
+ function listKcapProcesses(installRoot) {
169
+ try {
170
+ const script =
171
+ "Get-CimInstance Win32_Process -Filter \"Name='kcap.exe' OR Name='kcap-daemon.exe'\" | " +
172
+ "Select-Object ProcessId,ExecutablePath,CommandLine | ConvertTo-Json -Compress";
173
+ const out = execFileSync("powershell.exe",
174
+ ["-NoProfile", "-NonInteractive", "-Command", script],
175
+ { encoding: "utf8", windowsHide: true });
176
+ return filterKcapProcesses(out.trim() ? JSON.parse(out) : [], installRoot);
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ // Actionable diagnosis for a locked binary: name the processes holding it and
183
+ // say what to do, instead of leaving the user with npm's raw EBUSY. With
184
+ // `onlyIfFound` (the npm-failure path, where the cause may be unrelated —
185
+ // network, registry) it stays silent unless lockers are actually found.
186
+ function printLockedBinaryHelp(globalRoot, onlyIfFound = false) {
187
+ const procs = listKcapProcesses(path.join(globalRoot, "@kurrent"));
188
+ if (procs && procs.length) {
189
+ console.error("The kcap binary is locked by running kcap processes:");
190
+ for (const p of procs) {
191
+ console.error(` PID ${String(p.pid).padEnd(6)} ${p.name} (${p.role})`);
192
+ }
193
+ console.error("MCP servers belong to open Claude Code / agent sessions. Close those");
194
+ console.error("sessions (and stop the daemon, if running), then re-run `kcap update`.");
195
+ } else if (!onlyIfFound) {
196
+ console.error("The kcap binary appears to be locked by a running process (open");
197
+ console.error("Claude Code sessions run kcap MCP servers; the daemon also counts).");
198
+ console.error("Close agent sessions and stop the daemon, then re-run `kcap update`.");
199
+ }
200
+ }
201
+
51
202
  // Everything below actually DOES something (resolves the binary, execs it,
52
203
  // runs the update flow), so it's guarded to only run when this file is
53
204
  // executed directly — not when `require()`d (e.g. by the test).
@@ -87,11 +238,18 @@ if (require.main === module) {
87
238
  try { fs.chmodSync(binaryPath, 0o755); } catch {}
88
239
  }
89
240
 
241
+ // Reclaim exes parked by a previous rename-aside update, now that their
242
+ // processes may have exited. Cheap when there's nothing to do (one stat).
243
+ if (process.platform === "win32") {
244
+ sweepTrash(trashDirFromLauncher(__dirname));
245
+ }
246
+
90
247
  // `kcap update` for npm-global installs is driven HERE, in the Node launcher,
91
248
  // not by the native binary. The OS locks an executable image for the whole
92
- // process lifetime but a script only during load — so by driving the upgrade
93
- // from this script (with the native binary not running) npm can overwrite the
94
- // binary even on Windows, with no temp-file/rename/detached-helper dance.
249
+ // process lifetime but a script only during load — so with the short-lived
250
+ // probe exited, npm can overwrite the binary in place on macOS/Linux; on
251
+ // Windows the long-lived MCP/daemon processes are handled by rename-aside
252
+ // (see the binary-lock section above).
95
253
  // `--check` (JSON probe) and `--help` fall through to the native binary.
96
254
  {
97
255
  const updArgs = process.argv.slice(3);
@@ -132,10 +290,12 @@ function runUpdate(binaryPath, updArgs) {
132
290
  globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8", ...npmOpts }).trim();
133
291
  } catch {}
134
292
 
293
+ let realGlobalRoot = null;
135
294
  let isGlobalNpm = false;
136
295
  try {
137
296
  if (globalRoot) {
138
- const root = fs.realpathSync(globalRoot) + path.sep;
297
+ realGlobalRoot = fs.realpathSync(globalRoot);
298
+ const root = realGlobalRoot + path.sep;
139
299
  isGlobalNpm = fs.realpathSync(__filename).startsWith(root);
140
300
  }
141
301
  } catch {}
@@ -174,28 +334,21 @@ function runUpdate(binaryPath, updArgs) {
174
334
  // Couldn't determine — fall through and let npm decide (it's idempotent).
175
335
  }
176
336
 
177
- // Windows preflight: a running kcap-daemon.exe locks the binary, so `npm install`
178
- // would FAIL to overwrite it. Detect a running daemon and abort with instructions
179
- // BEFORE attempting the (doomed) install. macOS/Linux can replace the file in place,
180
- // so this guard is Windows-only.
181
- if (process.platform === "win32") {
182
- try {
183
- const status = execFileSync(binaryPath, ["daemon", "status"], { encoding: "utf8" });
184
- if (/running \(PID/i.test(status)) {
185
- console.error("A kcap daemon is running and locks the binary, so the update can't");
186
- console.error("replace it. Stop it first, then re-run `kcap update`:");
187
- console.error(" kcap daemon service stop (if installed as a service)");
188
- console.error(" kcap daemon stop (otherwise)");
189
- process.exit(1);
190
- }
191
- } catch {
192
- // status probe failed (no daemon / old binary) — fall through to the normal install.
193
- }
337
+ // Detect a running daemon up front (short-lived child; it exits before npm
338
+ // runs). Used only for the post-update notice the update itself no longer
339
+ // needs the daemon stopped on any platform. `--no-update-check` keeps this
340
+ // child's "update available" nudge from landing mid-update.
341
+ let daemonRunning = false;
342
+ try {
343
+ const status = execFileSync(binaryPath, ["daemon", "status", "--no-update-check"], { encoding: "utf8" });
344
+ daemonRunning = /running \(PID/i.test(status);
345
+ } catch {
346
+ // status probe failed (no daemon / old binary) treat as not running.
194
347
  }
195
348
 
196
349
  // Fail clearly instead of half-installing under a root-owned prefix.
197
350
  try {
198
- fs.accessSync(globalRoot, fs.constants.W_OK);
351
+ fs.accessSync(realGlobalRoot, fs.constants.W_OK);
199
352
  } catch {
200
353
  console.error(`Cannot write to the global npm directory (${globalRoot}).`);
201
354
  console.error("Re-run with the permissions you installed kcap with, or reinstall");
@@ -203,13 +356,31 @@ function runUpdate(binaryPath, updArgs) {
203
356
  process.exit(1);
204
357
  }
205
358
 
359
+ // Windows: move the (possibly executing) exes out of npm's way. See the
360
+ // binary-lock section above for why rename works when overwrite doesn't.
361
+ let moved = [];
362
+ if (process.platform === "win32") {
363
+ try {
364
+ moved = renameAsideBinaries(path.dirname(binaryPath), trashDirFor(realGlobalRoot));
365
+ } catch (e) {
366
+ // Even a rename was refused — something holds the file exclusively
367
+ // (AV scan, exclusive open). renameAsideBinaries already rolled back its
368
+ // partial moves; diagnose instead of letting npm EBUSY.
369
+ console.error(`Could not move the current kcap binary aside: ${e.message}`);
370
+ printLockedBinaryHelp(realGlobalRoot);
371
+ process.exit(1);
372
+ }
373
+ }
374
+
206
375
  const res = spawnSync("npm", ["install", "-g", resolveInstallSpec(info)], {
207
376
  stdio: "inherit",
208
377
  windowsHide: true,
209
378
  ...npmOpts,
210
379
  });
211
380
  if (res.status !== 0) {
381
+ restoreMoved(moved);
212
382
  console.error("npm install failed; kcap was not updated.");
383
+ if (process.platform === "win32") printLockedBinaryHelp(realGlobalRoot, /* onlyIfFound */ true);
213
384
  process.exit(res.status == null ? 1 : res.status);
214
385
  }
215
386
 
@@ -219,23 +390,30 @@ function runUpdate(binaryPath, updArgs) {
219
390
  require("./refresh").runRefreshes(fs.realpathSync(__filename));
220
391
  console.log("kcap updated.");
221
392
 
222
- // macOS/Linux: a running daemon self-detects the new binary and restarts when idle.
223
- // Just inform the user (best-effort; never fail the update for this). On Windows the
224
- // doomed install was already aborted by the preflight above, so this only runs on Unix.
225
- if (process.platform !== "win32") {
226
- try {
227
- const status = execFileSync(binaryPath, ["daemon", "status"], { encoding: "utf8" });
228
- if (/running \(PID/i.test(status)) {
229
- console.log("A kcap daemon is running; it will restart automatically when idle to");
230
- console.log("pick up the new version. Check with `kcap daemon status`, or apply now");
231
- console.log("with `kcap daemon restart --force`.");
232
- }
233
- } catch {
234
- // best-effort notice only
393
+ // A running daemon keeps executing the old image until restarted. On
394
+ // macOS/Linux it self-detects the new binary and restarts when idle; on
395
+ // Windows self-detection is off (the running image was moved, not replaced),
396
+ // so the user applies it explicitly.
397
+ if (daemonRunning) {
398
+ if (process.platform === "win32") {
399
+ console.log("A kcap daemon is running and still uses the old version. Apply the");
400
+ console.log("update with `kcap daemon restart` (add --force if agents are running).");
401
+ } else {
402
+ console.log("A kcap daemon is running; it will restart automatically when idle to");
403
+ console.log("pick up the new version. Check with `kcap daemon status`, or apply now");
404
+ console.log("with `kcap daemon restart --force`.");
235
405
  }
236
406
  }
237
407
 
238
408
  process.exit(0);
239
409
  }
240
410
 
241
- module.exports = { resolveInstallSpec, probeArgs };
411
+ module.exports = {
412
+ resolveInstallSpec,
413
+ probeArgs,
414
+ trashDirFor,
415
+ trashDirFromLauncher,
416
+ filterKcapProcesses,
417
+ describeRole,
418
+ restoreMoved,
419
+ };
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "kcap",
3
- "version": "0.9.5",
3
+ "version": "0.10.0",
4
4
  "description": "Records and visualizes Claude Code sessions via kcap CLI hooks"
5
5
  }
@@ -50,25 +50,31 @@ If `start_flow` / `send_to_participant` are not among the tools available in thi
50
50
  4. **Only call `close_flow` after the clean signal.** The run stays open until you explicitly close it — don't rely on it closing itself. Then report completion to the user.
51
51
  5. **If participant output is unclear or requires user input**, pause and ask the user before proceeding.
52
52
  6. **Never start a nested flow.** If you are the hosted participant (see above), do not call these tools yourself.
53
- 7. **Single participant.** In Phase D every flow definition has exactly one participant, `reviewer`. `send_to_participant` with any other `participant` value is rejected by the server, which names the valid participant in its error.
53
+ 7. **Address each role independently.** A flow definition declares one or more participant roles in its `participants` map (single-participant definitions use `reviewer`). A multi-participant `start_flow` returns no round — nothing has launched yet. Call `send_to_participant(flow_run_id, participant=<role>, message=…)` naming the role you want to address; its first message launches that role's agent lazily. Only one round is in flight per role at a time — sending to a role that's still working on a round gets a `409` naming the busy round — but every OTHER role stays addressable in the meantime. Sending an unknown role is rejected by the server, which names the valid roles in its error.
54
54
  8. **For a code review flow (`definition_id: "code-review"`), do NOT ask the participant to run tests.** CI covers test execution; participant feedback is on correctness, design, and adherence to conventions.
55
55
  9. **State where your changes live.** The participant's worktree is mirrored from the working tree you LAUNCHED from (your cwd's git root) — nothing else. If any part of the changeset lives elsewhere (another git worktree, another repository, a different machine) or is not in that tree, say so explicitly in `context`/`message` and inline the relevant diffs or file contents — or pass `mode: "context-only"` so the participant treats your context as the sole source of truth. The participant is instructed to flag referenced changes it cannot find in its worktree; incomplete context wastes a full round.
56
56
 
57
+ ## Pending messages
58
+
59
+ Participants can push you out-of-band notes between rounds — observations that don't warrant a full round result (e.g. "found something odd, still looking"). These ride along as `pending_messages` on `start_flow` (a single-participant start returns round 1's result, which can already carry them), `get_flow_status`, `send_to_participant`/`submit_review_round`, and `close_flow` responses, rendered as a list of `from <role> [<id>]: <text>` entries. React to each message **once, by its `<id>`**, the moment you see it. Delivery is acknowledged after rendering, so a message normally never reappears — but if that acknowledgment fails, the SAME message (same id) is redelivered on a later call: treat a repeated id as already handled, never react to it twice. `close_flow`'s response can carry final pending messages too — often the last thing a participant tells you — so read them before you report completion to the user.
60
+
57
61
  ## Guardrail errors
58
62
 
59
63
  The server enforces per-run budgets; watch for these in tool error responses:
60
64
 
61
65
  - **`400` containing `max_rounds (N) reached for this run — close the flow.`** — the run is still **open**, it's just hit its round cap. Stop submitting further rounds, summarize what you have, and call `close_flow`.
62
- - **`400` containing `budget_exceeded: …`** — the run has **already failed** and the participant agent has stopped. Report this to the user; do NOT retry and do NOT call `close_flow` — closing a failed run overwrites the failure status in the read model (the projector flips `failed` → `closed`), hiding what went wrong.
63
- - **A round that exceeds the definition's `round_timeout`** lands as a terminal **`unclear`** round, with the timeout explained in its result text — if you check round status programmatically, look for `unclear` and read the text for the timeout reason. The run itself stays open — you may submit another round or close the flow.
66
+ - **`400` containing `budget_exceeded: …`** — the run has **already failed** and all participant agents have stopped. Report this to the user; do NOT retry and do NOT call `close_flow` — closing a failed run overwrites the failure status in the read model (the projector flips `failed` → `closed`), hiding what went wrong.
67
+ - **A round that exceeds the definition's `round_timeout`** lands as a terminal **`unclear`** round, with the timeout explained in its result text — if you check round status programmatically, look for `unclear` and read the text for the timeout reason. The run itself stays open — you may submit another round to that role or close the flow.
64
68
  - **Idle runs are auto-reaped** after the definition's `idle_ttl` (server default 24h). Don't rely on this — always call `close_flow` yourself once you're done, whether the outcome was clean or you're abandoning the task.
65
69
  - **`400` starting `no_daemon_available:`** — no connected daemon has the repo checked out. Tell the user to run `kcap agent` on a machine with the repo cloned (or pass an explicit `daemon_name` + `repo_path`).
66
70
  - **`400` starting `daemon_outdated:`** — the daemon's kcap is too old to host flow participants. Tell the user to update (`npm i -g @kurrent/kcap`) and restart `kcap agent`.
67
- - **`400` starting `participant_unavailable:`**the participant agent died and automatic relaunch is not available yet. Close this flow and start a new one, carrying your context forward; re-submitting will keep failing.
68
- - **A round result of `unclear` whose text is exactly `participant_died` or `participant_stopped`** — the participant agent crashed or was stopped mid-round. The run stays open but has no live participant: close the flow and start a new one.
71
+ - **`400` containing `participant_unreachable`**that role's agent is in an ambiguous liveness state (its daemon disconnected or is restarting), so the server won't guess whether it's still alive rather than risk a duplicate launch. Retry the send shortly, or ask the user to stop the participant (dashboard/API) and then re-send to force a fresh relaunch.
72
+ - **A round result of `unclear` whose text is exactly `participant_died` or `participant_stopped`** — that role's agent crashed or was stopped mid-round. The run stays **open**: address the same role again with `send_to_participant` and it relaunches automatically — the fresh agent has **no memory of prior rounds**, so restate any context it needs in your message; its earlier spend still counts against the run budget. No need to close and restart the flow. Other roles are unaffected and remain addressable in the meantime.
69
73
 
70
74
  ## Workflow
71
75
 
76
+ Single-participant definitions start eagerly — round 1 runs as part of `start_flow`:
77
+
72
78
  ```
73
79
  start_flow(definition_id, target_kind, target_ref, target_title, context)
74
80
  → participant returns a result: kind findings (with the result text) | kind clean
@@ -86,12 +92,36 @@ if findings:
86
92
  report completion to user
87
93
  ```
88
94
 
95
+ Multi-participant definitions start round-less — you address each role yourself, and the run is clean only in aggregate:
96
+
97
+ ```
98
+ start_flow(definition_id, target_kind, target_ref, target_title, context)
99
+ → no round yet — roles have not launched
100
+
101
+ send_to_participant(flow_run_id, participant="reviewer", message=…)
102
+ → launches the reviewer's agent; returns kind findings | clean
103
+
104
+ send_to_participant(flow_run_id, participant="tester", message=…)
105
+ → launches the tester's agent independently — no need to wait on the reviewer's round;
106
+ returns kind findings | clean
107
+
108
+ # a role with an open round in flight 409s if you send to it again — address the OTHER
109
+ # role(s) in the meantime, then come back once its round completes
110
+
111
+ loop until every addressed role's latest round is clean and none is in flight:
112
+ address whichever role(s) still have findings
113
+ send_to_participant(flow_run_id, participant=<that role>, message=…)
114
+
115
+ close_flow(flow_run_id) # only once reviewer AND tester are both clean
116
+ report completion to user
117
+ ```
118
+
89
119
  ## Tool reference
90
120
 
91
121
  | Tool | Required args | Optional args | When to call |
92
122
  |---|---|---|---|
93
123
  | `start_flow` | `definition_id` (e.g. `spec-review`, `code-review`, or a custom catalog id), `target_kind` (what is being worked on: `spec`, `code`, `pr`, `branch`, `file`, etc.), `target_ref` (a path, branch name, or PR URL/number that identifies the target), `target_title` (short human-readable title), `context` (background context: what to focus on, constraints, definition of done) | `instructions`, `mode` (`context-only` — optional; by default, on the same machine, the participant's worktree is mirrored from your working tree including uncommitted changes, so it reads the actual source. Pass `context-only` to opt out and treat the submitted context as authoritative) | Once, at the start of a flow task. |
94
- | `send_to_participant` | `flow_run_id`, `participant` (Phase D flows have a single participant: `reviewer`), `message` | `instructions`, `async` (defaults to `true`) | After addressing a non-clean result. Pass the same `flow_run_id` and the updated message. |
124
+ | `send_to_participant` | `flow_run_id`, `participant` (role name declared in the flow definition's `participants` map; single-participant definitions use `reviewer` — an unknown role is rejected, naming the valid ones), `message` | `instructions`, `async` (defaults to `true`) | After addressing a non-clean result for that role, or to launch a role for the first time. Pass the same `flow_run_id`, the role's name, and the updated message. |
95
125
  | `get_flow_status` | `flow_run_id` | — | Poll or check the current status of a flow run (running, waiting, completed, failed). |
96
126
  | `close_flow` | `flow_run_id` | — | Only after the definition's clean signal — or when abandoning the task early; the run otherwise stays open until closed. |
97
127
 
@@ -121,3 +151,42 @@ send_to_participant(
121
151
  close_flow(flow_run_id="flow_abc123")
122
152
  # Report to user: flow complete, all findings resolved
123
153
  ```
154
+
155
+ ## Example (two roles: reviewer + tester)
156
+
157
+ `review-and-test`'s `participants` map declares `reviewer` and `tester` — each is addressed independently, and neither's `send_to_participant` waits on the other's round:
158
+
159
+ ```
160
+ # Step 1 — start; multi-participant, so no round comes back yet
161
+ start_flow(
162
+ definition_id="review-and-test",
163
+ target_kind="branch",
164
+ target_ref="feature/add-null-check",
165
+ target_title="Add null check on user input",
166
+ context="Review the diff on this branch and write/run tests for the new code path."
167
+ )
168
+ # → returns flow_run_id, e.g. "flow_xyz789"; no round in the response
169
+
170
+ # Step 2 — address both roles; each launches lazily on its first message.
171
+ # These are independent — send to tester without waiting for the reviewer's round.
172
+ send_to_participant(flow_run_id="flow_xyz789", participant="reviewer", message="…")
173
+ # → launches the reviewer; returns kind findings: missing null check on line 42
174
+ send_to_participant(flow_run_id="flow_xyz789", participant="tester", message="…")
175
+ # → launches the tester; returns kind clean: added a null-input test case, passing
176
+
177
+ # Step 3 — only the reviewer had findings; fix them and send a follow-up to JUST that role
178
+ send_to_participant(
179
+ flow_run_id="flow_xyz789",
180
+ participant="reviewer",
181
+ message="Fixed null check on line 42. Updated diff attached."
182
+ )
183
+ # → reviewer returns kind clean
184
+
185
+ # Step 4 — close only once EVERY addressed role's latest round is clean and none is
186
+ # in flight: reviewer clean + tester clean (from step 2, still current) = aggregate clean.
187
+ # One role going clean does not end the run by itself — track each role's latest result
188
+ # from your own send_to_participant responses; the run's status only reads "clean" once
189
+ # every addressed role's latest round is clean and none is in flight (the aggregate rule).
190
+ close_flow(flow_run_id="flow_xyz789")
191
+ # Report to user: flow complete, review and tests both clean
192
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kurrent/kcap",
3
- "version": "0.9.5",
3
+ "version": "0.10.0",
4
4
  "description": "CLI companion for Kurrent Capacitor — records and visualizes Claude Code sessions",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "repository": {
@@ -14,12 +14,12 @@
14
14
  "postinstall": "node bin/postinstall.js"
15
15
  },
16
16
  "optionalDependencies": {
17
- "@kurrent/kcap-darwin-arm64": "0.9.5",
18
- "@kurrent/kcap-linux-x64": "0.9.5",
19
- "@kurrent/kcap-linux-arm64": "0.9.5",
20
- "@kurrent/kcap-linux-musl-x64": "0.9.5",
21
- "@kurrent/kcap-linux-musl-arm64": "0.9.5",
22
- "@kurrent/kcap-win-x64": "0.9.5"
17
+ "@kurrent/kcap-darwin-arm64": "0.10.0",
18
+ "@kurrent/kcap-linux-x64": "0.10.0",
19
+ "@kurrent/kcap-linux-arm64": "0.10.0",
20
+ "@kurrent/kcap-linux-musl-x64": "0.10.0",
21
+ "@kurrent/kcap-linux-musl-arm64": "0.10.0",
22
+ "@kurrent/kcap-win-x64": "0.10.0"
23
23
  },
24
24
  "files": [
25
25
  "bin/",