@intentic/sandbox-contract 1.301.0 → 1.303.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 (143) hide show
  1. package/dist/contracts/agent.contract.d.ts +6 -9
  2. package/dist/contracts/agent.contract.d.ts.map +1 -1
  3. package/dist/contracts/agents.contract.d.ts +193 -433
  4. package/dist/contracts/agents.contract.d.ts.map +1 -1
  5. package/dist/contracts/agents.contract.js +6 -24
  6. package/dist/contracts/agents.contract.js.map +1 -1
  7. package/dist/contracts/areas.contract.d.ts +24 -0
  8. package/dist/contracts/areas.contract.d.ts.map +1 -0
  9. package/dist/contracts/areas.contract.js +32 -0
  10. package/dist/contracts/areas.contract.js.map +1 -0
  11. package/dist/contracts/automations.contract.d.ts +3 -0
  12. package/dist/contracts/automations.contract.d.ts.map +1 -1
  13. package/dist/contracts/capabilities.contract.d.ts +0 -46
  14. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  15. package/dist/contracts/endpoints.contract.d.ts +132 -0
  16. package/dist/contracts/endpoints.contract.d.ts.map +1 -1
  17. package/dist/contracts/endpoints.contract.js +50 -0
  18. package/dist/contracts/endpoints.contract.js.map +1 -1
  19. package/dist/contracts/providers.contract.d.ts +21 -0
  20. package/dist/contracts/providers.contract.d.ts.map +1 -1
  21. package/dist/contracts/providers.contract.js +1 -0
  22. package/dist/contracts/providers.contract.js.map +1 -1
  23. package/dist/contracts/runner.contract.d.ts +98 -95
  24. package/dist/contracts/runner.contract.d.ts.map +1 -1
  25. package/dist/contracts/sessions.contract.d.ts +0 -1
  26. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  27. package/dist/contracts/settings.contract.d.ts +110 -6
  28. package/dist/contracts/settings.contract.d.ts.map +1 -1
  29. package/dist/contracts/settings.contract.js +19 -1
  30. package/dist/contracts/settings.contract.js.map +1 -1
  31. package/dist/contracts/system.contract.d.ts +23 -12
  32. package/dist/contracts/system.contract.d.ts.map +1 -1
  33. package/dist/events/agent-events.d.ts +1 -3
  34. package/dist/events/agent-events.d.ts.map +1 -1
  35. package/dist/events/agent-events.js +1 -0
  36. package/dist/events/agent-events.js.map +1 -1
  37. package/dist/events/system-events.d.ts +32 -6
  38. package/dist/events/system-events.d.ts.map +1 -1
  39. package/dist/events/transcript.d.ts +2 -6
  40. package/dist/events/transcript.d.ts.map +1 -1
  41. package/dist/events/transcript.js +5 -1
  42. package/dist/events/transcript.js.map +1 -1
  43. package/dist/index.d.ts +481 -549
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +8 -1
  46. package/dist/index.js.map +1 -1
  47. package/dist/models/agent-catalog.d.ts.map +1 -1
  48. package/dist/models/agent-catalog.js +1 -1
  49. package/dist/models/agent-catalog.js.map +1 -1
  50. package/dist/policy/fence-paths.d.ts +10 -0
  51. package/dist/policy/fence-paths.d.ts.map +1 -0
  52. package/dist/policy/fence-paths.js +58 -0
  53. package/dist/policy/fence-paths.js.map +1 -0
  54. package/dist/protocol/routes.d.ts.map +1 -1
  55. package/dist/protocol/routes.js +4 -1
  56. package/dist/protocol/routes.js.map +1 -1
  57. package/dist/schemas/agents.d.ts +51 -19
  58. package/dist/schemas/agents.d.ts.map +1 -1
  59. package/dist/schemas/agents.js +7 -22
  60. package/dist/schemas/agents.js.map +1 -1
  61. package/dist/schemas/areas.d.ts +21 -0
  62. package/dist/schemas/areas.d.ts.map +1 -0
  63. package/dist/schemas/areas.js +32 -0
  64. package/dist/schemas/areas.js.map +1 -0
  65. package/dist/schemas/automations.d.ts +19 -3
  66. package/dist/schemas/automations.d.ts.map +1 -1
  67. package/dist/schemas/automations.js +2 -0
  68. package/dist/schemas/automations.js.map +1 -1
  69. package/dist/schemas/capabilities.d.ts +11 -46
  70. package/dist/schemas/capabilities.d.ts.map +1 -1
  71. package/dist/schemas/capabilities.js +15 -14
  72. package/dist/schemas/capabilities.js.map +1 -1
  73. package/dist/schemas/personas.d.ts.map +1 -1
  74. package/dist/schemas/personas.js +5 -1
  75. package/dist/schemas/personas.js.map +1 -1
  76. package/dist/schemas/providers/provider-oauth.d.ts +2 -0
  77. package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
  78. package/dist/schemas/providers/provider-oauth.js +4 -0
  79. package/dist/schemas/providers/provider-oauth.js.map +1 -1
  80. package/dist/schemas/providers/usage.d.ts +4 -0
  81. package/dist/schemas/providers/usage.d.ts.map +1 -1
  82. package/dist/schemas/providers/usage.js +4 -0
  83. package/dist/schemas/providers/usage.js.map +1 -1
  84. package/dist/schemas/settings.d.ts +99 -3
  85. package/dist/schemas/settings.d.ts.map +1 -1
  86. package/dist/schemas/settings.js +51 -14
  87. package/dist/schemas/settings.js.map +1 -1
  88. package/dist/schemas/shared.d.ts +4 -0
  89. package/dist/schemas/shared.d.ts.map +1 -1
  90. package/dist/schemas/shared.js +3 -3
  91. package/dist/schemas/shared.js.map +1 -1
  92. package/dist/schemas/turn-break.d.ts +32 -0
  93. package/dist/schemas/turn-break.d.ts.map +1 -0
  94. package/dist/schemas/turn-break.js +16 -0
  95. package/dist/schemas/turn-break.js.map +1 -0
  96. package/dist/state/definition.d.ts +40 -56
  97. package/dist/state/definition.d.ts.map +1 -1
  98. package/dist/state/history-state.d.ts.map +1 -1
  99. package/dist/state/history-state.js +2 -0
  100. package/dist/state/history-state.js.map +1 -1
  101. package/dist/state/workspace-state.d.ts +5 -0
  102. package/dist/state/workspace-state.d.ts.map +1 -1
  103. package/dist/state/workspace-state.js +1 -0
  104. package/dist/state/workspace-state.js.map +1 -1
  105. package/dist/text/transcript-fold.d.ts.map +1 -1
  106. package/dist/text/transcript-fold.js +3 -7
  107. package/dist/text/transcript-fold.js.map +1 -1
  108. package/dist/time/zone.d.ts +27 -0
  109. package/dist/time/zone.d.ts.map +1 -0
  110. package/dist/time/zone.js +35 -0
  111. package/dist/time/zone.js.map +1 -0
  112. package/package.json +16 -5
  113. package/src/contracts/agents.contract.ts +7 -30
  114. package/src/contracts/areas.contract.ts +42 -0
  115. package/src/contracts/endpoints.contract.ts +82 -0
  116. package/src/contracts/providers.contract.ts +5 -0
  117. package/src/contracts/settings.contract.ts +28 -0
  118. package/src/events/agent-events.ts +4 -1
  119. package/src/events/transcript.ts +9 -1
  120. package/src/index.ts +19 -1
  121. package/src/models/agent-catalog.ts +1 -1
  122. package/src/policy/fence-paths.test.ts +114 -0
  123. package/src/policy/fence-paths.ts +99 -0
  124. package/src/protocol/routes.test.ts +12 -0
  125. package/src/protocol/routes.ts +8 -1
  126. package/src/schemas/agents.ts +14 -35
  127. package/src/schemas/areas.test.ts +40 -0
  128. package/src/schemas/areas.ts +49 -0
  129. package/src/schemas/automations.ts +8 -0
  130. package/src/schemas/capabilities.ts +37 -19
  131. package/src/schemas/personas.ts +15 -4
  132. package/src/schemas/providers/provider-oauth.ts +9 -0
  133. package/src/schemas/providers/usage.ts +9 -0
  134. package/src/schemas/settings.ts +107 -26
  135. package/src/schemas/shared.ts +9 -6
  136. package/src/schemas/turn-break.ts +49 -0
  137. package/src/state/history-state.ts +7 -0
  138. package/src/state/workspace-state.test.ts +7 -3
  139. package/src/state/workspace-state.ts +9 -2
  140. package/src/text/transcript-fold.test.ts +3 -5
  141. package/src/text/transcript-fold.ts +3 -7
  142. package/src/time/zone.test.ts +90 -0
  143. package/src/time/zone.ts +118 -0
