@intentic/sandbox-contract 1.160.0 → 1.161.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentic/sandbox-contract",
3
- "version": "1.160.0",
3
+ "version": "1.161.0",
4
4
  "description": "oRPC wire contract for the intentic sandbox daemon — shared by the daemon and its browser client",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,7 +45,7 @@
45
45
  "@orpc/contract": "1.14.3",
46
46
  "tslib": "2.8.1",
47
47
  "zod": "4.4.3",
48
- "@intentic/extension-api": "1.160.0"
48
+ "@intentic/extension-api": "1.161.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@types/node": "24.13.2",
@@ -1,15 +1,23 @@
1
1
  import { oc } from "@orpc/contract";
2
- import { BuiltinPromptSchema, BuiltinPromptTextSchema, CleanerSavingsSchema, OkSchema, SandboxSettingsSchema } from "../schemas.js";
2
+ import {
3
+ BuiltinPromptSchema,
4
+ BuiltinPromptTextSchema,
5
+ DayWindowQuerySchema,
6
+ OkSchema,
7
+ SandboxSettingsSchema,
8
+ SavingsReportSchema,
9
+ } from "../schemas.js";
3
10
 
4
11
  // Per-sandbox agent settings (.intentic/settings.json). `get` returns the current flags with defaults applied
5
- // when the file is absent; `set` overwrites them. `savings` reports the output-cleaner token savings aggregated
6
- // from the live filter-stats ledger (the rtk-`gain` surface read-only, so a UI card can show what's working).
12
+ // when the file is absent; `set` overwrites them. `savings` reports what each token-reduction mechanism was
13
+ // worth the cleaners' realized per-command savings and the terse steer's measured A/B over an inclusive
14
+ // UTC day window, the same one the spend ledger takes, so a screen can filter both with one calendar.
7
15
  // `builtinPrompt` returns one of the two built-in system prompts as text — Intentic's own, or Claude Code's
8
16
  // read out of the installed CLI — so the settings page can SHOW the prompt behind a mode instead of asking the
9
17
  // user to trust a description of it, and can fork either into a custom one.
10
18
  export const settingsContract = {
11
19
  get: oc.route({ method: "GET", path: "/settings" }).output(SandboxSettingsSchema),
12
20
  set: oc.route({ method: "POST", path: "/settings" }).input(SandboxSettingsSchema).output(OkSchema),
13
- savings: oc.route({ method: "GET", path: "/settings/savings" }).output(CleanerSavingsSchema),
21
+ savings: oc.route({ method: "GET", path: "/settings/savings" }).input(DayWindowQuerySchema).output(SavingsReportSchema),
14
22
  builtinPrompt: oc.route({ method: "GET", path: "/settings/system-prompt/{base}" }).input(BuiltinPromptSchema).output(BuiltinPromptTextSchema),
15
23
  };
@@ -1,10 +1,10 @@
1
1
  import { oc } from "@orpc/contract";
2
- import { UsageRollupQuerySchema, UsageRollupSchema } from "../schemas.js";
2
+ import { DayWindowQuerySchema, UsageRollupSchema } from "../schemas.js";
3
3
 
4
4
  // The durable spend ledger (see UsageTurnSchema). Read-only over the wire — rows are appended daemon-side at
5
5
  // turn end, so the ledger stays a trustworthy record of what was actually spent, the same principle as the
6
6
  // activity log. `rollup` serves every cost/usage panel: it groups by day × provider × account × model, and the
7
7
  // browser re-projects from there (spend per day, cost by model, cache hit rate) without another round trip.
8
8
  export const usageContract = {
9
- rollup: oc.route({ method: "GET", path: "/usage/rollup" }).input(UsageRollupQuerySchema).output(UsageRollupSchema),
9
+ rollup: oc.route({ method: "GET", path: "/usage/rollup" }).input(DayWindowQuerySchema).output(UsageRollupSchema),
10
10
  };
