@ours.network/fleet 1.2.0-nightly.4 → 1.2.0-nightly.40

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 (310) hide show
  1. package/README.md +165 -53
  2. package/dist/account-origin.d.ts +2 -0
  3. package/dist/account-origin.js +6 -0
  4. package/dist/agent-ours/bridge.js +9 -0
  5. package/dist/agent-ours/correspondence.d.ts +10 -0
  6. package/dist/agent-ours/correspondence.js +1 -0
  7. package/dist/agent-ours/harness.js +8 -0
  8. package/dist/agent-ours/runtime.d.ts +6 -0
  9. package/dist/agent-ours/runtime.js +36 -0
  10. package/dist/agent-ours/service.js +33 -13
  11. package/dist/application/codex-models.d.ts +15 -0
  12. package/dist/application/codex-models.js +56 -0
  13. package/dist/application/conversation-history.d.ts +25 -0
  14. package/dist/application/conversation-history.js +189 -0
  15. package/dist/application/fleet-query-service.d.ts +1 -1
  16. package/dist/application/fleet-query-service.js +2 -2
  17. package/dist/application/legacy-supervisor-identity.d.ts +7 -0
  18. package/dist/application/legacy-supervisor-identity.js +43 -0
  19. package/dist/application/model-catalog.d.ts +1 -1
  20. package/dist/application/model-catalog.js +5 -2
  21. package/dist/application/preset-provenance.d.ts +19 -0
  22. package/dist/application/preset-provenance.js +59 -0
  23. package/dist/application/role-creation-service.js +1 -1
  24. package/dist/application/room-layout-definitions.d.ts +45 -0
  25. package/dist/application/room-layout-definitions.js +130 -0
  26. package/dist/application/session-control.d.ts +18 -0
  27. package/dist/application/session-control.js +54 -3
  28. package/dist/application/supervisor-ours-tools.d.ts +159 -0
  29. package/dist/application/supervisor-ours-tools.js +113 -0
  30. package/dist/application/task-layouts.d.ts +91 -0
  31. package/dist/application/task-layouts.js +249 -0
  32. package/dist/application/task-room-service.d.ts +99 -3
  33. package/dist/application/task-room-service.js +296 -31
  34. package/dist/atomic-file.d.ts +5 -0
  35. package/dist/atomic-file.js +62 -1
  36. package/dist/build-info.json +6 -4
  37. package/dist/capabilities.d.ts +3 -1
  38. package/dist/capabilities.js +4 -0
  39. package/dist/cli.js +186 -34
  40. package/dist/client-profile.d.ts +5 -16
  41. package/dist/client-profile.js +4 -112
  42. package/dist/config.d.ts +13 -0
  43. package/dist/config.js +19 -4
  44. package/dist/creation.js +2 -2
  45. package/dist/daemon-recovery.d.ts +1 -8
  46. package/dist/daemon-recovery.js +8 -113
  47. package/dist/docs.d.ts +3 -3
  48. package/dist/docs.js +44 -22
  49. package/dist/doctor.d.ts +1 -1
  50. package/dist/doctor.js +46 -59
  51. package/dist/erased-resources.d.ts +9 -0
  52. package/dist/erased-resources.js +87 -0
  53. package/dist/fleet-command-audit.d.ts +1 -0
  54. package/dist/fleet-command-audit.js +11 -6
  55. package/dist/harness/claude-code.js +9 -1
  56. package/dist/harness/claude-runtime.d.ts +12 -0
  57. package/dist/harness/claude-runtime.js +43 -0
  58. package/dist/harness/codex-app-server-proxy.d.ts +4 -1
  59. package/dist/harness/codex-app-server-proxy.js +41 -5
  60. package/dist/harness/codex-runtime.d.ts +11 -1
  61. package/dist/harness/codex-runtime.js +31 -4
  62. package/dist/harness/codex.d.ts +3 -2
  63. package/dist/harness/codex.js +8 -5
  64. package/dist/init-wizard.d.ts +2 -0
  65. package/dist/init-wizard.js +38 -19
  66. package/dist/isolation/policy.js +2 -0
  67. package/dist/isolation/types.d.ts +5 -0
  68. package/dist/minimal-setup.d.ts +2 -0
  69. package/dist/minimal-setup.js +44 -0
  70. package/dist/monitor.d.ts +1 -34
  71. package/dist/monitor.js +24 -151
  72. package/dist/notifications/fleet-producer.d.ts +18 -0
  73. package/dist/notifications/fleet-producer.js +129 -0
  74. package/dist/notifications/outbox.d.ts +42 -0
  75. package/dist/notifications/outbox.js +149 -0
  76. package/dist/owner-channel/channel.d.ts +3 -0
  77. package/dist/owner-channel/channel.js +34 -6
  78. package/dist/owner-channel/commands.d.ts +4 -0
  79. package/dist/owner-channel/commands.js +8 -0
  80. package/dist/owner-channel/lifecycle-outbox.js +4 -3
  81. package/dist/owner-channel/ours-client.js +2 -17
  82. package/dist/paths.d.ts +2 -0
  83. package/dist/paths.js +2 -0
  84. package/dist/preset-migration.js +25 -0
  85. package/dist/rooms-tasks/add-member.d.ts +48 -0
  86. package/dist/rooms-tasks/add-member.js +83 -0
  87. package/dist/rooms-tasks/archived-absence.d.ts +8 -0
  88. package/dist/rooms-tasks/archived-absence.js +48 -0
  89. package/dist/rooms-tasks/cli.js +34 -5
  90. package/dist/rooms-tasks/close.d.ts +5 -2
  91. package/dist/rooms-tasks/close.js +208 -28
  92. package/dist/rooms-tasks/config.d.ts +1 -1
  93. package/dist/rooms-tasks/config.js +11 -4
  94. package/dist/rooms-tasks/cowork-adapter.d.ts +6 -4
  95. package/dist/rooms-tasks/cowork-adapter.js +11 -117
  96. package/dist/rooms-tasks/cowork-http.d.ts +3 -0
  97. package/dist/rooms-tasks/cowork-http.js +70 -0
  98. package/dist/rooms-tasks/deletion.d.ts +1 -1
  99. package/dist/rooms-tasks/deletion.js +74 -5
  100. package/dist/rooms-tasks/erasure.d.ts +6 -0
  101. package/dist/rooms-tasks/erasure.js +169 -0
  102. package/dist/rooms-tasks/external-worker.d.ts +1 -1
  103. package/dist/rooms-tasks/external-worker.js +3 -4
  104. package/dist/rooms-tasks/layout-binding-client.d.ts +11 -0
  105. package/dist/rooms-tasks/layout-binding-client.js +56 -0
  106. package/dist/rooms-tasks/layout-binding-grants.d.ts +21 -0
  107. package/dist/rooms-tasks/layout-binding-grants.js +128 -0
  108. package/dist/rooms-tasks/layout-cli.d.ts +4 -0
  109. package/dist/rooms-tasks/layout-cli.js +107 -0
  110. package/dist/rooms-tasks/layout-config.d.ts +11 -0
  111. package/dist/rooms-tasks/layout-config.js +89 -0
  112. package/dist/rooms-tasks/layout-control.d.ts +21 -0
  113. package/dist/rooms-tasks/layout-control.js +52 -0
  114. package/dist/rooms-tasks/layout-service.d.ts +42 -0
  115. package/dist/rooms-tasks/layout-service.js +185 -0
  116. package/dist/rooms-tasks/layout.d.ts +114 -0
  117. package/dist/rooms-tasks/layout.js +250 -0
  118. package/dist/rooms-tasks/live-readiness.d.ts +18 -0
  119. package/dist/rooms-tasks/live-readiness.js +85 -0
  120. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  121. package/dist/rooms-tasks/member-startup.js +2 -0
  122. package/dist/rooms-tasks/provision.d.ts +14 -0
  123. package/dist/rooms-tasks/provision.js +173 -46
  124. package/dist/rooms-tasks/room-state.d.ts +4 -2
  125. package/dist/rooms-tasks/room-state.js +53 -8
  126. package/dist/rooms-tasks/task-state.d.ts +42 -7
  127. package/dist/rooms-tasks/task-state.js +153 -23
  128. package/dist/rooms-tasks/types.d.ts +28 -0
  129. package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
  130. package/dist/rooms-tasks/workspace-artifacts.js +93 -0
  131. package/dist/rooms-tasks/workspace.d.ts +26 -0
  132. package/dist/rooms-tasks/workspace.js +195 -0
  133. package/dist/runner.d.ts +19 -3
  134. package/dist/runner.js +89 -29
  135. package/dist/session/acp.d.ts +7 -1
  136. package/dist/session/acp.js +21 -6
  137. package/dist/session/arbiter.d.ts +3 -0
  138. package/dist/session/arbiter.js +20 -2
  139. package/dist/session/codex-app-server.d.ts +3 -0
  140. package/dist/session/codex-app-server.js +26 -3
  141. package/dist/session/control.d.ts +10 -1
  142. package/dist/session/control.js +37 -2
  143. package/dist/session/conversation-normalizer.d.ts +2 -1
  144. package/dist/session/conversation-normalizer.js +1 -1
  145. package/dist/session/conversation-types.d.ts +5 -3
  146. package/dist/session/task-notice.d.ts +14 -0
  147. package/dist/session/task-notice.js +13 -0
  148. package/dist/session/types.d.ts +3 -0
  149. package/dist/setup-tunnel-preflight.d.ts +32 -0
  150. package/dist/setup-tunnel-preflight.js +58 -0
  151. package/dist/socket-path.d.ts +2 -0
  152. package/dist/socket-path.js +22 -0
  153. package/dist/spawn.js +7 -0
  154. package/dist/startup-readiness.d.ts +13 -0
  155. package/dist/startup-readiness.js +38 -0
  156. package/dist/subscriptions/cli.d.ts +91 -0
  157. package/dist/subscriptions/cli.js +223 -0
  158. package/dist/subscriptions/launch.d.ts +37 -0
  159. package/dist/subscriptions/launch.js +93 -0
  160. package/dist/subscriptions/login.d.ts +75 -0
  161. package/dist/subscriptions/login.js +182 -0
  162. package/dist/subscriptions/observed.d.ts +7 -0
  163. package/dist/subscriptions/observed.js +44 -0
  164. package/dist/subscriptions/probe.d.ts +10 -0
  165. package/dist/subscriptions/probe.js +73 -0
  166. package/dist/subscriptions/service.d.ts +102 -0
  167. package/dist/subscriptions/service.js +364 -0
  168. package/dist/subscriptions/store.d.ts +71 -0
  169. package/dist/subscriptions/store.js +140 -0
  170. package/dist/supervisor/launchd.js +2 -1
  171. package/dist/supervisor/systemd.js +9 -23
  172. package/dist/temp-idle.d.ts +17 -0
  173. package/dist/temp-idle.js +42 -0
  174. package/dist/temp-lifecycle.d.ts +6 -1
  175. package/dist/temp-lifecycle.js +29 -13
  176. package/dist/web/agent-attachments.d.ts +29 -0
  177. package/dist/web/agent-attachments.js +162 -0
  178. package/dist/web/auth.d.ts +29 -1
  179. package/dist/web/auth.js +81 -7
  180. package/dist/web/available-port.d.ts +2 -0
  181. package/dist/web/available-port.js +23 -0
  182. package/dist/web/control.d.ts +3 -3
  183. package/dist/web/control.js +10 -8
  184. package/dist/web/fleet-config-service.d.ts +4 -1
  185. package/dist/web/fleet-config-service.js +46 -15
  186. package/dist/web/prefix-gateway.d.ts +29 -0
  187. package/dist/web/prefix-gateway.js +206 -0
  188. package/dist/web/runtime.d.ts +3 -1
  189. package/dist/web/runtime.js +116 -31
  190. package/dist/web/server.d.ts +16 -0
  191. package/dist/web/server.js +501 -53
  192. package/dist/web/service.d.ts +11 -2
  193. package/dist/web/service.js +30 -4
  194. package/dist/web/workspace-devices.d.ts +29 -0
  195. package/dist/web/workspace-devices.js +84 -0
  196. package/dist/web/workspace-tunnel.d.ts +1 -0
  197. package/dist/web/workspace-tunnel.js +32 -0
  198. package/dist/workspace-enrollment.d.ts +53 -0
  199. package/dist/workspace-enrollment.js +370 -0
  200. package/docs/room-layouts.md +227 -0
  201. package/docs/task-workspaces.md +130 -0
  202. package/examples/room-layouts/fresh.yaml +27 -0
  203. package/examples/room-layouts/scoped.yaml +21 -0
  204. package/examples/room-layouts/shared.yaml +19 -0
  205. package/package.json +13 -18
  206. package/presets/README.md +78 -0
  207. package/presets/brain-catalog.json +8 -2
  208. package/presets/fleet/agent_templates/Critic.yaml +1 -1
  209. package/presets/fleet/agent_templates/Developer.yaml +1 -1
  210. package/presets/fleet/agent_templates/Engineer.yaml +5 -0
  211. package/presets/fleet/agent_templates/LocalCoordinator.yaml +1 -1
  212. package/presets/fleet/agents/FleetCoordinator.yaml +1 -1
  213. package/presets/fleet/brains/claude-fable-5-1-high.yaml +5 -0
  214. package/presets/fleet/brains/claude-fable-5-1-low.yaml +5 -0
  215. package/presets/fleet/brains/claude-fable-5-1-max.yaml +5 -0
  216. package/presets/fleet/brains/claude-fable-5-1-medium.yaml +5 -0
  217. package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +5 -0
  218. package/presets/fleet/brains/claude-fable-5-high.yaml +1 -1
  219. package/presets/fleet/brains/claude-fable-5-low.yaml +1 -1
  220. package/presets/fleet/brains/claude-fable-5-max.yaml +1 -1
  221. package/presets/fleet/brains/claude-fable-5-medium.yaml +1 -1
  222. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +1 -1
  223. package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +4 -0
  224. package/presets/fleet/brains/claude-opus-5-5-high.yaml +5 -0
  225. package/presets/fleet/brains/claude-opus-5-5-low.yaml +5 -0
  226. package/presets/fleet/brains/claude-opus-5-5-max.yaml +5 -0
  227. package/presets/fleet/brains/claude-opus-5-5-medium.yaml +5 -0
  228. package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +5 -0
  229. package/presets/fleet/brains/claude-opus-5-high.yaml +1 -1
  230. package/presets/fleet/brains/claude-opus-5-low.yaml +1 -1
  231. package/presets/fleet/brains/claude-opus-5-max.yaml +1 -1
  232. package/presets/fleet/brains/claude-opus-5-medium.yaml +1 -1
  233. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +1 -1
  234. package/presets/fleet/brains/claude-sonnet-5-high.yaml +1 -1
  235. package/presets/fleet/brains/claude-sonnet-5-low.yaml +1 -1
  236. package/presets/fleet/brains/claude-sonnet-5-max.yaml +1 -1
  237. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +1 -1
  238. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +1 -1
  239. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +1 -1
  240. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +1 -1
  241. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +1 -1
  242. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +1 -1
  243. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +1 -1
  244. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +1 -1
  245. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +1 -1
  246. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +1 -1
  247. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +1 -1
  248. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +1 -1
  249. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +1 -1
  250. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +1 -1
  251. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +1 -1
  252. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +1 -1
  253. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +1 -1
  254. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +1 -1
  255. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +1 -1
  256. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +1 -1
  257. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +1 -1
  258. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +1 -1
  259. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +1 -1
  260. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +1 -1
  261. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +1 -1
  262. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +1 -1
  263. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +1 -1
  264. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +1 -1
  265. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +1 -1
  266. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +1 -1
  267. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +1 -1
  268. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +1 -1
  269. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +1 -1
  270. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +1 -1
  271. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +1 -1
  272. package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +5 -0
  273. package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +5 -0
  274. package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +5 -0
  275. package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +5 -0
  276. package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +5 -0
  277. package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +5 -0
  278. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +1 -1
  279. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +1 -1
  280. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +1 -1
  281. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +1 -1
  282. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +1 -1
  283. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +1 -1
  284. package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +5 -0
  285. package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +5 -0
  286. package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +5 -0
  287. package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +5 -0
  288. package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +5 -0
  289. package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +5 -0
  290. package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +5 -0
  291. package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +5 -0
  292. package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +5 -0
  293. package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +5 -0
  294. package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +5 -0
  295. package/presets/fleet/roles/Coordinator.yaml +34 -11
  296. package/presets/fleet/roles/Engineer.yaml +40 -0
  297. package/presets/fleet/room_layouts/engineering.yaml +25 -0
  298. package/presets/fleet/room_layouts/pair.yaml +24 -0
  299. package/presets/fleet/room_layouts/single.yaml +17 -0
  300. package/presets/fleet/room_layouts/team.yaml +30 -0
  301. package/presets/fleet/room_templates/engineering.yaml +19 -0
  302. package/presets/manifest.json +2 -2
  303. package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
  304. package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
  305. package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
  306. package/dist/web-app/icons/ours-fleet.svg +0 -4
  307. package/dist/web-app/index.html +0 -17
  308. package/dist/web-app/manifest.webmanifest +0 -15
  309. package/dist/web-app/offline.html +0 -18
  310. package/dist/web-app/sw.js +0 -51
