@intentic/sandbox-contract 1.158.0 → 1.159.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 (58) hide show
  1. package/dist/agent-catalog.d.ts +9 -0
  2. package/dist/agent-catalog.d.ts.map +1 -1
  3. package/dist/agent-catalog.js +13 -1
  4. package/dist/agent-catalog.js.map +1 -1
  5. package/dist/contracts/agent.contract.d.ts +48 -2
  6. package/dist/contracts/agent.contract.d.ts.map +1 -1
  7. package/dist/contracts/agents.contract.d.ts +53 -0
  8. package/dist/contracts/agents.contract.d.ts.map +1 -1
  9. package/dist/contracts/automations.contract.d.ts +7 -0
  10. package/dist/contracts/automations.contract.d.ts.map +1 -1
  11. package/dist/contracts/system.contract.d.ts +20 -4
  12. package/dist/contracts/system.contract.d.ts.map +1 -1
  13. package/dist/contracts/workspace.contract.d.ts +5 -0
  14. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  15. package/dist/contracts/workspace.contract.js +2 -1
  16. package/dist/contracts/workspace.contract.js.map +1 -1
  17. package/dist/events.d.ts +76 -0
  18. package/dist/events.d.ts.map +1 -1
  19. package/dist/events.js +5 -3
  20. package/dist/events.js.map +1 -1
  21. package/dist/hostnames.d.ts +2 -0
  22. package/dist/hostnames.d.ts.map +1 -1
  23. package/dist/hostnames.js +27 -1
  24. package/dist/hostnames.js.map +1 -1
  25. package/dist/index.d.ts +139 -6
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +6 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/model-order.d.ts +1 -0
  30. package/dist/model-order.d.ts.map +1 -1
  31. package/dist/model-order.js +2 -0
  32. package/dist/model-order.js.map +1 -1
  33. package/dist/path-refs.d.ts +4 -0
  34. package/dist/path-refs.d.ts.map +1 -0
  35. package/dist/path-refs.js +18 -0
  36. package/dist/path-refs.js.map +1 -0
  37. package/dist/routes.d.ts +8 -0
  38. package/dist/routes.d.ts.map +1 -0
  39. package/dist/routes.js +39 -0
  40. package/dist/routes.js.map +1 -0
  41. package/dist/schemas.d.ts +53 -0
  42. package/dist/schemas.d.ts.map +1 -1
  43. package/dist/schemas.js +17 -2
  44. package/dist/schemas.js.map +1 -1
  45. package/package.json +2 -2
  46. package/src/agent-catalog.ts +45 -1
  47. package/src/contracts/workspace.contract.ts +6 -0
  48. package/src/events.ts +42 -5
  49. package/src/hostnames.test.ts +22 -0
  50. package/src/hostnames.ts +40 -1
  51. package/src/index.ts +14 -0
  52. package/src/model-order.test.ts +34 -1
  53. package/src/model-order.ts +16 -0
  54. package/src/path-refs.test.ts +42 -0
  55. package/src/path-refs.ts +43 -0
  56. package/src/routes.test.ts +83 -0
  57. package/src/routes.ts +84 -0
  58. package/src/schemas.ts +49 -3
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "vitest";
2
- import { compareModelIds, familyOf, releaseOf, tierRankOf } from "./model-order.js";
2
+ import { compareModelIds, compareUnrankedModelIds, familyOf, releaseOf, tierRankOf } from "./model-order.js";
3
3
 