package/src/events.ts CHANGED
@@ -239,6 +239,27 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
239
239
  // The live gate: the provider's answer to "may this turn run", pushed mid-turn. Drives the rate-limited
240
240
  // notice, not the headroom readouts — see RateLimitInfoSchema.
241
241
  RateLimitInfoSchema.extend({ kind: z.literal("rate_limit_info"), account: z.string().optional() }),
242
+ /* The turn is alive but WAITING on the provider: a request failed transiently (5xx, 529, a dropped socket)
243
+ * and the harness is retrying it inside this same turn. A status, not a failure — nothing has been lost and
244
+ * the turn may still finish normally, so the client renders it where "thinking" goes rather than in the
245
+ * transcript.
246
+ *
247
+ * It exists because the retry budget is deliberately long (see CLAUDE_CODE_RETRY_WATCHDOG in
248
+ * harness-credentials.ts): a turn can now sit silent for minutes riding out an outage, and silence reads as
249
+ * a hang. The one action a user takes against an apparent hang is Stop, which is the only action that
250
+ * actually loses the work — so the wait has to be visible, with its own next-attempt clock.
251
+ *
252
+ * `attempt`/`maxAttempts` are the harness's own counters; `nextAttemptAt` (epoch ms) is when it will try
253
+ * again, so the readout counts down instead of freezing on a number nobody can interpret. */
254
+ z.object({
255
+ kind: z.literal("provider_retry"),
256
+ attempt: z.number(),
257
+ maxAttempts: z.number(),
258
+ nextAttemptAt: z.number(),
259
+ // The HTTP status behind it when there was one (529 reads as capacity, 500 as a fault — the client says
260
+ // which). Absent for a transport failure that never got a response.
261
+ status: z.number().optional(),
262
+ }),
242
263
  // Every plan-limit pool for the account that served the turn, read from the CLI's usage endpoint once the
243
264
  // turn settles. `account` tags which Claude account it belongs to, so the client keys headroom by account;
244
265
  // absent on an env-token turn, which has no account to attribute it to. No `measuredAt` on the wire: both
@@ -291,6 +312,12 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
291
312
  // that resumed itself, not a request for the user to do anything. It only reaches the client
292
313
  // when the resume could NOT start, which is when reconnecting really is the fix.
293
314
  "claude-token-refused",
315
+ // The model provider itself failed transiently — 500/502/503, a 529 at capacity, a dropped
316
+ // socket — and the harness's own in-turn retries did not outlast it. Nothing about the workspace
317
+ // or the request is wrong, so the daemon remembers the turn and re-runs it on an escalating
318
+ // backoff (provider-health.ts): the frame is a notice about a turn that is coming back, and
319
+ // reaches the client as a plain failure only once the attempts are spent.
320
+ "provider-outage",
294
321
  // The harness read the message as a slash command it doesn't have, and discarded everything
295
322
  // after the name — the model never saw the message. Nothing was processed, so the client holds
296
323
  // the text back instead of leaving the user to retype it (same treatment as claude-reauth).
@@ -302,13 +329,24 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
302
329
  ])
303
330
  .optional(),
304
331
  // rate_limit only: when the exhausted window reopens (epoch seconds, from the stream's own
305
- // rate_limit_event or the account's persisted usage windows), and what the daemon did about it —
306
- // "scheduled" = auto-resume is on and this turn re-runs itself a minute after `resetsAt`;
307
- // "available" = the daemon remembered the failed turn and enabling the autoResumeOnLimit setting
308
- // arms that same resume, which is what the chat's offer banner hangs off. Absent together when the
309
- // reset instant is unknown (nothing to schedule against).
332
+ // rate_limit_event or the account's persisted usage windows). Absent when the reset instant is unknown
333
+ // (nothing to schedule against).
310
334
  resetsAt: z.number().optional(),