@@ -0,0 +1,370 @@
1
+ import { validateAccountOrigin } from './account-origin.js';
2
+ import { lstatSync, readFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { readClientProfile } from './client-profile.js';
5
+ import { stateRoot, defaultConfigPath, workspaceOwnerInvite } from './paths.js';
6
+ import { replaceFileAtomically } from './atomic-file.js';
7
+ import { loadConfig, splitRootFor } from './config.js';
8
+ import { parse, parseDocument, stringify } from 'yaml';
9
+ import { randomUUID } from 'node:crypto';
10
+ import { attachOursClient } from '@ours.network/sdk/client';
11
+ import { preflightInitPaths } from './init-wizard.js';
12
+ import { WebAccessStore } from './web/access.js';
13
+ import { WorkspaceDeviceStore } from './web/workspace-devices.js';
14
+ export function readWorkspacePayload(file) {
15
+ const path = resolve(file), stat = lstatSync(path);
16
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0 || stat.size > 32768)
17
+ throw Error('Setup payload requires an owned private regular file (chmod 600)');
18
+ let p;
19
+ try {
20
+ p = JSON.parse(Buffer.from(readFileSync(path, 'utf8').trim(), 'base64url').toString());
21
+ }
22
+ catch {
23
+ throw Error('Invalid workspace payload');
24
+ }
25
+ if (p.version !== 1 || !['https://app.ours.network', 'https://app.ours-tunnel.com'].includes(p.appOrigin) || !/^[a-z0-9][a-z0-9-]{2,60}\.ours-tunnel\.com$/.test(p.hostname) || !/^[a-z0-9-]{2,30}@[a-z0-9-]{2,30}$/.test(p.rootName) || !/^[a-f0-9]{64}$/i.test(p.serverCid) || !p.challenge || !['nonce', 'accountId', 'workspaceId'].every(k => /^[\w-]{43}$/.test(String(p.challenge[k]))) || p.challenge.expiresAt <= Date.now() || p.challenge.expiresAt > Date.now() + 16 * 60000)
26
+ throw Error('Workspace payload is invalid or expired');
27
+ for (const k of ['connectorToken', 'invitation', 'name', 'surname'])
28
+ if (typeof p[k] !== 'string' || !p[k] || p[k].length > 8192 || /[\x00-\x1f\x7f]/.test(p[k]))
29
+ throw Error('Invalid workspace payload');
30
+ return p;
31
+ }
32
+ const GENERATED_VERSION = 'ours.network/fleet/v2';
33
+ /** The rooms section tunnel setup writes for the enrolled Human root. */
34
+ const generatedRooms = (rootCid, inviteFile) => ({ rooms: { owner: { provider: 'messenger-server', expected_cid: rootCid, public_invite_file: inviteFile, role: 'Owner' }, defaults: { attach_owner: true, close_when_task_done: true } } });
35
+ /**
36
+ * True only when this configuration is, key for key, what tunnel setup generated itself and nothing
37
+ * else was added beside it. Only then may its Owner entry be treated as the record of an enrollment.
38
+ */
39
+ function generatedConfigurationOnly(configuration) {
40
+ let document;
41
+ try {
42
+ document = parse(readFileSync(configuration, 'utf8'));
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ const only = (value, keys) => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).sort().join() === [...keys].sort().join();
48
+ if (!only(document, ['api_version', 'rooms']) || document.api_version !== GENERATED_VERSION)
49
+ return false;
50
+ const rooms = document.rooms;
51
+ if (!only(rooms, ['owner', 'defaults']) || !only(rooms.owner, ['provider', 'expected_cid', 'public_invite_file', 'role']) || !only(rooms.defaults, ['attach_owner', 'close_when_task_done']))
52
+ return false;
53
+ if (rooms.owner.provider !== 'messenger-server' || rooms.owner.role !== 'Owner' || rooms.owner.public_invite_file !== workspaceOwnerInvite() || typeof rooms.owner.expected_cid !== 'string' || !/^[a-f0-9]{64}$/i.test(rooms.owner.expected_cid)
54
+ || rooms.defaults.attach_owner !== true || rooms.defaults.close_when_task_done !== true)
55
+ return false;
56
+ const split = splitRootFor(configuration);
57
+ if (!existsSync(split))
58
+ return true;
59
+ const entries = readdirSync(split);
60
+ return entries.every(entry => entry === 'agents') && (!entries.length || (lstatSync(join(split, 'agents')).isDirectory() && readdirSync(join(split, 'agents')).length === 0));
61
+ }
62
+ /**
63
+ * The Owner entry of a generated configuration records an enrollment whose host state is gone:
64
+ * no binding and no invitation remain. Nothing else is ever replaced.
65
+ */
66
+ const recordsLostEnrollment = (configuration) => !existsSync(join(stateRoot(), 'workspace', 'binding.json')) && !existsSync(workspaceOwnerInvite()) && generatedConfigurationOnly(configuration);
67
+ function hostMessenger() {
68
+ const profile = readClientProfile();
69
+ const base = new URL(profile.endpoint);
70
+ base.pathname = base.pathname.replace(/\/daemon\/?$/, '/messenger/');
71
+ if (!base.pathname.endsWith('/messenger/'))
72
+ throw Error('Workspace setup requires the supported gateway client profile');
73
+ const secret = readFileSync(profile.credentialPath, 'utf8').trim();
74
+ return async (path, value) => { const response = await fetch(new URL('api/' + path, base), { method: value === undefined ? 'GET' : 'POST', headers: { 'X-Ours-Api-Token': secret, ...(value === undefined ? {} : { 'Content-Type': 'application/json', 'Origin': base.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }), signal: AbortSignal.timeout(value === undefined ? 15000 : 60000), redirect: 'error' }); if (!response.ok) {
75
+ await response.body?.cancel();
76
+ throw Object.assign(Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying'), { status: response.status });
77
+ } return response.json(); };
78
+ }
79
+ const daemon = (attach, purpose) => { const profile = readClientProfile(); return attach({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId, credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `ours-fleet-workspace-${purpose}-${process.pid}-${randomUUID()}`, env: {} }); };
80
+ const CID = /^[a-f0-9]{64}$/i, same = (a, b) => a.toLowerCase() === b.toLowerCase();
81
+ /**
82
+ * Who this host's Messenger is. Messenger either runs as the Human root itself, or as the person's own
83
+ * identity and then names the root the daemon describes for it. That is accepted only when this host's
84
+ * daemon, asked by Fleet itself, lists exactly one Human root, that root is the named one, and the
85
+ * Messenger identity is listed as a permanent identity under it.
86
+ */
87
+ async function messengerIdentity(request, attach) {
88
+ const bound = await request('workspace/enrollment-identity');
89
+ if (bound.rootCid === undefined)
90
+ return { cid: bound.cid, preserveProfile: bound.preserveProfile === true };
91
+ if (typeof bound.cid !== 'string' || !CID.test(bound.cid) || typeof bound.rootCid !== 'string' || !CID.test(bound.rootCid) || same(bound.cid, bound.rootCid))
92
+ throw Error('Bound Messenger identity unavailable');
93
+ const root = await humanRoot(attach, bound.rootCid, bound.cid);
94
+ return { cid: bound.cid, preserveProfile: bound.preserveProfile === true, root };
95
+ }
96
+ /** This host's only Human root, as its daemon lists it; optionally with a permanent identity that must be listed under it. */
97
+ async function humanRoot(attach, rootCid, under) {
98
+ const client = await daemon(attach, 'identities');
99
+ const rows = await client.listIdentities().finally(() => client.close());
100
+ const roots = rows.filter(row => 'kind' in row && row.kind === 'root'), root = roots[0], row = under === undefined ? undefined : rows.find(row => 'cid' in row && same(row.cid, under));
101
+ if (roots.length !== 1 || !('kind' in root) || !same(root.cid, rootCid) || (under !== undefined && (!row || !('kind' in row) || row.kind !== 'role' || row.temp !== null)))
102
+ throw Error("This host's Messenger identity is not a permanent identity under this host's Human root. Tunnel setup changed nothing.");
103
+ return { name: root.name, cid: root.cid };
104
+ }
105
+ /** Run one operation as the Human root and always give the root back, whatever happens. */
106
+ async function asHumanRoot(attach, root, purpose, use) {
107
+ const client = await daemon(attach, purpose);
108
+ let done = false;
109
+ try {
110
+ const bound = await client.chooseIdentity({ name: root.name, force: false }).catch((error) => {
111
+ throw Error(`This host's Human root cannot be used right now (${error.code ?? error.message}). Close the session that holds it, then run setup again. Nothing was changed.`);
112
+ });
113
+ if (!same(bound.cid, root.cid))
114
+ throw Error("This host's Human root changed while setup was running. Nothing was changed.");
115
+ const result = await use(client);
116
+ // The operation is not reported as done while the root may still be held. The release is attempted exactly once.
117
+ done = true;
118
+ const released = await client.releaseLease();
119
+ if (released.failed > 0)
120
+ throw Error("This host's Human root was not given back completely. Run setup again.");
121
+ return result;
122
+ }
123
+ finally {
124
+ // After a failure the root is released once as well; what failed first is what is reported.
125
+ if (!done)
126
+ await client.releaseLease().catch(() => { });
127
+ await client.close().catch(() => { });
128
+ }
129
+ }
130
+ /** The signed workspace binding, sent by the Human root itself: the same command Messenger sends when it runs as the root. */
131
+ async function proveWithHumanRoot(attach, root, payload, hostWorkspaceId, contactWaitMs) {
132
+ const serverCid = payload.serverCid.toUpperCase();
133
+ await asHumanRoot(attach, root, 'proof', async (client) => {
134
+ const peer = await client.addContact({ invite: payload.invitation });
135
+ if (peer.cid.toUpperCase() !== serverCid)
136
+ throw Error('Enrollment server identity mismatch');
137
+ const deadline = Date.now() + contactWaitMs;
138
+ while (!(await client.listContacts()).contacts.some(contact => contact.container_id.toUpperCase() === serverCid)) {
139
+ if (Date.now() >= deadline)
140
+ throw Error('Enrollment contact is not ready; obtain a fresh setup payload before retrying');
141
+ await new Promise(resolve => setTimeout(resolve, 100));
142
+ }
143
+ const outcome = await client.sendCommand({ contact: serverCid, command: 'bind-workspace', arguments: { type: 'ours.app.bind-workspace.v1', accountId: payload.challenge.accountId, workspaceId: payload.challenge.workspaceId, nonce: payload.challenge.nonce, hostWorkspaceId } });
144
+ if (!('sent' in outcome) || !outcome.sent)
145
+ throw Error('Workspace proof was not sent');
146
+ });
147
+ }
148
+ /**
149
+ * True when the Owner entry is the one tunnel setup manages and it still names the Human root of an
150
+ * installation whose Messenger now runs as the person's own identity under that root. Only then is the
151
+ * entry moved to that identity; an Owner entry anyone else wrote is never touched.
152
+ */
153
+ function managedRootOwner(configuration, rooms, root) {
154
+ if (!same(rooms.owner.expected_cid, root.cid) || rooms.owner.public_invite_file !== workspaceOwnerInvite() || rooms.defaults?.attach_owner === false)
155
+ return false;
156
+ if (!existsSync(join(stateRoot(), 'workspace', 'binding.json')))
157
+ return false;
158
+ let written;
159
+ try {
160
+ written = parseDocument(readFileSync(configuration, 'utf8')).toJS()?.rooms;
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ const entry = written?.owner, defaults = written?.defaults, keys = (value) => Object.keys(value).sort().join();
166
+ // Exactly the Owner entry and defaults tunnel setup writes; anything added or changed beside them is someone's own configuration.
167
+ return !!written && keys(written) === 'defaults,owner' && !!entry && keys(entry) === 'expected_cid,provider,public_invite_file,role' && entry.provider === 'messenger-server' && entry.role === 'Owner' && typeof entry.expected_cid === 'string' && same(entry.expected_cid, root.cid)
168
+ && !!defaults && keys(defaults) === 'attach_owner,close_when_task_done' && defaults.attach_owner === true && defaults.close_when_task_done === true;
169
+ }
170
+ /**
171
+ * Check the Fleet configuration tunnel setup will use, before the one-time command is used.
172
+ * Reads only; an existing Owner is compared with this host's own Messenger identity.
173
+ */
174
+ export async function checkWorkspaceConfiguration(configuration = defaultConfigPath(), attach = attachOursClient) {
175
+ const paths = preflightInitPaths(configuration);
176
+ if (!paths.manifestExisted) {
177
+ if (paths.rootExisted)
178
+ throw Error(`${paths.splitRoot} exists without ${paths.configPath}. Restore that file or move the directory away.`);
179
+ return;
180
+ }
181
+ const loaded = loadConfig(paths.configPath, { yamlMode: 'strict', deferredOwnerInviteFile: workspaceOwnerInvite() });
182
+ if (!loaded.rooms)
183
+ return;
184
+ if (loaded.rooms.defaults?.attach_owner === false)
185
+ throw Error(`${paths.configPath}: rooms.defaults.attach_owner is disabled; enable it before tunnel setup.`);
186
+ if (!loaded.rooms.owner.public_invite_file)
187
+ throw Error(`${paths.configPath}: rooms.owner must use public_invite_file before tunnel setup.`);
188
+ let cid, root;
189
+ try {
190
+ ({ cid, root } = await messengerIdentity(hostMessenger(), attach));
191
+ }
192
+ catch {
193
+ cid = undefined;
194
+ }
195
+ if (typeof cid !== 'string' || !/^[a-f0-9]{64}$/i.test(cid))
196
+ throw Error(`${paths.configPath} names a room Owner, and this host's Messenger identity cannot be read to compare with it. Start Messenger on this host first.`);
197
+ if (loaded.rooms.owner.expected_cid.toLowerCase() !== cid.toLowerCase() && !recordsLostEnrollment(paths.configPath) && !(root && managedRootOwner(paths.configPath, loaded.rooms, root)))
198
+ throw Error(`${paths.configPath}: rooms.owner.expected_cid names a different Owner than this host's Messenger identity. Tunnel setup does not replace an existing Owner.`);
199
+ }
200
+ export async function enrollWorkspace(payload, configuration = defaultConfigPath(), options = {}) {
201
+ const attach = options.attach ?? attachOursClient;
202
+ const request = hostMessenger();
203
+ if (new WebAccessStore().read().mode === 'none')
204
+ throw Error('Workspace enrollment requires protected web access; enable pairing or password before setup');
205
+ const dir = join(stateRoot(), 'workspace');
206
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
207
+ const appOrigin = validateAccountOrigin(payload.appOrigin);
208
+ const previousFile = join(dir, 'binding.json');
209
+ let migrating = false;
210
+ if (existsSync(previousFile)) {
211
+ const previous = JSON.parse(readFileSync(previousFile, 'utf8'));
212
+ if (previous.workspaceId !== payload.challenge.workspaceId)
213
+ throw Error('This host is already associated with another workspace');
214
+ const priorOrigin = validateAccountOrigin(previous.appOrigin ?? 'https://app.ours.network');
215
+ migrating = priorOrigin !== appOrigin;
216
+ if (migrating && !options.migrateAppOrigin)
217
+ throw Error('Account origin change requires explicit --migrate-app-origin and a fresh setup payload');
218
+ if (migrating && previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase())
219
+ throw Error('Account origin migration must retain the enrollment server identity');
220
+ }
221
+ const loaded = loadConfig(configuration, { deferredOwnerInviteFile: workspaceOwnerInvite() });
222
+ // Decided before anything is sent or written: the Owner entry is replaced only when it records an enrollment this host has lost.
223
+ const lost = Boolean(loaded.rooms) && recordsLostEnrollment(resolve(configuration));
224
+ if (loaded.rooms?.defaults?.attach_owner === false)
225
+ throw Error('Existing rooms.defaults.attach_owner is disabled; enable automatic Human Owner admission before workspace setup');
226
+ if (loaded.rooms && !loaded.rooms.owner.public_invite_file)
227
+ throw Error('Existing room owner configuration must be reviewed before workspace enrollment');
228
+ const identity = await messengerIdentity(request, attach);
229
+ if (!/^[a-f0-9]{64}$/i.test(identity.cid))
230
+ throw Error('Bound Messenger identity unavailable');
231
+ // An installation that ran Messenger as the Human root and now runs it as the person's own identity keeps its managed Owner entry, moved to that identity.
232
+ const moved = Boolean(loaded.rooms) && !lost && !!identity.root && !same(loaded.rooms.owner.expected_cid, identity.cid) && managedRootOwner(resolve(configuration), loaded.rooms, identity.root)
233
+ && JSON.parse(readFileSync(previousFile, 'utf8')).serverCid?.toUpperCase() === payload.serverCid.toUpperCase();
234
+ if (options.preserveProfile && identity.preserveProfile !== true)
235
+ throw Error('Existing-host setup requires Messenger profile-preservation support; update the host Messenger before retrying');
236
+ if (loaded.rooms && !lost && !moved && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
237
+ throw Error('Retained room owner CID conflicts with the Human root');
238
+ const inviteFile = loaded.rooms?.owner.public_invite_file || join(dir, 'owner.invite');
239
+ if (existsSync(inviteFile)) {
240
+ const stat = lstatSync(inviteFile);
241
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0)
242
+ throw Error('Retained Owner invitation file must be owned and private');
243
+ }
244
+ const devices = new WorkspaceDeviceStore();
245
+ const hostWorkspaceId = devices.workspaceId;
246
+ devices.close();
247
+ const { name, surname, ...proofPayload } = payload;
248
+ let result, ownerCid;
249
+ if (identity.root) {
250
+ // The Human root signs the binding; the person's own Messenger identity is the room Owner and issues the Owner invitation.
251
+ await proveWithHumanRoot(attach, identity.root, payload, hostWorkspaceId, options.contactWaitMs ?? 10000);
252
+ const invite = await request('invites', { mode: 'public' });
253
+ if (typeof invite.blob !== 'string' || !invite.blob)
254
+ throw Error('Malformed workspace enrollment response');
255
+ const after = await request('workspace/enrollment-identity');
256
+ if (typeof after.cid !== 'string' || !same(after.cid, identity.cid) || typeof after.rootCid !== 'string' || !same(after.rootCid, identity.root.cid))
257
+ throw Error("This host's Messenger identity changed while setup was running; local configuration is unchanged");
258
+ result = { submitted: true, rootCid: identity.root.cid, ownerInvite: invite.blob };
259
+ ownerCid = identity.cid;
260
+ }
261
+ else {
262
+ result = await request('workspace/enroll', options.preserveProfile ? { ...proofPayload, hostWorkspaceId, preserveProfile: true } : { ...payload, hostWorkspaceId });
263
+ if (!result.submitted || !/^[a-f0-9]{64}$/i.test(result.rootCid) || typeof result.ownerInvite !== 'string')
264
+ throw Error('Malformed workspace enrollment response');
265
+ // Whatever the configuration said before, what is recorded from here on is this host's own root.
266
+ if (result.rootCid.toLowerCase() !== identity.cid.toLowerCase())
267
+ throw Error('Workspace enrollment answered for a different root than this host identity; local configuration is unchanged');
268
+ if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
269
+ throw Error('Retained room owner CID conflicts with the Human root');
270
+ ownerCid = result.rootCid;
271
+ }
272
+ if (migrating) {
273
+ const deadline = Date.now() + 15000;
274
+ while (true) {
275
+ const response = await fetch(appOrigin + '/account-api/workspace-proof', { method: 'POST', headers: { Origin: appOrigin, 'Content-Type': 'application/json' }, credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(5000), body: JSON.stringify({ ...payload.challenge, hostWorkspaceId, rootCid: result.rootCid }) });
276
+ if (!response.ok) {
277
+ await response.body?.cancel();
278
+ throw Error('Account origin migration proof was rejected; local trust unchanged');
279
+ }
280
+ const receipt = await response.json();
281
+ if (receipt.verified === true)
282
+ break;
283
+ if (Date.now() >= deadline)
284
+ throw Error('Account origin migration proof is not confirmed; local trust unchanged');
285
+ await new Promise(resolve => setTimeout(resolve, 500));
286
+ }
287
+ }
288
+ replaceFileAtomically(inviteFile, result.ownerInvite + '\n', 0o600);
289
+ options.afterInvitation?.();
290
+ const split = splitRootFor(configuration);
291
+ mkdirSync(split, { recursive: true, mode: 0o700 });
292
+ if (!loaded.rooms)
293
+ replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
294
+ else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== ownerCid.toLowerCase())
295
+ replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
296
+ else if (moved) {
297
+ // The invitation above and this entry are two files. A setup interrupted between them leaves the entry naming the root,
298
+ // which is this same case again: running setup once more writes both.
299
+ const document = parseDocument(readFileSync(configuration, 'utf8'));
300
+ document.setIn(['rooms', 'owner', 'expected_cid'], ownerCid);
301
+ replaceFileAtomically(configuration, document.toString(), 0o600);
302
+ }
303
+ replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
304
+ const origin = `https://${payload.hostname}`;
305
+ replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
306
+ replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase(), ...(identity.root ? { proofRootCid: identity.root.cid } : {}) }) + '\n', 0o600);
307
+ loadConfig(configuration);
308
+ return { origin, hostWorkspaceId, rootCid: result.rootCid };
309
+ }
310
+ /** Only the existing signed-root receipt authorizes the scoped tunnel target. */
311
+ /**
312
+ * The enrollment server contact exists only to carry the signed binding. Once the account has confirmed
313
+ * that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
314
+ * person's chats. The server identity is the one recorded with the binding; nothing else is touched.
315
+ */
316
+ export async function removeEnrollmentContact(attach = attachOursClient) {
317
+ let serverCid, proofRootCid;
318
+ try {
319
+ ({ serverCid, proofRootCid } = JSON.parse(readFileSync(join(stateRoot(), 'workspace', 'binding.json'), 'utf8')));
320
+ }
321
+ catch {
322
+ return false;
323
+ }
324
+ if (typeof serverCid !== 'string' || !/^[A-F0-9]{64}$/i.test(serverCid))
325
+ return false;
326
+ const contact = serverCid.toUpperCase();
327
+ // The contact belongs to whoever sent the binding. When that was the Human root itself, the binding records it.
328
+ if (typeof proofRootCid === 'string' && CID.test(proofRootCid)) {
329
+ try {
330
+ const root = await humanRoot(attach, proofRootCid);
331
+ await asHumanRoot(attach, root, 'cleanup', client => client.removeContact({ contact }));
332
+ }
333
+ catch {
334
+ throw Error('the Human root did not remove it');
335
+ }
336
+ return true;
337
+ }
338
+ try {
339
+ await hostMessenger()('contacts/remove', { contact });
340
+ }
341
+ catch {
342
+ throw Error('Messenger did not remove it');
343
+ }
344
+ return true;
345
+ }
346
+ export async function configureWorkspacePort(payload, hostWorkspaceId, rootCid, port) {
347
+ const deadline = Date.now() + 15000;
348
+ while (true) {
349
+ const response = await fetch(payload.appOrigin + '/account-api/workspace-tunnel-configure', { method: 'POST', headers: { Origin: payload.appOrigin, 'Content-Type': 'application/json' }, credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(15000), body: JSON.stringify({ ...payload.challenge, hostWorkspaceId, rootCid, port }) });
350
+ if (!response.ok) {
351
+ await response.body?.cancel();
352
+ throw Error('Workspace tunnel target configuration failed (HTTP ' + response.status + ')');
353
+ }
354
+ const receipt = await response.json();
355
+ if (receipt.configured === true)
356
+ return;
357
+ if (Date.now() >= deadline)
358
+ throw Error('Signed workspace binding is not confirmed; tunnel target was not changed');
359
+ await new Promise(resolve => setTimeout(resolve, 500));
360
+ }
361
+ }
362
+ /**
363
+ * Point the tunnel at this host's port. The account answers only for a confirmed signed binding, and
364
+ * only after that answer is the enrollment contact removed; a refused or unconfirmed target leaves it,
365
+ * so the setup can be finished later. A removal that fails is reported and does not undo the setup.
366
+ */
367
+ export async function confirmWorkspaceTarget(payload, hostWorkspaceId, rootCid, port, cleanupFailed = () => { }) {
368
+ await configureWorkspacePort(payload, hostWorkspaceId, rootCid, port);
369
+ await removeEnrollmentContact().catch(cleanupFailed);
370
+ }
@@ -0,0 +1,227 @@
1
+ # YAML Room Layouts
2
+
3
+ Room Layout describes room goals, participants and their shared or independent
4
+ sessions. Rooms and agents have independent lifecycles.
5
+
6
+ Place a layout at `fleet/room_layouts/<name>.yaml` beside the split configuration
7
+ for `fleet.yaml`. Agent Templates remain in `fleet/agent_templates/`.
8
+
9
+ ```yaml
10
+ version: 1
11
+ participants:
12
+ product: {agent_template: Product}
13
+ developer: {agent_template: Developer}
14
+ qa: {agent_template: QA}
15
+ rooms:
16
+ discovery:
17
+ goal: Clarify the problem and acceptance criteria
18
+ members: [product, developer, qa]
19
+ design:
20
+ goal: Review implementation choices
21
+ members: [developer, qa]
22
+ amigos:
23
+ goal: Resolve a concrete acceptance question
24
+ members: [product, developer, qa]
25
+ roles: {product: product, developer: developer, qa: qa}
26
+ ```
27
+
28
+ Participant `instance_scope` controls instance reuse:
29
+
30
+ - `layout` (default): repeating a participant key reuses its exact running instance
31
+ and session within this layout run.
32
+ - `room`: each room using the key gets its own identity and session, lazily created
33
+ when that room opens. Repeated opens of the same active room reuse that instance.
34
+
35
+ For example, one shared process doctor and one independent critic per room:
36
+
37
+ ```yaml
38
+ version: 1
39
+ participants:
40
+ doctor: {agent_template: ProcessDoctor, instance_scope: layout}
41
+ critic: {agent_template: Critic, instance_scope: room}
42
+ rooms:
43
+ discovery:
44
+ goal: Agree on the problem
45
+ members: [doctor, critic]
46
+ design:
47
+ goal: Review the design
48
+ members: [doctor, critic]
49
+ ```
50
+
51
+ Different keys using the same Agent Template still create independent instances.
52
+ See the [shared](../examples/room-layouts/shared.yaml) and
53
+ [fresh](../examples/room-layouts/fresh.yaml) examples, and the mixed
54
+ [scoped](../examples/room-layouts/scoped.yaml) example. All choices can coexist.
55
+ Fresh agents receive their room context. Supply relevant evidence through ordinary
56
+ room messages or existing project documents when needed.
57
+
58
+ A participant without `agent_template` requires a binding. `layout instance`
59
+ returns a local exact reference (supervisor, agent, launch, CID and session).
60
+ Pass participant-to-reference YAML to `create --bindings`. A stopped or replaced instance is rejected. Cross-Fleet bindings require
61
+ an explicit owner grant, as described below.
62
+ Room-scoped participants require `agent_template`; an explicit binding for them
63
+ is rejected before creating resources. Bindings for layout-scoped participants
64
+ keep their existing behavior.
65
+
66
+ ```sh
67
+ ours-fleet layout validate work
68
+ ours-fleet layout create work --id example
69
+ ours-fleet layout open example design
70
+ ours-fleet layout open example discovery
71
+ ours-fleet layout open example amigos
72
+ ours-fleet layout close-room example amigos
73
+ ours-fleet layout status example
74
+ ours-fleet layout close example
75
+ ```
76
+
77
+ Any declared room may open first. `create` only saves the definition and resolved
78
+ Agent Templates; `open` lazily starts missing participants and admits all members
79
+ before sending their room context. The membership notification is informational. Existing room rules govern when
80
+ an agent contributes. Each room opens through an explicit command.
81
+
82
+ `close-room` archives a room and keeps agents available to other rooms. `close`
83
+ archives opened rooms and stops only temporary participants created by this layout
84
+ instance. Explicitly bound participants survive. Cleanup processes existing resources. A 3-Amigos room is an ordinary room with
85
+ complementary members.
86
+ Room-scoped instances follow the same cleanup rules: closing a room does not
87
+ retire them, and a closed room cannot reopen. A new layout run creates new instances.
88
+
89
+ Private state keeps the authorable definition unchanged. Its `participants` map
90
+ uses the original key for shared instances and `<room>:<participant>` for
91
+ room-scoped instances, with `participant` and `room` metadata. Declared but unopened
92
+ rooms have no running instance. Status and task read models distinguish these
93
+ entries and resolve their original Agent Template. Existing snapshots without
94
+ `instance_scope` retain their original keys and require no migration. Snapshots
95
+ using `room` require a Fleet version that supports this field; they are not a
96
+ downgrade format for older Fleet versions.
97
+
98
+ Supported room fields: `goal`, `members`, optional `roles`, `contract`,
99
+ `quiet_membership`, `anonymous`. `contract` is briefing text.
100
+ Top-level fields: `version: 1`, optional `description`, `participants`, `rooms`.
101
+ Participant fields: optional `agent_template`, `instance_scope: layout|room`.
102
+ Unknown fields, ambiguous YAML and invalid member references are rejected.
103
+ Existing templates and tasks keep their behavior.
104
+
105
+ Local Fleet control uses the existing authenticated control socket,
106
+ agent runtime, `spawnTemp` and session queue. Cross-Fleet bindings delegate
107
+ membership operations to their owning Fleet as described below. Private state
108
+ snapshots template definitions; public status hides their environment values.
109
+ Existing owner settings apply; owner CID/invite fingerprint are pinned. Unknown
110
+ mutation outcomes stop new work for inspection rather than blindly replaying a
111
+ spawn or room creation. `close` and `close-room` can still clean known resources.
112
+ Cleanup continues after individual errors and does not stop replacement or borrowed
113
+ instances. Confirmed stopped workers need no live control socket. An unknown
114
+ original outcome remains recorded, and cleanup reports incomplete until inspected;
115
+ it never claims that unknown resources were found or removed. Retrying cleanup
116
+ checks known resources and skips completed cleanup. Reopening an active layout room
117
+ checks its native state, CID and required seats and reports drift without repairing it.
118
+
119
+
120
+ Shared sessions retain available conversation context, not guaranteed perfect
121
+ recall or isolation between rooms. Compaction and model judgment still matter.
122
+ Rooms keep their existing turn scheduling.
123
+
124
+ Each layout instance creates distinct native room names, so several instances can
125
+ open the same declared room key. Participant roles must differ from the attached
126
+ owner role; a collision is rejected before creating resources.
127
+
128
+ Definitions are loaded for authoring commands. Retained instances use their saved
129
+ layout and Agent Templates. Status and cleanup operate on saved resources even
130
+ when the source YAML is broken or removed. New agent launches continue to use
131
+ Fleet's normal configuration and launch checks.
132
+
133
+ ## Borrowing an instance from another Fleet on the same host
134
+
135
+ Fleet A can open rooms using standalone temporary or persistent agents supervised
136
+ by Fleet B under another OS user. Both must select the same daemon instance and
137
+ Cowork gateway. An agent's original Fleet keeps its session, identity and lifecycle
138
+ control. This extends layout bindings; it does not add bindings to legacy
139
+ `room create` / RoomTemplate provisioning.
140
+
141
+ On the agent owner's Fleet B, use its existing web server and grant access to an
142
+ exact live standalone instance:
143
+
144
+ ```sh
145
+ ours-fleet web serve --port 49272 --no-open
146
+ # In another terminal belonging to Fleet B's owner:
147
+ ours-fleet layout share Architect --temporary --participant architect \
148
+ --server-url http://127.0.0.1:49272 --output ./architect.binding.yaml
149
+ ```
150
+
151
+ The command prints the grant ID and output path. It creates a binding YAML and
152
+ an adjacent `.token` file, both owner-only. The private owner record stores a
153
+ SHA-256 token digest, not the token. `share` never overwrites an existing export.
154
+ Transfer both files explicitly to Fleet A's user through your existing secure
155
+ file-transfer method, keeping the receiving credential file owned by that user
156
+ with mode `0600`. Do not share Fleet B's console session, client profile, daemon
157
+ credential, private state directory or supervisor socket. This feature creates
158
+ no additional server; Fleet B's existing web server must remain available.
159
+
160
+ Export each specialist under its logical layout participant key, then merge the
161
+ YAML mappings into one bindings document. Keep every adjacent token filename
162
+ unique. Relative credential paths resolve against the bindings file's directory.
163
+ The reusable layout contains participant factories or empty participant mappings,
164
+ never concrete credentials or running-instance references:
165
+
166
+ ```yaml
167
+ version: 1
168
+ participants:
169
+ architect: {}
170
+ developer: {}
171
+ doctor: {}
172
+ rooms:
173
+ product: {goal: Scope, members: [architect, doctor]}
174
+ design: {goal: Design, members: [architect, developer, doctor]}
175
+ delivery: {goal: Delivery, members: [developer, doctor]}
176
+ ```
177
+
178
+ On Fleet A:
179
+
180
+ ```sh
181
+ ours-fleet layout create project --id project-run --bindings ./bindings.yaml
182
+ ours-fleet layout open project-run product
183
+ ours-fleet layout open project-run design
184
+ ours-fleet layout open project-run delivery
185
+ ours-fleet layout close-room project-run product
186
+ ours-fleet layout close project-run
187
+ ```
188
+
189
+ The exact CID, launch and harness session are reused. Reopening an active room
190
+ checks existing membership without delivering duplicate context. Closing rooms
191
+ archives Cowork history and never stops borrowed instances. After acceptance,
192
+ the original Fleet B owner explicitly retires its standalone temporary specialists
193
+ using the existing lifecycle commands. Persistent specialists keep their ordinary
194
+ lifecycle. Mixed local-created and borrowed participants are supported.
195
+
196
+ An exported reference adds `remote` to the existing instance reference:
197
+
198
+ ```yaml
199
+ remote:
200
+ url: http://127.0.0.1:49272
201
+ grant_id: 11111111-1111-4111-8111-111111111111
202
+ credential_file: architect.binding.yaml.token
203
+ daemon_instance_id: 22222222-2222-4222-8222-222222222222
204
+ ```
205
+
206
+ The grant authorizes only `verify`, `join`, and `assign` for that exact instance.
207
+ Room admission validates the requested room ID and CID against shared Cowork and
208
+ uses an invite issued by Cowork on the owner side; caller-supplied contact invites
209
+ are rejected before contact mutation. Context delivery also requires its active seat, expected room CID and role in
210
+ Cowork. It cannot inspect inventory, spawn, stop, reconfigure, export identities,
211
+ or authenticate browser console routes. It grants the holder room admission and
212
+ room-context delivery, so transfer it only to the intended room creator.
213
+
214
+ Owner-side revocation does not stop the agent or remove existing memberships:
215
+
216
+ ```sh
217
+ ours-fleet layout revoke-binding <grant-id>
218
+ ```
219
+
220
+ Stopped/replaced sessions, a different daemon, revoked grants or unavailable
221
+ owner transport produce errors; no replacement is spawned. A lost response to a
222
+ membership/context mutation retains the existing layout `uncertain` marker.
223
+ Inspect the native room and owning Fleet before retrying; no mutation is blindly
224
+ replayed. Only direct loopback HTTP origins (`127.0.0.1`) are supported;
225
+ redirects, external hosts and forwarded requests are rejected. No cross-host
226
+ support, discovery protocol, mission service, or room conversation limits are
227
+ introduced.