@basein/runner 0.3.25 → 0.3.26

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.
Files changed (64) hide show
  1. package/README.md +27 -26
  2. package/dist/auth/client.d.ts +4 -1
  3. package/dist/auth/client.js +83 -14
  4. package/dist/bin/bir-hooks.js +4 -2
  5. package/dist/bin/bir.js +153 -37
  6. package/dist/bin/investigate.js +5 -0
  7. package/dist/bin/scenario-edit.d.ts +5 -0
  8. package/dist/bin/scenario-edit.js +17 -1
  9. package/dist/config/adapters/claude-code.d.ts +51 -7
  10. package/dist/config/adapters/claude-code.js +105 -18
  11. package/dist/config/generate.d.ts +61 -13
  12. package/dist/config/generate.js +108 -20
  13. package/dist/config/resolve.d.ts +5 -0
  14. package/dist/config/resolve.js +7 -0
  15. package/dist/control/correlation.js +0 -0
  16. package/dist/control/daemon.d.ts +28 -0
  17. package/dist/control/daemon.js +62 -0
  18. package/dist/control/paths.d.ts +19 -1
  19. package/dist/control/paths.js +20 -2
  20. package/dist/control/pin-guard.js +8 -2
  21. package/dist/control/recorder-main.js +17 -5
  22. package/dist/control/server.d.ts +63 -9
  23. package/dist/control/server.js +350 -62
  24. package/dist/control/transcript.d.ts +64 -3
  25. package/dist/control/transcript.js +318 -37
  26. package/dist/jsonrpc/framing.d.ts +18 -3
  27. package/dist/jsonrpc/framing.js +57 -4
  28. package/dist/proxy/intercept.d.ts +4 -0
  29. package/dist/proxy/intercept.js +4 -0
  30. package/dist/proxy/relay.d.ts +13 -0
  31. package/dist/proxy/relay.js +12 -1
  32. package/dist/proxy/session.d.ts +8 -1
  33. package/dist/proxy/session.js +67 -10
  34. package/dist/record/recorder.d.ts +6 -1
  35. package/dist/record/redact.d.ts +18 -4
  36. package/dist/record/redact.js +538 -79
  37. package/dist/record/remote-recorder.d.ts +26 -5
  38. package/dist/record/remote-recorder.js +174 -18
  39. package/dist/record/truncate.js +3 -1
  40. package/dist/replay/bash-channel.d.ts +7 -1
  41. package/dist/replay/bash-channel.js +9 -3
  42. package/dist/replay/builtins.d.ts +14 -0
  43. package/dist/replay/builtins.js +460 -95
  44. package/dist/replay/bundle.d.ts +15 -11
  45. package/dist/replay/bundle.js +167 -33
  46. package/dist/replay/controller.d.ts +62 -0
  47. package/dist/replay/controller.js +218 -32
  48. package/dist/replay/derive.js +46 -4
  49. package/dist/replay/handover.d.ts +13 -0
  50. package/dist/replay/handover.js +18 -0
  51. package/dist/replay/pricing.d.ts +61 -11
  52. package/dist/replay/pricing.js +135 -42
  53. package/dist/replay/safety.js +43 -5
  54. package/dist/upstream/http-client.d.ts +6 -3
  55. package/dist/upstream/http-client.js +54 -13
  56. package/dist/upstream/stdio-client.d.ts +7 -0
  57. package/dist/upstream/stdio-client.js +1 -1
  58. package/dist/util/atomic-write.d.ts +9 -1
  59. package/dist/util/atomic-write.js +9 -6
  60. package/dist/util/service-health.d.ts +7 -0
  61. package/dist/util/service-health.js +10 -3
  62. package/package.json +1 -1
  63. package/production.md +2 -2
  64. package/scripts/install.ps1 +14 -2
@@ -1117,6 +1117,12 @@ export function clearPortProblem(cwd) {
1117
1117
  }