335
+ // Where the daemon's resume of THIS turn stands — the same two states for a spent allowance and for a
336
+ // provider outage, because the client's reading of them is the same: "scheduled" = the resume is armed
337
+ // and this turn comes back by itself; "available" = the daemon remembered the failed turn and turning
338
+ // the setting on (autoResumeOnLimit / resumeAfterOutage) arms that same resume, which is what the
339
+ // chat's offer banner hangs off. Absent ⇒ there is nothing to resume.
311
340
  autoResume: z.enum(["scheduled", "available"]).optional(),
341
+ /* provider-outage only: the shape of the wait. `retryAt` (epoch seconds) is when the next attempt is
342
+ * due — not a fixed cadence, because an outage has no reset instant to aim at and hammering a provider
343
+ * that is down only spends tokens on refusals, so each attempt waits longer than the last
344
+ * (provider-health.ts owns the schedule).
345
+ *
346
+ * `attempt`/`maxAttempts` are on the wire so the notice can say the automation is BOUNDED. An
347
+ * on-by-default retry that gives no account of how long it will keep going is the kind users switch off
348
+ * defensively; one that says "attempt 2 of 6" is one they leave on. */
349
+ outage: z.object({ retryAt: z.number(), attempt: z.number(), maxAttempts: z.number() }).optional(),
312
350
  // rate_limit only: the account whose allowance is spent, as the DAEMON resolved it (the client's own
313
351
  // selection can be empty, which means "the provider's first"). It is what lets the chat offer the
314
352
  // provider's OTHER accounts as a resume-now instead of a wait — see /agent/resume-limit.
@@ -20,6 +20,7 @@ test("a payload from a build that predates a toggle parses, with the new toggle
20
20
  };
21
21
  expect(SandboxSettingsSchema.parse(older)).toEqual({
22
22
  ...older,
23
+ terseHoldout: 0,
23
24
  filterBackend: "native",
24
25
  systemPromptMode: "intentic",
25
26
  systemPrompt: "",
@@ -27,6 +28,7 @@ test("a payload from a build that predates a toggle parses, with the new toggle
27
28
  agentRetentionDays: 3,
28
29
  autoLand: true,
29
30
  autoResumeOnLimit: false,
31
+ resumeAfterOutage: true,
30
32
  });
31
33
  });
32
34
 
@@ -36,6 +38,8 @@ test("an empty object is the full default settings object", () => {
36
38
  skills: [],
37
39
  hashlineEdits: false,
38
40
  terseOutput: false,
41
+ // Off: the steer's turn-level control spends the tokens it measures, so measuring is opt-in.
42
+ terseHoldout: 0,
39
43
  iqSearch: false,
40
44
  outputCleaners: "off",
41
45
  outputHoldout: 0,
@@ -54,6 +58,9 @@ test("an empty object is the full default settings object", () => {
54
58
  // sandbox's finished work on branches nobody is watching.
55
59
  autoLand: true,
56
60
  autoResumeOnLimit: false,
61
+ // On, unlike the limit resume beside it: an outage resume spends nothing the dead turn hadn't already
62
+ // committed, and the turns it saves are the unattended ones nobody is watching to restart by hand.
63
+ resumeAfterOutage: true,
57
64
  });
58
65
  });
59
66
 