@@ -0,0 +1,99 @@
1
+ import { PUBLIC_DIR, STATE_DIR } from "@intentic/constants";
2
+
3
+ // The path arithmetic a workspace fence is made of: whether a path is inside a folder, whether one set of folders
4
+ // covers another, and what a fenced tree may still list. Pure and shared, because the daemon refuses on these answers
5
+ // and the browser draws on them, and two implementations would disagree at exactly the boundary that matters.
6
+
7
+ // Workspace-relative, forward-slash, no climb: segments are folded so `a/./b` and `a/b` are one path, and a path that
8
+ // climbs above the root folds to `undefined` rather than to something inside it.
9
+ // The root itself is "" — a real answer, not a missing one.
10
+ export const foldPath = (raw: string): string | undefined => {
11
+ if (raw.startsWith("/") || /^[A-Za-z]:/.test(raw)) {
12
+ return undefined;
13
+ }
14
+ const segments: string[] = [];
15
+ for (const segment of raw.split(/[\\/]/)) {
16
+ if (segment === "" || segment === ".") {
17
+ continue;
18
+ }
19
+ if (segment !== "..") {
20
+ segments.push(segment);
21
+ continue;
22
+ }
23
+ if (segments.pop() === undefined) {
24
+ return undefined;
25
+ }
26
+ }
27
+ return segments.join("/");
28
+ };
29
+
30
+ // Whether `path` is `folder` or lives under it, by segment rather than by string prefix: `app2` is not inside `app`.
31
+ // An unfoldable path is inside nothing.
32
+ export const pathInsideFolder = (folder: string, path: string): boolean => {
33
+ const outer = foldPath(folder);
34
+ const inner = foldPath(path);
35
+ if (outer === undefined || inner === undefined) {
36
+ return false;
37
+ }
38
+ return outer === "" || inner === outer || inner.startsWith(`${outer}/`);
39
+ };
40
+
41
+ // "Change the sandbox" as paths: the daemon's own configuration, and the outbox the internet reads. One list, read by
42
+ // the persona hook that judges a turn's writes and by the area schema that decides what a grant may name.
43
+ export const SANDBOX_PATHS: readonly string[] = [STATE_DIR, PUBLIC_DIR];
44
+
45
+ // Whether a path is one of those folders or sits inside one, by segment: `publications` is not `public`.
46
+ // No area may name such a folder, which is what keeps a fence from handing anyone the config that decides what agents
47
+ // may do — the enumerated control-plane table (state/workspace-state.ts) locks named entries, not the whole tree.
48
+ export const isSandboxPath = (path: string): boolean => SANDBOX_PATHS.some((folder) => pathInsideFolder(folder, path));
49
+
50
+ // A fence: the folders a caller may touch, or undefined for the whole workspace. Undefined and `[]` are deliberately
51
+ // different answers — no fence at all, versus a fence that admits nothing.
52
+ export type Fence = readonly string[] | undefined;
53
+
54
+ /** Whether the fence admits this path at all: reading it, writing it, searching in it. */
55
+ export const fenceAllows = (fence: Fence, path: string): boolean => fence === undefined || fence.some((folder) => pathInsideFolder(folder, path));
56
+
57
+ /**
58
+ * Whether a fenced tree may still LIST this path: everything the fence allows, plus the ancestors leading down to it.
59
+ * Without the ancestors a fence on `finance/reports` would hide `finance` itself and leave the folder unreachable.
60
+ */
61
+ export const fenceReaches = (fence: Fence, path: string): boolean => {
62
+ if (fenceAllows(fence, path)) {
63
+ return true;
64
+ }
65
+ const inner = foldPath(path);
66
+ return inner !== undefined && (fence ?? []).some((folder) => pathInsideFolder(inner, folder));
67
+ };
68
+
69
+ /**
70
+ * Whether `outer` covers `inner`: every folder the inner fence admits is one the outer fence admits too. The question
71
+ * behind every narrowing-only rule — handing work across, inheriting a fence — since a fence that covers another can
72
+ * safely stand in for it.
73
+ */
74
+ export const fenceCovers = (outer: Fence, inner: Fence): boolean => {
75
+ if (outer === undefined) {
76
+ return true;
77
+ }
78
+ if (inner === undefined) {
79
+ return false;
80
+ }
81
+ return inner.every((folder) => fenceAllows(outer, folder));
82
+ };
83
+
84
+ /**
85
+ * The tighter of two fences: what a turn may touch when a persona's folders meet the member's own. Undefined on both
86
+ * sides is the whole workspace; either side alone narrows to itself.
87
+ */
88
+ export const fenceIntersection = (left: Fence, right: Fence): Fence => {
89
+ if (left === undefined) {
90
+ return right;
91
+ }
92
+ if (right === undefined) {
93
+ return left;
94
+ }
95
+ // A folder survives only where one side contains it; the containing side's folder is already the narrower of the
96
+ // pair, so taking each side's contained folders and deduping gives the intersection with no overlap left over.
97
+ const kept = [...left.filter((folder) => fenceAllows(right, folder)), ...right.filter((folder) => fenceAllows(left, folder))];
98
+ return [...new Set(kept.map((folder) => foldPath(folder) ?? folder))];
99
+ };
@@ -47,6 +47,18 @@ describe(`routeNameForRequest`, () => {
47
47
  expect(routeNameForRequest(routes, `DELETE`, `/system/terminals/web-1`)).toBe(`system.killTerminal`);
48
48
  });
