@intentic/sandbox-contract 1.345.0 → 1.347.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 (222) hide show
  1. package/README.md +26 -14
  2. package/dist/contracts/accounts.contract.d.ts +0 -80
  3. package/dist/contracts/accounts.contract.d.ts.map +1 -1
  4. package/dist/contracts/agent.contract.d.ts +33 -2
  5. package/dist/contracts/agent.contract.d.ts.map +1 -1
  6. package/dist/contracts/agents.contract.d.ts +910 -29
  7. package/dist/contracts/agents.contract.d.ts.map +1 -1
  8. package/dist/contracts/agents.contract.js +43 -2
  9. package/dist/contracts/agents.contract.js.map +1 -1
  10. package/dist/contracts/device.contract.d.ts +2 -1
  11. package/dist/contracts/device.contract.d.ts.map +1 -1
  12. package/dist/contracts/endpoints.contract.d.ts +0 -1
  13. package/dist/contracts/endpoints.contract.d.ts.map +1 -1
  14. package/dist/contracts/providers.contract.d.ts +0 -1
  15. package/dist/contracts/providers.contract.d.ts.map +1 -1
  16. package/dist/contracts/runner.contract.d.ts +134 -123
  17. package/dist/contracts/runner.contract.d.ts.map +1 -1
  18. package/dist/contracts/settings.contract.d.ts +4 -0
  19. package/dist/contracts/settings.contract.d.ts.map +1 -1
  20. package/dist/contracts/system.contract.d.ts +102 -1
  21. package/dist/contracts/system.contract.d.ts.map +1 -1
  22. package/dist/contracts/translator.contract.d.ts +0 -80
  23. package/dist/contracts/translator.contract.d.ts.map +1 -1
  24. package/dist/events/agent-events.d.ts +43 -0
  25. package/dist/events/agent-events.d.ts.map +1 -1
  26. package/dist/events/agent-events.js +42 -37
  27. package/dist/events/agent-events.js.map +1 -1
  28. package/dist/events/failure-rows.d.ts +14 -0
  29. package/dist/events/failure-rows.d.ts.map +1 -0
  30. package/dist/events/failure-rows.js +109 -0
  31. package/dist/events/failure-rows.js.map +1 -0
  32. package/dist/events/sandbox-notice.d.ts +16 -0
  33. package/dist/events/sandbox-notice.d.ts.map +1 -1
  34. package/dist/events/sandbox-notice.js +1 -0
  35. package/dist/events/sandbox-notice.js.map +1 -1
  36. package/dist/events/system-events.d.ts +68 -0
  37. package/dist/events/system-events.d.ts.map +1 -1
  38. package/dist/events/system-events.js +7 -1
  39. package/dist/events/system-events.js.map +1 -1
  40. package/dist/index.d.ts +1158 -303
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/models/agent-lanes.d.ts.map +1 -1
  43. package/dist/models/agent-lanes.js +3 -5
  44. package/dist/models/agent-lanes.js.map +1 -1
  45. package/dist/models/agent-standing.d.ts +3 -20
  46. package/dist/models/agent-standing.d.ts.map +1 -1
  47. package/dist/models/agent-standing.js +12 -37
  48. package/dist/models/agent-standing.js.map +1 -1
  49. package/dist/models/model-pins.d.ts +3 -0
  50. package/dist/models/model-pins.d.ts.map +1 -1
  51. package/dist/models/model-pins.js +2 -0
  52. package/dist/models/model-pins.js.map +1 -1
  53. package/dist/models/plan-pools.d.ts +23 -5
  54. package/dist/models/plan-pools.d.ts.map +1 -1
  55. package/dist/models/plan-pools.js +116 -33
  56. package/dist/models/plan-pools.js.map +1 -1
  57. package/dist/protocol/doors/runner-protocol.d.ts +1 -0
  58. package/dist/protocol/doors/runner-protocol.d.ts.map +1 -1
  59. package/dist/protocol/doors/runner-protocol.js +1 -0
  60. package/dist/protocol/doors/runner-protocol.js.map +1 -1
  61. package/dist/protocol/raw/raw-json-routes.d.ts +3 -1
  62. package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
  63. package/dist/protocol/raw/raw-routes.d.ts +4 -0
  64. package/dist/protocol/raw/raw-routes.d.ts.map +1 -1
  65. package/dist/protocol/raw/raw-routes.js +1 -0
  66. package/dist/protocol/raw/raw-routes.js.map +1 -1
  67. package/dist/schemas/agent-next.d.ts +21 -0
  68. package/dist/schemas/agent-next.d.ts.map +1 -0
  69. package/dist/schemas/agent-next.js +21 -0
  70. package/dist/schemas/agent-next.js.map +1 -0
  71. package/dist/schemas/agent-search.d.ts +45 -0
  72. package/dist/schemas/agent-search.d.ts.map +1 -0
  73. package/dist/schemas/agent-search.js +28 -0
  74. package/dist/schemas/agent-search.js.map +1 -0
  75. package/dist/schemas/agent.d.ts +9 -0
  76. package/dist/schemas/agent.d.ts.map +1 -1
  77. package/dist/schemas/agent.js +14 -10
  78. package/dist/schemas/agent.js.map +1 -1
  79. package/dist/schemas/agents-list.d.ts +16 -0
  80. package/dist/schemas/agents-list.d.ts.map +1 -1
  81. package/dist/schemas/agents.d.ts +48 -43
  82. package/dist/schemas/agents.d.ts.map +1 -1
  83. package/dist/schemas/agents.js +10 -33
  84. package/dist/schemas/agents.js.map +1 -1
  85. package/dist/schemas/compose-overlay.d.ts +9 -0
  86. package/dist/schemas/compose-overlay.d.ts.map +1 -0
  87. package/dist/schemas/compose-overlay.js +11 -0
  88. package/dist/schemas/compose-overlay.js.map +1 -0
  89. package/dist/schemas/composer-drafts.d.ts +169 -0
  90. package/dist/schemas/composer-drafts.d.ts.map +1 -0
  91. package/dist/schemas/composer-drafts.js +60 -0
  92. package/dist/schemas/composer-drafts.js.map +1 -0
  93. package/dist/schemas/device-commands.d.ts +63 -0
  94. package/dist/schemas/device-commands.d.ts.map +1 -0
  95. package/dist/schemas/device-commands.js +42 -0
  96. package/dist/schemas/device-commands.js.map +1 -0
  97. package/dist/schemas/device-folder.d.ts +11 -0
  98. package/dist/schemas/device-folder.d.ts.map +1 -1
  99. package/dist/schemas/device-folder.js +1 -0
  100. package/dist/schemas/device-folder.js.map +1 -1
  101. package/dist/schemas/devices.d.ts +14 -67
  102. package/dist/schemas/devices.d.ts.map +1 -1
  103. package/dist/schemas/devices.js +7 -45
  104. package/dist/schemas/devices.js.map +1 -1
  105. package/dist/schemas/environment.d.ts.map +1 -1
  106. package/dist/schemas/environment.js.map +1 -1
  107. package/dist/schemas/hosts.d.ts +27 -0
  108. package/dist/schemas/hosts.d.ts.map +1 -1
  109. package/dist/schemas/hosts.js +21 -0
  110. package/dist/schemas/hosts.js.map +1 -1
  111. package/dist/schemas/privacy-egress.d.ts +41 -0
  112. package/dist/schemas/privacy-egress.d.ts.map +1 -0
  113. package/dist/schemas/privacy-egress.js +30 -0
  114. package/dist/schemas/privacy-egress.js.map +1 -0
  115. package/dist/schemas/privacy.d.ts +1 -0
  116. package/dist/schemas/privacy.d.ts.map +1 -1
  117. package/dist/schemas/privacy.js +1 -0
  118. package/dist/schemas/privacy.js.map +1 -1
  119. package/dist/schemas/providers/plan-limits.d.ts +2 -130
  120. package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
  121. package/dist/schemas/providers/plan-limits.js +5 -23
  122. package/dist/schemas/providers/plan-limits.js.map +1 -1
  123. package/dist/schemas/providers/provider-oauth.d.ts +0 -82
  124. package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
  125. package/dist/schemas/providers/provider-oauth.js +1 -6
  126. package/dist/schemas/providers/provider-oauth.js.map +1 -1
  127. package/dist/schemas/providers/refusals.d.ts +64 -0
  128. package/dist/schemas/providers/refusals.d.ts.map +1 -0
  129. package/dist/schemas/providers/refusals.js +33 -0
  130. package/dist/schemas/providers/refusals.js.map +1 -0
  131. package/dist/schemas/sessions.js +1 -1
  132. package/dist/schemas/sessions.js.map +1 -1
  133. package/dist/schemas/settings.d.ts +4 -0
  134. package/dist/schemas/settings.d.ts.map +1 -1
  135. package/dist/schemas/settings.js +2 -0
  136. package/dist/schemas/settings.js.map +1 -1
  137. package/dist/schemas/system.d.ts +16 -0
  138. package/dist/schemas/system.d.ts.map +1 -1
  139. package/dist/schemas/system.js +11 -0
  140. package/dist/schemas/system.js.map +1 -1
  141. package/dist/state/definition.d.ts +8 -0
  142. package/dist/state/definition.d.ts.map +1 -1
  143. package/dist/state/runtime-state.d.ts +3 -0
  144. package/dist/state/runtime-state.d.ts.map +1 -1
  145. package/dist/state/runtime-state.js +1 -0
  146. package/dist/state/runtime-state.js.map +1 -1
  147. package/dist/state/sandbox-build.d.ts +20 -0
  148. package/dist/state/sandbox-build.d.ts.map +1 -0
  149. package/dist/state/sandbox-build.js +43 -0
  150. package/dist/state/sandbox-build.js.map +1 -0
  151. package/dist/state/ways-back.d.ts +4 -0
  152. package/dist/state/ways-back.d.ts.map +1 -0
  153. package/dist/state/ways-back.js +8 -0
  154. package/dist/state/ways-back.js.map +1 -0
  155. package/dist/text/land-rows.d.ts +14 -0
  156. package/dist/text/land-rows.d.ts.map +1 -0
  157. package/dist/text/land-rows.js +86 -0
  158. package/dist/text/land-rows.js.map +1 -0
  159. package/dist/text/transcript-fold.d.ts +4 -2
  160. package/dist/text/transcript-fold.d.ts.map +1 -1
  161. package/dist/text/transcript-fold.js +21 -187
  162. package/dist/text/transcript-fold.js.map +1 -1
  163. package/package.json +6 -6
  164. package/src/contracts/agents.contract.ts +49 -25
  165. package/src/events/agent-events.ts +95 -78
  166. package/src/events/failure-rows.ts +148 -0
  167. package/src/events/sandbox-notice.ts +11 -2
  168. package/src/events/system-events.ts +14 -4
  169. package/src/ids/project-dir.test.ts +2 -2
  170. package/src/ids/project-dir.ts +4 -4
  171. package/src/models/agent-lanes.ts +7 -21
  172. package/src/models/agent-runtimes.ts +2 -2
  173. package/src/models/agent-standing.ts +31 -96
  174. package/src/models/model-pins.test.ts +31 -1
  175. package/src/models/model-pins.ts +17 -0
  176. package/src/models/plan-pools.test.ts +159 -24
  177. package/src/models/plan-pools.ts +215 -83
  178. package/src/protocol/doors/runner-protocol.ts +4 -1
  179. package/src/protocol/raw/raw-routes.ts +1 -0
  180. package/src/schemas/agent-next.ts +34 -0
  181. package/src/schemas/agent-search.ts +51 -0
  182. package/src/schemas/agent.ts +21 -12
  183. package/src/schemas/agents.ts +24 -63
  184. package/src/schemas/compose-overlay.test.ts +35 -0
  185. package/src/schemas/compose-overlay.ts +19 -0
  186. package/src/schemas/composer-drafts.ts +86 -0
  187. package/src/schemas/device-commands.ts +89 -0
  188. package/src/schemas/device-folder.ts +9 -0
  189. package/src/schemas/devices.ts +24 -106
  190. package/src/schemas/environment.ts +8 -9
  191. package/src/schemas/hosts.ts +56 -0
  192. package/src/schemas/machine-presence.test.ts +33 -0
  193. package/src/schemas/privacy-egress.test.ts +83 -0
  194. package/src/schemas/privacy-egress.ts +86 -0
  195. package/src/schemas/privacy.ts +4 -0
  196. package/src/schemas/project-delivery.ts +1 -1
  197. package/src/schemas/providers/plan-limits.test.ts +16 -0
  198. package/src/schemas/providers/plan-limits.ts +8 -49
  199. package/src/schemas/providers/provider-oauth.ts +1 -13
  200. package/src/schemas/providers/refusals.ts +62 -0
  201. package/src/schemas/sessions.ts +1 -1
  202. package/src/schemas/settings.ts +2 -0
  203. package/src/schemas/system.ts +15 -0
  204. package/src/state/runtime-state.ts +5 -0
  205. package/src/state/sandbox-build.test.ts +95 -0
  206. package/src/state/sandbox-build.ts +96 -0
  207. package/src/state/ways-back.test.ts +36 -0
  208. package/src/state/ways-back.ts +19 -0
  209. package/src/state/workspace-state.ts +1 -1
  210. package/src/text/land-rows.ts +111 -0
  211. package/src/text/transcript-fold.test.ts +38 -9
  212. package/src/text/transcript-fold.ts +30 -232
  213. package/dist/events/undelivered.d.ts +0 -10
  214. package/dist/events/undelivered.d.ts.map +0 -1
  215. package/dist/events/undelivered.js +0 -22
  216. package/dist/events/undelivered.js.map +0 -1
  217. package/dist/state/history-state.d.ts +0 -3
  218. package/dist/state/history-state.d.ts.map +0 -1
  219. package/dist/state/history-state.js +0 -153
  220. package/dist/state/history-state.js.map +0 -1
  221. package/src/events/undelivered.ts +0 -33
  222. package/src/state/history-state.ts +0 -313
