@intentic/machine 1.326.0 → 1.328.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 (271) hide show
  1. package/README.md +387 -33
  2. package/dist/commands.d.ts.map +1 -1
  3. package/dist/commands.js +2 -0
  4. package/dist/commands.js.map +1 -1
  5. package/dist/daemon-base.d.ts.map +1 -1
  6. package/dist/daemon-base.js +2 -3
  7. package/dist/daemon-base.js.map +1 -1
  8. package/dist/device/audit.d.ts +2 -0
  9. package/dist/device/audit.d.ts.map +1 -1
  10. package/dist/device/audit.js +23 -3
  11. package/dist/device/audit.js.map +1 -1
  12. package/dist/device/commands.d.ts +4 -0
  13. package/dist/device/commands.d.ts.map +1 -1
  14. package/dist/device/commands.js +7 -2
  15. package/dist/device/commands.js.map +1 -1
  16. package/dist/device/connection.d.ts +12 -1
  17. package/dist/device/connection.d.ts.map +1 -1
  18. package/dist/device/connection.js +77 -3
  19. package/dist/device/connection.js.map +1 -1
  20. package/dist/device/grant.d.ts +52 -0
  21. package/dist/device/grant.d.ts.map +1 -1
  22. package/dist/device/loopback-catch.d.ts +1 -1
  23. package/dist/device/loopback-catch.d.ts.map +1 -1
  24. package/dist/device/loopback-catch.js +5 -30
  25. package/dist/device/loopback-catch.js.map +1 -1
  26. package/dist/device/mcp.d.ts.map +1 -1
  27. package/dist/device/mcp.js +190 -30
  28. package/dist/device/mcp.js.map +1 -1
  29. package/dist/device/router.d.ts +52 -0
  30. package/dist/device/router.d.ts.map +1 -1
  31. package/dist/device/router.js +21 -0
  32. package/dist/device/router.js.map +1 -1
  33. package/dist/device/sandbox-rounds/auto-backup.d.ts +3 -1
  34. package/dist/device/sandbox-rounds/auto-backup.d.ts.map +1 -1
  35. package/dist/device/sandbox-rounds/auto-backup.js +14 -5
  36. package/dist/device/sandbox-rounds/auto-backup.js.map +1 -1
  37. package/dist/device/sandbox-rounds/auto-prepare.d.ts +3 -1
  38. package/dist/device/sandbox-rounds/auto-prepare.d.ts.map +1 -1
  39. package/dist/device/sandbox-rounds/auto-prepare.js +3 -3
  40. package/dist/device/sandbox-rounds/auto-prepare.js.map +1 -1
  41. package/dist/device/sandbox-rounds/in-flight.d.ts.map +1 -0
  42. package/dist/device/sandbox-rounds/in-flight.js.map +1 -0
  43. package/dist/device/sandbox-rounds/keeper.d.ts +26 -4
  44. package/dist/device/sandbox-rounds/keeper.d.ts.map +1 -1
  45. package/dist/device/sandbox-rounds/keeper.js +129 -31
  46. package/dist/device/sandbox-rounds/keeper.js.map +1 -1
  47. package/dist/device/sandbox-rounds/probation-watch.d.ts.map +1 -1
  48. package/dist/device/sandbox-rounds/probation-watch.js.map +1 -1
  49. package/dist/device/tools/android-parse.d.ts +69 -0
  50. package/dist/device/tools/android-parse.d.ts.map +1 -0
  51. package/dist/device/tools/android-parse.js +391 -0
  52. package/dist/device/tools/android-parse.js.map +1 -0
  53. package/dist/device/tools/android.d.ts +71 -0
  54. package/dist/device/tools/android.d.ts.map +1 -0
  55. package/dist/device/tools/android.js +534 -0
  56. package/dist/device/tools/android.js.map +1 -0
  57. package/dist/device/tools/apps.d.ts +3 -3
  58. package/dist/device/tools/apps.d.ts.map +1 -1
  59. package/dist/device/tools/apps.js +16 -6
  60. package/dist/device/tools/apps.js.map +1 -1
  61. package/dist/device/tools/describe.d.ts.map +1 -1
  62. package/dist/device/tools/describe.js +10 -5
  63. package/dist/device/tools/describe.js.map +1 -1
  64. package/dist/device/tools/device.d.ts +9 -15
  65. package/dist/device/tools/device.d.ts.map +1 -1
  66. package/dist/device/tools/device.js +34 -58
  67. package/dist/device/tools/device.js.map +1 -1
  68. package/dist/device/tools/elements.d.ts +13 -0
  69. package/dist/device/tools/elements.d.ts.map +1 -0
  70. package/dist/device/tools/elements.js +84 -0
  71. package/dist/device/tools/elements.js.map +1 -0
  72. package/dist/device/tools/ic-binary.d.ts +1 -0
  73. package/dist/device/tools/ic-binary.d.ts.map +1 -1
  74. package/dist/device/tools/ic-binary.js +40 -17
  75. package/dist/device/tools/ic-binary.js.map +1 -1
  76. package/dist/device/tools/sandboxes.d.ts +1 -1
  77. package/dist/device/tools/sandboxes.d.ts.map +1 -1
  78. package/dist/device/tools/sandboxes.js +9 -8
  79. package/dist/device/tools/sandboxes.js.map +1 -1
  80. package/dist/device/tools/shell.js +1 -1
  81. package/dist/device/tools/shell.js.map +1 -1
  82. package/dist/device/tools/view.d.ts +4 -0
  83. package/dist/device/tools/view.d.ts.map +1 -0
  84. package/dist/device/tools/view.js +4 -0
  85. package/dist/device/tools/view.js.map +1 -0
  86. package/dist/environments/children.js +1 -1
  87. package/dist/environments/children.js.map +1 -1
  88. package/dist/environments/commands.js +1 -1
  89. package/dist/environments/commands.js.map +1 -1
  90. package/dist/environments/crossing.d.ts +1 -1
  91. package/dist/environments/crossing.d.ts.map +1 -1
  92. package/dist/environments/machine.js +1 -1
  93. package/dist/environments/machine.js.map +1 -1
  94. package/dist/environments/wsl.d.ts.map +1 -0
  95. package/dist/environments/wsl.js.map +1 -0
  96. package/dist/resident.d.ts.map +1 -1
  97. package/dist/resident.js +14 -8
  98. package/dist/resident.js.map +1 -1
  99. package/dist/status.d.ts.map +1 -1
  100. package/dist/status.js +16 -13
  101. package/dist/status.js.map +1 -1
  102. package/dist/sync/attach-commands.d.ts +39 -0
  103. package/dist/sync/attach-commands.d.ts.map +1 -0
  104. package/dist/sync/attach-commands.js +152 -0
  105. package/dist/sync/attach-commands.js.map +1 -0
  106. package/dist/sync/commands.d.ts +8 -4
  107. package/dist/sync/commands.d.ts.map +1 -1
  108. package/dist/sync/commands.js +137 -79
  109. package/dist/sync/commands.js.map +1 -1
  110. package/dist/sync/config.d.ts +43 -9
  111. package/dist/sync/config.d.ts.map +1 -1
  112. package/dist/sync/config.js +86 -41
  113. package/dist/sync/config.js.map +1 -1
  114. package/dist/sync/endpoint.d.ts +8 -1
  115. package/dist/sync/endpoint.d.ts.map +1 -1
  116. package/dist/sync/endpoint.js +41 -1
  117. package/dist/sync/endpoint.js.map +1 -1
  118. package/dist/sync/environment.d.ts +11 -0
  119. package/dist/sync/environment.d.ts.map +1 -0
  120. package/dist/sync/environment.js +48 -0
  121. package/dist/sync/environment.js.map +1 -0
  122. package/dist/sync/exec.d.ts +1 -0
  123. package/dist/sync/exec.d.ts.map +1 -1
  124. package/dist/sync/exec.js +4 -5
  125. package/dist/sync/exec.js.map +1 -1
  126. package/dist/sync/folders.d.ts +3 -2
  127. package/dist/sync/folders.d.ts.map +1 -1
  128. package/dist/sync/folders.js +55 -2
  129. package/dist/sync/folders.js.map +1 -1
  130. package/dist/sync/forget-command.d.ts +22 -0
  131. package/dist/sync/forget-command.d.ts.map +1 -0
  132. package/dist/sync/forget-command.js +95 -0
  133. package/dist/sync/forget-command.js.map +1 -0
  134. package/dist/sync/git-bridge.d.ts +6 -0
  135. package/dist/sync/git-bridge.d.ts.map +1 -1
  136. package/dist/sync/git-bridge.js +64 -1
  137. package/dist/sync/git-bridge.js.map +1 -1
  138. package/dist/sync/gone-watch.d.ts +18 -0
  139. package/dist/sync/gone-watch.d.ts.map +1 -0
  140. package/dist/sync/gone-watch.js +142 -0
  141. package/dist/sync/gone-watch.js.map +1 -0
  142. package/dist/sync/gone.d.ts +22 -0
  143. package/dist/sync/gone.d.ts.map +1 -0
  144. package/dist/sync/gone.js +60 -0
  145. package/dist/sync/gone.js.map +1 -0
  146. package/dist/sync/local-sandboxes.d.ts +6 -0
  147. package/dist/sync/local-sandboxes.d.ts.map +1 -0
  148. package/dist/sync/local-sandboxes.js +26 -0
  149. package/dist/sync/local-sandboxes.js.map +1 -0
  150. package/dist/sync/mirror.d.ts +7 -0
  151. package/dist/sync/mirror.d.ts.map +1 -1
  152. package/dist/sync/mirror.js +434 -111
  153. package/dist/sync/mirror.js.map +1 -1
  154. package/dist/sync/mutagen.d.ts +50 -18
  155. package/dist/sync/mutagen.d.ts.map +1 -1
  156. package/dist/sync/mutagen.js +294 -80
  157. package/dist/sync/mutagen.js.map +1 -1
  158. package/dist/sync/project/adopt.d.ts +18 -0
  159. package/dist/sync/project/adopt.d.ts.map +1 -0
  160. package/dist/sync/project/adopt.js +86 -0
  161. package/dist/sync/project/adopt.js.map +1 -0
  162. package/dist/sync/{project-commands.d.ts → project/project-commands.d.ts} +4 -1
  163. package/dist/sync/project/project-commands.d.ts.map +1 -0
  164. package/dist/sync/{project-commands.js → project/project-commands.js} +10 -10
  165. package/dist/sync/project/project-commands.js.map +1 -0
  166. package/dist/sync/project/project-delivery.d.ts +23 -0
  167. package/dist/sync/project/project-delivery.d.ts.map +1 -0
  168. package/dist/sync/project/project-delivery.js +315 -0
  169. package/dist/sync/project/project-delivery.js.map +1 -0
  170. package/dist/sync/{project-files.d.ts → project/project-files.d.ts} +1 -1
  171. package/dist/sync/project/project-files.d.ts.map +1 -0
  172. package/dist/sync/{project-files.js → project/project-files.js} +5 -2
  173. package/dist/sync/project/project-files.js.map +1 -0
  174. package/dist/sync/{project-local.d.ts → project/project-local.d.ts} +1 -1
  175. package/dist/sync/project/project-local.d.ts.map +1 -0
  176. package/dist/sync/{project-local.js → project/project-local.js} +1 -1
  177. package/dist/sync/project/project-local.js.map +1 -0
  178. package/dist/sync/{project-remote.d.ts → project/project-remote.d.ts} +1 -1
  179. package/dist/sync/project/project-remote.d.ts.map +1 -0
  180. package/dist/sync/{project-remote.js → project/project-remote.js} +1 -1
  181. package/dist/sync/project/project-remote.js.map +1 -0
  182. package/dist/sync/{project-transfer.d.ts → project/project-transfer.d.ts} +20 -6
  183. package/dist/sync/project/project-transfer.d.ts.map +1 -0
  184. package/dist/sync/{project-transfer.js → project/project-transfer.js} +26 -23
  185. package/dist/sync/project/project-transfer.js.map +1 -0
  186. package/dist/sync/report.d.ts.map +1 -1
  187. package/dist/sync/report.js +13 -7
  188. package/dist/sync/report.js.map +1 -1
  189. package/dist/sync/restore-points.d.ts +15 -11
  190. package/dist/sync/restore-points.d.ts.map +1 -1
  191. package/dist/sync/restore-points.js +25 -24
  192. package/dist/sync/restore-points.js.map +1 -1
  193. package/dist/sync/retire.d.ts +14 -0
  194. package/dist/sync/retire.d.ts.map +1 -0
  195. package/dist/sync/retire.js +66 -0
  196. package/dist/sync/retire.js.map +1 -0
  197. package/dist/sync/siblings.d.ts +27 -0
  198. package/dist/sync/siblings.d.ts.map +1 -0
  199. package/dist/sync/siblings.js +112 -0
  200. package/dist/sync/siblings.js.map +1 -0
  201. package/dist/sync/ssh.d.ts +1 -0
  202. package/dist/sync/ssh.d.ts.map +1 -1
  203. package/dist/sync/ssh.js +37 -23
  204. package/dist/sync/ssh.js.map +1 -1
  205. package/dist/sync/tunnel.d.ts +6 -2
  206. package/dist/sync/tunnel.d.ts.map +1 -1
  207. package/dist/sync/tunnel.js +45 -48
  208. package/dist/sync/tunnel.js.map +1 -1
  209. package/dist/upkeep/doctor.d.ts +5 -0
  210. package/dist/upkeep/doctor.d.ts.map +1 -0
  211. package/dist/upkeep/doctor.js +73 -0
  212. package/dist/upkeep/doctor.js.map +1 -0
  213. package/dist/upkeep/entry.d.ts +26 -0
  214. package/dist/upkeep/entry.d.ts.map +1 -0
  215. package/dist/upkeep/entry.js +2 -0
  216. package/dist/upkeep/entry.js.map +1 -0
  217. package/dist/upkeep/login.d.ts +15 -0
  218. package/dist/upkeep/login.d.ts.map +1 -0
  219. package/dist/upkeep/login.js +128 -0
  220. package/dist/upkeep/login.js.map +1 -0
  221. package/dist/upkeep/manifest.d.ts +3 -0
  222. package/dist/upkeep/manifest.d.ts.map +1 -0
  223. package/dist/upkeep/manifest.js +6 -0
  224. package/dist/upkeep/manifest.js.map +1 -0
  225. package/dist/upkeep/reconcile.d.ts +45 -0
  226. package/dist/upkeep/reconcile.d.ts.map +1 -0
  227. package/dist/upkeep/reconcile.js +159 -0
  228. package/dist/upkeep/reconcile.js.map +1 -0
  229. package/dist/upkeep/retention.d.ts +10 -0
  230. package/dist/upkeep/retention.d.ts.map +1 -0
  231. package/dist/upkeep/retention.js +71 -0
  232. package/dist/upkeep/retention.js.map +1 -0
  233. package/dist/upkeep/retired.d.ts +12 -0
  234. package/dist/upkeep/retired.d.ts.map +1 -0
  235. package/dist/upkeep/retired.js +125 -0
  236. package/dist/upkeep/retired.js.map +1 -0
  237. package/dist/upkeep/second-ic.d.ts +4 -0
  238. package/dist/upkeep/second-ic.d.ts.map +1 -0
  239. package/dist/upkeep/second-ic.js +36 -0
  240. package/dist/upkeep/second-ic.js.map +1 -0
  241. package/dist/upkeep/trash.d.ts +9 -0
  242. package/dist/upkeep/trash.d.ts.map +1 -0
  243. package/dist/upkeep/trash.js +77 -0
  244. package/dist/upkeep/trash.js.map +1 -0
  245. package/dist/watchdog.d.ts +13 -0
  246. package/dist/watchdog.d.ts.map +1 -0
  247. package/dist/watchdog.js +38 -0
  248. package/dist/watchdog.js.map +1 -0
  249. package/package.json +9 -9
  250. package/dist/autostart/legacy-autostart.d.ts +0 -4
  251. package/dist/autostart/legacy-autostart.d.ts.map +0 -1
  252. package/dist/autostart/legacy-autostart.js +0 -33
  253. package/dist/autostart/legacy-autostart.js.map +0 -1
  254. package/dist/device/tools/in-flight.d.ts.map +0 -1
  255. package/dist/device/tools/in-flight.js.map +0 -1
  256. package/dist/sync/project-commands.d.ts.map +0 -1
  257. package/dist/sync/project-commands.js.map +0 -1
  258. package/dist/sync/project-files.d.ts.map +0 -1
  259. package/dist/sync/project-files.js.map +0 -1
  260. package/dist/sync/project-local.d.ts.map +0 -1
  261. package/dist/sync/project-local.js.map +0 -1
  262. package/dist/sync/project-remote.d.ts.map +0 -1
  263. package/dist/sync/project-remote.js.map +0 -1
  264. package/dist/sync/project-transfer.d.ts.map +0 -1
  265. package/dist/sync/project-transfer.js.map +0 -1
  266. package/dist/wsl.d.ts.map +0 -1
  267. package/dist/wsl.js.map +0 -1
  268. /package/dist/device/{tools → sandbox-rounds}/in-flight.d.ts +0 -0
  269. /package/dist/device/{tools → sandbox-rounds}/in-flight.js +0 -0
  270. /package/dist/{wsl.d.ts → environments/wsl.d.ts} +0 -0
  271. /package/dist/{wsl.js → environments/wsl.js} +0 -0
