@basein/runner 0.3.17 → 0.3.18

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 (38) hide show
  1. package/dist/bin/bir-scenario.d.ts +5 -1
  2. package/dist/bin/bir-scenario.js +155 -5
  3. package/dist/bin/bir.js +2 -2
  4. package/dist/bin/scenario-edit.d.ts +7 -0
  5. package/dist/bin/scenario-edit.js +9 -2
  6. package/dist/bin/scenario-liveness.d.ts +12 -1
  7. package/dist/bin/scenario-liveness.js +8 -4
  8. package/dist/bin/setup.js +3 -3
  9. package/dist/control/client.d.ts +5 -0
  10. package/dist/control/client.js +7 -0
  11. package/dist/control/ensure-hook.d.ts +14 -3
  12. package/dist/control/ensure-hook.js +18 -5
  13. package/dist/control/server.d.ts +165 -2
  14. package/dist/control/server.js +719 -21
  15. package/dist/control/transcript.d.ts +19 -0
  16. package/dist/control/transcript.js +57 -0
  17. package/dist/proxy/intercept.js +13 -0
  18. package/dist/proxy/session.d.ts +23 -0
  19. package/dist/proxy/session.js +71 -3
  20. package/dist/record/recorder.d.ts +146 -0
  21. package/dist/record/recorder.js +9 -0
  22. package/dist/record/remote-recorder.d.ts +59 -1
  23. package/dist/record/remote-recorder.js +297 -20
  24. package/dist/record/switchable-recorder.js +6 -0
  25. package/dist/replay/bundle.d.ts +8 -0
  26. package/dist/replay/bundle.js +20 -6
  27. package/dist/replay/controller.d.ts +33 -1
  28. package/dist/replay/controller.js +137 -12
  29. package/dist/replay/derive.d.ts +15 -0
  30. package/dist/replay/derive.js +5 -0
  31. package/dist/replay/plan.d.ts +90 -1
  32. package/dist/replay/plan.js +232 -10
  33. package/dist/replay/source-run.d.ts +2 -2
  34. package/dist/replay/source-run.js +3 -3
  35. package/dist/replay/targets.d.ts +40 -1
  36. package/dist/replay/targets.js +105 -3
  37. package/dist/replay/types.d.ts +6 -5
  38. package/package.json +1 -1
@@ -68,7 +68,11 @@
68
68
  * WHICH TOOLS ARE OFFERED. `run_scenario`, always — unless the runner is
69
69
  * switched off (`bir off`), when nothing is: an inert runner adds nothing to
70
70
  * the model's context, and a call from a list taken before the switch starts
