@intentic/sandbox-contract 1.304.0 → 1.305.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 (328) hide show
  1. package/README.md +5 -3
  2. package/dist/contracts/agent.contract.d.ts +195 -41
  3. package/dist/contracts/agent.contract.d.ts.map +1 -1
  4. package/dist/contracts/agent.contract.js +8 -8
  5. package/dist/contracts/agent.contract.js.map +1 -1
  6. package/dist/contracts/agents.contract.d.ts +203 -3
  7. package/dist/contracts/agents.contract.d.ts.map +1 -1
  8. package/dist/contracts/agents.contract.js +30 -2
  9. package/dist/contracts/agents.contract.js.map +1 -1
  10. package/dist/contracts/capabilities.contract.d.ts +18 -5
  11. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  12. package/dist/contracts/capabilities.contract.js +3 -3
  13. package/dist/contracts/capabilities.contract.js.map +1 -1
  14. package/dist/contracts/{host.contract.d.ts → device.contract.d.ts} +75 -2
  15. package/dist/contracts/device.contract.d.ts.map +1 -0
  16. package/dist/contracts/device.contract.js +17 -0
  17. package/dist/contracts/device.contract.js.map +1 -0
  18. package/dist/contracts/extensions.contract.d.ts +2 -2
  19. package/dist/contracts/git.contract.d.ts +24 -0
  20. package/dist/contracts/git.contract.d.ts.map +1 -1
  21. package/dist/contracts/panels.contract.d.ts +1 -1
  22. package/dist/contracts/personas.contract.d.ts +0 -9
  23. package/dist/contracts/personas.contract.d.ts.map +1 -1
  24. package/dist/contracts/personas.contract.js +1 -10
  25. package/dist/contracts/personas.contract.js.map +1 -1
  26. package/dist/contracts/runner.contract.d.ts +138 -98
  27. package/dist/contracts/runner.contract.d.ts.map +1 -1
  28. package/dist/contracts/runner.contract.js +1 -0
  29. package/dist/contracts/runner.contract.js.map +1 -1
  30. package/dist/contracts/secrets.contract.js +2 -2
  31. package/dist/contracts/secrets.contract.js.map +1 -1
  32. package/dist/contracts/sessions.contract.d.ts +23 -3
  33. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  34. package/dist/contracts/settings.contract.d.ts +0 -23
  35. package/dist/contracts/settings.contract.d.ts.map +1 -1
  36. package/dist/contracts/system.contract.d.ts +106 -8
  37. package/dist/contracts/system.contract.d.ts.map +1 -1
  38. package/dist/contracts/system.contract.js +11 -2
  39. package/dist/contracts/system.contract.js.map +1 -1
  40. package/dist/events/agent-events.d.ts +85 -10
  41. package/dist/events/agent-events.d.ts.map +1 -1
  42. package/dist/events/agent-events.js +26 -10
  43. package/dist/events/agent-events.js.map +1 -1
  44. package/dist/events/agent-words.d.ts +20 -0
  45. package/dist/events/agent-words.d.ts.map +1 -0
  46. package/dist/events/agent-words.js +82 -0
  47. package/dist/events/agent-words.js.map +1 -0
  48. package/dist/events/{cards.d.ts → requests.d.ts} +18 -18
  49. package/dist/events/requests.d.ts.map +1 -0
  50. package/dist/events/{cards.js → requests.js} +26 -26
  51. package/dist/events/requests.js.map +1 -0
  52. package/dist/events/resume.d.ts +1 -0
  53. package/dist/events/resume.d.ts.map +1 -1
  54. package/dist/events/resume.js +2 -0
  55. package/dist/events/resume.js.map +1 -1
  56. package/dist/events/system-events.d.ts +19 -3
  57. package/dist/events/system-events.d.ts.map +1 -1
  58. package/dist/events/transcript.d.ts +165 -25
  59. package/dist/events/transcript.d.ts.map +1 -1
  60. package/dist/events/transcript.js +34 -15
  61. package/dist/events/transcript.js.map +1 -1
  62. package/dist/events/watch-wake.d.ts.map +1 -1
  63. package/dist/events/watch-wake.js +4 -0
  64. package/dist/events/watch-wake.js.map +1 -1
  65. package/dist/index.d.ts +581 -99
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +9 -4
  68. package/dist/index.js.map +1 -1
  69. package/dist/models/model-offer.d.ts +2 -1
  70. package/dist/models/model-offer.d.ts.map +1 -1
  71. package/dist/models/model-offer.js +5 -4
  72. package/dist/models/model-offer.js.map +1 -1
  73. package/dist/models/model-roles.d.ts +2 -9
  74. package/dist/models/model-roles.d.ts.map +1 -1
  75. package/dist/models/model-roles.js +2 -9
  76. package/dist/models/model-roles.js.map +1 -1
  77. package/dist/policy/command-classes.d.ts +1 -0
  78. package/dist/policy/command-classes.d.ts.map +1 -1
  79. package/dist/policy/command-classes.js +8 -0
  80. package/dist/policy/command-classes.js.map +1 -1
  81. package/dist/policy/request-status.d.ts +5 -0
  82. package/dist/policy/request-status.d.ts.map +1 -0
  83. package/dist/policy/{card-status.js → request-status.js} +3 -3
  84. package/dist/policy/request-status.js.map +1 -0
  85. package/dist/policy/safety-policy.d.ts +1 -1
  86. package/dist/policy/safety-policy.d.ts.map +1 -1
  87. package/dist/policy/safety-policy.js +1 -0
  88. package/dist/policy/safety-policy.js.map +1 -1
  89. package/dist/policy/turned-away.d.ts +2 -0
  90. package/dist/policy/turned-away.d.ts.map +1 -0
  91. package/dist/policy/turned-away.js +13 -0
  92. package/dist/policy/turned-away.js.map +1 -0
  93. package/dist/protocol/peer-dial.d.ts +9 -0
  94. package/dist/protocol/peer-dial.d.ts.map +1 -1
  95. package/dist/protocol/peer-dial.js +12 -4
  96. package/dist/protocol/peer-dial.js.map +1 -1
  97. package/dist/protocol/peer-mcp-server.d.ts +1 -0
  98. package/dist/protocol/peer-mcp-server.d.ts.map +1 -1
  99. package/dist/protocol/peer-mcp-server.js +14 -1
  100. package/dist/protocol/peer-mcp-server.js.map +1 -1
  101. package/dist/schemas/agent.d.ts +2 -1
  102. package/dist/schemas/agent.d.ts.map +1 -1
  103. package/dist/schemas/agent.js +7 -0
  104. package/dist/schemas/agent.js.map +1 -1
  105. package/dist/schemas/agents.d.ts +26 -0
  106. package/dist/schemas/agents.d.ts.map +1 -1
  107. package/dist/schemas/agents.js +14 -0
  108. package/dist/schemas/agents.js.map +1 -1
  109. package/dist/schemas/automations.d.ts +8 -0
  110. package/dist/schemas/automations.d.ts.map +1 -1
  111. package/dist/schemas/capabilities.d.ts +25 -12
  112. package/dist/schemas/capabilities.d.ts.map +1 -1
  113. package/dist/schemas/capabilities.js +11 -6
  114. package/dist/schemas/capabilities.js.map +1 -1
  115. package/dist/schemas/chat-route.d.ts +49 -0
  116. package/dist/schemas/chat-route.d.ts.map +1 -0
  117. package/dist/schemas/{model-route.js → chat-route.js} +17 -5
  118. package/dist/schemas/chat-route.js.map +1 -0
  119. package/dist/schemas/context-trim.d.ts +9 -0
  120. package/dist/schemas/context-trim.d.ts.map +1 -0
  121. package/dist/schemas/context-trim.js +18 -0
  122. package/dist/schemas/context-trim.js.map +1 -0
  123. package/dist/schemas/devices.d.ts +21 -8
  124. package/dist/schemas/devices.d.ts.map +1 -1
  125. package/dist/schemas/devices.js +8 -9
  126. package/dist/schemas/devices.js.map +1 -1
  127. package/dist/schemas/extension-updates.d.ts +4 -4
  128. package/dist/schemas/extension-updates.js +1 -1
  129. package/dist/schemas/extension-updates.js.map +1 -1
  130. package/dist/schemas/git/git.d.ts +83 -0
  131. package/dist/schemas/git/git.d.ts.map +1 -1
  132. package/dist/schemas/git/git.js +40 -0
  133. package/dist/schemas/git/git.js.map +1 -1
  134. package/dist/schemas/hosts.d.ts +36 -5
  135. package/dist/schemas/hosts.d.ts.map +1 -1
  136. package/dist/schemas/hosts.js +7 -5
  137. package/dist/schemas/hosts.js.map +1 -1
  138. package/dist/schemas/metrics.d.ts +108 -0
  139. package/dist/schemas/metrics.d.ts.map +1 -0
  140. package/dist/schemas/metrics.js +77 -0
  141. package/dist/schemas/metrics.js.map +1 -0
  142. package/dist/schemas/panels.d.ts +2 -2
  143. package/dist/schemas/panels.js +1 -1
  144. package/dist/schemas/panels.js.map +1 -1
  145. package/dist/schemas/personas.d.ts +1 -13
  146. package/dist/schemas/personas.d.ts.map +1 -1
  147. package/dist/schemas/personas.js +3 -20
  148. package/dist/schemas/personas.js.map +1 -1
  149. package/dist/schemas/providers/plan-limits.d.ts +1 -0
  150. package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
  151. package/dist/schemas/providers/plan-limits.js +6 -0
  152. package/dist/schemas/providers/plan-limits.js.map +1 -1
  153. package/dist/schemas/secrets.d.ts +1 -1
  154. package/dist/schemas/secrets.d.ts.map +1 -1
  155. package/dist/schemas/secrets.js +1 -1
  156. package/dist/schemas/secrets.js.map +1 -1
  157. package/dist/schemas/settings.d.ts +0 -42
  158. package/dist/schemas/settings.d.ts.map +1 -1
  159. package/dist/schemas/settings.js +1 -16
  160. package/dist/schemas/settings.js.map +1 -1
  161. package/dist/schemas/shared.d.ts +2 -2
  162. package/dist/schemas/shared.d.ts.map +1 -1
  163. package/dist/schemas/shared.js +3 -3
  164. package/dist/schemas/shared.js.map +1 -1
  165. package/dist/schemas/system-prompt.d.ts +104 -0
  166. package/dist/schemas/system-prompt.d.ts.map +1 -0
  167. package/dist/schemas/system-prompt.js +25 -0
  168. package/dist/schemas/system-prompt.js.map +1 -0
  169. package/dist/schemas/workflows.d.ts +2 -2
  170. package/dist/schemas/workflows.d.ts.map +1 -1
  171. package/dist/schemas/workflows.js +2 -2
  172. package/dist/schemas/workflows.js.map +1 -1
  173. package/dist/state/definition.d.ts +18 -10
  174. package/dist/state/definition.d.ts.map +1 -1
  175. package/dist/state/fix-attempt-plan.d.ts +4 -0
  176. package/dist/state/fix-attempt-plan.d.ts.map +1 -1
  177. package/dist/state/fix-attempt-plan.js +3 -2
  178. package/dist/state/fix-attempt-plan.js.map +1 -1
  179. package/dist/state/fix-stance.d.ts +1 -0
  180. package/dist/state/fix-stance.d.ts.map +1 -1
  181. package/dist/state/fix-stance.js +11 -0
  182. package/dist/state/fix-stance.js.map +1 -1
  183. package/dist/state/history-state.d.ts.map +1 -1
  184. package/dist/state/history-state.js +8 -1
  185. package/dist/state/history-state.js.map +1 -1
  186. package/dist/state/runtime-state.d.ts +0 -1
  187. package/dist/state/runtime-state.d.ts.map +1 -1
  188. package/dist/state/runtime-state.js +0 -1
  189. package/dist/state/runtime-state.js.map +1 -1
  190. package/dist/state/workspace-state.d.ts +4 -4
  191. package/dist/state/workspace-state.d.ts.map +1 -1
  192. package/dist/state/workspace-state.js +4 -4
  193. package/dist/state/workspace-state.js.map +1 -1
  194. package/dist/text/documents.d.ts +2 -2
  195. package/dist/text/documents.d.ts.map +1 -1
  196. package/dist/text/documents.js.map +1 -1
  197. package/dist/text/mentions.d.ts +1 -0
  198. package/dist/text/mentions.d.ts.map +1 -1
  199. package/dist/text/mentions.js +5 -1
  200. package/dist/text/mentions.js.map +1 -1
  201. package/dist/text/transcript-fold.d.ts +8 -3
  202. package/dist/text/transcript-fold.d.ts.map +1 -1
  203. package/dist/text/transcript-fold.js +83 -28
  204. package/dist/text/transcript-fold.js.map +1 -1
  205. package/package.json +9 -9
  206. package/src/chores/chores.test.ts +1 -1
  207. package/src/chores/digest.test.ts +1 -1
  208. package/src/chores/probes.test.ts +5 -2
  209. package/src/chores/stack.test.ts +1 -1
  210. package/src/chores/verdict.test.ts +6 -5
  211. package/src/contracts/agent.contract.ts +10 -9
  212. package/src/contracts/agents.contract.ts +35 -2
  213. package/src/contracts/capabilities.contract.ts +3 -3
  214. package/src/contracts/{host.contract.ts → device.contract.ts} +8 -6
  215. package/src/contracts/personas.contract.ts +1 -13
  216. package/src/contracts/runner.contract.ts +3 -2
  217. package/src/contracts/secrets.contract.ts +2 -2
  218. package/src/contracts/system.contract.ts +13 -2
  219. package/src/contracts/webext.contract.ts +1 -1
  220. package/src/events/agent-events.ts +37 -14
  221. package/src/events/agent-words.test.ts +68 -0
  222. package/src/events/agent-words.ts +124 -0
  223. package/src/events/{cards.ts → requests.ts} +44 -44
  224. package/src/events/resume.test.ts +8 -1
  225. package/src/events/resume.ts +4 -0
  226. package/src/events/transcript.ts +51 -22
  227. package/src/events/watch-wake.test.ts +3 -2
  228. package/src/events/watch-wake.ts +4 -0
  229. package/src/ids/conversation-ids.test.ts +3 -3
  230. package/src/ids/hostnames.test.ts +1 -1
  231. package/src/ids/share-paths.test.ts +1 -1
  232. package/src/ids/tunnel-ids.test.ts +1 -1
  233. package/src/index.ts +9 -4
  234. package/src/models/agent-catalog.test.ts +1 -1
  235. package/src/models/capability-ledger.test.ts +1 -1
  236. package/src/models/model-offer.test.ts +9 -2
  237. package/src/models/model-offer.ts +10 -7
  238. package/src/models/model-order.test.ts +1 -1
  239. package/src/models/model-pins.test.ts +1 -1
  240. package/src/models/model-roles.test.ts +1 -1
  241. package/src/models/model-roles.ts +6 -12
  242. package/src/models/plan-pools.test.ts +1 -1
  243. package/src/models/provider-specs.test.ts +3 -3
  244. package/src/policy/batch-runs.test.ts +1 -1
  245. package/src/policy/command-classes.test.ts +37 -2
  246. package/src/policy/command-classes.ts +16 -0
  247. package/src/policy/credential-material.test.ts +6 -10
  248. package/src/policy/fence-paths.test.ts +1 -1
  249. package/src/policy/overlay-lint.test.ts +1 -1
  250. package/src/policy/owner-ticket.test.ts +1 -1
  251. package/src/policy/persona-home.test.ts +1 -1
  252. package/src/policy/{card-status.ts → request-status.ts} +6 -6
  253. package/src/policy/safety-policy.test.ts +1 -1
  254. package/src/policy/safety-policy.ts +1 -0
  255. package/src/policy/search-globs.test.ts +1 -1
  256. package/src/policy/turned-away.ts +18 -0
  257. package/src/protocol/container-requirements.test.ts +1 -1
  258. package/src/protocol/ingress-contract.test.ts +8 -2
  259. package/src/protocol/ingress-protocol.test.ts +5 -4
  260. package/src/protocol/peer-dial.test.ts +111 -41
  261. package/src/protocol/peer-dial.ts +39 -4
  262. package/src/protocol/peer-mcp-server.test.ts +37 -7
  263. package/src/protocol/peer-mcp-server.ts +22 -1
  264. package/src/protocol/routes.test.ts +4 -2
  265. package/src/schemas/agent.ts +13 -0
  266. package/src/schemas/agents.ts +25 -0
  267. package/src/schemas/areas.test.ts +2 -8
  268. package/src/schemas/automations.ts +5 -5
  269. package/src/schemas/capabilities.ts +20 -9
  270. package/src/schemas/chat-route.ts +61 -0
  271. package/src/schemas/context-trim.test.ts +38 -0
  272. package/src/schemas/context-trim.ts +29 -0
  273. package/src/schemas/dev-rebuild-log.test.ts +1 -1
  274. package/src/schemas/devices.ts +25 -22
  275. package/src/schemas/extension-updates.ts +1 -1
  276. package/src/schemas/git/git.test.ts +11 -0
  277. package/src/schemas/git/git.ts +66 -0
  278. package/src/schemas/hosts.ts +15 -8
  279. package/src/schemas/issues.ts +1 -1
  280. package/src/schemas/metrics.ts +107 -0
  281. package/src/schemas/panels.ts +2 -2
  282. package/src/schemas/personas.ts +5 -28
  283. package/src/schemas/providers/plan-limits.ts +9 -0
  284. package/src/schemas/secrets.ts +1 -1
  285. package/src/schemas/settings.test.ts +1 -1
  286. package/src/schemas/settings.ts +9 -36
  287. package/src/schemas/shared.ts +6 -6
  288. package/src/schemas/system-prompt.ts +41 -0
  289. package/src/schemas/version-seam.test.ts +1 -1
  290. package/src/schemas/webext.ts +1 -1
  291. package/src/schemas/workflows.ts +4 -4
  292. package/src/state/contract-lock.test.ts +15 -11
  293. package/src/state/fix-attempt-plan.test.ts +10 -1
  294. package/src/state/fix-attempt-plan.ts +6 -2
  295. package/src/state/fix-stance.test.ts +10 -1
  296. package/src/state/fix-stance.ts +13 -0
  297. package/src/state/history-state.ts +17 -1
  298. package/src/state/runtime-state.test.ts +3 -9
  299. package/src/state/runtime-state.ts +0 -4
  300. package/src/state/versions.test.ts +1 -1
  301. package/src/state/workspace-state.test.ts +12 -7
  302. package/src/state/workspace-state.ts +4 -4
  303. package/src/text/archives.test.ts +1 -1
  304. package/src/text/documents.test.ts +1 -1
  305. package/src/text/documents.ts +2 -2
  306. package/src/text/embed.test.ts +17 -6
  307. package/src/text/emoji.test.ts +1 -1
  308. package/src/text/mentions.test.ts +20 -2
  309. package/src/text/mentions.ts +10 -1
  310. package/src/text/path-refs.test.ts +1 -1
  311. package/src/text/title.test.ts +1 -1
  312. package/src/text/transcript-fold.test.ts +183 -1
  313. package/src/text/transcript-fold.ts +146 -39
  314. package/src/text/whisper.test.ts +1 -1
  315. package/src/text/workflow-faults.test.ts +1 -1
  316. package/src/time/zone.test.ts +6 -6
  317. package/dist/contracts/host.contract.d.ts.map +0 -1
  318. package/dist/contracts/host.contract.js +0 -16
  319. package/dist/contracts/host.contract.js.map +0 -1
  320. package/dist/events/cards.d.ts.map +0 -1
  321. package/dist/events/cards.js.map +0 -1
  322. package/dist/policy/card-status.d.ts +0 -5
  323. package/dist/policy/card-status.d.ts.map +0 -1
  324. package/dist/policy/card-status.js.map +0 -1
  325. package/dist/schemas/model-route.d.ts +0 -27
  326. package/dist/schemas/model-route.d.ts.map +0 -1
  327. package/dist/schemas/model-route.js.map +0 -1
  328. package/src/schemas/model-route.ts +0 -43