package/README.md CHANGED
@@ -17,8 +17,31 @@ flowchart LR
17
17
  entry from [local-agent](../local-agent) restarts it; inside a WSL distro the Windows side does.
18
18
  - **device** (`src/device/`): `device setup` redeems a one-time pairing token, then the agent keeps a WebSocket
19
19
  dialled out to the sandbox and answers MCP tools on it: `run_command`, files, windows and clipboard, `browser_*`
20
- through [browser](../browser), `screenshot` and `device` through [desktop-automation](../desktop-automation), and
21
- the intentic sandboxes on this machine through the `ic` CLI.
20
+ through [browser](../browser), `screenshot`, `device`, `ui_elements` and `ui_act` through
21
+ [desktop-automation](../desktop-automation), and the intentic sandboxes on this machine through the `ic` CLI.
22
+ - What the agent has been shown of the screen is kept for the process's life
23
+ ([`tools/view.ts`](src/device/tools/view.ts)): each screenshot is a frame with an id, shrunk to what a model reads
24
+ whole, and a coordinate is read in the newest frame and mapped back to desktop pixels, so one naming an older frame
25
+ is refused. An action's confirming screenshot re-captures the same part of the screen, and up to two identical ones
26
+ in a row come back as a sentence instead of the image. Element refs from `ui_elements` hold until the next listing.
27
+ - Input has two rules no switch lifts and one a switch decides ([`tools/device.ts`](src/device/tools/device.ts)):
28
+ keys that lock or leave the desktop (`super+l`, `ctrl+alt+Delete`, a console switch) are refused; text typed,
29
+ pasted or set into a field that the command classifier reads as destructive needs "Run destructive commands",
30
+ as running it would. The sandbox also judges that text with the owner's safety policy before it crosses
31
+ (`hosts/host-command-guard.ts`, `typedInCall`).
32
+ - An Android phone attached to the machine over adb, by USB or wireless debugging, gets its own tools
33
+ ([`tools/android.ts`](src/device/tools/android.ts), its parsers in `android-parse.ts`): `android_devices`,
34
+ `android_screenshot`, `android_ui_elements`, `android_act`, `android_shell`, `android_install` and
35
+ `android_logcat`. adb is found in `ANDROID_HOME`, `ANDROID_SDK_ROOT`, Android Studio's SDK, then `PATH`, afresh
36
+ on every call; without it each tool answers how to install platform-tools. A call names a phone by `serial` and
37
+ is refused when several are attached and none is named. Phone screenshots are frames like the desktop's, in a
38
+ `FrameLog` per phone and shown as `phone-…`, so a desktop frame is never read as a phone one. Element refs
39
+ (`e1`…) come from the uiautomator dump and hold until the next listing. The switches are the desktop's:
40
+ `screen` to look, `control` to touch, `shell` for the shell, logcat and the device list, `shell` and `write` to
41
+ install. Keys that lock the phone (POWER, SLEEP) are refused, and a command or typed text the classifiers read
42
+ as destructive (the shared one, plus `pm uninstall`, `pm clear`, `rm -r`, `settings put`, `svc`, `reboot`, a
43
+ wipe) needs "Run destructive commands". The sandbox judges `android_shell` as `adb shell <command>` and text
44
+ typed with `android_act` like the desktop's, before either crosses.
22
45
  - The sandbox tools are thin callers of `ic` ([`tools/sandboxes.ts`](src/device/tools/sandboxes.ts)): the listing is
