@intentic/sandbox-contract 1.224.0 → 1.226.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 (109) hide show
  1. package/README.md +14 -14
  2. package/dist/agent-catalog.d.ts +2 -0
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +19 -3
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/chores/chores.js +42 -42
  7. package/dist/chores/chores.js.map +1 -1
  8. package/dist/chores/extension-update.js +2 -2
  9. package/dist/chores/extension-update.js.map +1 -1
  10. package/dist/chores/fix-deps.js +1 -1
  11. package/dist/chores/fix-deps.js.map +1 -1
  12. package/dist/chores/probes.js +1 -1
  13. package/dist/chores/probes.js.map +1 -1
  14. package/dist/chores/verdict.js +2 -2
  15. package/dist/chores/verdict.js.map +1 -1
  16. package/dist/contracts/capabilities.contract.d.ts +41 -0
  17. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  18. package/dist/contracts/exit.contract.d.ts +80 -0
  19. package/dist/contracts/exit.contract.d.ts.map +1 -0
  20. package/dist/contracts/exit.contract.js +13 -0
  21. package/dist/contracts/exit.contract.js.map +1 -0
  22. package/dist/contracts/settings.contract.d.ts +12 -0
  23. package/dist/contracts/settings.contract.d.ts.map +1 -1
  24. package/dist/events.d.ts +4 -4
  25. package/dist/events.d.ts.map +1 -1
  26. package/dist/events.js +5 -5
  27. package/dist/events.js.map +1 -1
  28. package/dist/fast-tier.d.ts +9 -0
  29. package/dist/fast-tier.d.ts.map +1 -0
  30. package/dist/fast-tier.js +19 -0
  31. package/dist/fast-tier.js.map +1 -0
  32. package/dist/history-state.js +3 -3
  33. package/dist/history-state.js.map +1 -1
  34. package/dist/index.d.ts +203 -68
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +5 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/model-order.d.ts +1 -0
  39. package/dist/model-order.d.ts.map +1 -1
  40. package/dist/model-order.js +5 -0
  41. package/dist/model-order.js.map +1 -1
  42. package/dist/output-fields.d.ts.map +1 -1
  43. package/dist/output-fields.js +2 -2
  44. package/dist/output-fields.js.map +1 -1
  45. package/dist/prompt-complexity.d.ts +19 -0
  46. package/dist/prompt-complexity.d.ts.map +1 -0
  47. package/dist/prompt-complexity.js +83 -0
  48. package/dist/prompt-complexity.js.map +1 -0
  49. package/dist/publish-drafts.js +2 -2
  50. package/dist/publish-drafts.js.map +1 -1
  51. package/dist/schemas.d.ts +214 -0
  52. package/dist/schemas.d.ts.map +1 -1
  53. package/dist/schemas.js +113 -2
  54. package/dist/schemas.js.map +1 -1
  55. package/dist/workflow-faults.js +3 -3
  56. package/dist/workflow-faults.js.map +1 -1
  57. package/dist/workspace-state.d.ts +20 -20
  58. package/dist/workspace-state.d.ts.map +1 -1
  59. package/dist/workspace-state.js +20 -20
  60. package/dist/workspace-state.js.map +1 -1
  61. package/package.json +5 -5
  62. package/src/agent-catalog.test.ts +72 -27
  63. package/src/agent-catalog.ts +91 -3
  64. package/src/agent-run-model.test.ts +3 -3
  65. package/src/capability-ledger.test.ts +34 -15
  66. package/src/chores/chores.test.ts +1 -1
  67. package/src/chores/chores.ts +42 -42
  68. package/src/chores/digest.test.ts +1 -1
  69. package/src/chores/extension-update.ts +2 -2
  70. package/src/chores/fix-deps.ts +1 -1
  71. package/src/chores/probes.test.ts +6 -6
  72. package/src/chores/probes.ts +1 -1
  73. package/src/chores/stack.test.ts +3 -3
  74. package/src/chores/verdict.test.ts +20 -20
  75. package/src/chores/verdict.ts +2 -2
  76. package/src/contract-lock.test.ts +1 -1
  77. package/src/contracts/exit.contract.ts +42 -0
  78. package/src/contracts/providers.contract.ts +1 -1
  79. package/src/conversation-ids.ts +1 -1
  80. package/src/events.test.ts +3 -3
  81. package/src/events.ts +6 -6
  82. package/src/fast-tier.test.ts +88 -0
  83. package/src/fast-tier.ts +72 -0
  84. package/src/history-state.ts +3 -3
  85. package/src/hostnames.test.ts +1 -1
  86. package/src/index.ts +5 -0
  87. package/src/model-order.test.ts +11 -11
  88. package/src/model-order.ts +22 -0
  89. package/src/output-fields.ts +2 -2
  90. package/src/path-refs.test.ts +4 -4
  91. package/src/prompt-complexity.test.ts +160 -0
  92. package/src/prompt-complexity.ts +271 -0
  93. package/src/publish-drafts.ts +2 -2
  94. package/src/quick-model.test.ts +11 -11
  95. package/src/routes.test.ts +11 -5
  96. package/src/runtime-state.test.ts +1 -1
  97. package/src/schemas.test.ts +8 -8
  98. package/src/schemas.ts +311 -6
  99. package/src/search-globs.test.ts +2 -2
  100. package/src/share-paths.test.ts +1 -1
  101. package/src/title.test.ts +9 -9
  102. package/src/title.ts +1 -1
  103. package/src/tunnel-ids.test.ts +3 -3
  104. package/src/versions.test.ts +3 -3
  105. package/src/versions.ts +1 -1
  106. package/src/workflow-faults.test.ts +6 -6
  107. package/src/workflow-faults.ts +3 -3
  108. package/src/workspace-state.test.ts +32 -32
  109. package/src/workspace-state.ts +20 -20
