@intentic/sandbox-contract 1.165.0 → 1.167.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 (69) hide show
  1. package/dist/agent-catalog.d.ts +0 -1
  2. package/dist/agent-catalog.d.ts.map +1 -1
  3. package/dist/agent-catalog.js +1 -2
  4. package/dist/agent-catalog.js.map +1 -1
  5. package/dist/contracts/agent.contract.d.ts +2 -2
  6. package/dist/contracts/agents.contract.d.ts +18 -0
  7. package/dist/contracts/agents.contract.d.ts.map +1 -1
  8. package/dist/contracts/agents.contract.js.map +1 -1
  9. package/dist/contracts/automations.contract.d.ts +1 -1
  10. package/dist/contracts/ci.contract.d.ts +4 -0
  11. package/dist/contracts/ci.contract.d.ts.map +1 -1
  12. package/dist/contracts/ci.contract.js +2 -1
  13. package/dist/contracts/ci.contract.js.map +1 -1
  14. package/dist/contracts/extensions.contract.d.ts +1 -0
  15. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  16. package/dist/contracts/gate.contract.d.ts +47 -0
  17. package/dist/contracts/gate.contract.d.ts.map +1 -0
  18. package/dist/contracts/gate.contract.js +9 -0
  19. package/dist/contracts/gate.contract.js.map +1 -0
  20. package/dist/contracts/kimi.contract.d.ts +0 -70
  21. package/dist/contracts/kimi.contract.d.ts.map +1 -1
  22. package/dist/contracts/kimi.contract.js +1 -5
  23. package/dist/contracts/kimi.contract.js.map +1 -1
  24. package/dist/contracts/settings.contract.d.ts +37 -3
  25. package/dist/contracts/settings.contract.d.ts.map +1 -1
  26. package/dist/contracts/system.contract.d.ts +68 -8
  27. package/dist/contracts/system.contract.d.ts.map +1 -1
  28. package/dist/contracts/system.contract.js +3 -1
  29. package/dist/contracts/system.contract.js.map +1 -1
  30. package/dist/contracts/translator.contract.d.ts +11 -0
  31. package/dist/contracts/translator.contract.d.ts.map +1 -1
  32. package/dist/contracts/translator.contract.js.map +1 -1
  33. package/dist/contracts/workspace.contract.d.ts +3 -1
  34. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  35. package/dist/events.d.ts +98 -2
  36. package/dist/events.d.ts.map +1 -1
  37. package/dist/events.js +21 -2
  38. package/dist/events.js.map +1 -1
  39. package/dist/index.d.ts +188 -81
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +3 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/model-order.d.ts.map +1 -1
  44. package/dist/model-order.js +25 -6
  45. package/dist/model-order.js.map +1 -1
  46. package/dist/schemas.d.ts +179 -21
  47. package/dist/schemas.d.ts.map +1 -1
  48. package/dist/schemas.js +90 -13
  49. package/dist/schemas.js.map +1 -1
  50. package/dist/session-names.d.ts +8 -0
  51. package/dist/session-names.d.ts.map +1 -0
  52. package/dist/session-names.js +17 -0
  53. package/dist/session-names.js.map +1 -0
  54. package/package.json +13 -2
  55. package/src/agent-catalog.ts +6 -11
  56. package/src/contracts/agents.contract.ts +8 -6
  57. package/src/contracts/ci.contract.ts +5 -1
  58. package/src/contracts/gate.contract.ts +19 -0
  59. package/src/contracts/kimi.contract.ts +4 -21
  60. package/src/contracts/system.contract.ts +9 -0
  61. package/src/contracts/translator.contract.ts +4 -6
  62. package/src/events.ts +56 -8
  63. package/src/index.ts +3 -0
  64. package/src/model-order.test.ts +29 -12
  65. package/src/model-order.ts +50 -18
  66. package/src/quick-model.test.ts +3 -3
  67. package/src/schemas.test.ts +20 -0
  68. package/src/schemas.ts +305 -64
  69. package/src/session-names.ts +44 -0
@@ -42,7 +42,7 @@ export const PROVIDER_ACCESS: Record<NativeProvider, ProviderAccess> = {
42
42
  claude: { kind: "subscription", requirement: "Claude subscription", runs: "Claude Code" },
43
43
  codex: { kind: "subscription", requirement: "ChatGPT subscription", runs: "Codex" },
44
44
  grok: { kind: "subscription", requirement: "SuperGrok subscription", runs: "Grok" },
45
- kimi: { kind: "key", requirement: "Moonshot API key", runs: "Kimi Code" },
45
+ kimi: { kind: "subscription", requirement: "Kimi Code subscription", runs: "Kimi Code" },
46
46
  gemini: { kind: "free", requirement: "Google sign-in", runs: "Gemini, Claude and GPT-OSS under Claude Code" },
47
47
  };
48
48
 
