@sweny-ai/core 0.1.198 → 0.1.200

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 (44) hide show
  1. package/dist/agent-env.d.ts +94 -2
  2. package/dist/agent-env.js +144 -7
  3. package/dist/budget.d.ts +77 -11
  4. package/dist/budget.js +132 -16
  5. package/dist/cli/renderer.js +5 -0
  6. package/dist/cli/run-history.d.ts +12 -0
  7. package/dist/cli/run-history.js +16 -0
  8. package/dist/cli/run-output.d.ts +16 -1
  9. package/dist/cli/run-output.js +46 -4
  10. package/dist/cli/runs.js +38 -1
  11. package/dist/cli/ticket.js +7 -2
  12. package/dist/eval/index.d.ts +10 -0
  13. package/dist/eval/index.js +27 -6
  14. package/dist/eval/judge.d.ts +3 -0
  15. package/dist/eval/judge.js +12 -2
  16. package/dist/executor.d.ts +5 -1
  17. package/dist/executor.js +276 -31
  18. package/dist/harness/acp.d.ts +5 -2
  19. package/dist/harness/acp.js +11 -23
  20. package/dist/harness/auth.js +2 -1
  21. package/dist/harness/claude-code.d.ts +18 -13
  22. package/dist/harness/claude-code.js +34 -15
  23. package/dist/harness/codex.d.ts +22 -5
  24. package/dist/harness/codex.js +67 -11
  25. package/dist/harness/compat.js +1 -0
  26. package/dist/harness/index.d.ts +1 -1
  27. package/dist/harness/index.js +1 -1
  28. package/dist/harness/pi.d.ts +11 -5
  29. package/dist/harness/pi.js +27 -9
  30. package/dist/harness/prompts.d.ts +11 -1
  31. package/dist/harness/prompts.js +21 -2
  32. package/dist/harness/sandbox-wrapper.d.ts +13 -2
  33. package/dist/harness/sandbox-wrapper.js +29 -4
  34. package/dist/harness/types.d.ts +9 -1
  35. package/dist/index.d.ts +4 -4
  36. package/dist/index.js +2 -2
  37. package/dist/journal.d.ts +48 -10
  38. package/dist/journal.js +164 -16
  39. package/dist/safe-outputs.d.ts +9 -0
  40. package/dist/safe-outputs.js +41 -26
  41. package/dist/skills/linear.js +3 -1
  42. package/dist/studio.js +2 -1
  43. package/dist/types.d.ts +34 -1
  44. package/package.json +1 -1
@@ -77,6 +77,8 @@ export declare const PI_ENV_PREFIXES: readonly string[];
77
77
  * name is in {@link PI_AUTH_VARS} (`meta` to `META_API_KEY`).
78
78
  */
79
79
  export declare const PI_PROVIDER_VARS: Readonly<Record<string, readonly string[]>>;
80
+ /** pi settings that are not secrets: their presence alone never selects a provider. */
81
+ export declare const PI_NON_SECRET_VARS: ReadonlySet<string>;
80
82
  /** The {@link PI_AUTH_VARS} names for one pi provider id (empty for a custom `models.json` provider). */
81
83
  export declare function piProviderVars(provider: string): string[];
