@intentic/sandbox-contract 1.166.0 → 1.168.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.
Files changed (67) hide show
  1. package/dist/agent-catalog.d.ts +20 -3
  2. package/dist/agent-catalog.d.ts.map +1 -1
  3. package/dist/agent-catalog.js +81 -6
  4. package/dist/agent-catalog.js.map +1 -1
  5. package/dist/contracts/agent.contract.d.ts +36 -8
  6. package/dist/contracts/agent.contract.d.ts.map +1 -1
  7. package/dist/contracts/agent.contract.js +1 -2
  8. package/dist/contracts/agent.contract.js.map +1 -1
  9. package/dist/contracts/agents.contract.d.ts +55 -39
  10. package/dist/contracts/agents.contract.d.ts.map +1 -1
  11. package/dist/contracts/agents.contract.js.map +1 -1
  12. package/dist/contracts/automations.contract.d.ts +1 -1
  13. package/dist/contracts/extensions.contract.d.ts +4 -0
  14. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  15. package/dist/contracts/gate.contract.d.ts +1 -1
  16. package/dist/contracts/kimi.contract.d.ts +0 -70
  17. package/dist/contracts/kimi.contract.d.ts.map +1 -1
  18. package/dist/contracts/kimi.contract.js +1 -5
  19. package/dist/contracts/kimi.contract.js.map +1 -1
  20. package/dist/contracts/sessions.contract.d.ts +1 -39
  21. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  22. package/dist/contracts/settings.contract.d.ts +29 -5
  23. package/dist/contracts/settings.contract.d.ts.map +1 -1
  24. package/dist/contracts/system.contract.d.ts +78 -4
  25. package/dist/contracts/system.contract.d.ts.map +1 -1
  26. package/dist/contracts/system.contract.js +9 -2
  27. package/dist/contracts/system.contract.js.map +1 -1
  28. package/dist/contracts/translator.contract.d.ts +47 -0
  29. package/dist/contracts/translator.contract.d.ts.map +1 -1
  30. package/dist/contracts/translator.contract.js.map +1 -1
  31. package/dist/events.d.ts +92 -159
  32. package/dist/events.d.ts.map +1 -1
  33. package/dist/events.js +37 -4
  34. package/dist/events.js.map +1 -1
  35. package/dist/index.d.ts +267 -181
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/schemas.d.ts +269 -44
  40. package/dist/schemas.d.ts.map +1 -1
  41. package/dist/schemas.js +92 -25
  42. package/dist/schemas.js.map +1 -1
  43. package/dist/title.d.ts.map +1 -1
  44. package/dist/title.js.map +1 -1
  45. package/dist/workspace-state.d.ts +9 -0
  46. package/dist/workspace-state.d.ts.map +1 -0
  47. package/dist/workspace-state.js +71 -0
  48. package/dist/workspace-state.js.map +1 -0
  49. package/package.json +2 -2
  50. package/src/agent-catalog.test.ts +118 -0
  51. package/src/agent-catalog.ts +183 -24
  52. package/src/contracts/agent.contract.ts +1 -14
  53. package/src/contracts/agents.contract.ts +8 -6
  54. package/src/contracts/gate.contract.ts +2 -2
  55. package/src/contracts/kimi.contract.ts +4 -21
  56. package/src/contracts/system.contract.ts +24 -1
  57. package/src/contracts/translator.contract.ts +4 -6
  58. package/src/events.test.ts +32 -0
  59. package/src/events.ts +105 -26
  60. package/src/index.ts +1 -0
  61. package/src/model-order.ts +1 -1
  62. package/src/quick-model.test.ts +1 -1
  63. package/src/schemas.test.ts +8 -4
  64. package/src/schemas.ts +296 -111
  65. package/src/title.ts +6 -2
  66. package/src/workspace-state.test.ts +129 -0
  67. package/src/workspace-state.ts +160 -0
package/src/schemas.ts CHANGED
@@ -50,8 +50,8 @@ export const EditorContextSchema = z.object({
50
50
  });
51
51
  export type EditorContext = z.infer<typeof EditorContextSchema>;
52
52
 
53
- // The client-minted stable conversation identity. Constrained because it lands in branch names (agent/<id>)
54
- // and filesystem paths — the regex is the injection guard. Shared by the turn input and the attach input.
53
+ // The client-minted stable conversation identity. Constrained because isolated conversations also use it in
54
+ // branch names (agent/<id>) and filesystem paths — the regex is the injection guard. Shared by turn + attach.
55
55
  const ConversationIdSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/);
56
56
 
57
57
  // Where a conversation came from when nobody typed it into the browser: an automation wake carrying a message
@@ -102,7 +102,7 @@ export const AgentTurnSchema = z
102
102
  account: z.string().optional(),
103
103
  sessionId: z.string().optional(),
104
104
  // The client-minted stable conversation identity (survives provider/account/harness switches, which
105
- // retire sessions). Keys the fleet registry entry, the conversation's worktree, and the turn run.
105
+ // retire sessions). Keys the fleet registry entry and turn run, plus the worktree when isolated.
106
106
  conversationId: ConversationIdSchema.optional(),
107
107
  // When true, the turn runs in the conversation's isolated git worktree (created lazily on first use)
108
108
  // instead of the shared /work tree — the parallel-agents mode. Requires conversationId.
@@ -157,10 +157,10 @@ export const AttachTurnSchema = z.object({
157
157
  });
158
158
  export type AttachTurn = z.infer<typeof AttachTurnSchema>;
159
159
 
160
- // ---- agents: the parallel-conversation fleet ----
161
- // A "fleet agent" is a conversation with a registry entry every isolated conversation, keyed by its
162
- // conversationId. Isolated ones own a git worktree (branch agent/<id> in every workspace repo); the fleet
163
- // surface shows all of them with live status/activity/cost so the user can drive N agents in parallel.
160
+ // ---- agents: the conversation fleet ----
161
+ // A "fleet agent" is any conversation with a registry entry, keyed by its conversationId. Isolated ones own a
162
+ // git worktree (branch agent/<id> in every workspace repo); workspace conversations have no branch. The fleet
163
+ // surface shows both through the same status/activity/cost lifecycle.
164
164
 
165
165
  // idle/running/awaiting are the turn lifecycle (awaiting = paused on a plan approval or question); ready /
166
166
  // landed / conflict are outcomes of the land flow — `ready` is a clean completion whose delta stayed on the
@@ -173,7 +173,31 @@ export type AttachTurn = z.infer<typeof AttachTurnSchema>;
173
173
  // unlabelled: without it such a turn rehydrates as `idle`, which is the resting status of a turn that finished
