@intentic/sandbox-contract 1.164.0 → 1.166.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 (62) hide show
  1. package/dist/contracts/agent.contract.d.ts +20 -2
  2. package/dist/contracts/agent.contract.d.ts.map +1 -1
  3. package/dist/contracts/agents.contract.d.ts +3 -0
  4. package/dist/contracts/agents.contract.d.ts.map +1 -1
  5. package/dist/contracts/automations.contract.d.ts +7 -0
  6. package/dist/contracts/automations.contract.d.ts.map +1 -1
  7. package/dist/contracts/automations.contract.js +1 -0
  8. package/dist/contracts/automations.contract.js.map +1 -1
  9. package/dist/contracts/ci.contract.d.ts +27 -0
  10. package/dist/contracts/ci.contract.d.ts.map +1 -1
  11. package/dist/contracts/ci.contract.js +3 -1
  12. package/dist/contracts/ci.contract.js.map +1 -1
  13. package/dist/contracts/extensions.contract.d.ts +1 -0
  14. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  15. package/dist/contracts/gate.contract.d.ts +47 -0
  16. package/dist/contracts/gate.contract.d.ts.map +1 -0
  17. package/dist/contracts/gate.contract.js +9 -0
  18. package/dist/contracts/gate.contract.js.map +1 -0
  19. package/dist/contracts/sessions.contract.d.ts +3 -0
  20. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  21. package/dist/contracts/settings.contract.d.ts +10 -0
  22. package/dist/contracts/settings.contract.d.ts.map +1 -1
  23. package/dist/contracts/system.contract.d.ts +48 -4
  24. package/dist/contracts/system.contract.d.ts.map +1 -1
  25. package/dist/contracts/workspace.contract.d.ts +3 -1
  26. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  27. package/dist/events.d.ts +127 -2
  28. package/dist/events.d.ts.map +1 -1
  29. package/dist/events.js +23 -2
  30. package/dist/events.js.map +1 -1
  31. package/dist/hostnames.d.ts +2 -0
  32. package/dist/hostnames.d.ts.map +1 -1
  33. package/dist/hostnames.js +2 -0
  34. package/dist/hostnames.js.map +1 -1
  35. package/dist/index.d.ts +165 -3
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/model-order.d.ts.map +1 -1
  40. package/dist/model-order.js +25 -6
  41. package/dist/model-order.js.map +1 -1
  42. package/dist/schemas.d.ts +130 -1
  43. package/dist/schemas.d.ts.map +1 -1
  44. package/dist/schemas.js +55 -3
  45. package/dist/schemas.js.map +1 -1
  46. package/dist/session-names.d.ts +8 -0
  47. package/dist/session-names.d.ts.map +1 -0
  48. package/dist/session-names.js +17 -0
  49. package/dist/session-names.js.map +1 -0
  50. package/package.json +13 -2
  51. package/src/contracts/automations.contract.ts +13 -0
  52. package/src/contracts/ci.contract.ts +6 -1
  53. package/src/contracts/gate.contract.ts +19 -0
  54. package/src/events.ts +66 -7
  55. package/src/hostnames.ts +16 -0
  56. package/src/index.ts +3 -0
  57. package/src/model-order.test.ts +29 -12
  58. package/src/model-order.ts +49 -17
  59. package/src/quick-model.test.ts +2 -2
  60. package/src/schemas.test.ts +14 -0
  61. package/src/schemas.ts +204 -10
  62. package/src/session-names.ts +44 -0