1118
1118
  }
1119
1119
  // ── the background recorder's own log ──────────────────────────────────────
1120
+ /** The longest a log's last lines may hold an exit back ({@link exitLogged}). */
1121
+ const LOG_CLOSE_MS = 1_000;
1122
+ /** The log this process pointed `console` at: the one an exit waits for. */
1123
+ let processLog;
1124
+ /** Set by the first {@link exitLogged}. */
1125
+ let exiting = false;
1120
1126
  function silentConsole() {
1121
1127
  const sink = new Writable({
1122
1128
  write(_chunk, _encoding, callback) {
@@ -1166,6 +1172,7 @@ export class DaemonLog {
1166
1172
  }
1167
1173
  /** Open the file and point `console` (every `logLine`) at it. Never throws. */
1168
1174
  open() {
1175
+ processLog = this;
1169
1176
  try {
1170
1177
  ensureDir(dirname(this.current));
1171
1178
  }
@@ -1303,6 +1310,61 @@ export class DaemonLog {
1303
1310
  close() {
1304
1311
  this.stream?.end();
1305
1312
  }
1313
+ /**
1314
+ * End the log for good, before an exit. `done` is called once: when every
1315
+ * line already logged is in the file, or when it cannot be — there is no
1316
+ * stream, it failed, or the file did not take them within
1317
+ * {@link LOG_CLOSE_MS}.
1318
+ *
1319
+ * Lines logged after this call are dropped. Written to the stream, one of
1320
+ * them would be a write after its end, which destroys the stream and the
1321
+ * lines it still held: a send failing in the background during a shutdown
1322
+ * would cost the log the very lines this waits for.
1323
+ */
1324
+ end(done) {
1325
+ const stream = this.stream;
1326
+ this.stream = undefined;
1327
+ this.fd = undefined;
1328
+ globalThis.console = silentConsole();
1329
+ if (!stream) {
1330
+ done();
1331
+ return;
1332
+ }
1333
+ let called = false;
1334
+ const once = () => {
1335
+ if (called)
1336
+ return;
1337
+ called = true;
1338
+ clearTimeout(late);
1339
+ done();
1340
+ };
1341
+ // Not unref'd: `done` is an exit, and it must happen even when the write
1342
+ // it waits for never returns (a disk that hangs).
1343
+ const late = setTimeout(once, LOG_CLOSE_MS);
1344
+ stream.end(once);
1345
+ }
1346
+ }
1347
+ /**
1348
+ * `process.exit(code)`, once the lines already logged are in the log file.
1349
+ *
1350
+ * A background recorder's log is a stream that writes in the background, and
1351
+ * a `process.exit()` right after a `logLine()` lost what the stream had not
1352
+ * written yet: a line logged right behind another, and every line while the
1353
+ * file was still being opened. Those are the lines that say why the recorder
1354
+ * is not running — its port is taken, and by whom; this project's recorder
1355
+ * already serves it; it was asked to stop. So the stream is ended first, and
1356
+ * the exit waits for it ({@link DaemonLog.end}, at most {@link LOG_CLOSE_MS}).
1357
+ * Without a log file of its own — `bir-hooks` in a terminal — the exit is at
1358
+ * once, as before; so is a second call, which means "now".
1359
+ *
1360
+ * It returns: whoever calls it must do nothing more.
1361
+ */
1362
+ export function exitLogged(code) {
1363
+ const log = processLog;
1364
+ if (!log || exiting)
1365
+ process.exit(code);
1366
+ exiting = true;
1367
+ log.end(() => process.exit(code));
1306
1368
  }
1307
1369
  // ── the start lock ─────────────────────────────────────────────────────────
1308
1370
  function lockPath(cwd) {
@@ -7,8 +7,26 @@
7
7
  * assumption is made about `$HOME` being set — Windows sets `USERPROFILE`
8
8
  * instead, and `os.homedir()` already knows the difference.
9
9
  */
10
- /** Root of BaseInstRunner's state. `BIR_HOME` overrides it (tests, sandboxes). */
10
+ /**
11
+ * Root of BaseInstRunner's state. `BIR_HOME` overrides it (tests, sandboxes).
12
+ *
13
+ * An empty `BIR_HOME` counts as unset (`||`, not `??`). Taken as a path it is
14
+ * the current directory: the sign-in, installed.json and the logs then land in
15
+ * whichever folder a command happens to run in, one copy per folder.
16
+ */
11
17
  export declare function configDir(): string;
18
+ /**
19
+ * The port `bir install` writes into the hook URLs, and the one `bir-hooks`
20
+ * prefers. Claude Code's `settings.json` needs a *static* URL, so the control
21
+ * server cannot be purely ephemeral: it asks for this port and falls back to an
22
+ * ephemeral one only if it is taken — in which case the installed hooks point at
23
+ * a port nobody is listening on, and both `bir-hooks` and `bir doctor` say so.
24
+ *
25
+ * Here rather than in control/server.ts so that `bir` can name it without
26
+ * loading the control server: that module and what it imports cost every
27
+ * `bir` command about 50 ms, and the `bir` MCP server starts one per call.
28
+ */
29
+ export declare const DEFAULT_CONTROL_PORT = 53411;
12
30
  /** Where control servers advertise themselves, one file per project cwd. */
13
31
  export declare function controlDir(): string;
14
32
  /** The sidecar holding original MCP entries so `bir uninstall` can restore them. */
@@ -11,10 +11,28 @@ import { homedir } from "node:os";
11
11
  import { createHash } from "node:crypto";
12
12
  import { join } from "node:path";
13
13
  import { mkdirSync } from "node:fs";
14
- /** Root of BaseInstRunner's state. `BIR_HOME` overrides it (tests, sandboxes). */
14
+ /**
15
+ * Root of BaseInstRunner's state. `BIR_HOME` overrides it (tests, sandboxes).
16
+ *
17
+ * An empty `BIR_HOME` counts as unset (`||`, not `??`). Taken as a path it is
18
+ * the current directory: the sign-in, installed.json and the logs then land in
19
+ * whichever folder a command happens to run in, one copy per folder.
20
+ */
15
21
  export function configDir() {
16
- return process.env.BIR_HOME ?? join(homedir(), ".baseinstrunner");
22
+ return process.env.BIR_HOME || join(homedir(), ".baseinstrunner");
17
23
  }
24
+ /**
25
+ * The port `bir install` writes into the hook URLs, and the one `bir-hooks`
26
+ * prefers. Claude Code's `settings.json` needs a *static* URL, so the control
27
+ * server cannot be purely ephemeral: it asks for this port and falls back to an
28
+ * ephemeral one only if it is taken — in which case the installed hooks point at
29
+ * a port nobody is listening on, and both `bir-hooks` and `bir doctor` say so.
30
+ *
31
+ * Here rather than in control/server.ts so that `bir` can name it without
32
+ * loading the control server: that module and what it imports cost every
33
+ * `bir` command about 50 ms, and the `bir` MCP server starts one per call.
34
+ */
35
+ export const DEFAULT_CONTROL_PORT = 53411;
18
36
  /** Where control servers advertise themselves, one file per project cwd. */
19
37
  export function controlDir() {
20
38
  return join(configDir(), "control");
@@ -352,7 +352,11 @@ function commandVerdict(mine, theirs, powershell = false) {
352
352
  // No command at all: the model left it to the directive.
353
353
  if (!mine)
354
354
  return { ok: true };
355
- if (mine.replace(/\s+/g, " ") === theirs.replace(/\s+/g, " "))
355
+ // The same command, however its words are spaced. Spaces and tabs only: a
356
+ // new line chains (see the file comment), and with `\s` the step `ls` + new
357
+ // line + `rm -rf x` was "the same" as a call of `ls rm -rf x` — one command
358
+ // shown, two run — before the chains below were ever compared.
359
+ if (mine.replace(/[ \t]+/g, " ") === theirs.replace(/[ \t]+/g, " "))
356
360
  return { ok: true };
357
361
  // What a substitution runs is not in any head: only an exact copy is the step.
358
362
  if ((powershell ? PS_SUBSTITUTION : SUBSTITUTION).test(theirs)) {
@@ -469,7 +473,9 @@ function statementVerdict(model, step) {
469
473
  const mine = model[key];
470
474
  if (typeof mine !== "string")
471
475
  return { ok: false, why: `it does not supply the step's ${key}` };
472
- if (mine.replace(/\s+/g, " ").trim() === theirs.replace(/\s+/g, " ").trim())
476
+ // Spaces and tabs only, as in commandVerdict: a new line may chain here
477
+ // too. A query laid out on other lines still passes, by its verbs, below.
478
+ if (mine.replace(/[ \t]+/g, " ").trim() === theirs.replace(/[ \t]+/g, " ").trim())
473
479
  continue;
474
480
  // A shell command's substitution runs what no head shows (backticks are
475
481
  // only an identifier quote in SQL, so SQL is not held to this).
@@ -23,7 +23,7 @@
23
23
  import { hostname } from "node:os";
24
24
  import { ControlServer, DEFAULT_CONTROL_PORT } from "./server.js";
25
25
  import { DISCOVERY_CHECK_MS, HEARTBEAT_MS, pidAlive, readDiscovery, removeDiscoveryIfOwned, removeSupersededRecord, sweepStaleDiscovery, writeDiscovery, writeSupersededRecord, } from "./discovery.js";
26
- import { DAEMON_CWD_ENV, DAEMON_ENV, DAEMON_LOG_ENV, DERIVE_KEY_ENV, DaemonLog, USE_API_KEY_ENV, clearPortProblem, compareVersions, probeHealth, projectEndpoint, rediscover, validPort, writePortProblem, } from "./daemon.js";
26
+ import { DAEMON_CWD_ENV, DAEMON_ENV, DAEMON_LOG_ENV, DERIVE_KEY_ENV, DaemonLog, USE_API_KEY_ENV, clearPortProblem, compareVersions, exitLogged, probeHealth, projectEndpoint, rediscover, validPort, writePortProblem, } from "./daemon.js";
27
27
  import { configDir, normalizePath, supersededLogPath } from "./paths.js";
28
28
  import { RecorderKeeper } from "./recorder-keeper.js";
29
29
  import { FILE_BUILTINS, parseBashAllow, parseBuiltinTools, parsePathAllow } from "../replay/builtins.js";
@@ -34,6 +34,7 @@ import { resolveServers } from "../config/resolve.js";
34
34
  import { isRecorderHealthReporter } from "../record/recorder.js";
35
35
  import { errText, logLine, logOnce } from "../util/log.js";
36
36
  import { openJournal } from "../util/journal.js";
37
+ import { MAX_TIMER_MS } from "../util/service-health.js";
37
38
  import { packageVersion } from "../util/version.js";
38
39
  /** How long `close()` may take on the way out: it flushes unfinished runs to the service. */
39
40
  const SHUTDOWN_CLOSE_MS = 3_000;
@@ -57,9 +58,14 @@ const SWEEP_EVERY_MS = 24 * 60 * 60 * 1000;
57
58
  * that had exited would turn every one of them into a hook error.
58
59
  */
59
60
  const SUPERSEDED_EXIT_MS = 24 * 60 * 60 * 1000;
61
+ /**
62
+ * A positive whole number, held to what a timer takes ({@link MAX_TIMER_MS}):
63
+ * most of these become a delay, and `BIR_RECORDER_TICK_MS=3000000000` would
64
+ * otherwise run the minute check every millisecond.
65
+ */
60
66
  function positiveInt(value, fallback) {
61
67
  const n = Number(value);
62
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
68
+ return Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), MAX_TIMER_MS) : fallback;
63
69
  }
64
70
  /** Like {@link positiveInt}, but 0 is a meaningful setting rather than unset. */
65
71
  function nonNegativeInt(value, fallback) {
@@ -454,7 +460,8 @@ export async function runRecorder() {
454
460
  removeDiscoveryIfOwned(cwd, process.pid);
455
461
  removeSupersededRecord(cwd, process.pid);
456
462
  logLine(event, {});
457
- process.exit(exitCode);
463
+ // Not `process.exit()`: the line above is not in the log file yet.
464
+ exitLogged(exitCode);
458
465
  };
459
466
  const shutdown = async () => {
460
467
  if (closing)
@@ -478,7 +485,7 @@ export async function runRecorder() {
478
485
  // A second signal while stopping means "now": on POSIX it used to be
479
486
  // swallowed, and a recorder stuck in its shutdown could not be stopped.
480
487
  if (closing)
481
- finish("control.stop_forced", 1);
488
+ return finish("control.stop_forced", 1);
482
489
  void shutdown();
483
490
  };
484
491
  const options = {
@@ -529,7 +536,12 @@ export async function runRecorder() {
529
536
  await sleep(500);
530
537
  continue;
531
538
  }
532
- process.exit(verdict === "ours" ? 0 : 1);
539
+ // `portBusy` has just logged why — who holds the port, or that this
540
+ // project's recorder already serves it. The log writes in the background,
541
+ // so a `process.exit()` here could lose that line, and the start-up lines
542
+ // before it with it.
543
+ exitLogged(verdict === "ours" ? 0 : 1);
544
+ return;
533
545
  }
534
546
  }
535
547
  clearPortProblem(cwd);
@@ -282,6 +282,12 @@ export interface ControlServerOptions {
282
282
  * failing. Unset or `BIR_INTENT_ARM=0` is off.
283
283
  */
284
284
  intentArm?: boolean;
285
+ /**
286
+ * How long a turn's books wait after `Stop` for the turn's last message to
287
+ * reach the transcript, in ms (`sealRun`). Overrides `BIR_COST_SETTLE_MS`;
288
+ * 0 reads the cost at `Stop`, as every runner up to 0.3.25 did.
289
+ */
290
+ costSettleMs?: number;
285
291
  /**
286
292
  * Whether the model's first call after a plan handed over asks the service
287
293
  * for the plan of the work recorded after an earlier hand-over at that step,
@@ -351,6 +357,15 @@ export declare class ControlServer {
351
357
  private readonly attention;
352
358
  /** Plans a `/scenario/run` is executing right now: one run per plan. */
353
359
  private readonly runningPlans;
360
+ /**
361
+ * Plans whose direct run its caller cancelled, by scenario id, and when
362
+ * ({@link declineAfterCancel}): the same plan is not armed again for
363
+ * {@link AFTER_CANCEL_MS} when the run sent a call, so its steps do not run
364
+ * twice. Here, not on the session: after `/clear` — a new session — or in a
365
+ * second window the same request armed the plan again, and ran every step
366
+ * a second time.
367
+ */
368
+ private readonly cancelledScenarios;
354
369
  /**
355
370
  * Steer plans whose last pin the guard declined (control/pin-guard.ts): the
356
371
  * model made the step's call its own way, so the plan is a step behind the
@@ -698,6 +713,34 @@ export declare class ControlServer {
698
713
  * the service's own answer, not the one a hook may have been given at once.
699
714
  */
700
715
  private noteDisabledMatch;
716
+ /**
717
+ * A plan whose caller cancelled it (Esc on `run_scenario`, or a timeout) a
718
+ * few minutes ago, after at least one of its steps had run, is not armed
719
+ * again when the same plan matches: the steps' side effects are in place
720
+ * already, and the model is told so by the note kept for it
721
+ * ({@link stashCancelledNote}). Armed, the plan would have run them a second
722
+ * time — a row added twice, a mail sent twice — and the model would have had
723
+ * the note and the instruction to run the plan in the same message.
724
+ *
725
+ * The mark is spent by the first match it is asked about, whichever way that
726
+ * goes: it holds back one arming, never a stream of them, and never on a
727
+ * plan that sent no call (there is nothing to repeat: a step left out, one
728
+ * whose logic broke, one that could not be sent), nor one that is long
729
+ * cancelled ({@link AFTER_CANCEL_MS}). A person who wants the plan again asks
730
+ * again. Except while the cancelled run is still going — Esc does not stop a
731
+ * step in flight, and the run ends after it: every match until then is held,
732
+ * and the first one after spends the mark.
733
+ *
734
+ * The mark is the recorder's, not the session's: after `/clear` or in a
735
+ * second window the same request arrives in another session. The note the
736
+ * cancelled run left goes with the decline, to the session it is declined
737
+ * in, when that note is still unread.
738
+ *
739
+ * Returns true when it declined.
740
+ */
741
+ private declineAfterCancel;
742
+ /** Mark `state`'s scenario held after its caller cancelled it ({@link declineAfterCancel}). */
743
+ private holdAfterCancel;
701
744
  /**
702
745
  * A `direct` plan is announced as "call mcp__bir__run_scenario". In a project
703
746
  * with no `bir` server that is a tool the model does not have: it would say
@@ -896,6 +939,19 @@ export declare class ControlServer {
896
939
  * session of another host (see {@link CloseReason}).
897
940
  */
898
941
  private sealRun;
942
+ /** How long a turn's books wait for its last message: `BIR_COST_SETTLE_MS`, 0 for no wait. */
943
+ private costSettleMs;
944
+ /**
945
+ * `work` joins what a drain of `run` waits for ({@link drainRun}). Never
946
+ * rejects: a seal's waits are best effort, and say so in the log themselves.
947
+ */
948
+ private holdSeal;
949
+ /**
950
+ * The second half of {@link sealRun}: what the turn cost, its execution
951
+ * reports, and the finish of its recording. Run at the seal, or a moment
952
+ * after it, once the turn's last message is in the transcript.
953
+ */
954
+ private closeBooks;
899
955
  /**
900
956
  * Report what a matched turn cost (docs/calculatedReplay.md §11).
901
957
  *
@@ -925,7 +981,12 @@ export declare class ControlServer {
925
981
  * one `run.unmeasured` line says which.
926
982
  */
927
983
  private hostCost;
928
- /** Transcript cost since `mark`; unmeasured (and zero) without one, or with a model that has no price. */
984
+ /**
985
+ * Transcript cost since `mark`; unmeasured (and zero) without one, or with a
986
+ * model that has no price. Up to `until` — the mark of the session's next
987
+ * run — when the turn's books are closed after that run has opened, and up
988
+ * to now otherwise.
989
+ */
929
990
  private costSince;
930
991
  private reportExecution;
931
992
  /**
@@ -1566,12 +1627,5 @@ export declare class ControlServer {
1566
1627
  */
1567
1628
  private record;
1568
1629
  }
1569
- /**
1570
- * The port `bir install` writes into the hook URLs, and the one `bir-hooks`
1571
- * prefers. Claude Code's `settings.json` needs a *static* URL, so the control
1572
- * server cannot be purely ephemeral: it asks for this port and falls back to an
1573
- * ephemeral one only if it is taken — in which case the installed hooks point at
1574
- * a port nobody is listening on, and both `bir-hooks` and `bir doctor` say so.
1575
- */
1576
- export declare const DEFAULT_CONTROL_PORT = 53411;
1630
+ export { DEFAULT_CONTROL_PORT } from "./paths.js";
1577
1631
  //# sourceMappingURL=server.d.ts.map