@@ -0,0 +1,42 @@
1
+ import { eventIterator, oc } from "@orpc/contract";
2
+ import { IntenticLineSchema } from "../events.js";
3
+ import { ExitCountriesSchema, ExitIdParamSchema, ExitListSchema, ExitObservationSchema, ExitUseInputSchema, OkSchema } from "../schemas.js";
4
+
5
+ // The live GEO EXIT surface. An exit is ADDED as an `exit` capability (which provider, a resting country,
6
+ // whether it comes up on boot); it is STARTED, MOVED and ROTATED here. Same split as the vpn contract and for
7
+ // the same reason: switching country is a runtime operation performed many times over one stored pool, by the
8
+ // operator from the Status card and by the agent through `exit` on its PATH, which calls these very routes.
9
+ //
10
+ // The difference from vpn is what "success" means. A dial succeeds when the tunnel is up; a country switch
11
+ // succeeds only when the egress ADDRESS has moved, which is why `use` and `rotate` end by fetching an
12
+ // ExitObservation through the exit's own proxy and fail when it does not agree with what was asked for.
13
+ //
14
+ // Nothing here changes the sandbox's default route, ever. Each exit publishes a SOCKS proxy and callers opt
15
+ // in; the daemon's own traffic, the model endpoint and the tunnel that makes this sandbox reachable stay on
16
+ // the plain uplink no matter what is up.
17
+ export const exitContract = {
18
+ // Every configured exit with its live state, where it was asked to come out and where it actually does.
19
+ // Drives the Status card, the browser account picker and `geo list` (the CLI is `geo`: `exit` is a shell
20
+ // builtin, so a binary of that name is unreachable from a command line).
21
+ list: oc.route({ method: "GET", path: "/exit" }).output(ExitListSchema),
22
+ // What this exit can reach, ranked by how much capacity is actually there. Live from the provider when it
23
+ // answers (Tor's directory, VPN Gate's CSV, the pasted confs), from the baked fallback when it does not,
24
+ // and `live` says which. This is what auto-fills the country picker instead of a user hunting hostnames.
25
+ countries: oc.route({ method: "GET", path: "/exit/{id}/countries" }).input(ExitIdParamSchema).output(ExitCountriesSchema),
26
+ // Bring the exit up at its stored country. Streams, because a first start pulls a catalog, brings up a
27
+ // tunnel and then verifies the address, which is tens of seconds on the free providers and can fail at
28
+ // each step with something the user has to read. Idempotent: starting an up exit reports it and stops.
29
+ start: oc.route({ method: "POST", path: "/exit/{id}/start" }).input(ExitIdParamSchema).output(eventIterator(IntenticLineSchema)),
30
+ // Move to another country, starting the exit first if it was down. Streams for the same reason as start,
31
+ // and THROWS when the observed country does not end up matching: a switch that silently left traffic where
32
+ // it was is the one failure mode this whole feature exists to make impossible.
33
+ use: oc.route({ method: "POST", path: "/exit/{id}/use" }).input(ExitUseInputSchema).output(eventIterator(IntenticLineSchema)),
34
+ // A different address in the SAME country. Cheap on tor (a control-port signal), a re-dial to another
35
+ // server on the rest. Fails when the address does not actually change, which on a small pool it can't.
36
+ rotate: oc.route({ method: "POST", path: "/exit/{id}/rotate" }).input(ExitIdParamSchema).output(eventIterator(IntenticLineSchema)),
37
+ // What the world sees through this exit right now. Cheap, unstreamed, and the honest answer to "am I
38
+ // actually in Germany", which is the question every other route here is judged against.
39
+ check: oc.route({ method: "POST", path: "/exit/{id}/check" }).input(ExitIdParamSchema).output(ExitObservationSchema),
40
+ // Take it down. Tolerates an already-down exit: "make it not be up" is the contract, not "it was up".
41
+ stop: oc.route({ method: "POST", path: "/exit/{id}/stop" }).input(ExitIdParamSchema).output(OkSchema),
42
+ };
@@ -3,7 +3,7 @@ import { ModelsSchema, NativeProviderParamSchema } from "../schemas.js";
3
3
 
4
4
  // EVERY NATIVE PROVIDER'S PICKER CATALOG, ON ONE ROUTE.
5
5
  //
6
- // There were five of these /claude/models, /codex/models, /grok/models, /kimi/models, /gemini/models, one per
6
+ // There were five of these: /claude/models, /codex/models, /grok/models, /kimi/models, /gemini/models, one per
7
7
  // provider, each a contract entry, a route factory and a service field wired separately. They differed in
8
8
  // nothing: same method, same output schema, same "the daemon's catalog for this provider, never empty". Three of
9
9
  // the five route files were the same fifteen lines with a name swapped, and the daemon paid for that shape twice