package/src/hostnames.ts CHANGED
@@ -14,6 +14,22 @@ export const sandboxHostname = (id: string, zone: string): string => `${sandboxS
14
14
  // The container sshd hostname the desktop-sync (Mutagen) reaches over the sandbox tunnel: `ssh-<id>.<zone>`.
15
15
  export const sshHostname = (id: string, zone: string): string => `ssh-${id}.${zone}`;
16
16
 
17
+ /* The LOOPBACK name: `local-<id>.<zone>`, an A record pointing at 127.0.0.1.
18
+ *
19
+ * A public DNS name for a private address looks odd until you ask what the alternative is. A browser on the
20
+ * same machine as the sandbox can reach its daemon in microseconds instead of crossing to a Cloudflare edge
21
+ * and back — but only over HTTPS, because Safari refuses http://127.0.0.1 from an HTTPS page as mixed content
22
+ * (WebKit 171934, open since 2017), and HTTPS needs a name a public CA will certify. An IP literal cannot have
23
+ * one; this can. The daemon holds the key and gets the certificate by proving control of the zone over
24
+ * DNS-01 (there is nothing on the public internet for a CA to connect to).
25
+ *
26
+ * It discloses nothing: the id is already the leading label of the sandbox's public hostname, and the address
27
+ * it resolves to is every machine's own loopback. */
28
+ export const localHostname = (id: string, zone: string): string => `local-${id}.${zone}`;
29
+
30
+ // What that record points at, and the reason it is safe to publish: every resolver on earth gets 127.0.0.1.
31
+ export const LOCAL_ADDRESS = "127.0.0.1";
32
+
17
33
  // A per-host SSH tunnel's Cloudflare tunnel NAME (its hostname reuses sshHostname with the host-ssh id).
18
34
  export const hostSshTunnelName = (id: string): string => `host-ssh-${id}`;
19
35
 
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.
@@ -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
 
@@ -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", () => {
@@ -29,6 +29,11 @@ test("a payload from a build that predates a toggle parses, with the new toggle
29
29
  autoLand: true,
30
30
  autoResumeOnLimit: false,
31
31
  resumeAfterOutage: true,
32
+ autoResumeOnRestart: true,
33
+ gateCommand: "",
34
+ gateQuietMs: 20_000,
35
+ gateTimeoutMs: 900_000,
36
+ gateAutoFix: true,
32
37
  });
33
38
  });
34
39
 
@@ -61,6 +66,15 @@ test("an empty object is the full default settings object", () => {
61
66
  // On, unlike the limit resume beside it: an outage resume spends nothing the dead turn hadn't already
62
67
  // committed, and the turns it saves are the unattended ones nobody is watching to restart by hand.
63
68
  resumeAfterOutage: true,
69
+ // On: a daemon restart is usually intentic's own doing (an image update, an approved environment
70
+ // change), not the user's decision, so the turn it interrupted resumes rather than staying stuck.
71
+ autoResumeOnRestart: true,
72
+ // Empty disables the landing gate until the owner supplies this workspace's verification command.
73
+ gateCommand: "",
74
+ gateQuietMs: 20_000,
75
+ gateTimeoutMs: 900_000,
76
+ // Once a gate is configured, a red verdict wakes one fixer by default.
77
+ gateAutoFix: true,
64
78
  });
65
79
  });
66
80
 
package/src/schemas.ts CHANGED
@@ -715,6 +715,45 @@ export const SandboxSettingsSchema = z.object({
715
715
  * (automation wakes, Discord, webhooks), which no browser-held preference could ever rescue. It is the same
716
716
  * reasoning that leaves the auth resume ungated: this is the provider's failure, not the user's decision. */
717
717
  resumeAfterOutage: z.boolean().default(true),
718
+ /* When the daemon dies under a running turn, re-run that turn once it is back (agent/turn-journal.ts records
719
+ * 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,
722
+ * every environment approval and every dev-sandbox.sh swap. Approving the Dockerfile change an agent asked
723
+ * for must not cost the run that asked for it, and a user who just clicked Approve is in the room expecting
724
+ * the work to continue, not a second button.
725
+ *
726
+ * OFF still records the interruption: the fleet card reads `interrupted` (see AgentStatusSchema) and an
727
+ * automation's row shows an `interrupted` run — nothing is re-run, but nothing is silently lost either. */
728
+ autoResumeOnRestart: z.boolean().default(true),
729
+ /* THE LANDING GATE — the check command run over the COMPOSITE of landed work, once the fleet goes quiet.
730
+ * Empty ⇒ no gate at all, which is the default: only the owner knows what verifies this workspace, and a
731
+ * guessed command that fails on a fresh clone would read as the gate finding a bug on its first run.
732
+ *
733
+ * Configuring it is the opt-in, which is why there is no separate enable flag to disagree with it. The
734
+ * command runs in the workspace root through `sh -c`, exactly as a terminal would run it (see gate/gate.ts
735
+ * for why this is NOT an automation guard: a suite outlives GUARD_TIMEOUT_MS, and a timed-out guard reads
736
+ * as "skipped" — a silent green over a suite that never finished). */
737
+ gateCommand: z.string().max(500).default(""),
738
+ /* How long after a land the gate waits before running. A landing burst is the case this exists for: five
739
+ * agents finishing within a minute of each other are five lands, and a gate that ran per land would spend
740
+ * five suites to answer about four trees nobody will ever push. Every land re-arms the timer, so the run
741
+ * happens once, on the tree the user is about to review.
742
+ *
743
+ * It counts from the last LAND and nothing else — explicitly not "until the fleet is idle". Agents here run
744
+ * for hours, so a fleet of twenty with one long runner would never present a quiet moment, and a gate that
745
+ * waited for one would only ever fire when clicked (gate/gate.ts). */
746
+ gateQuietMs: z.number().min(0).max(600_000).default(20_000),
747
+ // Ceiling on one gate run, after which the child is killed and the verdict is `failed` with `timedOut`.
748
+ // Never a pass: a suite that did not finish has not said anything about the tree, and the one thing this
749
+ // gate exists to prevent is a green light nobody earned.
750
+ gateTimeoutMs: z.number().min(60_000).max(3_600_000).default(900_000),
751
+ /* 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
753
+ * that the spend here is the POINT: a red gate whose fix waits for the user to notice has moved the CI
754
+ * round-trip into the workspace without removing it from the user's day. One attempt per verdict, so a
755
+ * command that fails for a reason no agent can fix costs one turn, not a loop (gate/gate.ts). */
756
+ gateAutoFix: z.boolean().default(true),
718
757
  });
719
758
  export type SandboxSettings = z.infer<typeof SandboxSettingsSchema>;
720
759
 
@@ -1204,8 +1243,9 @@ export type WorkspaceClassification = z.infer<typeof WorkspaceClassificationSche
1204
1243
  // within `text` so clients highlight without re-finding the needle.
1205
1244
  export const WorkspaceSearchQuerySchema = z.object({
1206
1245
  query: z.string().min(2).max(512),
1207
- // Search verbs only — anchor/git verbs (outline, context, log, who, …) are CLI-only surface.
1208
- mode: z.enum(["q", "find", "files", "def", "refs", "sym", "ast", "ask"]).optional(),
1246
+ // Search verbs only — anchor/git verbs (outline, context, log, who, …) are CLI-only surface. Natural language
1247
+ // has no verb of its own: `q` classifies the query and answers it semantically when the words call for it.
1248
+ mode: z.enum(["q", "find", "files", "def", "refs", "sym", "ast"]).optional(),
1209
1249
  includeIgnored: z.stringbool().optional(),
1210
1250
  limit: z.coerce.number().int().positive().optional(),
1211
1251
  after: z.string().optional(),
@@ -1233,6 +1273,9 @@ export const WorkspaceSearchFreshnessSchema = z.object({
1233
1273
  state: z.enum(["fresh", "building", "stale"]),
1234
1274
  ageMs: z.number().optional(),
1235
1275
  progress: z.number().optional(),
1276
+ // How many files the index has not caught up with, when it is stale. A count is reportable; "stale" alone
1277
+ // reads as a warning about the answer, which it almost never is.
1278
+ behind: z.number().optional(),
1236
1279
  });
1237
1280
  export type WorkspaceSearchFreshness = z.infer<typeof WorkspaceSearchFreshnessSchema>;
1238
1281
  export const WorkspaceSearchResultSchema = z.object({
@@ -1244,8 +1287,12 @@ export const WorkspaceSearchResultSchema = z.object({
1244
1287
  truncated: z.boolean(),
1245
1288
  cursor: z.string().optional(),
1246
1289
  hint: z.string().optional(),
1247
- // Code-graph neighbors of the top hits (definition anchors + ready-made follow-up commands).
1290
+ // Code-graph neighbors of the top hits (definition anchors + the strongest caller of each).
1248
1291
  related: z.array(z.string()).optional(),
1292
+ // Ranked `path:line` anchors that placed but were NOT shown, best first — the answer often sits at rank 5–13,
1293
+ // behind groups the budget spent itself on. The text surface has always printed this map; a JSON caller could
1294
+ // not see it, so it had to page through `cursor` to learn what the terminal was told up front.
1295
+ candidates: z.array(z.string()).optional(),
1249
1296
  // Run provenance for benchmarking: retrieval stages DISABLED this invocation (absent = full pipeline).
1250
1297
  features: z.array(z.string()).optional(),
1251
1298
  });
@@ -1999,9 +2046,15 @@ export const AutomationApprovalIdParamSchema = z.object({ id: z.string() });
1999
2046
 
2000
2047
  export const AutomationRunSchema = z.object({
2001
2048
  at: z.number(),
2002
- // skipped = the guard said no; error = the guard passed but the agent turn surfaced an error.
2003
- outcome: z.enum(["completed", "skipped", "error"]),
2049
+ // skipped = the guard said no; error = the guard passed but the agent turn surfaced an error; interrupted =
2050
+ // the daemon died mid-wake, so the run reached no outcome of its own (see agent/turn-journal.ts). Without
2051
+ // that last one an interrupted fire records NOTHING and simply vanishes from the row's history, which reads
2052
+ // as "it never fired" — the one reading a 3 a.m. automation must not be given.
2053
+ outcome: z.enum(["completed", "skipped", "error", "interrupted"]),
2004
2054
  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(),
2005
2058
  });
2006
2059
  export type AutomationRun = z.infer<typeof AutomationRunSchema>;
2007
2060
 
@@ -2038,8 +2091,18 @@ export const PipelineRunSchema = z.object({
2038
2091
  project: z.string(),
2039
2092
  // The vendor's numeric run/pipeline id — what rerun/cancel address.
2040
2093
  runId: z.number(),
2041
- // github's display_title (the commit/PR line); gitlab's pipeline name when set. Absent the view shows ref@sha.
2094
+ // The run's headline: github's display_title (the commit subject, or the PR title when a PR triggered it),
2095
+ // gitlab's pipeline name or the head commit's subject. Absent ⇒ the view falls back to ref@sha.
2042
2096
  title: z.string().optional(),
2097
+ // Who the vendor credits for the run — the actor who set it off, matching what both vendors' own UIs
2098
+ // show. The avatar is a vendor-hosted URL; absent ⇒ the view draws the author's initials instead.
2099
+ authorName: z.string().optional(),
2100
+ authorAvatarUrl: z.string().optional(),
2101
+ // What set the run off, in the vendor's own vocabulary: gitlab's pipeline `source` (push, schedule,
2102
+ // merge_request_event, web, api, trigger…) or github's `event` (push, pull_request, schedule,
2103
+ // workflow_dispatch…). Left raw rather than flattened into a shared enum — the vendor's word is the
2104
+ // precise one, and the view only calls it out when it isn't the everyday push.
2105
+ trigger: z.string().optional(),
2043
2106
  branch: z.string(),
2044
2107
  sha: z.string(),
2045
2108
  status: PipelineStatusSchema,
@@ -2052,6 +2115,28 @@ export const PipelineRunSchema = z.object({
2052
2115
  });
2053
2116
  export type PipelineRun = z.infer<typeof PipelineRunSchema>;
2054
2117
 
2118
+ // One job inside a pipeline run. The view fetches these lazily (one extra call per visible run) so the list
2119
+ // endpoint stays cheap. Both GitHub Actions jobs and GitLab CI jobs normalize onto these fields.
2120
+ // `stage` is GitLab's native sequential grouping and is absent on GitHub — the Actions jobs API exposes no
2121
+ // `stage` and no `needs`, so the view instead layers GitHub jobs into execution waves off the timestamps
2122
+ // below (overlapping runtimes ⇒ ran in parallel). Both are epoch ms; absent while a job is still queued.
2123
+ export const PipelineJobSchema = z.object({
2124
+ name: z.string(),
2125
+ status: PipelineStatusSchema,
2126
+ stage: z.string().optional(),
2127
+ startedAt: z.number().optional(),
2128
+ finishedAt: z.number().optional(),
2129
+ durationSeconds: z.number().optional(),
2130
+ // The job's page on its host — the shortest path from "this step failed" to the log that says why.
2131
+ webUrl: z.string().optional(),
2132
+ });
2133
+ export type PipelineJob = z.infer<typeof PipelineJobSchema>;
2134
+
2135
+ export const CiJobsResponseSchema = z.object({
2136
+ jobs: z.array(PipelineJobSchema),
2137
+ });
2138
+ export type CiJobsResponse = z.infer<typeof CiJobsResponseSchema>;
2139
+
2055
2140
  // One mapped repo's CI wiring state. `hookWarning` is the manual-setup story when webhook registration was
2056
2141
  // refused (token scope, role) or impossible (no public URL): what happened plus the target URL + secret to
2057
2142
  // paste into the repo's webhook settings — the git-access sshRegistrationWarning pattern.
@@ -2069,9 +2154,17 @@ export const CiRunsResponseSchema = z.object({
2069
2154
  repos: z.array(CiRepoSchema),
2070
2155
  // Newest first, across all mapped repos.
2071
2156
  runs: z.array(PipelineRunSchema),
2157
+ // When the owner last opened the pipelines view. Rides the runs response so the rail can decide what is
2158
+ // NEW without a second call — a breakage older than this has already been seen and must not badge again.
2159
+ // Absent ⇒ never opened, so everything counts as unseen.
2160
+ seenAt: z.number().optional(),
2072
2161
  });
2073
2162
  export type CiRunsResponse = z.infer<typeof CiRunsResponseSchema>;
2074
2163
 
2164
+ // Stamping the view as read hands back the timestamp it wrote, so the client updates without a refetch.
2165
+ export const CiSeenResponseSchema = z.object({ seenAt: z.number() });
2166
+ export type CiSeenResponse = z.infer<typeof CiSeenResponseSchema>;
2167
+
2075
2168
  // rerun/cancel/fix address a run by repo + vendor id; the daemon re-resolves repo → project + token per call,
2076
2169
  // so a stale card can't act on a project the workspace no longer maps to.
2077
2170
  export const CiRunParamSchema = z.object({ repo: z.string(), runId: z.number() });
@@ -2081,6 +2174,97 @@ export type CiRunParam = z.infer<typeof CiRunParamSchema>;
2081
2174
  export const CiFixResponseSchema = z.object({ conversationId: z.string() });
2082
2175
  export type CiFixResponse = z.infer<typeof CiFixResponseSchema>;
2083
2176
 
2177
+ /* ---- the landing gate: the workspace's own verdict on the composite of landed work ----
2178
+ *
2179
+ * WHERE THIS SITS, and why it is not one of the four other places it could:
2180
+ *
2181
+ * A fleet of 5-20 agents lands work into the main tree as UNCOMMITTED changes (agents/land.ts), the user
2182
+ * reviews and commits it by parts, pushes, and CI answers minutes later. This gate front-runs that answer by
2183
+ * asking the same question of the same artifact, before the push.
2184
+ *
2185
+ * NOT inside an agent's turn. An isolated worktree's `node_modules` reads as the MAIN checkout's, so a
2186
+ * monorepo's workspace links resolve cross-package imports to /work's sources rather than the worktree's edited
2187
+ * ones (agents/worktrees.ts). A suite run in a worktree therefore tests the agent's edits against everyone
2188
+ * else's UNEDITED siblings: it invents failures that don't exist and passes changes that break on the
2189
+ * composite, and two agents editing one contract each go green alone and red together. The composite is the
2190
+ * only honest artifact, and it exists in exactly one place — the main working tree.
2191
+ *
2192
+ * NOT at commit. The user commits BY PARTS, and a suite reads the worktree, not the index — so a verdict taken
2193
+ * at a partial commit describes a tree that never gets pushed as such. Commit is where a verdict is DISPLAYED
2194
+ * (ReviewPanel's badge), computed earlier.
2195
+ *
2196
+ * NOT at push. By then HEAD has moved, per-path attribution has expired (agents/origins.ts), and the agents may
2197
+ * be archived with their worktrees reclaimed — so the fix starts cold, in the same position `/ci/fix` is in.
2198
+ * That saves the CI round-trip and none of the context switch.
2199
+ *
2200
+ * So: after the land, before the staging — the one window where the artifact is what CI will see, attribution
2201
+ * is still live, and nobody is waiting on it. */
2202
+
2203
+ /* What the gate has to say about the tree right now.
2204
+ *
2205
+ * idle — no command configured, or nothing has run yet.
2206
+ * armed — work landed; the quiet period is counting down (see gateQuietMs).
2207
+ * running — the check is live. `output` grows as it streams.
2208
+ * passed — exited 0 over `fingerprint`.
2209
+ * failed — exited non-zero, or was killed by gateTimeoutMs (`timedOut`). The state a fix answers.
2210
+ * error — the gate itself could not run: the command was not spawnable. NOT a fix-able failure, because
2211
+ * there is nothing wrong with the code — the gate is misconfigured, and saying "tests failed"
2212
+ * would send an agent hunting a bug that isn't there.
2213
+ * cancelled — the user stopped the run, or the tree moved under it.
2214
+ */
2215
+ export const GateStatusSchema = z.enum(["idle", "armed", "running", "passed", "failed", "error", "cancelled"]);
2216
+ export type GateStatus = z.infer<typeof GateStatusSchema>;
2217
+
2218
+ // An agent whose landed work the failure implicates. `paths` are its attributed files that the check's own
2219
+ // output NAMED — empty when the output named none of them, which is the honest shape for a failure that could
2220
+ // not be pinpointed (an integration break between two deltas, a suite that prints no paths at all): the agent
2221
+ // is listed because its work is in the tree under test, not because anything accused it.
2222
+ export const GateAgentSchema = z.object({
2223
+ agentId: z.string(),
2224
+ title: z.string().optional(),
2225
+ provider: AgentProviderSchema.optional(),
2226
+ paths: z.array(z.string()),
2227
+ });
2228
+ export type GateAgent = z.infer<typeof GateAgentSchema>;
2229
+
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.
2234
+ export const GateFixSchema = z.object({
2235
+ startedAt: z.number(),
2236
+ sessionId: z.string().optional(),
2237
+ // `running` while the turn streams; `done` when it ended cleanly, whatever the re-check then said;
2238
+ // `error` when the turn itself failed (a provider outage, no credential), which is worth distinguishing
2239
+ // because it is the one case where re-running the fix could still help.
2240
+ outcome: z.enum(["running", "done", "error"]),
2241
+ detail: z.string().optional(),
2242
+ });
2243
+ export type GateFix = z.infer<typeof GateFixSchema>;
2244
+
2245
+ export const GateVerdictSchema = z.object({
2246
+ status: GateStatusSchema,
2247
+ // The command this verdict ran, echoed rather than read back from settings: a verdict read after the
2248
+ // setting changed still has to say what produced it.
2249
+ command: z.string(),
2250
+ startedAt: z.number().optional(),
2251
+ finishedAt: z.number().optional(),
2252
+ exitCode: z.number().optional(),
2253
+ timedOut: z.boolean().optional(),
2254
+ // The check's own output, tail-capped (GATE_OUTPUT_BYTES). The tail, not the head: a suite's verdict and
2255
+ // its failure summary are at the end, and a head-capped buffer of a chatty build is all progress lines.
2256
+ output: z.string(),
2257
+ /* WHICH TREE this verdict is about — HEAD plus the shape of every repo's uncommitted content. Recomputed
2258
+ * on read: when it no longer matches, the verdict is `stale` and the badge says so instead of asserting a
2259
+ * green light over a tree that has since moved. This is what keeps a passed verdict from outliving its
2260
+ * subject when the user edits, discards, or commits half of it. */
2261
+ fingerprint: z.string(),
2262
+ stale: z.boolean(),
2263
+ implicated: z.array(GateAgentSchema),
2264
+ fix: GateFixSchema.optional(),
2265
+ });
2266
+ export type GateVerdict = z.infer<typeof GateVerdictSchema>;
2267
+
2084
2268
  // ---- drafts: agent-proposed posts awaiting owner approval (.intentic/drafts/<id>.json) ----
2085
2269
  // One JSON file per draft. The AGENT creates drafts with its normal file tools — it can't call daemon routes,
2086
2270
  // the same split as the environment proposal — while the daemon edits/deletes them on the owner's behalf, so
@@ -2206,8 +2390,12 @@ export const PortForwardResultSchema = z.object({ previewUrl: z.string().optiona
2206
2390
  export type PortForwardResult = z.infer<typeof PortForwardResultSchema>;
2207
2391
 
2208
2392
  // ---- terminal ----
2209
- // EVERY attachable tmux session in the sandbox the web app's ONE global terminal panel (the interactive I/O
2210
- // is the /system/terminal WebSocket, not oRPC): `shell` = a web-* session the user opened (numbered pill),
2393
+ // EVERY live surface in the sandbox the web app's ONE global panel can show. Mostly tmux sessions (the
2394
+ // interactive I/O is the /system/terminal WebSocket, not oRPC), plus the agent's browser, which is not a
2395
+ // terminal at all — no more than a `process` row is — but IS the same question: what is running right now,
2396
+ // and can I look at it? One list, because the panel that answers that question is one panel.
2397
+ //
2398
+ // `shell` = a web-* session the user opened (numbered pill),
2211
2399
  // `panel` = a panel-* dev-server session (labeled by its panel key, started via Start; running:false =
2212
2400
  // untracked, e.g. a finished one-shot job's lingering shell), `agent` = an agent-* session the Claude agent's
2213
2401
  // Bash commands run in (live-watchable, AI-marked in the UI; running:false once every window is a finished
@@ -2218,7 +2406,11 @@ export type PortForwardResult = z.infer<typeof PortForwardResultSchema>;
2218
2406
  // process (a lingering shell after a crash reads false). A process row that maps to an installed extension's
2219
2407
  // declared process carries extensionId+processName, the address for its /extensions start/stop routes. The
2220
2408
  // `{name}` kill-route param is a bare string validated in the handler (a bad name is a BAD_REQUEST) since the
2221
- // same charset gates a `tmux kill-session -t` shell-out.
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.
2222
2414
  //
2223
2415
  // `activityAt` (epoch ms of the session's last output) and `exitCode` (the LAST window's exit status, absent
2224
2416
  // while that pane still lives) are what let a finished session be READ rather than merely listed: the panel's
@@ -2228,12 +2420,14 @@ export type PortForwardResult = z.infer<typeof PortForwardResultSchema>;
2228
2420
  export const TerminalSessionSchema = z.object({
2229
2421
  name: z.string(),
2230
2422
  label: z.string().optional(),
2231
- kind: z.enum(["shell", "panel", "agent", "job", "process"]),
2423
+ kind: z.enum(["shell", "panel", "agent", "job", "process", "browser"]),
2232
2424
  running: z.boolean(),
2233
2425
  activityAt: z.number(),
2234
2426
  exitCode: z.number().optional(),
2235
2427
  extensionId: z.string().optional(),
2236
2428
  processName: z.string().optional(),
2429
+ // Browser sessions only: the page the agent is on right now.
2430
+ url: z.string().optional(),
2237
2431
  });
2238
2432
  export const TerminalsListSchema = z.object({ sessions: z.array(TerminalSessionSchema) });
2239
2433
  export type TerminalsList = z.infer<typeof TerminalsListSchema>;