82
84
  export interface PiProviderResolution {
@@ -115,6 +117,12 @@ export interface BuildAgentEnvOpts {
115
117
  prefixes?: readonly string[];
116
118
  /** Operator passthrough list (`env-passthrough`). `"*"` inherits everything. */
117
119
  passthrough?: readonly string[];
120
+ /**
121
+ * Where `CLAUDE_CODE_USE_BEDROCK` / `CLAUDE_CODE_USE_VERTEX` are read when
122
+ * deciding the cloud vars. Default: `source`. A harness `env` overlay passes
123
+ * the overlaid env, so an overlay that turns Bedrock on keeps the `AWS_*` vars.
124
+ */
125
+ routing?: Record<string, string | undefined>;
118
126
  logger?: Pick<Logger, "warn">;
119
127
  }
120
128
  /**
@@ -221,7 +229,8 @@ export declare function noPushGitConfig(dir: string): Array<[string, string]>;
221
229
  * scoping is on. Withholds {@link PUSH_TOKEN_VARS} (the ssh agent socket and
222
230
  * askpass programs among them) and every {@link isAgentCredential} name, even
223
231
  * one a node granted with `agent_env`, appends
224
- * {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present,
232
+ * {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present
233
+ * (and drops `GIT_CONFIG_PARAMETERS`, which git would apply after them),
225
234
  * sets `GIT_ASKPASS` to a refusing program, turns off git's terminal prompt,
226
235
  * routes git's ssh through a wrapper that refuses `git-receive-pack` (so an
227
236
  * ssh push fails even to a remote with an explicit `pushurl` under
@@ -306,6 +315,72 @@ export declare function grantedAgentEnv(agentEnv: readonly string[] | undefined,
306
315
  grant: string[];
307
316
  dropped?: string;
308
317
  };
318
+ /**
319
+ * The `env` and `envRemove` options every harness takes. They hold per
320
+ * harness instance: every node and every judge and route call (`complete()`)
321
+ * on that instance gets them, so per-run isolation means one harness per
322
+ * `execute()`.
323
+ */
324
+ export interface HarnessEnvOptions {
325
+ /**
326
+ * Values laid over the agent env after scoping and passthrough, then put
327
+ * through the same credential rule as the inherited env: a skill credential
328
+ * (`GITHUB_TOKEN`, `SLACK_*`, a custom skill's config name) is withheld
329
+ * unless the node grants it with `agent_env`, and the harness's own auth
330
+ * vars always pass. When it names one of the harness's own credentials, the
331
+ * inherited ones are dropped first, so the overlay's login replaces the
332
+ * parent's instead of sitting beside it. In a staged or dry run the push
333
+ * block lands on top: it sets `GIT_ASKPASS`, `GH_CONFIG_DIR`, the ssh
334
+ * command and its git config entries over any overlaid value, drops
335
+ * `GIT_CONFIG_PARAMETERS` and every push token. `SWENY_*` names here only
336
+ * reach the agent; sweny itself reads its settings from `process.env`.
337
+ * Use it to hand a run its own `HOME` or `CLAUDE_CONFIG_DIR` without
338
+ * touching `process.env`, which is never modified. `HOME`,
339
+ * `CLAUDE_CONFIG_DIR` and `CODEX_HOME` must be absolute paths: the sandbox
340
+ * denies the login files under them.
341
+ */
342
+ env?: Readonly<Record<string, string>>;
343
+ /**
344
+ * Names removed from the agent env after scoping and passthrough, before
345
+ * `env` is applied (e.g. a parent `ANTHROPIC_API_KEY` when the run should
346
+ * use a login under `CLAUDE_CONFIG_DIR`).
347
+ */
348
+ envRemove?: readonly string[];
349
+ }
350
+ /** A validated, copied {@link HarnessEnvOptions}. */
351
+ export interface AgentEnvOverlay {
352
+ readonly env: Readonly<Record<string, string>>;
353
+ readonly remove: readonly string[];
354
+ }
355
+ /**
356
+ * The executable a spawn of `command` with this env and cwd would run: a path
357
+ * with a separator resolves against `cwd`, a bare name is looked up on
358
+ * `env.PATH`. Undefined when nothing executable is found. Used so a version
359
+ * check probes the same file the spawn later runs (a harness `env` overlay may
360
+ * set its own `PATH`).
361
+ */
362
+ export declare function resolveExecutable(command: string, env: Record<string, string | undefined>, cwd?: string): string | undefined;
363
+ /**
364
+ * Validate and copy a harness's `env` / `envRemove` options. Throws on a
365
+ * non-string value or an invalid name, so a bad overlay fails at construction
366
+ * instead of at spawn. Returns undefined when neither option is set. The copy
367
+ * means a later change to the caller's objects has no effect.
368
+ */
369
+ export declare function agentEnvOverlay(opts: HarnessEnvOptions): AgentEnvOverlay | undefined;
370
+ /**
371
+ * Apply a caller overlay to a scoped agent env, before {@link finishAgentEnv}
372
+ * (which then withholds any overlaid skill credential the node did not grant).
373
+ * Drops the `remove` names, then, when the overlay names any of `credentials`
374
+ * (the harness's own login vars), drops the inherited ones, then lays the
375
+ * overlay values on top. Pure: returns a copy.
376
+ */
377
+ export declare function applyEnvOverlay(env: Record<string, string>, overlay: AgentEnvOverlay | undefined, credentials: readonly string[]): Record<string, string>;
378
+ /**
379
+ * `process.env` as the agent will see it for auth and command lookups (a
380
+ * harness's `preflight()` probe): the overlay applied by {@link applyEnvOverlay},
381
+ * except `SWENY_*` names, which stay as sweny reads them from `process.env`.
382
+ */
383
+ export declare function overlaidProcessEnv(overlay: AgentEnvOverlay | undefined, credentials: readonly string[]): Record<string, string>;
309
384
  /** What one node's agent call may see: env var names and network hosts. */
310
385
  export interface AgentAccess {
311
386
  /**
@@ -428,14 +503,31 @@ export declare function checkSandboxSupport(platform?: NodeJS.Platform, pathEnv?
428
503
  * - `network.allowedDomains` + `strictAllowlist`: non-listed hosts are denied
429
504
  * deterministically rather than prompted (no prompt exists headless).
430
505
  * - `credentials`: the agent's own Anthropic credentials are unset for
431
- * sandboxed commands, and the Claude Code credentials file is unreadable.
506
+ * sandboxed commands, and the Claude Code credentials file is unreadable:
507
+ * the host's, and the one the agent env selects ({@link claudeCredentialFiles},
508
+ * `agentEnv` carrying a harness `env` overlay's `HOME` / `CLAUDE_CONFIG_DIR`).
432
509
  */
433
510
  export declare function buildSandboxSettings(domains: readonly string[], opts?: {
434
511
  failIfUnavailable?: boolean;
435
512
  home?: string;
513
+ agentEnv?: Record<string, string | undefined>;
514
+ /** The agent's working directory, for a relative `HOME` / `CLAUDE_CONFIG_DIR`. */
515
+ cwd?: string;
436
516
  }): SandboxSettings;
517
+ /**
518
+ * Claude Code login files to deny to sandboxed commands: the host's
519
+ * (`<hostHome>/.claude/.credentials.json`), plus the one `env` selects when it
520
+ * points elsewhere: `$CLAUDE_CONFIG_DIR/.credentials.json`, and
521
+ * `$HOME/.claude/.credentials.json`. A relative value resolves against the
522
+ * agent's `cwd`, where the agent would resolve it. Deduplicated, host file first.
523
+ */
524
+ export declare function claudeCredentialFiles(env: Record<string, string | undefined>, hostHome: string, cwd?: string): string[];
437
525
  export interface ResolveAgentSandboxOpts {
438
526
  env: Record<string, string | undefined>;
527
+ /** The agent's env when it differs from `env` (a harness `env` overlay): its login files are denied too. */
528
+ agentEnv?: Record<string, string | undefined>;
529
+ /** The agent's working directory (resolves a relative path in `agentEnv`). */
530
+ cwd?: string;
439
531
  /** Explicit mode (client option); falls back to `env.SWENY_SANDBOX`. */
440
532
  mode?: SandboxMode;
441
533
  /** Explicit extra hosts (client option); falls back to `env.SWENY_SANDBOX_ALLOWED_DOMAINS`. */
package/dist/agent-env.js CHANGED
@@ -262,7 +262,7 @@ export const PI_PROVIDER_VARS = {
262
262
  "xiaomi-token-plan-sgp": ["XIAOMI_TOKEN_PLAN_SGP_API_KEY"],
263
263
  };
264
264
  /** pi settings that are not secrets: their presence alone never selects a provider. */
265
- const PI_NON_SECRET_VARS = new Set([
265
+ export const PI_NON_SECRET_VARS = new Set([
266
266
  "AZURE_OPENAI_BASE_URL",
267
267
  "AZURE_OPENAI_RESOURCE_NAME",
268
268
  "CLOUDFLARE_ACCOUNT_ID",
@@ -338,7 +338,8 @@ export function resolvePiProvider(env, model, explicit) {
338
338
  }
339
339
  /** A stored Codex login (`codex login`): `auth.json` under `CODEX_HOME`, default `~/.codex`. */
340
340
  export function hasCodexLogin(env = process.env, exists = existsSync) {
341
- const home = env.CODEX_HOME || path.join(homedir(), ".codex");
341
+ // The env's HOME, not the process's: a harness `env` overlay may give the run its own.
342
+ const home = env.CODEX_HOME || path.join(env.HOME || homedir(), ".codex");
342
343
  return exists(path.join(home, "auth.json"));
343
344
  }
344
345
  /**
@@ -535,9 +536,10 @@ export function buildAgentEnv(source, opts = {}) {
535
536
  const prefixes = [...(opts.prefixes ?? AGENT_ENV_PREFIXES)];
536
537
  // Bedrock / Vertex routing is a Claude Code setting; only its default prefixes carry it.
537
538
  if (!opts.prefixes) {
538
- if (truthy(source.CLAUDE_CODE_USE_BEDROCK))
539
+ const routing = opts.routing ?? source;
540
+ if (truthy(routing.CLAUDE_CODE_USE_BEDROCK))
539
541
  prefixes.push(...BEDROCK_PREFIXES);
540
- if (truthy(source.CLAUDE_CODE_USE_VERTEX))
542
+ if (truthy(routing.CLAUDE_CODE_USE_VERTEX))
541
543
  for (const v of VERTEX_VARS)
542
544
  exact.add(v);
543
545
  }
@@ -636,7 +638,8 @@ export function noPushGitConfig(dir) {
636
638
  * scoping is on. Withholds {@link PUSH_TOKEN_VARS} (the ssh agent socket and
637
639
  * askpass programs among them) and every {@link isAgentCredential} name, even
638
640
  * one a node granted with `agent_env`, appends
639
- * {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present,
641
+ * {@link noPushGitConfig} after any `GIT_CONFIG_*` entries already present
642
+ * (and drops `GIT_CONFIG_PARAMETERS`, which git would apply after them),
640
643
  * sets `GIT_ASKPASS` to a refusing program, turns off git's terminal prompt,
641
644
  * routes git's ssh through a wrapper that refuses `git-receive-pack` (so an
642
645
  * ssh push fails even to a remote with an explicit `pushurl` under
@@ -662,6 +665,10 @@ export function withPushBlocked(env, enabled) {
662
665
  // Every skill credential too, even one a node granted with `agent_env`.
663
666
  if (PUSH_TOKEN_VARS.includes(k) || isAgentCredential(k))
664
667
  continue;
668
+ // git reads GIT_CONFIG_PARAMETERS after the GIT_CONFIG_COUNT entries, so a value there would
669
+ // override the block (push.default, core.hooksPath, credential.helper).
670
+ if (k === "GIT_CONFIG_PARAMETERS")
671
+ continue;
665
672
  out[k] = v;
666
673
  }
667
674
  const base = Number.parseInt(out.GIT_CONFIG_COUNT ?? "0", 10);
@@ -814,6 +821,116 @@ export function grantedAgentEnv(agentEnv, opts) {
814
821
  return { grant: [], dropped: `agent_env withheld in a staged or dry run: ${names.join(", ")}` };
815
822
  return { grant: names };
816
823
  }
824
+ /** POSIX leaves env names loosely defined; the rules here: not empty, no `=`, no NUL, no whitespace. */
825
+ function checkEnvName(option, name) {
826
+ if (typeof name !== "string" || name === "" || /[=\0\s]/.test(name)) {
827
+ throw new Error(`${option}: invalid env var name ${JSON.stringify(name)} (must be a non-empty string without "=", NUL or whitespace)`);
828
+ }
829
+ return name;
830
+ }
831
+ /**
832
+ * The executable a spawn of `command` with this env and cwd would run: a path
833
+ * with a separator resolves against `cwd`, a bare name is looked up on
834
+ * `env.PATH`. Undefined when nothing executable is found. Used so a version
835
+ * check probes the same file the spawn later runs (a harness `env` overlay may
836
+ * set its own `PATH`).
837
+ */
838
+ export function resolveExecutable(command, env, cwd = process.cwd()) {
839
+ const ok = (p) => {
840
+ try {
841
+ accessSync(p, fsConstants.X_OK);
842
+ return statSync(p).isFile();
843
+ }
844
+ catch {
845
+ return false;
846
+ }
847
+ };
848
+ if (command.includes(path.sep) || command.includes("/")) {
849
+ const p = path.resolve(cwd, command);
850
+ return ok(p) ? p : undefined;
851
+ }
852
+ for (const dir of (env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
853
+ const p = path.resolve(cwd, dir, command);
854
+ if (ok(p))
855
+ return p;
856
+ }
857
+ return undefined;
858
+ }
859
+ /** Overlay names whose value is a directory the sandbox derives credential-file denies from. */
860
+ const OVERLAY_PATH_VARS = ["HOME", "CLAUDE_CONFIG_DIR", "CODEX_HOME"];
861
+ function isPlainObject(v) {
862
+ if (v === null || typeof v !== "object")
863
+ return false;
864
+ const proto = Object.getPrototypeOf(v);
865
+ return proto === Object.prototype || proto === null;
866
+ }
867
+ /**
868
+ * Validate and copy a harness's `env` / `envRemove` options. Throws on a
869
+ * non-string value or an invalid name, so a bad overlay fails at construction
870
+ * instead of at spawn. Returns undefined when neither option is set. The copy
871
+ * means a later change to the caller's objects has no effect.
872
+ */
873
+ export function agentEnvOverlay(opts) {
874
+ if (opts.env === undefined && opts.envRemove === undefined)
875
+ return undefined;
876
+ const env = {};
877
+ if (opts.env !== undefined) {
878
+ if (!isPlainObject(opts.env)) {
879
+ throw new Error("env: must be a plain object of env var names to string values (not a Map or class instance)");
880
+ }
881
+ for (const [k, v] of Object.entries(opts.env)) {
882
+ checkEnvName("env", k);
883
+ if (typeof v !== "string") {
884
+ throw new Error(`env: value for ${k} must be a string, got ${typeof v}` +
885
+ (v === undefined ? " (leave the key out, or name it in envRemove to unset it)" : ""));
886
+ }
887
+ if (v.includes("\0"))
888
+ throw new Error(`env: value for ${k} contains NUL`);
889
+ if (OVERLAY_PATH_VARS.includes(k) && !path.isAbsolute(v)) {
890
+ throw new Error(`env: ${k} must be an absolute path, got ${JSON.stringify(v)}`);
891
+ }
892
+ env[k] = v;
893
+ }
894
+ }
895
+ if (opts.envRemove !== undefined && !Array.isArray(opts.envRemove)) {
896
+ throw new Error("envRemove: must be an array of env var names");
897
+ }
898
+ const remove = (opts.envRemove ?? []).map((n) => checkEnvName("envRemove", n));
899
+ return Object.freeze({ env: Object.freeze(env), remove: Object.freeze(remove) });
900
+ }
901
+ /**
902
+ * Apply a caller overlay to a scoped agent env, before {@link finishAgentEnv}
903
+ * (which then withholds any overlaid skill credential the node did not grant).
904
+ * Drops the `remove` names, then, when the overlay names any of `credentials`
905
+ * (the harness's own login vars), drops the inherited ones, then lays the
906
+ * overlay values on top. Pure: returns a copy.
907
+ */
908
+ export function applyEnvOverlay(env, overlay, credentials) {
909
+ if (!overlay)
910
+ return env;
911
+ const out = { ...env };
912
+ for (const name of overlay.remove)
913
+ delete out[name];
914
+ if (credentials.some((c) => Object.hasOwn(overlay.env, c)))
915
+ for (const c of credentials)
916
+ delete out[c];
917
+ return Object.assign(out, overlay.env);
918
+ }
919
+ /**
920
+ * `process.env` as the agent will see it for auth and command lookups (a
921
+ * harness's `preflight()` probe): the overlay applied by {@link applyEnvOverlay},
922
+ * except `SWENY_*` names, which stay as sweny reads them from `process.env`.
923
+ */
924
+ export function overlaidProcessEnv(overlay, credentials) {
925
+ const full = Object.fromEntries(Object.entries(process.env).filter((e) => e[1] != null));
926
+ if (!overlay)
927
+ return full;
928
+ const own = (n) => !n.startsWith("SWENY_");
929
+ return applyEnvOverlay(full, {
930
+ env: Object.fromEntries(Object.entries(overlay.env).filter(([k]) => own(k))),
931
+ remove: overlay.remove.filter(own),
932
+ }, credentials);
933
+ }
817
934
  /** True when a run request may load no MCP server but sweny's own bridge (read-only or staged). */
818
935
  export function mcpWithheld(readOnly, access) {
819
936
  return readOnly || access?.noMcp === true;
@@ -1031,9 +1148,12 @@ function defaultProbe() {
1031
1148
  * - `network.allowedDomains` + `strictAllowlist`: non-listed hosts are denied
1032
1149
  * deterministically rather than prompted (no prompt exists headless).
1033
1150
  * - `credentials`: the agent's own Anthropic credentials are unset for
1034
- * sandboxed commands, and the Claude Code credentials file is unreadable.
1151
+ * sandboxed commands, and the Claude Code credentials file is unreadable:
1152
+ * the host's, and the one the agent env selects ({@link claudeCredentialFiles},
1153
+ * `agentEnv` carrying a harness `env` overlay's `HOME` / `CLAUDE_CONFIG_DIR`).
1035
1154
  */
1036
1155
  export function buildSandboxSettings(domains, opts = {}) {
1156
+ const loginFiles = claudeCredentialFiles(opts.agentEnv ?? {}, opts.home ?? homedir(), opts.cwd);
1037
1157
  return {
1038
1158
  enabled: true,
1039
1159
  failIfUnavailable: opts.failIfUnavailable ?? true,
@@ -1046,7 +1166,7 @@ export function buildSandboxSettings(domains, opts = {}) {
1046
1166
  credentials: {
1047
1167
  envVars: AGENT_AUTH_VARS.map((name) => ({ name, mode: "deny" })),
1048
1168
  files: [
1049
- { path: path.join(opts.home ?? homedir(), ".claude", ".credentials.json"), mode: "deny" },
1169
+ ...loginFiles.map((p) => ({ path: p, mode: "deny" })),
1050
1170
  // Run journals and their keys: an agent that could read a key could forge records.
1051
1171
  { path: runStateRoot(), mode: "deny" },
1052
1172
  ],
@@ -1055,6 +1175,21 @@ export function buildSandboxSettings(domains, opts = {}) {
1055
1175
  filesystem: { denyRead: [runStateRoot()], denyWrite: [runStateRoot()] },
1056
1176
  };
1057
1177
  }
1178
+ /**
1179
+ * Claude Code login files to deny to sandboxed commands: the host's
1180
+ * (`<hostHome>/.claude/.credentials.json`), plus the one `env` selects when it
1181
+ * points elsewhere: `$CLAUDE_CONFIG_DIR/.credentials.json`, and
1182
+ * `$HOME/.claude/.credentials.json`. A relative value resolves against the
1183
+ * agent's `cwd`, where the agent would resolve it. Deduplicated, host file first.
1184
+ */
1185
+ export function claudeCredentialFiles(env, hostHome, cwd = process.cwd()) {
1186
+ const files = [path.join(hostHome, ".claude", ".credentials.json")];
1187
+ if (env.CLAUDE_CONFIG_DIR)
1188
+ files.push(path.resolve(cwd, env.CLAUDE_CONFIG_DIR, ".credentials.json"));
1189
+ if (env.HOME)
1190
+ files.push(path.resolve(cwd, env.HOME, ".claude", ".credentials.json"));
1191
+ return [...new Set(files)];
1192
+ }
1058
1193
  /**
1059
1194
  * Resolve the `sandbox` query option for one agent call.
1060
1195
  *
@@ -1091,6 +1226,8 @@ export function resolveAgentSandbox(opts) {
1091
1226
  settings: buildSandboxSettings([...DEFAULT_SANDBOX_DOMAINS, ...(opts.nodeDomains ?? []), ...extra], {
1092
1227
  // Preflight passed: from here the sandbox is enforced or the node fails.
1093
1228
  failIfUnavailable: true,
1229
+ agentEnv: opts.agentEnv,
1230
+ cwd: opts.cwd,
1094
1231
  }),
1095
1232
  };
1096
1233
  }
package/dist/budget.d.ts CHANGED
@@ -8,8 +8,14 @@
8
8
  *
9
9
  * Tokens are input + output tokens as the harness reports them (cache reads and
10
10
  * writes are not counted, matching the run receipt). Cost is the harness's own
11
- * reported USD, never estimated. Only agent node runs are counted: judge,
12
- * routing and retry-reflection completions report no usage and are not metered.
11
+ * reported USD, never estimated. Every model completion the executor itself
12
+ * makes is counted too, under its own `kind` (see {@link SpendKind}): judge
13
+ * scoring, route evaluation, retry reflection and the safe-output screen. A
14
+ * completion counts when its harness reports usage for it; one that reports
15
+ * none adds nothing (the same as a node run without usage). The run totals
16
+ * and the stop are over all kinds together, across a resume too; the split is
17
+ * `trace.spend`. Once a ceiling is reached no further executor completion
18
+ * starts. The decision model (`route_by: decider`) is not metered.
13
19
  *
14
20
  * Enforcement, per unit the harness can report:
15
21
  * - live: the harness calls `onUsage` during the node; crossing a limit aborts
@@ -17,7 +23,9 @@
17
23
  * - between nodes: usage on the finished result is checked; a crossing fails the
18
24
  * node, and the run halts before the next node starts.
19
25
  * - across a resume: the run journal records each attempt's spend (live and
20
- * final), and `sweny workflow resume` seeds the run total with it.
26
+ * final), and each metered completion (judge, route, reflection, screen)
27
+ * as its own record; `sweny workflow resume` seeds the run total with all
28
+ * of it, split by kind.
21
29
  */
22
30
  import type { NodeUsage } from "./types.js";
23
31
  /** The `budget` field of a node or workflow. */
@@ -36,6 +44,20 @@ export interface Spend {
36
44
  tokens: number;
37
45
  costUsd: number;
38
46
  }
47
+ /** Spend restored on a resume: the total, and its split by kind when the journal has one. */
48
+ export interface SeedSpend extends Spend {
49
+ byKind?: Partial<Record<SpendKind, Spend>>;
50
+ }
51
+ /** What a unit of spend paid for. `node` is an agent node run; the rest are the executor's own completions. */
52
+ export type SpendKind = "node" | "judge" | "route" | "reflect" | "screen";
53
+ /** The order kinds are listed in, wherever the split is shown. */
54
+ export declare const SPEND_KINDS: readonly SpendKind[];
55
+ /** Spend of one {@link SpendKind}. `calls` counts the completions that reported usage. */
56
+ export interface KindSpend extends Spend {
57
+ calls: number;
58
+ }
59
+ /** Spend by kind. A kind with no reported usage is absent. */
60
+ export type SpendBreakdown = Partial<Record<SpendKind, KindSpend>>;
39
61
  export type BudgetUnit = "tokens" | "cost_usd";
40
62
  /** Recorded on the node whose spend crossed a budget (`NodeResult.budget`). */
41
63
  export interface BudgetOverrun {
@@ -45,6 +67,18 @@ export interface BudgetOverrun {
45
67
  limit: number;
46
68
  /** Spend at the moment of the stop (live) or at the end of the node. */
47
69
  spent: number;
70
+ /** The executor completion whose spend crossed it (judge, route, ...). Absent for a node run. */
71
+ kind?: SpendKind;
72
+ }
73
+ /**
74
+ * Thrown into an executor completion (judge, route, reflection, screen) the
75
+ * budget refuses: a ceiling was already reached, so the call is not made.
76
+ * Callers that swallow a failed completion (a judge's parse retry) must let
77
+ * this one through, so a refusal is never mistaken for a model answer.
78
+ */
79
+ export declare class BudgetRefusal extends Error {
80
+ readonly breach: BudgetOverrun;
81
+ constructor(breach: BudgetOverrun);
48
82
  }
49
83
  export declare function toLimits(b: Budget | undefined): SpendLimits;
50
84
  /** The tighter of two limit sets, per unit. */
@@ -64,21 +98,43 @@ export declare function describeOverrun(o: BudgetOverrun, nodeId?: string): stri
64
98
  export declare class BudgetGuard {
65
99
  readonly runLimits: SpendLimits;
66
100
  private spent;
101
+ private kinds;
67
102
  constructor(runLimits: SpendLimits);
68
103
  get totals(): Spend;
104
+ /**
105
+ * Spend by kind, in {@link SPEND_KINDS} order. Resumed spend is restored
106
+ * under its kind; a journal without meter records seeds `node` only.
107
+ */
108
+ breakdown(): SpendBreakdown;
109
+ /** True once any completion other than a node run reported usage. */
110
+ get hasMeteredCalls(): boolean;
111
+ /**
112
+ * Count one executor completion (judge, route, ...) against the run. Usage
113
+ * with no tokens and no cost counts nothing. Returns the breach this report
114
+ * caused, judged against the totals before it, like a live node report.
115
+ */
116
+ meter(kind: SpendKind, usage: NodeUsage | undefined, nodeBudget?: NodeBudget): BudgetOverrun | undefined;
69
117
  /** True when any limit exists at all, so the executor can skip the machinery. */
70
118
  active(nodeLimits: SpendLimits): boolean;
71
119
  /** The run ceiling is already reached: no further attempt may start. */
72
120
  runExhausted(): BudgetOverrun | undefined;
73
121
  node(nodeLimits: SpendLimits): NodeBudget;
74
122
  /**
75
- * Spend the run made before this process (a resumed run's journaled usage).
76
- * Counted against the run ceiling like any other spend, so a crash never
77
- * resets the whole-run budget.
123
+ * Spend the run made before this process (a resumed run's journaled usage
124
+ * and metered completions). Counted against the run ceiling like any other
125
+ * spend, so a crash never resets the whole-run budget. Restored under its
126
+ * kind when the journal has the split (a journal written before meter
127
+ * records existed has node spend only). Seeded spend is not a call: `calls`
128
+ * is unchanged.
78
129
  */
79
- seed(s: Spend): void;
80
- /** @internal */
81
- commit(s: Spend): void;
130
+ seed(s: SeedSpend): void;
131
+ /**
132
+ * @internal `counted`: this commit is one completion that reported usage, so
133
+ * `calls` goes up. A commit that is not (seeded spend, an attempt that
134
+ * reported nothing) adds its spend only, and a zero one leaves no trace, so
135
+ * the split is the same with and without a limit.
136
+ */
137
+ commit(s: Spend, kind: SpendKind, counted: boolean): void;
82
138
  }
83
139
  export declare class NodeBudget {
84
140
  private readonly guard;
@@ -88,10 +144,18 @@ export declare class NodeBudget {
88
144
  /** A limit is already reached before the next attempt (a retry after a spend-heavy attempt, or a spent run). */
89
145
  exhausted(): BudgetOverrun | undefined;
90
146
  attempt(outer?: AbortSignal): BudgetAttempt;
147
+ /**
148
+ * Spend this visit made before a resume (a checkpointed visit's journaled
149
+ * spend). It counts against the node's own limit only: the run total
150
+ * already holds it from the seed.
151
+ */
152
+ restore(s: Spend): void;
91
153
  /** @internal */
92
154
  get committed(): Spend;
93
- /** @internal */
94
- commit(s: Spend): void;
155
+ /** Count one executor completion made during this visit (judge, retry reflection, screen). */
156
+ meter(kind: SpendKind, usage: NodeUsage | undefined): BudgetOverrun | undefined;
157
+ /** @internal See {@link BudgetGuard.commit}. */
158
+ commit(s: Spend, kind: SpendKind, counted: boolean): void;
95
159
  }
96
160
  /** One agent run. Its `signal` is the caller's signal plus a budget stop. */
97
161
  export declare class BudgetAttempt {
@@ -101,6 +165,8 @@ export declare class BudgetAttempt {
101
165
  private readonly controller;
102
166
  private live;
103
167
  private liveUsage;
168
+ /** A live report carried tokens or cost. */
169
+ private liveReported;
104
170
  private stopped;
105
171
  private settled;
106
172
  private readonly onOuterAbort;