@intentic/sandbox-contract 1.345.0 → 1.346.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
@@ -0,0 +1,95 @@
1
+ import { DEV_SANDBOX_IMAGE } from "../policy/overlay-lint.js";
2
+ import { buildParity, builtFromCheckout, rebuildPending, type RunningSandbox, type SandboxBuild, sameBuild, swapVerdict } from "./sandbox-build.js";
3
+
4
+ // Every "which build" question is one of these, asked of two SandboxBuild records; a surface that compares fields of its
5
+ // own choosing is how "pending rebuild" outlived a rebuild and a runner read outdated for good (2026-10).
6
+
7
+ const stable = "ghcr.io/intentic/sandbox:stable";
8
+ const digest = (byte: string): string => `sha256:${byte.repeat(64)}`;
9
+
10
+ test("a rebuild is pending exactly when the overlay wanted is not the one running", () => {
11
+ expect(rebuildPending("h1", "h1")).toBe(false);
12
+ expect(rebuildPending("h2", "h1")).toBe(true);
13
+ // A stock container wanting an overlay waits for it; one wanting none waits for nothing, whatever it runs.
14
+ expect(rebuildPending("h1", undefined)).toBe(true);
15
+ expect(rebuildPending(undefined, undefined)).toBe(false);
16
+ expect(rebuildPending(undefined, "h1")).toBe(false);
17
+ });
18
+
19
+ test("a runner on the parent's base and overlay is current, and either moving is outdated", () => {
20
+ const parent: SandboxBuild = { base: stable, overlay: "abc123" };
21
+ expect(buildParity(parent, { ...parent })).toBe("current");
22
+ expect(buildParity(parent, { ...parent, overlay: "def456" })).toBe("outdated");
23
+ expect(buildParity(parent, { ...parent, base: "ghcr.io/intentic/sandbox:2.2.0" })).toBe("outdated");
24
+ });
25
+
26
+ test("an overlay on one side only is a difference; neither side having one is not", () => {
27
+ expect(buildParity({ base: stable }, { base: stable })).toBe("current");
28
+ expect(buildParity({ base: stable, overlay: "abc" }, { base: stable })).toBe("outdated");
29
+ expect(buildParity({ base: stable }, { base: stable, overlay: "abc" })).toBe("outdated");
30
+ });
31
+
32
+ // One tag is one name for whatever the registry held when each machine pulled it; the digest is the build.
33
+ test("the base is compared by digest when both sides read one, and by its reference otherwise", () => {
34
+ expect(buildParity({ base: stable, baseDigest: digest("a") }, { base: stable, baseDigest: digest("b") })).toBe("outdated");
35
+ expect(buildParity({ base: stable, baseDigest: digest("a") }, { base: "ghcr.io/intentic/sandbox:beta", baseDigest: digest("a") })).toBe("current");
36
+ expect(buildParity({ base: stable, baseDigest: digest("a") }, { base: stable })).toBe("current");
37
+ expect(buildParity({ base: stable, baseDigest: digest("a") }, { base: "ghcr.io/intentic/sandbox:beta" })).toBe("outdated");
38
+ });
39
+
40
+ // Each machine's `intentic-sandbox:dev` is whatever its checkout held, so the name agreeing proves nothing, and an update
41
+ // cannot bring it level: `ic` refuses to move a checkout build onto the published image (recreate.rs).
42
+ test("nothing to compare, or a build from a checkout on either side, reads as unknown rather than as a warning", () => {
43
+ const parent: SandboxBuild = { base: stable, overlay: "abc" };
44
+ expect(buildParity(parent, undefined)).toBe("unknown");
45
+ expect(buildParity(parent, { base: "" })).toBe("unknown");
46
+ expect(buildParity({ base: "" }, parent)).toBe("unknown");
47
+ expect(buildParity({ base: DEV_SANDBOX_IMAGE, overlay: "abc" }, { base: DEV_SANDBOX_IMAGE, overlay: "abc" })).toBe("unknown");
48
+ expect(buildParity(parent, { base: DEV_SANDBOX_IMAGE, overlay: "abc" })).toBe("unknown");
49
+ });
50
+
51
+ // A rollback pin is a local tag too, and a rolled-back sandbox rejoins its channel by updating, not by a checkout.
52
+ test("only the dogfood base is a build from a checkout", () => {
53
+ expect(builtFromCheckout({ base: DEV_SANDBOX_IMAGE })).toBe(true);
54
+ expect(builtFromCheckout({ base: stable })).toBe(false);
55
+ expect(builtFromCheckout({ base: "intentic-sandbox-rollback-demo:f6e917ce9b90" })).toBe(false);
56
+ expect(builtFromCheckout({ base: "" })).toBe(false);
57
+ });
58
+
59
+ // A swap is told landed by what answers, never by the version: a checkout build is 0.0.0 before and after, and an update
60
+ // of one followed by "the version moved" waited out its deadline and said the sandbox never came back (2026-10).
61
+ const running = (build: SandboxBuild, bootedAt: number): RunningSandbox => ({ build, bootedAt });
62
+
63
+ test("two builds are the same only when something can tell, and differ the moment the overlay or digest does", () => {
64
+ expect(sameBuild({ base: stable, baseDigest: digest("a") }, { base: stable, baseDigest: digest("a") })).toBe(true);
65
+ expect(sameBuild({ base: stable, baseDigest: digest("a") }, { base: stable, baseDigest: digest("b") })).toBe(false);
66
+ expect(sameBuild({ base: stable, overlay: "h1" }, { base: stable, overlay: "h2" })).toBe(false);
67
+ expect(sameBuild({ base: stable }, { base: "ghcr.io/intentic/sandbox:beta" })).toBe(false);
68
+ expect(sameBuild({ base: stable }, { base: stable })).toBeUndefined();
69
+ expect(sameBuild({ base: DEV_SANDBOX_IMAGE }, { base: DEV_SANDBOX_IMAGE })).toBeUndefined();
70
+ });
71
+
72
+ test("an update of a checkout build lands on the new boot, though every version reads 0.0.0", () => {
73
+ const from = running({ base: DEV_SANDBOX_IMAGE }, 1000);
74
+ expect(swapVerdict({ from }, from)).toBe("waiting");
75
+ expect(swapVerdict({ from }, running({ base: DEV_SANDBOX_IMAGE }, 2000))).toBe("landed");
76
+ });
77
+
78
+ test("an update of a release lands when the digest moves, and a new boot on the old digest came back unmoved", () => {
79
+ const from = running({ base: stable, baseDigest: digest("a") }, 1000);
80
+ expect(swapVerdict({ from }, running({ base: stable, baseDigest: digest("b") }, 2000))).toBe("landed");
81
+ expect(swapVerdict({ from }, running({ base: stable, baseDigest: digest("a") }, 2000))).toBe("unmoved");
82
+ expect(swapVerdict({ from }, from)).toBe("waiting");
83
+ // A rollback names another tag: moved, digest or not.
84
+ expect(swapVerdict({ from: running({ base: stable }, 1000) }, running({ base: "ghcr.io/intentic/sandbox:0.9.0" }, 1000))).toBe("landed");
85
+ });
86
+
87
+ test("a rebuild lands on its overlay, and a new boot on any other overlay came back without it", () => {
88
+ const from = running({ base: stable, overlay: "old" }, 1000);
89
+ expect(swapVerdict({ from, overlay: "new" }, running({ base: stable, overlay: "new" }, 2000))).toBe("landed");
90
+ expect(swapVerdict({ from, overlay: "new" }, running({ base: stable, overlay: "old" }, 2000))).toBe("unmoved");
91
+ expect(swapVerdict({ from, overlay: "new" }, from)).toBe("waiting");
92
+ // Pressed with nothing read: only the overlay can say.
93
+ expect(swapVerdict({ from: undefined, overlay: "new" }, running({ base: stable, overlay: "new" }, 2000))).toBe("landed");
94
+ expect(swapVerdict({ from: undefined }, running({ base: stable }, 2000))).toBe("waiting");
95
+ });
@@ -0,0 +1,96 @@
1
+ import { DEV_SANDBOX_IMAGE } from "../policy/overlay-lint.js";
2
+
3
+ // WHICH BUILD A SANDBOX IS, as one record, and every comparison made between two of them. A build is the base image it
4
+ // sits on and the environment overlay built on that base, each by content: the base by its registry digest when the
5
+ // runner read one, the overlay by the hash of the recipe built. A sandbox holds two at any moment, the build it runs
6
+ // (stamped on its container) and the overlay it wants (composed from its approvals now); a parent and its runner hold
7
+ // one each. "Rebuild pending" and runner parity are asked here and nowhere else. Each fix that once picked its own field
8
+ // (an image tag for parity, a cached file for pending) stayed local while the next surface kept its own (2026-10).
9
+
10
+ export interface SandboxBuild {
11
+ // The base image as the runner named it (SANDBOX_BASE_IMAGE, else the image itself); empty when no runner said.
12
+ readonly base: string;
13
+ // The base's registry digest (`sha256:…`) when the runner could read one: what makes `base` one exact image.
14
+ readonly baseDigest?: string | undefined;
15
+ // sha256 of the environment overlay built on the base; absent on a stock image.
16
+ readonly overlay?: string | undefined;
17
+ }
18
+
19
+ // Built on a base compiled from a checkout, which names whatever that machine's checkout held when it was built: it is
20
+ // compared with nothing, and a newer build of it comes from the checkout rather than the registry.
21
+ export const builtFromCheckout = (build: Pick<SandboxBuild, "base">): boolean => build.base === DEV_SANDBOX_IMAGE;
22
+
23
+ // The overlay a sandbox wants is not the one it runs. Wanting none is nothing to rebuild; a container built with an
24
+ // overlay whose every input has since gone still wants the bare one composed for it, which has a hash of its own.
25
+ export const rebuildPending = (desired: string | undefined, running: string | undefined): boolean => desired !== undefined && desired !== (running ?? "");
26
+
27
+ // Whether two sandboxes run the same code and environment: the base by digest when both sides read one (one tag pulled
28
+ // on two machines at two moments is two builds), else by its reference, and the overlay by hash, none on both sides
29
+ // agreeing. Unknown when either side named no base or was built from a checkout: nothing an update could bring level.
30
+ export const buildParity = (parent: SandboxBuild, other: SandboxBuild | undefined): "current" | "outdated" | "unknown" => {
31
+ if (other === undefined || parent.base === "" || other.base === "" || builtFromCheckout(parent) || builtFromCheckout(other)) {
32
+ return "unknown";
33
+ }
34
+ const sameBase =
35
+ parent.baseDigest !== undefined && other.baseDigest !== undefined ? parent.baseDigest === other.baseDigest : parent.base === other.base;
36
+ return sameBase && (parent.overlay ?? "") === (other.overlay ?? "") ? "current" : "outdated";
37
+ };
38
+
39
+ // WHAT ANSWERS: the build a daemon runs and the moment it started, as /info reports it (`running`). A swap (a rebuild, an
40
+ // update, a rollback) replaces the container, so the sandbox answering afterwards is a different boot; whether it is the
41
+ // build the swap aimed at is the build's to say. A daemon's version cannot: every build from a checkout is 0.0.0, so a
42
+ // swap told by "the version moved" never landed for one (2026-10).
43
+ export interface RunningSandbox {
44
+ readonly build: SandboxBuild;
45
+ // Epoch ms this daemon started: a container swapped in is a new boot even where its build reads the same.
46
+ readonly bootedAt: number;
47
+ }
48
+
49
+ // Whether two builds are the same image and overlay: false when either differs, true when both bases carry a digest and
50
+ // they and the overlays agree, undefined when nothing can tell (a checkout build, or one tag with no digest read on
51
+ // either side, which two pulls a release apart both are).
52
+ export const sameBuild = (a: SandboxBuild, b: SandboxBuild): boolean | undefined => {
53
+ if ((a.overlay ?? "") !== (b.overlay ?? "")) {
54
+ return false;
55
+ }
56
+ if (builtFromCheckout(a) || builtFromCheckout(b)) {
57
+ return undefined;
58
+ }
59
+ if (a.baseDigest !== undefined && b.baseDigest !== undefined) {
60
+ return a.baseDigest === b.baseDigest;
61
+ }
62
+ return a.base === b.base ? undefined : false;
63
+ };
64
+
65
+ // What a swap is aiming at, read when it was pressed: the sandbox answering then, and for a rebuild the overlay it builds.
66
+ export interface SwapAim {
67
+ readonly from: RunningSandbox | undefined;
68
+ readonly overlay?: string | undefined;
69
+ }
70
+
71
+ // Where a swap stands by what answers now. `landed`: the sandbox runs what it aimed at (the overlay a rebuild built; for
72
+ // an update or a rollback, a build other than the one pressed on, or a new boot where the builds cannot tell). `unmoved`:
73
+ // a new boot answers on the very build it was pressed on, so the swap came back without taking. `waiting`: the sandbox
74
+ // pressed on still answers, or nothing here can tell yet.
75
+ export type SwapVerdict = "landed" | "unmoved" | "waiting";
76
+
77
+ export const swapVerdict = (aim: SwapAim, now: RunningSandbox): SwapVerdict => {
78
+ const rebooted = aim.from !== undefined && now.bootedAt !== aim.from.bootedAt;
79
+ if (aim.overlay !== undefined) {
80
+ if (now.build.overlay === aim.overlay) {
81
+ return "landed";
82
+ }
83
+ return rebooted ? "unmoved" : "waiting";
84
+ }
85
+ if (aim.from === undefined) {
86
+ return "waiting";
87
+ }
88
+ const same = sameBuild(aim.from.build, now.build);
89
+ if (same === false) {
90
+ return "landed";
91
+ }
92
+ if (!rebooted) {
93
+ return "waiting";
94
+ }
95
+ return same === true ? "unmoved" : "landed";
96
+ };
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { packageRoot } from "@intentic/constants/node";
4
+ import { DeviceFeatureSchema } from "../schemas/hosts.js";
5
+ import { waysBack } from "./ways-back.js";
6
+
7
+ // What `ic features --json` answers (_sandbox/ic/src/features.rs holds the same list to this golden).
8
+ // SAFETY: the golden is this package's own file; the assertions below fail on any field it lacks.
9
+ const IC_FEATURES = JSON.parse(readFileSync(join(packageRoot(import.meta.url), "golden", "ic-features.json"), "utf8")) as { readonly features: readonly string[] };
10
+
11
+ const pruned = (image: string, version: string) => ({ image, version, download: true });
12
+ const kept = (image: string, version: string) => ({ image, version });
13
+
14
+ test("every feature ic says it has is one the contract knows, the reachable listing among them", () => {
15
+ for (const feature of IC_FEATURES.features) {
16
+ expect(DeviceFeatureSchema.safeParse(feature).success).toBe(true);
17
+ }
18
+ expect(IC_FEATURES.features).toContain("reachable-ways-back");
19
+ });
20
+
21
+ test("a current ic's ways back are offered as it lists them, each image once", () => {
22
+ const current = { features: [...IC_FEATURES.features] };
23
+ const pin = kept("pin:a", "1.2.0");
24
+ const release = pruned("ghcr.io/intentic/sandbox:1.1.0", "1.1.0");
25
+ expect(waysBack(current, [pin, pin, release])).toEqual([pin, release]);
26
+ });
27
+
28
+ // 2026-10-19: an ic from before then listed pruned dev builds, rows of 0.0.0 no download can bring back.
29
+ test("an older ic's rows it could never reach are left out, and only those", () => {
30
+ const older = { features: ["rollback-to", "sleep"] };
31
+ const dev = kept("pin:dev", "0.0.0");
32
+ const release = pruned("pin:gone", "1.1.0");
33
+ const rows = [dev, pruned("pin:gone-dev", "0.0.0"), release, { image: "pin:nameless", download: true }];
34
+ expect(waysBack(older, rows)).toEqual([dev, release]);
35
+ expect(waysBack(undefined, rows)).toEqual([dev, release]);
36
+ });
@@ -0,0 +1,19 @@
1
+ import { DEVICE_FEATURE_REACHABLE_WAYS_BACK, type DeviceFacts, deviceSupports } from "../schemas/hosts.js";
2
+ import type { RollbackTarget } from "../schemas/updates.js";
3
+ import { DEV_VERSION } from "./versions.js";
4
+
5
+ // THE WAYS BACK A SANDBOX ROW OFFERS, as the device's ic means them (2026-10-20). An ic from 2026-10-19 on lists only the
6
+ // ways back a rollback can reach (versions.rs `ways_back`) and says so in the device's features (`reachable-ways-back`,
7
+ // ic's own `ic features`): its rows are offered as they are. An older ic also listed every pin pruned outside it, each as
8
+ // a download of its version, and a dev build's version is 0.0.0, which no registry publishes: three rows of "0.0.0", none
9
+ // of which could be had. Only for such a host are the rows an older ic could not reach left out, by that ic's own rule
10
+ // (a download needs a published version), rather than every host's rows being filtered on a guess. Either way each image
11
+ // is offered once.
12
+
13
+ // A release the registry publishes an image under: a dotted version, and not the one every dev build says.
14
+ const published = (version: string | undefined): boolean => version !== undefined && /^v?\d+\.\d+\.\d+$/u.test(version) && version.replace(/^v/u, "") !== DEV_VERSION;
15
+
16
+ export const waysBack = <T extends Pick<RollbackTarget, "image" | "version" | "download">>(facts: Pick<DeviceFacts, "features"> | undefined, targets: readonly T[]): T[] => {
17
+ const once = [...new Map(targets.map((target) => [target.image, target])).values()];
18
+ return deviceSupports(facts, DEVICE_FEATURE_REACHABLE_WAYS_BACK) ? once : once.filter((target) => target.download !== true || published(target.version));
19
+ };
@@ -210,7 +210,7 @@ const STATE_FILES = [
210
210
  portability: "carry",
211
211
  },