@@ -1,4 +1,4 @@
1
- import { expect, test } from "vitest";
1
+ import { test, expect } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import { createMcpServer, type McpAuditEntry, textResult, tool } from "./peer-mcp-server.js";
4
4
 
@@ -37,7 +37,8 @@ const build = () => {
37
37
  return { handle, audits };
38
38
  };
39
39
 
40
- const call = (name: string, args: Record<string, unknown>, allowed = true) => build().handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }, { allowed });
40
+ const call = (name: string, args: Record<string, unknown>, allowed = true) =>
41
+ build().handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }, { allowed });
41
42
 
42
43
  test("initialize names the peer and its build; ping answers empty; an unknown method is a JSON-RPC error", async () => {
43
44
  const { handle } = build();
@@ -52,14 +53,38 @@ test("initialize names the peer and its build; ping answers empty; an unknown me
52
53
 
53
54
  test("tools/list publishes each tool's schema as JSON Schema, without the dialect line", async () => {
54
55
  const { handle } = build();
55
- const listed = (await handle({ jsonrpc: "2.0", id: 1, method: "tools/list" }, { allowed: true })) as { result: { tools: { name: string; inputSchema: Record<string, unknown> }[] } };
56
+ const listed = (await handle({ jsonrpc: "2.0", id: 1, method: "tools/list" }, { allowed: true })) as {
57
+ result: { tools: { name: string; inputSchema: Record<string, unknown> }[] };
58
+ };
56
59
  expect(listed.result.tools.map((entry) => entry.name)).toEqual(["echo", "fail"]);
57
60
  expect(listed.result.tools[0]?.inputSchema).toMatchObject({ type: "object", properties: { text: { type: "string" } } });
58
61
  expect(listed.result.tools[0]?.inputSchema).not.toHaveProperty("$schema");
59
62
  });
60
63
 
64
+ test("a tuple is published without the boolean `items` llama.cpp's grammar converter refuses, and still checks its length", async () => {
65
+ const listed = tool({
66
+ name: "point",
67
+ description: "A point.",
68
+ input: z.object({ at: z.tuple([z.number(), z.number()]), path: z.array(z.tuple([z.number(), z.number()])) }),
69
+ run: async () => textResult("ok"),
70
+ });
71
+ expect(JSON.stringify(listed.inputSchema)).not.toContain("false");
72
+ expect(listed.inputSchema).toMatchObject({
73
+ properties: {
74
+ at: { type: "array", prefixItems: [{ type: "number" }, { type: "number" }], minItems: 2, maxItems: 2 },
75
+ path: { items: { type: "array", prefixItems: [{ type: "number" }, { type: "number" }], maxItems: 2 } },
76
+ },
77
+ });
78
+ const parsed = (await listed.call({ at: [1, 2, 3], path: [] }, undefined)) as { isError: boolean };
79
+ expect(parsed.isError).toBe(true);
80
+ });
81
+
61
82
  test("a call is checked against the same schema, and a bad argument is a readable result, not a fault", async () => {
62
- expect(await call("echo", { text: "hi" })).toEqual({ jsonrpc: "2.0", id: 1, result: { content: [{ type: "text", text: "hi" }], isError: false } });
83
+ expect(await call("echo", { text: "hi" })).toEqual({
84
+ jsonrpc: "2.0",
85
+ id: 1,
86
+ result: { content: [{ type: "text", text: "hi" }], isError: false },
87
+ });
63
88
  const bad = (await call("echo", { text: "" })) as { result: { content: { text: string }[]; isError: boolean } };
64
89
  expect(bad.result.isError).toBe(true);
65
90
  expect(bad.result.content[0]?.text).toContain("text");
@@ -75,8 +100,11 @@ test("a tool this peer does not have answers in the peer's own words", async ()
75
100
 
76
101
  test("a thrown refusal and a thrown failure are both results, and the audit line says which", async () => {
77
102
  const { handle, audits } = build();
78
- const refused = (await handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "echo", arguments: { text: "hi" } } }, { allowed: false })) as {
79
- result: { content: { text: string }[]; isError: boolean };
103
+ const refused = (await handle(
104
+ { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "echo", arguments: { text: "hi" } } },
105
+ { allowed: false },
106
+ )) as {
107
+ result: { content: { type: string; text: string }[]; isError: boolean };
80
108
  };
81
109
  expect(refused.result).toEqual({ content: [{ type: "text", text: "the switch is off" }], isError: true });
82
110
  await handle({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "fail", arguments: {} } }, { allowed: true });
@@ -99,5 +127,7 @@ test("an audit log that cannot be written never fails the answer", async () => {
99
127
  throw new Error("disk full");
100
128
  },
101
129
  });
102
- expect(await handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "hi", arguments: {} } }, undefined)).toMatchObject({ result: { isError: false } });
130
+ expect(await handle({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "hi", arguments: {} } }, undefined)).toMatchObject({
131
+ result: { isError: false },
132
+ });
103
133
  });