@@ -1,5 +1,6 @@
1
1
  // agents: the conversation fleet
2
2
  import { LandingDeliverySchema } from "./project-delivery.js";
3
+ import { AgentNextSchema } from "./agent-next.js";
3
4
  import { z } from "zod";
4
5
  import { AgentHarnessSchema, AgentOriginSchema, AgentProviderSchema, ConversationQueueSchema, ForkedFromSchema, FreshSessionSchema } from "./agent.js";
5
6
  import { LoopStateSchema } from "./loops.js";
@@ -294,21 +295,27 @@ export const AgentSummarySchema = z.object({
294
295
  .describe(
295
296
  "Which kind of failure it was, as the turn's own error frame coded it. Absent for a failure nothing could classify, which reads as the plain red line it is.",
296
297
  ),
297
- // Epoch seconds the spent allowance reopens; absent means genuinely unknown (Grok, Cursor) — never guess one.
298
- limitResetsAt: z.number().optional().describe("When the spent allowance reopens, in epoch seconds. Absent when the provider publishes no instant."),
299
- limitHeld: z.boolean().optional().describe("Whether the refused turn is held whole, so sending again re-runs it instead of appending to it."),
300
- // Whether the daemon has already booked a retry at the reset; the one thing that keeps a stranded card out of the Attention lane.
301
- limitScheduled: z.boolean().optional().describe("Whether the held turn is already booked to go again at the reset, so nobody has to press anything."),
302
- // Account a booked move is sending the held turn to; set alongside `limitScheduled`, cleared by the move's own turn.
303
- limitMoving: z.string().optional().describe("The account the held turn is being moved to by the owner's policy, while that move is booked."),
304
- // The resume pass's own instant for the booked re-run, the one it fires by (limit-due.ts), so a card states when it
305
- // goes rather than inferring it from the reset: they differ once the account it goes on is still spent at the reset.
306
- limitNextAt: z
307
- .number()
298
+ // Who worded `failure`, as the turn's error frame said (its `own`): a card leads with those words only when they are
299
+ // the sandbox's own sentence, as the chat's notice does (failureView), and with its kind's line otherwise.
300
+ failureOwn: z
301
+ .boolean()
308
302
  .optional()
309
303
  .describe(
310
- "When the held turn goes again by itself, in epoch seconds, while it is booked to (limitScheduled): later than the reset where the account it goes on is still spent, and already past for a booked move, which goes on the next pass. Absent while only a press sends it.",
304
+ "Whether `failure` is the sandbox's own sentence rather than the provider's or the harness's words. Absent for theirs, and for an ending recorded before it was said.",
311
305
  ),
306
+ // Epoch seconds the spent allowance reopens; absent means genuinely unknown (Grok, Cursor) — never guess one.
307
+ limitResetsAt: z.number().optional().describe("When the spent allowance reopens, in epoch seconds. Absent when the provider publishes no instant."),
308
+ limitHeld: z.boolean().optional().describe("Whether the refused turn is held whole, so sending again re-runs it instead of appending to it."),
309
+ // What a sandbox older than `next` (before 1.343.0) said in its place, read so the editor can derive `next` from it
310
+ // (agentStatus-standing.ts, withNext): the hosted editor meets such a sandbox for as long as it stays off the release
311
+ // that dropped them, which carried a Breaking-Note and so is never taken by itself. No sandbox sends them since.
312
+ limitScheduled: z.boolean().optional().describe("Sent only by sandboxes older than `next`: whether the held turn was booked to go again by itself."),
313
+ limitMoving: z.string().optional().describe("Sent only by sandboxes older than `next`: the account a booked move was taking the held turn to."),
314
+ limitNextAt: z.number().optional().describe("Sent only by sandboxes older than `next`: when the booked re-run went, in epoch seconds."),
315
+ // The one answer to "does it go by itself, and when" (agent-next.ts), derived from what it holds every time it is read.
316
+ next: AgentNextSchema.optional().describe(
317
+ "What carries it on next with nobody pressing anything, and when: a held turn re-run, a message held for memory, or messages booked for later. Absent when only a press moves it.",
318
+ ),
312
319
  provider: AgentProviderSchema.describe("Which model provider it runs on."),
313
320
  harness: AgentHarnessSchema.describe("Which agentic loop it runs on."),
314
321
  // Latched with the conversation, so the card can say where work runs without asking anything else.
@@ -475,7 +482,8 @@ export const AgentSummarySchema = z.object({
475
482
  .describe(
476
483
  "When somebody last opened it, in milliseconds. Newer activity than this is what makes it unread. Kept by the sandbox rather than by a browser, so clearing site data or picking up a phone does not resurrect every badge.",
477
484
  ),
478
- // Browser-side words the daemon cannot read, reported so the unattended archive sweep never files them away.
485
+ // Derived from the drafts the sandbox holds (composer-drafts.ts), or reported by a client that keeps its own, so the
486
+ // unattended archive sweep never files away a conversation somebody is still writing to.
479
487
  unsentAt: z
480
488
  .number()
481
489
  .optional()
@@ -512,10 +520,10 @@ export const AgentSummarySchema = z.object({
512
520
  .describe(
513
521
  "The last attempt to bring its work into the workspace failed, and why. Cleared by the next land that goes through. Absent when nothing failed, and from a sandbox older than it.",
514
522
  ),
515
- // The land's own last word on the owner's folder, for a project attached to this computer's sandbox: written there,
523
+ // The land's own last word on the owner's folder, for a sandbox made for one folder: written there,
516
524
  // partly kept out, waiting for the computer, or refused (schemas/project-delivery.ts).
517
525
  delivery: LandingDeliverySchema.optional().describe(
518
- "What became of its last land in the folder on the owner's computer, for a project folder attached to this computer's sandbox. Absent for any other land, and from a sandbox older than it.",
526
+ "What became of its last land in the folder on the owner's computer, for a sandbox made for one folder. Absent for any other land, and from a sandbox older than it.",
519
527
  ),
520
528
  // Only the causes that still hold, re-read live (conversations/land/standing.ts), never the stored report's own list: a
521
529
  // blocker the user has since cleared is not something to offer them an action about.
@@ -809,53 +817,6 @@ export const AgentsRemovedSchema = z.object({
809
817
  ),
810
818
  });
811
819
  export type AgentsRemoved = z.infer<typeof AgentsRemovedSchema>;
812
- // Searches only speech, both sides of the conversation, never thinking, tool output or protocol text, which would
813
- // otherwise match nearly the whole board. Two-char minimum; `caseSensitive` off means case-insensitive, matching
814
- // workspace search's own switch.
815
- export const AgentSearchQuerySchema = z.object({
816
- query: z
817
- .string()
818
- .trim()
819
- .min(2)
820
- .describe(
821
- "What to look for. Searched against what was said, both sides of the conversation, and nothing else: not the thinking, not the tool output, which between them name nearly every identifier in the workspace and would return most of the board.",
822
- ),
823
- caseSensitive: z.stringbool().optional().describe("Whether capitals matter."),
824
- });
825
- // Why a row survived the filter: the matched line, windowed, and who said it. `speaker` rides with the text since agent
826
- // prose alone can read as something the user typed.
827
- export const SpeakerSchema = z.enum(["user", "agent"]);
828
- export type Speaker = z.infer<typeof SpeakerSchema>;
829
- export const MatchSnippetSchema = z.object({
830
- text: z.string().describe("The matching line, with a little either side of it."),
831
- speaker: SpeakerSchema.describe(
832
- "Who said it. Carried with the words rather than beside them, because a line of the agent's prose under a card reads as something you typed until the row says otherwise.",
833
- ),
834
- });
835
- export type MatchSnippet = z.infer<typeof MatchSnippetSchema>;
836
- // One matching agent and its evidence; `snippet` is absent for a title match, which the card already shows.
837
- export const AgentMatchSchema = z.object({
838
- id: z.string().describe("Which conversation matched."),
839
- snippet: MatchSnippetSchema.optional().describe(
840
- "Why, in its own words. Absent when the title was the match, which the card already shows: repeating it underneath is noise where evidence was wanted.",
841
- ),
842
- });
843
- export type AgentMatch = z.infer<typeof AgentMatchSchema>;
844
- // How many agents were actually read, so the board can say a search saw less than the whole fleet.
845
- export const AgentSearchResultSchema = z.object({
846
- matches: z.array(AgentMatchSchema).describe("What matched, from the live fleet and the archive together."),
847
- scanned: z
848
- .number()
849
- .describe(
850
- "How many conversations were actually read, so a screen can say when a search saw less than everything rather than implying it saw all of it.",
851
- ),
852
- indexing: z
853
- .boolean()
854
- .describe(
855
- "Whether what was said is still being read in the background. True means this answer can still grow, so a screen must say it is incomplete rather than presenting it as the whole list.",
856
- ),
857
- });
858
- export type AgentSearchResult = z.infer<typeof AgentSearchResultSchema>;
859
820
  // The user-chosen display title, bounded like sanitizeTitle's own cap.
860
821
  export const AgentRenameSchema = z.object({
861
822
  id: z.string().min(1).describe("Which conversation."),
@@ -890,7 +851,7 @@ export const AgentAutoLandSchema = z.object({
890
851
  "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.",
891
852
  ),
892
853
  });
893
- // A composer's unsent words, reported by the editor that holds them: the words themselves stay in the browser.
854
+ // A composer's unsent words, reported by a client that keeps them to itself rather than writing them as a draft.
894
855
  export const AgentUnsentSchema = z.object({
895
856
  id: z.string().min(1).describe("Which conversation."),
896
857
  at: z
@@ -0,0 +1,35 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { packageRoot } from "@intentic/constants/node";
5
+ import { ComposedOverlayLineSchema } from "./compose-overlay.js";
6
+
7
+ // The lines a target image's composer prints, as examples: golden/compose-overlay.json, which ic's reader is held to
8
+ // (_sandbox/ic/src/sandbox/composition.rs). This is the TS half: each is a line the contract reads, its hash its overlay's.
9
+
10
+ interface ComposeCase {
11
+ readonly name: string;
12
+ readonly base: string;
13
+ readonly line: unknown;
14
+ }
15
+
16
+ // SAFETY: the golden is this package's own file; the assertions below fail on any field it lacks.
17
+ const GOLDEN = JSON.parse(readFileSync(join(packageRoot(import.meta.url), "golden", "compose-overlay.json"), "utf8")) as { readonly cases: readonly ComposeCase[] };
18
+
19
+ test("every golden line is a composer's line, its hash the overlay's own and its FROM the base it was asked for", () => {
20
+ expect(GOLDEN.cases.length).toBeGreaterThan(2);
21
+ for (const { base, line } of GOLDEN.cases) {
22
+ const read = ComposedOverlayLineSchema.parse(line);
23
+ expect(line).toEqual(read);
24
+ if (read.overlay === null) {
25
+ expect(read.hash).toBeUndefined();
26
+ continue;
27
+ }
28
+ expect(read.hash).toBe(createHash("sha256").update(read.overlay).digest("hex"));
29
+ expect(read.overlay).toContain(`\nFROM ${base}\n`);
30
+ }
31
+ });
32
+
33
+ test("a line in another format is not one this contract reads", () => {
34
+ expect(ComposedOverlayLineSchema.safeParse({ compose: 2, overlay: null }).success).toBe(false);
35
+ });
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+
3
+ // WHAT A TARGET IMAGE COMPOSES FOR A SANDBOX, as the host reads it: the one line the image's own composer prints
4
+ // (`node /opt/sandbox/dist/environment/compose-overlay.js --base <image>`, _sandbox/sandbox/src/environment/compose-overlay.ts) over read-only
5
+ // mounts of the sandbox's volumes before a swap onto it, which `ic` builds instead of the overlay the running daemon
6
+ // composed (_sandbox/ic/src/sandbox/composition.rs). golden/compose-overlay.json holds examples the Rust test reads.
7
+
8
+ // The one format `ic` reads; any other, or none, sends it back to the running daemon's overlay.
9
+ export const COMPOSE_OVERLAY_FORMAT = 1;
10
+
11
+ export const ComposedOverlayLineSchema = z.object({
12
+ compose: z.literal(COMPOSE_OVERLAY_FORMAT).describe("The format of this line. A reader takes no other."),
13
+ overlay: z
14
+ .string()
15
+ .nullable()
16
+ .describe("The Dockerfile the image's daemon composes on its first boot, byte for byte; null when the sandbox wants no overlay on it."),
17
+ hash: z.string().optional().describe("sha256 of `overlay`, what the new container is stamped with (SANDBOX_ENVIRONMENT_HASH). Absent with no overlay."),
18
+ });
19
+ export type ComposedOverlayLine = z.infer<typeof ComposedOverlayLineSchema>;
@@ -0,0 +1,86 @@
1
+ import { z } from "zod";
2
+ import { ATTACHMENT_LIMIT, ConversationIdSchema, ForkOfSchema, FreshOfSchema, QueuedServingSchema } from "./agent.js";
3
+
4
+ // A PERSON'S UNSENT WORDS FOR ONE CONVERSATION, held by the sandbox. Until 2026-10-20 a composer's words lived only in the
5
+ // browser: in each window's tab snapshot, moved into a per-browser "closed drafts" set when the tab closed and back out
6
+ // when it reopened, and broadcast between windows as previews. Every move was a chance to lose or duplicate them: a
7
+ // popped-out chat and the board each held a copy for a round trip, two windows wrote the same chat's words over each
8
+ // other, and another device never saw them at all. Now the sandbox holds the one record, per person and conversation, and
9
+ // a browser keeps only an optimistic copy of it. A draft never goes by itself (AgentSummary.next never names one), and it
10
+ // is not egress: no model reads it until the person sends it, when it becomes the message the privacy shield judges.
11
+
12
+ // The words as the composer holds them: what the message will say, its files, and who it will go to when it opens a
13
+ // conversation (the pick a draft for a conversation the sandbox has never seen has nowhere else to live).
14
+ const draftWords = {
15
+ text: z.string().max(200_000).describe("The words, exactly as typed."),
16
+ attachments: z
17
+ .array(z.string().min(1))
18
+ .max(ATTACHMENT_LIMIT)
19
+ .optional()
20
+ .describe("Files staged with it, as workspace paths already uploaded."),
21
+ serves: QueuedServingSchema.optional().describe(
22
+ "Who the message is to go to: the provider, loop, model, effort and account picked in the composer. Absent when nothing was picked.",
23
+ ),
24
+ forkOf: ForkOfSchema.optional().describe("Where the conversation this draft opens is cut from, when it is a fork no turn has started yet."),
25
+ freshOf: FreshOfSchema.optional().describe("The work a fresh session this draft opens starts from, when no turn has started it yet."),
26
+ };
27
+
28
+ export const ComposerDraftSchema = z.object({
29
+ conversationId: ConversationIdSchema.describe("The conversation the words are for. It may not exist yet: a new chat's words are a draft before its first turn."),
30
+ ...draftWords,
31
+ since: z.number().describe("When the composer first held these words unsent, in milliseconds."),
32
+ revision: z
33
+ .number()
34
+ .int()
35
+ .positive()
36
+ .describe("Moves with every write of any of your drafts. A write or a clear naming an older one is answered with the newer draft instead."),
37
+ });
38
+ export type ComposerDraft = z.infer<typeof ComposerDraftSchema>;
39
+
40
+ export const ComposerDraftsSchema = z.object({
41
+ drafts: z.array(ComposerDraftSchema).describe("Every draft you hold in this sandbox, newest first. Only your own: another member's unsent words are theirs."),
42
+ revision: z
43
+ .number()
44
+ .int()
45
+ .nonnegative()
46
+ .optional()
47
+ .describe(
48
+ "The newest revision handed out when this list was read. A draft you hold at a revision past it was written after the read, so its absence here says nothing about it.",
49
+ ),
50
+ });
51
+ export type ComposerDrafts = z.infer<typeof ComposerDraftsSchema>;
52
+
53
+ export const DraftWriteSchema = z.object({
54
+ id: ConversationIdSchema.describe("The conversation the words are for."),
55
+ ...draftWords,
56
+ since: z.number().optional().describe("When the words were first held unsent. Leave it out to keep the draft's own, or to start it now."),
57
+ base: z
58
+ .number()
59
+ .int()
60
+ .positive()
61
+ .optional()
62
+ .describe(
63
+ "The draft's revision these words were edited from. When the draft has been written since (another window, another device), nothing is written and the newer draft comes back with `conflict`, so no one's words are overwritten unseen. Leave it out to write a draft you have never read.",
64
+ ),
65
+ });
66
+ export type DraftWrite = z.infer<typeof DraftWriteSchema>;
67
+
68
+ export const DraftWrittenSchema = z.object({
69
+ draft: ComposerDraftSchema.optional().describe("The draft as it now stands. Absent when there is none: it was cleared, or sent, before this write arrived."),
70
+ conflict: z
71
+ .literal(true)
72
+ .optional()
73
+ .describe("Nothing was written: the draft had moved past `base`. Merge your words into this one and write again from its revision."),
74
+ });
75
+ export type DraftWritten = z.infer<typeof DraftWrittenSchema>;
76
+
77
+ export const DraftClearSchema = z.object({
78
+ id: ConversationIdSchema.describe("The conversation whose draft to clear."),
79
+ revision: z
80
+ .number()
81
+ .int()
82
+ .positive()
83
+ .optional()
84
+ .describe("Clear it only while it is still this revision, so words written since in another window survive. Leave it out to clear whatever is there."),
85
+ });
86
+ export type DraftClear = z.infer<typeof DraftClearSchema>;
@@ -0,0 +1,89 @@
1
+ import { z } from "zod";
2
+
3
+ // Closed set of command names; the daemon builds argv from the name alone (hosts/device-commands.ts). Never a free-text
4
+ // command: the same socket carries `run_command`, which would grant a shell on the user's machine.
5
+ // `sync-unpair` runs the machine's own `sync uninstall --sandbox` (terminates both Mutagen sessions, drops the pairing,
6
+ // self-revokes enrollment); revoking from the sandbox side is a different route.
7
+ // `dev-restart`, `dev-rebuild` and `sync-install` are the three whose argv the daemon fills from what only it knows — the
8
+ // dev checkout on that machine, a freshly minted pairing token — which is also why none takes a path or a token from the
9
+ // caller. The two dev commands are also why a machine-side op is the wrong shape for them: this door carries a COMMAND
10
+ // STRING to a tool every released agent already has, so a sandbox can drive a machine whose agent predates the feature.
11
+ // `dev-rebuild-log` is the read side of `dev-rebuild`: the build is detached out there, so its log is the only place its
12
+ // progress exists, and polling this is what turns a fired-and-forgotten command into something with a progress bar.
13
+ // `sync-clean` removes the build output left in directories the sandbox has deleted, which is what stops those
14
+ // deletions from ever landing. It deletes only content the session already ignores, so it is the one command here whose
15
+ // worst outcome is a rebuild — which is why it is a button and not a turn.
16
+ // `mirror-ignore`/`mirror-unignore` are `mirror-off`'s per-port form, and the reason they exist: a port number that is
17
+ // permanently taken on one machine's localhost has no answer in an all-or-nothing switch, so the choice lives where the
18
+ // conflict does — one port, one device, durable — instead of muting every port the pairing serves.
19
+ export const DeviceCommandSchema = z.enum([
20
+ "mirror-off",
21
+ "mirror-on",
22
+ "mirror-ignore",
23
+ "mirror-unignore",
24
+ "sync-pause",
25
+ "sync-resume",
26
+ "sync-unpair",
27
+ "sync-clean",
28
+ "dev-restart",
29
+ "dev-rebuild",
30
+ "dev-rebuild-log",
31
+ "sync-install",
32
+ ]);
33
+ export type DeviceCommand = z.infer<typeof DeviceCommandSchema>;
34
+ // The reversible sync switches: the subset a device's own row drives with a pair of buttons, as opposed to the two
35
+ // commands a card elsewhere issues once. Its own type so those button tables stay total without carrying entries for
36
+ // commands they can never send.
37
+ export const DeviceSyncSwitchSchema = DeviceCommandSchema.exclude(["dev-restart", "dev-rebuild", "dev-rebuild-log", "sync-install", "sync-clean"]);
38
+ export type DeviceSyncSwitch = z.infer<typeof DeviceSyncSwitchSchema>;
39
+
40
+ // Where a detached rebuild reports itself, and the reader of that report (dev-rebuild-log.ts).
41
+ export { DEV_REBUILD_EXIT_MARK, DEV_REBUILD_QUIET_MARK, type DevRebuildLog, devRebuildLogPath, readDevRebuildLog } from "./dev-rebuild-log.js";
42
+
43
+ // Machine's sandbox id (absent = every paired sandbox); must start alphanumeric, else it parses as a CLI flag.
44
+ export const DeviceSandboxIdSchema = z
45
+ .string()
46
+ .max(200)
47
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
48
+ // A folder on the DEVICE for `sync-install` to keep in step. The only caller-supplied string that reaches a command
49
+ // line, so its shape is the guard: `~`, an absolute POSIX path or a drive letter, and none of the characters that would
50
+ // end the argument it sits in. `~` is expanded by the daemon, which is why `$` is not allowed to arrive here. The line
51
+ // may be PowerShell, which reads the typographic quotes “ ” „ as double quotes and ‘ ’ ‚ ‛ as single quotes beside the
52
+ // ASCII ones, so those are refused too: the daemon quotes the folder, and this keeps a second guard where one quoter
53
+ // misses a character.
54
+ export const DeviceLocalDirSchema = z
55
+ .string()
56
+ .min(1)
57
+ .max(4096)
58
+ // The curly quotes as escapes: bun's transpiler rewrites a literal one in a regex to its escape and tsgo keeps it,
59
+ // so spelled literally the pattern reads differently from source and from dist, and contract.lock.json can match
60
+ // only one of them.
61
+ .regex(/^(?:~|\/|[A-Za-z]:[\\/])[^"'`$;|&\n\r\u201C\u201D\u201E\u2018\u2019\u201A\u201B]*$/);
62
+ // A TCP port number, the one value a caller supplies that reaches a command line as a number rather than a string.
63
+ export const PortNumberSchema = z.number().int().min(1).max(65535);
64
+ export const DeviceCommandInputSchema = z.object({
65
+ id: z.string().min(1),
66
+ command: DeviceCommandSchema,
67
+ sandboxId: DeviceSandboxIdSchema.optional(),
68
+ // `sync-install` only: which half to enroll (a member's "sync" still comes back mirror, decided daemon-side) and,
69
+ // for file sync, the folder on that device. The pairing token is minted by the daemon; no caller ever carries one.
70
+ mode: z.enum(["sync", "mirror"]).optional(),
71
+ localDir: DeviceLocalDirSchema.optional(),
72
+ // The two per-port mirror switches only. A number rather than a string, so nothing a caller sends can widen the
73
+ // command line it lands in.
74
+ port: PortNumberSchema.optional(),
75
+ });
76
+ export type DeviceCommandInput = z.infer<typeof DeviceCommandInputSchema>;
77
+ // `ok` is the command's own exit status, not this route's: a refusal or non-zero exit is a real answer, not a thrown
78
+ // error. Only an unreachable machine throws. `output` is the command's own printed text.
79
+ export const DeviceCommandResultSchema = z.object({
80
+ ok: z.boolean(),
81
+ message: z.string(),
82
+ output: z.string().optional(),
83
+ // WHICH KIND OF `ok: false` THIS IS, because a caller that repeats itself needs to tell them apart: the device
84
+ // turning the command away (a switch of its own is off, the path is out of its reach) will turn it away again,
85
+ // whereas a command it accepted and then killed at its deadline says nothing except that this attempt was too slow.
86
+ refused: z.boolean(),
87
+ });
88
+ export type DeviceCommandResult = z.infer<typeof DeviceCommandResultSchema>;
89
+
@@ -33,6 +33,15 @@ export type DeviceConflict = z.infer<typeof DeviceConflictSchema>;
33
33
  export const clearableOnDevice = (conflict: DeviceConflict): boolean =>
34
34
  conflict.nature === "derived-leftover" && conflict.local === "untracked" && conflict.sandbox === "deleted";
35
35
 
36
+ // WHY A FOLDER'S FILE SYNC IS HELD STILL, AND BY WHOM (2026-10-20). The machine agent reads each off a fact it keeps
37
+ // on the pairing and makes Mutagen agree every pass (its sync/holds.ts), so a pause never outlives its reason and a
38
+ // resume never undoes somebody else's. `owner` is a person's Pause, the only one a person lifts; every other is the
39
+ // agent's own and lifts by itself once its reason ends. `transfer` (2026-10-21) is a bring-back, a restore or a delivery
40
+ // of landed work writing into the folder. Strings on the wire, so a reason a newer agent adds still reads as held to an
41
+ // older page.
42
+ export const DeviceSyncHoldSchema = z.enum(["owner", "gone", "container-missing", "container-trashed", "swap", "unreachable", "low-disk", "transfer"]);
43
+ export type DeviceSyncHold = z.infer<typeof DeviceSyncHoldSchema>;
44
+
36
45
  // The drive reading that paused a pairing for want of room: when (epoch ms), and how much was free of how much.
37
46
  export const DeviceLowDiskSchema = z.object({
38
47
  since: z.number(),
@@ -1,7 +1,8 @@
1
1
  // What one of the user's own machines is running.
2
2
  import { z } from "zod";
3
+ import { PortNumberSchema } from "./device-commands.js";
3
4
  import { DeviceBridgeHoldSchema, DeviceConflictSchema, DeviceLowDiskSchema } from "./device-folder.js";
4
- import { parseHostConnection, type DeviceFacts, DeviceFactsSchema, MachineIdSchema, userDistrosOf, WslEnvironmentSchema } from "./hosts.js";
5
+ import { parseHostConnection, type DeviceFacts, DeviceFactsSchema, MachineIdentitySchema, MachineIdSchema, userDistrosOf, WslEnvironmentSchema } from "./hosts.js";
5
6
  import { RollbackTargetSchema, UpdateOutcomeSchema } from "./updates.js";
6
7
  import { DEV_VERSION } from "../state/versions.js";
7
8
  // Desktop-sync report shape shared by the agent, daemon and browser, produced only by the agent's own `deviceReport`.
@@ -230,92 +231,7 @@ export type DeviceAgentFlow = z.infer<typeof DeviceAgentFlowSchema>;
230
231
  // Adds the device id to the flow input.
231
232
  export const DeviceAgentFlowInputSchema = DeviceAgentFlowSchema.extend({ id: z.string().min(1) });
232
233
  export type DeviceAgentFlowInput = z.infer<typeof DeviceAgentFlowInputSchema>;
233
- // Closed set of command names; the daemon builds argv from the name alone (hosts/device-commands.ts). Never a free-text
234
- // command: the same socket carries `run_command`, which would grant a shell on the user's machine.
235
- // `sync-unpair` runs the machine's own `sync uninstall --sandbox` (terminates both Mutagen sessions, drops the pairing,
236
- // self-revokes enrollment); revoking from the sandbox side is a different route.
237
- // `dev-restart`, `dev-rebuild` and `sync-install` are the three whose argv the daemon fills from what only it knows — the
238
- // dev checkout on that machine, a freshly minted pairing token — which is also why none takes a path or a token from the
239
- // caller. The two dev commands are also why a machine-side op is the wrong shape for them: this door carries a COMMAND
240
- // STRING to a tool every released agent already has, so a sandbox can drive a machine whose agent predates the feature.
241
- // `dev-rebuild-log` is the read side of `dev-rebuild`: the build is detached out there, so its log is the only place its
242
- // progress exists, and polling this is what turns a fired-and-forgotten command into something with a progress bar.
243
- // `sync-clean` removes the build output left in directories the sandbox has deleted, which is what stops those
244
- // deletions from ever landing. It deletes only content the session already ignores, so it is the one command here whose
245
- // worst outcome is a rebuild — which is why it is a button and not a turn.
246
- // `mirror-ignore`/`mirror-unignore` are `mirror-off`'s per-port form, and the reason they exist: a port number that is
247
- // permanently taken on one machine's localhost has no answer in an all-or-nothing switch, so the choice lives where the
248
- // conflict does — one port, one device, durable — instead of muting every port the pairing serves.
249
- export const DeviceCommandSchema = z.enum([
250
- "mirror-off",
251
- "mirror-on",
252
- "mirror-ignore",
253
- "mirror-unignore",
254
- "sync-pause",
255
- "sync-resume",
256
- "sync-unpair",
257
- "sync-clean",
258
- "dev-restart",
259
- "dev-rebuild",
260
- "dev-rebuild-log",
261
- "sync-install",
262
- ]);
263
- export type DeviceCommand = z.infer<typeof DeviceCommandSchema>;
264
- // The reversible sync switches: the subset a device's own row drives with a pair of buttons, as opposed to the two
265
- // commands a card elsewhere issues once. Its own type so those button tables stay total without carrying entries for
266
- // commands they can never send.
267
- export const DeviceSyncSwitchSchema = DeviceCommandSchema.exclude(["dev-restart", "dev-rebuild", "dev-rebuild-log", "sync-install", "sync-clean"]);
268
- export type DeviceSyncSwitch = z.infer<typeof DeviceSyncSwitchSchema>;
269
-
270
- // Where a detached rebuild reports itself, and the reader of that report (dev-rebuild-log.ts).
271
- export { DEV_REBUILD_EXIT_MARK, DEV_REBUILD_QUIET_MARK, type DevRebuildLog, devRebuildLogPath, readDevRebuildLog } from "./dev-rebuild-log.js";
272
-
273
- // Machine's sandbox id (absent = every paired sandbox); must start alphanumeric, else it parses as a CLI flag.
274
- export const DeviceSandboxIdSchema = z
275
- .string()
276
- .max(200)
277
- .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
278
- // A folder on the DEVICE for `sync-install` to keep in step. The only caller-supplied string that reaches a command
279
- // line, so its shape is the guard: `~`, an absolute POSIX path or a drive letter, and none of the characters that would
280
- // end the argument it sits in. `~` is expanded by the daemon, which is why `$` is not allowed to arrive here. The line
281
- // may be PowerShell, which reads the typographic quotes “ ” „ as double quotes and ‘ ’ ‚ ‛ as single quotes beside the
282
- // ASCII ones, so those are refused too: the daemon quotes the folder, and this keeps a second guard where one quoter
283
- // misses a character.
284
- export const DeviceLocalDirSchema = z
285
- .string()
286
- .min(1)
287
- .max(4096)
288
- // The curly quotes as escapes: bun's transpiler rewrites a literal one in a regex to its escape and tsgo keeps it,
289
- // so spelled literally the pattern reads differently from source and from dist, and contract.lock.json can match
290
- // only one of them.
291
- .regex(/^(?:~|\/|[A-Za-z]:[\\/])[^"'`$;|&\n\r\u201C\u201D\u201E\u2018\u2019\u201A\u201B]*$/);
292
- // A TCP port number, the one value a caller supplies that reaches a command line as a number rather than a string.
293
- export const PortNumberSchema = z.number().int().min(1).max(65535);
294
- export const DeviceCommandInputSchema = z.object({
295
- id: z.string().min(1),
296
- command: DeviceCommandSchema,
297
- sandboxId: DeviceSandboxIdSchema.optional(),
298
- // `sync-install` only: which half to enroll (a member's "sync" still comes back mirror, decided daemon-side) and,
299
- // for file sync, the folder on that device. The pairing token is minted by the daemon; no caller ever carries one.
300
- mode: z.enum(["sync", "mirror"]).optional(),
301
- localDir: DeviceLocalDirSchema.optional(),
302
- // The two per-port mirror switches only. A number rather than a string, so nothing a caller sends can widen the
303
- // command line it lands in.
304
- port: PortNumberSchema.optional(),
305
- });
306
- export type DeviceCommandInput = z.infer<typeof DeviceCommandInputSchema>;
307
- // `ok` is the command's own exit status, not this route's: a refusal or non-zero exit is a real answer, not a thrown
308
- // error. Only an unreachable machine throws. `output` is the command's own printed text.
309
- export const DeviceCommandResultSchema = z.object({
310
- ok: z.boolean(),
311
- message: z.string(),
312
- output: z.string().optional(),
313
- // WHICH KIND OF `ok: false` THIS IS, because a caller that repeats itself needs to tell them apart: the device
314
- // turning the command away (a switch of its own is off, the path is out of its reach) will turn it away again,
315
- // whereas a command it accepted and then killed at its deadline says nothing except that this attempt was too slow.
316
- refused: z.boolean(),
317
- });
318
- export type DeviceCommandResult = z.infer<typeof DeviceCommandResultSchema>;
234
+ export * from "./device-commands.js";
319
235
 
320
236
  // What a synced folder reports beyond Mutagen's word, its conflicts and what the device holds back (device-folder.ts).
321
237
  export * from "./device-folder.js";
@@ -323,8 +239,7 @@ export * from "./device-folder.js";
323
239
  // One paired sandbox as the local agent holds it; `localDir` is which folder on that device holds this sandbox's /work.
324
240
  export const DevicePairingSchema = z.object({
325
241
  sandboxId: z.string(),
326
- // The account's name for the sandbox, stamped by the editor that reads the report (withSandboxNames), as a
327
- // container's `name` is. A machine never sends one: all it knows is the folder, named once at setup.
242
+ // The account's name for the sandbox, stamped by the editor that reads the report (withSandboxNames); never a machine's.
328
243
  name: z.string().optional(),
329
244
  mode: z.enum(["sync", "mirror"]),
330
245
  // Set only for mode "sync", and only for the sandbox being reported to.
@@ -332,12 +247,13 @@ export const DevicePairingSchema = z.object({
332
247
  // Which sandbox folder `localDir` holds when it is not /work itself: a project pairing's `/work/<name>`, the
333
248
  // owner's own folder, which carries no state backup and no git bridge. Absent means /work.
334
249
  remoteDir: z.string().optional(),
335
- // The folderless pairing that holds this machine's sync token for its own local sandbox, which project folders
336
- // attach to (`intentic-machine sync attach`). Absent on every other pairing.
337
- projectsHost: z.boolean().optional(),
338
- // Whether landed work in this project folder is delivered to `localDir` by itself (`deliverProject`). Only a
339
- // folder attached to the machine's own sandbox says "auto"; absent means Bring back by hand, as before.
250
+ // Whether landed work in this project folder is delivered to `localDir` by itself (`deliverProject`). A project
251
+ // sandbox's folder says "auto"; absent means Bring back by hand, as before. (2026-10-21) The folderless
252
+ // `projectsHost` pairing of the desktop app's one sandbox per computer went with that sandbox; an older agent's report
253
+ // that still carries it parses, the key dropped.
340
254
  deliver: z.enum(["auto", "off"]).optional(),
255
+ // (2026-10-20) The slug this machine's ic knows the sandbox by: how the desktop app starts or forgets a folder's sandbox.
256
+ slug: z.string().optional(),
341
257
  // Machine-owned switch (agent's own `sync mirror off`); absent reads as "on", not merely unknown.
342
258
  mirroring: z.enum(["on", "off"]).optional(),
343
259
  // Mutagen's own status word, kept verbatim rather than reduced to a traffic light.
@@ -346,7 +262,9 @@ export const DevicePairingSchema = z.object({
346
262
  conflicts: z.number().int().nonnegative().optional(),
347
263
  // Conflicted paths, capped at `CONFLICT_PATHS_MAX`; `conflicts` above is the true, uncapped count.
348
264
  conflictedPaths: z.array(DeviceConflictSchema).optional(),
265
+ // Mutagen's word, and why the agent holds it still, owner first (DeviceSyncHoldSchema); agreeing by its next pass.
349
266
  paused: z.boolean().optional(),
267
+ heldBy: z.array(z.string()).optional(),
350
268
  // Second session's status (the state-dir backup mirror), reported separately: the two fail independently.
351
269
  backupStatus: z.string().optional(),
352
270
  // (2026-10-18) While the drive holding `localDir` is nearly full, the reading that paused its sync and git bridge; and the
@@ -488,12 +406,11 @@ export const SshHostKeySchema = z
488
406
 
489
407
  // POST /system/authorized-key: a machine's agent enrolling its SSH key for desktop sync, redeeming a pairing token.
490
408
  // Every field past `key` is optional: an older agent sends none of them and is still enrolled.
491
- export const SyncEnrollmentRequestSchema = z.object({
409
+ // The key, and the computer and environment enrolling it (the same identity a device redeem sends), so the
410
+ // enrollment joins that machine's card.
411
+ export const SyncEnrollmentRequestSchema = MachineIdentitySchema.extend({
492
412
  // One authorized_keys line; its comment is the name the enrollment goes by (`DeviceSync.machine`).
493
413
  key: z.string(),
494
- // The computer and the environment on it that are enrolling, so the enrollment joins that machine's card.
495
- machineId: MachineIdSchema.optional(),
496
- environment: z.string().min(1).optional(),
497
414
  });
498
415
  export type SyncEnrollmentRequest = z.infer<typeof SyncEnrollmentRequestSchema>;
499
416
 
@@ -546,6 +463,10 @@ export const DeviceSchema = z.object({
546
463
  // (a switch that is off, an `ic` too old to list). Absent when it listed, or when nothing answered at all. What a
547
464
  // card falling back to a command says instead of "connect the computer": that computer IS connected (2026-10-07).
548
465
  sandboxesUnread: z.string().optional(),
466
+ // Whether its card's "Manage sandboxes on this device" switch is on (HostSummary.manages). Not the same fact as
467
+ // `sandboxes`: "Run commands" alone lists the containers, and only this switch lets one be made, started or removed,
468
+ // so a screen offering such a press asks here first. Absent on a sync-only row and from a daemon older than it.
469
+ manages: z.boolean().optional(),
549
470
  gap: DeviceGapSchema.optional(),
550
471
  });
551
472
  export type Device = z.infer<typeof DeviceSchema>;
@@ -659,14 +580,11 @@ const doorsRunningSandbox = (devices: readonly Device[], slug: string | undefine
659
580
  ? []
660
581
  : devices.filter((device) => device.hostId !== undefined && device.online === true && (device.sandboxes ?? []).some((box) => box.slug === slug));
661
582
 
662
- // The connected, online device whose docker reports a given sandbox slug: the machine that sandbox RUNS ON, as opposed
663
- // to any machine merely paired with it. Answers the question every "run it there instead of asking the owner to type
664
- // it" path starts from, and is `undefined` for a sync-only agent, which reports no containers at all.
665
- // Reads the row's own container list, not the report's: a card that grants sandbox management without "Run commands"
666
- // can run every swap this answer leads to while refusing to describe itself, and judging it on the report would print
667
- // a terminal command for a machine one click could have done it on.
668
- // Answers with a door, and a container verb may take any door of the machine; a command written for a PATH may not
669
- // (`hostHoldingPath`).
583
+ // The connected, online device whose docker reports a given sandbox slug: the machine that sandbox RUNS ON, not any
584
+ // machine merely paired with it, and `undefined` for a sync-only agent, which reports no containers. Read off the row's
585
+ // own container list, not the report's: a card granting sandbox management without "Run commands" refuses to describe
586
+ // itself yet runs every swap this answer leads to, so judging it on the report printed a command one click could do.
587
+ // Answers with a door, any of which a container verb may take; a command written for a PATH may not (`hostHoldingPath`).
670
588
  export const hostRunningSandbox = (devices: readonly Device[], slug: string | undefined): string | undefined =>
671
589
  doorsRunningSandbox(devices, slug)[0]?.hostId;
672
590
 
@@ -1,9 +1,8 @@
1
1
  // environment: the overlay Dockerfile extending the sandbox image
2
2
  import { z } from "zod";
3
- // The approved file is daemon-composed (pinned FROM + capability fragments + the owner-approved custom section). Status
4
- // is derived, never stored:
5
- // applied: sha256(approved) === appliedHash.
6
- // pending rebuild: approved present but hashes differ.
3
+ // The approved overlay is daemon-composed (pinned FROM + capability fragments + the owner-approved custom section) from its
4
+ // inputs on every read. Status is derived, never stored:
5
+ // pending rebuild: rebuildPending(approved.hash, appliedHash) (state/sandbox-build.ts); applied otherwise.
7
6
  // proposed: proposal present with a hash different from custom's.
8
7
 
9
8
  const environmentFileSchema = z.object({ content: z.string(), hash: z.string() });
@@ -45,10 +44,10 @@ export type RuntimeInstall = z.infer<typeof RuntimeInstallSchema>;
45
44
  // (`environment propose`) clears its tool's entries.
46
45
  export const SettledDraftSchema = z.object({ tool: z.string(), hash: z.string(), at: z.number() });
47
46
  export type SettledDraft = z.infer<typeof SettledDraftSchema>;
48
- // One overlay the daemon composed, by the hash a rebuild is stamped with, and the custom-section blocks it carried (each
49
- // by name and the hash of its trimmed steps). Kept so "is this tool in the image" is answered against the overlay the
50
- // running container was actually built from, which is rarely the newest one composed: every approval, capability change
51
- // and daemon update composes again, and a rebuild builds whatever was newest when it started.
47
+ // One overlay the daemon composed, by the hash a rebuild is stamped with, and the sections it carried, each by the hash
48
+ // of its trimmed steps: capability fragments unnamed, custom-section blocks by name. Kept so "is this in the image" is
49
+ // answered against the overlay the running container was actually built from, which is rarely the newest one composed:
50
+ // every approval, capability change and daemon update composes again, and a rebuild builds whatever was newest then.
52
51
  export const EnvironmentCompositionSchema = z.object({
53
52
  hash: z.string(),
54
53
  at: z.number(),
@@ -196,7 +195,7 @@ export type EnvironmentRemove = z.infer<typeof EnvironmentRemoveSchema>;
196
195
  export const EnvironmentContentsSchema = z.object({ items: z.array(EnvironmentItemSchema) });
197
196
  export type EnvironmentContents = z.infer<typeof EnvironmentContentsSchema>;
198
197
  // A sandbox is four stores: /work, /history, the container, and the AI-provider credential root. A bundle carries only
199
- // the first two (WORKSPACE_STATE_FILES/HISTORY_STATE_FILES); it cannot rebuild the container or credentials, so
198
+ // the first two (WORKSPACE_STATE_FILES here, the daemon's HISTORY_STATE_FILES); it cannot rebuild the container or credentials, so
200
199
  // importing one is a report, not a claim of equivalence. Only the outbound half is here; inbound is one of arrival.ts's
201
200
  // four sources.
202
201