package/src/schemas.ts CHANGED
@@ -648,6 +648,14 @@ export const SandboxSettingsSchema = z.object({
648
648
  skills: z.array(z.string()).default([]),
649
649
  hashlineEdits: z.boolean().default(false),
650
650
  terseOutput: z.boolean().default(false),
651
+ /* Measurement control for the terse steer, at TURN level — the same trick `outputHoldout` plays over
652
+ * commands, one layer up. A fraction [0,1] of otherwise-eligible turns run WITHOUT the steer and record
653
+ * which arm they ran on (UsageTurn.terse), so the savings report can compare two real populations.
654
+ *
655
+ * It has to be an experiment: unlike a cleaned command, which yields its own raw baseline in the same
656
+ * event, a turn cannot be re-run to see what it would have said unsteered. 0 ⇒ no measurement (every
657
+ * eligible turn is steered), which is the default because the control costs the very tokens it measures. */
658
+ terseHoldout: z.number().min(0).max(1).default(0),
651
659
  /* WHICH SYSTEM PROMPT THE AGENT RUNS ON — the base, before anything this turn composes.
652
660
  *
653
661
  * intentic — Intentic's own prompt, tuned for this harness (intentic-prompt.ts). The default.
@@ -697,6 +705,16 @@ export const SandboxSettingsSchema = z.object({
697
705
  // records every limit-hit either way and the chat OFFERS the toggle at the moment it would have helped —
698
706
  // enabling it then still resumes the turn that just bounced.
699
707
  autoResumeOnLimit: z.boolean().default(false),
708
+ /* When a turn dies because the MODEL PROVIDER was failing (500/502/503, a 529 at capacity, a dropped
709
+ * socket), re-run it on an escalating backoff until it goes through or the attempts are spent.
710
+ *
711
+ * Defaults ON, unlike autoResumeOnLimit, and the difference is not an inconsistency: a spent allowance is
712
+ * the user's own budget, and resuming into a fresh window spends something they may have been saving. An
713
+ * outage resume spends nothing the dead turn had not already committed, resolves in minutes rather than
714
+ * hours, and — the deciding argument — the turns hurt worst by it are the ones with nobody in the room
715
+ * (automation wakes, Discord, webhooks), which no browser-held preference could ever rescue. It is the same
716
+ * reasoning that leaves the auth resume ungated: this is the provider's failure, not the user's decision. */
717
+ resumeAfterOutage: z.boolean().default(true),
700
718
  });
701
719
  export type SandboxSettings = z.infer<typeof SandboxSettingsSchema>;
702
720
 
@@ -710,22 +728,43 @@ export type SandboxSettings = z.infer<typeof SandboxSettingsSchema>;
710
728
  export const BuiltinPromptTextSchema = z.object({ text: z.string(), version: z.string() });
711
729
  export type BuiltinPromptText = z.infer<typeof BuiltinPromptTextSchema>;
712
730
 
713
- // ---- output-cleaner savings report (rtk-`gain`-style) ----
714
- // Whichever cleaner is ACTUALLY compressing output owns the numbers, so the report is read from that backend's
731
+ /* ---- savings report: what each token-reduction mechanism actually saved ----
732
+ *
733
+ * TWO FAMILIES, deliberately never one list of bars. They are measured differently, and a chart that ranks
734
+ * them side by side claims a confidence and a denominator that only one of them has:
735
+ *
736
+ * input — shell output the cleaners trimmed before the model ever saw it. Both sides of the comparison come
737
+ * off the SAME command (raw in, emitted out), so the counterfactual is observed rather than
738
+ * estimated: exact, per command, no sample size to argue about.
739
+ * output — the model's own tokens under the terse steer. There is no second run of the same turn to compare
740
+ * against, so the only honest number is an experiment: a turn-level holdout, an n per arm, and a
741
+ * margin. It is absent entirely until both arms are large enough for the delta to mean anything.
742
+ *
743
+ * The two are also in different units of value — a saved tool-output token is saved again on every later
744
+ * request of that conversation, an output token is saved once but costs several times as much — which is the
745
+ * other reason they are separate sections with separate totals rather than one number.
746
+ */
747
+
748
+ // One mechanism's realized saving, biggest first. `savedTokens` is what THIS stage removed from what reached
749
+ // it in pipeline order — sequential attribution, which is why the stages sum exactly to raw − emitted and can
750
+ // be drawn as one stacked bar. It is NOT "what turning this cleaner off would cost you": the cap downstream
751
+ // would have eaten some of the same lines. `commands` is how many commands the stage ran on. Negative for the
752
+ // `footer` stage, which adds the retrieval pointer back — a cost on the same ledger as what it bought.
753
+ export const SavingsStageSchema = z.object({ id: z.string(), commands: z.number(), savedTokens: z.number() });
754
+
755
+ // Whichever cleaner is ACTUALLY compressing output owns these numbers, so they are read from that backend's
715
756
  // own ledger: "native" aggregates historyRoot/logs/filter-stats.jsonl (one row per agent Bash command, written