4
4
  /* The order every provider's catalog is served and browsed in. The rule exists because only Anthropic publishes
5
5
  * a ranking: the OpenAI-compatible endpoints behind Codex, Gemini, Kimi and Grok hand back a SET, and taking
@@ -26,6 +26,29 @@ test("lands the same models at the head and the tail whichever order the endpoin
26
26
  }
27
27
  });
28
28
 
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.
33
+ const arrivals = [
34
+ ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"],
35
+ ["gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-sol"],
36
+ ["gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"],
37
+ ];
38
+ const heads = arrivals.map((arrival) => arrival.toSorted(compareUnrankedModelIds)[0]);
39
+
40
+ expect(new Set(heads).size).toBe(1);
41
+ // Same rule, so ranking still outranks the tiebreak: the mini rung stays at the tail, under every sibling.
42
+ expect(["gpt-5.4-mini", ...arrivals[0]!].toSorted(compareUnrankedModelIds).at(-1)).toBe("gpt-5.4-mini");
43
+ });
44
+
45
+ test("leaves a RANKED catalog's ties alone — the id tiebreak is for sets, and Anthropic publishes an opinion", () => {
46
+ // compareUnrankedModelIds would seat claude-fable-5 ahead of claude-opus-5 on the id alone. Anthropic's
47
+ // catalog arrives newest-first, so that order is a fact about the provider, not a leftover to be broken.
48
+ expect(["claude-opus-5", "claude-fable-5"].toSorted(compareModelIds)).toEqual(["claude-opus-5", "claude-fable-5"]);
49
+ expect(["claude-opus-5", "claude-fable-5"].toSorted(compareUnrankedModelIds)).toEqual(["claude-fable-5", "claude-opus-5"]);
50
+ });
51
+
29
52
  test("reads each vendor's tier vocabulary, not just Claude's", () => {
30
53
  expect(["gemini-3-flash", "gemini-3-flash-lite", "gemini-3-pro"].toSorted(compareModelIds)).toEqual([
31
54
  "gemini-3-pro",
@@ -57,6 +80,16 @@ test("leads with a family carrying no tier word at all, so a brand-new flagship
57
80
  expect(["claude-sonnet-5", "claude-mythos-1", "claude-opus-5"].toSorted(compareModelIds)[0]).toBe("claude-mythos-1");
58
81
  });
59
82
 
83
+ test("files a re-served open-weights model on the cheap rung, not at the head of the catalog it visits", () => {
84
+ // Google's channel vends gpt-oss beside Gemini and Claude. It carries no tier word of its own, so the
85
+ // lead-the-unknown rule would open that whole section on it — above Opus.
86
+ expect(["gpt-oss-120b-medium", "claude-opus-4-6-thinking", "gemini-pro-agent"].toSorted(compareModelIds)).toEqual([
87
+ "claude-opus-4-6-thinking",
88
+ "gemini-pro-agent",
89
+ "gpt-oss-120b-medium",
90
+ ]);
91
+ });
92
+
60
93
  test("keeps the arrival order between ids the rule cannot separate — Anthropic's catalog IS ranked", () => {
61
94
  // Same tier, same version: nothing here outranks the order the provider itself reported.
62
95
  expect(["claude-opus-5", "claude-fable-5"].toSorted(compareModelIds)).toEqual(["claude-opus-5", "claude-fable-5"]);
@@ -97,6 +97,10 @@ const TIER_RANK: Readonly<Record<string, number>> = {
97
97
  mini: 1,
98
98
  // Efficient: the cheap/fast end, the rung whose whole purpose is to cost less than the one above it.
99
99
  haiku: 2,
100
+ // An open-weights model a vendor re-serves beside its own (gpt-oss-120b on Google's channel) is that rung by
101
+ // construction: it is there to be the free/cheap option next to the frontier line, never the flagship. Without
102
+ // it the id carries no tier word at all and would LEAD the section it sits in.
103
+ oss: 2,
100
104
  lite: 2,
101
105
  nano: 2,
102
106
  fast: 2,
@@ -122,3 +126,15 @@ export const tierRankOf = (family: string): number => {
122
126
  // is stable, so two ids this rule cannot separate keep the order they arrived in (for Claude, the provider's own).
123
127
  export const compareModelIds = (left: string, right: string): number =>
124
128
  tierRankOf(familyOf(left)) - tierRankOf(familyOf(right)) || compareRelease(releaseOf(left), releaseOf(right));
129
+
130
+ /* The order for a catalog its endpoint published as a SET — Codex, Gemini, Kimi and Grok, i.e. everything but
131
+ * Anthropic's ranked list. Falling back on arrival order is what the rule above does with a tie, and for a RANKED
132
+ * catalog that is exactly right: the tie is the provider's own opinion, so claude-opus-5 stays ahead of
133
+ * 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.
137
+ *
138
+ * So a set breaks its own ties on the id. Which sibling that seats first is arbitrary — but it is the same
139
+ * arbitrary answer every refresh, which is the property `default` actually needs. */
140
+ export const compareUnrankedModelIds = (left: string, right: string): number => compareModelIds(left, right) || left.localeCompare(right);
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { rankRefCandidates, referenceTails } from "./path-refs.js";
3
+
4
+ describe("referenceTails", () => {
5
+ test("offers the reference itself first, then shorter tails of it", () => {
6
+ expect(referenceTails("_apps/web/src/foo.ts", "/work")).toEqual(["_apps/web/src/foo.ts", "web/src/foo.ts", "src/foo.ts"]);
7
+ });
8
+
9
+ test("anchors an absolute path under the workspace root", () => {
10
+ expect(referenceTails("/work/_apps/foo.ts", "/work")[0]).toBe("_apps/foo.ts");
11
+ });
12
+
13
+ test("strips an isolated turn's worktree lead by dropping segments", () => {
14
+ // The worktree mirrors the workspace layout below /history/worktrees/<id>, so the real path is a tail.
15
+ expect(referenceTails("/history/worktrees/agent-7/_apps/foo.ts", "/work")).toContain("_apps/foo.ts");
16
+ });
17
+
18
+ test("never cuts down to a bare filename — one `index.ts` is as good as another", () => {
19
+ expect(referenceTails("a/b/index.ts", "/work").at(-1)).toBe("b/index.ts");
20
+ expect(referenceTails("index.ts", "/work")).toEqual([]);
21
+ });
22
+
23
+ test("normalizes a ./ lead and windows separators", () => {
24
+ expect(referenceTails("./src/foo.ts", "/work")).toEqual(["src/foo.ts"]);
25
+ expect(referenceTails("src\\foo.ts", "/work")).toEqual(["src/foo.ts"]);
26
+ });
27
+ });
28
+
29
+ describe("rankRefCandidates", () => {
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.
32
+ expect(rankRefCandidates("pages/Foo.vue", ["app/mypages/Foo.vue", "app/pages/Foo.vue"])).toEqual(["app/pages/Foo.vue"]);
33
+ });
34
+
35
+ test("ranks the shallowest match first — the app's file, not a copy in a fixture tree", () => {
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
+ });
38
+
39
+ test("matches the tail as a whole path too", () => {
40
+ expect(rankRefCandidates("src/foo.ts", ["src/foo.ts", "vendor/src/foo.ts"])).toEqual(["src/foo.ts", "vendor/src/foo.ts"]);
41
+ });
42
+ });
@@ -0,0 +1,43 @@
1
+ /* Which file a NAMED reference means. A path written in prose is only loosely anchored to the workspace: an
2
+ * agent that has been working in `_apps/web/src` writes `pages/workspace/Foo.vue`, and a turn running in an
3
+ * isolated worktree prints `/history/worktrees/<id>/_apps/web/src/foo.ts` — neither is the workspace-relative
4
+ * path the file routes speak, but both END in it.
5
+ *
6
+ * So a reference is resolved by matching progressively shorter TAILS of it against the real tree. The rules
7
+ * live here, in the contract package, because both sides run them: the browser first against the workspace
8
+ * tree it already has cached, then the daemon (/workspace/resolve) against the iq engine's full sweep, which
9
+ * sees the files the capped tree walk left out. Two matchers that disagreed would make a link's destination
10
+ * depend on which one answered. */
11
+
12
+ // How many leading segments a reference may carry that the workspace doesn't (the `/history/worktrees/<id>/`
13
+ // lead of a worktree path is 3; a foreign absolute root is rarely deeper).
14
+ const MAX_DROPS = 6;
15
+ // A tail is never cut down to a bare filename: `index.ts` names a hundred files in a monorepo and picking one
16
+ // at random is worse than not linking. The link grammar never emits a slash-less reference either.
17
+ const MIN_SEGMENTS = 2;
18
+ // Enough candidates for a picker; a reference matching more than this is ambiguous by any measure.
19
+ export const MAX_REF_CANDIDATES = 10;
20
+
21
+ // The tails worth matching, longest (most specific) first. `root` is the container workspace root: a path
22
+ // under it is already the answer minus that lead, and a path under any OTHER absolute root (a worktree) still
23
+ // mirrors the layout below its own lead, which the successive drops strip.
24
+ export const referenceTails = (raw: string, root: string): readonly string[] => {
25
+ const normalized = raw.replaceAll(`\\`, `/`).replace(/^\.\//, ``);
26
+ const anchored = normalized.startsWith(`${root}/`) ? normalized.slice(root.length + 1) : normalized.replace(/^\/+/, ``);
27
+ const segments = anchored.split(`/`).filter((segment) => segment !== `` && segment !== `.`);
28
+ const tails: string[] = [];
29
+ for (let drop = 0; drop <= MAX_DROPS && segments.length - drop >= MIN_SEGMENTS; drop++) {
30
+ tails.push(segments.slice(drop).join(`/`));
31
+ }
32
+ return tails;
33
+ };
34
+
35
+ // The paths that genuinely END in `tail` on a segment boundary, best first — the shared ranking both matchers
36
+ // return their candidates in. Shallowest wins: `pages/Foo.vue` means the app's page, not the copy six
37
+ // directories down in a fixture tree. (The daemon's glob is anchored only at the string level — `**/pages/x.vue`
38
+ // also matches `mypages/x.vue` — so the boundary is enforced here rather than by the pattern.)
39
+ export const rankRefCandidates = (tail: string, paths: readonly string[]): readonly string[] =>
40
+ paths
41
+ .filter((path) => path === tail || path.endsWith(`/${tail}`))
42
+ .toSorted((a, b) => a.split(`/`).length - b.split(`/`).length || a.length - b.length || (a < b ? -1 : 1))
43
+ .slice(0, MAX_REF_CANDIDATES);
@@ -0,0 +1,83 @@
1
+ import { oc } from "@orpc/contract";
2
+ import { describe, expect, it } from "vitest";
3
+ import { SANDBOX_ROUTE_NAMES, SANDBOX_ROUTES, sandboxRouteName } from "./index.js";
4
+ import { contractRoutes, routeNameForRequest } from "./routes.js";
5
+
6
+ const fixture = {
7
+ vpn: {
8
+ list: oc.route({ method: "GET", path: "/vpn" }),
9
+ connect: oc.route({ method: "POST", path: "/vpn/{id}/connect" }),
10
+ },
11
+ system: {
12
+ killTerminal: oc.route({ method: "DELETE", path: "/system/terminals/{name}" }),
13
+ },
14
+ };
15
+
16
+ describe(`contractRoutes`, () => {
17
+ it(`names every procedure <group>.<route>, sorted`, () => {
18
+ expect(contractRoutes(fixture).map((route) => route.name)).toEqual([`system.killTerminal`, `vpn.connect`, `vpn.list`]);
19
+ });
20
+
21
+ it(`carries the wire method and path template`, () => {
22
+ expect(contractRoutes(fixture).find((route) => route.name === `vpn.connect`)).toEqual({
23
+ name: `vpn.connect`,
24
+ method: `POST`,
25
+ path: `/vpn/{id}/connect`,
26
+ });
27
+ });
28
+
29
+ it(`ignores non-procedure members rather than inventing routes for them`, () => {
30
+ expect(contractRoutes({ vpn: { list: fixture.vpn.list, NOT_A_ROUTE: { hello: true } } }).map((r) => r.name)).toEqual([`vpn.list`]);
31
+ });
32
+ });
33
+
34
+ describe(`routeNameForRequest`, () => {
35
+ const routes = contractRoutes(fixture);
36
+
37
+ it(`matches a literal path`, () => {
38
+ expect(routeNameForRequest(routes, `GET`, `/vpn`)).toBe(`vpn.list`);
39
+ });
40
+
41
+ it(`matches a templated segment`, () => {
42
+ expect(routeNameForRequest(routes, `POST`, `/vpn/corp-gw/connect`)).toBe(`vpn.connect`);
43
+ expect(routeNameForRequest(routes, `DELETE`, `/system/terminals/web-1`)).toBe(`system.killTerminal`);
44
+ });
45
+
46
+ it(`strips the query string before matching`, () => {
47
+ expect(routeNameForRequest(routes, `GET`, `/vpn?refresh=1`)).toBe(`vpn.list`);
48
+ });
49
+
50
+ it(`is method-sensitive`, () => {
51
+ expect(routeNameForRequest(routes, `POST`, `/vpn`)).toBeUndefined();
52
+ });
53
+
54
+ it(`never matches a longer or shorter path than the template`, () => {
55
+ expect(routeNameForRequest(routes, `POST`, `/vpn/corp-gw/connect/extra`)).toBeUndefined();
56
+ expect(routeNameForRequest(routes, `POST`, `/vpn/connect`)).toBeUndefined();
57
+ });
58
+
59
+ it(`does not let an empty segment stand in for a param`, () => {
60
+ expect(routeNameForRequest(routes, `DELETE`, `/system/terminals/`)).toBeUndefined();
61
+ });
62
+
63
+ it(`returns undefined for the daemon's hand-written non-contract routes`, () => {
64
+ expect(routeNameForRequest(routes, `GET`, `/health`)).toBeUndefined();
65
+ });
66
+ });
67
+
68
+ describe(`the real sandbox contract`, () => {
69
+ it(`derives a route table with no duplicate names`, () => {
70
+ expect(SANDBOX_ROUTE_NAMES.length).toBe(SANDBOX_ROUTES.length);
71
+ expect(new Set(SANDBOX_ROUTE_NAMES).size).toBe(SANDBOX_ROUTE_NAMES.length);
72
+ });
73
+
74
+ it(`covers every oc.route in the contract`, () => {
75
+ // Guards the walk against a future contract nesting deeper than group → procedure, which would
76
+ // silently advertise fewer routes than the daemon serves.
77
+ expect(SANDBOX_ROUTES.length).toBeGreaterThan(100);
78
+ });
79
+
80
+ it(`resolves a known concrete request back to its contract name`, () => {
81
+ expect(sandboxRouteName(`GET`, `/vpn`)).toBe(`vpn.list`);
82
+ });
83
+ });
package/src/routes.ts ADDED
@@ -0,0 +1,84 @@
1
+ /* The daemon's route surface, named. A sandbox daemon is baked into an image, so the browser talking to it is
2
+ * routinely NEWER than the daemon: a released app plane serves every user's sandbox, whatever image they last
3
+ * pulled, and in local development the web app is always ahead of the last `pnpm build:sandbox`. Both are
4
+ * normal and neither should force an update.
5
+ *
6
+ * What must not happen is the failure being SILENT. A route the daemon predates answers 404, which the browser
7
+ * has no way to tell apart from "you asked for a file that isn't there" — so a missing feature reads as a
8
+ * broken one, and diagnosing it costs an hour of "did the image rebuild?".
9
+ *
10
+ * So the daemon ADVERTISES the routes it implements (the /events hello frame) and the browser compares that
11
+ * against the contract it was itself built with. Everything present on both sides works exactly as before;
12
+ * anything the daemon lacks is a KNOWN, NAMED gap the UI can gate a feature on or explain in an error, instead
13
+ * of a mystery 404. Old daemon + new browser stays fully supported — it just stops being confusing.
14
+ *
15
+ * Route names are `<group>.<route>` (`vpn.list`, `kimi.models`), derived from the contract object both sides
16
+ * import. Nothing is generated and nothing is hand-maintained: adding a route to the contract adds it here.
17
+ *
18
+ * Everything here is a pure function of a contract passed in — index.ts binds them to `sandboxContract` once it
19
+ * is assembled, which is what keeps this module out of an import cycle with it. */
20
+
21
+ // The shape we read off an oRPC contract procedure. `~orpc.route` is the contract metadata oRPC attaches to
22
+ // every `oc.route(...)` procedure; we only need the wire method + path, so this is deliberately structural
23
+ // rather than an import of oRPC's internal types (which are not part of its public surface).
24
+ interface ContractProcedureLike {
25
+ readonly "~orpc": { readonly route?: { readonly method?: string; readonly path?: string } };
26
+ }
27
+
28
+ const procedureRoute = (value: unknown): { method: string; path: string } | undefined => {
29
+ if (typeof value !== "object" || value === null || !("~orpc" in value)) {
30
+ return undefined;
31
+ }
32
+ const { route } = (value as ContractProcedureLike)["~orpc"];
33
+ if (route?.method === undefined || route.path === undefined) {
34
+ return undefined;
35
+ }
36
+ return { method: route.method, path: route.path };
37
+ };
38
+
39
+ // One advertised route: its contract name plus the wire shape, so a concrete request path can be matched back
40
+ // to the name it came from (see routeNameForRequest).
41
+ export interface ContractRoute {
42
+ readonly name: string;
43
+ readonly method: string;
44
+ // The oRPC path template, with `{param}` placeholders — e.g. `/system/terminals/{name}`.
45
+ readonly path: string;
46
+ }
47
+
48
+ // Walk a contract object (two levels: group → procedure) into its flat route list, sorted by name so the
49
+ // advertised array is stable and diffable.
50
+ export const contractRoutes = (contract: Record<string, unknown>): ContractRoute[] => {
51
+ const routes: ContractRoute[] = [];
52
+ for (const [group, procedures] of Object.entries(contract)) {
53
+ if (typeof procedures !== "object" || procedures === null) {
54
+ continue;
55
+ }
56
+ for (const [name, procedure] of Object.entries(procedures as Record<string, unknown>)) {
57
+ const route = procedureRoute(procedure);
58
+ if (route !== undefined) {
59
+ routes.push({ name: `${group}.${name}`, method: route.method, path: route.path });
60
+ }
61
+ }
62
+ }
63
+ return routes.toSorted((a, b) => a.name.localeCompare(b.name));
64
+ };
65
+
66
+ // Does a concrete request path match this route's template? Segment-wise, with `{param}` matching exactly one
67
+ // segment — the same shape oRPC mounts, so a template can never match a longer or shorter path.
68
+ const pathMatches = (template: string, path: string): boolean => {
69
+ const wanted = template.split("/");
70
+ const actual = path.split("/");
71
+ if (wanted.length !== actual.length) {
72
+ return false;
73
+ }
74
+ return wanted.every((segment, index) => (segment.startsWith("{") && segment.endsWith("}") ? actual[index] !== "" : segment === actual[index]));
75
+ };
76
+
77
+ // The contract route a concrete request belongs to, or undefined when the path is not a contract route at all
78
+ // (the daemon also serves hand-written Hono routes like /health and /workspace/raw — those are never gated).
79
+ // The query string is stripped first; callers pass whatever they handed to fetch.
80
+ export const routeNameForRequest = (routes: readonly ContractRoute[], method: string, pathWithQuery: string): string | undefined => {
81
+ const path = pathWithQuery.split("?")[0] ?? pathWithQuery;
82
+ const upper = method.toUpperCase();
83
+ return routes.find((route) => route.method.toUpperCase() === upper && pathMatches(route.path, path))?.name;
84
+ };
package/src/schemas.ts CHANGED
@@ -54,6 +54,24 @@ export type EditorContext = z.infer<typeof EditorContextSchema>;
54
54
  // and filesystem paths — the regex is the injection guard. Shared by the turn input and the attach input.
55
55
  const ConversationIdSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/);