71
- * nothing and asks nobody. The rest come with scenario editing, which
71
+ * nothing and asks nobody. `report_wrong_result` comes with it: the agent that
72
+ * reads a replay's results is the one who can tell they do not answer the
73
+ * request, and says so in one call that holds the plan and undoes nothing
74
+ * (the service's review of calcscenarioautocorrect.md, item 19); the control
75
+ * server knows which session asks, and so which plan. The rest come with scenario editing, which
72
76
  * `bir setup` turns on (a person's `bir scenario editing off` is kept): the
73
77
  * reading tools — also with `BIR_SCENARIO_TOOLS=read` in this server's
74
78
  * environment — and the tools that CHANGE a scenario (D2). A project wired by
@@ -68,7 +68,11 @@
68
68
  * WHICH TOOLS ARE OFFERED. `run_scenario`, always — unless the runner is
69
69
  * switched off (`bir off`), when nothing is: an inert runner adds nothing to
70
70
  * the model's context, and a call from a list taken before the switch starts
71
- * nothing and asks nobody. The rest come with scenario editing, which
71
+ * nothing and asks nobody. `report_wrong_result` comes with it: the agent that
72
+ * reads a replay's results is the one who can tell they do not answer the
73
+ * request, and says so in one call that holds the plan and undoes nothing
74
+ * (the service's review of calcscenarioautocorrect.md, item 19); the control
75
+ * server knows which session asks, and so which plan. The rest come with scenario editing, which
72
76
  * `bir setup` turns on (a person's `bir scenario editing off` is kept): the
73
77
  * reading tools — also with `BIR_SCENARIO_TOOLS=read` in this server's
74
78
  * environment — and the tools that CHANGE a scenario (D2). A project wired by
@@ -184,6 +188,39 @@ const TOOL = {
184
188
  "never otherwise.",
185
189
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
186
190
  };
191
+ /**
192
+ * The agent's way to say a replay was wrong (the service's review of
193
+ * calcscenarioautocorrect.md, item 19). A plan that took the wrong half of an
194
+ * "if … otherwise" was caught only when the agent said so in its answer, or
195
+ * did the task again — and the results it was handed told it not to check.
196
+ * Offered wherever `run_scenario` is: the agent that reads a replay's results
197
+ * is the one who can tell they are wrong. It holds the plan on the service and
198
+ * undoes nothing. Must match `REPORT_TOOL_NAME`'s suffix in the replay
199
+ * controller.
200
+ */
201
+ const REPORT_TOOL_NAME = "report_wrong_result";
202
+ /** What the agent may say, in characters; the control server and the service cut it there too. */
203
+ const REPORT_REASON_MAX = 2_000;
204
+ /** The whole report's request: the control server waits up to 10 s for the service. */
205
+ const REPORT_TIMEOUT_MS = 15_000;
206
+ const REPORT_TOOL = {
207
+ name: REPORT_TOOL_NAME,
208
+ description: "Call this when the results of a replayed scenario (run_scenario, or a plan bir ran for you) do not answer " +
209
+ "the user's request — for example, the wrong branch of an if/otherwise was taken, or the wrong item was acted " +
210
+ "on. Say why in one or two sentences. It does not undo anything.",
211
+ inputSchema: {
212
+ type: "object",
213
+ properties: {
214
+ reason: {
215
+ type: "string",
216
+ maxLength: REPORT_REASON_MAX,
217
+ description: "Why the results do not answer the request, in one or two sentences.",
218
+ },
219
+ },
220
+ required: ["reason"],
221
+ additionalProperties: false,
222
+ },
223
+ };
187
224
  /**
188
225
  * Ask the control server to run the armed plan. Never throws. The reasons it
189
226
  * returns are for the audit log; {@link toolResult} decides what the model reads.
@@ -213,8 +250,14 @@ async function runArmedPlan(cwd, flight, toolUseId) {
213
250
  "content-type": "application/json",
214
251
  authorization: `Bearer ${found.token}`,
215
252
  },
216
- // `ppid`: the host that started this server and the session's proxies (WHOSE PROXIES above).
217
- body: JSON.stringify(toolUseId ? { toolUseId, ppid: process.ppid } : { ppid: process.ppid }),
253
+ // `ppid`: the host that started this server and the session's proxies
254
+ // (WHOSE PROXIES above). `reports`: this server offers
255
+ // `report_wrong_result`, so the results may say the agent can use it.
256
+ body: JSON.stringify({
257
+ ...(toolUseId ? { toolUseId } : {}),
258
+ ppid: process.ppid,
259
+ reports: listTools(cwd).some((t) => t.name === REPORT_TOOL_NAME),
260
+ }),
218
261
  signal: controller.signal,
219
262
  });
220
263
  const raw = await res.text();
@@ -333,6 +376,60 @@ function toolResult(result) {
333
376
  return said(ALREADY_RUNNING_TEXT);
334
377
  return said(UNAVAILABLE_TEXT);
335
378
  }
379
+ /**
380
+ * Tell the control server the agent said a replay was wrong (review item 19):
381
+ * it knows the session — by the host's id for this call, as for
382
+ * `run_scenario` — and so the plan, and it reports it to the service. Never
383
+ * throws; every failure is an answer.
384
+ */
385
+ async function reportWrongResult(cwd, flight, reason, toolUseId) {
386
+ const found = await resolveControl(cwd, DISCOVERY_GRACE_MS, 50);
387
+ if (!found?.url || !found.token)
388
+ return { ok: false, why: "no_plan" };
389
+ if (flight.cancelled)
390
+ return { ok: false, why: "cancelled" };
391
+ const controller = new AbortController();
392
+ flight.abort = controller;
393
+ const timer = setTimeout(() => controller.abort(), REPORT_TIMEOUT_MS);
394
+ timer.unref?.();
395
+ try {
396
+ const res = await fetch(`${found.url.replace(/\/+$/, "")}/scenario/dispute`, {
397
+ method: "POST",
398
+ headers: { "content-type": "application/json", authorization: `Bearer ${found.token}` },
399
+ body: JSON.stringify({ reason, ...(toolUseId ? { toolUseId } : {}), ppid: process.ppid }),
400
+ signal: controller.signal,
401
+ });
402
+ const raw = await res.text();
403
+ // An older recorder has no such route (404): it cannot take the report.
404
+ if (!res.ok)
405
+ return { ok: false, why: res.status === 404 ? "unsupported" : `HTTP ${res.status}` };
406
+ const body = JSON.parse(raw);
407
+ return body && typeof body === "object" && !Array.isArray(body) ? body : { ok: false };
408
+ }
409
+ catch (err) {
410
+ return { ok: false, why: flight.cancelled ? "cancelled" : errText(err) };
411
+ }
412
+ finally {
413
+ clearTimeout(timer);
414
+ flight.abort = undefined;
415
+ }
416
+ }
417
+ /**
418
+ * The one sentence the agent reads back. Never an error: the report is a side
419
+ * note to its answer, and whatever happened to it, the agent's next move is to
420
+ * tell the user.
421
+ */
422
+ function reportAnswer(result) {
423
+ if (result.ok === true) {
424
+ return said(result.held === true
425
+ ? "Reported. bir will not use this plan again until it is checked."
426
+ : "Reported. Now tell the user what is wrong with these results.");
427
+ }
428
+ if (result.why === "no_plan") {
429
+ return said("Nothing was reported: no scenario was replayed for this session's latest request.");
430
+ }
431
+ return said("The report could not be sent right now. Tell the user what is wrong with these results.");
432
+ }
336
433
  // ── the scenario tools: `bir scenario …`, spawned ─────────────────────────────
337
434
  /** How long a `bir` that may change a scenario may take. A check replays the chain on the service. */
338
435
  const BIR_TIMEOUT_MS = 120_000;
@@ -720,7 +817,11 @@ function readToolsOptedIn() {
720
817
  const value = (process.env.BIR_SCENARIO_TOOLS ?? "").trim().toLowerCase();
721
818
  return value === "read" || value === "1" || value === "on" || value === "true";
722
819
  }
723
- /** The tools to list for `cwd`, read now: the switches may have moved since the last list. */
820
+ /**
821
+ * The tools to list for `cwd`, read now: the switches may have moved since the
822
+ * last list. `report_wrong_result` comes with `run_scenario`, editing on or off:
823
+ * whoever reads a replay's results may need to say they are wrong.
824
+ */
724
825
  function listTools(cwd) {
725
826
  if (runnerOff(cwd))
726
827
  return [];
@@ -728,6 +829,7 @@ function listTools(cwd) {
728
829
  const reading = editing || readToolsOptedIn();
729
830
  return [
730
831
  TOOL,
832
+ REPORT_TOOL,
731
833
  ...SCENARIO_TOOLS.filter((t) => (t.changes ? editing : reading)).map(({ name, description, inputSchema }) => ({
732
834
  name,
733
835
  description,
@@ -968,7 +1070,8 @@ function main() {
968
1070
  cwd,
969
1071
  state: () => {
970
1072
  const off = runnerOff(cwd);
971
- return { tools: off ? 0 : listTools(cwd).length, off };
1073
+ const tools = off ? [] : listTools(cwd);
1074
+ return { tools: tools.length, off, report: tools.some((t) => t.name === REPORT_TOOL_NAME) };
972
1075
  },
973
1076
  });
974
1077
  /**
@@ -1035,6 +1138,27 @@ function main() {
1035
1138
  }
1036
1139
  reply({ jsonrpc: "2.0", id, result: answer });
1037
1140
  };
1141
+ /** `report_wrong_result`: tell the control server, and answer exactly once — unless cancelled. */
1142
+ const answerReport = async (id, flight, reason, toolUseId) => {
1143
+ let result;
1144
+ try {
1145
+ result = await reportWrongResult(cwd, flight, reason, toolUseId);
1146
+ }
1147
+ catch (err) {
1148
+ result = { ok: false, why: errText(err) };
1149
+ }
1150
+ if (inFlight.get(id) === flight)
1151
+ inFlight.delete(id);
1152
+ if (flight.cancelled || closing)
1153
+ return;
1154
+ logLine("scenario.reported", {
1155
+ ok: result.ok === true,
1156
+ held: result.held === true || undefined,
1157
+ why: typeof result.why === "string" ? result.why.slice(0, 200) : undefined,
1158
+ bound: toolUseId !== undefined || undefined,
1159
+ });
1160
+ reply({ jsonrpc: "2.0", id, result: reportAnswer(result) });
1161
+ };
1038
1162
  /** A scenario tool: run `bir`, and answer exactly once — unless cancelled. */
1039
1163
  const answerTool = async (id, tool, args, flight) => {
1040
1164
  let result;
@@ -1136,6 +1260,32 @@ function main() {
1136
1260
  void answerTool(id, scenarioTool, paramsObject(params.arguments), flight);
1137
1261
  return;
1138
1262
  }
1263
+ if (name === REPORT_TOOL_NAME) {
1264
+ // Off: nothing ran a plan, so there is nothing to report — said
1265
+ // plainly, as the reading tools say it.
1266
+ if (runnerOff(cwd)) {
1267
+ logLine("scenario.reported", { ok: false, why: "runner is off" });
1268
+ reply({
1269
+ jsonrpc: "2.0",
1270
+ id,
1271
+ result: said("BaseInstRunner is switched off here, so there is nothing to report."),
1272
+ });
1273
+ return;
1274
+ }
1275
+ const reason = paramsObject(params.arguments).reason;
1276
+ if (typeof reason !== "string" || reason.trim() === "") {
1277
+ reply({
1278
+ jsonrpc: "2.0",
1279
+ id,
1280
+ result: { content: [{ type: "text", text: "`reason` is required: say in a sentence or two why the results are wrong." }], isError: true },
1281
+ });
1282
+ return;
1283
+ }
1284
+ const flight = { tool: REPORT_TOOL_NAME, cancelled: false };
1285
+ inFlight.set(id, flight);
1286
+ void answerReport(id, flight, reason.trim().slice(0, REPORT_REASON_MAX), toolUseIdOf(params));
1287
+ return;
1288
+ }
1139
1289
  if (name !== TOOL_NAME) {
1140
1290
  reply(errorResponse(id, JsonRpcErrorCode.MethodNotFound, `unknown tool: ${params.name}`));
1141
1291
  return;
package/dist/bin/bir.js CHANGED
@@ -3269,8 +3269,8 @@ async function doctor(args) {
3269
3269
  // lets Claude Code run them without asking; said only when that is not so.
3270
3270
  if (scenarioEntry && isScenarioServer(scenarioEntry.config)) {
3271
3271
  if (!editingEnabled(sidecar, cwd)) {
3272
- notes.push("the bir MCP server offers run_scenario only: scenario editing is off here — `bir scenario editing on` " +
3273
- "adds investigate and the scenario tools (restart Claude Code after)");
3272
+ notes.push("the bir MCP server offers run_scenario and report_wrong_result only: scenario editing is off here — " +
3273
+ "`bir scenario editing on` adds investigate and the scenario tools (restart Claude Code after)");
3274
3274
  }
3275
3275
  else if (![claudeUserSettingsPath(), paths.settings, paths.settingsLocal].some((file) => allowRules(file).some((rule) => rule === BIR_TOOLS_RULE || rule === `${BIR_TOOLS_RULE}__*`))) {
3276
3276
  notes.push("Claude Code asks before investigate and the scenario tools run here — `bir setup` allows every bir tool " +
@@ -222,6 +222,13 @@ export declare const SCENARIO_EDIT_SUBCOMMANDS: ReadonlySet<string>;
222
222
  export declare const READ_TOOLS: readonly ["scenario_show", "scenario_edits", "investigate"];
223
223
  /** The ones that change a scenario: offered only after `bir scenario editing on` (D2). */
224
224
  export declare const EDIT_TOOLS: readonly ["scenario_check", "scenario_edit", "scenario_undo"];
225
+ /**
226
+ * The ones every session with the `bir` server has, editing on or off: the
227
+ * delivery of a direct plan, and the agent's way to say a replay's results do
228
+ * not answer the request (the service's review of calcscenarioautocorrect.md,
229
+ * item 19). `bir setup`'s allow rule, `mcp__bir`, covers every one.
230
+ */
231
+ export declare const BASE_TOOLS: readonly ["run_scenario", "report_wrong_result"];
225
232
  export declare function scenarioEditCommand(args: ScenarioEditArgs, deps: ScenarioEditDeps): Promise<number>;
226
233
  /**
227
234
  * A logic file's text. UTF-8, with a byte-order mark dropped — and UTF-16 with
@@ -59,6 +59,13 @@ export const SCENARIO_EDIT_SUBCOMMANDS = new Set([
59
59
  export const READ_TOOLS = ["scenario_show", "scenario_edits", "investigate"];
60
60
  /** The ones that change a scenario: offered only after `bir scenario editing on` (D2). */
61
61
  export const EDIT_TOOLS = ["scenario_check", "scenario_edit", "scenario_undo"];
62
+ /**
63
+ * The ones every session with the `bir` server has, editing on or off: the
64
+ * delivery of a direct plan, and the agent's way to say a replay's results do
65
+ * not answer the request (the service's review of calcscenarioautocorrect.md,
66
+ * item 19). `bir setup`'s allow rule, `mcp__bir`, covers every one.
67
+ */
68
+ export const BASE_TOOLS = ["run_scenario", "report_wrong_result"];
62
69
  const USAGE = {
63
70
  show: "usage: bir scenario show <runId|scnId> [--step <n>]",
64
71
  check: "usage: bir scenario check <runId|scnId> --step <n> [--input-logic <file|->] [--output-logic <file|->] [--unfreeze] [--json]",
@@ -1067,11 +1074,11 @@ function describeEditing(out, sidecar, cwd) {
1067
1074
  // working on a scenario, and every other session would pay for their
1068
1075
  // definitions on every request without ever calling them.
1069
1076
  if (on) {
1070
- out(` the bir MCP server offers run_scenario, ${READ_TOOLS.join(", ")} (they read)`);
1077
+ out(` the bir MCP server offers ${[...BASE_TOOLS, ...READ_TOOLS].join(", ")} (they read)`);
1071
1078
  out(` and ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
1072
1079
  }
1073
1080
  else {
1074
- out(" the bir MCP server offers run_scenario");
1081
+ out(` the bir MCP server offers ${BASE_TOOLS.join(", ")}`);
1075
1082
  out(` \`bir scenario editing on\` adds ${EDIT_TOOLS.join(", ")} (they change a scenario)`);
1076
1083
  out(` and ${READ_TOOLS.join(", ")} (they read)`);
1077
1084
  out(" (BIR_SCENARIO_TOOLS=read in the server's environment offers the reading ones alone)");
@@ -75,6 +75,13 @@ export interface HelloBody {
75
75
  /** How many tools this server lists now (0 when off). */
76
76
  tools: number;
77
77
  off: boolean;
78
+ /**
79
+ * It lists `report_wrong_result` (runner 0.3.18 on; the service's review of
80
+ * calcscenarioautocorrect.md, item 19): a replay's results may then tell the
81
+ * agent it can say they are wrong. Sent only when true; an older recorder
82
+ * ignores it.
83
+ */
84
+ report?: boolean;
78
85
  }
79
86
  /** A discovery record as written, its pid not yet checked. */
80
87
  export interface RecorderRecord {
@@ -100,10 +107,14 @@ export declare function launchRevive(cwd: string): Promise<void>;
100
107
  export interface LivenessOptions {
101
108
  /** The project this server runs for. */
102
109
  cwd: string;
103
- /** What this server lists now. Called for every hello. */
110
+ /**
111
+ * What this server lists now. Called for every hello. `report`: the list
112
+ * holds `report_wrong_result` (review item 19).
113
+ */
104
114
  state: () => {
105
115
  tools: number;
106
116
  off: boolean;
117
+ report?: boolean;
107
118
  };
108
119
  now?: () => number;
109
120
  random?: () => number;
@@ -314,10 +314,14 @@ export class ScenarioLiveness {
314
314
  state() {
315
315
  try {
316
316
  const s = this.opts.state();
317
- return { tools: Number.isInteger(s.tools) && s.tools >= 0 ? s.tools : 0, off: s.off === true };
317
+ return {
318
+ tools: Number.isInteger(s.tools) && s.tools >= 0 ? s.tools : 0,
319
+ off: s.off === true,
320
+ report: s.report === true && s.off !== true,
321
+ };
318
322
  }
319
323
  catch {
320
- return { tools: 0, off: false };
324
+ return { tools: 0, off: false, report: false };
321
325
  }
322
326
  }
323
327
  async sendHello(why) {
@@ -330,8 +334,8 @@ export class ScenarioLiveness {
330
334
  const target = this.target();
331
335
  if (!target)
332
336
  return;
333
- const { tools, off } = this.state();
334
- const body = { pid: process.pid, version: packageVersion(), tools, off };
337
+ const { tools, off, report } = this.state();
338
+ const body = { pid: process.pid, version: packageVersion(), tools, off, ...(report ? { report } : {}) };
335
339
  this.tried = target.identity;
336
340
  this.lastHelloAt = this.now();
337
341
  let reach;
package/dist/bin/setup.js CHANGED
@@ -43,7 +43,7 @@ import { normalizePath } from "../control/paths.js";
43
43
  import { allowRules, BIR_TOOLS_RULE, claudeCodePaths, claudeCodeSetUp } from "../config/adapters/claude-code.js";
44
44
  import { allocateReplacementPort, editingEnabled, isScenarioServer, isWrapped, LOOPBACK_NO_PROXY, probeFreePort, proxyConfigured, projectRecord, readSidecar, SCENARIO_SERVER_KEY, setProjectRecord, settleReplaySwitch, writeSidecar, allocateFreeProjectPort, } from "../config/generate.js";
45
45
  import { resolveServers } from "../config/resolve.js";
46
- import { EDIT_TOOLS, READ_TOOLS } from "./scenario-edit.js";
46
+ import { BASE_TOOLS, EDIT_TOOLS, READ_TOOLS } from "./scenario-edit.js";
47
47
  import { errText } from "../util/log.js";
48
48
  import { offSource } from "../util/off-switch.js";
49
49
  import { packageVersion } from "../util/version.js";
@@ -508,8 +508,8 @@ export async function runSetup(args, deps) {
508
508
  if (resolveServers(project).some((s) => s.name === SCENARIO_SERVER_KEY && isScenarioServer(s.config))) {
509
509
  const asks = !allowRules(claudeCodePaths(project).settingsLocal).includes(BIR_TOOLS_RULE);
510
510
  out(editingEnabled(readSidecar(), project)
511
- ? ` tools ${["run_scenario", ...READ_TOOLS, ...EDIT_TOOLS].join(", ")} — ${asks ? "Claude Code asks before each" : "Claude Code runs them without asking, except a forced change"}`
512
- : " tools run_scenario only — scenario editing is off here (`bir scenario editing on` adds investigate and the scenario tools)");
511
+ ? ` tools ${[...BASE_TOOLS, ...READ_TOOLS, ...EDIT_TOOLS].join(", ")} — ${asks ? "Claude Code asks before each" : "Claude Code runs them without asking, except a forced change"}`
512
+ : ` tools ${BASE_TOOLS.join(" and ")} only — scenario editing is off here (\`bir scenario editing on\` adds investigate and the scenario tools)`);
513
513
  }
514
514
  if (ensured) {
515
515
  const { info, health: h } = ensured.status;
@@ -68,6 +68,11 @@ export declare class ControlClient {
68
68
  }): Promise<RegisterResult | undefined>;
69
69
  /** Report one completed MCP call. Resolves false when the send was dropped. */
70
70
  report(step: ProxyStepReport): Promise<boolean>;
71
+ /**
72
+ * The tool list of the server this proxy wraps, as it answered `tools/list`
73
+ * (the service's autocorrect.md AC12). Resolves false when the send was dropped.
74
+ */
75
+ tools(serverName: string, tools: readonly unknown[]): Promise<boolean>;
71
76
  health(): Promise<Record<string, unknown> | undefined>;
72
77
  /**
73
78
  * Ask the control server to shut down. Resolves true when it agreed; the
@@ -26,6 +26,13 @@ export class ControlClient {
26
26
  const body = this.identity ? { ...step, proxyId: this.identity.proxyId } : step;
27
27
  return (await this.post("/proxy/step", body)) !== undefined;
28
28
  }
29
+ /**
30
+ * The tool list of the server this proxy wraps, as it answered `tools/list`
31
+ * (the service's autocorrect.md AC12). Resolves false when the send was dropped.
32
+ */
33
+ async tools(serverName, tools) {
34
+ return (await this.post("/proxy/tools", { ...this.identity, serverName, tools })) !== undefined;
35
+ }
29
36
  async health() {
30
37
  return (await this.request("GET", "/health"));
31
38
  }
@@ -49,9 +49,14 @@
49
49
  * only with enough of the budget left.
50
50
  *
51
51
  * STDOUT IS CONTEXT. For SessionStart, Claude Code adds whatever a command hook
52
- * prints on stdout to the model's context. So nothing is printed unless the
53
- * control server answered with something (today it answers `{}`), and then it
54
- * is the answer, verbatim, as JSON.
52
+ * prints on stdout to the model's context — unless it is one JSON object, which
53
+ * it reads as the hook's answer instead. So nothing is printed unless the
54
+ * control server answered with something, and then it is the answer, as one
55
+ * line of JSON and nothing else. The control server answers `{}`, or — when
56
+ * plans of the account wait for their owner (the service's review of
57
+ * calcscenarioautocorrect.md, item 8) — `{ "systemMessage": "bir: …" }`, a
58
+ * line Claude Code shows the person and never the model. Characters past ASCII
59
+ * are written as `\u` escapes, so a console code page cannot garble the line.
55
60
  */
56
61
  import { type EnsureOptions } from "./daemon.js";
57
62
  /**
@@ -93,6 +98,12 @@ export interface EnsureHookOptions extends EnsureOptions {
93
98
  */
94
99
  overrideCheck?: ((projectCwd: string) => StaleOverrides) | false;
95
100
  }
101
+ /**
102
+ * `value` as one line of JSON whose every character past ASCII is a `\u`
103
+ * escape: the same JSON to any reader, and nothing a console's code page can
104
+ * turn into something else on the way to Claude Code.
105
+ */
106
+ export declare function asciiJson(value: unknown): string;
96
107
  /**
97
108
  * Run the hook for `cwd`. Resolves the exit code, which is always 0.
98
109
  */
@@ -49,9 +49,14 @@
49
49
  * only with enough of the budget left.
50
50
  *
51
51
  * STDOUT IS CONTEXT. For SessionStart, Claude Code adds whatever a command hook
52
- * prints on stdout to the model's context. So nothing is printed unless the
53
- * control server answered with something (today it answers `{}`), and then it
54
- * is the answer, verbatim, as JSON.
52
+ * prints on stdout to the model's context — unless it is one JSON object, which
53
+ * it reads as the hook's answer instead. So nothing is printed unless the
54
+ * control server answered with something, and then it is the answer, as one
55
+ * line of JSON and nothing else. The control server answers `{}`, or — when
56
+ * plans of the account wait for their owner (the service's review of
57
+ * calcscenarioautocorrect.md, item 8) — `{ "systemMessage": "bir: …" }`, a
58
+ * line Claude Code shows the person and never the model. Characters past ASCII
59
+ * are written as `\u` escapes, so a console code page cannot garble the line.
55
60
  */
56
61
  import { appendFileSync } from "node:fs";
57
62
  import { Console } from "node:console";
@@ -152,6 +157,14 @@ function reportStaleOverrides(projectCwd, logFile, msLeft, check) {
152
157
  /* a check that failed leaves things as they were; `bir doctor` names what is stale */
153
158
  }
154
159
  }
160
+ /**
161
+ * `value` as one line of JSON whose every character past ASCII is a `\u`
162
+ * escape: the same JSON to any reader, and nothing a console's code page can
163
+ * turn into something else on the way to Claude Code.
164
+ */
165
+ export function asciiJson(value) {
166
+ return JSON.stringify(value).replace(/[\u007f-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
167
+ }
155
168
  /** True when this process's own environment switches the runner off (`BIR_OFF=1` or `true`). */
156
169
  function offInThisEnvironment() {
157
170
  const value = (process.env.BIR_OFF ?? "").trim().toLowerCase();
@@ -228,8 +241,8 @@ export async function runEnsureHook(cwd, opts = {}) {
228
241
  signal: timeoutSignal(Math.max(300, Math.min(RELAY_TIMEOUT_MS, left()))),
229
242
  });
230
243
  const answer = (await res.json().catch(() => ({})));
231
- if (res.ok && answer && typeof answer === "object" && Object.keys(answer).length > 0) {
232
- stdout.write(JSON.stringify(answer));
244
+ if (res.ok && answer && typeof answer === "object" && !Array.isArray(answer) && Object.keys(answer).length > 0) {
245
+ stdout.write(asciiJson(answer));
233
246
  }
234
247
  }
235
248
  catch (err) {