@basein/runner 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,6 +61,8 @@ the browser — the OAuth device grant, the same shape `gh auth login` uses. It
61
61
  never handles a password, so it works for accounts that only sign in with
62
62
  Google, and the link can be opened on any device, which is what makes it usable
63
63
  on a headless runner. `--no-browser` prints the link without opening one;
64
+ `--token <value>` redeems a one-time setup token made on the console's *Set up
65
+ the runner* page, with no browser step at all (service 2026-09 or newer);
64
66
  `--password` is the deprecated email-and-password prompt, removed next release.
65
67
 
66
68
  Check it:
@@ -200,6 +200,18 @@ export interface DeviceLoginOptions {
200
200
  * that a script may read, and the code block is not it.
201
201
  */
202
202
  export declare function deviceLogin(authUrl: string, opts?: DeviceLoginOptions): Promise<AuthSession | undefined>;
203
+ /**
204
+ * Sign in with a setup token and cache the session.
205
+ *
206
+ * A setup token is a device code the console minted already approved for the
207
+ * person who was signed in there (`POST /auth/device/setup-token`), so this is
208
+ * the browser flow with the waiting removed: one redemption, no polling. It is
209
+ * the secret that opens a session, so it is never printed or logged here, for
210
+ * the same reason a device code never is.
211
+ */
212
+ export declare function tokenLogin(authUrl: string, token: string, opts?: {
213
+ fetchImpl?: typeof fetch;
214
+ }): Promise<AuthSession>;
203
215
  /**
204
216
  * Sign in with an email and a password.
205
217
  *
@@ -517,6 +517,47 @@ export async function deviceLogin(authUrl, opts = {}) {
517
517
  saveCredentials(session);
518
518
  return session;
519
519
  }
520
+ /**
521
+ * Sign in with a setup token and cache the session.
522
+ *
523
+ * A setup token is a device code the console minted already approved for the
524
+ * person who was signed in there (`POST /auth/device/setup-token`), so this is
525
+ * the browser flow with the waiting removed: one redemption, no polling. It is
526
+ * the secret that opens a session, so it is never printed or logged here, for
527
+ * the same reason a device code never is.
528
+ */
529
+ export async function tokenLogin(authUrl, token, opts = {}) {
530
+ const fetchImpl = opts.fetchImpl ?? fetch;
531
+ const res = await fetchImpl(`${authUrl}/auth/device/token`, {
532
+ method: "POST",
533
+ headers: { "content-type": "application/json" },
534
+ body: JSON.stringify({ deviceCode: token.trim() }),
535
+ });
536
+ if (res.status === 404)
537
+ throw new DeviceFlowUnsupported();
538
+ if (res.ok) {
539
+ const session = toSession((await res.json()));
540
+ saveCredentials(session);
541
+ logLine("auth.token_login", { url: authUrl });
542
+ return session;
543
+ }
544
+ const body = (await res.json().catch(() => ({})));
545
+ switch (body.error) {
546
+ case "expired_token":
547
+ throw new DeviceFlowAborted("this setup token has expired or was already used — make a new one on the console's " +
548
+ "Set up the runner page and paste it within ten minutes", "expired_token");
549
+ case "invalid_grant":
550
+ case "authorization_pending":
551
+ case "slow_down":
552
+ case "access_denied":
553
+ // A pending or unknown code is not a setup token: the console only hands
554
+ // out codes that are already approved.
555
+ throw new DeviceFlowAborted("that is not a valid setup token — copy the whole token from the console's " +
556
+ "Set up the runner page", "access_denied");
557
+ default:
558
+ throw new Error(`sign-in failed: ${body.error ?? `HTTP ${res.status}`}`);
559
+ }
560
+ }
520
561
  /**
521
562
  * A name for this machine, shown on the approval page.
522
563
  *
@@ -33,6 +33,19 @@
33
33
  * BIR_DERIVE_MODEL derivation model (default claude-haiku-4-5-…)
34
34
  * BIR_MATCH_BUDGET_MS prompt-hook match wait (default 2500)
35
35
  * BIR_DERIVE_BUDGET_MS first PreToolUse derivation wait (default 8000)
36
+ * BIR_DERIVE_RECENT_RESULTS how many of this prompt's tool results derivation
37
+ * reads, so a target named by an earlier step can be
38
+ * found (default 5; 0 off)
39
+ * BIR_INTENT_SOURCES which text a step's intent may come from
40
+ * (default text,thinking,tool)
41
+ * BIR_INTENT_REQUESTS_PER_TURN probes one turn may send (default 40).
42
+ * Separate from BIR_INTENT_MATCHES_PER_TURN: arming
43
+ * is a claim on the turn, counting hits is not
44
+ * BIR_SEGMENT_ARM 1 lets a handed-out segment actually run mid-task.
45
+ * Unset is observe-only: what would have armed is
46
+ * logged and nothing is replaced
47
+ * BIR_MIN_SEGMENT_STEER_SIMILARITY minimum similarity to steer a segment
48
+ * (default 0.95)
36
49
  * BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (default 120000)
37
50
  * BIR_STEP_TIMEOUT_MS one direct tools/call (default 60000)
38
51
  *
@@ -33,6 +33,19 @@
33
33
  * BIR_DERIVE_MODEL derivation model (default claude-haiku-4-5-…)
34
34
  * BIR_MATCH_BUDGET_MS prompt-hook match wait (default 2500)
35
35
  * BIR_DERIVE_BUDGET_MS first PreToolUse derivation wait (default 8000)
36
+ * BIR_DERIVE_RECENT_RESULTS how many of this prompt's tool results derivation
37
+ * reads, so a target named by an earlier step can be
38
+ * found (default 5; 0 off)
39
+ * BIR_INTENT_SOURCES which text a step's intent may come from
40
+ * (default text,thinking,tool)
41
+ * BIR_INTENT_REQUESTS_PER_TURN probes one turn may send (default 40).
42
+ * Separate from BIR_INTENT_MATCHES_PER_TURN: arming
43
+ * is a claim on the turn, counting hits is not
44
+ * BIR_SEGMENT_ARM 1 lets a handed-out segment actually run mid-task.
45
+ * Unset is observe-only: what would have armed is
46
+ * logged and nothing is replaced
47
+ * BIR_MIN_SEGMENT_STEER_SIMILARITY minimum similarity to steer a segment
48
+ * (default 0.95)
36
49
  * BIR_REPLAY_BUDGET_MS whole-plan ceiling, direct mode (default 120000)
37
50
  * BIR_STEP_TIMEOUT_MS one direct tools/call (default 60000)
38
51
  *
@@ -93,6 +106,29 @@ function positiveInt(value, fallback) {
93
106
  const n = Number(value);
94
107
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
95
108
  }
109
+ /** Like {@link positiveInt}, but 0 is a meaningful setting rather than unset. */
110
+ function nonNegativeInt(value, fallback) {
111
+ const n = Number(value);
112
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
113
+ }
114
+ /**
115
+ * `BIR_INTENT_SOURCES` — which text a step's intent may be read from
116
+ * (segmented.md R-INTENT-3). Unset or unparseable means all three.
117
+ *
118
+ * It governs only what is *sent*. Turning every source off does not stop a
119
+ * probe: the request still goes out with an empty text and the server builds a
120
+ * line from the tool name and arguments (R-HIT-4).
121
+ */
122
+ function parseIntentSources(value) {
123
+ if (value === undefined)
124
+ return undefined;
125
+ const allowed = ["text", "thinking", "tool"];
126
+ const wanted = value
127
+ .split(",")
128
+ .map((s) => s.trim().toLowerCase())
129
+ .filter((s) => allowed.includes(s));
130
+ return new Set(wanted);
131
+ }
96
132
  /**
97
133
  * Assemble the replay configuration (docs/calculatedReplay.md §5.3 of the guide).
98
134
  *
@@ -116,6 +152,25 @@ function buildReplayOptions(auth) {
116
152
  planMs: positiveInt(process.env.BIR_REPLAY_BUDGET_MS, 120_000),
117
153
  stepMs: positiveInt(process.env.BIR_STEP_TIMEOUT_MS, 60_000),
118
154
  },
155
+ // Intent matching in the ReAct loop (fallbk.md §Runner 4). On with replay;
156
+ // BIR_INTENT_MATCH=0 turns it off on its own.
157
+ intentMatch: {
158
+ enabled: process.env.BIR_INTENT_MATCH !== "0",
159
+ // 4 000, not 1 500: a request whose best segment lands in the not-clear
160
+ // band has one live question to ask under the server's own 2 500 ms
161
+ // attempt, and must still answer inside the hook's 30 s (segmented.md
162
+ // R-HIT-11).
163
+ budgetMs: positiveInt(process.env.BIR_INTENT_MATCH_BUDGET_MS, 4_000),
164
+ maxPerTurn: positiveInt(process.env.BIR_INTENT_MATCHES_PER_TURN, 3),
165
+ maxRequestsPerTurn: positiveInt(process.env.BIR_INTENT_REQUESTS_PER_TURN, 40),
166
+ },
167
+ // Observe-only until an operator has read the logs and turned it on
168
+ // (segmented.md R-OUT-10, R-LIFE-7). Arming is never the way to find out.
169
+ segmentArm: process.env.BIR_SEGMENT_ARM === "1",
170
+ minSegmentSimilarity: (() => {
171
+ const n = Number(process.env.BIR_MIN_SEGMENT_STEER_SIMILARITY);
172
+ return Number.isFinite(n) ? n : 0.95;
173
+ })(),
119
174
  authUrl: auth.baseUrl,
120
175
  // Read late, not captured: `RemoteRecorder` refreshes the access token as the
121
176
  // session outlives it, and a snapshot taken here would go stale mid-run.
@@ -128,6 +183,7 @@ function buildReplayOptions(auth) {
128
183
  minSimilarity: opts.minSimilarity,
129
184
  allowServers: allowServers ? [...allowServers].join(",") : "(all wrapped)",
130
185
  derive: process.env.ANTHROPIC_API_KEY ? "anthropic" : "recorded sample values",
186
+ intentMatch: opts.intentMatch?.enabled ? "on" : "off",
131
187
  why: "matched prompts will run their calculated scenario — steered steps are auto-approved",
132
188
  });
133
189
  }
@@ -152,6 +208,8 @@ async function main() {
152
208
  host: { app: "claude-code" },
153
209
  correlationDecision: process.env.BIR_CORRELATION_DECISION === "ask" ? "ask" : "allow",
154
210
  noCorrelation: process.env.BIR_NO_CORRELATION === "1",
211
+ deriveRecentResults: nonNegativeInt(process.env.BIR_DERIVE_RECENT_RESULTS, 5),
212
+ intentSources: parseIntentSources(process.env.BIR_INTENT_SOURCES),
155
213
  replay: buildReplayOptions(auth),
156
214
  });
157
215
  const address = await server.listen();
package/dist/bin/bir.js CHANGED
@@ -28,7 +28,7 @@ import { isScenarioServer, isWrapped, PACKAGE_NAME, readSidecar, scenarioEntry,
28
28
  import { isRemote, resolveServers } from "../config/resolve.js";
29
29
  import { buildHooksBlock, claudeCodePaths, fileForScope, installHooks, readTextOrNull, setServerEntry, sha256, uninstallHooks, } from "../config/adapters/claude-code.js";
30
30
  import { readGenericServers, setGenericServerEntry } from "../config/adapters/generic.js";
31
- import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, logout, normalizeAuthUrl, resolveAuthUrl, } from "../auth/client.js";
31
+ import { AUTH_URL_HINT, DeviceFlowAborted, DeviceFlowUnsupported, authenticate, deviceLogin, describeAuthService, legacyPasswordLogin, tokenLogin, logout, normalizeAuthUrl, resolveAuthUrl, } from "../auth/client.js";
32
32
  import { DEFAULT_CONTROL_PORT } from "../control/server.js";
33
33
  import { errText } from "../util/log.js";
34
34
  import { packageVersion } from "../util/version.js";
@@ -67,6 +67,7 @@ Options:
67
67
  --json machine-readable output for status / doctor
68
68
  --dry replay against recorded outputs only; run no real tools
69
69
  --no-browser login: print the link and code, open nothing (SSH, headless)
70
+ --token <value> login: redeem a one-time setup token from the console (no browser)
70
71
  --password login: use the old email/password prompt (deprecated)`);
71
72
  process.exit(code);
72
73
  }
@@ -115,6 +116,9 @@ function parseArgs(argv) {
115
116
  case "--no-browser":
116
117
  args.browser = false;
117
118
  break;
119
+ case "--token":
120
+ args.token = argv[++i] ?? "";
121
+ break;
118
122
  case "--config":
119
123
  args.configPath = argv[++i];
120
124
  break;
@@ -540,6 +544,20 @@ async function doctor(args) {
540
544
  else {
541
545
  notes.push(`recording to ${String(health.authUrl ?? "the configured BaseIn service")}`);
542
546
  }
547
+ // Whether a recurring sub-task the service found may actually run mid-task
548
+ // (segmented.md R-LIFE-8). Observe-only is the default and must be visible
549
+ // without reading a log: an operator turns arming on by reading what *would*
550
+ // have armed, and needs to know which of the two states this machine is in.
551
+ notes.push(health.segmentArm === true
552
+ ? "recurring sub-tasks MAY run mid-task (BIR_SEGMENT_ARM=1)"
553
+ : "recurring sub-tasks are observed only — nothing is replaced (BIR_SEGMENT_ARM unset)");
554
+ const sessions = health.sessions ?? [];
555
+ const probes = sessions.reduce((n, s) => n + (s.intent?.probes ?? 0), 0);
556
+ const armed = sessions.reduce((n, s) => n + (s.intent?.armed ?? 0), 0);
557
+ if (sessions.length > 0) {
558
+ notes.push(`mid-turn matching this session: ${probes} probe${probes === 1 ? "" : "s"}, ` +
559
+ `${armed} armed`);
560
+ }
543
561
  }
544
562
  else if (!resolveAuthUrl()) {
545
563
  // No control server to ask, and nothing in this shell either. That is a
@@ -902,7 +920,29 @@ async function main() {
902
920
  return 1;
903
921
  }
904
922
  let session;
905
- if (args.password) {
923
+ if (args.token !== undefined) {
924
+ // A setup token from the console: already approved for one account, so
925
+ // there is no page to open and nothing to wait for.
926
+ if (!args.token.trim()) {
927
+ process.stderr.write("[bir] --token needs the token itself: bir login --token <token>\n");
928
+ return 1;
929
+ }
930
+ try {
931
+ session = await tokenLogin(baseUrl, args.token);
932
+ }
933
+ catch (err) {
934
+ if (err instanceof DeviceFlowAborted) {
935
+ process.stderr.write(`[bir] ${err.message}\n`);
936
+ return 1;
937
+ }
938
+ if (err instanceof DeviceFlowUnsupported) {
939
+ process.stderr.write("[bir] this service does not offer setup tokens; run `bir login` without --token.\n");
940
+ return 1;
941
+ }
942
+ throw err;
943
+ }
944
+ }
945
+ else if (args.password) {
906
946
  process.stderr.write("[bir] --password is deprecated and will be removed in the next release. " +
907
947
  "Browser sign-in works for every account, including Google-only ones.\n");
908
948
  session = await legacyPasswordLogin(baseUrl);
@@ -23,6 +23,7 @@
23
23
  * token on every route, and never echoes the token back. A loopback port that
24
24
  * accepts unauthenticated step reports is a local exfiltration channel.
25
25
  */
26
+ import { type IntentSource } from "./transcript.js";
26
27
  import { type Recorder } from "../record/recorder.js";
27
28
  import { type ReplayOptions } from "../replay/controller.js";
28
29
  /** The subset of the Claude Code hook payload we consume (all optional, all defensive). */
@@ -41,6 +42,13 @@ export interface HookPayload {
41
42
  }
42
43
  export interface ControlServerOptions {
43
44
  recorder: Recorder;
45
+ /**
46
+ * Tool results of the current prompt handed to derivation
47
+ * (`BIR_DERIVE_RECENT_RESULTS`, segmented.md R-PARAM-2). Default 5; 0 off.
48
+ */
49
+ deriveRecentResults?: number;
50
+ /** `BIR_INTENT_SOURCES` — which intent rungs may be sent (R-INTENT-3). */
51
+ intentSources?: ReadonlySet<IntentSource>;
44
52
  /** Session working directory — keys the discovery file. */
45
53
  cwd: string;
46
54
  /** Preferred port; 0 (or a busy port) falls back to an ephemeral one. */
@@ -107,6 +115,18 @@ export declare class ControlServer {
107
115
  private readonly pendingSeals;
108
116
  /** Calculated replay. Inert unless `opts.replay.enabled`. */
109
117
  private readonly replay;
118
+ /**
119
+ * How many of this prompt's tool results derivation may read
120
+ * (`BIR_DERIVE_RECENT_RESULTS`, segmented.md R-PARAM-2). A target is often
121
+ * named by an earlier step's output and never by the prompt.
122
+ */
123
+ private readonly deriveRecentResults;
124
+ /**
125
+ * Which rungs of R-INTENT-1 the runner may send (`BIR_INTENT_SOURCES`). It
126
+ * governs only what is *sent*: with every source off, the probe still goes out
127
+ * with an empty text and the server builds the tool line (R-HIT-4).
128
+ */
129
+ private readonly intentSources;
110
130
  constructor(opts: ControlServerOptions);
111
131
  listen(): Promise<ControlServerAddress>;
112
132
  close(): Promise<void>;
@@ -167,6 +187,8 @@ export declare class ControlServer {
167
187
  * false` and zero rather than a number that is confidently wrong.
168
188
  */
169
189
  private runCost;
190
+ /** Transcript cost since `mark`; unmeasured (and zero) without one. */
191
+ private costSince;
170
192
  private reportExecution;
171
193
  /** Seal the run *and* wait for everything queued to reach the service. */
172
194
  private finalizeRun;
@@ -183,6 +205,67 @@ export declare class ControlServer {
183
205
  * record `tool_selected`. The hook is this step's only observer.
184
206
  */
185
207
  private onToolPre;
208
+ /** A controller decision as a `PreToolUse` answer, or undefined to carry on. */
209
+ private preToolAnswer;
210
+ /**
211
+ * Recordings this turn must not count a step hit against (segmented.md
212
+ * R-HIT-7).
213
+ *
214
+ * A repeat of a recording's own prompt is *prompt* recurrence, counted as
215
+ * `iterations`, never step recurrence. Without this, three prompt repeats
216
+ * would reach `SEGMENT_MIN_HITS` on every step of the whole recording and the
217
+ * detector would build a whole-run-sized segment beside the whole-run
218
+ * scenario.
219
+ */
220
+ private excludeRunsFor;
221
+ /**
222
+ * Per recording a plan of this turn came from, how far that plan got
223
+ * (segmented.md R-OUT-6).
224
+ *
225
+ * The server uses it to refuse a segment that starts *before* where this turn
226
+ * already is: re-running steps the turn has just done is worse than not
227
+ * replaying at all. The largest position per recording wins, and a plan
228
+ * declined for anything but a known-bad first step reports nothing — it ran
229
+ * none of the recording, so it blocks none of it.
230
+ *
231
+ * Rebuilt on every probe rather than cached: a plan that hands over between
232
+ * two calls changes the answer.
233
+ */
234
+ private ranThroughFor;
235
+ /**
236
+ * Intent matching in the ReAct loop (fallbk.md §Runner 4, segmented.md 10.3).
237
+ *
238
+ * Called only while the model is driving. Every unsteered, non-housekeeping
239
+ * tool call is sent: the service counts it as a hit on whatever recorded step
240
+ * it matches, and may hand back a scenario or a segment to run in its place.
241
+ *
242
+ * A call made with **no reasoning at all** still probes (R-HIT-4): the server
243
+ * builds an intent from the tool name and arguments, and a step the agent took
244
+ * without narrating it is exactly as much a recurrence as one it explained.
245
+ * Every failure is a miss.
246
+ */
247
+ private tryIntentMatch;
248
+ /**
249
+ * Schedule a fragment once a matched turn's plan has handed over (fallbk.md
250
+ * D7). Only a turn that is not already recording needs one: an unmatched turn
251
+ * whose intent-armed plan handed over is recording the model's work anyway.
252
+ */
253
+ private noteHandover;
254
+ /**
255
+ * Open the fragment run and record from this call on (fallbk.md D7).
256
+ *
257
+ * The run id is swapped in place: steps allocated before belonged to a turn
258
+ * the service never created a run for, and nothing of theirs may land in the
259
+ * fragment — so pending correlations are latched and built-ins forgotten.
260
+ */
261
+ private openFragment;
262
+ /** Resolve with `p`, or null once `ms` passes or it rejects. */
263
+ private withinBudget;
264
+ /**
265
+ * `PostToolUse`, and where a hand-over noticed while threading this call's
266
+ * output reaches the model at once, as `additionalContext` (fallbk.md D4).
267
+ */
268
+ private onToolPost;
186
269
  /**
187
270
  * `PostToolUse` / `PostToolUseFailure`. For a built-in this closes the pair.
188
271
  * For a correlated MCP call it normally does nothing — the proxy owns that
@@ -190,7 +273,7 @@ export declare class ControlServer {
190
273
  * or the call never reached it), the hook's own view is recorded after
191
274
  * {@link PROXY_REPORT_GRACE_MS} rather than the step being lost entirely.
192
275
  */
193
- private onToolPost;
276
+ private toolPost;
194
277
  private errorText;
195
278
  /**
196
279
  * `SubagentStart` / `SubagentStop`. These carry `agent_id`, which is the only