174
174
  // CLEANLY, so the board files a killed agent under Finished and the question it was holding disappears with the
175
175
  // process that asked it. See agents-store.ts — this is the status a live turn leaves on disk.
176
- export const AgentStatusSchema = z.enum(["idle", "running", "awaiting", "ready", "landed", "conflict", "error", "interrupted"]);
176
+ //
177
+ /* `stopping` and `stopped` are the two halves of a user's Stop, and they exist because a hard-cancel is NOT
178
+ * instant: /agent/stop aborts the provider and then waits for the turn's generator to unwind (worktree and
179
+ * registry cleanup), which is seconds of real time. For that whole window the runtime half still said
180
+ * `running`, so every surface kept its spinner turning on a turn the user had already killed — and then the
181
+ * card jumped to a settled state out of nowhere. `stopping` is what the daemon knows the instant the abort
182
+ * lands, published immediately so the press has a visible result; `stopped` is where the turn comes to rest.
183
+ *
184
+ * `stopped` is deliberately its own value rather than `interrupted` or `error`. Not `error`, which is what a
185
+ * stopped turn used to report (every provider adapter surfaces the abort's unwind as an error frame) — a card
186
+ * accusing the user's own deliberate press of being a failure. Not `interrupted` either: that one means the
187
+ * daemon died under the turn, and a boot pass may re-run it, which is precisely what must never happen to a
188
+ * turn a person chose to end. */
189
+ export const AgentStatusSchema = z.enum([
190
+ "idle",
191
+ "running",
192
+ "awaiting",
193
+ "stopping",
194
+ "stopped",
195
+ "ready",
196
+ "landed",
197
+ "conflict",
198
+ "error",
199
+ "interrupted",
200
+ ]);
177
201
  export type AgentStatus = z.infer<typeof AgentStatusSchema>;
178
202
  // The card's live activity snippet: the last tool the agent used (with its target) and the in-progress todo.