@@ -71,8 +71,8 @@ export const HARNESSES: readonly { label: string; value: AgentHarness }[] = [
71
71
 
72
72
  // Whether a turn on this provider/harness pair ACTUALLY runs the Claude Code Agent SDK loop — which is not the
73
73
  // same question as `harness === "claude-code"`. Claude is always its own Claude Code loop, and kimi/gemini have
74
- // no native runtime at all (Moonshot speaks the Anthropic protocol directly; Google is re-served through the
75
- // translator), so all three run it whatever harness the client happened to send; only codex/grok have a native
74
+ // no native runtime at all (both are re-served through the translator), so all three run it whatever harness the
75
+ // client happened to send; only codex/grok have a native
76
76
  // runtime to switch away from. An ACP agent runs its own loop and is never one of these.
77
77
  //
78
78
  // Everything the SDK loop owns keys off this: the SteeringQueue that makes mid-turn injection possible, and the
@@ -114,13 +114,8 @@ export const modelsFor = (provider: AgentProvider): CatalogOption[] => {
114
114
  // the only constrained tier and it fails two ways: no non-Claude scale HAS it, and Claude's API rejects it
115
115
  // outright when extended thinking is disabled ("effort 'max' is not supported when thinking is disabled on this
116
116
  // model" — a 400 that kills the turn before the model sees it, surfacing only as the SDK's `unknown` error
117
- // category). Every point where a selection can land on an invalid pair restore from storage, provider switch,
118
- // thinking toggle, the picker's own option list runs the pair through here, so the combination is unreachable
119
- // rather than merely discouraged.
117
+ // category). It is the one rule a MODEL's published tier list can't expressthe daemon reports what a model
118
+ // accepts without knowing this turn's thinking settingso the consumer that assembles the offered scale
119
+ // (effortsFor, web-side) filters through here, and the clamp over that scale makes the pair unreachable.
120
120
  export const effortAllowed = (effort: string, provider: AgentProvider, thinking: boolean): boolean =>
121
121
  effort !== "max" || (provider === "claude" && thinking);
122
-
123
- // The tier a selection falls back to when effortAllowed rejects it — one rung down from 'max', the top of every
124
- // scale that excludes it.
125
- export const clampEffort = (effort: string, provider: AgentProvider, thinking: boolean): string =>
126
- effortAllowed(effort, provider, thinking) ? effort : "xhigh";
@@ -20,19 +20,21 @@ import {
20
20
  OkSchema,
21
21
  } from "../schemas.js";
22
22
 
23
- // The fleet: every registered conversation-agent (see AgentSummarySchema). `diff` is the conversation
24
- // worktree's CUMULATIVE multi-repo delta vs its recorded per-repo bases one flat change set per repo
23
+ // The fleet: every registered conversation-agent (see AgentSummarySchema). Registry-level actions (read,
24
+ // rename, seen, archive) apply uniformly. For branch-backed conversations, `diff` is the worktree's CUMULATIVE
25
+ // multi-repo delta vs its recorded per-repo bases — one flat change set per repo
25
26
  // (AgentChanges), each file flagged `landed` or not, deliberately not the working tree's staged/unstaged
26
27
  // shape: a worktree the user never checks out has no index they could stage into. `land` merges the worktree
27
28
  // branches into the main tree (per-repo, conflicts reported, nothing lost on failure); `discard` removes
28
- // worktrees + branches + registry entry. An unknown {id} is NOT_FOUND; land/discard while the turn runs is CONFLICT.
29
+ // worktrees + branches + registry entry. Those branch actions reject workspace conversations explicitly. An
30
+ // unknown {id} is NOT_FOUND; land/discard while the turn runs is CONFLICT.
29
31
  // `rename` sets the user-chosen display title — legal mid-turn (it touches no worktree state).
30
32
  // `seen`/`seenAll` stamp the read marker behind the cards' unread badge (AgentSummarySchema.seenAt) — also
31
33
  // legal mid-turn, and like `rename` they never bump `updatedAt` (reading is not activity).
32
34
  //
33
- // ARCHIVE is the non-destructive counterpart to discard, and the one the board leans on: `archive` commits
34
- // whatever the worktree still holds onto agent/<id>, drops the CHECKOUT (the expensive part — one file tree
35
- // per repo per agent) and keeps the branch, the entry, and the transcript. `list` then stops carrying it and
35
+ // ARCHIVE is the non-destructive counterpart to discard, and the one the board leans on: for an isolated
36
+ // conversation, `archive` commits whatever the worktree still holds onto agent/<id> and drops the CHECKOUT;
37
+ // for a workspace conversation it has no git teardown. Both keep the entry and transcript. `list` stops carrying it and
36
38
  // `archived` does; `unarchive` puts it back, and either way the next turn re-attaches a worktree from the
37
39
  // surviving branch. Archiving a running agent is CONFLICT, same as land/discard.
38
40
  //
@@ -1,5 +1,5 @@
1
1
  import { oc } from "@orpc/contract";
2
- import { CiFixResponseSchema, CiJobsResponseSchema, CiRunParamSchema, CiRunsResponseSchema, OkSchema } from "../schemas.js";
2
+ import { CiFixResponseSchema, CiJobsResponseSchema, CiRunParamSchema, CiRunsResponseSchema, CiSeenResponseSchema, OkSchema } from "../schemas.js";
3
3
 
4
4
  // Pipelines on the workspace repos' github/gitlab remotes. `runs` serves the Pipelines rail view (cache +
5
5
  // on-demand backfill; per-repo webhook warnings ride along); `rerun`/`cancel` proxy to the vendor; `fix`
@@ -12,4 +12,8 @@ export const ciContract = {
12
12
  cancel: oc.route({ method: "POST", path: "/ci/runs/cancel" }).input(CiRunParamSchema).output(OkSchema),
13
13
  jobs: oc.route({ method: "POST", path: "/ci/runs/jobs" }).input(CiRunParamSchema).output(CiJobsResponseSchema),
14
14
  fix: oc.route({ method: "POST", path: "/ci/fix" }).input(CiRunParamSchema).output(CiFixResponseSchema),
15
+ // "I have looked at the pipelines" — what silences the rail badge for breakages already read. No input:
16
+ // the surface is read as a whole, and the daemon stamps its own clock so a skewed browser can't mark
17
+ // future failures as already seen.
18
+ seen: oc.route({ method: "POST", path: "/ci/seen" }).output(CiSeenResponseSchema),
15
19
  };
@@ -0,0 +1,19 @@
1
+ import { oc } from "@orpc/contract";
2
+ import { GateVerdictSchema, OkSchema } from "../schemas.js";
3
+
4
+ // The landing gate — the check command run over the composite of landed work once the fleet goes quiet (see
5
+ // GateVerdictSchema for where this sits and why). Four verbs, all about ONE verdict: the gate answers about the
6
+ // main working tree, of which there is exactly one, so nothing here is addressed by id.
7
+ //
8
+ // `verdict` is the one read, and the panel polls it; it recomputes staleness per call, so a passed verdict stops
9
+ // claiming a green light the moment the tree moves under it. The three writes answer `ok` and nothing else —
10
+ // each starts work that outlives the request, so there is no result to return and the poll is what reports.
11
+ // `run` arms nothing and waits for nothing: it starts the check now, the user's own "I'm about to commit, check
12
+ // this". `fix` opens a seeded workspace conversation for a red verdict; it has the same registry lifecycle as
13
+ // /ci/fix, minus the isolated worktree it must not have.
14
+ export const gateContract = {
15
+ verdict: oc.route({ method: "GET", path: "/gate/verdict" }).output(GateVerdictSchema),
16
+ run: oc.route({ method: "POST", path: "/gate/run" }).output(OkSchema),
17
+ cancel: oc.route({ method: "POST", path: "/gate/cancel" }).output(OkSchema),
18
+ fix: oc.route({ method: "POST", path: "/gate/fix" }).output(OkSchema),
19
+ };
@@ -1,26 +1,9 @@
1
1
  import { oc } from "@orpc/contract";
2
- import {
3
- AccountIdSchema,
4
- AccountRenameSchema,
5
- KimiConnectSchema,
6
- ModelsSchema,
7
- OauthAccountListSchema,
8
- OauthAccountSchema,
9
- OkSchema,
10
- } from "../schemas.js";
2
+ import { ModelsSchema } from "../schemas.js";
11
3
 
12
- // Kimi Code (Moonshot) the sandbox owns the credential, an API key rather than an OAuth grant. Kimi speaks the
13
- // Anthropic Messages protocol, so a Kimi turn runs on the SAME Claude Code harness as Claude, with the harness
14
- // pointed at Moonshot's Anthropic-compatible endpoint and authenticated with the stored key (see agent.routes).
15
- // `connect` stores a pasted key as a new account and returns it; `models` is the live catalog for the picker;
16
- // `accounts` lists connected keys (tokens never ride back); `rename` renames one (a pasted key carries no
17
- // identity to derive a name from, so this is the ONLY way a Kimi row gets a distinguishing name);
18
- // `disconnect` clears the one named by id.
4
+ // Kimi Code's picker catalog. Authentication and accounts belong to the bundled translator just like the other
5
+ // routed subscriptions (translator.contract.ts); this provider-specific route only projects CLIProxyAPI's Kimi
6
+ // model definitions into the shared picker shape.
19
7
  export const kimiContract = {
20
- connect: oc.route({ method: "POST", path: "/kimi/account/connect" }).input(KimiConnectSchema).output(OauthAccountSchema),
21
- // Kimi/Moonshot's live models for the picker — the source of valid ids (see kimi-models.ts).
22
8
  models: oc.route({ method: "GET", path: "/kimi/models" }).output(ModelsSchema),
23
- accounts: oc.route({ method: "GET", path: "/kimi/accounts" }).output(OauthAccountListSchema),
24
- rename: oc.route({ method: "POST", path: "/kimi/account/rename" }).input(AccountRenameSchema).output(OauthAccountSchema),
25
- disconnect: oc.route({ method: "POST", path: "/kimi/account/disconnect" }).input(AccountIdSchema).output(OkSchema),
26
9
  };
@@ -2,6 +2,8 @@ import { eventIterator, oc } from "@orpc/contract";
2
2
  import { z } from "zod";
3
3
  import { SystemEventSchema } from "../events.js";
4
4
  import {
5
+ BrowserNameParamSchema,
6
+ BrowsersListSchema,
5
7
  DaemonSessionSchema,
6
8
  HostTunnelInputSchema,
7
9
  HostTunnelSchema,
@@ -38,4 +40,11 @@ export const systemContract = {
38
40
  // (browser fetch sends the header), unlike the header-less WS route which app.ts exempts.
39
41
  terminals: oc.route({ method: "GET", path: "/system/terminals" }).output(TerminalsListSchema),
40
42
  killTerminal: oc.route({ method: "DELETE", path: "/system/terminals/{name}" }).input(TerminalNameParamSchema).output(OkSchema),
43
+ // The agent's live Chromiums and the pages each has open — the Browsers view's roster, polled while it is on
44
+ // screen and by the rail so its tile can appear the moment a turn starts browsing. The frames are the
45
+ // separate /system/browser-view WebSocket; this is the control plane, exactly as `terminals` is for tmux.
46
+ // `closeBrowser` shuts one Chromium down: the agent's next browser tool call then fails as if it had crashed,
47
+ // which is the honest account of the owner pulling the plug.
48
+ browsers: oc.route({ method: "GET", path: "/system/browsers" }).output(BrowsersListSchema),
49
+ closeBrowser: oc.route({ method: "DELETE", path: "/system/browsers/{name}" }).input(BrowserNameParamSchema).output(OkSchema),
41
50
  };
@@ -7,12 +7,10 @@ import { KeyedProviderSchema, OkSchema, TranslatorAccountsSchema, TranslatorComp
7
7
  // than an API key. A provider can hold several accounts side by side (the translator balances across them);
8
8
  // `accounts` lists what's connected per provider and `disconnect` clears ONE account by its auth-file `name`.
9
9
  //
10
- // Two login shapes ride one pair of routes. Codex and Grok mint a one-time device `code`: the user enters it at
11
- // the provider's site and the translator polls to completion in the background, so the UI just polls `accounts`
12
- // and `complete` is never called. Google has no device flow it redirects the browser to a loopback URL this
13
- // sandbox can't receive so its `connect` returns an EMPTY code, the card asks the user to paste the URL they
14
- // landed on, and `complete` hands it to the translator to finish the exchange. The card branches on that empty
15
- // code rather than on the provider id, so a provider that later gains a device flow needs no UI change.
10
+ // Two login shapes ride one pair of routes. Codex, Grok and Kimi use device authorization: the translator polls
11
+ // to completion in the background, so the UI only polls `accounts`. Google redirects the browser to a loopback
12
+ // URL this sandbox can't receive, so `complete` hands the landing URL to the translator. `connect.flow` tells the
13
+ // card which mechanic it received without inferring it from whether an optional device code happened to exist.
16
14
  export const translatorContract = {
17
15
  accounts: oc.route({ method: "GET", path: "/translator/accounts" }).output(TranslatorAccountsSchema),
18
16
  connect: oc
package/src/events.ts CHANGED
@@ -262,12 +262,14 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
262
262
  * actually loses the work — so the wait has to be visible, with its own next-attempt clock.
263
263
  *
264
264
  * `attempt`/`maxAttempts` are the harness's own counters; `nextAttemptAt` (epoch ms) is when it will try
265
- * again, so the readout counts down instead of freezing on a number nobody can interpret. */
265
+ * again, so the readout counts down instead of freezing on a number nobody can interpret. Optional because
266
+ * only Claude's harness reports the delay: Codex says which attempt it is on and nothing else
267
+ * (codex-agent.ts), and inventing a countdown for it would be a clock the retry never keeps. */
266
268
  z.object({
267
269
  kind: z.literal("provider_retry"),
268
270
  attempt: z.number(),
269
271
  maxAttempts: z.number(),
270
- nextAttemptAt: z.number(),
272
+ nextAttemptAt: z.number().optional(),
271
273
  // The HTTP status behind it when there was one (529 reads as capacity, 500 as a fault — the client says
272
274
  // which). Absent for a transport failure that never got a response.
273
275
  status: z.number().optional(),
@@ -348,7 +350,8 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
348
350
  // provider outage, because the client's reading of them is the same: "scheduled" = the resume is armed
349
351
  // and this turn comes back by itself; "available" = the daemon remembered the failed turn and turning
350
352
  // the setting on (autoResumeOnLimit / resumeAfterOutage) arms that same resume, which is what the
351
- // chat's offer banner hangs off. Absent there is nothing to resume.
353
+ // chat's offer banner hangs off. Absent normally means there is nothing automatic to resume; the
354
+ // usage-limit feature gate also leaves it absent while preserving the explicit account-switch path.
352
355
  autoResume: z.enum(["scheduled", "available"]).optional(),
353
356
  /* provider-outage only: the shape of the wait. `retryAt` (epoch seconds) is when the next attempt is
354
357
  * due — not a fixed cadence, because an outage has no reset instant to aim at and hammering a provider
@@ -395,19 +398,63 @@ export type IntenticLine = z.infer<typeof IntenticLineSchema>;
395
398
  export const HeartbeatSchema = z.object({ kind: z.literal("heartbeat") });
396
399
  export type Heartbeat = z.infer<typeof HeartbeatSchema>;
397
400
 
401
+ // One step of the daemon's boot chain. `key` is the stable id the daemon declares it under, `label` the words
402
+ // the browser shows. A step that FAILED is still a step that finished — the boot chain is log-and-continue by
403
+ // design (see main.ts), so a failure degrades one subsystem rather than holding the gate closed forever.
404
+ export const BootStepSchema = z.object({
405
+ key: z.string(),
406
+ label: z.string(),
407
+ state: z.enum(["pending", "running", "done", "failed"]),
408
+ // Elapsed ms, once the step has finished.
409
+ ms: z.number().optional(),
410
+ });
411
+ export type BootStep = z.infer<typeof BootStepSchema>;
412
+
413
+ /* WHERE THE DAEMON IS IN ITS BOOT. The listeners come up before the state they serve has converged (main.ts:
414
+ * "listen first, converge behind the gate"), which is what stops a restart from reading as an outage — but it
415
+ * also means the daemon spends the first seconds of every boot both reachable and unable to answer, and until
416
+ * this frame existed the browser had no way to tell that apart from a healthy sandbox. It painted an operable
417
+ * workspace off its persisted cache and then parked every request the user made against the readiness gate.
418
+ *
419
+ * The step list is declared UP FRONT and sent whole, pending entries included, so the browser can say "4 of 11,
420
+ * loading the conversation registry" rather than "something is happening" — a boot that takes minutes has one
421
+ * slow step, and naming it is the whole point. Snapshot-not-diff, like every other roster on this stream. */
422
+ export const BootProgressSchema = z.object({
423
+ // False only while the chain is still converging. The browser holds every daemon read until this is true.
424
+ ready: z.boolean(),
425
+ // Epoch ms the daemon started converging, so the browser can show a total elapsed that survives a reconnect.
426
+ startedAt: z.number(),
427
+ steps: z.array(BootStepSchema),
428
+ });
429
+ export type BootProgress = z.infer<typeof BootProgressSchema>;
430
+
431
+ // Pushed on every step transition and once more when the gate opens. Rides /events, which answers before the
432
+ // gate precisely so this can be delivered while everything else waits.
433
+ export const BootSchema = z.object({ kind: z.literal("boot"), ...BootProgressSchema.shape });
434
+ export type Boot = z.infer<typeof BootSchema>;
435
+
398
436
  // The stream's first frame: the workspace's stable identity, minted at the first boot of an empty /work. The
399
437
  // browser remembers it per sandbox id and drops that sandbox's persisted query cache when it changes — a wiped
400
438
  // and recreated workspace (cleanup.sh + reconnect keeps the same sandbox id) must not be painted from the
401
- // previous workspace's cache.
439
+ // previous workspace's cache. `build` is the same guard against a different axis: the daemon's own compiled
440
+ // tree, so an image update (or a `pnpm build:sandbox` swap in dev) drops what the browser cached from the
441
+ // PREVIOUS build instead of hydrating payloads the new one no longer shapes that way.
402
442
  //
403
443
  // It also advertises `routes` — the contract route names (`vpn.list`, `kimi.models`) this daemon actually
404
444
  // implements, from ITS build of the contract. A browser is routinely newer than the daemon it talks to (a
405
445
  // released app plane serves whatever image each user last pulled; in local dev the web app is always ahead of
406
446
  // the last `pnpm build:sandbox`), and that stays fully supported — the browser just compares the two sets so a
407
447
  // route the daemon predates surfaces as a named, explained gap instead of a bare 404 nobody can attribute.
408
- // Optional: a daemon built before this field simply advertises nothing, and every route is assumed present,
409
- // which is exactly the pre-existing behaviour.
410
- export const HelloSchema = z.object({ kind: z.literal("hello"), workspaceId: z.string(), routes: z.array(z.string()).optional() });
448
+ //
449
+ // Every added field is optional: a daemon built before one simply says nothing, and the browser's fallback is
450
+ // the pre-existing behaviour routes all assumed present, the daemon assumed ready, the cache left alone.
451
+ export const HelloSchema = z.object({
452
+ kind: z.literal("hello"),
453
+ workspaceId: z.string(),
454
+ routes: z.array(z.string()).optional(),
455
+ build: z.string().optional(),
456
+ boot: BootProgressSchema.optional(),
457
+ });
411
458
  export type Hello = z.infer<typeof HelloSchema>;
412
459
 
413
460
  // The FULL discovered repo set (sorted root-relative ids), pushed whenever it changes — a clone, a scaffold,
@@ -457,12 +504,13 @@ export type Presence = z.infer<typeof PresenceSchema>;
457
504
  export const AgentsSchema = z.object({ kind: z.literal("agents"), agents: z.array(AgentSummarySchema), rev: z.number() });
458
505
  export type Agents = z.infer<typeof AgentsSchema>;
459
506
 
460
- // The /events stream union: the hello identity frame, then liveness heartbeats interleaved with
507
+ // The /events stream union: the hello identity frame, then liveness heartbeats interleaved with boot progress,
461
508
  // workspace-change batches, repo-set snapshots, and presence + fleet roster snapshots. oRPC validates every
462
509
  // yielded frame against this, so all kinds must live here.
463
510
  export const SystemEventSchema = z.discriminatedUnion("kind", [
464
511
  HelloSchema,
465
512
  HeartbeatSchema,
513
+ BootSchema,
466
514
  WorkspaceChangedSchema,
467
515
  ReposChangedSchema,
468
516
  PresenceSchema,
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { claudeContract } from "./contracts/claude.contract.js";
10
10
  import { codexContract } from "./contracts/codex.contract.js";
11
11
  import { draftsContract } from "./contracts/drafts.contract.js";
12
12
  import { extensionsContract } from "./contracts/extensions.contract.js";
13
+ import { gateContract } from "./contracts/gate.contract.js";
13
14
  import { geminiContract } from "./contracts/gemini.contract.js";
14
15
  import { gitContract } from "./contracts/git.contract.js";
15
16
  import { grokContract } from "./contracts/grok.contract.js";
@@ -41,6 +42,7 @@ export { claudeContract } from "./contracts/claude.contract.js";
41
42
  export { codexContract } from "./contracts/codex.contract.js";
42
43
  export { draftsContract } from "./contracts/drafts.contract.js";
43
44
  export { extensionsContract } from "./contracts/extensions.contract.js";
45
+ export { gateContract } from "./contracts/gate.contract.js";
44
46
  export { geminiContract } from "./contracts/gemini.contract.js";
45
47
  export { gitContract } from "./contracts/git.contract.js";
46
48
  export { grokContract } from "./contracts/grok.contract.js";
@@ -92,6 +94,7 @@ export const sandboxContract = {
92
94
  settings: settingsContract,
93
95
  intentic: intenticContract,
94
96
  gemini: geminiContract,
97
+ gate: gateContract,
95
98
  git: gitContract,
96
99
  grok: grokContract,
97
100
  kimi: kimiContract,
@@ -7,37 +7,42 @@ import { compareCheapestFirst, compareModelIds, compareUnrankedModelIds, familyO
7
7
  * conversations on whichever id sorted first. */
8
8
 
9
9
  // A Codex catalog exactly as an OpenAI-compatible /v1/models hands it over: alphabetical, i.e. meaningless.
10
- const CODEX = ["gpt-5.1-codex", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-sol", "gpt-5.6-terra"];
10
+ const CODEX = ["gpt-5.1-codex", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"];
11
11
 
12
12
  test("ranks the frontier line above the cheap one and the newest release above its predecessors", () => {
13
13
  // The base line (no tier word) leads, newest first; the mini rung sinks under all of it regardless of how
14
14
  // recently it shipped — which is the whole decision a user makes in this list.
15
- expect(CODEX.toSorted(compareModelIds)).toEqual(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.5", "gpt-5.1-codex", "gpt-5.4-mini"]);
15
+ expect(CODEX.toSorted(compareModelIds)).toEqual(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.1-codex", "gpt-5.4-mini"]);
16
16
  });
17
17
 
18
- test("lands the same models at the head and the tail whichever order the endpoint listed them in", () => {
19
- // Arrival order survives only as the tiebreak between two ids the rule ranks equally (the 5.6 siblings), so
20
- // an alphabetical registry and a reversed one can no longer disagree about which model the group opens on.
18
+ test("orders a release's named tiers strongest-first whichever order the endpoint listed them in", () => {
21
19
  for (const arrival of [CODEX.toSorted(), CODEX.toReversed()]) {
22
20
  const ordered = arrival.toSorted(compareModelIds);
23
21
 
24
- expect(ordered.slice(0, 2).toSorted()).toEqual(["gpt-5.6-sol", "gpt-5.6-terra"]);
22
+ expect(ordered.slice(0, 3)).toEqual(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
25
23
  expect(ordered.at(-1)).toBe("gpt-5.4-mini");
26
24
  }
27
25
  });
28
26
 
29
- test("an unranked catalog settles its own ties, so the SAME sibling opens the group on every refresh", () => {
30
- // The measured failure: the translator's /v1/models hands sol/terra/luna back in a different order per
31
- // request, and the rule above ranks all three equally — same tier, same 5.6 release. Under plain stability
32
- // the catalog's head (i.e. the model a fresh conversation starts on) followed that reshuffling.
27
+ test("keeps release-local tiers below the next generation and above the previous one", () => {
28
+ expect(["gpt-5.6-luna", "gpt-5.5", "gpt-5.7", "gpt-5.6-sol"].toSorted(compareModelIds)).toEqual([
29
+ "gpt-5.7",
30
+ "gpt-5.6-sol",
31
+ "gpt-5.6-luna",
32
+ "gpt-5.5",
33
+ ]);
34
+ });
35
+
36
+ test("the Codex release-tier order is stable across catalog refreshes", () => {
33
37
  const arrivals = [
34
38
  ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"],
35
39
  ["gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-sol"],
36
40
  ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"],
37
41
  ];
38
- const heads = arrivals.map((arrival) => arrival.toSorted(compareUnrankedModelIds)[0]);
39
42
 
40
- expect(new Set(heads).size).toBe(1);
43
+ for (const arrival of arrivals) {
44
+ expect(arrival.toSorted(compareUnrankedModelIds)).toEqual(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]);
45
+ }
41
46
  // Same rule, so ranking still outranks the tiebreak: the mini rung stays at the tail, under every sibling.
42
47
  expect(["gpt-5.4-mini", ...arrivals[0]!].toSorted(compareUnrankedModelIds).at(-1)).toBe("gpt-5.4-mini");
43
48
  });
@@ -68,6 +73,14 @@ test("reads each vendor's tier vocabulary, not just Claude's", () => {
68
73
  ]);
69
74
  });
70
75
 
76
+ test("reads Kimi's k-prefixed generation so K3 leads the K2.x catalog", () => {
77
+ const catalog = ["kimi-k2.6", "kimi-k2.7-code-highspeed", "kimi-k3", "kimi-k2.7-code"];
78
+
79
+ expect(releaseOf("kimi-k3")).toEqual({ version: [3], date: 0 });
80
+ expect(familyOf("kimi-k3")).toBe(familyOf("kimi-k2.6"));
81
+ expect(catalog.toSorted(compareUnrankedModelIds)).toEqual(["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6"]);
82
+ });
83
+
71
84
  test("the rightmost tier word wins, because tier words compose", () => {
72
85
  // flash-lite is the cheap end of Flash, codex-max the frontier end of Codex — reading the leftmost word
73
86
  // instead would file both under the tier they modify.
@@ -169,6 +182,10 @@ test("finds each vendor's own cheap rung, including a re-served open-weights row
169
182
  expect(["grok-4", "grok-4-fast"].toSorted(compareCheapestFirst)[0]).toBe("grok-4-fast");
170
183
  });
171
184
 
185
+ test("reads a release-local tier ladder from the cheap end too", () => {
186
+ expect(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"].toSorted(compareCheapestFirst)[0]).toBe("gpt-5.6-luna");
187
+ });
188
+
172
189
  test("falls back on the newest release for a catalog that publishes no cheap tier at all", () => {
173
190
  // Kimi names no tier word anywhere, so every row is UNRANKED and the tier term cancels. Serving the newest
174
191
  // of what it does publish is the honest answer — there is no cheaper rung to find.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Anthropic's REST /v1/models answers newest-first: that IS a provider opinion, and Claude's catalog rides it
5
5
  * (claude-models.ts). Every other provider here is read through an OpenAI-compatible /v1/models — Codex and
6
- * Gemini via the bundled translator, Kimi via Moonshot — or out of xAI's "Did you mean" rejection, and those
6
+ * Gemini and Kimi via the bundled translator — or out of xAI's "Did you mean" rejection, and those
7
7
  * endpoints publish a SET, not a ranking: they hand the ids back in whatever order their registry iterates,
8
8
  * which in practice is alphabetical. Reading that as a preference is what put "GPT 5.4 Mini" at the head of the
9
9
  * Codex group with GPT 5.6 below it, and what made a fresh Codex conversation start on whichever id happened to
@@ -14,9 +14,11 @@
14
14
  * every vendor names its models the same way — which is what lets the daemon's four catalog services and the
15
15
  * web's picker share one rule instead of each inventing a local one. */
16
16
 
17
- // A version-ish segment: digits and dots, optionally v-prefixed (`4`, `5.1`, `v2`, `20251001`). Everything else
18
- // is a NAME segment and belongs to the family which is what makes the split below exhaustive.
19
- const VERSION_SEGMENT = /^v?[\d.]+$/;
17
+ // A version-ish segment: digits and dots, optionally prefixed by the vendor's version marker (`4`, `5.1`, `v2`,
18
+ // `k3`, `k2.7`, `20251001`). Kimi is the one provider that fuses the marker with the generation; treating `k3`
19
+ // as a name made the current flagship look unversioned, so K2.x sorted above it. Everything else is a NAME
20
+ // segment and belongs to the family — which is what makes the split below exhaustive.
21
+ const VERSION_SEGMENT = /^(?:v|k)?[\d.]+$/i;
20
22
 
21
23
  // A date stamp rather than a version component: six digits or more (20251001, 250514). The distinction is not
22
24
  // cosmetic — claude-opus-4-1-20250805 (Opus 4.1) and claude-opus-4-20250514 (Opus 4.0) compare as (4,1) vs (4)
@@ -48,7 +50,7 @@ export interface ModelRelease {
48
50
  export const releaseOf = (id: string): ModelRelease => {
49
51
  const numeric = segmentsOf(id)
50
52
  .filter((segment) => VERSION_SEGMENT.test(segment))
51
- .map((segment) => segment.replace(/^v/, ""));
53
+ .map((segment) => segment.replace(/^[vk]/i, ""));
52
54
  const stamps = numeric.filter((segment) => DATE_SEGMENT.test(segment)).map(Number);
53
55
  return {
54
56
  version: numeric
@@ -109,12 +111,21 @@ const TIER_RANK: Readonly<Record<string, number>> = {
109
111
 
110
112
  const UNRANKED = -1;
111
113
 
112
- // The LAST recognized word wins, because tier words compose and the rightmost is the most specific one:
113
- // gemini-flash-lite is the cheap end of Flash, gpt-codex-max the frontier end of Codex.
114
- export const tierRankOf = (family: string): number => {
114
+ /* Some providers name a capability ladder INSIDE one release instead of using the cross-release adjectives
115
+ * above. Codex 5.6's Sol/Terra/Luna rows are that shape: they must remain together ahead of the older 5.5 line,
116
+ * but their order is not an arbitrary id tiebreak — Sol is the strongest, followed by Terra, then Luna. Keeping
117
+ * this as a separate rank lets release recency still win across generations (a future GPT 5.7 base model must
118
+ * not be buried under a recognized 5.6 suffix), while the three siblings sort by their real tier. */
119
+ const RELEASE_TIER_RANK: Readonly<Record<string, number>> = {
120
+ sol: 0,
121
+ terra: 1,
122
+ luna: 2,
123
+ };
124
+
125
+ const lastRankOf = (family: string, ranks: Readonly<Record<string, number>>): number => {
115
126
  let rank = UNRANKED;
116
127
  for (const segment of family.split("-")) {
117
- const found = TIER_RANK[segment];
128
+ const found = ranks[segment];
118
129
  if (found !== undefined) {
119
130
  rank = found;
120
131
  }
@@ -122,18 +133,32 @@ export const tierRankOf = (family: string): number => {
122
133
  return rank;
123
134
  };
124
135
 
125
- // The canonical order of two model ids: tier first, then release. Hand it straight to Array#toSorted — that sort
126
- // is stable, so two ids this rule cannot separate keep the order they arrived in (for Claude, the provider's own).
127
- export const compareModelIds = (left: string, right: string): number =>
128
- tierRankOf(familyOf(left)) - tierRankOf(familyOf(right)) || compareRelease(releaseOf(left), releaseOf(right));
136
+ const releaseTierRankOf = (family: string): number => lastRankOf(family, RELEASE_TIER_RANK);
137
+
138
+ // The LAST recognized word wins, because tier words compose and the rightmost is the most specific one:
139
+ // gemini-flash-lite is the cheap end of Flash, gpt-codex-max the frontier end of Codex.
140
+ export const tierRankOf = (family: string): number => lastRankOf(family, TIER_RANK);
141
+
142
+ // The canonical order of two model ids: broad tier first, then release, then a tier declared within that release.
143
+ // Hand it straight to Array#toSorted — that sort is stable, so two ids this rule cannot separate keep the order
144
+ // they arrived in (for Claude, the provider's own).
145
+ export const compareModelIds = (left: string, right: string): number => {
146
+ const leftFamily = familyOf(left);
147
+ const rightFamily = familyOf(right);
148
+ return (
149
+ tierRankOf(leftFamily) - tierRankOf(rightFamily) ||
150
+ compareRelease(releaseOf(left), releaseOf(right)) ||
151
+ releaseTierRankOf(leftFamily) - releaseTierRankOf(rightFamily)
152
+ );
153
+ };
129
154
 
130
155
  /* The order for a catalog its endpoint published as a SET — Codex, Gemini, Kimi and Grok, i.e. everything but
131
156
  * Anthropic's ranked list. Falling back on arrival order is what the rule above does with a tie, and for a RANKED
132
157
  * catalog that is exactly right: the tie is the provider's own opinion, so claude-opus-5 stays ahead of
133
158
  * claude-fable-5. For a set there is no opinion to keep, and the header of this file assumed the leftover order
134
- * was at least alphabetical — it is not. A subscription vending sol/terra/luna (same tier, same 5.6 release, three
135
- * ids this rule cannot separate) hands its rows back in whatever order its registry iterated THIS request, so the
136
- * tie decided which model a fresh conversation opened on AND flipped between catalog refreshes.
159
+ * was at least alphabetical — it is not. A subscription can hand tied rows back in whatever order its registry
160
+ * iterated THIS request, so the tie decides which model a fresh conversation opens on and can flip between
161
+ * catalog refreshes.
137
162
  *
138
163
  * So a set breaks its own ties on the id. Which sibling that seats first is arbitrary — but it is the same
139
164
  * arbitrary answer every refresh, which is the property `default` actually needs. */
@@ -151,5 +176,12 @@ export const compareUnrankedModelIds = (left: string, right: string): number =>
151
176
  * not the efficient rung — and the cheap end is only ever a family whose tier word is actually recognized.
152
177
  * Falling off the end of a catalog with no efficient tier at all (Kimi publishes none) is then honest: the
153
178
  * newest of what it does publish, chosen by the release tiebreak below. */
154
- export const compareCheapestFirst = (left: string, right: string): number =>
155
- tierRankOf(familyOf(right)) - tierRankOf(familyOf(left)) || compareRelease(releaseOf(left), releaseOf(right));
179
+ export const compareCheapestFirst = (left: string, right: string): number => {
180
+ const leftFamily = familyOf(left);
181
+ const rightFamily = familyOf(right);
182
+ return (
183
+ tierRankOf(rightFamily) - tierRankOf(leftFamily) ||
184
+ compareRelease(releaseOf(left), releaseOf(right)) ||
185
+ releaseTierRankOf(rightFamily) - releaseTierRankOf(leftFamily)
186
+ );
187
+ };
@@ -9,7 +9,7 @@ import { type QuickModelSource, quickModelKey, resolveQuickModel } from "./quick
9
9
  const CLAUDE: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001`] };
10
10
  const GOOGLE: QuickModelSource = { provider: `gemini`, ready: true, models: [`gemini-3-flash`, `gemini-3-flash-lite`, `gemini-3-pro`] };
11
11
  const CODEX: QuickModelSource = { provider: `codex`, ready: true, models: [`gpt-5.4-mini`, `gpt-5.6`] };
12
- const KIMI: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2-0711-preview`, `kimi-k2-0905-preview`] };
12
+ const KIMI: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2.6`, `kimi-k2.7-code`, `kimi-k3`] };
13
13
 
14
14
  const offline = (source: QuickModelSource): QuickModelSource => ({ ...source, ready: false });
15
15
 
@@ -31,7 +31,7 @@ test("puts tier ahead of cost — a free frontier model is still the wrong tool
31
31
  expect(resolveQuickModel([CLAUDE, proOnly], ``)).toEqual({ provider: `claude`, model: `claude-haiku-4-5-20251001` });
32
32
  });
33
33
 
34
- test("prefers a subscription to a metered key at equal tier, since only one of them charges per click", () => {
34
+ test("uses stable provider order when two subscriptions offer the same tier", () => {
35
35
  const kimiCheap: QuickModelSource = { provider: `kimi`, ready: true, models: [`kimi-k2-mini`] };
36
36
  const claudeCheap: QuickModelSource = { provider: `claude`, ready: true, models: [`claude-haiku-4-5`] };
37
37
 
@@ -72,7 +72,7 @@ test("ignores a malformed pin instead of running an empty model id", () => {
72
72
 
73
73
  test("serves the newest of a catalog that publishes no cheap tier at all", () => {
74
74
  // Kimi names no tier word anywhere. There is no cheaper rung to find, so the newest row is the honest answer.
75
- expect(resolveQuickModel([KIMI], ``)).toEqual({ provider: `kimi`, model: `kimi-k2-0905-preview` });
75
+ expect(resolveQuickModel([KIMI], ``)).toEqual({ provider: `kimi`, model: `kimi-k3` });
76
76
  });
77
77
 
78
78
  test("reports nothing when no account is connected, so the button can say so instead of failing on click", () => {
@@ -21,6 +21,8 @@ test("a payload from a build that predates a toggle parses, with the new toggle
21
21
  expect(SandboxSettingsSchema.parse(older)).toEqual({
22
22
  ...older,
23
23
  terseHoldout: 0,
24
+ iqContext: false,
25
+ iqContextHoldout: 0,
24
26
  filterBackend: "native",
25
27
  systemPromptMode: "intentic",
26
28
  systemPrompt: "",
@@ -30,6 +32,10 @@ test("a payload from a build that predates a toggle parses, with the new toggle
30
32
  autoResumeOnLimit: false,
31
33
  resumeAfterOutage: true,
32
34
  autoResumeOnRestart: true,
35
+ gateCommand: "",
36
+ gateQuietMs: 20_000,
37
+ gateTimeoutMs: 900_000,
38
+ gateAutoFix: true,
33
39
  });
34
40
  });
35
41
 
@@ -42,6 +48,10 @@ test("an empty object is the full default settings object", () => {
42
48
  // Off: the steer's turn-level control spends the tokens it measures, so measuring is opt-in.
43
49
  terseHoldout: 0,
44
50
  iqSearch: false,
51
+ // Off, and its holdout with it: pre-injection spends input tokens on every eligible turn, and the
52
+ // control that would tell you whether they paid for themselves costs the turns it measures.
53
+ iqContext: false,
54
+ iqContextHoldout: 0,
45
55
  outputCleaners: "off",
46
56
  outputHoldout: 0,
47
57
  filterBackend: "native",
@@ -65,6 +75,12 @@ test("an empty object is the full default settings object", () => {
65
75
  // On: a daemon restart is usually intentic's own doing (an image update, an approved environment
66
76
  // change), not the user's decision, so the turn it interrupted resumes rather than staying stuck.
67
77
  autoResumeOnRestart: true,
78
+ // Empty disables the landing gate until the owner supplies this workspace's verification command.
79
+ gateCommand: "",
80
+ gateQuietMs: 20_000,
81
+ gateTimeoutMs: 900_000,
82
+ // Once a gate is configured, a red verdict wakes one fixer by default.
83
+ gateAutoFix: true,
68
84
  });
69
85
  });
70
86
 
@@ -74,3 +90,7 @@ test("a key of the wrong type is still a parse failure — tolerance is for abse
74
90
  // The prompt cap is a real bound, not advice: the text IS the system prompt, and every turn pays for it.
75
91
  expect(SandboxSettingsSchema.safeParse({ systemPrompt: "x".repeat(20001) }).success).toBe(false);
76
92
  });
93
+
94
+ test("usage-limit auto-resume stays off while the feature is disabled, including for an older saved true value", () => {
95
+ expect(SandboxSettingsSchema.parse({ autoResumeOnLimit: true }).autoResumeOnLimit).toBe(false);
96
+ });