56
56
 
57
+ // Where a conversation came from when nobody typed it into the browser: an automation wake carrying a message
58
+ // from OUTSIDE the sandbox (a Discord mention, a web-chat visitor, a webhook). Such a wake runs as an ordinary
59
+ // isolated conversation — registry entry, worktree, chat tab, land flow — and this is the only thing that
60
+ // distinguishes it on the surface: the card's provenance line and the reason its first prompt is not the
61
+ // user's. Set daemon-side by the dispatcher that received the message; the browser never sends one.
62
+ export const AgentOriginSchema = z.object({
63
+ // The automation whose configured prompt opened the conversation.
64
+ automationId: z.string(),
65
+ // The listener provider that received the message ("discord", "webchat", …) or "webhook" for an event
66
+ // trigger. An open string for the same reason Trigger.provider is: sources are extension-declared.
67
+ provider: z.string(),
68
+ // The external thread it arrived on — a Discord channel id, a widget conversation id. Absent for webhooks.
69
+ channelId: z.string().optional(),
70
+ // Who sent it, as the source names them.
71
+ author: z.string().optional(),
72
+ });
73
+ export type AgentOrigin = z.infer<typeof AgentOriginSchema>;
74
+
57
75
  // How tool calls are gated — the Claude Agent SDK's PermissionMode, narrowed to the four the composer offers