@@ -165,7 +165,7 @@ const randomBelow = (upperBound: number): number => {
165
165
  // browser tabs opened in the same instant are exactly the case this must not produce the same name for.
166
166
  const pick = <T>(values: readonly T[]): T => values[randomBelow(values.length)]!;
167
167
 
168
- // Lowercase base36, one character per draw 0-9a-z, all of which the id guard accepts.
168
+ // Lowercase base36, one character per draw: 0-9a-z, all of which the id guard accepts.
169
169
  const tail = (): string => Array.from({ length: TAIL_LENGTH }, () => randomBelow(36).toString(36)).join("");
170
170
 
171
171
  /* A fresh conversation id: `<adjective>-<noun>-<tail>`, e.g. `swift-otter-k9m2`. Sixteen characters or so
@@ -3,8 +3,8 @@ import { RESUME_NOTES, resumeDisclosure, withResumeNote, withoutResumeNote } fro
3
3
 
4
4
  /* The resume note is a round trip across the wire: the daemon wraps a prompt to tell the model what interrupted
5
5
  * it, and the client unwraps the SAME prompt off an attach head to tell whether it already has that bubble. A
6
- * mismatch between the two halves fails silently and cosmetically a paragraph of machine prose rendered as
7
- * something the user typed which is exactly the kind of drift that stays broken. */
6
+ * mismatch between the two halves fails silently and cosmetically: a paragraph of machine prose rendered as
7
+ * something the user typed, which is exactly the kind of drift that stays broken. */
8
8
  test("a resume note round-trips back to the user's own words", () => {
9
9
  for (const note of Object.values(RESUME_NOTES)) {
10
10
  expect(withoutResumeNote(withResumeNote("ship the parser", note))).toBe("ship the parser");
@@ -39,7 +39,7 @@ test("a re-run discloses as a notice, and the answered case as a note on the mes
39
39
  const disclosure = resumeDisclosure(withResumeNote("ship the parser", RESUME_NOTES[reason]));
40
40
  expect(disclosure?.kind).toBe("notice");
41
41
  }
42
- // The answer is new words the user really did type, so nothing is dropped the interruption rides them.
42
+ // The answer is new words the user really did type, so nothing is dropped: the interruption rides them.
43
43
  const answered = resumeDisclosure(withResumeNote("option two", RESUME_NOTES.answered));
44
44
  expect(answered).toEqual({ kind: "note", note: { title: expect.any(String), text: RESUME_NOTES.answered } });
45
45
  });
package/src/events.ts CHANGED
@@ -737,7 +737,7 @@ export const AgentEventSchema = z.discriminatedUnion("kind", [
737
737
  * is, so nothing is re-run and nothing asks the user to reconnect, the one recovery that looks
738
738
  * plausible and is guaranteed to waste their time. */
739
739
  "claude-not-entitled",
740
- // The model provider itself failed transiently 500/502/503, a 529 at capacity, a dropped
740
+ // The model provider itself failed transiently: 500/502/503, a 529 at capacity, a dropped
741
741
  // socket, and the harness's own in-turn retries did not outlast it. Nothing about the workspace
742
742
  // or the request is wrong, so the daemon remembers the turn and re-runs it on an escalating
743
743
  // backoff (provider-health.ts): the frame is a notice about a turn that is coming back, and
@@ -813,7 +813,7 @@ export type AttachFrame = z.infer<typeof AttachFrameSchema>;
813
813
  // and telling the model to "continue from that point instead of starting over" about words it has never seen
814
814
  // is how a resume reads as the user contradicting themselves.
815
815
  const REPEATED =
816
- "The interrupted request is repeated below where part of it was already completed in this session, continue from that point instead of starting over.";
816
+ "The interrupted request is repeated below, where part of it was already completed in this session, continue from that point instead of starting over.";
817
817
  export const RESUME_NOTES = {
818
818
  auth: `The Claude credential that interrupted this conversation has been renewed, and this turn resumed automatically. ${REPEATED}`,
819
819
  outage: `The model provider was briefly unavailable and interrupted this conversation; this turn resumed automatically. ${REPEATED}`,
@@ -822,7 +822,7 @@ export const RESUME_NOTES = {
822
822
  // instead, and this is the turn their answer starts (turn-resume.ts). What rides below the note is the
823
823
  // answer itself, so the model picks the session back up at exactly the decision it had handed over.
824
824
  answered:
825
- "The sandbox restarted while this conversation was waiting for the user to respond; it is back, and their response follows below continue from where the session left off.",
825
+ "The sandbox restarted while this conversation was waiting for the user to respond; it is back, and their response follows below: continue from where the session left off.",
826
826
  } as const;
827
827
 
828
828
  // The prompt a resume actually sends: the note (each carries its own account of what the words below are),
@@ -860,9 +860,9 @@ export type ResumeReason = keyof typeof RESUME_NOTES;
860
860
  export type ResumeDisclosure = { readonly kind: "notice"; readonly text: string } | { readonly kind: "note"; readonly note: TurnNote };
861
861
 
862
862
  const RESUME_DISCLOSURES: Record<ResumeReason, ResumeDisclosure> = {
863
- auth: { kind: "notice", text: "Claude sign-in renewed this turn picked up where it left off." },
864
- outage: { kind: "notice", text: "The model provider came back this turn picked up where it left off." },
865
- restart: { kind: "notice", text: "The sandbox came back this turn picked up where it left off." },
863
+ auth: { kind: "notice", text: "Claude sign-in renewed, this turn picked up where it left off." },
864
+ outage: { kind: "notice", text: "The model provider came back, this turn picked up where it left off." },
865
+ restart: { kind: "notice", text: "The sandbox came back, this turn picked up where it left off." },
866
866
  answered: { kind: "note", note: { title: "Picked back up after a sandbox restart", text: RESUME_NOTES.answered } },
867
867
  };
868
868
 
@@ -0,0 +1,88 @@
1
+ import { expect, test } from "vitest";
2
+ import { fastTierModel } from "./fast-tier.js";
3
+
4
+ /* WHERE A DOWNGRADED TURN ACTUALLY LANDS. The judge (prompt-complexity.ts) says a turn could be cheaper; this
5
+ * says whether there is anywhere cheaper to put it, on the provider it is already on.
6
+ *
7
+ * Two properties carry the whole feature, and everything below is one of them: a downgrade is only ever to a
8
+ * genuinely CHEAPER rung of the same catalog (so "cheaper" can never quietly become "older", or "the same model
9
+ * with less thinking"), and it never crosses PROVIDER (because that retires the conversation's session, which
10
+ * throws away the context that made the follow-up cheap to answer in the first place). */
11
+
12
+ const CLAUDE = [`claude-opus-5`, `claude-sonnet-5`, `claude-haiku-4-5-20251001`];
13
+ const GOOGLE = [`gemini-3-pro`, `gemini-3-flash`, `gemini-3-flash-lite`];
14
+
15
+ const fastFor = (model: string, over: { models?: readonly string[]; pinned?: readonly string[]; provider?: string } = {}) =>
16
+ fastTierModel({ provider: over.provider ?? `claude`, model, models: over.models ?? CLAUDE, pinned: over.pinned ?? [] });
17
+
18
+ // --- Auto: the cheapest rung the provider publishes ----------------------------------------------------
19
+
20
+ test("sends a frontier turn to the provider's own cheap rung", () => {
21
+ expect(fastFor(`claude-opus-5`)).toBe(`claude-haiku-4-5-20251001`);
22
+ });
23
+
24
+ test("reads the cheap end the same way the quick model does, on every vendor's vocabulary", () => {
25
+ expect(fastFor(`gemini-3-pro`, { provider: `gemini`, models: GOOGLE })).toBe(`gemini-3-flash-lite`);
26
+ });
27
+
28
+ // --- the ceiling: the user's own pick -------------------------------------------------------------------
29
+
30
+ test("a user already on the cheap rung has nowhere to be sent", () => {
31
+ // The common case worth being exact about rather than an edge case. Undefined means "run what they asked
32
+ // for", which is the honest answer and the one that costs nothing to be wrong about.
33
+ expect(fastFor(`claude-haiku-4-5-20251001`)).toBeUndefined();
34
+ });
35
+
36
+ test("never swaps a model for an older build of the same tier", () => {
37
+ // A downgrade has to be legible AS a downgrade. Last year's Sonnet under this year's Sonnet is not a
38
+ // saving the feature promised, it is a different turn wearing the user's own model name.
39
+ expect(fastFor(`claude-sonnet-5`, { models: [`claude-sonnet-5`, `claude-sonnet-4`] })).toBeUndefined();
40
+ });
41
+
42
+ test("never downgrades a model whose family this build does not recognise", () => {
43
+ // An id carrying no tier word is a provider's base line or a family nobody here has heard of, and betting
44
+ // a user's turn on the guess that an unknown id is the budget one is the wrong direction to be wrong in.
45
+ expect(fastFor(`claude-opus-5`, { models: [`some-new-thing`] })).toBeUndefined();
46
+ expect(fastFor(`some-new-thing`, { models: CLAUDE })).toBeUndefined();
47
+ });
48
+
49
+ test("an unloaded catalog resolves to no downgrade rather than to a guess", () => {
50
+ expect(fastFor(`claude-opus-5`, { models: [] })).toBeUndefined();
51
+ });
52
+
53
+ test("no pick yet means nothing to be cheaper than", () => {
54
+ expect(fastFor(``)).toBeUndefined();
55
+ });
56
+
57
+ // --- pins -----------------------------------------------------------------------------------------------
58
+
59
+ test("a pin on this provider wins over the catalog's own cheap end", () => {
60
+ expect(fastFor(`claude-opus-5`, { pinned: [`claude:claude-sonnet-5`] })).toBe(`claude-sonnet-5`);
61
+ });
62
+
63
+ test("takes a pinned id verbatim, so a model the static catalog has not caught up with is still pinnable", () => {
64
+ // The same call resolveQuickModels makes, and for the same reason: the picker offers a custom-id escape
65
+ // hatch, and second-guessing the id here would run a different model than the settings row names.
66
+ expect(fastFor(`claude-opus-5`, { pinned: [`claude:claude-haiku-9`] })).toBe(`claude-haiku-9`);
67
+ });
68
+
69
+ test("drops a pin naming another provider rather than crossing to it", () => {
70
+ // Switching provider retires the conversation's session (turnRequest.ts `resumes`). Starting the
71
+ // conversation over to save a fraction of a cent is not a saving, so the pin is ignored and Auto answers.
72
+ expect(fastFor(`claude-opus-5`, { pinned: [`gemini:gemini-3-flash-lite`] })).toBe(`claude-haiku-4-5-20251001`);
73
+ });
74
+
75
+ test("skips a pin that is not actually cheaper than what the user picked", () => {
76
+ // A pin is a preference about WHICH cheap rung, never a licence to swap a model for its equal or better.
77
+ expect(fastFor(`claude-sonnet-5`, { pinned: [`claude:claude-opus-5`] })).toBe(`claude-haiku-4-5-20251001`);
78
+ });
79
+
80
+ test("walks past an unusable pin to the next one that names this provider", () => {
81
+ const pinned = [`gemini:gemini-3-flash`, `claude:claude-opus-5`, `claude:claude-haiku-4-5`];
82
+
83
+ expect(fastFor(`claude-opus-5`, { pinned })).toBe(`claude-haiku-4-5`);
84
+ });
85
+
86
+ test("falls back to Auto when no pin survives, rather than to no downgrade at all", () => {
87
+ expect(fastFor(`claude-opus-5`, { pinned: [`nonsense`, `gemini:gemini-3-flash`] })).toBe(`claude-haiku-4-5-20251001`);
88
+ });
@@ -0,0 +1,72 @@
1
+ import { compareCheapestFirst, isCheaperRung } from "./model-order.js";
2
+ import { parsePinned } from "./quick-model.js";
3
+ import type { AgentProvider } from "./schemas.js";
4
+
5
+ /* WHICH MODEL A DOWNGRADED TURN ACTUALLY RUNS ON, the second half of automatic tier selection.
6
+ * prompt-complexity.ts decides a turn COULD be cheaper; this decides whether there is anything cheaper to put
7
+ * it on, and names it.
8
+ *
9
+ * THERE IS NO "STANDARD TIER" SETTING, and its absence is the design rather than an omission. The standard tier
10
+ * is whatever the user already picked for this conversation. So this mechanism can only ever route DOWN, from a
11
+ * model somebody chose to a cheaper rung of the same catalog, which means the worst case of a wrong answer is
12
+ * one turn's quality on a model the user can see and correct, never a bill they did not ask for. Every
13
+ * ambiguous case in this file therefore resolves to `undefined`, which the caller reads as "run what they
14
+ * asked for".
15
+ *
16
+ * THE PROVIDER IS NEVER CROSSED, and this is the constraint the rest of the app imposes rather than one this
17
+ * file would have chosen. A conversation's provider session is resumed only while the selection still matches
18
+ * the runtime and account that minted it (turnRequest.ts `resumes`, which compares provider, account and
19
+ * harness, and pointedly NOT model). A model swap inside one provider is free, and the session carries on. A
20
+ * provider swap RETIRES the session and cuts a new segment, which throws away the very context that made the
21
+ * follow-up cheap to answer. So a cross-provider pin here is dropped rather than honoured: saving a fraction of
22
+ * a cent by starting the conversation over is not a saving.
23
+ *
24
+ * THE ORDER IS A LADDER, for the reason quick-model.ts is one, but a shorter one: the caller spends the head
25
+ * and falls back to the user's own pick, rather than walking rungs. A downgrade that cannot be started is not
26
+ * worth a second attempt when the honest answer (their model) is sitting right there. */
27
+
28
+ export interface FastTierInput {
29
+ // The provider this turn is on. Both the pick and every candidate belong to it; see above.
30
+ readonly provider: AgentProvider;
31
+ // The model the user picked, i.e. the standard tier and the ceiling. Empty when the composer has not
32
+ // resolved one yet, which resolves to no downgrade: there is nothing to be cheaper THAN.
33
+ readonly model: string;
34
+ // That provider's catalog as the caller can see it. Empty is a real state (a catalog that has not loaded),
35
+ // and it resolves to no downgrade rather than to a guess.
36
+ readonly models: readonly string[];
37
+ // settings.autoFastModels: an ordered list of `${provider}:${modelId}` keys (quickModelKey), or empty for
38
+ // Auto. Entries naming another provider are dropped, not honoured.
39
+ readonly pinned: readonly string[];
40
+ }
41
+
42
+ /* THE CHEAPER MODEL TO RUN THIS TURN ON, or undefined for "there isn't one, use their pick".
43
+ *
44
+ * A PIN IS TAKEN VERBATIM against the catalog, the same call resolveQuickModels makes and for the same reason:
45
+ * the model picker offers a custom-id escape hatch for a model the static catalog has not caught up with, and
46
+ * second-guessing the id here would run a different model than the settings row names. It is still checked for
47
+ * being CHEAPER, because that is not a fact about the catalog, it is a fact about the id, and a pin that is not
48
+ * cheaper than the pick is not a downgrade at all.
49
+ *
50
+ * AUTO IS THE CHEAPEST ROW THE PROVIDER PUBLISHES, read through the same cheap-end order the quick model uses,
51
+ * so the two features cannot disagree about which rung is the cheap one. Derived, never stored: connect an
52
+ * account tomorrow and the answer improves by itself, exactly as quickModel's empty default does.
53
+ *
54
+ * NOTHING CHEAPER THAN THE PICK ⇒ UNDEFINED, and that is the common case worth being exact about rather than
55
+ * the edge case: a user already working on the cheap rung has nowhere to be sent, and a user on a model whose
56
+ * family this build does not recognize is not going to be downgraded on a guess (isCheaperRung). */
57
+ export const fastTierModel = (input: FastTierInput): string | undefined => {
58
+ if (input.model === "") {
59
+ return undefined;
60
+ }
61
+ const pinned = input.pinned
62
+ .flatMap((key) => {
63
+ const choice = parsePinned(key);
64
+ return choice === undefined || choice.provider !== input.provider ? [] : [choice.model];
65
+ })
66
+ .find((model) => isCheaperRung(model, input.model));
67
+ if (pinned !== undefined) {
68
+ return pinned;
69
+ }
70
+ const cheapest = input.models.toSorted(compareCheapestFirst)[0];
71
+ return cheapest !== undefined && isCheaperRung(cheapest, input.model) ? cheapest : undefined;
72
+ };
@@ -95,7 +95,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
95
95
  {
96
96
  path: "ssh-hosts/",
97
97
  portability: "secret",
98
- note: "Re-add each ssh host on the Capabilities view its key does not travel.",
98
+ note: "Re-add each ssh host on the Capabilities view, its key does not travel.",
99
99
  },
100
100
  // The cli-proxy's config, which holds the routed subscriptions' provider tokens.
101
101
  {
@@ -108,7 +108,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
108
108
 
109
109
  /* Signs every browser session cookie. Carrying it would let a bundle's holder mint sessions against the
110
110
  * target, an export becomes a credential, and the target minting its own costs exactly one sign-in. */
111
- { path: "session-secret", portability: "identity", note: "Sign in again the target signs its own sessions." },
111
+ { path: "session-secret", portability: "identity", note: "Sign in again, the target signs its own sessions." },
112
112
  {
113
113
  path: "browser-access-disabled",
114
114
  portability: "identity",
@@ -117,7 +117,7 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
117
117
  {
118
118
  path: "push.json",
119
119
  portability: "identity",
120
- note: "Re-enable notifications in the browsers you use a push subscription is bound to the sandbox that minted it.",
120
+ note: "Re-enable notifications in the browsers you use, a push subscription is bound to the sandbox that minted it.",
121
121
  },
122
122
  { path: "sync-enrollments.json", portability: "identity", note: "Re-pair desktop sync from the Sync tab." },
123
123
  { path: "sync-pair-consumed.json", portability: "identity" },
@@ -42,7 +42,7 @@ test("the local sync folder carries the very id the sandbox's URL does", () => {
42
42
  const url = `https://${sandboxHostname(ID, "intentic.dev")}`;
43
43
  expect(sandboxIdFromUrl(url)).toBe(ID);
44
44
  expect(syncFolder("Radarsu Local", url)).toBe(`~/intentic/radarsu-local-${ID}`);
45
- // A bare host (no scheme) and a port are both accepted the daemon/CLI and the browser pass different shapes.
45
+ // A bare host (no scheme) and a port are both accepted: the daemon/CLI and the browser pass different shapes.
46
46
  expect(sandboxIdFromUrl(`${sandboxHostname(ID, "intentic.dev")}:443`)).toBe(ID);
47
47
  // Own-Cloudflare: the owner's chosen subdomain IS the id, so the folder still matches what they browse to.
48
48
  expect(syncFolder("shop", "https://dev-box.example.com")).toBe("~/intentic/shop-dev-box");
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { ciContract } from "./contracts/ci.contract.js";
10
10
  import { claudeContract } from "./contracts/claude.contract.js";
11
11
  import { draftsContract } from "./contracts/drafts.contract.js";
12
12
  import { endpointsContract } from "./contracts/endpoints.contract.js";
13
+ import { exitContract } from "./contracts/exit.contract.js";
13
14
  import { extensionsContract } from "./contracts/extensions.contract.js";
14
15
  import { personasContract } from "./contracts/personas.contract.js";
15
16
  import { gitContract } from "./contracts/git.contract.js";
@@ -47,6 +48,7 @@ export { ciContract } from "./contracts/ci.contract.js";
47
48
  export { claudeContract } from "./contracts/claude.contract.js";
48
49
  export { draftsContract } from "./contracts/drafts.contract.js";
49
50
  export { endpointsContract, type TrialHealth, TrialStatusSchema, type TrialStatusResponse } from "./contracts/endpoints.contract.js";
51
+ export { exitContract } from "./contracts/exit.contract.js";
50
52
  export { extensionsContract } from "./contracts/extensions.contract.js";
51
53
  export { personasContract } from "./contracts/personas.contract.js";
52
54
  export { gitContract } from "./contracts/git.contract.js";
@@ -99,11 +101,13 @@ export * from "./agent-run-model.js";
99
101
  export * from "./capability-env.js";
100
102
  export * from "./capability-secrets.js";
101
103
  export * from "./conversation-ids.js";
104
+ export * from "./fast-tier.js";
102
105
  export * from "./host-protocol.js";
103
106
  export * from "./listener-protocol.js";
104
107
  export * from "./hostnames.js";
105
108
  export * from "./model-order.js";
106
109
  export * from "./path-refs.js";
110
+ export * from "./prompt-complexity.js";
107
111
  export * from "./quick-model.js";
108
112
  export * from "./output-fields.js";
109
113
  export * from "./publish-drafts.js";
@@ -153,6 +157,7 @@ export const sandboxContract = {
153
157
  translator: translatorContract,
154
158
  usage: usageContract,
155
159
  vpn: vpnContract,
160
+ exit: exitContract,
156
161
  workflows: workflowsContract,
157
162
  };
158
163
 
@@ -11,7 +11,7 @@ const CODEX = ["gpt-5.1-codex", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-
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
- // recently it shipped which is the whole decision a user makes in this list.
14
+ // recently it shipped, which is the whole decision a user makes in this list.
15
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
 
@@ -47,7 +47,7 @@ test("the Codex release-tier order is stable across catalog refreshes", () => {
47
47
  expect(["gpt-5.4-mini", ...arrivals[0]!].toSorted(compareUnrankedModelIds).at(-1)).toBe("gpt-5.4-mini");
48
48
  });
49
49
 
50
- test("leaves a RANKED catalog's ties alone the id tiebreak is for sets, and Anthropic publishes an opinion", () => {
50
+ test("leaves a RANKED catalog's ties alone: the id tiebreak is for sets, and Anthropic publishes an opinion", () => {
51
51
  // compareUnrankedModelIds would seat claude-fable-5 ahead of claude-opus-5 on the id alone. Anthropic's
52
52
  // catalog arrives newest-first, so that order is a fact about the provider, not a leftover to be broken.
53
53
  expect(["claude-opus-5", "claude-fable-5"].toSorted(compareModelIds)).toEqual(["claude-opus-5", "claude-fable-5"]);
@@ -82,7 +82,7 @@ test("reads Kimi's k-prefixed generation so K3 leads the K2.x catalog", () => {
82
82
  });
83
83
 
84
84
  test("the rightmost tier word wins, because tier words compose", () => {
85
- // flash-lite is the cheap end of Flash, codex-max the frontier end of Codex reading the leftmost word
85
+ // flash-lite is the cheap end of Flash, codex-max the frontier end of Codex: reading the leftmost word
86
86
  // instead would file both under the tier they modify.
87
87
  expect(tierRankOf(familyOf("gemini-3-flash-lite"))).toBe(tierRankOf("lite"));
88
88
  expect(tierRankOf(familyOf("gpt-5.1-codex-max"))).toBe(tierRankOf("max"));
@@ -95,7 +95,7 @@ test("leads with a family carrying no tier word at all, so a brand-new flagship
95
95
 
96
96
  test("files a re-served open-weights model on the cheap rung, not at the head of the catalog it visits", () => {
97
97
  // Google's channel vends gpt-oss beside Gemini and Claude. It carries no tier word of its own, so the
98
- // lead-the-unknown rule would open that whole section on it above Opus.
98
+ // lead-the-unknown rule would open that whole section on it: above Opus.
99
99
  expect(["gpt-oss-120b-medium", "claude-opus-4-6-thinking", "gemini-pro-agent"].toSorted(compareModelIds)).toEqual([
100
100
  "claude-opus-4-6-thinking",
101
101
  "gemini-pro-agent",
@@ -103,7 +103,7 @@ test("files a re-served open-weights model on the cheap rung, not at the head of
103
103
  ]);
104
104
  });
105
105
 
106
- test("keeps the arrival order between ids the rule cannot separate Anthropic's catalog IS ranked", () => {
106
+ test("keeps the arrival order between ids the rule cannot separate: Anthropic's catalog IS ranked", () => {
107
107
  // Same tier, same version: nothing here outranks the order the provider itself reported.
108
108
  expect(["claude-opus-5", "claude-fable-5"].toSorted(compareModelIds)).toEqual(["claude-opus-5", "claude-fable-5"]);
109
109
  expect(["claude-fable-5", "claude-opus-5"].toSorted(compareModelIds)).toEqual(["claude-fable-5", "claude-opus-5"]);
@@ -124,7 +124,7 @@ test("stands an id with nothing but numbers (and an ACP row's empty one) as its
124
124
 
125
125
  test("holds date stamps apart from version components, or a dated build outranks the point release after it", () => {
126
126
  // The failure this prevents: claude-opus-4-1-20250805 (Opus 4.1) read as (4,1,20250805) loses to
127
- // claude-opus-4-20250514 (Opus 4.0) read as (4,20250514) the older model, by six digits.
127
+ // claude-opus-4-20250514 (Opus 4.0) read as (4,20250514): the older model, by six digits.
128
128
  expect(releaseOf("claude-opus-4-1-20250805")).toEqual({ version: [4, 1], date: 20250805 });
129
129
  expect(["claude-opus-4-20250514", "claude-opus-4-1-20250805"].toSorted(compareModelIds)).toEqual([
130
130
  "claude-opus-4-1-20250805",
@@ -153,7 +153,7 @@ test("sorts an unversioned rolling alias under the releases that name their vers
153
153
  // What the quick model behind a one-click helper resolves against: the same tier scale, read for the weakest
154
154
  // row instead of the strongest.
155
155
 
156
- test("opens on the efficient rung and buries the frontier one the exact inverse of the picker's order", () => {
156
+ test("opens on the efficient rung and buries the frontier one: the exact inverse of the picker's order", () => {
157
157
  const claude = ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001"];
158
158
 
159
159
  expect(claude.toSorted(compareCheapestFirst)).toEqual(["claude-haiku-4-5-20251001", "claude-sonnet-5", "claude-opus-5"]);
@@ -162,7 +162,7 @@ test("opens on the efficient rung and buries the frontier one — the exact inve
162
162
 
163
163
  test("keeps an UNRANKED family off the cheap end, where a plain reversal would have seated it first", () => {
164
164
  // The whole reason this is not `-compareModelIds`. An id with no tier word is the provider's base line, and
165
- // an unheard-of family is likelier the next flagship than the next budget tier so both orders agree it is
165
+ // an unheard-of family is likelier the next flagship than the next budget tier, so both orders agree it is
166
166
  // not the efficient rung, and a helper never spends frontier money on a commit message.
167
167
  expect(["gpt-5.6", "gpt-5.4-mini"].toSorted(compareCheapestFirst)).toEqual(["gpt-5.4-mini", "gpt-5.6"]);
168
168
  expect(["claude-mythos-1", "claude-haiku-4-5", "claude-sonnet-5"].toSorted(compareCheapestFirst).at(-1)).toBe("claude-mythos-1");
@@ -177,7 +177,7 @@ test("takes the NEWEST build of the cheap rung, not merely any of them", () => {
177
177
  test("finds each vendor's own cheap rung, including a re-served open-weights row", () => {
178
178
  expect(["gemini-3-pro", "gemini-3-flash", "gemini-3-flash-lite"].toSorted(compareCheapestFirst)[0]).toBe("gemini-3-flash-lite");
179
179
  // Google's channel vends gpt-oss beside Gemini's own line; it is there to be the cheap option, and `oss`
180
- // is what says so without that word the id carries no tier at all and would sink to the bottom.
180
+ // is what says so: without that word the id carries no tier at all and would sink to the bottom.
181
181
  expect(["claude-opus-4-6-thinking", "gpt-oss-120b-medium"].toSorted(compareCheapestFirst)[0]).toBe("gpt-oss-120b-medium");
182
182
  expect(["grok-4", "grok-4-fast"].toSorted(compareCheapestFirst)[0]).toBe("grok-4-fast");
183
183
  });
@@ -188,7 +188,7 @@ test("reads a release-local tier ladder from the cheap end too", () => {
188
188
 
189
189
  test("refuses the thinking variant of a model, however new it is", () => {
190
190
  /* The bug this rule exists for, in the shape the live catalog actually publishes it: a routed channel vends
191
- * one row per thinking LEVEL, and the newest row of the cheapest model was the high one so the ladder
191
+ * one row per thinking LEVEL, and the newest row of the cheapest model was the high one, so the ladder
192
192
  * whose whole job is to be the cheap rung reached for the most expensive reading of it, and a commit
193
193
  * message that takes 2s took closer to 30. */
194
194
  expect(["gemini-3.6-flash-high", "gemini-3.5-flash-extra-low"].toSorted(compareCheapestFirst)[0]).toBe("gemini-3.5-flash-extra-low");
@@ -219,6 +219,6 @@ test("names the thinking rows, and only those", () => {
219
219
 
220
220
  test("falls back on the newest release for a catalog that publishes no cheap tier at all", () => {
221
221
  // Kimi names no tier word anywhere, so every row is UNRANKED and the tier term cancels. Serving the newest
222
- // of what it does publish is the honest answer there is no cheaper rung to find.
222
+ // of what it does publish is the honest answer: there is no cheaper rung to find.
223
223
  expect(["kimi-k2-0711-preview", "kimi-k2-0905-preview"].toSorted(compareCheapestFirst)[0]).toBe("kimi-k2-0905-preview");
224
224
  });
@@ -230,6 +230,28 @@ export const compareCheapestFirst = (left: string, right: string): number => {
230
230
  );
231
231
  };
232
232
 
233
+ /* IS `candidate` A STRICTLY CHEAPER RUNG THAN `pick`, on the tier ladder alone. The predicate automatic tier
234
+ * selection runs on (prompt-complexity.ts judges the turn, fast-tier.ts spends this to find something cheaper
235
+ * to run it on), and it lives here because it is a question about the tier vocabulary, which is this file's
236
+ * one curated fact.
237
+ *
238
+ * TIER ONLY, deliberately: not release, not thinking level. A downgrade has to be legible as a downgrade, and
239
+ * "the same model one release older" or "the same model with less thinking" are not the saving this mechanism
240
+ * promises, they are a different turn wearing the user's model name. Haiku under Sonnet is; Sonnet 4 under
241
+ * Sonnet 5 is not.
242
+ *
243
+ * AN UNRANKED FAMILY IS FALSE ON EITHER SIDE, and both directions matter. A candidate carrying no tier word is
244
+ * a provider's base line or a family nobody here has heard of, so calling it the budget option is a guess, and
245
+ * the scale already says the likelier reading is "next flagship". The PICK being unranked is the subtler half:
246
+ * the safety argument for automatic tier selection is that it can only ever route DOWN, and against an id whose
247
+ * tier is unknown nothing can be shown to be down. So an unrecognised pick is left alone rather than swapped
248
+ * for something merely known to be cheap, which is how a saving turns into a bill. */
249
+ export const isCheaperRung = (candidate: string, pick: string): boolean => {
250
+ const candidateRank = tierRankOf(familyOf(candidate));
251
+ const pickRank = tierRankOf(familyOf(pick));
252
+ return candidateRank !== UNRANKED && pickRank !== UNRANKED && candidateRank > pickRank;
253
+ };
254
+
233
255
  /* WOULD RUNNING THIS ID MAKE THE MODEL THINK, as far as its name admits, which for a routed catalog is as far
234
256
  * as anyone can tell without running it. True only for an id that spells out a level ABOVE the quiet end, so an
235
257
  * ordinary id nobody has annotated (claude-haiku-4-5, kimi-k2) is never accused of it.
@@ -92,7 +92,7 @@ export const fieldsValidator = (fields: readonly OutputField[]): z.ZodType =>
92
92
  export const fieldsExample = (fields: readonly OutputField[]): Record<string, unknown> =>
93
93
  Object.fromEntries(
94
94
  fields.map((field) => {
95
- const hint = `${field.description}${field.required ? "" : " (optional omit if it does not apply)"}`;
95
+ const hint = `${field.description}${field.required ? "" : " (optional, omit if it does not apply)"}`;
96
96
  if (field.type === "number") {
97
97
  return [field.name, 0];
98
98
  }
@@ -108,4 +108,4 @@ export const fieldsExample = (fields: readonly OutputField[]): Record<string, un
108
108
 
109
109
  // One line per field, for surfaces with no room to render an example: "risk (string, required), how likely …".
110
110
  export const describeFields = (fields: readonly OutputField[]): string =>
111
- fields.map((field) => `- \`${field.name}\` (${field.type}${field.required ? ", required" : ", optional"}) ${field.description}`).join(`\n`);
111
+ fields.map((field) => `- \`${field.name}\` (${field.type}${field.required ? ", required" : ", optional"}), ${field.description}`).join(`\n`);
@@ -15,7 +15,7 @@ describe("referenceTails", () => {
15
15
  expect(referenceTails("/history/worktrees/agent-7/_apps/foo.ts", "/work")).toContain("_apps/foo.ts");
16
16
  });
17
17
 
18
- test("never cuts down to a bare filename one `index.ts` is as good as another", () => {
18
+ test("never cuts down to a bare filename: one `index.ts` is as good as another", () => {
19
19
  expect(referenceTails("a/b/index.ts", "/work").at(-1)).toBe("b/index.ts");
20
20
  expect(referenceTails("index.ts", "/work")).toEqual([]);
21
21
  });
@@ -28,11 +28,11 @@ describe("referenceTails", () => {
28
28
 
29
29
  describe("rankRefCandidates", () => {
30
30
  test("keeps only matches that end in the tail on a segment boundary", () => {
31
- // `mypages/` merely ends with the same characters the daemon's glob can't tell, so this must.
31
+ // `mypages/` merely ends with the same characters: the daemon's glob can't tell, so this must.
32
32
  expect(rankRefCandidates("pages/Foo.vue", ["app/mypages/Foo.vue", "app/pages/Foo.vue"])).toEqual(["app/pages/Foo.vue"]);
33
33
  });
34
34
 
35
- test("ranks the shallowest match first the app's file, not a copy in a fixture tree", () => {
35
+ test("ranks the shallowest match first: the app's file, not a copy in a fixture tree", () => {
36
36
  expect(rankRefCandidates("pages/Foo.vue", ["a/b/c/pages/Foo.vue", "a/pages/Foo.vue"])).toEqual(["a/pages/Foo.vue", "a/b/c/pages/Foo.vue"]);
37
37
  });
38
38
 
@@ -41,7 +41,7 @@ describe("rankRefCandidates", () => {
41
41
  });
42
42
  });
43
43
 
44
- test("isTestPath: test files, fixture dirs and runner configs never product code that merely says 'test'", () => {
44
+ test("isTestPath: test files, fixture dirs and runner configs: never product code that merely says 'test'", () => {
45
45
  for (const path of [
46
46
  `src/agents/land.test.ts`,
47
47
  `src/pages/Foo.spec.tsx`,