@basein/runner 0.3.8 → 0.3.10

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 (55) hide show
  1. package/README.md +46 -5
  2. package/dist/auth/client.d.ts +15 -0
  3. package/dist/auth/client.js +34 -12
  4. package/dist/bin/bir-hooks.d.ts +1 -1
  5. package/dist/bin/bir-hooks.js +1 -1
  6. package/dist/bin/bir.d.ts +2 -0
  7. package/dist/bin/bir.js +60 -6
  8. package/dist/bin/investigate.d.ts +5 -0
  9. package/dist/bin/investigate.js +117 -15
  10. package/dist/bin/setup.d.ts +12 -0
  11. package/dist/bin/setup.js +185 -56
  12. package/dist/bin/update.d.ts +81 -0
  13. package/dist/bin/update.js +194 -0
  14. package/dist/config/adapters/claude-code.d.ts +17 -0
  15. package/dist/config/adapters/claude-code.js +28 -1
  16. package/dist/config/generate.d.ts +14 -0
  17. package/dist/control/daemon.d.ts +26 -1
  18. package/dist/control/daemon.js +53 -3
  19. package/dist/control/pin-guard.d.ts +6 -1
  20. package/dist/control/pin-guard.js +15 -1
  21. package/dist/control/recorder-keeper.js +2 -1
  22. package/dist/control/server.d.ts +200 -2
  23. package/dist/control/server.js +741 -48
  24. package/dist/control/transcript.d.ts +4 -0
  25. package/dist/control/transcript.js +4 -0
  26. package/dist/proxy/session.js +1 -0
  27. package/dist/record/housekeeping.d.ts +7 -1
  28. package/dist/record/housekeeping.js +11 -3
  29. package/dist/record/recorder.d.ts +34 -0
  30. package/dist/record/remote-recorder.d.ts +14 -0
  31. package/dist/record/remote-recorder.js +107 -13
  32. package/dist/record/switchable-recorder.js +1 -0
  33. package/dist/record/truncate.d.ts +11 -0
  34. package/dist/record/truncate.js +11 -0
  35. package/dist/replay/controller.d.ts +146 -4
  36. package/dist/replay/controller.js +340 -31
  37. package/dist/replay/derive.js +2 -0
  38. package/dist/replay/handover.d.ts +13 -1
  39. package/dist/replay/handover.js +19 -0
  40. package/dist/replay/plan.d.ts +6 -0
  41. package/dist/replay/plan.js +13 -3
  42. package/dist/replay/safety.d.ts +15 -4
  43. package/dist/replay/safety.js +19 -6
  44. package/dist/replay/source-run.js +2 -1
  45. package/dist/replay/types.d.ts +16 -0
  46. package/dist/util/service-headers.d.ts +24 -0
  47. package/dist/util/service-headers.js +47 -0
  48. package/docs/BaseInstRunner.md +115 -1
  49. package/docs/calculatedReplay.md +239 -27
  50. package/docs/calculatedReplayGuide.md +134 -12
  51. package/docs/installRun.md +91 -10
  52. package/docs/quickstart.md +64 -9
  53. package/package.json +1 -1
  54. package/production.md +81 -16
  55. package/scripts/install.ps1 +25 -1
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `bir update` — put the newest version where THIS copy of the runner is.
3
+ *
4
+ * There are two ways the runner gets onto a machine, and each keeps it in a
5
+ * different place:
6
+ *
7
+ * - the console's install line (scripts/install.sh, install.ps1) installs it
8
+ * into `~/.baseinstrunner/npm`, with the npm that sits beside the Node its
9
+ * launchers name. `npm i -g` never writes there: typed by hand it adds a
10
+ * second copy in npm's own prefix and leaves the one the launchers start
11
+ * as old as before, and which copy a shell then finds depends on its PATH;
12
+ * - `npm install -g @basein/runner`, into npm's own prefix.
13
+ *
14
+ * So the command that updates the runner depends on where the runner is, and
15
+ * the copy that is running is the one that knows. A copy under
16
+ * `~/.baseinstrunner/npm` repeats what the install line did: the same Node,
17
+ * the npm beside it, `--prefix` that folder. Any other installed copy runs
18
+ * `npm install -g`, after asking npm where it keeps its global packages: when
19
+ * this copy is not there (pnpm, yarn, another Node's prefix), npm would write
20
+ * the second copy this command exists to prevent, so nothing is installed.
21
+ *
22
+ * It replaces the package and nothing else. `bir setup` in each project is
23
+ * what moves the wiring and replaces an older recorder, and it is the next
24
+ * thing the output names.
25
+ *
26
+ * NOT TESTED on Windows against a live session: npm replaces the folder this
27
+ * process and the recorder were loaded from. Neither keeps a file of it open,
28
+ * and the install line has always done the same replacement, but if npm ends
29
+ * with EPERM or EBUSY the answer is to close Claude Code and run it again.
30
+ */
31
+ import { spawnSync } from "node:child_process";
32
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
33
+ import { homedir } from "node:os";
34
+ import { dirname, join } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { PACKAGE_NAME } from "../config/generate.js";
37
+ import { normalizePath } from "../control/paths.js";
38
+ import { packageVersion } from "../util/version.js";
39
+ /** What `bir update` installs. */
40
+ const SPEC = `${PACKAGE_NAME}@latest`;
41
+ /**
42
+ * The one Node flag the install scripts hand to npm. `--use-env-proxy` is not
43
+ * carried: npm reads HTTPS_PROXY itself.
44
+ */
45
+ const NPM_NODE_FLAGS = ["--use-system-ca"];
46
+ /** The folder of this package: the nearest `package.json` that names it. */
47
+ export function packageRoot(from = fileURLToPath(import.meta.url)) {
48
+ let dir = dirname(from);
49
+ for (let i = 0; i < 5; i += 1) {
50
+ try {
51
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
52
+ if (pkg.name === PACKAGE_NAME)
53
+ return dir;
54
+ }
55
+ catch {
56
+ /* keep walking */
57
+ }
58
+ dir = dirname(dir);
59
+ }
60
+ return undefined;
61
+ }
62
+ /** Where a global install into `prefix` keeps this package: npm adds `lib` everywhere but on Windows. */
63
+ export function packageDirIn(prefix, platform = process.platform) {
64
+ const modules = platform === "win32" ? join(prefix, "node_modules") : join(prefix, "lib", "node_modules");
65
+ return join(modules, ...PACKAGE_NAME.split("/"));
66
+ }
67
+ /** The `npm-cli.js` that ships beside a Node binary, where the install scripts look for it. */
68
+ export function npmCliBeside(execPath, platform = process.platform) {
69
+ const dir = dirname(execPath);
70
+ return platform === "win32"
71
+ ? join(dir, "node_modules", "npm", "bin", "npm-cli.js")
72
+ : join(dir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
73
+ }
74
+ function samePath(a, b) {
75
+ const real = (p) => {
76
+ try {
77
+ return realpathSync(p);
78
+ }
79
+ catch {
80
+ return p;
81
+ }
82
+ };
83
+ return normalizePath(real(a)) === normalizePath(real(b));
84
+ }
85
+ /** Decide what `bir update` runs. Changes nothing. */
86
+ export function planUpdate(deps = {}) {
87
+ const root = deps.packageRoot ?? packageRoot();
88
+ const platform = deps.platform ?? process.platform;
89
+ if (!root)
90
+ return { kind: "refused", why: "this bir cannot tell where its own package is, so it cannot update it." };
91
+ if (/[\\/]_npx[\\/]/.test(root)) {
92
+ return {
93
+ kind: "refused",
94
+ why: "this bir runs from npx's cache, so there is no installed copy to update. " +
95
+ `Install it (npm install -g ${PACKAGE_NAME}), then run \`bir setup\` in the project.`,
96
+ };
97
+ }
98
+ if (!/[\\/]node_modules[\\/]/.test(root)) {
99
+ return {
100
+ kind: "refused",
101
+ why: `this bir runs from a checkout (${root}), not from an installed package: \`git pull\` and \`npm run build\` there update it.`,
102
+ };
103
+ }
104
+ const prefix = join(deps.home ?? homedir(), ".baseinstrunner", "npm");
105
+ if (samePath(root, packageDirIn(prefix, platform))) {
106
+ const execPath = deps.execPath ?? process.execPath;
107
+ const npmCli = deps.npmCli ?? npmCliBeside(execPath, platform);
108
+ if (!existsSync(npmCli)) {
109
+ return {
110
+ kind: "refused",
111
+ why: `the Node this bir runs on (${execPath}) has no npm beside it any more (${npmCli} is missing). ` +
112
+ "Paste the install line from the console again: it puts both back.",
113
+ };
114
+ }
115
+ const flags = (deps.execArgv ?? process.execArgv).filter((a) => NPM_NODE_FLAGS.includes(a));
116
+ return {
117
+ kind: "private",
118
+ root,
119
+ command: execPath,
120
+ args: [...flags, npmCli, "install", "-g", "--prefix", prefix, "--no-fund", "--no-audit", SPEC],
121
+ };
122
+ }
123
+ return { kind: "npm", root, command: "npm", args: ["install", "-g", SPEC] };
124
+ }
125
+ /**
126
+ * `npm` on PATH is `npm.cmd` on Windows, which Node starts only through a
127
+ * shell. The whole line is passed as one string then (every word of it is
128
+ * ours), and an absolute path — the private copy's Node — never needs it.
129
+ */
130
+ function needsShell(command) {
131
+ return process.platform === "win32" && command === "npm";
132
+ }
133
+ function defaultRun(command, args) {
134
+ const r = needsShell(command)
135
+ ? spawnSync([command, ...args].join(" "), { stdio: "inherit", shell: true, windowsHide: true })
136
+ : spawnSync(command, args, { stdio: "inherit", windowsHide: true });
137
+ return r.error ? null : r.status;
138
+ }
139
+ function defaultCapture(command, args) {
140
+ const opts = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true };
141
+ const r = needsShell(command)
142
+ ? spawnSync([command, ...args].join(" "), { ...opts, shell: true })
143
+ : spawnSync(command, args, opts);
144
+ return r.error || r.status !== 0 ? undefined : r.stdout;
145
+ }
146
+ function versionAt(root) {
147
+ try {
148
+ return JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
149
+ }
150
+ catch {
151
+ return undefined;
152
+ }
153
+ }
154
+ /** `bir update`. Returns the exit code: 1 when nothing was updated. */
155
+ export function runUpdate(deps) {
156
+ const plan = planUpdate(deps);
157
+ if (plan.kind === "refused") {
158
+ deps.err(plan.why);
159
+ return 1;
160
+ }
161
+ const run = deps.run ?? defaultRun;
162
+ if (plan.kind === "npm") {
163
+ // Where would npm put it? Somewhere else means a second copy, and the
164
+ // `bir` a shell finds would stay this one. An npm that cannot say is not
165
+ // held against the update: npm itself reports what is wrong with it.
166
+ const answer = (deps.capture ?? defaultCapture)("npm", ["root", "-g"]);
167
+ const globalModules = answer?.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop();
168
+ if (globalModules && !samePath(join(globalModules, ...PACKAGE_NAME.split("/")), plan.root)) {
169
+ deps.err(`npm keeps its global packages in ${globalModules}, and this bir is not there (it runs from ${plan.root}): ` +
170
+ "`npm install -g` would add a second copy and leave this one as it is. " +
171
+ "Update it with the tool that installed it, then run `bir setup` in each project.");
172
+ return 1;
173
+ }
174
+ }
175
+ const before = versionAt(plan.root) ?? packageVersion();
176
+ deps.out(`Updating bir ${before} in ${plan.root}`);
177
+ deps.out(` ${[plan.command, ...plan.args].map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ")}`);
178
+ const code = run(plan.command, plan.args);
179
+ if (code !== 0) {
180
+ deps.err(code === null
181
+ ? `${plan.command} could not be started, so nothing was updated.`
182
+ : `npm ended with exit code ${code}, so bir was not updated; the lines above say why.`);
183
+ if (process.platform === "win32") {
184
+ deps.err("If they name EPERM or EBUSY, a running program holds the runner's files: close Claude Code and run `bir update` again.");
185
+ }
186
+ return 1;
187
+ }
188
+ const after = versionAt(plan.root);
189
+ deps.out(after && after !== before ? `bir is now ${after} (it was ${before}).` : `bir ${after ?? before} is the newest version; nothing changed.`);
190
+ deps.out("Next: `bir setup` in each project that uses it. It moves the wiring to this version and replaces an older recorder;");
191
+ deps.out("one that is serving a Claude Code session mid-turn stays until `bir up --restart`, once that session ends.");
192
+ return 0;
193
+ }
194
+ //# sourceMappingURL=update.js.map
@@ -20,6 +20,7 @@
20
20
  * committed and would carry this machine's node and runner paths to every
21
21
  * clone, and never the user scope, which applies to every project.
22
22
  */
23
+ import { type InstalledSidecar } from "../generate.js";
23
24
  import { type McpServerConfig, type Scope } from "../resolve.js";
24
25
  export interface ClaudeCodePaths {
25
26
  /** `~/.claude.json` (user + local scopes). */
@@ -106,6 +107,22 @@ export declare function addAllowRules(settingsPath: string, rules: string[]): {
106
107
  export declare function removeAllowRules(settingsPath: string, rules: string[]): string | undefined;
107
108
  /** True when `settingsPath` holds any hook of ours. */
108
109
  export declare function hasBirHooks(settingsPath: string): boolean;
110
+ /**
111
+ * Whether `cwd` is set up for Claude Code: an install wrapped a server for
112
+ * it, or hooks of ours are in its settings files.
113
+ */
114
+ export declare function claudeCodeSetUp(cwd: string, sidecar: InstalledSidecar): boolean;
115
+ /**
116
+ * The hosts other than Claude Code that `cwd` was set up for (`bir setup
117
+ * --host langchain`), or an empty list.
118
+ */
119
+ export declare function otherHosts(cwd: string, sidecar: InstalledSidecar): string[];
120
+ /**
121
+ * Whether `cwd` is set up for another host and NOT for Claude Code. Asked
122
+ * afresh each time rather than stored: a later `bir setup` for Claude Code in
123
+ * the same folder must be seen at once.
124
+ */
125
+ export declare function otherHostOnly(cwd: string, sidecar: InstalledSidecar): boolean;
109
126
  /** Which file holds a given scope's `mcpServers` for this cwd. */
110
127
  export declare function fileForScope(scope: Scope, paths: ClaudeCodePaths): string;
111
128
  export declare function sha256(text: string): string;
@@ -23,8 +23,9 @@
23
23
  import { createHash } from "node:crypto";
24
24
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
25
25
  import { dirname, join, parse as parsePath, resolve as resolvePath } from "node:path";
26
+ import { normalizePath } from "../../control/paths.js";
26
27
  import { writeFileAtomic } from "../../util/atomic-write.js";
27
- import { FIRST_PROJECT_PORT, PACKAGE_NAME, SCENARIO_SERVER_KEY } from "../generate.js";
28
+ import { FIRST_PROJECT_PORT, PACKAGE_NAME, SCENARIO_SERVER_KEY, projectRecord, } from "../generate.js";
28
29
  import { claudeJsonPath, parseJsonFile, parseJsonText, projectKeyFor, } from "../resolve.js";
29
30
  export function claudeCodePaths(cwd, claudeJsonOverride) {
30
31
  return {
@@ -258,6 +259,32 @@ export function hasBirHooks(settingsPath) {
258
259
  return false;
259
260
  return Object.values(hooks).some((matchers) => (matchers ?? []).some((m) => (m.hooks ?? []).some((h) => isBirHook(h))));
260
261
  }
262
+ /**
263
+ * Whether `cwd` is set up for Claude Code: an install wrapped a server for
264
+ * it, or hooks of ours are in its settings files.
265
+ */
266
+ export function claudeCodeSetUp(cwd, sidecar) {
267
+ const here = normalizePath(cwd);
268
+ if (Object.values(sidecar.servers ?? {}).some((record) => normalizePath(record.cwd) === here))
269
+ return true;
270
+ const paths = claudeCodePaths(cwd);
271
+ return hasBirHooks(paths.settingsLocal) || hasBirHooks(paths.settings);
272
+ }
273
+ /**
274
+ * The hosts other than Claude Code that `cwd` was set up for (`bir setup
275
+ * --host langchain`), or an empty list.
276
+ */
277
+ export function otherHosts(cwd, sidecar) {
278
+ return projectRecord(sidecar, cwd)?.hosts ?? [];
279
+ }
280
+ /**
281
+ * Whether `cwd` is set up for another host and NOT for Claude Code. Asked
282
+ * afresh each time rather than stored: a later `bir setup` for Claude Code in
283
+ * the same folder must be seen at once.
284
+ */
285
+ export function otherHostOnly(cwd, sidecar) {
286
+ return otherHosts(cwd, sidecar).length > 0 && !claudeCodeSetUp(cwd, sidecar);
287
+ }
261
288
  /** Which file holds a given scope's `mcpServers` for this cwd. */
262
289
  export function fileForScope(scope, paths) {
263
290
  switch (scope) {
@@ -484,6 +484,20 @@ export interface ProjectRecord {
484
484
  * approved by the runner) was shown for this project; it is said once.
485
485
  */
486
486
  approvalNoteShown?: boolean;
487
+ /**
488
+ * Hosts other than Claude Code this folder was set up for, with
489
+ * `bir setup --host <name>` (`langchain`). Such a host needs no hooks and
490
+ * no wrapped servers — its adapter runs inside the application and finds
491
+ * the recorder by its control file — so the entry is all there is: the
492
+ * folder's own port and token, and its replay switch.
493
+ *
494
+ * A folder that is set up for such a host and not for Claude Code gets its
495
+ * recorder started with a small environment (control/daemon.ts,
496
+ * `smallEnv`): the application's keys and passwords are not the recorder's
497
+ * to hold. One set up for Claude Code too keeps the whole environment, as
498
+ * Claude Code's own steps may need it.
499
+ */
500
+ hosts?: string[];
487
501
  }
488
502
  export interface InstalledSidecar {
489
503
  version: 1;
@@ -206,6 +206,12 @@ export interface EnsureOptions {
206
206
  * instead of clearing it, and throw {@link RecorderStoppedError} when found.
207
207
  */
208
208
  revive?: boolean;
209
+ /**
210
+ * Start the recorder with the small environment ({@link smallEnv}) or with
211
+ * the whole one. Unset, the folder decides: small when it is set up for
212
+ * another host and not for Claude Code. Tests pass it.
213
+ */
214
+ smallEnv?: boolean;
209
215
  }
210
216
  export interface EnsureResult {
211
217
  status: DaemonStatus;
@@ -358,8 +364,27 @@ export declare function reviveDaemon(cwd: string, opts?: Pick<EnsureOptions, "ho
358
364
  * happened to start it switched all of them off for the recorder's life. That
359
365
  * window's SessionStart hook tells the recorder its own session is off
360
366
  * instead (ensure-hook.ts); the flag files still switch everything.
367
+ *
368
+ * `small` starts from {@link smallEnv} rather than from everything the
369
+ * caller has: for a folder set up for another host and not for Claude Code.
370
+ */
371
+ export declare function daemonEnv(base: NodeJS.ProcessEnv, cwd: string, logFile: string, small?: boolean): NodeJS.ProcessEnv;
372
+ /**
373
+ * The small environment: what a recorder gets in a folder that is set up for
374
+ * a host other than Claude Code, and not for Claude Code.
375
+ *
376
+ * {@link daemonEnv} otherwise copies everything but three names, so a model
377
+ * key or a database password in the window that started the recorder reaches
378
+ * it — and step code runs inside the recorder, with no sandbox. With Claude
379
+ * Code that window is the developer's own. In such a folder it is the window
380
+ * of an application, and the recordings its plans are written from hold what
381
+ * that application's users typed. The same list as the LangChain adapter's
382
+ * own, which starts the recorder with it in every folder.
383
+ *
384
+ * A plan's step that needs a variable outside the list fails for it. That is
385
+ * by design: step code must not depend on the application's secrets.
361
386
  */
362
- export declare function daemonEnv(base: NodeJS.ProcessEnv, cwd: string, logFile: string): NodeJS.ProcessEnv;
387
+ export declare function smallEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
363
388
  /**
364
389
  * Stop the control server for `cwd`.
365
390
  *
@@ -64,6 +64,7 @@ import { ControlClient } from "./client.js";
64
64
  import { listSupersededRecorders, pidAlive, readDiscovery, removeDiscovery, removeDiscoveryIfOwned, removeSupersededRecord, writeDiscovery, writeSupersededRecord, } from "./discovery.js";
65
65
  import { configDir, controlDir, controlKey, daemonLogPath, downMarkerPath, ensureDir, logsDir, normalizePath, portProblemPath, } from "./paths.js";
66
66
  import { nodeFlagsToCarry, projectRecord, projectToken, readSidecar, } from "../config/generate.js";
67
+ import { otherHostOnly } from "../config/adapters/claude-code.js";
67
68
  import { writeFileAtomic } from "../util/atomic-write.js";
68
69
  import { timeoutSignal } from "../util/service-health.js";
69
70
  import { errText, logLine } from "../util/log.js";
@@ -686,7 +687,7 @@ export async function ensureDaemon(cwd, opts = {}) {
686
687
  const script = opts.hooksScript ?? hooksScriptPath();
687
688
  ensureDir(logsDir());
688
689
  rotateLog(logFile);
689
- const childEnv = daemonEnv(env, cwd, logFile);
690
+ const childEnv = daemonEnv(env, cwd, logFile, opts.smallEnv ?? startsSmall(cwd));
690
691
  const launchedAt = Date.now();
691
692
  const launched = process.platform === "win32" && childEnv[WINDOWS_LAUNCH_ENV] !== "spawn"
692
693
  ? launchWindows(script, childEnv, clamp(remaining(deadline) - 1_000, 2_000, 8_000))
@@ -816,9 +817,12 @@ export async function reviveDaemon(cwd, opts = {}) {
816
817
  * happened to start it switched all of them off for the recorder's life. That
817
818
  * window's SessionStart hook tells the recorder its own session is off
818
819
  * instead (ensure-hook.ts); the flag files still switch everything.
820
+ *
821
+ * `small` starts from {@link smallEnv} rather than from everything the
822
+ * caller has: for a folder set up for another host and not for Claude Code.
819
823
  */
820
- export function daemonEnv(base, cwd, logFile) {
821
- const env = { ...base };
824
+ export function daemonEnv(base, cwd, logFile, small = false) {
825
+ const env = small ? smallEnv(base) : { ...base };
822
826
  deleteEnv(env, SETUP_TOKEN_ENV);
823
827
  if (base[USE_API_KEY_ENV] !== "1")
824
828
  deleteEnv(env, "ANTHROPIC_API_KEY");
@@ -835,6 +839,52 @@ export function daemonEnv(base, cwd, logFile) {
835
839
  env[DAEMON_CWD_ENV] = isAbsolute(cwd) ? cwd : resolvePath(cwd);
836
840
  return env;
837
841
  }
842
+ /**
843
+ * What {@link smallEnv} lets through besides the `BIR_*` variables: the path,
844
+ * the home and temp folders, what the system needs to start a process (on
845
+ * Windows, Node does not start without `SystemRoot`), and the proxy and
846
+ * certificate settings the recorder's own calls to the service need.
847
+ */
848
+ const SMALL_ENV_KEPT = new Set([
849
+ "PATH", "HOME", "TMPDIR", "TEMP", "TMP",
850
+ "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "APPDATA", "LOCALAPPDATA",
851
+ "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "PATHEXT",
852
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY",
853
+ "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR",
854
+ ]);
855
+ /**
856
+ * The small environment: what a recorder gets in a folder that is set up for
857
+ * a host other than Claude Code, and not for Claude Code.
858
+ *
859
+ * {@link daemonEnv} otherwise copies everything but three names, so a model
860
+ * key or a database password in the window that started the recorder reaches
861
+ * it — and step code runs inside the recorder, with no sandbox. With Claude
862
+ * Code that window is the developer's own. In such a folder it is the window
863
+ * of an application, and the recordings its plans are written from hold what
864
+ * that application's users typed. The same list as the LangChain adapter's
865
+ * own, which starts the recorder with it in every folder.
866
+ *
867
+ * A plan's step that needs a variable outside the list fails for it. That is
868
+ * by design: step code must not depend on the application's secrets.
869
+ */
870
+ export function smallEnv(base) {
871
+ const env = {};
872
+ for (const [key, value] of Object.entries(base)) {
873
+ const name = key.toUpperCase();
874
+ if (SMALL_ENV_KEPT.has(name) || name.startsWith("BIR_"))
875
+ env[key] = value;
876
+ }
877
+ return env;
878
+ }
879
+ /** Whether `cwd`'s recorder is started with {@link smallEnv}. Never throws: unreadable settings mean the whole environment, as before. */
880
+ function startsSmall(cwd) {
881
+ try {
882
+ return otherHostOnly(cwd, readSidecar());
883
+ }
884
+ catch {
885
+ return false;
886
+ }
887
+ }
838
888
  /** Delete `name` from an env copy — case-insensitively on Windows, where env names are. */
839
889
  function deleteEnv(env, name) {
840
890
  for (const key of Object.keys(env)) {
@@ -78,6 +78,11 @@ export declare function commandShape(command: string, depth?: number, escape?: s
78
78
  /**
79
79
  * Whether the model's call `modelArgs` to `toolName` may be pinned to the
80
80
  * step's `pinnedArgs`. See the file comment for the rule.
81
+ *
82
+ * `otherHost` is a session whose host is not Claude Code. Every rule above
83
+ * the last one reads a tool's name as Claude Code's (`Bash`, `Write`, `Read`,
84
+ * an `mcp__` name), and another host's tool of the same name is another tool:
85
+ * only the general rule is applied there.
81
86
  */
82
- export declare function pinCompatible(toolName: string, modelArgs: unknown, pinnedArgs: unknown): PinVerdict;
87
+ export declare function pinCompatible(toolName: string, modelArgs: unknown, pinnedArgs: unknown, otherHost?: boolean): PinVerdict;
83
88
  //# sourceMappingURL=pin-guard.d.ts.map
@@ -487,12 +487,26 @@ function statementVerdict(model, step) {
487
487
  /**
488
488
  * Whether the model's call `modelArgs` to `toolName` may be pinned to the
489
489
  * step's `pinnedArgs`. See the file comment for the rule.
490
+ *
491
+ * `otherHost` is a session whose host is not Claude Code. Every rule above
492
+ * the last one reads a tool's name as Claude Code's (`Bash`, `Write`, `Read`,
493
+ * an `mcp__` name), and another host's tool of the same name is another tool:
494
+ * only the general rule is applied there.
490
495
  */
491
- export function pinCompatible(toolName, modelArgs, pinnedArgs) {
496
+ export function pinCompatible(toolName, modelArgs, pinnedArgs, otherHost = false) {
492
497
  const model = record(modelArgs);
493
498
  const step = record(pinnedArgs);
494
499
  if (stableStringify(model) === stableStringify(step))
495
500
  return { ok: true };
501
+ if (otherHost) {
502
+ const modelKeys = Object.keys(model);
503
+ const stepKeys = Object.keys(step);
504
+ if (modelKeys.length === 0 || stepKeys.length === 0)
505
+ return { ok: true };
506
+ return modelKeys.some((k) => stepKeys.includes(k))
507
+ ? { ok: true }
508
+ : { ok: false, why: "it shares no argument with the step" };
509
+ }
496
510
  if (toolName === "Bash" || toolName === "PowerShell") {
497
511
  const mine = typeof model.command === "string" ? model.command.trim() : "";
498
512
  const theirs = typeof step.command === "string" ? step.command.trim() : "";
@@ -47,6 +47,7 @@ import { SwitchableRecorder } from "../record/switchable-recorder.js";
47
47
  import { configPath } from "./paths.js";
48
48
  import { isUnhealthyStatus, serviceHealth, serviceTimeoutMs, timeoutSignal } from "../util/service-health.js";
49
49
  import { isRunnerOff } from "../util/off-switch.js";
50
+ import { serviceHeaders } from "../util/service-headers.js";
50
51
  import { errText, logDetail, logLine } from "../util/log.js";
51
52
  /** What `authenticate()` hands out under `BIR_AUTH_DISABLE=1` (auth/client.ts). */
52
53
  const LOCAL_DEV_SESSION = {
@@ -187,7 +188,7 @@ export async function refreshSession(baseUrl, session, fetchImpl = fetch, timeou
187
188
  try {
188
189
  res = await fetchImpl(`${trimUrl(baseUrl)}/auth/refresh`, {
189
190
  method: "POST",
190
- headers: { "content-type": "application/json" },
191
+ headers: { "content-type": "application/json", ...serviceHeaders() },
191
192
  body: JSON.stringify({ refreshToken: session.refreshToken }),
192
193
  signal: timeoutSignal(timeoutMs),
193
194
  });