49
49
 
50
+ // Sorted by name, `agents.get` precedes `agents.search`, so first-match would read `/agents/search` as an id.
51
+ it(`prefers a literal segment over a parameter, whichever the sorted list holds first`, () => {
52
+ const siblings = contractRoutes({
53
+ agents: {
54
+ get: oc.route({ method: "GET", path: "/agents/{id}" }),
55
+ search: oc.route({ method: "GET", path: "/agents/search" }),
56
+ },
57
+ });
58
+ expect(routeNameForRequest(siblings, `GET`, `/agents/search`)).toBe(`agents.search`);
59
+ expect(routeNameForRequest(siblings, `GET`, `/agents/abc`)).toBe(`agents.get`);
60
+ });
61
+
50
62
  it(`strips the query string before matching`, () => {
51
63
  expect(routeNameForRequest(routes, `GET`, `/vpn?refresh=1`)).toBe(`vpn.list`);
52
64
  });
@@ -156,10 +156,17 @@ const pathMatches = (template: string, path: string): boolean => {
156
156
 
157
157
  // The contract route a request belongs to, or undefined for a hand-written daemon route (/health, /workspace/raw) never
158
158
  // gated by the contract. Query string stripped first.
159
+ // How many segments a template leaves open; the tie-breaker below prefers the template that leaves fewest.
160
+ const paramCount = (template: string): number => template.split("/").filter((segment) => segment.startsWith("{") && segment.endsWith("}")).length;
161
+
159
162
  export const routeNameForRequest = (routes: readonly ContractRoute[], method: string, pathWithQuery: string): string | undefined => {
160
163
  const path = pathWithQuery.split("?")[0] ?? pathWithQuery;
161
164
  const upper = method.toUpperCase();
162
- return routes.find((route) => route.method.toUpperCase() === upper && pathMatches(route.path, path))?.name;
165
+ // A literal segment outranks a parameter: `/agents/search` is the search route, not `get` with an id of "search",
166
+ // whichever of the two the sorted list happens to hold first.
167
+ return routes
168
+ .filter((route) => route.method.toUpperCase() === upper && pathMatches(route.path, path))
169
+ .toSorted((a, b) => paramCount(a.path) - paramCount(b.path))[0]?.name;
163
170
  };
164
171
 
165
172
  // The route a typed client call belongs to; oRPC addresses a procedure by contract position (`['git','stashApply']`),
@@ -2,6 +2,7 @@
2
2
  import { z } from "zod";
3
3
  import { AgentHarnessSchema, AgentOriginSchema, AgentProviderSchema, ForkedFromSchema } from "./agent.js";
4
4
  import { LoopStateSchema } from "./loops.js";
5
+ import { LimitPolicySchema, RetryPolicySchema, TurnBreakPolicySchema, TurnBreakSchema } from "./turn-break.js";
5
6
  import { EMOJI_MAX_LENGTH, isSingleEmoji } from "../text/emoji.js";
6
7
  // A fleet agent is any conversation with a registry entry, keyed by conversationId. Isolated ones own a git worktree
7
8
  // (branch agent/<id>); workspace conversations have none, but both share one status/activity/cost lifecycle.
@@ -252,15 +253,11 @@ export const AgentSummarySchema = z.object({
252
253
  .describe(
253
254
  "This conversation's own answer to whether its work merges automatically. Absent means it follows the sandbox-wide setting, which is the common case.",
254
255
  ),
255
- // Per-conversation override, written by the in-chat retry press (not the settings toggle) so one late-night click
256
- // can't arm every agent; absent inherits the sandbox setting.
257
- resumeAfterOutage: z.boolean().optional(),
258
- // Off by default, unlike its neighbors: firing the moment a spent allowance reopens spends a window the user may be
259
- // saving.
260
- resumeAfterLimit: z.boolean().optional(),
261
- // Per-conversation override for moving a held turn to another account with room the moment it's refused; absent
262
- // inherits.
263
- moveAfterLimit: z.boolean().optional(),
256
+ // This conversation's own answer to each ending's one question, written by the in-chat control (not the settings
257
+ // row) so one late-night click can't arm every agent; absent inherits the sandbox-wide policy.
258
+ limitPolicy: LimitPolicySchema.optional(),
259
+ outagePolicy: RetryPolicySchema.optional(),
260
+ stopPolicy: RetryPolicySchema.optional(),
264
261
  // A collaborator's ask to land (collaborators can't merge themselves); cleared by whichever merge or discard
265
262
  // answers it.
266
263
  landRequested: z
@@ -619,33 +616,15 @@ export const AgentAutoLandSchema = z.object({
619
616
  "Whether its work merges automatically when a turn finishes. Null clears the override and goes back to following the sandbox-wide setting, so a conversation does not sit holding a frozen copy of a default it has quietly stopped following.",
620
617
  ),
621
618
  });
622
- // Same `null`-clears-the-override shape as autoLand, for this conversation's own outage-resume posture.
623
- export const AgentResumeAfterOutageSchema = z.object({
619
+ // Same `null`-clears-the-override shape as autoLand, for this conversation's own answer to one ending's question.
620
+ // One route rather than one per ending: they are the same decision asked about different walls, and three near-identical
621
+ // verbs is how the surfaces drifted apart in the first place.
622
+ export const AgentBreakPolicySchema = z.object({
624
623
  id: z.string().min(1).describe("Which conversation."),
625
- resumeAfterOutage: z
626
- .boolean()
627
- .nullable()
628
- .describe("Whether it retries by itself when the model provider was what failed. Null clears the override back to the sandbox-wide setting."),
629
- });
630
- // Same three-state override, for the limit blocker; written by the card's own offer when a limit strands a turn.
631
- export const AgentResumeAfterLimitSchema = z.object({
632
- id: z.string().min(1).describe("Which conversation."),
633
- resumeAfterLimit: z
634
- .boolean()
635
- .nullable()
636
- .describe(
637
- "Whether the turn a spent allowance refused is sent again by itself once the window reopens. Null clears the override back to the sandbox-wide setting.",
638
- ),
639
- });
640
- // Same three-state override, for moving a held turn to another account with room.
641
- export const AgentMoveAfterLimitSchema = z.object({
642
- id: z.string().min(1).describe("Which conversation."),
643
- moveAfterLimit: z
644
- .boolean()
645
- .nullable()
646
- .describe(
647
- "Whether the turn a spent allowance refused is moved to another connected account of the same provider that has room, as soon as the refusal lands. Null clears the override back to the sandbox-wide setting.",
648
- ),
624
+ ending: TurnBreakSchema.describe("Which wall this answers for: a spent usage limit, a provider outage, or a turn that stopped short."),
625
+ policy: TurnBreakPolicySchema.nullable().describe(
626
+ "What happens next for that ending. `wait` holds the turn for a press; `retry` re-runs it on a bounded ladder (outage, stop); `resend` sends it again at the published reset and `move` also tries another account with room (limit only). An answer the ending does not allow is refused. Null clears the override and goes back to following the sandbox-wide policy, so a conversation does not sit holding a frozen copy of a default it has quietly stopped following.",
627
+ ),
649
628
  });
650
629
  export const AgentFileDiffQuerySchema = z.object({
651
630
  id: z.string().min(1).describe("Which conversation."),
@@ -0,0 +1,40 @@
1
+ import { PUBLIC_DIR, STATE_DIR } from "@intentic/constants";
2
+ import { describe, expect, test } from "vitest";
3
+ import { AreaFolderSchema } from "./areas.js";
4
+
5
+ // What an area may name. These refusals are load-bearing rather than cosmetic: an area's folders become the fence
6
+ // every file route refuses on, so they are the whole of what keeps a grant below maintainer out of the config that
7
+ // decides what agents may do.
8
+ describe("AreaFolderSchema", () => {
9
+ test("an ordinary folder, at any depth, is an area", () => {
10
+ expect(AreaFolderSchema.safeParse("support").success).toBe(true);
11
+ expect(AreaFolderSchema.safeParse("finance/reports").success).toBe(true);
12
+ });
13
+
14
+ test("the workspace root is not an area: naming no area at all is what granting everything means", () => {
15
+ expect(AreaFolderSchema.safeParse(".").success).toBe(false);
16
+ expect(AreaFolderSchema.safeParse("/").success).toBe(false);
17
+ });
18
+
19
+ test("a folder that climbs out of the workspace is refused however it is spelled", () => {
20
+ expect(AreaFolderSchema.safeParse("../etc").success).toBe(false);
21
+ expect(AreaFolderSchema.safeParse("support/../..").success).toBe(false);
22
+ });
23
+
24
+ test("the sandbox's own configuration and its outbox can never be named", () => {
25
+ for (const folder of [
26
+ STATE_DIR,
27
+ `${STATE_DIR}/config`,
28
+ `${STATE_DIR}/config/hooks`,
29
+ PUBLIC_DIR,
30
+ `${PUBLIC_DIR}/site`,
31
+ ]) {
32
+ expect(AreaFolderSchema.safeParse(folder).success, folder).toBe(false);
33
+ }
34
+ });
35
+
36
+ test("a folder that merely starts with one of those names is ordinary", () => {
37
+ expect(AreaFolderSchema.safeParse("publications").success).toBe(true);
38
+ expect(AreaFolderSchema.safeParse("support/public-notes").success).toBe(true);
39
+ });
40
+ });
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { foldPath, isSandboxPath } from "../policy/fence-paths.js";
3
+ import { entryId } from "./internal.js";
4
+
5
+ // A named part of the workspace: the unit a person's reach is granted in. Named rather than listed per member because
6
+ // a folder list on every row makes each new folder an edit per member, and two rows meant to see the same thing drift
7
+ // apart the first time one is updated and the other isn't.
8
+ // An area is not a credential and holds none; what it holds is a decision about who sees which folders, which is why
9
+ // the file is tracked and a change to it shows up in review.
10
+
11
+ // Workspace-relative, forward-slash, no climb. Checked rather than rewritten: a transform here would make the route's
12
+ // wire shape inexpressible, and two builds compare surfaces by that shape. The route folds what it writes instead, so
13
+ // two spellings of one folder still cannot make two entries.
14
+ export const AreaFolderSchema = z
15
+ .string()
16
+ .min(1)
17
+ .max(200)
18
+ .refine((raw) => (foldPath(raw) ?? "") !== "", {
19
+ message: "a folder is workspace-relative and inside the workspace; the workspace root is what naming no area already means",
20
+ })
21
+ // A fence is what a writer's every file route refuses on, so an area naming the control plane would be the one way
22
+ // a grant below maintainer could reach the config that decides what agents may do, or publish to the internet.
23
+ .refine((raw) => !isSandboxPath(raw), {
24
+ message: "an area cannot name the sandbox's own configuration or its public outbox",
25
+ });
26
+
27
+ export const AreaSchema = z.object({
28
+ id: entryId.describe("The area's id, the name a member row points at."),
29
+ label: z.string().max(60).optional().describe("What to call it on screen. Absent falls back to the id, which somebody chose anyway."),
30
+ brief: z
31
+ .string()
32
+ .max(200)
33
+ .optional()
34
+ .describe("What this part of the workspace is, in one line, so whoever grants it can tell what they are handing over."),
35
+ folders: z
36
+ .array(AreaFolderSchema)
37
+ .min(1)
38
+ .max(50)
39
+ .describe(
40
+ "The folders it admits, workspace-relative. At least one: an area naming nothing would be a grant with no reader, and the way to grant everything is to name no area at all.",
41
+ ),
42
+ });
43
+ export type Area = z.infer<typeof AreaSchema>;
44
+
45
+ export const AreasListSchema = z.object({
46
+ areas: z.array(AreaSchema).describe("Every named part of the workspace this sandbox grants access in."),
47
+ });
48
+
49
+ export const AreaIdParamSchema = z.object({ id: entryId.describe("Which area.") });
@@ -4,6 +4,7 @@ import { AgentOriginSchema, ModelPinSchema } from "./agent.js";
4
4
  import { AgentSummarySchema } from "./agents.js";
5
5
  import { entryId } from "./internal.js";
6
6
  import { IssuesConfigSchema } from "./issues.js";
7
+ import { ZoneSchema } from "../time/zone.js";
7
8
  // An automation wakes the agent: the daemon fires each enabled one on its trigger, runs the optional guard command
8
9
  // (non-zero exit skips the wake), then runs one turn with the prompt. The manifest is user config; run history is
9
10
  // daemon-recorded.
@@ -60,6 +61,13 @@ export const TriggerSchema = z.discriminatedUnion("kind", [
60
61
  z.object({
61
62
  kind: z.literal("schedule").describe("On a clock."),
62
63
  cron: z.string().min(1).describe("When, in cron notation."),
64
+ // A cron is a WALL-CLOCK RULE: "43 20 * * *" is not a moment, it is 20:43 on some clock, and which clock is
65
+ // not written in it. The composer sends the reader's own zone here; absent, the sandbox's `timezone` setting
66
+ // answers, and absent that, UTC. Stored per automation rather than only globally so one chore can keep a
67
+ // colleague's hours, or a market's, without moving everything else.
68
+ tz: ZoneSchema.optional().describe(
69
+ "Which clock the times in the cron mean, as a zone name like Europe/Warsaw. Leave it out to use the sandbox's own setting, which is what you want unless this one chore belongs to a different place.",
70
+ ),
63
71
  // Fires only once at least this many other sessions have started since this automation's last wake; a due run
64
72
  // short of that is recorded as skipped.
65
73
  afterSessions: z
@@ -3,7 +3,6 @@
3
3
  import { z } from "zod";
4
4
  import { ExitConfigSchema } from "./exit.js";
5
5
  import { entryId } from "./internal.js";
6
- import { ServiceKindSchema } from "./inventory.js";
7
6
  import { NetdiskConfigSchema } from "./netdisk.js";
8
7
  import { VpnConfigSchema } from "./vpn.js";
9
8
  // One-way: the browser extension bundles webext.js alone, so nothing there may reach back into this file.
@@ -15,8 +14,6 @@ export const CapabilityKindSchema = z.enum([
15
14
  "devops",
16
15
  "monorepo",
17
16
  "mcp",
18
- "service",
19
- "integration",
20
17
  "cli",
21
18
  "plugin",
22
19
  "extension",
@@ -42,18 +39,6 @@ export const McpConfigSchema = z.object({
42
39
  url: z.url().describe("Where the tool server answers."),
43
40
  token: z.string().optional().describe("The credential it needs, if any. Stored, never echoed back."),
44
41
  });
45
- export const ServiceConfigSchema = z.object({
46
- service: ServiceKindSchema.describe("Which service to provision."),
47
- domain: z.string().min(1).describe("The address it should answer on."),
48
- on: z.string().min(1).describe("Which machine to put it on."),
49
- expose: z.string().min(1).describe("How it should be reachable."),
50
- });
51
- // External-app credential injected into deployed apps (i.have.stripe → STRIPE_API_KEY), not agent-facing like `cli`.
52
- // Closed, unlike `cli`: it becomes an `i.have.<provider>` deploy.config.ts entry, so the vocabulary belongs to the
53
- // deploy engine, not an extension.
54
- export const IntegrationConfigSchema = z.object({
55
- provider: z.literal("stripe").describe("Which outside service's credential to make available to deployed apps."),
56
- });
57
42
  // Gives the agent an authenticated CLI tool: credential plus any non-secret URL, injected into the agent's env each
58
43
  // turn, taught via an .agents/skills/<id> cheatsheet. Provider fields are data in an extension's
59
44
  // `contributes.capabilities`, validated at add-time, not by this schema.
@@ -234,6 +219,43 @@ export const LOCAL_MODEL_WINDOW_DEFAULT: LocalModelWindow = "65536";
234
219
  // ceiling no shipped GGUF was trained for it.
235
220
  export const LOCAL_MODEL_WINDOW_MIN = 2048;
236
221
  export const LOCAL_MODEL_WINDOW_MAX = 1_048_576;
222
+ // q8_0 KV cost per token, the rate every window price derives from: 64 KiB, so the rungs come out at exactly 1, 2, 4
223
+ // and 8 GiB. One rate rather than a per-model one, because the true cost varies ~2x across the list (50–60 KB/token
224
+ // measured) and a per-row figure is arithmetic somebody redoes by hand on every model added. Rounded UP into that band
225
+ // rather than down: over-reserving costs a rung, under-reserving costs an allocation failure a card had promised
226
+ // against.
227
+ export const LOCAL_MODEL_KV_BYTES_PER_TOKEN = 65_536;
228
+ // What a rung honestly serves. `instant` is the one the connect view prefetches — it downloads in under a minute and
229
+ // cannot drive a full agent turn, so no surface may sell it as one; `work` is what that view recommends for real use.
230
+ export type LocalModelTier = "instant" | "work";
231
+ export interface LocalModelChoice {
232
+ // Hugging Face owner/repo/file.gguf, exactly as LocalModelConfig.model carries it.
233
+ readonly id: string;
234
+ readonly label: string;
235
+ // The published file's own size, not a rounded guess: the card's label, the fit arithmetic and the download
236
+ // estimate all read this, so a model cannot be priced two ways.
237
+ readonly weightsBytes: number;
238
+ readonly tier: LocalModelTier;
239
+ }
240
+ // The curated list, smallest first. ponytail: these bytes are the Hugging Face tree API's answer for each file — the
241
+ // hand-written labels that predated them overstated gemma-4 12B by 2x and Qwen3.8 27B by a third, which is the class of
242
+ // drift a machine-readable size exists to end.
243
+ export const LOCAL_MODELS: readonly LocalModelChoice[] = [
244
+ { id: "unsloth/Qwen3.5-2B-GGUF/Qwen3.5-2B-Q4_K_M.gguf", label: "Qwen3.5 2B", weightsBytes: 1_280_835_840, tier: "instant" },
245
+ {
246
+ id: "unsloth/Phi-4-mini-instruct-GGUF/Phi-4-mini-instruct-Q4_K_M.gguf",
247
+ label: "Phi-4-mini 3.8B",
248
+ weightsBytes: 2_491_874_272,
249
+ tier: "work",
250
+ },
251
+ { id: "unsloth/Qwen3.5-9B-GGUF/Qwen3.5-9B-Q4_K_M.gguf", label: "Qwen3.5 9B", weightsBytes: 5_680_522_464, tier: "work" },
252
+ { id: "unsloth/gemma-4-12b-it-GGUF/gemma-4-12b-it-Q4_K_M.gguf", label: "Gemma 4 12B", weightsBytes: 7_121_861_440, tier: "work" },
253
+ { id: "unsloth/Qwen3.8-27B-GGUF/Qwen3.8-27B-UD-Q4_K_M.gguf", label: "Qwen3.8 27B", weightsBytes: 16_464_440_224, tier: "work" },
254
+ ];
255
+ export const localModelChoice = (id: string): LocalModelChoice | undefined => LOCAL_MODELS.find((choice) => choice.id === id);
256
+ // Exactly one row carries the instant tier; the fit module and the prefetch route take it from here rather than
257
+ // repeating its id, so the prefetched bytes and the offered option cannot name different files.
258
+ export const LOCAL_MODEL_INSTANT: LocalModelChoice = LOCAL_MODELS.find((choice) => choice.tier === "instant")!;
237
259
  export const LocalModelConfigSchema = z.object({
238
260
  model: z.string().min(1),
239
261
  gpu: z.enum(["on", "off"]).default("off"),
@@ -266,8 +288,6 @@ export const WalletConfigSchema = z.object({
266
288
  });
267
289
  export type WalletConfig = z.infer<typeof WalletConfigSchema>;
268
290
  export type McpConfig = z.infer<typeof McpConfigSchema>;
269
- export type ServiceConfig = z.infer<typeof ServiceConfigSchema>;
270
- export type IntegrationConfig = z.infer<typeof IntegrationConfigSchema>;
271
291
  export type CliConfig = z.infer<typeof CliConfigSchema>;
272
292
  export type PluginConfig = z.infer<typeof PluginConfigSchema>;
273
293
  export type ExtensionConfig = z.infer<typeof ExtensionConfigSchema>;
@@ -283,8 +303,6 @@ export const CapabilitySchema = z.discriminatedUnion("kind", [
283
303
  // operator panel.
284
304
  z.object({ id: entryId, kind: z.literal("monorepo"), config: z.object({}) }),
285
305
  z.object({ id: entryId, kind: z.literal("mcp"), config: McpConfigSchema }),
286
- z.object({ id: entryId, kind: z.literal("service"), config: ServiceConfigSchema }),
287
- z.object({ id: entryId, kind: z.literal("integration"), config: IntegrationConfigSchema }),
288
306
  z.object({ id: entryId, kind: z.literal("cli"), config: CliConfigSchema }),
289
307
  z.object({ id: entryId, kind: z.literal("plugin"), config: PluginConfigSchema }),
290
308
  z.object({ id: entryId, kind: z.literal("extension"), config: ExtensionConfigSchema }),
@@ -45,7 +45,11 @@ export const PersonaPowersSchema = z.object({
45
45
  });
46
46
  export type PersonaPowers = z.infer<typeof PersonaPowersSchema>;
47
47
  // `folders` only refuses file-tool calls outside it; it stops a misread instruction, not a shell. The container is the
48
- // real workspace-wide fence.
48
+ // real workspace-wide fence, and a conversation started by a fenced person is narrower still — the turn works inside
49
+ // this persona's folders AND that person's areas, never the wider of the two.
50
+ // A plain folder list rather than a named area (schemas/areas.ts), because the two answer different questions: a
51
+ // persona's fence is written once for that persona, while an area is a grant several people hold and one edit has to
52
+ // move all of them.
49
53
  // No placement field, by decision: every session already opens in its own private copy, never the shared tree.
50
54
  export const PersonaWorkspaceSchema = z.object({
51
55
  // Absent means the workspace root.
@@ -192,7 +196,9 @@ export const PersonaSchema = z.object({
192
196
  .string()
193
197
  .max(200)
194
198
  .optional()
195
- .describe("What this persona is for, in one line. A new chat is routed onto a persona by this sentence, and the Personas page shows it under the name."),
199
+ .describe(
200
+ "What this persona is for, in one line. A new chat is routed onto a persona by this sentence, and the Personas page shows it under the name.",
201
+ ),
196
202
  powers: PersonaPowersSchema.optional().describe(
197
203
  "What a conversation wearing it may do. Absent means the full toolbox, so a card written before this existed behaves exactly as it did.",
198
204
  ),
@@ -221,13 +227,18 @@ export const PersonaSchema = z.object({
221
227
  export type Persona = z.infer<typeof PersonaSchema>;
222
228
  // Ladder filtered to connected providers, deduplicated. Empty (absent, or every provider disconnected) means the card
223
229
  // has no opinion — the caller decides, not "run nothing".
224
- export const personaModels = (card: Pick<Persona, "models">, sources: readonly ModelSource[]): readonly ModelPin[] => readyChain(sources, card.models ?? []);
230
+ export const personaModels = (card: Pick<Persona, "models">, sources: readonly ModelSource[]): readonly ModelPin[] =>
231
+ readyChain(sources, card.models ?? []);
225
232
  // Asked once per chat, on the message it was sent with; answers with the one card the message belongs to, or none.
226
233
  // `folder`/`paths` are the facts a card's `context`/`startIn` can be matched against that words alone can't supply.
227
234
  export const PersonaRouteAskSchema = z.object({
228
235
  prompt: z.string().min(1).max(20000).describe("The message a new chat is about to open with."),
229
236
  folder: z.string().max(200).optional().describe("The workspace folder the chat was opened in, when it was opened in one."),
230
- paths: z.array(z.string().min(1).max(500)).max(50).default([]).describe("Workspace paths the message names: uploads, @-mentions, the editor's own file."),
237
+ paths: z
238
+ .array(z.string().min(1).max(500))
239
+ .max(50)
240
+ .default([])
241
+ .describe("Workspace paths the message names: uploads, @-mentions, the editor's own file."),
231
242
  });
232
243
  export type PersonaRouteAsk = z.infer<typeof PersonaRouteAskSchema>;
233
244
  export const PersonaRouteSchema = z.object({
@@ -141,6 +141,15 @@ export const ModelSchema = z.object({
141
141
  // The served window, not the training length — memory can clamp it lower. Turns are refused against this. Absent
142
142
  // means unknown, never unlimited.
143
143
  contextWindow: z.number().optional().describe("How many tokens this model will accept in one request, where the server publishes it."),
144
+ // Epoch seconds. A fact about the MODEL, not about any one account: a routed provider balances across credentials,
145
+ // so a model every one of them is benched on is unrunnable however much headroom their rings show. Absent is the
146
+ // ordinary case and means runnable, never "unknown".
147
+ availableAt: z
148
+ .number()
149
+ .optional()
150
+ .describe(
151
+ "When this model can be asked again, where every credential that serves it is currently refused. Absent means it can be asked now. A model here is still worth showing, unlike one the plan does not cover at all: the wait is the whole answer.",
152
+ ),
144
153
  });
145
154
  export type Model = z.infer<typeof ModelSchema>;
146
155
  export const ModelsSchema = z.object({
@@ -60,9 +60,18 @@ export const UsageTurnSchema = z.object({
60
60
  openingListings: z.number().optional(),
61
61
  // Tool calls before the turn first touched a file it later edited; absent (not zero) if it edited nothing.
62
62
  callsBeforeTarget: z.number().optional(),
63
+ // Calls that ended in error, counted once each however many updates reported it; absent means unmeasured, not a
64
+ // clean turn.
65
+ failedCalls: z.number().optional(),
63
66
  // Arm of the project-map experiment, stable per conversation; mapChars is the note's length when sent.
64
67
  mapArm: z.boolean().optional(),
65
68
  mapChars: z.number().optional(),
69
+ // Arm of the field-notes experiment, stable per conversation; notesChars is what the budget let through.
70
+ notesArm: z.boolean().optional(),
71
+ notesChars: z.number().optional(),
72
+ // Hash of the brief that was sent, recorded on control turns too. Load-bearing rather than decorative: the file is
73
+ // rewritten monthly, so a 30-day window holds two different treatments and pooling them would measure neither.
74
+ notesCohort: z.string().optional(),
66
75
  // What became of pre-turn retrieval on this turn, and how long it took. Assignment and DELIVERY are different
67
76
  // facts: the first version of this mechanism was assigned to every eligible turn and reached four in five of them,
68
77
  // which is the difference between a null result and a mechanism that never ran. Absent means the flag was off, so