716
757
  // by agent-output-filter), "rtk" reads rtk's own gain ledger. Reading one ledger regardless of backend is how
717
758
  // this card went stale: under rtk the native filter is switched off, nothing appends, and the last numbers
718
759
  // written — a test run's, as it happened — sat on the card looking live.
719
- //
720
- // `perCleaner` attributes which cleaner ids fired across commands; `holdout` is the measured control (commands
721
- // the holdout bypassed) vs the cleaned population — a real saved-% rather than an estimate; `gaps` are
722
- // high-volume commands that matched no cleaner (the next handler to write). All three are native-only: rtk
723
- // reports totals, not per-command attribution, so they arrive empty under that backend. Empty/zeroed when no
724
- // commands have run yet.
725
- export const CleanerSavingsSchema = z.object({
760
+ export const InputSavingsSchema = z.object({
726
761
  // Which backend's ledger these numbers came from — shown on the card, because a number without its source
727
762
  // cannot be told apart from a stale one. Defaulted for the same daemon-older-than-browser seam as settings.
728
763
  source: z.enum(["native", "rtk"]).default("native"),
764
+ // False ⇒ these totals cover the ledger's whole life, not the range the reader selected: `rtk gain` reports
765
+ // no timestamps, so its numbers cannot be windowed and the screen has to say so rather than let a 7-day
766
+ // filter sit above an all-time figure.
767
+ windowed: z.boolean(),
729
768
  // When that ledger last recorded a command (epoch ms), so the card can show its age instead of implying
730
769
  // freshness it doesn't have. Absent when the ledger has never been written or its age can't be read.
731
770
  updatedAt: z.number().optional(),
@@ -733,11 +772,43 @@ export const CleanerSavingsSchema = z.object({
733
772
  rawTokens: z.number(),
734
773
  emittedTokens: z.number(),
735
774
  savedPct: z.number(),
736
- perCleaner: z.array(z.object({ id: z.string(), commands: z.number() })),
775
+ // Per-stage attribution, biggest first. Native-only: rtk reports totals, not which of its handlers fired.
776
+ perCleaner: z.array(SavingsStageSchema),
777
+ // The measured control — commands the holdout left raw — against the cleaned population. A real saved-%
778
+ // for the pipeline as a whole rather than an estimate, and the only whole-pipeline counterfactual there is.
737
779
  holdout: z.object({ cleaned: z.number(), heldOut: z.number(), measuredSavedPct: z.number().optional() }),
780
+ // High-volume commands that matched no cleaner: where the next handler is worth writing. Native-only.
738
781
  gaps: z.array(z.object({ command: z.string(), tokens: z.number() })),
739
782
  });
740
- export type CleanerSavings = z.infer<typeof CleanerSavingsSchema>;
783
+ export type InputSavings = z.infer<typeof InputSavingsSchema>;
784
+
785
+ // One arm of the turn-level experiment: the turns that ran with the steer, and the turns the holdout ran
786
+ // without it. Mean output tokens PER TURN, because the arms never hold the same number of turns.
787
+ export const SavingsArmSchema = z.object({ turns: z.number(), meanOutputTokens: z.number() });
788
+
789
+ // The terse steer, as measured. Only turns where the steer was ELIGIBLE are counted — a turn under a custom
790
+ // system prompt drops the steer along with everything else the daemon appends, so it belongs to neither arm.
791
+ export const OutputSavingsSchema = z.object({
792
+ on: SavingsArmSchema,
793
+ off: SavingsArmSchema,
794
+ // Turns per arm before a delta is reported at all. Carried on the wire so the screen's "measuring…" state
795
+ // counts toward the daemon's real threshold instead of a number the browser guessed.
796
+ minTurns: z.number(),
797
+ /* The three below are present TOGETHER, and only once both arms clear `minTurns` — a schema that can't
798
+ * express a half-measured experiment is how a 34%-that-becomes-8%-tomorrow never reaches the screen.
799
+ * deltaPct — change in mean output tokens per turn under the steer; negative is a saving.
800
+ * marginPct — ± percentage points, 95% (Welch, unequal variances and unequal arms).
801
+ * savedTokens — what the delta is worth over the turns that actually ran with the steer, in this window. */
802
+ deltaPct: z.number().optional(),
803
+ marginPct: z.number().optional(),
804
+ savedTokens: z.number().optional(),
805
+ });
806
+ export type OutputSavings = z.infer<typeof OutputSavingsSchema>;
807
+
808
+ // `output` is absent when the experiment isn't running at all (terse off, or no holdout set) — a section that
809
+ // isn't there reads as "not measured", which is the truth, while zeros would read as "measured, worth nothing".
810
+ export const SavingsReportSchema = z.object({ input: InputSavingsSchema, output: OutputSavingsSchema.optional() });
811
+ export type SavingsReport = z.infer<typeof SavingsReportSchema>;
741
812
 
742
813
  // ---- intentic CLI ----
743
814
 
@@ -2260,6 +2331,14 @@ export const UsageTurnSchema = z.object({
2260
2331
  cacheCreationTokens: z.number(),
2261
2332
  costUsd: z.number(),
2262
2333
  durationMs: z.number(),
2334
+ /* Which arm of the terse experiment this turn ran on (settings.terseHoldout) — the only record of it, and
2335
+ * the reason the savings report can say what the steer is worth instead of guessing.
2336
+ *
2337
+ * ABSENT means "not part of the experiment", not "off": a turn under a custom system prompt drops the
2338
+ * steer along with everything else the daemon appends, and a turn run with the experiment switched off has
2339
+ * no control to be compared against. Pooling those into the off-arm would compare steered turns against a
2340
+ * population selected by something other than the coin flip, which is not a control at all. */
2341
+ terse: z.boolean().optional(),
2263
2342
  });
2264
2343
  export type UsageTurn = z.infer<typeof UsageTurnSchema>;
2265
2344
 
@@ -2287,12 +2366,14 @@ export const UsageRollupRowSchema = z.object({
2287
2366
  durationMs: z.number(),
2288
2367
  });
2289
2368
  export type UsageRollupRow = z.infer<typeof UsageRollupRowSchema>;
2290
- // Inclusive UTC day bounds (YYYY-MM-DD). Both absent ⇒ the whole ledger.
2291
- export const UsageRollupQuerySchema = z.object({
2369
+ // Inclusive UTC day bounds (YYYY-MM-DD). Both absent ⇒ the whole ledger. Shared by every windowed read of a
2370
+ // daemon ledger (spend, savings): one window shape, so a screen that filters two ledgers at once filters them
2371
+ // with the same calendar.
2372
+ export const DayWindowQuerySchema = z.object({
2292
2373
  from: z.string().optional(),
2293
2374
  to: z.string().optional(),
2294
2375
  });
2295
- export type UsageRollupQuery = z.infer<typeof UsageRollupQuerySchema>;
2376
+ export type DayWindowQuery = z.infer<typeof DayWindowQuerySchema>;
2296
2377
  export const UsageRollupSchema = z.object({ rows: z.array(UsageRollupRowSchema) });
2297
2378
 
2298
2379
  // ---- usage: per-account token/cost totals ----