58
76
  // (the SDK also has 'dontAsk'/'auto', which have no UI here). The user picks one per turn AND the agent can
59
77
  // move itself between them mid-turn, so this is both a turn input and the payload of the `mode` frame.
@@ -89,6 +107,10 @@ export const AgentTurnSchema = z
89
107
  // When true, the turn runs in the conversation's isolated git worktree (created lazily on first use)
90
108
  // instead of the shared /work tree — the parallel-agents mode. Requires conversationId.
91
109
  isolated: z.boolean().optional(),
110
+ // Set ONLY by the daemon's own automation dispatchers: this turn opens a conversation on behalf of an
111
+ // outside message rather than a user. Recorded on the registry entry so the fleet can say where the
112
+ // agent came from. Requires conversationId — there is nothing to record it on otherwise.
113
+ origin: AgentOriginSchema.optional(),
92
114
  // The client-held transcript of a conversation that just switched provider/account: seeds the FIRST
93
115
  // turn of the replacement session. The daemon folds it into the prompt as one role-attributed context
94
116
  // preamble for every runtime. Mutually exclusive with sessionId — a resumed session has its context.
@@ -114,6 +136,9 @@ export const AgentTurnSchema = z
114
136
  })
115
137
  .refine((turn) => turn.isolated !== true || turn.conversationId !== undefined, {
116
138
  message: "isolated requires conversationId",
139
+ })
140
+ .refine((turn) => turn.origin === undefined || turn.conversationId !== undefined, {
141
+ message: "origin requires conversationId",
117
142
  });