212
212
  // Where releases before 2026-10-12 kept the wakes automations held for a person. They now sit on the history volume
213
- // (`automation-holds/`, history-state.ts), off the workspace every turn writes, and nothing reads this directory; a
213
+ // (`automation-holds/`, the daemon's held-wakes-store.ts), off the workspace every turn writes, and nothing reads this directory; a
214
214
  // build that read it is only ever rolled back to. Declared still, since the extension that listed them binds it.
215
215
  {
216
216
  path: ".intentic/records/approvals/",
@@ -0,0 +1,111 @@
1
+ import type { AgentEvent } from "../events/agent-events.js";
2
+ import type { TranscriptRow } from "../events/transcript.js";
3
+ import { noticeCode } from "../events/sandbox-notice.js";
4
+
5
+ // The notices a turn's work leaves as it moves: an install starting, the branch rebased onto the workspace while it
6
+ // waited, and where the finished work went (landed, held, conflicted, or into a parent's checkout).
7
+
8
+ // Clause appended to the landed notice for a workspace dependency change, or empty when there is none. Reports the
9
+ // install as already started, or as on its way: the daemon starts it within seconds, in the install lane, behind any
10
+ // install already running; never as a request.
11
+ const dependencyLine = (deps: { missing: number; started: string[]; deferred: boolean } | undefined): string => {
12
+ if (deps === undefined || deps.missing === 0) {
13
+ return ``;
14
+ }
15
+ // `missing` counts versions the turn bumped as well as names it added, so the sentence names both.
16
+ const what = `${deps.missing} ${deps.missing === 1 ? `dependency` : `dependencies`} it added or changed`;
17
+ return deps.deferred
18
+ ? ` ${what} ${deps.missing === 1 ? `is` : `are`} being installed in your tree: the install waits for any other install to finish, appears in Work terminals, and its outcome lands in Activity.`
19
+ : ` Installing ${what}; the project's checks run when that finishes, and the outcome lands in Activity.`;
20
+ };
21
+
22
+ // Said as an agent's own install starts: where it writes and what outlasts it. Names up to three projects, the workspace
23
+ // root first; an install the gate could not place in a project says only where it writes. Coded like every sandbox
24
+ // notice (sandbox-notice.ts `installing`), so the chat can say it in the reader's language.
25
+ const INSTALL_NAMED = 3;
26
+ export const installRow = (event: { readonly reach: "own-copy" | "main-tree"; readonly projects: readonly string[] }): TranscriptRow => {
27
+ const root = event.projects.includes(``);
28
+ const dirs = event.projects.filter((dir) => dir !== ``).map((dir) => `${dir}/`);
29
+ const shownDirs = dirs.slice(0, INSTALL_NAMED - (root ? 1 : 0));
30
+ const more = dirs.length - shownDirs.length;
31
+ const named = [...(root ? [`the workspace root`] : []), ...shownDirs].join(`, `);
32
+ const list = more > 0 ? `${named} and ${more} more` : named;
33
+ const where = list === `` ? `` : ` in ${list}`;
34
+ const ownCopy = event.reach === `own-copy`;
35
+ return {
36
+ role: "notice",
37
+ text: ownCopy
38
+ ? `Installing packages${where} into this conversation's own copy. The manifest and lockfile land with the work, and the review lists what it adds.`
39
+ : `Installing packages${where} in the main tree, one install at a time.`,
40
+ noticeCode: noticeCode({
41
+ code: "installing",
42
+ params: {
43
+ ownCopy,
44
+ root: root || undefined,
45
+ projects: shownDirs.length === 0 ? undefined : shownDirs.join(`, `),
46
+ more: more > 0 ? more : undefined,
47
+ },
48
+ }),
49
+ };
50
+ };
51
+
52
+ // One line reporting the daemon's rebase of this agent's branch onto the current workspace; `blocked` names repos it
53
+ // couldn't rebase, where the turn ran from an older base.
54
+ export const syncLine = (sync: { commits: number; blocked: readonly string[] }): string => {
55
+ const moved =
56
+ sync.commits > 0
57
+ ? `Your workspace moved on while this agent waited, its branch was rebased onto your latest ${sync.commits} commit${sync.commits === 1 ? `` : `s`}.`
58
+ : undefined;
59
+ const blocked =
60
+ sync.blocked.length > 0
61
+ ? `Couldn't rebase onto your workspace in ${sync.blocked.join(`, `)}: the turn is running from the older base, so its land may need a resolve.`
62
+ : undefined;
63
+ return [moved, blocked].filter((line) => line !== undefined).join(` `);
64
+ };
65
+
66
+ // Row for a spawned child's work gone into its parent's checkout, or held off it by a clash with the parent's edits.
67
+ const intoParentRow = (event: Extract<AgentEvent, { kind: "landed" }>): TranscriptRow => {
68
+ if (event.landed) {
69
+ return {
70
+ role: "notice",
71
+ text: `Changes went into the parent agent's checkout, as its in-process subagents' edits do: they reach your workspace with its land.`,
72
+ noticeCode: noticeCode({ code: "intoParent" }),
73
+ };
74
+ }
75
+ const files = (event.conflicts ?? []).flatMap((conflict) => conflict.paths).length;
76
+ return {
77
+ role: "notice",
78
+ text: `${files} file(s) clash with the parent agent's own edits, so nothing was written into its checkout. The parent was told, and can bring the changes in to resolve.`,
79
+ noticeCode: noticeCode({ code: "intoParentClash", params: { files } }),
80
+ };
81
+ };
82
+
83
+ // Row for a finished turn: held on its branch, landed automatically, or conflicted. A landed turn that changed
84
+ // dependencies appends dependencyLine, since the Changes diff can't show that.
85
+ export const landedRow = (event: Extract<AgentEvent, { kind: "landed" }>): TranscriptRow => {
86
+ if (event.into !== undefined) {
87
+ return intoParentRow(event);
88
+ }
89
+ if (event.held === true) {
90
+ return { role: "notice", text: `Finished: the work is on this agent's branch, ready to land from its review.`, noticeCode: noticeCode({ code: "landHeld" }) };
91
+ }
92
+ if (!event.landed) {
93
+ // Per-file cause (your edits, a moved main line, a binary) is spelled out in the review, not named here.
94
+ const conflicts = event.conflicts ?? [];
95
+ const files = conflicts.flatMap((conflict) => conflict.paths).length;
96
+ const repos = conflicts.map((conflict) => conflict.repo).join(`, `);
97
+ return {
98
+ role: "notice",
99
+ text: `${files} file(s) couldn't land automatically in ${repos}. Open the agent's review to see what blocked them and land from there.`,
100
+ noticeCode: noticeCode({ code: "landConflict", params: { files, repos } }),
101
+ };
102
+ }
103
+ // noticeAction fires only here, right as the auto-behavior ran; an active install takes the slot instead.
104
+ const deps = event.deps === undefined || event.deps.missing === 0 ? undefined : event.deps;
105
+ return {
106
+ role: "notice",
107
+ text: `Changes landed in your workspace: review them in the Changes panel.${dependencyLine(event.deps)}`,
108
+ noticeAction: (event.deps?.started.length ?? 0) > 0 ? "depsInstall" : "landHold",
109
+ noticeCode: noticeCode({ code: "landed", params: deps === undefined ? undefined : { deps: deps.missing, queued: deps.deferred || undefined } }),
110
+ };
111
+ };
@@ -1,5 +1,5 @@
1
1
  import { STATE_DIR } from "@intentic/constants";
2
- import type { AgentEvent } from "../events/agent-events.js";
2
+ import { type AgentErrorCode, AgentErrorCodeSchema, type AgentEvent } from "../events/agent-events.js";
3
3
  import { type TranscriptPatch, type TranscriptRow, TranscriptRowSchema } from "../events/transcript.js";
4
4
  import { sandboxNoticeOf } from "../events/sandbox-notice.js";
5
5
  import { watchWakePrompt } from "../events/watch-wake.js";
@@ -306,7 +306,8 @@ describe("foldTurn", () => {
306
306
  expect(foldOf("hi", events)).toEqual([
307
307
  { role: "user", text: "hi", sentAt: SENT_AT },
308
308
  { role: "assistant", text: "I'll take a look" },
309
- { role: "notice", text: refusal },
309
+ // A failure nobody named is still a failure: coded, the unclassified kind, never bare words.
310
+ { role: "notice", text: refusal, noticeCode: { code: "failed", params: { message: refusal } } },
310
311
  ]);
311
312
  const outage: AgentEvent[] = [
312
313
  {
@@ -349,11 +350,16 @@ describe("foldTurn", () => {
349
350
  noticeCode: { code: "renewalWithdrawn", params: { message: "Token refused.", error: "claude-token-refused" } },
350
351
  };
351
352
  // The live window hears the rewrite as a patch on the row it already drew, then the stop's own row.
353
+ const stop: TranscriptRow = {
354
+ role: "notice",
355
+ text: "Claude Code returned an error result: 401.",
356
+ noticeCode: { code: "failed", params: { message: "Claude Code returned an error result: 401." } },
357
+ };
352
358
  expect(patches).toEqual([
353
359
  { op: "replace", index: 1, row: withdrawn },
354
- { op: "append", row: { role: "notice", text: "Claude Code returned an error result: 401." } },
360
+ { op: "append", row: stop },
355
361
  ]);
356
- expect(fold.rows.slice(1)).toEqual([withdrawn, { role: "notice", text: "Claude Code returned an error result: 401." }]);
362
+ expect(fold.rows.slice(1)).toEqual([withdrawn, stop]);
357
363
  // A renewal armed again keeps its promise.
358
364
  const again = new TranscriptFold(openingOf("hi"));
359
365
  again.apply(refused);
@@ -1096,14 +1102,23 @@ describe(`the code beside a notice's words`, () => {
1096
1102
  });
1097
1103
  });
1098
1104
 
1099
- it(`codes a failure the sandbox names, and leaves a provider's own words to themselves`, () => {
1105
+ it(`codes every failure, says who worded it, and names an unnamed one as unclassified`, () => {
1100
1106
  const busy = `This agent is already running a turn, wait for it to finish.`;
1101
- expect(lastOf([{ kind: `error`, code: `agent-busy`, message: busy }])).toEqual({
1107
+ expect(lastOf([{ kind: `error`, code: `agent-busy`, message: busy, own: true }])).toEqual({
1102
1108
  role: `notice`,
1103
1109
  text: busy,
1104
- noticeCode: { code: `failed`, params: { message: busy, error: `agent-busy` } },
1110
+ noticeCode: { code: `failed`, params: { message: busy, error: `agent-busy`, own: true } },
1111
+ });
1112
+ expect(lastOf([{ kind: `error`, message: `Overloaded.` }])).toEqual({
1113
+ role: `notice`,
1114
+ text: `Overloaded.`,
1115
+ noticeCode: { code: `failed`, params: { message: `Overloaded.` } },
1116
+ });
1117
+ // A spent allowance is a failure like any other: it was the one code left as bare words (2026-10-20: 41 rows a week).
1118
+ expect(lastOf([{ kind: `error`, code: `rate_limit`, message: `You've hit your usage limit.` }])?.noticeCode).toEqual({
1119
+ code: `failed`,
1120
+ params: { message: `You've hit your usage limit.`, error: `rate_limit` },
1105
1121
  });
1106
- expect(lastOf([{ kind: `error`, message: `Overloaded.` }])).toEqual({ role: `notice`, text: `Overloaded.` });
1107
1122
  expect(lastOf([{ kind: `error`, code: `claude-token-refused`, message: `Token refused.` }])).toEqual({
1108
1123
  role: `notice`,
1109
1124
  text: `Token refused. Reconnect the account to pick this conversation back up.`,
@@ -1127,10 +1142,24 @@ describe(`the code beside a notice's words`, () => {
1127
1142
  const rows = events.flatMap((turn) => foldTurn(openingOf(`go`), turn, `stopped`)).filter((row) => row.noticeCode !== undefined);
1128
1143
  expect(rows.filter((row) => sandboxNoticeOf(row) === undefined)).toEqual([]);
1129
1144
  expect(new Set(rows.map((row) => row.noticeCode?.code))).toEqual(
1130
- new Set([`synced`, `compacted`, `intoParent`, `intoParentClash`, `landHeld`, `outageWaiting`, `memoryHeld`, `kept`, `renewalWithdrawn`, `stopped`]),
1145
+ new Set([`synced`, `compacted`, `intoParent`, `intoParentClash`, `landHeld`, `outageWaiting`, `memoryHeld`, `kept`, `renewalWithdrawn`, `failed`, `stopped`]),
1131
1146
  );
1132
1147
  });
1133
1148
 
1149
+ // Every failure code the wire names, and none at all, folds to a failure row this build reads back: the class of row
1150
+ // a reader had to recognise by its words cannot be written. A code added to the wire is held to it here.
1151
+ it(`folds every failure, attended or not, ran or not, to a coded failure row`, () => {
1152
+ const codes: readonly (AgentErrorCode | undefined)[] = [...AgentErrorCodeSchema.options, undefined];
1153
+ const ran: AgentEvent = { kind: `tool_call`, id: `t1`, name: `Edit`, category: `edit`, status: `completed` };
1154
+ for (const code of codes) {
1155
+ const frame: AgentEvent = { kind: `error`, message: `Words.`, code };
1156
+ for (const events of [[frame], [{ ...frame, unattended: true }], [{ ...frame, held: { ran: false } }], [ran, frame]]) {
1157
+ const failure = foldTurn(openingOf(`go`), events, `settled`).at(-1);
1158
+ expect({ code, coded: failure?.role === `notice` && sandboxNoticeOf(failure)?.code !== undefined }).toEqual({ code, coded: true });
1159
+ }
1160
+ }
1161
+ });
1162
+
1134
1163
  // A newer sandbox's code must not cost an older app the whole page: the row parses, and the reader draws its words.
1135
1164
  it(`reads a row whose code this build does not know, and one written before rows carried codes`, () => {
1136
1165
  const newer: TranscriptRow = { role: `notice`, text: `Something new happened.`, noticeCode: { code: `somethingNew`, params: { count: 2 } } };