179
203
  export const AgentActivitySchema = z.object({
@@ -238,6 +262,14 @@ export const AgentSummarySchema = z.object({
238
262
  // Completed turns and lifetime tool calls — the card's msgs/tools counters.
239
263
  turns: z.number().optional(),
240
264
  toolUses: z.number().optional(),
265
+ /* The agents THIS agent started (SubagentSessionSchema), live and lifetime. Absent ⇒ it has never delegated,
266
+ * which is most agents — so the card's chip appears on content rather than reading "0" down the board.
267
+ *
268
+ * It earns a place on a card because a fleet card is the answer to "what is this agent up to", and an agent
269
+ * running five children looked exactly like an agent running none: the work was real, the spend was real, and
270
+ * the board said nothing. The tokens are NOT folded into the parent's cost — a child's spend is its own, and
271
+ * the Subagents area is where it is attributed. */
272
+ subagents: z.object({ running: z.number(), total: z.number() }).optional(),
241
273
  // The agent's cumulative output (base → branch tip across every repo), refreshed on each land —
242
274
  // the card's "12 files · +412 −96" readout. Independent of what has landed.
243
275
  diff: z.object({ files: z.number(), insertions: z.number(), deletions: z.number() }).optional(),
@@ -355,14 +387,56 @@ export const AgentLandSchema = z.object({ id: z.string().min(1), mode: LandModeS
355
387
  // The providers whose model can run UNDER the Claude Code harness through the bundled translator (CLIProxyAPI),
356
388
  // which holds their SUBSCRIPTION OAuth and re-serves it behind an Anthropic endpoint. The `claude` provider is
357
389
  // absent — native Anthropic OAuth serves it directly, without the translator. Codex and Grok also have a native
358
- // runtime and so carry the harness axis; `gemini` is routed-only (Google publishes no Anthropic-protocol
359
- // endpoint and this sandbox bakes no Gemini runtime), so a Gemini turn is always a Claude Code turn.
360
- export const KeyedProviderSchema = z.enum(["codex", "grok", "gemini"]);
390
+ // runtime and so carry the harness axis; Kimi and Gemini are routed-only, so their turns always use Claude Code.
391
+ export const KeyedProviderSchema = z.enum(["codex", "grok", "kimi", "gemini"]);
361
392
  export type KeyedProvider = z.infer<typeof KeyedProviderSchema>;
362
393
 
394
+ // ---- plan-limit usage ----
395
+ // Declared ABOVE both account shapes because both carry it: headroom is one idea in this product, not a Claude
396
+ // idea that other providers imitate. A native account (OauthAccount) and a routed subscription
397
+ // (TranslatorAccount) differ in who holds the credential and how the reading is taken — never in what a
398
+ // reading IS — so every surface that draws a percentage reads this one type and no other.
399
+
400
+ // One plan-limit pool. `kind` is the provider's own key ('five_hour' | 'seven_day' | 'seven_day_opus' |
401
+ // 'seven_day_sonnet' | 'model:Fable' | …) rather than an enum we'd have to keep in step with the provider: an
402
+ // unrecognised pool is shown under its raw key, which is far better than being silently folded into a
403
+ // neighbour. `label` is the provider's OWN display name where it supplies one (the per-model buckets do) — it
404
+ // wins over anything we'd infer, because the model names in a plan's limits are the provider's to rename.
405
+ // `resetsAt` is epoch SECONDS (matching the SDK's frame).
406
+ export const UsageWindowSchema = z.object({
407
+ kind: z.string(),
408
+ label: z.string().optional(),
409
+ utilization: z.number(), // 0-100
410
+ resetsAt: z.number().optional(),
411
+ });
412
+ export type UsageWindow = z.infer<typeof UsageWindowSchema>;
413
+
414
+ // An account's headroom: EVERY window the provider reports, read together, plus when the reading was taken.
415
+ // All of them, not the binding one, because "which pool is binding" changes between turns and a reader
416
+ // comparing accounts needs the same pools on every row. How the reading is TAKEN is per provider and stops at
417
+ // the daemon's readers: Claude's rides the turn's own stream, ChatGPT's and Google's are pulled through
418
+ // CLIProxyAPI's credential-scoped management call. All of them are control requests, so none costs tokens.
419
+ //
420
+ // Within one window utilization only climbs, so an un-reset window stays a valid FLOOR however old it is; past
421
+ // its `resetsAt` it describes a pool that no longer exists and the store drops it. `measuredAt` is epoch MS
422
+ // (matching connectedAt) — deliberately a different unit from the windows' seconds.
423
+ export const AccountUsageSchema = z.object({
424
+ windows: z.array(UsageWindowSchema),
425
+ measuredAt: z.number(),
426
+ });
427
+ export type AccountUsage = z.infer<typeof AccountUsageSchema>;
428
+
363
429
  // One connected subscription in the translator. `name` is CLIProxyAPI's auth-file name — the stable store key a
364
430
  // disconnect addresses — and `label` the sign-in identity it reported (the account email, else the file name).
365
- export const TranslatorAccountSchema = z.object({ name: z.string(), label: z.string() });
431
+ export const TranslatorAccountSchema = z.object({
432
+ name: z.string(),
433
+ label: z.string(),
434
+ // The same headroom an OauthAccount carries, on the same field, for the same reason: the account rows are
435
+ // one list to the reader. Optional because a provider whose quota this sandbox cannot read (Grok, Kimi) —
436
+ // or one that did not answer — must still render as the connected account it is, with a dot instead of a
437
+ // ring.
438
+ usage: AccountUsageSchema.optional(),
439
+ });
366
440
  export type TranslatorAccount = z.infer<typeof TranslatorAccountSchema>;
367
441
  // Which routed-provider subscriptions are connected in the translator, per provider — a LIST per provider, not
368
442
  // a flag: CLIProxyAPI holds any number of auth files per provider side by side and balances requests across
@@ -371,6 +445,7 @@ export type TranslatorAccount = z.infer<typeof TranslatorAccountSchema>;
371
445
  export const TranslatorAccountsSchema = z.object({
372
446
  codex: z.array(TranslatorAccountSchema),
373
447
  grok: z.array(TranslatorAccountSchema),
448
+ kimi: z.array(TranslatorAccountSchema),
374
449
  gemini: z.array(TranslatorAccountSchema),
375
450
  });
376
451
  export type TranslatorAccounts = z.infer<typeof TranslatorAccountsSchema>;
@@ -429,14 +504,8 @@ export const SteerSchema = z
429
504
  // True cancel for the conversation's in-flight turn — aborts the agent daemon-side, unlike closing the
430
505
  // /agent fetch (which sends no cancel frame).
431
506
  export const StopTurnSchema = z.object({ conversationId: z.string().min(1) });
432
- // Fire the conversation's remembered usage-limit resume NOW instead of waiting out the reset. `account`
433
- // points the re-run at a different connected account of the same provider — the "resume on another account"
434
- // action a spent allowance offers when the sandbox holds more than one; omitted, the turn re-runs on
435
- // whatever served it (a plain "try again now"). NOT_FOUND when nothing is pending (the failure was already
436
- // superseded by a fresh turn, or the daemon restarted).
437
- export const ResumeLimitSchema = z.object({ conversationId: z.string().min(1), account: z.string().min(1).optional() });
438
-
439
- // ---- claude subscription usage ----
507
+
508
+ // ---- claude rate-limit gate ----
440
509
  // The GATE signal: whether the provider is letting turns through right now, and — when it is refusing — which
441
510
  // window is binding and when it lifts. This is the SDK's rate_limit_event, mapped one-to-one, and it is only
442
511
  // ever about the CURRENT moment. It is deliberately NOT the thing the headroom displays read: the event names a
@@ -450,34 +519,6 @@ export const RateLimitInfoSchema = z.object({
450
519
  });
451
520
  export type RateLimitInfo = z.infer<typeof RateLimitInfoSchema>;
452
521
 
453
- // One plan-limit pool. `kind` is the provider's own key ('five_hour' | 'seven_day' | 'seven_day_opus' |
454
- // 'seven_day_sonnet' | 'model:Fable' | …) rather than an enum we'd have to keep in step with the provider: an
455
- // unrecognised pool is shown under its raw key, which is far better than being silently folded into a
456
- // neighbour. `label` is the provider's OWN display name where it supplies one (the per-model buckets do) — it
457
- // wins over anything we'd infer, because the model names in a plan's limits are the provider's to rename.
458
- // `resetsAt` is epoch SECONDS (matching the SDK's frame).
459
- export const UsageWindowSchema = z.object({
460
- kind: z.string(),
461
- label: z.string().optional(),
462
- utilization: z.number(), // 0-100
463
- resetsAt: z.number().optional(),
464
- });
465
- export type UsageWindow = z.infer<typeof UsageWindowSchema>;
466
-
467
- // An account's headroom: EVERY window the provider reports, read together, plus when the reading was taken.
468
- // All of them, not the binding one, because "which pool is binding" changes between turns and a reader
469
- // comparing accounts needs the same pools on every row. Sourced from the CLI's own usage endpoint at turn end
470
- // (see claudeUsageWindows) — a control request, so it costs no tokens.
471
- //
472
- // Within one window utilization only climbs, so an un-reset window stays a valid FLOOR however old it is; past
473
- // its `resetsAt` it describes a pool that no longer exists and the store drops it. `measuredAt` is epoch MS
474
- // (matching connectedAt) — deliberately a different unit from the windows' seconds.
475
- export const AccountUsageSchema = z.object({
476
- windows: z.array(UsageWindowSchema),
477
- measuredAt: z.number(),
478
- });
479
- export type AccountUsage = z.infer<typeof AccountUsageSchema>;
480
-
481
522
  // ---- provider oauth ----
482
523
  // Claude uses the PKCE authorize-URL + paste-back handshake (start → exchange). Codex uses OpenAI's device-code
483
524
  // flow (start → poll): the browser signs in at verificationUri and enters userCode; the daemon polls until done.
@@ -504,8 +545,9 @@ export const OauthAccountSchema = z.object({
504
545
  needsReauth: z.boolean().optional(),
505
546
  detail: z.string().optional(),
506
547
  // The account's last known subscription-usage snapshot, so the picker can show what's left on each account
507
- // before the user commits a turn to one. Claude-only (it is the sole provider whose stream reports a usage
508
- // window) and absent until that account has run a turn an unmeasured account reads as unknown, never 0%.
548
+ // before the user commits a turn to one. Absent until a reading exists for it an unmeasured account reads
549
+ // as unknown, never 0%. Claude is the provider that fills it here, because its stream reports the windows;
550
+ // the routed subscriptions carry the identical field on TranslatorAccount, filled by a pulled reading.
509
551
  usage: AccountUsageSchema.optional(),
510
552
  });
511
553
  export type OauthAccount = z.infer<typeof OauthAccountSchema>;
@@ -525,11 +567,6 @@ export const OauthExchangeSchema = z.object({
525
567
  label: z.string().optional(),
526
568
  });
527
569
  export const AuthorizeChallengeSchema = z.object({ authorizeUrl: z.string(), verifier: z.string(), state: z.string() });
528
- // Kimi (Moonshot) authenticates with an API key, not OAuth: the user pastes a key from their Moonshot account
529
- // and the sandbox stores it as an account (one key per account, several accounts side by side). `label` is the
530
- // user's display name (blank ⇒ the daemon derives a default). The key never rides back out — connection status
531
- // is existence in `/kimi/accounts`.
532
- export const KimiConnectSchema = z.object({ apiKey: z.string().min(1), label: z.string().optional() });
533
570
  // xAI Grok (via OpenCode) uses subscription OAuth via the headless device-code method. `start` returns the
534
571
  // `url` the user opens (xAI's verification_uri_complete, which pre-fills the code) and `code` — the same
535
572
  // one-time code, surfaced so the card matches x.ai exactly. There is no paste-back: OpenCode polls to
@@ -539,13 +576,15 @@ export const KimiConnectSchema = z.object({ apiKey: z.string().min(1), label: z.
539
576
  // A device-code login start: the verification URL + the one-time code the user enters there. The native Grok
540
577
  // flow (via OpenCode) — see TranslatorStartSchema for the routed-provider connect, which adds `state`.
541
578
  export const DeviceStartSchema = z.object({ url: z.string(), code: z.string() });
542
- // A routed-provider subscription login start (codex/grok/gemini via CLIProxyAPI). Codex and Grok mint a
543
- // one-time device `code` the user enters at the provider's site, and CLIProxyAPI polls to completion on its
544
- // own the card only waits. Google publishes no device flow: the user approves in a browser and is redirected
545
- // to a loopback URL this sandbox never receives, so `code` is empty and the card asks them to paste that URL
546
- // back (see TranslatorCompleteSchema). Which half a provider uses is READ from the response rather than
547
- // hardcoded per provider, so the card needs no provider table.
548
- export const TranslatorStartSchema = z.object({ url: z.string(), code: z.string(), state: z.string() });
579
+ // A routed-provider subscription login start (codex/grok/kimi/gemini via CLIProxyAPI). Device flows poll to
580
+ // completion after the user approves upstream; redirect flows need the browser's landing URL pasted back. The
581
+ // explicit flow discriminator matters even when a provider's verification URL already embeds its optional code.
582
+ export const TranslatorStartSchema = z.object({
583
+ url: z.string(),
584
+ code: z.string(),
585
+ state: z.string(),
586
+ flow: z.enum(["device", "redirect"]),
587
+ });
549
588
  // The paste-back half of a redirect login: the URL the provider sent the browser to, carrying the grant as
550
589
  // ?code=&state=. `state` ties it to the handshake that issued it — the translator rejects a mismatch.
551
590
  export const TranslatorCompleteSchema = z.object({
@@ -555,7 +594,7 @@ export const TranslatorCompleteSchema = z.object({
555
594
  });
556
595
  // A provider's model catalog, resolved daemon-side from live discovery with a persisted last-known-good list and
557
596
  // a seed floor (Grok via opencode.ts xaiModels, Codex via codex-models.ts, Claude via the Agent SDK's
558
- // supportedModels) — never empty, so the picker is never blank. `label` is the humanized display name; `default`
597
+ // supportedModels) — never empty, so the picker is never blank. `label` is the provider's display name; `default`
559
598
  // is the model a fresh chat on that provider seeds (always present). Shared by /grok/models, /codex/models,
560
599
  // /claude/models. `efforts` is the reasoning-effort tiers the model accepts (Claude reports them per model);
561
600
  // empty ⇒ the client's default tiers.
@@ -563,8 +602,8 @@ export const TranslatorCompleteSchema = z.object({
563
602
  // EVERY field here is provider-reported — nothing about a model is curated in this repo, so a new release or a
564
603
  // renamed family flows to the UI with no code change. Providers differ in how much they publish: the Claude
565
604
  // Agent SDK reports a display name, a capability description, effort tiers, and capability flags, while the
566
- // OpenAI-compatible /v1/models endpoints (codex/grok/kimi) report ids only — those rows render label-only, and
567
- // that absence is the honest answer rather than something to paper over with a hand-written table.
605
+ // Some OpenAI-compatible /v1/models endpoints report ids only — those rows render label-only, and that absence
606
+ // is the honest answer rather than something to paper over with a hand-written table.
568
607
  //
569
608
  // ORDER IS MEANINGFUL: `models` arrives in the provider's own preference order, which is what the picker sorts
570
609
  // by, and `default` is the provider's own default. Neither is re-ranked locally.
@@ -624,6 +663,11 @@ export const BuiltinPromptSchema = z.object({ base: z.enum(["intentic", "claude"
624
663
  // iqSearch — loads the image-baked iq Claude Code plugin (skill + SessionStart nudge) so the agent
625
664
  // prefers the iq CLI over grep/find/Glob; off ⇒ plugin not loaded, native search tools
626
665
  // only. Opt-in (default off); the browser Search box uses iq regardless.
666
+ // iqContext — retrieves for the user's message BEFORE the turn starts and prepends the ranked answer
667
+ // to it, so the model opens with the anchors instead of paying a search round-trip to
668
+ // find them. Independent of iqSearch: that one teaches the agent to search, this one
669
+ // answers ahead of it.
670
+ // iqContextHoldout — measurement control for iqContext, same shape as terseHoldout (UsageTurn.iqContext).
627
671
  // outputCleaners — the Bash output-cleaner spec (agent-output-filter): "off" = filter disabled (default),
628
672
  // "" = all cleaners on, else an iq-style allow-list / default-minus
629
673
  // spec ("git,pnpm" = only those; "-cap" = all except). Threaded to the filter via env.
@@ -643,6 +687,7 @@ export const BuiltinPromptSchema = z.object({ base: z.enum(["intentic", "claude"
643
687
  // surface fail to parse the moment a toggle is added — which reaches the user as a page of switches that are
644
688
  // silently dead, not as an error. It also means an older on-disk manifest keeps the owner's other picks rather
645
689
  // than being discarded whole.
690
+
646
691
  export const SandboxSettingsSchema = z.object({
647
692
  stableSystemPrompt: z.boolean().default(false),
648
693
  skills: z.array(z.string()).default([]),
@@ -678,6 +723,21 @@ export const SandboxSettingsSchema = z.object({
678
723
  * Cap is roomy — the bases it stands in for are ~6.8k characters — but finite, because every turn pays it. */
679
724
  systemPrompt: z.string().max(20000).default(""),
680
725
  iqSearch: z.boolean().default(false),
726
+ /* RETRIEVE BEFORE THE TURN, don't wait to be asked. The daemon runs the user's message through the resident
727
+ * iq engine and prepends the ranked answer to it, so a turn that would have opened with two or three search
728
+ * calls opens with the anchors already in hand. Independent of `iqSearch`, which only teaches the agent to
729
+ * reach for the CLI once it decides to search — this one answers ahead of that decision, and the two
730
+ * compose: the injected capsule names the anchors, the CLI is there for what it missed.
731
+ *
732
+ * It rides the USER message (turn-context.ts), never the system prompt, for the same reason the setup
733
+ * notice does: it changes every turn, and the system prefix is kept byte-stable for the prompt cache.
734
+ * Off by default — it spends input tokens on every eligible turn, and whether that trade pays is exactly
735
+ * what the holdout below is for. */
736
+ iqContext: z.boolean().default(false),
737
+ // Measurement control for the pre-injection, identical in shape to `terseHoldout`: a fraction [0,1] of
738
+ // otherwise-eligible turns run WITHOUT the retrieved context and stamp their arm onto the ledger
739
+ // (UsageTurn.iqContext), so the report compares two real populations of turns instead of asserting a saving.
740
+ iqContextHoldout: z.number().min(0).max(1).default(0),
681
741
  outputCleaners: z.string().default("off"),
682
742
  outputHoldout: z.number().min(0).max(1).default(0),
683
743
  filterBackend: z.enum(["native", "rtk"]).default("native"),
@@ -699,17 +759,12 @@ export const SandboxSettingsSchema = z.object({
699
759
  * automation-opened agents (Discord, webhooks, email) finish turns with no browser in the room — a
700
760
  * browser-held preference could not govern them. Per-agent override: AgentSummarySchema.autoLand. */
701
761
  autoLand: z.boolean().default(true),
702
- // When a turn dies on the Claude subscription's usage limit, re-run it automatically once the limit
703
- // window resets (a minute after, so a skewed clock can't retry into the same closed window). Off by
704
- // default: an unattended retry spends the fresh window without the user in the room, so the daemon
705
- // records every limit-hit either way and the chat OFFERS the toggle at the moment it would have helped —
706
- // enabling it then still resumes the turn that just bounced.
707
- autoResumeOnLimit: z.boolean().default(false),
708
762
  /* When a turn dies because the MODEL PROVIDER was failing (500/502/503, a 529 at capacity, a dropped
709
763
  * socket), re-run it on an escalating backoff until it goes through or the attempts are spent.
710
764
  *
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
765
+ * Defaults ON, and a spent Claude allowance is the counter-example that explains why: that one is the
766
+ * user's own budget, and resuming into a freshly reset window spends something they may have been saving
767
+ * so a usage limit stops the turn and says when it resets, and nothing re-runs it. An
713
768
  * outage resume spends nothing the dead turn had not already committed, resolves in minutes rather than
714
769
  * hours, and — the deciding argument — the turns hurt worst by it are the ones with nobody in the room
715
770
  * (automation wakes, Discord, webhooks), which no browser-held preference could ever rescue. It is the same
@@ -717,8 +772,8 @@ export const SandboxSettingsSchema = z.object({
717
772
  resumeAfterOutage: z.boolean().default(true),
718
773
  /* When the daemon dies under a running turn, re-run that turn once it is back (agent/turn-journal.ts records
719
774
  * every in-flight turn; the boot pass in agent/turn-resume.ts re-runs what survived). ON by default, where
720
- * autoResumeOnLimit is off, and the difference is who broke the turn: a spent allowance is the user's own
721
- * budget, while a restart is usually intentic's OWN doing — the container is recreated on every update,
775
+ * a spent usage limit re-runs nothing, and the difference is who broke the turn: a spent allowance is the
776
+ * user's own budget, while a restart is usually intentic's OWN doing — the container is recreated on every update,
722
777
  * every environment approval and every dev-sandbox.sh swap. Approving the Dockerfile change an agent asked
723
778
  * for must not cost the run that asked for it, and a user who just clicked Approve is in the room expecting
724
779
  * the work to continue, not a second button.
@@ -726,7 +781,7 @@ export const SandboxSettingsSchema = z.object({
726
781
  * OFF still records the interruption: the fleet card reads `interrupted` (see AgentStatusSchema) and an
727
782
  * automation's row shows an `interrupted` run — nothing is re-run, but nothing is silently lost either. */
728
783
  autoResumeOnRestart: z.boolean().default(true),
729
- /* THE LANDING GATE — the check command run over the COMPOSITE of landed work, once the fleet goes quiet.
784
+ /* THE LANDING GATE — the check command run over the COMPOSITE of landed work after a landing debounce.
730
785
  * Empty ⇒ no gate at all, which is the default: only the owner knows what verifies this workspace, and a
731
786
  * guessed command that fails on a fresh clone would read as the gate finding a bug on its first run.
732
787
  *
@@ -749,7 +804,7 @@ export const SandboxSettingsSchema = z.object({
749
804
  // gate exists to prevent is a green light nobody earned.
750
805
  gateTimeoutMs: z.number().min(60_000).max(3_600_000).default(900_000),
751
806
  /* Wake a fixer automatically when the gate goes red, instead of only lighting the badge. ON with a
752
- * configured command, unlike the other unattended-spend toggles (autoResumeOnLimit), and the difference is
807
+ * configured command, and the difference from the unattended spend a usage limit refuses is
753
808
  * that the spend here is the POINT: a red gate whose fix waits for the user to notice has moved the CI
754
809
  * round-trip into the workspace without removing it from the user's day. One attempt per verdict, so a
755
810
  * command that fails for a reason no agent can fix costs one turn, not a loop (gate/gate.ts). */
@@ -821,13 +876,21 @@ export const InputSavingsSchema = z.object({
821
876
  });
822
877
  export type InputSavings = z.infer<typeof InputSavingsSchema>;
823
878
 
824
- // One arm of the turn-level experiment: the turns that ran with the steer, and the turns the holdout ran
825
- // without it. Mean output tokens PER TURN, because the arms never hold the same number of turns.
826
- export const SavingsArmSchema = z.object({ turns: z.number(), meanOutputTokens: z.number() });
879
+ // One arm of a turn-level experiment: the turns that ran with the mechanism, and the turns the holdout ran
880
+ // without it. A mean PER TURN, because the arms never hold the same number of turns.
881
+ export const SavingsArmSchema = z.object({ turns: z.number(), mean: z.number() });
827
882
 
828
- // The terse steer, as measured. Only turns where the steer was ELIGIBLE are counted a turn under a custom
829
- // system prompt drops the steer along with everything else the daemon appends, so it belongs to neither arm.
830
- export const OutputSavingsSchema = z.object({
883
+ /* A turn-level A/B the one shape both of this sandbox's turn experiments report in, because they differ in
884
+ * nothing but which flag flips and what the turns are judged on. Only turns the mechanism was ELIGIBLE for are
885
+ * counted: a turn under a custom system prompt drops the terse steer along with everything else the daemon
886
+ * appends, so it belongs to neither arm.
887
+ *
888
+ * `metric` says what `mean` counts and what `deltaPct` is a delta in. The terse steer is judged on the model's
889
+ * OWN output tokens, which is the thing it steers. Pre-injection is judged on COST, because it spends input
890
+ * tokens deliberately to buy back search turns — scored on output tokens it would look like a pure expense,
891
+ * and scored on input tokens like a pure loss; the trade only nets out in money. */
892
+ export const TurnExperimentSchema = z.object({
893
+ metric: z.enum(["outputTokens", "costUsd"]),
831
894
  on: SavingsArmSchema,
832
895
  off: SavingsArmSchema,
833
896
  // Turns per arm before a delta is reported at all. Carried on the wire so the screen's "measuring…" state
@@ -835,18 +898,24 @@ export const OutputSavingsSchema = z.object({
835
898
  minTurns: z.number(),
836
899
  /* The three below are present TOGETHER, and only once both arms clear `minTurns` — a schema that can't
837
900
  * express a half-measured experiment is how a 34%-that-becomes-8%-tomorrow never reaches the screen.
838
- * deltaPct — change in mean output tokens per turn under the steer; negative is a saving.
839
- * marginPct — ± percentage points, 95% (Welch, unequal variances and unequal arms).
840
- * savedTokens — what the delta is worth over the turns that actually ran with the steer, in this window. */
901
+ * deltaPct — change in the metric's mean per turn under the mechanism; negative is a saving.
902
+ * marginPct — ± percentage points, 95% (Welch, unequal variances and unequal arms).
903
+ * saved — what the delta is worth over the turns that actually ran with it, in this window, in the
904
+ * metric's own unit (tokens, or dollars). */
841
905
  deltaPct: z.number().optional(),
842
906
  marginPct: z.number().optional(),
843
- savedTokens: z.number().optional(),
907
+ saved: z.number().optional(),
844
908
  });
845
- export type OutputSavings = z.infer<typeof OutputSavingsSchema>;
909
+ export type TurnExperiment = z.infer<typeof TurnExperimentSchema>;
846
910
 
847
- // `output` is absent when the experiment isn't running at all (terse off, or no holdout set) — a section that
848
- // isn't there reads as "not measured", which is the truth, while zeros would read as "measured, worth nothing".
849
- export const SavingsReportSchema = z.object({ input: InputSavingsSchema, output: OutputSavingsSchema.optional() });
911
+ // `output`/`context` are absent when that experiment isn't running at all (its flag off, or no holdout set) — a
912
+ // section that isn't there reads as "not measured", which is the truth, while zeros would read as "measured,
913
+ // worth nothing".
914
+ export const SavingsReportSchema = z.object({
915
+ input: InputSavingsSchema,
916
+ output: TurnExperimentSchema.optional(),
917
+ context: TurnExperimentSchema.optional(),
918
+ });
850
919
  export type SavingsReport = z.infer<typeof SavingsReportSchema>;
851
920
 
852
921
  // ---- intentic CLI ----
@@ -999,9 +1068,9 @@ export const RepoChangesSchema = z.object({
999
1068
  remote: GitRemoteStateSchema.optional(),
1000
1069
  // WHICH AGENT PUT IT THERE: repo-relative path → the agent ids that landed it, newest land first. Keyed by
1001
1070
  // PATH rather than carried on each GitChange because a path can be listed on two sides at once (staged and
1002
- // edited again) and its origin is the same fact for both. Only agents can appear here — a main-tree turn,
1003
- // a terminal edit and your own typing never pass through land, so they are simply absent (see
1004
- // agents/origins.ts), which is why the panel badges an agent and says nothing at all for anyone else.
1071
+ // edited again) and its origin is the same fact for both. Only branch-backed agents whose work passed
1072
+ // through land can appear here; workspace conversations, terminal edits and the user's typing are absent
1073
+ // (see agents/origins.ts), so the panel badges an attributable agent and says nothing for anyone else.
1005
1074
  // Ids, not titles: the identity for every id named here rides the response once, in `originAgents`.
1006
1075
  origins: z.record(z.string(), z.array(z.string())).optional(),
1007
1076
  // Why the repo could not be scanned at all, condensed to git's own one-line reason ("fatal: bad object HEAD").
@@ -2052,9 +2121,9 @@ export const AutomationRunSchema = z.object({
2052
2121
  // as "it never fired" — the one reading a 3 a.m. automation must not be given.
2053
2122
  outcome: z.enum(["completed", "skipped", "error", "interrupted"]),
2054
2123
  detail: z.string().optional(),
2055
- // The runtime session the wake ran in, so the row can open the transcript. Absent for a run that never
2056
- // reached a provider (skipped by its guard) or whose provider minted no session before it died.
2057
- sessionId: z.string().optional(),
2124
+ // The stable conversation opened by the wake, so the row can open the provider-neutral agent transcript.
2125
+ // Absent only for a run skipped before a conversation was needed.
2126
+ conversationId: z.string().optional(),
2058
2127
  });
2059
2128
  export type AutomationRun = z.infer<typeof AutomationRunSchema>;
2060
2129
 
@@ -2227,13 +2296,12 @@ export const GateAgentSchema = z.object({
2227
2296
  });
2228
2297
  export type GateAgent = z.infer<typeof GateAgentSchema>;
2229
2298
 
2230
- // The fix turn one red verdict got. A MAIN-TREE turn, not an isolated conversation, so there is no
2231
- // conversationId and no fleet card to open the composite it must reproduce lives in the main working tree and
2232
- // a fresh worktree branches from HEAD without it. `sessionId` is what makes the run readable after the fact,
2233
- // the same thing an automation's run record carries for the same reason.
2299
+ // The fix turn one red verdict got. It is a workspace conversation because the composite it must reproduce
2300
+ // lives as uncommitted content in the main working tree; it still has the same stable registry identity as any
2301
+ // other conversation.
2234
2302
  export const GateFixSchema = z.object({
2235
2303
  startedAt: z.number(),
2236
- sessionId: z.string().optional(),
2304
+ conversationId: z.string(),
2237
2305
  // `running` while the turn streams; `done` when it ended cleanly, whatever the re-check then said;
2238
2306
  // `error` when the turn itself failed (a provider outage, no credential), which is worth distinguishing
2239
2307
  // because it is the one case where re-running the fix could still help.
@@ -2406,11 +2474,9 @@ export type PortForwardResult = z.infer<typeof PortForwardResultSchema>;
2406
2474
  // process (a lingering shell after a crash reads false). A process row that maps to an installed extension's
2407
2475
  // declared process carries extensionId+processName, the address for its /extensions start/stop routes. The
2408
2476
  // `{name}` kill-route param is a bare string validated in the handler (a bad name is a BAD_REQUEST) since the
2409
- // same charset gates a `tmux kill-session -t` shell-out. `browser` = a `browser-<sdk session>` Chromium the
2410
- // agent is driving through its @playwright/mcp tools (browser/browser-sessions.ts) watchable live over the
2411
- // /system/browser-view WebSocket, `running` while that Chromium is connected, and hidden from the strip by
2412
- // the same rule as `agent`: it is a record of work, not a place. Its `label` is the page's own title and
2413
- // `url` the page it is on, which is the one thing a browser pill has to say that a terminal pill does not.
2477
+ // same charset gates a `tmux kill-session -t` shell-out. The agent's BROWSER is deliberately NOT one of these
2478
+ // kinds: a Chromium with its own tab strip is a surface in its own right, not a pane in the terminal panel, so
2479
+ // it lists from /system/browsers with the pages it has open (BrowserSessionSchema below).
2414
2480
  //
2415
2481
  // `activityAt` (epoch ms of the session's last output) and `exitCode` (the LAST window's exit status, absent
2416
2482
  // while that pane still lives) are what let a finished session be READ rather than merely listed: the panel's
@@ -2420,19 +2486,128 @@ export type PortForwardResult = z.infer<typeof PortForwardResultSchema>;
2420
2486
  export const TerminalSessionSchema = z.object({
2421
2487
  name: z.string(),
2422
2488
  label: z.string().optional(),
2423
- kind: z.enum(["shell", "panel", "agent", "job", "process", "browser"]),
2489
+ kind: z.enum(["shell", "panel", "agent", "job", "process"]),
2424
2490
  running: z.boolean(),
2425
2491
  activityAt: z.number(),
2426
2492
  exitCode: z.number().optional(),
2427
2493
  extensionId: z.string().optional(),
2428
2494
  processName: z.string().optional(),
2429
- // Browser sessions only: the page the agent is on right now.
2430
- url: z.string().optional(),
2431
2495
  });
2432
2496
  export const TerminalsListSchema = z.object({ sessions: z.array(TerminalSessionSchema) });
2433
2497
  export type TerminalsList = z.infer<typeof TerminalsListSchema>;
2434
2498
  export const TerminalNameParamSchema = z.object({ name: z.string() });
2435
2499
 
2500
+ /* ---- browsers: the Chromium the agent drives through its @playwright/mcp tools ----
2501
+ *
2502
+ * A `browser-<sdk session>` Chromium (browser/browser-sessions.ts), watchable live over the
2503
+ * /system/browser-view WebSocket. It lists apart from the terminals because it is shaped differently in the one
2504
+ * way that decides a UI: a terminal is ONE stream of bytes, while a browser holds SEVERAL pages at once and the
2505
+ * question "what is the agent looking at?" only has an answer if the wire carries all of them. So `pages` is the
2506
+ * point of this schema — the view renders them as a tab strip and binds the screencast to whichever the user
2507
+ * picks, and `active` is the one the agent itself last touched (what the view follows until the user says
2508
+ * otherwise).
2509
+ *
2510
+ * `id` is opaque and minted per session, and it is what makes a tab survive a relist: it is stable for the life
2511
+ * of the page, unlike its url (the agent navigates away) or its position (a closed tab renumbers the rest). */
2512
+ export const BrowserPageSchema = z.object({
2513
+ id: z.string(),
2514
+ // The page's own title. Absent mid-navigation, which is exactly when a tab still needs to render.
2515
+ title: z.string().optional(),
2516
+ url: z.string(),
2517
+ // The page the agent last drove — on a finished session, the one it ended on. Exactly one page has it.
2518
+ active: z.boolean(),
2519
+ });
2520
+ export const BrowserSessionSchema = z.object({
2521
+ name: z.string(),
2522
+ // The pill's text: the active page's title, else its host, else which browser this is.
2523
+ label: z.string(),
2524
+ // Which MCP server drives it: `web` (the credential-free browser) or a logged-in capability's id — the
2525
+ // difference between a throwaway page and one signed in as the user, which is worth saying out loud.
2526
+ server: z.string(),
2527
+ // False once that Chromium is gone (the turn ended, the agent closed it, it crashed). A finished session
2528
+ // still lists for a while, with the pages it had — the record of where the agent went.
2529
+ running: z.boolean(),
2530
+ activityAt: z.number(),
2531
+ // When that Chromium went away, for the "closed 20m ago" line a finished session leads with. Absent while
2532
+ // running, which is the same fact as `running` — but the view needs the timestamp, not just the flag.
2533
+ finishedAt: z.number().optional(),
2534
+ pages: z.array(BrowserPageSchema),
2535
+ });
2536
+ export type BrowserPage = z.infer<typeof BrowserPageSchema>;
2537
+ export type BrowserSession = z.infer<typeof BrowserSessionSchema>;
2538
+ export const BrowsersListSchema = z.object({ sessions: z.array(BrowserSessionSchema) });
2539
+ export type BrowsersList = z.infer<typeof BrowsersListSchema>;
2540
+ export const BrowserNameParamSchema = z.object({ name: z.string() });
2541
+
2542
+ /* ---- subagents: the agents an agent starts ----
2543
+ *
2544
+ * The third thing a turn spawns that the operator can be shown, after its shell and its browser — and the only
2545
+ * one that is itself an agent. Two kinds land in this one list, because from outside they are the same fact
2546
+ * (another agent, working, that you did not start):
2547
+ * • `subagent` — the SDK's Agent/Task tool. The daemon learns of it from the SubagentStart/SubagentStop hooks
2548
+ * and the task_* stream messages, joined on `toolUseId`.
2549
+ * • `codex` / `grok` — a CLI the agent drove from its own Bash (agent/delegation.ts). Detected in the Bash
2550
+ * PreToolUse hook, bound to its thread/session id from the command's output.
2551
+ *
2552
+ * `id` IS THE SPAWNING TOOL CALL'S id — the Agent card's, or the Bash card's for a delegation. It is the one key
2553
+ * every source already carries (the SDK's subagent meta, its task_* messages, and the `parentToolUseId` the
2554
+ * client nests inner frames under), so nothing has to be correlated: a card links to its subagent with the id it
2555
+ * already has, and the subagent points back at the card the same way. The ids the transcripts are actually READ
2556
+ * with — the SDK's agent id, a Codex thread, an OpenCode session — stay daemon-side, because no surface asks a
2557
+ * question they answer.
2558
+ *
2559
+ * WHAT A KIND CHANGES, and it is only ever the live view: a subagent has no process of its own to look at, so
2560
+ * watching it means reading its transcript. A delegation runs in a tmux window, so it has both — `terminal`
2561
+ * names it, and the card keeps its existing "Watch in terminal" beside the transcript door. */
2562
+ export const SubagentKindSchema = z.enum(["subagent", "codex", "grok"]);
2563
+ export type SubagentKind = z.infer<typeof SubagentKindSchema>;
2564
+
2565
+ // running/pending are live; the rest are terminal. Deliberately the SDK's own task vocabulary
2566
+ // (SDKTaskUpdatedMessage.patch.status) rather than AgentStatus: this is not a fleet card's lifecycle (no
2567
+ // draft/landed/conflict), and mapping the two would invent states neither side reports.
2568
+ export const SubagentStatusSchema = z.enum(["pending", "running", "completed", "failed", "killed", "paused"]);
2569
+ export type SubagentStatus = z.infer<typeof SubagentStatusSchema>;
2570
+
2571
+ export const SubagentSessionSchema = z.object({
2572
+ id: z.string(),
2573
+ kind: SubagentKindSchema,
2574
+ // The conversation whose turn spawned this — what the area groups its rows by, and the way back to the chat
2575
+ // the card lives in.
2576
+ conversationId: z.string(),
2577
+ // What it is and what it was asked to do: the subagent type (`Explore`, `general-purpose`) or the delegated
2578
+ // provider's model, and the caller's one-line description. The area's row and the card's title read as
2579
+ // `Explore · Locate claimIndexer definition`.
2580
+ agentType: z.string().optional(),
2581
+ description: z.string().optional(),
2582
+ model: z.string().optional(),
2583
+ // How deep in the spawn tree (1 = spawned by the turn itself). From the SDK's meta.json; a subagent may
2584
+ // itself delegate, and a flat list that cannot say so reads as though the turn started all of them.
2585
+ spawnDepth: z.number().optional(),
2586
+ // Backgrounded: the parent went on working instead of waiting for it. This is the whole reason the list
2587
+ // exists — a backgrounded child used to be invisible until its result landed, sometimes minutes later.
2588
+ background: z.boolean().optional(),
2589
+ status: SubagentStatusSchema,
2590
+ startedAt: z.number(),
2591
+ endedAt: z.number().optional(),
2592
+ activityAt: z.number(),
2593
+ // What it has spent and done so far (task_progress). Tokens are the child's own, so a parent's cost line and
2594
+ // the sum of its children's are two different true numbers.
2595
+ tokens: z.number().optional(),
2596
+ toolUses: z.number().optional(),
2597
+ lastTool: z.string().optional(),
2598
+ // Its report — the last assistant message (SubagentStop) or the task summary. The answer to "what did it
2599
+ // conclude?" without opening the transcript, which is the question a finished child is read for.
2600
+ summary: z.string().optional(),
2601
+ error: z.string().optional(),
2602
+ // A delegation's live view: the tmux session its command runs in. Absent for an SDK subagent, which has no
2603
+ // process of its own to attach to.
2604
+ terminal: z.string().optional(),
2605
+ });
2606
+ export type SubagentSession = z.infer<typeof SubagentSessionSchema>;
2607
+ export const SubagentsListSchema = z.object({ sessions: z.array(SubagentSessionSchema) });
2608
+ export type SubagentsList = z.infer<typeof SubagentsListSchema>;
2609
+ export const SubagentIdParamSchema = z.object({ id: z.string() });
2610
+
2436
2611
  // ---- environment: the overlay Dockerfile extending the sandbox image ----
2437
2612
  // The approved file is DAEMON-COMPOSED: pinned FROM + capability fragments + the owner-approved custom section.
2438
2613
  // The agent writes the proposal file (.intentic/environment.Dockerfile — custom-section content only, no FROM)
@@ -2598,7 +2773,8 @@ export const UsageTurnSchema = z.object({
2598
2773
  // when the provider's own subscription default served it without the daemon naming one.
2599
2774
  model: z.string().optional(),
2600
2775
  harness: z.string(),
2601
- // The conversation this turn belonged to, so spend can join to a fleet agent. Absent on a main-tree turn.
2776
+ // The conversation this turn belonged to, so spend can join to a fleet agent. Absent only for an internal
2777
+ // one-shot turn that has no conversation identity.
2602
2778
  conversationId: z.string().optional(),
2603
2779
  // The provider's own turn count for the request (a Claude "turn" can be several under the hood), so turns
2604
2780
  // and cost stay comparable across providers. 1 when the provider reported none.
@@ -2617,6 +2793,15 @@ export const UsageTurnSchema = z.object({
2617
2793
  * no control to be compared against. Pooling those into the off-arm would compare steered turns against a
2618
2794
  * population selected by something other than the coin flip, which is not a control at all. */
2619
2795
  terse: z.boolean().optional(),
2796
+ /* Which arm of the pre-injection experiment this turn ran on (settings.iqContextHoldout), on the same terms
2797
+ * as `terse` above: absent ⇒ outside the experiment.
2798
+ *
2799
+ * TRUE means the turn was ASSIGNED the retrieved context, not that a note was necessarily prepended — a
2800
+ * treatment turn whose retrieval came back empty or unconfident injects nothing. That is deliberate: the
2801
+ * arms have to be the coin flip's populations, and re-labelling a turn by what retrieval happened to find
2802
+ * would sort turns by how searchable their question was, which is a property of the question. The control
2803
+ * arm contains the same unsearchable questions in the same proportion, so they cancel. */
2804
+ iqContext: z.boolean().optional(),
2620
2805
  });
2621
2806
  export type UsageTurn = z.infer<typeof UsageTurnSchema>;
2622
2807