@@ -22,6 +22,27 @@ export interface McpTool<Ctx> {
22
22
 
23
23
  export const textResult = (text: string, isError = false): Record<string, unknown> => ({ content: [{ type: "text", text }], isError });
24
24
 
25
+ // Every tool schema has to survive llama.cpp's grammar converter, which is how a local model is held to a tool's shape:
26
+ // it reads `items` before `prefixItems` and refuses a boolean schema, so zod's closed-tuple form (`items: false`) fails
27
+ // the conversion of the WHOLE tool set, not just its own tool. Length is what closes a tuple, and `maxItems` still says
28
+ // it, so no other reader sees a different shape. Applied again by the daemon's bridge, whose peers are separately
29
+ // installed software that can be any age.
30
+ export const converterReadable = (value: unknown): unknown => {
31
+ if (Array.isArray(value)) {
32
+ return value.map(converterReadable);
33
+ }
34
+ if (typeof value !== "object" || value === null) {
35
+ return value;
36
+ }
37
+ const node = value as Record<string, unknown>;
38
+ const prefix = node["prefixItems"];
39
+ const tupleLength = node["items"] === false && Array.isArray(prefix) ? prefix.length : undefined;
40
+ const walked = Object.fromEntries(
41
+ Object.entries(node).flatMap(([key, child]) => (tupleLength !== undefined && key === "items" ? [] : [[key, converterReadable(child)] as const])),
42
+ );
43
+ return tupleLength === undefined ? walked : { ...walked, maxItems: tupleLength };
44
+ };
45
+
25
46
  // Builds one tool from a single zod schema; the generic carries its type to the handler, and `McpTool` erases it again
26
47
  // since the dispatch table holds every tool. `$schema` is dropped from the JSON Schema output as redundant.
27
48
  export const tool = <Schema extends z.ZodType, Ctx>(spec: {
@@ -34,7 +55,7 @@ export const tool = <Schema extends z.ZodType, Ctx>(spec: {
34
55
  return {
35
56
  name: spec.name,
36
57
  description: spec.description,
37
- inputSchema,
58
+ inputSchema: converterReadable(inputSchema) as Record<string, unknown>,
38
59
  call: async (args, ctx) => {
39
60
  const parsed = spec.input.safeParse(args);
40
61
  // Readable enough for a model to fix its own call: which field, and what was expected.
@@ -1,5 +1,5 @@
1
1
  import { eventIterator, oc } from "@orpc/contract";
2
- import { describe, expect, it } from "vitest";
2
+ import { describe, it, expect } from "bun:test";
3
3
  import { z } from "zod";
4
4
  import { SANDBOX_ROUTE_NAMES, SANDBOX_ROUTE_SHAPES, SANDBOX_ROUTES, sandboxRouteName } from "../index.js";
5
5
  import { contractRoutes, routeNameForRequest, routeShapes, streamOf } from "./routes.js";
@@ -183,7 +183,9 @@ describe(`the real sandbox contract`, () => {
183
183
  `system.manageDeviceSandbox`,
184
184
  `system.runDeviceAgentFlow`,
185
185
  `vpn.connect`,
186
- ].filter((name) => name in SANDBOX_ROUTE_SHAPES).toSorted(),
186
+ ]
187
+ .filter((name) => name in SANDBOX_ROUTE_SHAPES)
188
+ .toSorted(),
187
189
  ).toEqual([
188
190
  `agent.attach`,
189
191
  `capabilities.add`,
@@ -3,6 +3,7 @@ import { CONVERSATION_ID } from "../ids/conversation-ids.js";
3
3
  import { ModelRoleSchema } from "../models/model-roles.js";
4
4
  import { NATIVE_PROVIDERS } from "../models/provider-specs.js";
5
5
  import { AgentPlacementSchema } from "../protocol/runner-protocol.js";
6
+ import { MENTION_LIMIT } from "../text/mentions.js";
6
7
  import { entryId } from "./internal.js";
7
8
  // Agent runtimes the daemon can serve: native providers have dedicated adapters, `endpoint/<id>` names an installed
8
9
  // endpoint capability, anything else is an ACP agent capability id. A bare string, not an enum, so an unknown id is a
@@ -77,6 +78,9 @@ export type CommandLocus = z.infer<typeof CommandLocusSchema>;
77
78
  export const CommandClassSchema = z.enum([
78
79
  // Rewrites or discards committed work: force-push, hard reset, force-delete a branch, clean -f, filter-branch.
79
80
  "git.destructive",
81
+ // Moves a checkout to another branch. Only ever matches in a tree the conversation SHARES with its owner: inside
82
+ // its own copy a conversation may stand where it likes, and the drift that causes is reported, not refused.
83
+ "git.branch-switch",
80
84
  // Recursive-force deletion (`rm -rf`), and its spelling in a script (`fs.rm(p, { recursive: true })`).
81
85
  "files.destructive",
82
86
  // State nothing recovers at this locus; which roots count (/, /history in the sandbox) depends on the locus.
@@ -136,6 +140,15 @@ export const AgentTurnSchema = z
136
140
  .max(20)
137
141
  .optional()
138
142
  .describe("Files to hand the agent along with the prompt, as workspace paths. Upload them first."),
143
+ // Read out of the prompt's own `@path` tokens rather than chosen: a tokenizer over pasted text guesses, so a
144
+ // miss here is dropped and only a chosen attachment can refuse the turn.
145
+ mentions: z
146
+ .array(z.string().min(1))
147
+ .max(MENTION_LIMIT)
148
+ .optional()
149
+ .describe(
150
+ "Workspace paths the prompt mentions with `@`. Unlike attachments, one that escapes the workspace or names no file is ignored rather than refused.",
151
+ ),
139
152
  // Which provider serves the turn, absent = claude; a sessionId resumes only on the provider that minted it.
140
153
  agent: AgentProviderSchema.optional().describe("Which model provider serves this turn. Leave it out for Claude."),
141
154
  // Which agentic loop runs the turn, absent = provider's own; "claude-code" forces the SDK loop for any
@@ -444,6 +444,22 @@ export const AgentSummarySchema = z.object({
444
444
  .describe(
445
445
  "Outside conditions this conversation is parked on, each of which will wake it. Absent means none, which is nearly every conversation: an armed watch is why a finished-looking agent starts working by itself, and why a hosted machine will not go idle.",
446
446
  ),
447
+ // In memory only: a daemon restart leaves transcript rows naming jobs this list no longer carries.
448
+ jobs: z
449
+ .array(
450
+ z.object({
451
+ id: z.string().describe("The daemon's handle for this job, the one its transcript row names."),
452
+ label: z.string().describe("What the job is, in the agent's own words when it gave any, else its command on one line."),
453
+ session: z.string().describe("The terminal session its pane runs in, which is what opening it focuses."),
454
+ startedAt: z.number().describe("When it started, in milliseconds."),
455
+ endedAt: z.number().optional().describe("When the command exited, in milliseconds. Absent while it runs."),
456
+ exitCode: z.number().int().optional().describe("The code it exited with. Absent while it runs, or when its exit left none."),
457
+ }),
458
+ )
459
+ .optional()
460
+ .describe(
461
+ "Commands this conversation left running in the background, and how the most recent ones ended. Absent means none since the daemon started. A job is running exactly while it has no end.",
462
+ ),
447
463
  // Epoch ms it was archived; nothing is lost (branch, transcript, counters stay), and unarchiving re-attaches a
448
464
  // fresh worktree from the branch.
449
465
  archivedAt: z
@@ -456,6 +472,8 @@ export const AgentSummarySchema = z.object({
456
472
  export type AgentSummary = z.infer<typeof AgentSummarySchema>;
457
473
  // One armed watch as a card carries it; derived from AgentSummarySchema.watches so the two shapes can't drift apart.
458
474
  export type AgentWatch = NonNullable<AgentSummary["watches"]>[number];
475
+ // One background job as a card carries it.
476
+ export type AgentJob = NonNullable<AgentSummary["jobs"]>[number];
459
477
  // AgentsListSchema is declared later, after AutomationApprovalSchema, since the fleet list carries held wakes and zod
460
478
  // needs that type declared first.
461
479
  export const AgentIdSchema = z.object({ id: z.string().min(1).describe("Which conversation.") });
@@ -673,6 +691,13 @@ export type LandConflict = z.infer<typeof LandConflictSchema>;
673
691
  // hand.
674
692
  export const LandResultSchema = z.object({
675
693
  landed: z.boolean().describe("Whether the entire composed change was applied."),
694
+ // Merged and nothing moved is the one outcome with nothing to show for itself; without this the caller cannot tell
695
+ // it from a merge that carried work, and a press that did nothing looked exactly like a press that worked.
696
+ changed: z
697
+ .boolean()
698
+ .describe(
699
+ "Whether anything actually moved. False alongside merged means there was nothing on the branch to apply: the work is already in your tree, or the branch never carried any.",
700
+ ),
676
701
  conflicts: z.array(LandConflictSchema).optional().describe("What stopped the whole composed change, grouped per repository."),
677
702
  resolving: z
678
703
  .array(
@@ -1,5 +1,5 @@
1
1
  import { PUBLIC_DIR, STATE_DIR } from "@intentic/constants";
2
- import { describe, expect, test } from "vitest";
2
+ import { describe, test, expect } from "bun:test";
3
3
  import { AreaFolderSchema } from "./areas.js";
4
4
 
5
5
  // What an area may name. These refusals are load-bearing rather than cosmetic: an area's folders become the fence
@@ -22,13 +22,7 @@ describe("AreaFolderSchema", () => {
22
22
  });
23
23
 
24
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
- ]) {
25
+ for (const folder of [STATE_DIR, `${STATE_DIR}/config`, `${STATE_DIR}/config/hooks`, PUBLIC_DIR, `${PUBLIC_DIR}/site`]) {
32
26
  expect(AreaFolderSchema.safeParse(folder).success, folder).toBe(false);
33
27
  }
34
28
  });
@@ -124,7 +124,7 @@ export const TriggerSchema = z.discriminatedUnion("kind", [
124
124
  }),
125
125
  ]);
126
126
  export type Trigger = z.infer<typeof TriggerSchema>;
127
- // The Front Desk widget's settings, present only on `webchat` listener automations. Split into what the widget itself
127
+ // The Visitor chat widget's settings, present only on `webchat` listener automations. Split into what the widget itself
128
128
  // may read (public by construction) and what only the daemon may (turnstileSecret); GET /webchat/<id>/config serves the
129
129
  // first group by naming it, never by omitting the second.
130
130
  export const WebchatConfigSchema = z.object({
@@ -177,7 +177,7 @@ export const WebchatConfigSchema = z.object({
177
177
  sessionTtlMinutes: z.number().int().positive().optional(),
178
178
  });
179
179
  export type WebchatConfig = z.infer<typeof WebchatConfigSchema>;
180
- // Default daily agent-turn ceiling for an unconfigured Front Desk; high enough for real traffic, low enough to blunt a
180
+ // Default daily agent-turn ceiling for an unconfigured Visitor chat; high enough for real traffic, low enough to blunt a
181
181
  // script.
182
182
  export const WEBCHAT_DAILY_MAX_DEFAULT = 200;
183
183
  // The widget wire: the shapes GET /webchat/<id>/config, GET .../challenge and POST .../message speak, living beside the
@@ -201,7 +201,7 @@ export const WebchatPublicConfigSchema = z.object({
201
201
  });
202
202
  export type WebchatPublicConfig = z.infer<typeof WebchatPublicConfigSchema>;
203
203
  // A proof-of-work challenge: find a nonce whose SHA-256 of `${salt}:${nonce}` starts with `difficulty` zero bits. One
204
- // shape for every public door (Front Desk, bug intake), and one matching solver, embed.ts.
204
+ // shape for every public door (Visitor chat, bug intake), and one matching solver, embed.ts.
205
205
  export const PowChallengeSchema = z.object({ salt: z.string(), difficulty: z.number().int().positive() });
206
206
  export type PowChallenge = z.infer<typeof PowChallengeSchema>;
207
207
  // One visitor message; `conversationId` is the widget's own localStorage id, a thread key rather than a secret — the
@@ -224,7 +224,7 @@ export const WebchatMessageSchema = z.object({
224
224
  });
225
225
  export type WebchatMessage = z.infer<typeof WebchatMessageSchema>;
226
226
  // What GET /webchat/<id>/messages answers: replies queued after the visitor's stream closed, which is every reply an
227
- // approval-gated desk produces and every one a human writes as the agent. Public by construction, like the config:
227
+ // approval-gated guest produces and every one a human writes as the agent. Public by construction, like the config:
228
228
  // reaching it needs nothing but an allowed origin and the conversation id already in that browser.
229
229
  export const WebchatPendingSchema = z.object({
230
230
  // Oldest first, so appending them to the log preserves the order they were written in.
@@ -272,7 +272,7 @@ export const AutomationSchema = z.object({
272
272
  "A command run before the wake that decides whether there is anything to do. Skipped by the guard is often the most useful thing an automation can report.",
273
273
  ),
274
274
  prompt: z.string().min(1).describe("What the woken agent is told."),
275
- // The Front Desk widget's settings, `webchat` listener automations only, ignored on every other trigger.
275
+ // The Visitor chat widget's settings, `webchat` listener automations only, ignored on every other trigger.
276
276
  webchat: WebchatConfigSchema.optional().describe("Settings for the public chat widget, for an automation that answers visitors."),
277
277
  // Bug intake settings, `issues` triggers only; its own field rather than a shared bag, since a chat's and an
278
278
  // intake's fields mostly don't overlap.
@@ -24,12 +24,13 @@ export const CapabilityKindSchema = z.enum([
24
24
  "docker",
25
25
  "browser",
26
26
  "identity",
27
- "host",
27
+ "device",
28
28
  "webext",
29
29
  "agent",
30
30
  "endpoint",
31
31
  "localmodel",
32
32
  "wallet",
33
+ "fleet",
33
34
  ]);
34
35
  export type CapabilityKind = z.infer<typeof CapabilityKindSchema>;
35
36
  export const CapabilityStateSchema = z.enum(["active", "pending", "error", "inactive"]);
@@ -164,7 +165,7 @@ export type IdentityConfig = z.infer<typeof IdentityConfigSchema>;
164
165
  // machine, never here, so a compromised sandbox can't exceed what the owner ticked.
165
166
  // on/off, not boolean: capability configs arrive from the form as strings, and a select renders an enum.
166
167
  const hostScope = z.enum(["on", "off"]);
167
- export const HostScopesSchema = z.object({
168
+ export const DeviceScopesSchema = z.object({
168
169
  // Run commands in a real shell (PowerShell on Windows, the login shell on Linux). Off ⇒ files/screen only.
169
170
  shell: hostScope.default("on"),
170
171
  // Create, modify and trash files under `roots`. Reads are always allowed within them; this is the write half.
@@ -181,8 +182,8 @@ export const HostScopesSchema = z.object({
181
182
  // One directory per line. Empty ⇒ the machine's home directory, which is what the agent reports at connect.
182
183
  roots: z.string().optional(),
183
184
  });
184
- export type HostScopes = z.infer<typeof HostScopesSchema>;
185
- export const HostConfigSchema = HostScopesSchema.extend({ platform: z.string().min(1) });
185
+ export type DeviceScopes = z.infer<typeof DeviceScopesSchema>;
186
+ export const DeviceConfigSchema = DeviceScopesSchema.extend({ platform: z.string().min(1) });
186
187
  // An ACP agent served as a chat provider: the daemon spawns `command` as a long-lived JSON-RPC subprocess, and the
187
188
  // capability id becomes the provider id. `env` is a pasted KEY=VALUE block, the whole secret field; `loginCommand` runs
188
189
  // an interactive device-code login in a visible terminal.
@@ -287,6 +288,14 @@ export const WalletConfigSchema = z.object({
287
288
  deny: z.string().optional(),
288
289
  });
289
290
  export type WalletConfig = z.infer<typeof WalletConfigSchema>;
291
+ // The owner's Intentic account, reachable from inside this sandbox: what lets it create sandboxes on that account
292
+ // instead of asking a person to fetch a setup code from the browser. The token is minted at Settings ▸ Tokens and is
293
+ // account-scoped, so it is held by the daemon and never injected into the agent's env — the approval card on every
294
+ // create is only an approval while the agent cannot mint a claim behind it.
295
+ export const FleetConfigSchema = z.object({
296
+ token: z.string().min(1).describe("A provisioning token from Settings ▸ Tokens. Stored, never echoed back."),
297
+ });
298
+ export type FleetConfig = z.infer<typeof FleetConfigSchema>;
290
299
  export type McpConfig = z.infer<typeof McpConfigSchema>;
291
300
  export type CliConfig = z.infer<typeof CliConfigSchema>;
292
301
  export type PluginConfig = z.infer<typeof PluginConfigSchema>;
@@ -294,7 +303,7 @@ export type ExtensionConfig = z.infer<typeof ExtensionConfigSchema>;
294
303
  export type SshConfig = z.infer<typeof SshConfigSchema>;
295
304
  export type DockerConfig = z.infer<typeof DockerConfigSchema>;
296
305
  export type BrowserConfig = z.infer<typeof BrowserConfigSchema>;
297
- export type HostConfig = z.infer<typeof HostConfigSchema>;
306
+ export type DeviceConfig = z.infer<typeof DeviceConfigSchema>;
298
307
  export type AcpAgentConfig = z.infer<typeof AcpAgentConfigSchema>;
299
308
  export type EndpointConfig = z.infer<typeof EndpointConfigSchema>;
300
309
  export const CapabilitySchema = z.discriminatedUnion("kind", [
@@ -321,7 +330,7 @@ export const CapabilitySchema = z.discriminatedUnion("kind", [
321
330
  z.object({ id: entryId, kind: z.literal("browser"), config: BrowserConfigSchema }),
322
331
  // One email identity browser accounts are born from; they join it via their own `identity` field.
323
332
  z.object({ id: entryId, kind: z.literal("identity"), config: IdentityConfigSchema }),
324
- z.object({ id: entryId, kind: z.literal("host"), config: HostConfigSchema }),
333
+ z.object({ id: entryId, kind: z.literal("device"), config: DeviceConfigSchema }),
325
334
  // The user's own browser, through their installed extension; `host`'s sibling, one capability per browser. Distinct
326
335
  // from `browser`, the sandbox's own Chromium profile: this one is the person's, already signed into everything,
327
336
  // only ever borrowed.
@@ -335,6 +344,8 @@ export const CapabilitySchema = z.discriminatedUnion("kind", [
335
344
  z.object({ id: entryId, kind: z.literal("localmodel"), config: LocalModelConfigSchema }),
336
345
  // The sandbox's USDC wallet (WalletConfigSchema), one per sandbox; the key never enters the container.
337
346
  z.object({ id: entryId, kind: z.literal("wallet"), config: WalletConfigSchema }),
347
+ // The owner's account, one per sandbox: the box holding this may provision sandboxes on it.
348
+ z.object({ id: entryId, kind: z.literal("fleet"), config: FleetConfigSchema }),
338
349
  ]);
339
350
  export type Capability = z.infer<typeof CapabilitySchema>;
340
351
  // A credential the owner must type elsewhere (a WhatsApp pairing code); rendered large and copyable, not buried in
@@ -365,7 +376,7 @@ export const CapabilitySummarySchema = z.object({
365
376
  // by catalog card, not kind, since several cards share one kind. Re-derived on every read, so it drops out once its
366
377
  // evidence moves; `prefill` is never a secret, even one sitting in a checked-in file.
367
378
  export const CapabilityRecommendationSchema = z.object({
368
- card: z.string().describe("Which connection is being suggested."),
379
+ entry: z.string().describe("Which catalog entry is being suggested."),
369
380
  evidence: z
370
381
  .string()
371
382
  .describe("What was seen that prompted it: a file, a remote, printed verbatim so the claim can be checked rather than believed."),
@@ -398,9 +409,9 @@ export const CapabilityConnectionSchema = z.object({
398
409
  .describe("Its settings exactly as stored, credentials included. The field names are its own kind's, which the caller already knows."),
399
410
  });
400
411
  export type CapabilityConnection = z.infer<typeof CapabilityConnectionSchema>;
401
- // DELETE /capabilities/recommendations/{card}: the declined evidence is recorded daemon-side, so the suggestion returns
412
+ // DELETE /capabilities/recommendations/{entry}: the declined evidence is recorded daemon-side, so the suggestion returns
402
413
  // once the workspace changes under it.
403
- export const CapabilityCardParamSchema = z.object({ card: z.string().describe("Which suggestion to stop making.") });
414
+ export const CapabilityEntryParamSchema = z.object({ entry: z.string().describe("Which suggestion to stop making.") });
404
415
  // POST /capabilities/{id}/secret: replaces just the per-kind secret field and re-runs the capability's idempotent
405
416
  // apply.
406
417
  export const CapabilitySecretInputSchema = z.object({
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ import { AgentProviderSchema } from "./agent.js";
3
+ import { entryId } from "./internal.js";
4
+
5
+ // One reading, asked once per chat on the message it opens with, answering whichever of two questions the chat still
6
+ // has: which persona handles it, and what it runs on. Both are the same classification over lists the sandbox supplies,
7
+ // both are only answerable before anything has run, and neither is worth a model call of its own — so there is one
8
+ // call, and `model`/`persona` on the ask say which halves it is being paid to answer.
9
+
10
+ export const ChatRouteAskSchema = z.object({
11
+ prompt: z.string().min(1).max(20000).describe("The message a new chat is about to open with."),
12
+ paths: z
13
+ .array(z.string().min(1).max(500))
14
+ .max(50)
15
+ .default([])
16
+ .describe("Workspace paths the message names: uploads, @-mentions, the editor's own file. How much real code the work touches, and which persona's ground it stands on."),
17
+ folder: z.string().max(200).optional().describe("The workspace folder the chat was opened in, when it was opened in one."),
18
+ editorContext: z.boolean().optional().describe("Whether the message carries a file and selection the user pointed at, so it is about real code."),
19
+ planMode: z.boolean().optional().describe("Whether the chat opens in plan mode, which is a request to think before acting."),
20
+ // The two halves, each a fact about THIS chat rather than a setting: a chat already pointed at a model or a persona
21
+ // by hand has nothing left to ask about it, whatever the sandbox's settings say.
22
+ model: z.boolean().describe("Whether to choose the model, effort and account: true when the chat is on Auto and nothing has been picked by hand."),
23
+ persona: z.boolean().describe("Whether to choose the persona: true when persona matching is on and the chat has not been pointed at one by hand."),
24
+ });
25
+ export type ChatRouteAsk = z.infer<typeof ChatRouteAskSchema>;
26
+
27
+ export const ModelPickSchema = z.object({
28
+ provider: AgentProviderSchema.describe("Which provider serves the conversation."),
29
+ model: z.string().min(1).describe("Which of its models."),
30
+ effort: z.string().optional().describe("How hard it should think, where the model offers a choice. Absent takes the model's own default."),
31
+ account: z
32
+ .string()
33
+ .optional()
34
+ .describe("Which connected account pays, by its daemon-minted id. Absent leaves it to whichever account has the most headroom."),
35
+ });
36
+ export type ModelPick = z.infer<typeof ModelPickSchema>;
37
+
38
+ // Absent `id` is a real, safe answer: the chat stays open to every account, and the reason says why nothing was named.
39
+ export const PersonaVerdictSchema = z.object({
40
+ id: entryId.optional().describe("The persona this message belongs to, or absent when none does."),
41
+ reason: z.string().describe("Why, in the one clause a chat can show. Present whether or not a persona was named."),
42
+ });
43
+
44
+ // Absent `pick` is a real, safe answer: the chat runs on whatever the picker remembered, and the reason says why.
45
+ export const ModelVerdictSchema = z.object({
46
+ pick: ModelPickSchema.optional().describe("What the conversation should run on, or absent when nothing could be chosen and the usual pick stands."),
47
+ reason: z.string().describe("Why, in the one clause a chat can show. Present whether or not a model was named."),
48
+ });
49
+
50
+ // One verdict per half asked for; a half that wasn't asked is absent, never a verdict with an empty answer.
51
+ export const ChatRouteSchema = z.object({
52
+ persona: PersonaVerdictSchema.optional().describe("The persona half's answer, present only when it was asked for."),
53
+ model: ModelVerdictSchema.optional().describe("The model half's answer, present only when it was asked for."),
54
+ // Absent means nothing was spent: a folder match, the only runnable model, an unset role, a spent chain, or the
55
+ // deadline passing before any rung answered.
56
+ judge: z
57
+ .string()
58
+ .optional()
59
+ .describe("Which model answered, as `provider:model`, so the chat can name what the reading cost. Absent when no model was reached at all."),
60
+ });
61
+ export type ChatRoute = z.infer<typeof ChatRouteSchema>;
@@ -0,0 +1,38 @@
1
+ import { test, expect } from "bun:test";
2
+ import { contextTrimLine } from "./context-trim.js";
3
+
4
+ // The one thing a reader ever sees of this mechanism. It has to answer three questions in one line: why the turn ran
5
+ // thin, what it lost, and whether the reader's own instructions were among the losses.
6
+
7
+ test("the window comes first, then what went, then what stayed", () => {
8
+ const line = contextTrimLine({ window: 16_384, omitted: ["Map of this project", "How this sandbox asks agents to work"], base: true });
9
+
10
+ expect(line).toContain("16k window");
11
+ expect(line).toContain("Map of this project, How this sandbox asks agents to work");
12
+ // Without this clause the line reads as "your AGENTS.md may not have arrived", which is the one thing it is not.
13
+ expect(line).toContain("Your workspace rules");
14
+ });
15
+
16
+ // A swapped base is a bigger change than any note and is stated as its own fact, not folded into the list.
17
+ test("a swapped base says so, and a kept one does not", () => {
18
+ const swapped = contextTrimLine({ window: 16_384, omitted: [], base: true });
19
+ const kept = contextTrimLine({ window: 32_768, omitted: ["Map of this project"], base: false });
20
+
21
+ expect(swapped).toContain("base instructions were swapped");
22
+ expect(kept).not.toContain("base instructions");
23
+ });
24
+
25
+ // Nothing to list happens for real: a turn whose optional notes were all off already, on a window small enough to swap
26
+ // the base. The sentence must not trail off into an empty list.
27
+ test("an empty list leaves no dangling clause", () => {
28
+ const line = contextTrimLine({ window: 16_384, omitted: [], base: true });
29
+
30
+ expect(line).toContain("16k window.");
31
+ expect(line).not.toContain("left out");
32
+ });
33
+
34
+ // A user's own server can be started with any number at all, and rounding it to "24k" would misquote the thing the
35
+ // reader has to go and change.
36
+ test("a window that is not a round number is spelled out", () => {
37
+ expect(contextTrimLine({ window: 24_000, omitted: ["Map of this project"], base: false })).toContain("24,000 window");
38
+ });
@@ -0,0 +1,29 @@
1
+ // context-trim: what a window too small for a full turn made the sandbox leave out
2
+ import { z } from "zod";
3
+
4
+ // The only disclosure of a note that never rode; without it a turn runs thinner and nothing on screen says why.
5
+ export const ContextTrimSchema = z.object({
6
+ window: z.number().int().positive().describe("The window that decided it, in tokens, as the model's server declared it."),
7
+ omitted: z
8
+ .array(z.string())
9
+ .describe("What was left out, under the same label the chat would have drawn it with, in the order a full turn would have read them."),
10
+ base: z
11
+ .boolean()
12
+ .describe(
13
+ "Whether the agent loop's own base instructions were swapped for a short paragraph as well, which only a replacing runtime can do.",
14
+ ),
15
+ });
16
+ export type ContextTrim = z.infer<typeof ContextTrimSchema>;
17
+
18
+ // 16k, 32k; a server's own odd number spelled out in full.
19
+ const windowLabel = (tokens: number): string => (tokens % 1024 === 0 ? `${tokens / 1024}k` : tokens.toLocaleString("en-US"));
20
+
21
+ // The closing clause is load-bearing: without it the row reads as "my AGENTS.md may not have arrived".
22
+ export const contextTrimLine = ({ window, omitted, base }: ContextTrim): string => {
23
+ const swapped = base ? ` The agent's own base instructions were swapped for a short paragraph, too.` : ``;
24
+ const left = omitted.length === 0 ? `` : ` — left out: ${omitted.join(`, `)}`;
25
+ return (
26
+ `Sent thin for this model's ${windowLabel(window)} window${left}.${swapped}` +
27
+ ` Your workspace rules, this turn's persona and where its files live still rode.`
28
+ );
29
+ };
@@ -1,4 +1,4 @@
1
- import { expect, test } from "vitest";
1
+ import { test, expect } from "bun:test";
2
2
  import { DEV_REBUILD_EXIT_MARK, DEV_REBUILD_QUIET_MARK, devRebuildLogPath, readDevRebuildLog } from "./devices.js";
3
3
 
4
4
  // The one account a detached rebuild gives of itself. The daemon builds the shell that writes this text and the browser