118
143
  export type AgentTurn = z.infer<typeof AgentTurnSchema>;
119
144
 
@@ -169,6 +194,9 @@ export const AgentSummarySchema = z.object({
169
194
  account: z.string().optional(),
170
195
  // The worktree branch (agent/<id>); absent for a non-isolated (main-tree) conversation.
171
196
  branch: z.string().optional(),
197
+ // Present when the conversation was opened by an outside message rather than by the user (see
198
+ // AgentOriginSchema) — the card's provenance line. Absent ⇒ the user started it.
199
+ origin: AgentOriginSchema.optional(),
172
200
  // The ROOT repo's short base sha — the checkout moment's display identity. Per-repo bases stay
173
201
  // daemon-internal (agents.diff already reports against them).
174
202
  base: z.string().optional(),
@@ -200,7 +228,12 @@ export const AgentSummarySchema = z.object({
200
228
  archivedAt: z.number().optional(),
201
229
  });
202
230
  export type AgentSummary = z.infer<typeof AgentSummarySchema>;
203
- export const AgentsListSchema = z.object({ agents: z.array(AgentSummarySchema) });
231
+ // `rev` is the registry revision this roster was read at — a counter the daemon bumps on every registry change.
232
+ // It is what makes the browser's optimistic writes safe: the fleet is published as full snapshots (last frame
233
+ // wins), so without an ordering stamp a roster READ before a mutation but delivered after it silently puts the
234
+ // mutated agents back. The browser drops any roster older than the newest it has applied, and holds its own
235
+ // pending change until a roster at or past the revision that applied it arrives. See useAgents.ts.
236
+ export const AgentsListSchema = z.object({ agents: z.array(AgentSummarySchema), rev: z.number() });
204
237
  export const AgentIdSchema = z.object({ id: z.string().min(1) });
205
238
  // archive's input: the agents to take off the board. Absent `ids` ⇒ every finished agent that is archivable
206
239
  // right now (the lane header's "Clear"); unarchive always names its ids (a restore, or a bulk archive's undo).
@@ -211,7 +244,9 @@ export const AgentIdsSchema = z.object({ ids: z.array(z.string().min(1)).min(1).
211
244
  // the slower response resurrect what the faster one just filed away — a delta composes where a snapshot races.
212
245
  // Whole summaries rather than ids because the receiving side has to SHOW them (the archive list, and the agent
213
246
  // detail page addressed by id); the ids "Undo" needs come off them for free.
214
- export const AgentsMovedSchema = z.object({ moved: z.array(AgentSummarySchema) });
247
+ // The agents an archive/unarchive actually moved, plus the registry revision that applied the move — the
248
+ // browser holds its optimistic add/remove of exactly these ids until it sees a roster at or past `rev`.
249
+ export const AgentsMovedSchema = z.object({ moved: z.array(AgentSummarySchema), rev: z.number() });
215
250
  export type AgentsMoved = z.infer<typeof AgentsMovedSchema>;
216
251
  // rename's input: the user-chosen display title (bounded like sanitizeTitle's cap).
217
252
  export const AgentRenameSchema = z.object({ id: z.string().min(1), title: z.string().trim().min(1).max(80) });
@@ -795,6 +830,12 @@ export const WorkspaceChildrenSchema = z.object({
795
830
  export type WorkspaceChildren = z.infer<typeof WorkspaceChildrenSchema>;
796
831
  export const WorkspaceFileQuerySchema = z.object({ path: z.string().min(1) });
797
832
  export const WorkspaceFileSchema = z.object({ path: z.string(), content: z.string() });
833
+ // Resolve a file reference an agent (or a compiler, or a terminal) NAMED to the workspace path it means. Prose
834
+ // paths are routinely partial — a model that has been discussing `_apps/web/src` writes
835
+ // `pages/workspace/Foo.vue` — so a clickable mention has to be matched as a path SUFFIX against the real tree,
836
+ // not read as root-relative. `path` is absent when nothing in the workspace ends in that reference.
837
+ export const WorkspaceResolveQuerySchema = z.object({ path: z.string().min(1).max(512) });
838
+ export const WorkspaceResolveSchema = z.object({ path: z.string().optional() });
798
839
  // Direct file management over the /work tree (delete / new folder / rename+move / copy). Byte writes + the
799
840
  // editor's text save go through the plain POST /workspace/upload route (a body doesn't fit oRPC), not here.
800
841
  export const WorkspaceDirSchema = z.object({ path: z.string().min(1) });
@@ -1546,6 +1587,10 @@ export const AutomationApprovalSchema = z.object({
1546
1587
  automationId: z.string(),
1547
1588
  // The event/listener payload the wake would have carried; absent for schedule triggers.
1548
1589
  payload: z.string().optional(),
1590
+ // The provenance + title the held wake would have opened its conversation with — snapshotted alongside the
1591
+ // payload so an approved external wake surfaces on the fleet exactly as an auto one would have.
1592
+ origin: AgentOriginSchema.optional(),
1593
+ title: z.string().optional(),
1549
1594
  createdAt: z.number(),
1550
1595
  });
1551
1596
  export type AutomationApproval = z.infer<typeof AutomationApprovalSchema>;
@@ -1696,7 +1741,8 @@ export type PortForwardResult = z.infer<typeof PortForwardResultSchema>;
1696
1741
  // is the /system/terminal WebSocket, not oRPC): `shell` = a web-* session the user opened (numbered pill),
1697
1742
  // `panel` = a panel-* dev-server session (labeled by its panel key, started via Start; running:false =
1698
1743
  // untracked, e.g. a finished one-shot job's lingering shell), `agent` = an agent-* session the Claude agent's
1699
- // Bash commands run in (live-watchable, AI-marked in the UI), `job` = a job-* session the daemon's terminal
1744
+ // Bash commands run in (live-watchable, AI-marked in the UI; running:false once every window is a finished
1745
+ // command's dead pane, which is what lets the panel sweep it), `job` = a job-* session the daemon's terminal
1700
1746
  // runner executes user-triggered flows in (capability adds, infra check), `process` = a managed background
1701
1747
  // process riding a panel session (an extension's declared processes, dockerd) — surfaced in the panel's
1702
1748
  // background-processes popover with read-only log views, never as a killable tab; running is the actual