23
46
  `ic sandbox list --json` passed through, and start, stop, restart, the swaps, `set-shape`, `forget-shape` and the
24
47
  logs are ic's own verbs, argv spelled by the contract (`icShapeArgs`, `icPowerArgs`). `diagnose_sandbox` is
@@ -45,7 +68,8 @@ flowchart LR
45
68
  of the sandbox's is written into it: no state backup session, no git bridge, and an ignore list that keeps a
46
69
  project's own `.intentic/` and `refs/` ([`sync/config.ts`](src/sync/config.ts) holds a remote dir to those two
47
70
  shapes, and refuses `sync.json` whole otherwise). `setup` refuses a folder that is, holds or sits inside another
48
- sandbox's, and a set-up-again that would change where a paired sandbox's folder syncs. It is **copy-first** unless
71
+ sandbox's, in this environment or another of this PC's (see [What folder sync owns](#what-folder-sync-owns)), and a
72
+ set-up-again that would change where a paired sandbox's folder syncs. It is **copy-first** unless
49
73
  its owner opted into two-way: see [Copy-first projects](#copy-first-projects).
50
74
  - **Through Docker** ([`sync/endpoint.ts`](src/sync/endpoint.ts)): with `setup --transport auto`, the default, a
51
75
  project's sandbox is reached through this machine's own Docker engine when the container `ic` named after the
@@ -58,6 +82,13 @@ flowchart LR
58
82
  container's, and the container is checked to still be this sandbox before any session is made through it.
59
83
  - Enrollment, the ports read and the report still go to the sandbox's own address, so the tunnel is untouched.
60
84
  - Existing ssh pairings keep ssh until they are set up again.
85
+ - (2026-10-05) The transport is no longer decided once: the container is asked after on every prepare and every
86
+ minute (`checkContainers` in [`sync/gone-watch.ts`](src/sync/gone-watch.ts)). A stopped container, or an engine that
87
+ does not answer, changes nothing. A container the engine no longer holds is looked up in ic: still listed (a swap
88
+ moving it) changes nothing; in ic's trash pauses the folder's sync with that reason. When the sandbox still answers
89
+ at its address, whether ic holds it nowhere or cannot say, it lives elsewhere now and the pairing moves onto ssh
90
+ (paused instead without an enrollment to ride); in neither and silent, the sandbox is gone (below); otherwise its
91
+ sync is paused until the container is back.
61
92
 
62
93
  (2026-10-01) Measured on Docker Desktop from Windows and from WSL: a session was up in 2–4 s, against up to 90 s
63
94
  through the tunnel. Bind-mounting the folder into the container was rejected: a land or an agent's `rm -rf` would
@@ -68,7 +99,13 @@ flowchart LR
68
99
  name, terminating any extras, and recreates a session whose rules drifted (its ignores, its folders, its sync mode,
69
100
  its symlink mode, how often it scans the sandbox). A two-way session replaced by another waits until no conflicts
70
101
  are left and has its derived residue swept first; every other replacement happens as soon as the sandbox answers
71
- (`settlesFirst` in [`sync/mutagen.ts`](src/sync/mutagen.ts) says why for each).
102
+ (`settlesFirst` in [`sync/mutagen.ts`](src/sync/mutagen.ts) says why for each). Every session and forward is created
103
+ with three Mutagen labels, `intentic-owner=<machineId>-<environment>`, `intentic-sandbox=<sanitized id>` and
104
+ `intentic-kind=sync|state|forward`; see [What folder sync owns](#what-folder-sync-owns).
105
+ - **This computer's own sandbox takes folders** (see [Folders attached to this computer's sandbox](#folders-attached-to-this-computers-sandbox)):
106
+ `sync setup --projects-host` enrolls it with no folder of its own, and each folder the owner opens attaches to it as
107
+ `/work/<name>` (`sync attach`), copy-first, with the land of a conversation written back into it by itself
108
+ (`deliverProject`).
72
109
  - **What file sync costs this device is cycles, and the sandbox's side decides how many.** Mutagen's agent in the
73
110
  sandbox has no recursive watcher, and both sessions force it to poll (`--watch-mode-<side> force-poll`): every 2 s
74
111
  for the workspace, which is how late an agent's edit reaches the folder, and every 60 s for the state backup, which
@@ -94,8 +131,12 @@ flowchart LR
94
131
  host key the enrollment carries when it carries one (a sandbox reads its sshd's public key off its history volume and
95
132
  answers with it, `SyncEnrollmentAnswerSchema` in the contract), else with nothing, so `accept-new` records the key
96
133
  the sandbox presents now, as it does for an older sandbox. Outside an enrollment a changed key is still refused. A sandbox whose ports poll has failed for ten
97
- minutes has its forwards taken off localhost, and they come back with its first answer. `sync uninstall` stops and
98
- unregisters Mutagen's daemon only when it is this agent's own copy, never a Mutagen the user installed.
134
+ minutes has its forwards taken off localhost, and they come back with its first answer; after an hour its sessions
135
+ are paused, an hour counted from `unreachableSince` in `sync.json` (2026-10-05: it was counted in memory, so an agent
136
+ restarted more often than hourly never paused a dead sandbox's sessions). A sandbox that no longer exists is another
137
+ matter: see [Sandboxes that are gone](#sandboxes-that-are-gone). `sync uninstall` stops and unregisters Mutagen's
138
+ daemon only when it is this agent's own copy and no Mutagen the user installed is on PATH, since one daemon serves
139
+ both.
99
140
  - Symbolic links travel only where the device can create them ([`sync/symlinks.ts`](src/sync/symlinks.ts)). A
100
141
  Windows PC without Developer Mode refuses every link, and Mutagen would try again on every cycle, so its sessions
101
142
  use `--symlink-mode ignore`. Turning Developer Mode on brings them back at the agent's next start.
@@ -105,7 +146,8 @@ flowchart LR
105
146
  a sandbox joins enrollments, sync enrollments and device rows on it, never on a hostname.
106
147
  - The features a device advertises (`set-shape`, `reshape-later`, `rollback-to`, `background-prepare`) are read off
107
148
  the `ic` under it, from that `ic`'s own help (`ic sandbox shape --set` for the first two, `ic sandbox rollback --to`
108
- for the third, `ic sandbox prepare --auto` for the fourth), rather than listed beside the code. `background-prepare`
149
+ for the third, `ic sandbox prepare --auto` for the fourth), rather than listed beside the code. The agent's own two,
150
+ `loopback-catch` and `project-delivery`, are always advertised, since this build answers both. `background-prepare`
109
151
  is the `prepare-background` op the update card sends when it opens: the same unattended download as the timer's,
110
152
  run now. The device RPC inputs are strict, so an op or field this agent does not know is refused
111
153
  rather than dropped, and a `to` on anything but a rollback is refused. The one exception is the grant a sandbox
@@ -117,7 +159,9 @@ flowchart LR
117
159
  set after startup; a test holds the sources to it.
118
160
  - Scopes are enforced here and nowhere else: the sandbox only asks, and a refusal names the switch that is off.
119
161
  Files stay inside the configured roots, writes need their own switch, and every call is appended to
120
- `~/.intentic/machine/audit.jsonl`. While an agent drives input on Windows, a notice shows on screen and
162
+ `~/.intentic/machine/audit.jsonl`, set aside as `audit.jsonl.1` once it reaches 8 MB (2026-10-05: it grew without
163
+ limit; the writer rolls it itself, and the [upkeep](#upkeep) does for an agent that wrote nothing lately). While an
164
+ agent drives input on Windows, a notice shows on screen and
121
165
  `PAUSE_HOTKEY` pauses every link. The one door behind no switch is this agent's own Update and Restart
122
166
  (`runAgentFlow`, [`device/tools/agent.ts`](src/device/tools/agent.ts)): no agent tool reaches it, and the sandbox
123
167
  admits only a maintainer to it, so the owner's maintenance does not wait on the agents' "Run commands" (2026-09-29;
@@ -210,7 +254,7 @@ folded where the platform folds it). With `--json` each prints exactly one JSON
210
254
 
211
255
  | Command | Success output |
212
256
  |---|---|
213
- | `sync changes` | `{ "ok": true, "pairing": "<id>", "direction": "to-sandbox"\|"both", "changes": [{ "path", "kind": "added"\|"modified"\|"deleted", "size"?, "conflict"?: true }], "truncated"?: true }` |
257
+ | `sync changes` | `{ "ok": true, "pairing": "<pairing key>", "direction": "to-sandbox"\|"both", "changes": [{ "path", "kind": "added"\|"modified"\|"deleted", "size"?, "conflict"?: true }], "truncated"?: true }` |
214
258
  | `sync bring-back [--path <p>]... [--paths-file <file>]` | `{ "ok": true, "point": "<id>", "applied": [{ "path", "kind" }], "skipped": [{ "path", "reason" }] }` |
215
259
  | `sync restore-points` | `{ "ok": true, "points": [{ "id", "createdAt", "entries": n }] }` |
216
260
  | `sync restore --point <id>` | `{ "ok": true, "restored": n, "skipped": [{ "path", "reason" }] }` |
@@ -221,7 +265,7 @@ folded where the platform folds it). With `--json` each prints exactly one JSON
221
265
  sandbox copy's), `deleted` was removed there. The sandbox is listed by ONE command over the pairing's ssh alias, a
222
266
  node program that walks `/work/<name>` and prints path, size and sha256 NUL-separated, so any name survives. This
223
267
  device is walked the same way, reading a file only where its size matches the sandbox's, through a hash cache keyed
224
- by path, size and times in `~/.intentic/machine/hashes/<pairing>.json`. Both walks prune by the pairing's ignore list
268
+ by path, size and times in `~/.intentic/machine/hashes/<pairing key>.json`. Both walks prune by the pairing's ignore list
225
269
  read as Mutagen reads it: a name, `*` within it, at any depth (or at the root with a leading `/`), taking a matched
226
270
  folder's contents along; any other spelling is refused rather than approximated. Mutagen's `.mutagen-temporary-*`
227
271
  scratch files are left out too, and so are links and anything else that is not a regular file, on either side.
@@ -253,15 +297,16 @@ folded where the platform folds it). With `--json` each prints exactly one JSON
253
297
  their last agreement to the same content: Mutagen records that and transfers nothing. A sandbox file that changed
254
298
  again meanwhile is a sandbox modification one-way-safe keeps, as a conflict. The e2e run checked all of it: no
255
299
  conflicts after resume, the sandbox still holding the agent's content, and a later edit here carried over as usual.
256
- - **Restore points** live in `~/.intentic/machine/restore/<pairing>/<id>/`, the id being the creation time in ISO 8601
300
+ - **Restore points** live in `~/.intentic/machine/restore/<pairing key>/<id>/`, the id being the creation time in ISO 8601
257
301
  basic format (`20260928T213000.123Z`, a folder name on every system). Each holds `manifest.json`, which is
258
- `{ id, createdAt, dir, entries: [{ path, kind, backedUp, applied, backup?, mode? }] }` (`applied` is the sha256
259
- bring-back left, null for a deletion), and `files/<path>`, a copy of every file bring-back overwrote or deleted. Only
302
+ `{ id, createdAt, dir, entries: [{ path, kind, backedUp, applied, backup?, mode? }], landing? }` (`applied` is the
303
+ sha256 bring-back left, null for a deletion; `landing` names the land a delivery wrote), and `files/<path>`, a copy of
304
+ every file bring-back overwrote or deleted. A delivery keeps its points here in the same shape. Only
260
305
  what was actually written stays in the manifest. All of it is on the disk before the first write here: each copy is
261
306
  flushed as it is made, the manifest by a durable write, then every folder of the point up to `restore/<pairing>`, so a
262
307
  crash right after a bring-back never finds the folder rewritten and the copies empty. Files put in the folder are
263
308
  flushed before the rename that places them.
264
- - **Retention**, after each bring-back and under its lock: a pairing keeps its newest 20 points that hold files, plus
309
+ - **Retention**, after each bring-back or delivery and under its lock: a pairing keeps its newest 20 points that hold files, plus
265
310
  every such point younger than 30 days. A point that holds nothing (a bring-back that wrote nothing, or one cut off
266
311
  before its manifest) never counts toward the 20, is never listed, and goes at the next bring-back. Until then it is
267
312
  the `point` that bring-back answered with, so that answer always names a real point, as the desktop app expects.
@@ -269,9 +314,225 @@ folded where the platform folds it). With `--json` each prints exactly one JSON
269
314
  folder still holds exactly what bring-back left (by sha256). Anything else is skipped and reported, one already put
270
315
  back included. Copy-first then carries the restored files to the sandbox, as it does any edit here, so the agent's
271
316
  versions they replace are gone from there too.
272
- - One bring-back or restore runs per folder at a time (`restore/<pairing>/.operation.pid`, a pid of this boot, checked
273
- alive). A pause an operation made and never lifted (the process killed in between) is recorded in
274
- `restore/<pairing>/.paused`, and the next command about that folder resumes the session.
317
+ - One bring-back, restore or delivery runs per folder at a time (`restore/<pairing key>/.operation.pid`, a pid of this
318
+ boot, checked alive). A pause an operation made and never lifted (the process killed in between) is recorded in
319
+ `restore/<pairing key>/.paused`, and the next command about that folder resumes the session.
320
+
321
+ ### Folders attached to this computer's sandbox
322
+
323
+ Each computer has one always-available local sandbox, and any number of folders attach to it, each as `/work/<name>`.
324
+ A folder used to get a project sandbox of its own (`sync setup --project`, above), which still works.
325
+
326
+ **The projects host.** `intentic-machine sync setup --url <sandboxUrl> --pair <token> --projects-host` (with
327
+ `--transport` and `--sandbox-id` as for any setup) enrolls the machine sandbox exactly as a setup does, and records a
328
+ folderless pairing: `{ sandboxUrl, sandboxId, mode: "sync", syncToken, projectsHost: true, transport?, container? }`.
329
+ It has no Mutagen session, no git bridge and no state backup. It holds the sandbox's sync token, and it is the one
330
+ pairing that mirrors the sandbox's ports to localhost. It reaches the sandbox through Docker when the sandbox's
331
+ container runs on this machine's engine, so its forwards ride `docker://` too. Refused:
332
+
333
+ - `--projects-host` with `--dir`, `--remote-dir` or `--project`.
334
+ - Turning a sandbox that syncs a folder into the projects host, and the other way round. Unpair first.
335
+ - An enrollment that comes back ports-only.
336
+
337
+ Enrolling again rotates the sandbox's one token per machine key, so the new token is written into every pairing of that
338
+ sandbox (`withPairing` in [`sync/config.ts`](src/sync/config.ts)).
339
+
340
+ **Attaching and detaching.** Both are local: no enrollment, no network. With `--json` each prints exactly one JSON
341
+ object, `{ "ok": false, "error": "<sentence>" }` and exit code 1 on failure, as the copy-first commands do.
342
+
343
+ | Command | Success output |
344
+ |---|---|
345
+ | `sync attach --sandbox-url <url> --dir <folder> --name <name>` | `{ "ok": true, "pairing": "<sandboxId>~<name>", "remoteDir": "/work/<name>", "folder": "<folder as given, absolute>" }` |
346
+ | `sync detach --dir <folder>` | `{ "ok": true, "pairing": "<key>", "folder": "<folder>" }` |
347
+
348
+ - `attach` finds the projects host whose URL has the same host as `--sandbox-url`. Without one it answers "this
349
+ computer's sandbox is not set up for folders yet".
350
+ - It refuses:
351
+ - a name `isProjectDirName` refuses (the sandbox's reserved names included);
352
+ - a folder that is not there;
353
+ - a whole disk, the home folder or one holding it, `/var/home` or a whole home in it, and the system's folders (on
354
+ Windows, on whichever drive), as the desktop app does ([`folderRefusal`](src/sync/folders.ts)). (2026-10-06) A WSL
355
+ distro's folder named from Windows (`\\wsl.localhost\<distro>\…`, `\\wsl$\…`) is held to the distro's rules: its
356
+ `/home`, a whole home in it, `/root`, a mounted disk (`/mnt/c`) and its system's folders;
357
+ - a folder that is, holds or sits inside any other pairing's, this environment's or (2026-10-05) another environment
358
+ of this PC's ([`sync/siblings.ts`](src/sync/siblings.ts), as `setup` does);
359
+ - a name already attached for another folder.
360
+ - It records `{ key: "<sandboxId>~<name>", sandboxId, sandboxUrl, syncToken (the host's), mode: "sync", localDir,
361
+ remoteDir: "/work/<name>", project: true, direction: "to-sandbox", deliver: "auto", transport?, container? }`.
362
+ Docker is preferred, as for the host. Attaching the same folder under the same name again keeps the direction its
363
+ owner chose. `localDir` is the folder as given, made absolute, as `setup` records one; links are resolved only to
364
+ compare folders (2026-10-05: the desktop app finds its folder in `status --json` by the path it passed, and on Fedora
365
+ Atomic `/home` is itself a link to `/var/home`, so a link-resolved path would never be found).
366
+ - It returns once the pairing is recorded and the resident agent is running. The agent creates the session on its next
367
+ pass, and `status --json` lists the folder with its `localDir`, `remoteDir`, `deliver` and `mutagenStatus`.
368
+ `mutagenStatus` is absent until the session exists, then Mutagen's own words: `connecting-beta`, `scanning`,
369
+ `reconciling`, `staging-beta`, `transitioning`, `saving` while the first copy runs, and `watching` once a whole cycle
370
+ has finished. Read the first `watching` as "first copy done": later edits pass through the same words again.
371
+ - `detach` removes the pairing first, so the agent recreates nothing, then ends its session and drops its listing record.
372
+ Its restore points stay. A folder with a sandbox of its own is refused, with the `sync uninstall` that unpairs it.
373
+ - `changes`, `bring-back`, `restore-points`, `restore` and `direction` work on an attached folder as on any project, by
374
+ `--dir`.
375
+
376
+ **A pairing key.** Every pairing is filed under `pairingKey` = `key ?? sandboxId`. Every pairing made before folders could
377
+ attach has no `key`, so its key is its sandbox id and nothing of it moves: its session names, its restore points, its
378
+ listing record, its lock. The key names what is a folder's own: the `upsertPairing`/`removePairing`/`set*` writes, the
379
+ Mutagen session (`sessionName`), `restore/<key>/` with its lock and pause marker, and `hashes/<key>.json`. The sandbox
380
+ id still names what is the sandbox's: the enrollment and its token, the ssh alias and tunnel (one block per sandbox),
381
+ the container, the forwards, the swap pause, the report's `sandboxId`. `sync.json` is refused whole for:
382
+
383
+ - two pairings under one key;
384
+ - an attached key that is not `<sandboxId>~<name>` for its `/work/<name>`;
385
+ - a projects host with a folder, remote dir, project flag or key.
386
+
387
+ `--sandbox` (pause, resume, mirror, autoheal, uninstall) selects every pairing of the sandbox it names.
388
+
389
+ **One poll, one mirror, one report per sandbox** ([`sync/mirror.ts`](src/sync/mirror.ts)):
390
+
391
+ - The sandbox's own pairing polls its ports; with no host, the first folder attached to it does. The poll is also how the
392
+ sandbox's liveness and a revoked enrollment are noticed. Every other folder follows that answer
393
+ (`followSandbox`): paused after an hour of no answer, resumed on the first. A folder never mirrors a port, whatever
394
+ `mirrorOff` says. It carries the sandbox's switch only so the report says the same of it.
395
+ - A revoked enrollment drops every pairing of the sandbox, since they share its token.
396
+ - The report goes to each sandbox once (`reportCarriers`), and `scopedReport` carries all of its pairings, host and
397
+ folders, each with `remoteDir`, `projectsHost` and `deliver`: the daemon keeps one report per machine.
398
+ - **A folder's first copy waits for its report** (`readyToPrepare`). The daemon makes `/work/<name>` a repository of its
399
+ own once a report names it. So a folder whose session does not exist yet gets one only after a report naming it was
400
+ taken, tried again each pass otherwise. A daemon with no report route (404) is not waited for.
401
+
402
+ (2026-10-05) An attached folder's session is `intentic-<sandbox>--<name>-<8 hex of sha256(name)>`. A sanitized id never
403
+ holds `--`, so the name can be no sandbox's session nor its `-state` backup (a folder called `state` included). The hash
404
+ keeps `my.app` and `my_app` apart, which sanitize alike. Letting every folder poll was rejected: ten folders would cost
405
+ the daemon eleven polls every five seconds, and three rejected polls counted per sandbox would revoke after one tick.
406
+
407
+ **Delivering landed work** ([`sync/project/project-delivery.ts`](src/sync/project/project-delivery.ts)). When a conversation's work
408
+ lands in `/work/<name>`, the daemon calls `deliverProject` on the device link (`ProjectDeliverySchema` in the contract),
409
+ and this agent writes the change into the folder. It is a procedure of the link, not an MCP tool, so no agent can call
410
+ it, and it sits behind no switch of the grant: the folder's own `deliver: "auto"` is the permission. It is logged and
411
+ appended to the audit file like every call.
412
+
413
+ - **Which folder**: a project pairing whose `remoteDir` is the delivery's, of the sandbox on the other end of the link
414
+ (by URL host), with `deliver: "auto"`. None is `NOT_FOUND`, one without delivery `FORBIDDEN`, each with a sentence.
415
+ A delivery past `PROJECT_DELIVERY_MAX_BYTES` decoded (base and next together), not base64, a kind that does not carry
416
+ what it says, or a path twice is refused whole as `BAD_REQUEST`.
417
+ - **How**: under the folder's lock, with its session flushed and held still, as a bring-back is. Each file, against
418
+ what the folder holds now:
419
+ - the folder already holds `next` (or nothing, for a deletion): `already`;
420
+ - it holds `base` (nothing, for an addition): written as landed, or removed, `applied`;
421
+ - the owner changed it too, and `base`, `next` and the folder's copy are all text (no NUL in the first 8000 bytes):
422
+ merged by `git merge-file`, and `merged` with the content when that is clean. A clash is `edited`, and so is
423
+ anything not text, a file the owner deleted, a deletion of a file the owner changed, and a file the owner made
424
+ where the land adds one. No `git` on PATH is `missing-git`;
425
+ - a link or a non-file on the way is `link`; a non-portable path, or one into any `.git` folder, is `outside`. On
426
+ Windows a name Windows cannot hold (`<>:"|?*`, a trailing dot or space, a device name such as `NUL` or `COM1.txt`)
427
+ is not portable either, and is refused before anything is made for it (2026-10-06: `notes:extra` used to leave an
428
+ empty `.notes` behind and read as `edited`);
429
+ - (2026-10-06) a case-only rename (`Readme.md` deleted, `README.md` added) on a disk that holds both spellings as one
430
+ file (the two lstat to one file) is not a deletion: the addition is planned as a change of the old spelling's
431
+ content, and the file takes the new spelling once it holds what landed. The old spelling answers as its new one
432
+ did, `already` or the same conflict.
433
+ - **The restore point** is taken before the first write, with the bring-back's own primitives and shape, and cut to what
434
+ was written, so `sync restore --point <id>` undoes a delivery unchanged. None when nothing is written. A file that
435
+ moved between the plan and its write is the owner's, and is reported `edited` instead. A new file gets the landed
436
+ executable bit; an existing one keeps its own mode.
437
+ - The answer is `{ point?, folder, applied, merged: [{ path, content }], already, conflicts: [{ path, reason }] }`. The
438
+ sandbox already holds what landed, so a file written as landed has moved to the same bytes on both sides, which
439
+ Mutagen records as agreement. A merged one differs until the daemon writes `content` there too.
440
+
441
+ (2026-10-05) The merge runs `git merge-file` in place on copies in the staging folder and reads the result back as bytes,
442
+ rather than with `-p`: stdout comes back decoded as UTF-8, which would rewrite a Latin-1 file's bytes.
443
+
444
+ ### Sandboxes that are gone
445
+
446
+ (2026-10-05) A pairing used to be dropped only on three rejected polls in a row, an uninstall or a setup again. A sandbox
447
+ deleted elsewhere answered 502 forever, which is also what a restarting one answers, so its pairing, its paused sessions
448
+ and its forwards stayed for good: one PC held 13 pairings, 10 for gone sandboxes, logged a failed reconcile for each every
449
+ ten minutes and posted 5,760 unlogged reports a day to each.
450
+
451
+ Two witnesses can say a sandbox is gone ([`sync/gone.ts`](src/sync/gone.ts)); absence alone never does:
452
+
453
+ - **The edge.** Every answer from the sandbox's address that is not OK (the ports poll, a report, the announcement of a
454
+ folder's first copy) has its `x-intentic-edge` header read. `unknown-sandbox`, the contract's final verdict
455
+ (`edgeVerdictIsFinal`), means the platform has no such sandbox.
456
+ - **This machine's ic**, only for a sandbox kept here: one its pairing reaches through Docker, or one ic has listed here
457
+ before (the slug is recorded as `icSlug` the first time `ic sandbox list --json` names it; a sandbox whose daemon
458
+ answered on loopback is asked about). It is gone when ic answered its listing and its trash (`ic sandbox list`'s
459
+ "removed, still recoverable" lines) and neither holds it, while the sandbox's own poll does not answer either. Asked
460
+ every ten minutes, and only on a machine that keeps one of its pairings' sandboxes. An ic that does not answer (Docker
461
+ Desktop not started yet) concludes nothing. (2026-10-06) A sandbox that still answers at its address lives on another
462
+ computer now, whatever ic here says: marked gone on ic's word alone, its answer unmarked it at the hourly recheck and
463
+ ic marked it again minutes later, for good.
464
+
465
+ What follows is the same for both ([`sync/gone-watch.ts`](src/sync/gone-watch.ts)):
466
+
467
+ - **Marked.** Every pairing of the sandbox gets `goneSince` (first heard), `goneCheckedAt` and `goneBy` (`edge` or
468
+ `local`) in `sync.json`. Its sessions are paused with the watcher's own marker, `fileSyncPausedFor: "gone"`; a pause
469
+ somebody else made is left alone. Its ports come off localhost, its tunnel listener closes, no report is posted to
470
+ it, nothing is created for it, and its folders do nothing more. One line says so, with the day it will be retired.
471
+ - **Asked again** at most hourly (one ports poll), whatever the outcome. An answer clears all three fields, lifts the
472
+ pause, and the next pass puts the ports back. A sandbox ic listed again (restored from its trash) is cleared at once
473
+ when ic was the witness; the edge's word is withdrawn only by the sandbox answering.
474
+ - **Retired** once seven days (the trash window) have passed since it was first said to be gone, on a verdict asked
475
+ within the last hour, so an agent that was off for a week asks once first. Retiring ([`sync/retire.ts`](src/sync/retire.ts))
476
+ removes the pairings from `sync.json` first, then terminates their Mutagen sessions and forwards, rewrites the ssh
477
+ config fragment without the sandbox's block, strips its alias from this agent's `known_hosts` under every port it
478
+ was bound to, deletes each pairing's `hashes/<key>.json`, and removes the git bridge's `sandbox` remote,
479
+ `refs/remotes/sandbox/*` and `refs/intentic/bridged/*` from each repo whose remote pointed at the sandbox. The local
480
+ folder and every restore point under `restore/<key>/` stay, and the one log line says so.
481
+
482
+ **`intentic-machine sync forget <slug|sandboxId> [--json] [--here]`** retires a sandbox's pairings now, with the same
483
+ retirement. The name is matched exactly: the sandbox id (as written or sanitized), either slug its URL carries, the
484
+ slug ic listed it under, or its container's name. Naming nothing paired is not a failure (`Nothing paired in this
485
+ environment is called <name>.`), since the caller is a removal that already happened. On a Windows PC's Windows side it
486
+ also runs `sync forget <name> --here --json` in each supervised WSL distro, through `wsl.exe` as the distros' agents
487
+ are run. `ic sandbox remove` calls it, best effort. With `--json` it prints one object:
488
+ `{ "ok": true, "retired": [{ "sandboxId", "pairings": [<key>], "folders": [<dir>] }], "environments"?: [{ "environment": "wsl:<distro>", "ok", "retired"?, "error"? }] }`,
489
+ or `{ "ok": false, "error" }` with exit code 1.
490
+
491
+ **The device link** ([`device/connection.ts`](src/device/connection.ts)) has its own form of the same rule. A WebSocket
492
+ cannot read the edge's 502, so after six failed dials in a row each further attempt first asks the sandbox's address
493
+ with a plain `GET /health`. On `unknown-sandbox` the link stops dialling and is marked with `goneSince` on its own entry
494
+ in `device.json` (matched by address and token, so connecting the sandbox again replaces the mark with the link). It is
495
+ asked again every hour and dials at once when the sandbox answers; seven days on it is forgotten, as a revoked (1008)
496
+ link is.
497
+
498
+ ### What folder sync owns
499
+
500
+ (2026-10-05) Several things folder sync makes are shared with somebody else: a Mutagen daemon with the owner's own
501
+ Mutagen, a sandbox's enrollments and the PC's loopback ports with the other environments of the same PC. Each now says
502
+ whose it is.
503
+
504
+ - **Mutagen sessions carry their owner.** Created with `--label intentic-owner=<machineId>-<environment>` (environment is
505
+ `windows`, `macos`, `linux` or the WSL distro's name, [`sync/environment.ts`](src/sync/environment.ts)),
506
+ `intentic-sandbox=<sanitized id>` and `intentic-kind=sync|state|forward`. Listings print each session as
507
+ `<name>@<owner>@<identifier>`. A sweep acts on a session labelled with this owner, or on an unlabelled one under the
508
+ `intentic-` prefix (every session made before labels); one labelled with another owner is never touched, and
509
+ terminations go by identifier so a session of another owner under the same name never goes along. Mutagen cannot add
510
+ a label to an existing session, so an unlabelled one is recreated only when that is safe (the sandbox answers, and a
511
+ two-way session has settled, as for any replacement) and at most one per pass (`RelabelBudget`): a recreate over two
512
+ copies that agree rescans and copies nothing. Forwards get labels when they are next created.
513
+ - **The orphan sweep is standing**: every minute, not only at the watcher's start, a setup and a revocation.
514
+ - **Mutagen is this agent's pinned copy** (0.18.1, downloaded into its own bin), with a Mutagen on PATH used only while
515
+ that download cannot be had. A daemon of another version refuses every client ("client/daemon version mismatch"),
516
+ and each failed listing used to read as "no sessions". Now a listing that fails is a failure everywhere (the report
517
+ leaves session statuses out, nothing is created or swept on it, the pass skips that step), and a version mismatch
518
+ restarts the daemon with this agent's copy, at most once every half hour. Sessions are kept on disk and come back.
519
+ - **Every child call is bounded**: listings and terminations 60 s, a create 5 minutes, a flush 60 s, ssh's config and
520
+ key calls 30 s, and any child started without a bound of its own 10 minutes (`CHILD_TIMEOUT_MS`, sync/exec.ts). On
521
+ top of that the watcher marks its progress at every step; one that has made none for 20 minutes is abandoned, says so
522
+ in one line ("no progress for 20 minutes (it was at: ...); restarting it"), releases its tunnels and is replaced by a
523
+ fresh loop. Its heartbeat going stale is what `status` reports meanwhile.
524
+ - **Keys and enrollments per environment.** A newly made key is commented `<hostname>-<environment>`, so the sandbox
525
+ files a new enrollment under that name. An existing key keeps its comment (a sandbox older than this agent would read
526
+ a changed comment on the same key as a second machine holding sync); the sandbox tells existing ones apart by machine
527
+ and environment instead (`_sandbox/sandbox/src/peers/desktop-sync.ts`).
528
+ - **Tunnel ports per environment.** Under WSL's mirrored networking a distro's loopback is the Windows side's, so both
529
+ sides pairing one sandbox derived one port twice. A distro now derives its ports in 20000-23999 from its name and the
530
+ sandbox id; every native environment keeps 24000-27999 exactly as before, so nothing outside WSL moves.
531
+ - **One folder, one sync, across the PC.** `setup` and `attach` read the other environments' `sync.json` (from the
532
+ Windows side each supervised distro's through `wsl.exe -d <distro> cat`; from a distro the Windows side's beside its
533
+ agent) and compare after one spelling: `C:\code\app` and `/mnt/c/code/app` are `drive:c/code/app`, and
534
+ `\\wsl.localhost\Ubuntu\home\ada\app` and Ubuntu's `/home/ada/app` are `wsl:ubuntu/home/ada/app`. An overlap is refused
535
+ naming the side that syncs it; a side that could not be read is said in a note, and not checked.
275
536
 
276
537
  ### Upgrades that can be undone
277
538
 
@@ -281,17 +542,23 @@ folded where the platform folds it). With `--json` each prints exactly one JSON
281
542
  tick, `upgrade`, `run`, the browser's Update and Restart) waits while any channel record in this environment's ic
282
543
  home (`INTENTIC_HOME`, else `~/.intentic`) says `swap_phase=cutover` with `swap_at` in the last 30 minutes, or while
283
544
  this process runs a flow that moves a container ([`device/sandbox-rounds/swap-records.ts`](src/device/sandbox-rounds/swap-records.ts)); the tick
284
- looks again in five minutes, a command waits and says so.
545
+ looks again in five minutes, a command waits and says so. A keeper's fix is not such a flow (see [The keeper](#the-keeper)).
285
546
  - **The probation watch** ([`device/sandbox-rounds/probation-watch.ts`](src/device/sandbox-rounds/probation-watch.ts)) runs
286
547
  `ic sandbox watch <slug> --json` every minute for each sandbox whose record names a `swap_phase`, and
287
548
  `ic sandbox watch --json` over every sandbox half a minute after start (a reboot mid-swap) and every ten minutes.
288
549
  ic finishes or undoes an interrupted cutover and rolls a failing new version back; the agent logs what it did.
289
- The per-record watch runs in every environment, since only the environment that swapped holds the record; the sweep
290
- over every sandbox runs on the root only, as the daily jobs below do, because a WSL distro shares the Windows
291
- side's Docker engine.
292
- - **Daily**, on the root: `ic sandbox backup <slug> --auto --json` for each running sandbox, one at a time, twenty to
293
- forty minutes after start and then every day, then one `ic sandbox tidy --json`, whose volumes nobody claims are
294
- named and never deleted. Both back off from a slug that keeps failing, as auto-prepare does, and each has its switch
550
+ The per-record watch runs in every environment, since only the environment that swapped holds the record, and so
551
+ does the sweep: ic answers each environment only for the sandboxes it keeps (`HOST_PLATFORM` on the container,
552
+ [`ic`'s side.rs](../../_sandbox/ic/src/sandbox/side.rs)).
553
+ - **Daily**, in every environment, for the sandboxes it keeps: `ic sandbox backup <slug> --auto --json` for each
554
+ running sandbox, one at a time, twenty to forty minutes after start (a supervised distro half an hour later than the
555
+ root, so the two sides of a PC never read one engine's disks at once) and then every day, then one
556
+ `ic sandbox tidy --json`. Volumes nobody claims are never deleted outright: ic moves each unclaimed set into its
557
+ trash, where a person can restore it for a week.
558
+
559
+ (2026-10-05) These ran on the root only, because a WSL distro shares the Windows side's engine. Once ic began leaving
560
+ each side's sandboxes to that side, a sandbox made from WSL had nobody backing it up, preparing its update or tidying
561
+ after it, so each environment now runs them for its own. Both back off from a slug that keeps failing, as auto-prepare does, and each has its switch
295
562
  beside the update ones (`intentic-machine updates --backups off`, `--tidy off`).
296
563
  - **File sync holds still while its sandbox is swapped here** ([`sync/swap-pause.ts`](src/sync/swap-pause.ts)). A
297
564
  pairing's local slug is the first label of its sandbox's public hostname (what ic names a sandbox with one) or the
@@ -311,8 +578,8 @@ folded where the platform folds it). With `--json` each prints exactly one JSON
311
578
  this same agent with other settings. What outlives the agent on purpose is `ic` (bounded by its own swap), Mutagen's
312
579
  daemon and the browser it opened; what must not, the agent ends itself: every `run_command` group still running
313
580
  when it stops, and on Linux the groups a crashed agent left, which the next agent ends at its start
314
- ([`tools/command-ledger.ts`](src/device/tools/command-ledger.ts)). The login entries of the two agents this one
315
- replaced on 2026-08-29 (`intentic-host`, the sync mirror) are removed once, at start.
581
+ ([`tools/command-ledger.ts`](src/device/tools/command-ledger.ts)). What older generations of the agent left (the
582
+ login entries, folders and PATH links of the two agents this one replaced on 2026-08-29) is the [upkeep](#upkeep)'s.
316
583
  - `intentic-machine sandbox <verb> [slug]` passes `list`, `start`, `stop`, `restart`, `update`, `rollback [--to]`,
317
584
  `versions`, `logs`, `doctor`, `watch`, `backup`, `backups` and `fix [--code] [--auto] [--yes] [--accept] [--json]
318
585
  [--source]` straight to ic, in this terminal, with ic's exit code: a sandbox can be looked at and repaired from here
@@ -336,39 +603,125 @@ clock.
336
603
  - **What the keeper never does**: apply a fix that needs a yes (each is logged with `intentic-machine sandbox fix
337
604
  <slug>`, which asks in a terminal, as the desktop app's buttons and the recovery panel's command do), run two fixes at
338
605
  once, run while the probation watch is running ic, fix a sandbox a swap is moving or one a flow of this agent holds,
339
- or start the sweep while any flow runs. Each run holds the sandboxes it may touch as a flow that moves a container,
340
- so the other rounds leave them alone and the agent does not restart under it.
606
+ or start the sweep while any flow runs. Each run holds the sandboxes it acts on (this side's containers, and any
607
+ other ic names in a progress line) so the other rounds leave them alone.
608
+
609
+ (2026-10-05) A run used to hold every slug it might touch, records of removed sandboxes included, as a flow that
610
+ MOVES a container, and auto-upgrade reads those as swaps: rog put its agent upgrade off seven times "while" eight
611
+ sandboxes were "mid-swap", one with no container and two in the trash. A fix is now held as fixing, which no agent
612
+ restart waits for. A fix starts or restarts a container at most; on POSIX `ic` outlives an agent restart, on Windows
613
+ the restart ends the run and the next sweep starts it again. The one step of a fix a restart must not land in,
614
+ finishing an interrupted cutover, is in ic's own cutover record, which every restart already waits for.
341
615
  - **Bounded.** A run is stopped after eight minutes (starting Docker Desktop alone can take five), with its process
342
616
  group on POSIX and its process tree on Windows.
343
617
  - **Waiting.** A sandbox whose run left something (needs-you, failed, no verdict) waits 3 minutes, then 6, 12, 24 and
344
618
  30 at most, and the wait ends when its link comes back. One ic found healthy or fixed while its link stays down is
345
619
  asked about again after five minutes, not every ten seconds. The sweep's own cadence stretches on the same ladder
346
620
  (never under five minutes) while a sweep leaves something. An ic that cannot fix (one from before `sandbox fix`,
347
- which clap refuses with no JSON, or no ic at all) is said once and asked again on the same ladder.
621
+ which clap refuses with no JSON, or no ic at all) is said once and asked again on the same ladder. A run that found
622
+ no sandbox at all prints the machine's own report under `"slug": null` and exits 1: that is a fix that ran, not an ic
623
+ that cannot fix (2026-10-06: read as one, it held every round back), and what it left on the machine is said once.
624
+
625
+ (2026-10-05) A "fixed" no longer always clears the ladder: the first one after two or more failures within the hour
626
+ keeps it (the next look waits that rung, never under five minutes, and the next failure goes a rung higher), so a
627
+ sandbox that breaks again after each restart is restarted less and less often instead of every three minutes. A
628
+ second "fixed" in a row, a "healthy", or an hour without a failure clears it. The ladder stays in memory: ic keeps the
629
+ lasting ledger of its own repairs.
348
630
  - **The log** (`~/.intentic/machine/machine.log`, like every round): what ic is doing as it does it, and each
349
631
  sandbox's verdict when it changes (a standing `healthy` or `needs-you` is said once per stretch; `fixed` always).
350
632
  - **Where.** Every environment runs its own keeper, since `ic sandbox fix` knows only the sandboxes its own ic keeps
351
- records of; a WSL distro's shares the Windows side's Docker Desktop.
633
+ records of; a WSL distro's shares the Windows side's Docker Desktop. Each keeper sweeps only while its environment
634
+ keeps a sandbox of its own: one the listing names without `keptElsewhere`, or one its ic still has a record of that
635
+ sits in ic's trash (read off ic's `intentic-trashed-<time>-<slug>` marker volumes). An environment that keeps none
636
+ says so once and does not sweep, so it never starts Docker Desktop for the other side's sandboxes. A sandbox that ic
637
+ stops marking `keptElsewhere` (adopted from a side whose keeper went silent) counts as this side's by the same rule.
638
+ - **Gone is gone.** (2026-10-05) A slug with no container and no trash entry is not fixed, whatever names it: a link
639
+ that is down, a leftover record, a loopback address. rog ran `ic sandbox fix sandbox-2e8d89d75865` every few minutes
640
+ for a sandbox removed long before. It is said once and left. Until Docker has answered a listing once, nothing can be
641
+ told, so the records stand in for the listing, which is the logon case the keeper is for.
352
642
  - **The switch** is this environment's `sandboxKeeper` in `machine.json` (absent means on), set by
353
643
  `intentic-machine sandbox keeper on|off` and read by `keeper status`. It is re-read every round.
354
644
  - **It keeps the agent resident.** An agent with no link, no pairing and no distro to serve used to take its login
355
645
  entry away and exit, and then nothing started Docker Desktop after the next reboot. It now stays while the keeper is
356
646
  on and this machine hosts a sandbox other than a runner: the ones `ic sandbox list --json` names when Docker answers,
357
- else the ones ic keeps a record of (a removed sandbox's record lasts until `ic sandbox tidy` archives it, so the
358
- listing wins whenever it answers). The resident asks at start and then every five minutes, and only while it has
647
+ except those another side keeps (`keptElsewhere`, 2026-10-05: a distro's agent stayed for the Windows side's
648
+ sandboxes), else the ones ic keeps a record of (a removed sandbox's record lasts until `ic sandbox tidy` archives it,
649
+ so the listing wins whenever it answers). The resident asks at start and then every five minutes, and only while it has
359
650
  nothing else to serve. `keeper off` lets it go; `intentic-machine uninstall` retires it whatever this machine hosts,
360
651
  while `device uninstall` and `sync uninstall` leave it running for the sandboxes here and say so (2026-09-30:
361
652
  retiring as before was rejected, since a machine whose last link was revoked kept its sandbox down after every
362
653
  reboot).
363
654
 
655
+ ### Upkeep
656
+
657
+ What older releases left on a device, and the stores with no bound, are put right by a reconciler that ships with each
658
+ release ([`upkeep/`](src/upkeep)). Every environment's agent runs it a minute after it starts and every six hours, so a
659
+ machine converges whether or not it restarts. It replaced `legacy-autostart.ts`, whose one marker file retired five
660
+ login entries once and then stood in for every later cleanup too (2026-10-05).
661
+
662
+ - **The manifest** ([`upkeep/manifest.ts`](src/upkeep/manifest.ts)) is a list in code that grows with every release.
663
+ Each entry says how to find one kind of thing and what is done with it: `retire`, `trash` (moved to
664
+ `~/.intentic/machine/trash/<stamp>-<name>`, never deleted outright), `prune`, `rotate`, `repair` or `report`. An entry
665
+ is an idempotent check, or done once behind a marker of its own (`~/.intentic/machine/upkeep/<id>`), so an entry a
666
+ later release adds runs on machines that hold every older marker. Nothing is acted on in doubt: what cannot be read,
667
+ or is in use, is skipped and reported with why.
668
+ - **What it covers now:**
669
+ - the login entries of `intentic-host` and `intentic-sync`'s mirror, once (the old `legacy-autostart-retired` marker
670
+ counts as done and is moved to the new name);
671
+ - their folders `~/.intentic/host` and `~/.intentic/sync`, their `~/.local/bin` links where those point into them,
672
+ and `sync.json.bak-loopback`, to the trash, unless a program from the folder is running (read from `/proc` on
673
+ Linux, `ps` on macOS; on Windows a running binary makes the move fail, which is the same skip);
674
+ - `intentic-link-watch` (its script and systemd timer and unit): disabled and trashed, since the link has its own
675
+ silence watchdog (`peerLinkSilenceMs`);
676
+ - this agent's own login entry, written again where it differs from what this build writes, a logon task's action
677
+ and settings included ([local-agent](../local-agent)); an entry launching another install's command is left on
678
+ Linux and macOS, as at start. Only the installed agent checks it, never a `doctor` run from a checkout;
679
+ - `IntenticMutagenDaemon` on Windows: removed once no pairing needs Mutagen, written again (by sync's own
680
+ `registerMutagenAutostart`) while one does and this agent's own Mutagen copy is the one in use;
681
+ - `~/.intentic/machine/trash` entries older than 30 days, by the stamp in their name (a moved folder keeps its old
682
+ times), deleted; a name with no stamp is left and reported;
683
+ - `audit.jsonl` set aside at 8 MB;
684
+ - the install command's half downloads in `bin/` (`*.part`, `*.part-<release>`) older than a day, never while an
685
+ upgrade runs;
686
+ - a second `ic` found first on PATH (a root install's `/usr/local/bin/ic`), reported with both versions, since only
687
+ sudo could change it.
688
+ - **What it says.** One line in `machine.log` per pass, and `~/.intentic/machine/upkeep.json`:
689
+ `{ at, version, found: { <kind>: n }, fixed: { <kind>: n }, skipped: [{ kind, what, why }] }` (`at` in epoch ms).
690
+ The device's facts carry it to the sandboxes it is linked to as `upkeep` (`DeviceUpkeepSchema` in the contract's
691
+ `schemas/hosts.ts`), with at most ten skipped lines, refreshed on every pull of the Devices view.
692
+ - **`intentic-machine doctor`** runs the same pass in a terminal and changes nothing; `--fix` does what the agent's pass
693
+ would, under the same lock, and writes `upkeep.json`; `--json` prints one object, `upkeep.json`'s shape plus `fix`
694
+ and `items: [{ id, kind, action, what, outcome: "fixed" | "would-fix" | "skipped", why? }]`. While another pass that
695
+ fixes runs, it answers with that pass's pid.
696
+
697
+ ### The hang watchdog
698
+
699
+ Every supervisor of this agent restarts it when it exits, and none can tell a hung agent from a busy one
700
+ ([`watchdog.ts`](src/watchdog.ts), 2026-10-05). So the agent watches its own event loop from a Worker thread, which has
701
+ a loop of its own: the main loop pings it every five seconds, and after three minutes without a ping the Worker writes
702
+ `event loop stalled for N s; exiting so the supervisor restarts the agent` to the log (straight to fd 2, where every
703
+ supervisor sends it) and kills the process with SIGKILL. Checks of its own held apart by far more than their
704
+ interval (three of them and at least three seconds, at most half the limit) mean the whole process was paused, by a
705
+ sleep, a suspended VM or a clock stepped forward, and start the count again (2026-10-06: the first check after a
706
+ laptop's sleep found the last ping minutes old and killed a healthy agent). That is an unclean exit to every supervisor: systemd's
707
+ `Restart=on-failure` and launchd's `KeepAlive` restart it, on Windows TerminateProcess leaves exit code 1, which the
708
+ launcher passes to the logon task, and the Windows side restarts a distro's agent that stopped. The Worker is made from
709
+ source text, so the compiled binary needs no second file; both it and the SIGKILL were checked under
710
+ `bun build --compile`. Like any crash, a stall counts toward a new release's trial ([`agent-trial.ts`](src/agent-trial.ts)).
711
+
364
712
  ## Key files
365
713
 
366
- - [src/commands.ts](src/commands.ts) — the CLI: `device`, `sync`, `sandbox`, `run`, `status`, `upgrade`, `updates`, `uninstall`.
714
+ - [src/commands.ts](src/commands.ts) — the CLI: `device`, `sync`, `sandbox`, `run`, `status`, `doctor`, `upgrade`, `updates`, `uninstall`.
367
715
  - [src/resident.ts](src/resident.ts) — the resident process that holds links, pairings and WSL distros.
368
716
  - [src/device/mcp.ts](src/device/mcp.ts) — every tool a sandbox can call on this device.
369
717
  - [src/device/policy.ts](src/device/policy.ts) — scope checks and the file-root boundary.
370
718
  - [src/sync/endpoint.ts](src/sync/endpoint.ts) — how a pairing reaches its sandbox: the tunnelled sshd (`tunnel.ts`), or Docker for a project on this machine.
719
+ - [src/sync/attach-commands.ts](src/sync/attach-commands.ts) — `sync attach` and `sync detach`, folders on this computer's own sandbox.
720
+ - [src/sync/project/project-delivery.ts](src/sync/project/project-delivery.ts) — landed work written into an attached folder (`deliverProject`).
721
+ - [src/sync/gone.ts](src/sync/gone.ts) — when a paired sandbox counts as gone; `gone-watch.ts` acts on it, `retire.ts` retires its pairings, `forget-command.ts` is `sync forget`.
371
722
  - [src/environments/machine.ts](src/environments/machine.ts) — the Windows root and its WSL children.
723
+ - [src/upkeep/manifest.ts](src/upkeep/manifest.ts) — what the device upkeep finds and puts right; `reconcile.ts` runs it.
724
+ - [src/watchdog.ts](src/watchdog.ts) — the agent's own hang watchdog.
372
725
 
373
726
  ## Commands
374
727
 
@@ -376,4 +729,5 @@ clock.
376
729
  pnpm --filter @intentic/machine test
377
730
  pnpm turbo run build --filter=./_devices/machine
378
731
  node _devices/machine/dist/cli.js status
732
+ node _devices/machine/dist/cli.js doctor # what the upkeep would do here; --fix does it
379
733
  ```
@@ -1 +1 @@
1
- {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"AAAA,OAAO,EAA+B,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAqGjF,eAAO,MAAM,QAAQ,kDAoBnB,CAAC"}
1
+ {"version":3,"file":"commands.d.ts","sourceRoot":"","sources":["../src/commands.ts"],"names":[],"mappings":"AAAA,OAAO,EAA+B,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAuGjF,eAAO,MAAM,QAAQ,kDAqBnB,CAAC"}
package/dist/commands.js CHANGED
@@ -7,6 +7,7 @@ import { runForeground } from "./resident.js";
7
7
  import { readResident, restartResident, stopResident } from "./supervision.js";
8
8
  import { status } from "./status.js";
9
9
  import { syncCommands, syncUninstall } from "./sync/commands.js";
10
+ import { doctor } from "./upkeep/doctor.js";
10
11
  import { MACHINE_VERSION } from "./version.js";
11
12
  const run = buildCommand({
12
13
  docs: { brief: "Restart this machine's agent: sandbox connections, file sync, port mirroring (setup starts it for you)" },
@@ -86,6 +87,7 @@ export const commands = buildRouteMap({
86
87
  environment: environmentRoutes,
87
88
  run,
88
89
  status,
90
+ doctor,
89
91
  version,
90
92
  upgrade,
91
93
  updates,