hive-am 1.10.1 → 1.12.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 (193) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/package.json +1 -1
  3. package/server/dist/db.js +13 -4
  4. package/server/dist/dispatches.js +50 -2
  5. package/server/dist/http/routes/fs.js +3 -2
  6. package/server/dist/http/routes/orchestration.js +16 -1
  7. package/server/dist/http/routes/resources.js +32 -5
  8. package/server/dist/instructions.js +1 -1
  9. package/server/dist/objects/docker-info.js +118 -11
  10. package/server/dist/objects/docker.js +492 -16
  11. package/server/dist/objects/files.js +152 -0
  12. package/server/dist/objects/index.js +61 -24
  13. package/server/dist/objects/model.js +72 -4
  14. package/server/dist/runtime.js +25 -1
  15. package/server/mcp/dispatch.mjs +42 -2
  16. package/web/.next/BUILD_ID +1 -1
  17. package/web/.next/app-build-manifest.json +149 -151
  18. package/web/.next/app-path-routes-manifest.json +4 -4
  19. package/web/.next/build-manifest.json +13 -13
  20. package/web/.next/prerender-manifest.json +43 -43
  21. package/web/.next/react-loadable-manifest.json +4 -4
  22. package/web/.next/required-server-files.json +1 -1
  23. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  24. package/web/.next/server/app/_not-found.html +1 -1
  25. package/web/.next/server/app/_not-found.rsc +3 -3
  26. package/web/.next/server/app/agents/[id]/page.js +4 -4
  27. package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
  28. package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
  29. package/web/.next/server/app/agents/page.js +2 -2
  30. package/web/.next/server/app/agents/page.js.nft.json +1 -1
  31. package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
  32. package/web/.next/server/app/agents.html +1 -1
  33. package/web/.next/server/app/agents.rsc +5 -5
  34. package/web/.next/server/app/chat/page.js +1 -1
  35. package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -1
  36. package/web/.next/server/app/chat.html +1 -1
  37. package/web/.next/server/app/chat.rsc +4 -4
  38. package/web/.next/server/app/colony/page.js +2 -2
  39. package/web/.next/server/app/colony/page.js.nft.json +1 -1
  40. package/web/.next/server/app/colony/page_client-reference-manifest.js +1 -1
  41. package/web/.next/server/app/colony.html +1 -1
  42. package/web/.next/server/app/colony.rsc +4 -4
  43. package/web/.next/server/app/connections/page.js +1 -1
  44. package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
  45. package/web/.next/server/app/connections.html +1 -1
  46. package/web/.next/server/app/connections.rsc +4 -4
  47. package/web/.next/server/app/index.html +1 -1
  48. package/web/.next/server/app/index.rsc +4 -4
  49. package/web/.next/server/app/page.js +2 -2
  50. package/web/.next/server/app/page_client-reference-manifest.js +1 -1
  51. package/web/.next/server/app/relations/page.js +1 -1
  52. package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
  53. package/web/.next/server/app/relations.html +1 -1
  54. package/web/.next/server/app/relations.rsc +4 -4
  55. package/web/.next/server/app/resources/[id]/page.js +1 -1
  56. package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -1
  57. package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
  58. package/web/.next/server/app/resources/page.js +2 -2
  59. package/web/.next/server/app/resources/page.js.nft.json +1 -1
  60. package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
  61. package/web/.next/server/app/resources.html +1 -1
  62. package/web/.next/server/app/resources.rsc +4 -4
  63. package/web/.next/server/app/schedules/page.js +1 -1
  64. package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  65. package/web/.next/server/app/schedules.html +1 -1
  66. package/web/.next/server/app/schedules.rsc +4 -4
  67. package/web/.next/server/app/sessions/page.js +2 -2
  68. package/web/.next/server/app/sessions/page.js.nft.json +1 -1
  69. package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  70. package/web/.next/server/app/sessions.html +1 -1
  71. package/web/.next/server/app/sessions.rsc +4 -4
  72. package/web/.next/server/app/skills/page.js +1 -1
  73. package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
  74. package/web/.next/server/app/skills.html +1 -1
  75. package/web/.next/server/app/skills.rsc +4 -4
  76. package/web/.next/server/app/types/page.js +1 -1
  77. package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
  78. package/web/.next/server/app/types.html +1 -1
  79. package/web/.next/server/app/types.rsc +4 -4
  80. package/web/.next/server/app-paths-manifest.json +4 -4
  81. package/web/.next/server/chunks/12.js +1 -0
  82. package/web/.next/server/chunks/143.js +1 -1
  83. package/web/.next/server/chunks/295.js +1 -0
  84. package/web/.next/server/chunks/374.js +6 -0
  85. package/web/.next/server/chunks/687.js +1 -0
  86. package/web/.next/server/chunks/770.js +2 -0
  87. package/web/.next/server/chunks/{944.js → 867.js} +1 -1
  88. package/web/.next/server/chunks/953.js +1 -1
  89. package/web/.next/server/middleware-build-manifest.js +1 -1
  90. package/web/.next/server/middleware-react-loadable-manifest.js +1 -1
  91. package/web/.next/server/pages/404.html +1 -1
  92. package/web/.next/server/pages/500.html +1 -1
  93. package/web/.next/server/server-reference-manifest.json +1 -1
  94. package/web/.next/static/{4u5XzGwgVbDEHRyGvCzGf → GT9TTAUuwG8duMIDd7bTf}/_buildManifest.js +1 -1
  95. package/web/.next/static/chunks/0937d497-114a2a3de515565e.js +1 -0
  96. package/web/.next/static/chunks/199-c000d298c94aac98.js +1 -0
  97. package/web/.next/static/chunks/214-35b88db02113ae02.js +1 -0
  98. package/web/.next/static/chunks/218-91cee960142e2880.js +1 -0
  99. package/web/.next/static/chunks/2b9ff8ee-969e65cc354b2150.js +1 -0
  100. package/web/.next/static/chunks/{969-07e498da015b149a.js → 350-bf7c44f226170b15.js} +1 -1
  101. package/web/.next/static/chunks/517-870d39e307db4dbb.js +1 -0
  102. package/web/.next/static/chunks/523-665ef4ba8ec58171.js +1 -0
  103. package/web/.next/static/chunks/545-f3d12b73a5a530a3.js +1 -0
  104. package/web/.next/static/chunks/642-dc874c8caf203ee1.js +1 -0
  105. package/web/.next/static/chunks/654-92a63b3c4b412666.js +1 -0
  106. package/web/.next/static/chunks/{2657.8d96393b971ae134.js → 657.b4c39e676c3e20dc.js} +1 -1
  107. package/web/.next/static/chunks/701.6abcaf5e60463eef.js +6 -0
  108. package/web/.next/static/chunks/706-fa2be0f10ffebe6a.js +1 -0
  109. package/web/.next/static/chunks/740-72f941744e530c78.js +1 -0
  110. package/web/.next/static/chunks/757-03361e7a97a605a2.js +1 -0
  111. package/web/.next/static/chunks/790-91732fc93acda224.js +1 -0
  112. package/web/.next/static/chunks/837-33c7480524f48577.js +1 -0
  113. package/web/.next/static/chunks/870-18f6bab953828102.js +1 -0
  114. package/web/.next/static/chunks/{545.bfbd92b7632c09de.js → 926.03a65d3574bc521c.js} +1 -1
  115. package/web/.next/static/chunks/97.594543c2196de524.js +1 -0
  116. package/web/.next/static/chunks/982-147e6a55046c90ba.js +1 -0
  117. package/web/.next/static/chunks/app/_not-found/page-948f39e30bfc6144.js +1 -0
  118. package/web/.next/static/chunks/app/agents/[id]/page-b9ed0dd106af45f7.js +1 -0
  119. package/web/.next/static/chunks/app/agents/layout-89bb7e860326cf4b.js +1 -0
  120. package/web/.next/static/chunks/app/agents/page-0f809513514f3c4d.js +1 -0
  121. package/web/.next/static/chunks/app/chat/page-5ec04b8997e2c0f7.js +1 -0
  122. package/web/.next/static/chunks/app/colony/page-2d41fa9508904912.js +1 -0
  123. package/web/.next/static/chunks/app/connections/page-301e45029c8a7b52.js +1 -0
  124. package/web/.next/static/chunks/app/layout-114e3639e77db079.js +1 -0
  125. package/web/.next/static/chunks/app/page-5549d1e344c5f5bb.js +1 -0
  126. package/web/.next/static/chunks/app/relations/page-18e76ccb008fc7f5.js +1 -0
  127. package/web/.next/static/chunks/app/resources/[id]/page-caf4c61561524382.js +1 -0
  128. package/web/.next/static/chunks/app/resources/page-d27d342404176148.js +1 -0
  129. package/web/.next/static/chunks/app/schedules/page-448f7f201195bc00.js +1 -0
  130. package/web/.next/static/chunks/app/sessions/page-8b8ee7e01a3a8def.js +1 -0
  131. package/web/.next/static/chunks/app/skills/page-638e3f7d7bbf657c.js +1 -0
  132. package/web/.next/static/chunks/app/types/page-ee51477946f8029f.js +1 -0
  133. package/web/.next/static/chunks/{fcafe1ac.47e831cc63938bfb.js → fcafe1ac.366e4950d3b0d4b7.js} +1 -1
  134. package/web/.next/static/chunks/framework-bbac72adbbe2d328.js +1 -0
  135. package/web/.next/static/chunks/main-app-61597cc1ea26ecc5.js +1 -0
  136. package/web/.next/static/chunks/main-c15f3fee674e0303.js +1 -0
  137. package/web/.next/static/chunks/pages/{_app-8aeadc281b5934a7.js → _app-d1670a2c6ce392fc.js} +1 -1
  138. package/web/.next/static/chunks/pages/_error-95aac014d202fa7a.js +1 -0
  139. package/web/.next/static/chunks/webpack-0ff2be4f351faaaf.js +1 -0
  140. package/web/.next/static/css/c484c502b2d13072.css +1 -0
  141. package/web/.next/trace +31 -31
  142. package/server/dist/objects/templates.js +0 -67
  143. package/web/.next/server/chunks/345.js +0 -2
  144. package/web/.next/server/chunks/368.js +0 -1
  145. package/web/.next/server/chunks/403.js +0 -1
  146. package/web/.next/server/chunks/505.js +0 -6
  147. package/web/.next/server/chunks/561.js +0 -1
  148. package/web/.next/server/chunks/616.js +0 -1
  149. package/web/.next/server/chunks/802.js +0 -1
  150. package/web/.next/static/chunks/0937d497-daa99a23a4431317.js +0 -1
  151. package/web/.next/static/chunks/1517-e96b538fa33001f5.js +0 -1
  152. package/web/.next/static/chunks/1606-2aa8ee5a6751f920.js +0 -1
  153. package/web/.next/static/chunks/1771-4688ad65e21bb512.js +0 -1
  154. package/web/.next/static/chunks/2547-821b5a01cff1915f.js +0 -1
  155. package/web/.next/static/chunks/2654-4aeef09c0d6bf56a.js +0 -1
  156. package/web/.next/static/chunks/2b9ff8ee-0851c7e556e3a39b.js +0 -1
  157. package/web/.next/static/chunks/3214-c9d0c933db5b0f44.js +0 -1
  158. package/web/.next/static/chunks/3757-45ea801718ec19e4.js +0 -1
  159. package/web/.next/static/chunks/3790-3d3f11a264e8ede8.js +0 -1
  160. package/web/.next/static/chunks/4030-bc2e0aa4da0d9ad8.js +0 -1
  161. package/web/.next/static/chunks/4162-c2361cb017547c37.js +0 -1
  162. package/web/.next/static/chunks/4491-49c700f509985c9e.js +0 -1
  163. package/web/.next/static/chunks/4837-8dc05d75f258f9e1.js +0 -1
  164. package/web/.next/static/chunks/5587-7e580517c4105e63.js +0 -1
  165. package/web/.next/static/chunks/5642-09dd4161ddddaf94.js +0 -1
  166. package/web/.next/static/chunks/7097.60e7ac5abf76b2cb.js +0 -1
  167. package/web/.next/static/chunks/7706-bd0d2bda0e0c8d1e.js +0 -1
  168. package/web/.next/static/chunks/8523-82e1ee6239699d8a.js +0 -1
  169. package/web/.next/static/chunks/8701.0db2914c123a180e.js +0 -6
  170. package/web/.next/static/chunks/9977-78b955d85f795a21.js +0 -1
  171. package/web/.next/static/chunks/app/_not-found/page-d7975fab88bfe2c3.js +0 -1
  172. package/web/.next/static/chunks/app/agents/[id]/page-da4050632f63874d.js +0 -1
  173. package/web/.next/static/chunks/app/agents/layout-2536c0c15d09ddcf.js +0 -1
  174. package/web/.next/static/chunks/app/agents/page-0d8769f28c624f9c.js +0 -1
  175. package/web/.next/static/chunks/app/chat/page-b1569cf72e958d8b.js +0 -1
  176. package/web/.next/static/chunks/app/colony/page-ffdc123dac47ad69.js +0 -1
  177. package/web/.next/static/chunks/app/connections/page-0e4f9f272da95c83.js +0 -1
  178. package/web/.next/static/chunks/app/layout-611dd72158f6f098.js +0 -1
  179. package/web/.next/static/chunks/app/page-68ae166b2f8dbf94.js +0 -1
  180. package/web/.next/static/chunks/app/relations/page-a9d991b174372f37.js +0 -1
  181. package/web/.next/static/chunks/app/resources/[id]/page-48131f750e983a07.js +0 -1
  182. package/web/.next/static/chunks/app/resources/page-706d012a4c0d57ce.js +0 -1
  183. package/web/.next/static/chunks/app/schedules/page-5821c0d26afde69f.js +0 -1
  184. package/web/.next/static/chunks/app/sessions/page-6145eb19002dd390.js +0 -1
  185. package/web/.next/static/chunks/app/skills/page-c080683abd0496ab.js +0 -1
  186. package/web/.next/static/chunks/app/types/page-a991fe9afa51bdb7.js +0 -1
  187. package/web/.next/static/chunks/framework-ab1d276eb4175e96.js +0 -1
  188. package/web/.next/static/chunks/main-18f19eeb8b4d3fe4.js +0 -1
  189. package/web/.next/static/chunks/main-app-27cc7ffdb888b879.js +0 -1
  190. package/web/.next/static/chunks/pages/_error-6bffadfc60f180a1.js +0 -1
  191. package/web/.next/static/chunks/webpack-ba68c1813987a630.js +0 -1
  192. package/web/.next/static/css/10b50a352e0391ea.css +0 -1
  193. /package/web/.next/static/{4u5XzGwgVbDEHRyGvCzGf → GT9TTAUuwG8duMIDd7bTf}/_ssgManifest.js +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.12.0] - 2026-10-23
10
+
11
+ ### Added
12
+
13
+ - **Inspect a Docker resource in every mode.** Inspect is now also in **More** for an existing container (it was only there for a container hive-am creates), and it shows the container's **Labels**, grouped as hive-am's, compose's and the rest. It has two panels: **Configured in hive-am**, always there (the settings of a container hive-am creates, the name of an existing one, or what the compose file says about a service), and **What Docker says**, the live detail. A stopped container is inspected as before, without the shell button.
14
+ - **Raw `docker inspect`.** Inspect has a **Raw JSON** tab with the whole `docker inspect`, highlighted like the code explorer (same themes and whitespace setting, a Web Worker for big ones), with line numbers, a search box that jumps from match to match and **Copy JSON**, which copies exactly what is shown. The server hides the value of variables whose name looks like a secret, and the password of a URL with credentials in any other variable (`postgres://user:••••••••@host/db`), until you press **Reveal**; labels and the command are not hidden. Each compose service has it from its own Inspect. New routes: `GET /api/objects/:id/inspect/raw` and `GET /api/docker/inspect?kind=container|image|volume|network&ref=`, open like the other reads of resources (the app is also opened through other names on the network); the hiding above is done by the server, but it is for the screen, not a security boundary: any page can ask for `reveal=1`, and `GET /api/objects/:id/inspect` already carries the variables in the clear.
15
+ - **Pick networks and volumes from Docker.** In **Create a container**, the **Network** is a picker with the networks of this machine and the **Volumes** are rows: a volume of this machine (picked or typed; a new name is created by Docker on the first start, and the row says so, unless Docker is not answering) or a folder, the path in the container and **Read only**. Images, networks and volumes say how many containers use them. A volume row needs both sides: one with only a source or only a path says so, and the server refuses `data:` or `:/data`, which used to fail only in `docker run`. The rows keep the same saved format, so resources saved before show as they were. New routes `GET /api/docker/volumes` and `GET /api/docker/networks`; `GET /api/docker/containers` gives each container's `mounts` and `networks`, and `GET /api/docker/images` each image's `inUse`. Everything here only reads Docker.
16
+ - **Create and delete networks and volumes from the Docker form.** A network that is not on the machine says the container will not start without it and offers **Create network**; a new volume name offers **Create it now**. Each is an immediate action with a notice, labelled `hive-am.created=1` and marked *created by hive-am*; asking again for one that exists changes nothing. A network or a volume hive-am created that no container uses (running or stopped) can be deleted from the suggestions after a confirmation; the server checks the label and the use again against Docker and refuses another's, Docker's own networks, anonymous volumes and anything in use. New routes, local app only: `POST /api/docker/networks`, `POST /api/docker/volumes`, `DELETE /api/docker/networks/:name`, `DELETE /api/docker/volumes/:name`.
17
+ - **Build an image from a Dockerfile.** Under the image of **Create a container**, **Build from a Dockerfile** takes the file, the build folder (its own by default) and the image name (suggested from the folder), runs `docker build` with the label `hive-am.created=1` and picks the image when it ends; only a regular file named like a Dockerfile (`Dockerfile`, `Containerfile`, `Dockerfile.<suffix>`, `<name>.Dockerfile`, its real name too) is built, because a failure shows the last lines of the output. It waits up to 15 minutes, runs the Dockerfile's steps on this machine and may download base images. The folder picker can now list Dockerfiles (`files=dockerfile,containerfile`). New route, local app only: `POST /api/docker/images/build`.
18
+ - **Edit or copy the compose file and a Dockerfile (server).** New routes, local app only (reading included), to read, save and copy the compose file of a compose resource (always the path saved in it, never one from the page) and a Dockerfile (a regular file named like one, its real name too, up to 512 KB). A save is atomic (a temporary file in the same folder, then a rename: the original survives a failed write) and carries the etag read with the file, so a file changed on disk since is a 409 instead of being overwritten. A copy goes next to the original and never replaces a file without `overwrite`; a compose copy can become the resource's file. Saving the compose file does not touch Docker: it answers whether `docker compose config` reads it (an invalid file is saved and marked) and whether services are running.
19
+ - **Edit or copy the compose file and a Dockerfile.** A compose resource has a **File** tab with its compose file in the code editor: **Save** (after a confirmation that names the file), **Save as copy** next to it (the resource can use the copy, on by default) and **Discard**. If the file changed on disk since it was opened you choose to reload it or overwrite it; if services are running, a notice offers **Create again**; a file Docker cannot read is saved and its error shown. In **Build from a Dockerfile**, **Edit** and **Save as copy** open the Dockerfile in the same editor, and a copy becomes the file to build; saving builds nothing. Windows line endings (`\r\n`) and the file's mode are kept when saving. Going to another resource from the list does not ask about unsaved edits. An existing container offers no file editing.
20
+ - **Additional networks and more options of `docker run`.** A container hive-am creates can be connected to **additional networks** besides its main one; they are connected and disconnected in place (`docker network connect / disconnect`, running or stopped, same container), never by creating it again. **More options of docker run** adds a fixed, checked list: labels, user, working folder, entrypoint and hostname; any other option in the settings (such as `privileged`) is refused instead of ignored. The settings of a container saved before give the same `hive-am.cfg` hash as before, so updating does not create any container again.
21
+ - **Create a managed copy of an existing container.** hive-am still never changes a container it did not create; **Create a managed copy** (in **More** and in its Settings) reads its `docker inspect` and opens the form of a new container hive-am creates, filled in with its image, ports, named volumes and folders, its own variables, networks, limits and options, and lists what could not be copied (anonymous volumes, random ports). The original is only read. New route, local app only: `GET /api/objects/:id/docker/managed-copy`.
22
+ - **Relations map of a Docker resource.** A new **Relations** tab (every Docker mode) maps the resource's neighbourhood only: its containers (for compose, those of its project), their image, the volumes and the folders of this machine they mount (shown as different kinds of node), their networks and the other containers that share one of those, at most 12 per shared node with *and N more*, while Docker's own networks only count their users. Choosing a node lights what it is joined to and shows its detail below, where **Inspect** (Summary and Raw JSON for hive-am's containers, the raw `docker inspect` for the rest) opens in the same panel; a container of a hive-am resource offers **Edit settings**. Read only, no search and no whole-machine view. `GET /api/docker/containers` now gives each container's `object`, `project` and `service`.
23
+
24
+ ### Changed
25
+
26
+ - **Save and apply, with a confirmation before a container is created again.** In the Settings of a container hive-am creates, *Save and restart* becomes **Save and apply**, for a running or a stopped container. It first asks what would change: additional networks are applied in place without asking; anything else needs the container to be created again, and a confirmation lists each change and its kind, what is lost (files written inside, logs, restart count, id and IP), the anonymous volumes left behind unmounted and the commands that will run, with secrets hidden. Nothing in Docker changes until it is confirmed, and the server refuses an unconfirmed recreate. **Save** alone still never touches Docker. Containers created from now on carry the label `hive-am.networks=1`, and hive-am keeps their networks as configured: a network connected by hand is disconnected on the next start. New routes, local app only: `POST /api/objects/:id/docker/plan` and `POST /api/objects/:id/docker/apply`.
27
+ - **Inspect explains instead of failing.** A container hive-am has not created yet, an existing container Docker cannot find, a compose service with no container and Docker not installed or not running no longer show a bare error: Inspect shows what is configured and says why Docker has nothing. When the settings changed since hive-am created the container, it says the container will be created again on the next start.
28
+ - **`GET /api/objects/:id/inspect` changed shape.** It answers `{ exists, configured, ... }`: with `exists: true` the detail of `docker inspect` and `cfgStale`, with `exists: false` a `reason` (`not-created`, `not-found`, `no-service`, `no-docker`).
29
+
30
+ ### Removed
31
+
32
+ - **App templates for Docker resources.** Creating a Docker resource no longer offers **From a template** (the ready-made Redis, PostgreSQL, MySQL and other services), so hive-am shows no predefined catalog of services or images and depends on no registry for one; the form opens in **Create a container**, and the other modes are unchanged. `GET /api/object-templates` and `POST /api/objects/from-template` are gone. A compose resource made from a template keeps working as before: its file stays in the data folder (`stacks/<name>/`) and is shown, started and edited (**File** tab) like any other compose file; nothing is moved or deleted.
33
+
34
+ ## [1.11.0] - 2026-10-23
35
+
36
+ ### Added
37
+
38
+ - **Cancel a delegated task.** A task sent by mistake or no longer needed can be taken back, queued or running. In **Recent delegations** (Colony and Home) each unfinished task has a **×** button: a queued one is cancelled at once and never starts, a running one asks first and is stopped. The orchestrator is told once, in the place where it delegated. Orchestrators get two new tools, `dispatch_cancel` (only for their own tasks; they get no extra message for a task they cancel) and `dispatch_list` (their tasks that have not finished). A cancelled task shows as **cancelled** with a grey dot, frees its place in the limit of 10, and what the worker already changed stays in the folder. Unfinished tasks now come first in the list and are never hidden by its limit. Stop on a worker still stops only its current turn and marks that delegation failed.
39
+
40
+ ### Fixed
41
+
42
+ - **Lists follow the language at once.** Changing the language with the app open left some names in the old language until a reload: the **No colony** group in the chat's agent list and in **Resources**, and the colony hints in the agent form and the resource manager field. They now change right away.
43
+ - **Collapsing the agent list clears its search.** A search typed in the chat's agent list kept filtering the collapsed list, which has no search box, so agents and groups seemed to vanish and the group headings stayed disabled. Collapsing now clears the search, and expanding shows the full list with an empty box.
44
+ - **More of the interface follows the language.** The **Entrypoint**, **Gateway** and **Aliases** labels of a service's details, and the names screen readers hear for the method picker of a new HTTP request and for the sideways scroll bar of the diff view, were always in English; a request that failed in an HTTP file showed `error` in English too. They now come from the dictionaries. Empty cells and values show a plain hyphen instead of a long dash, and a service's empty lists (health log, variables, ports, mounts, networks) say **none**.
45
+
9
46
  ## [1.10.1] - 2026-10-23
10
47
 
11
48
  ### Fixed
@@ -290,7 +327,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
290
327
  - Complete documentation in English and Spanish.
291
328
  - npm package with the `hive-am` command.
292
329
 
293
- [Unreleased]: https://github.com/hughcbscrt/hive-am/compare/v1.10.1...HEAD
330
+ [Unreleased]: https://github.com/hughcbscrt/hive-am/compare/v1.12.0...HEAD
331
+ [1.12.0]: https://github.com/hughcbscrt/hive-am/compare/v1.11.0...v1.12.0
332
+ [1.11.0]: https://github.com/hughcbscrt/hive-am/compare/v1.10.1...v1.11.0
294
333
  [1.10.1]: https://github.com/hughcbscrt/hive-am/compare/v1.10.0...v1.10.1
295
334
  [1.10.0]: https://github.com/hughcbscrt/hive-am/compare/v1.9.1...v1.10.0
296
335
  [1.9.1]: https://github.com/hughcbscrt/hive-am/compare/v1.9.0...v1.9.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hive-am",
3
- "version": "1.10.1",
3
+ "version": "1.12.0",
4
4
  "description": "A colony of coding agents: orchestrators and subagents over Claude Code, OpenCode, Kiro and Cursor, with real session resume, a browser UI and Telegram.",
5
5
  "keywords": [
6
6
  "ai-agents",
package/server/dist/db.js CHANGED
@@ -245,8 +245,9 @@ export const agents = {
245
245
  allSessions: () => q(`SELECT s.*, a.name agent_name, a.role agent_role, f.name from_name FROM agent_sessions s JOIN agents a ON a.id=s.agent_id LEFT JOIN agents f ON f.id=s.from_id ORDER BY s.last_seen DESC`).all(),
246
246
  };
247
247
  // ---- dispatch log ----
248
- // A delegation is `queued` until the worker's turn starts (it waits behind the worker's other turns), then `running`, then `done`, `failed`
249
- // or `interrupted` (the server stopped while it was queued or running).
248
+ // A delegation is `queued` until the worker's turn starts (it waits behind the worker's other turns), then `running`, then `done`, `failed`,
249
+ // `interrupted` (the server stopped while it was queued or running) or `cancelled` (its orchestrator or the person took it back). Only a
250
+ // row still queued or running is closed, so the first of the end of the turn and a cancel sets the one final state.
250
251
  export const dispatches = {
251
252
  start(from, to, task, mode = 'sync', origin = null) {
252
253
  const id = uid();
@@ -256,16 +257,24 @@ export const dispatches = {
256
257
  get: (id) => q('SELECT * FROM dispatches WHERE id=?').get(id),
257
258
  setRunning: (id) => q("UPDATE dispatches SET status='running' WHERE id=? AND status='queued'").run(id),
258
259
  setSession: (id, sessionId) => q('UPDATE dispatches SET session_id=? WHERE id=?').run(sessionId, id),
259
- finish: (id, status, result) => q('UPDATE dispatches SET status=?, result=?, finished_at=? WHERE id=?').run(status, result.slice(0, 20000), now(), id),
260
+ finish: (id, status, result) => q("UPDATE dispatches SET status=?, result=?, finished_at=? WHERE id=? AND status IN ('queued','running')").run(status, result.slice(0, 20000), now(), id).changes > 0,
261
+ /** Cancels a task that has not finished; false if it already had. Cancelled by its orchestrator, it is never announced (the tool told it). */
262
+ cancel: (id, result, byOrchestrator) => {
263
+ const at = now();
264
+ return q("UPDATE dispatches SET status='cancelled', result=?, finished_at=?, notified_at=CASE WHEN ? THEN ? ELSE notified_at END WHERE id=? AND status IN ('queued','running')").run(result, at, byOrchestrator ? 1 : 0, at, id).changes > 0;
265
+ },
260
266
  markNotified: (id) => q('UPDATE dispatches SET notified_at=? WHERE id=? AND notified_at IS NULL').run(now(), id),
261
267
  markRead: (id) => q('UPDATE dispatches SET read_at=? WHERE id=? AND read_at IS NULL').run(now(), id),
262
268
  /** Background tasks of an orchestrator that have not finished yet. */
263
269
  unfinished: (from) => q("SELECT COUNT(*) AS n FROM dispatches WHERE from_id=? AND mode='background' AND status IN ('queued','running')").get(from).n,
270
+ /** Every task of an orchestrator that has not finished, oldest first. */
271
+ open: (from) => q("SELECT * FROM dispatches WHERE from_id=? AND status IN ('queued','running') ORDER BY created_at, id").all(from),
264
272
  /** Finished background tasks whose result was not handed to the orchestrator yet. */
265
273
  undelivered: () => q("SELECT * FROM dispatches WHERE mode='background' AND status NOT IN ('queued','running') AND notified_at IS NULL ORDER BY created_at").all(),
266
274
  /** At startup nothing is running any more: what was queued or running is interrupted (a task is never resumed). */
267
275
  interruptLeftovers: () => q("UPDATE dispatches SET status='interrupted', finished_at=? WHERE status IN ('queued','running')").run(now()).changes,
268
- recent: (limit = 50) => q('SELECT * FROM dispatches ORDER BY created_at DESC LIMIT ?').all(limit),
276
+ /** The newest rows, plus any older one that has not finished (so the interface can always show and cancel it). */
277
+ recent: (limit = 50) => q("SELECT * FROM (SELECT * FROM dispatches ORDER BY created_at DESC LIMIT ?) UNION SELECT * FROM dispatches WHERE status IN ('queued','running') ORDER BY created_at DESC").all(limit),
269
278
  };
270
279
  // Agents left 'running' by a crash/blackout are simply idle again: their session is intact on disk.
271
280
  q("UPDATE agents SET status='idle' WHERE status='running'").run();
@@ -1,6 +1,6 @@
1
1
  import { agents, dispatches } from './db.js';
2
2
  import { threads } from './connections/store.js';
3
- import { liveOrigin, notifyAgentsChanged, startDispatch } from './runtime.js';
3
+ import { abortDispatch, cancelQueued, liveOrigin, notifyAgentsChanged, startDispatch } from './runtime.js';
4
4
  import { deliverTo } from './wake.js';
5
5
  import { DomainError } from './errors.js';
6
6
  /**
@@ -15,7 +15,7 @@ export const MAX_BACKGROUND = 10; // unfinished background tasks per orchestrato
15
15
  export const MAX_WAIT_S = 60; // the longest one `dispatch_status` call waits
16
16
  const POLL_MS = 250;
17
17
  const RETRY_MS = Number(process.env.HIVE_AM_WAKE_RETRY_MS) || 5000; // wait before trying a delivery again (as wake-ups do)
18
- const FINISHED = new Set(['done', 'failed', 'interrupted']);
18
+ const FINISHED = new Set(['done', 'failed', 'interrupted', 'cancelled']);
19
19
  const NO_TEXT = '(the subagent finished without a text answer)';
20
20
  const nameOf = (id) => agents.get(id)?.name ?? 'a removed agent';
21
21
  /** Starts a delegation without waiting for it. In the background, its end is announced to the orchestrator; a synchronous one is polled. */
@@ -74,6 +74,40 @@ export async function taskStatus(fromId, id, waitSeconds) {
74
74
  out.note = `wait_seconds is capped at ${MAX_WAIT_S}: waited ${MAX_WAIT_S} s at most.`;
75
75
  return out;
76
76
  }
77
+ const CANCELLED_BY = { orchestrator: 'Cancelled by the orchestrator before it finished.', person: 'Cancelled by the person before it finished.' };
78
+ /**
79
+ * Takes a delegated task back. A queued one leaves the worker's queue and never starts; a running one has its turn aborted (only that turn).
80
+ * One already finished is left as it is and its state is answered, so cancelling twice is harmless. The row is closed by the same conditional
81
+ * update as the end of the turn, so whichever comes first sets the one final state. `by` is the orchestrator that started it, or null for the
82
+ * person (from the web), who may cancel any task; cancelled by the person, the orchestrator is told once.
83
+ */
84
+ export function cancelTask(by, id) {
85
+ if (!id)
86
+ throw new DispatchError('id is required');
87
+ const row = dispatches.get(id);
88
+ if (!row)
89
+ throw new DispatchError(`There is no delegated task with id "${id}".`, 'not_found');
90
+ if (by !== null && row.from_id !== by)
91
+ throw new DispatchError(`Task "${id}" was not started by you.`, 'forbidden');
92
+ const was = row.status;
93
+ const cancelled = !FINISHED.has(was) && dispatches.cancel(id, by === null ? CANCELLED_BY.person : CANCELLED_BY.orchestrator, by !== null);
94
+ if (cancelled) {
95
+ if (was === 'queued')
96
+ cancelQueued(row.to_id, id);
97
+ else
98
+ abortDispatch(row.to_id, id);
99
+ notifyAgentsChanged();
100
+ // At once, not when the turn ends: a queued task only settles when the worker's queue reaches it.
101
+ if (by === null)
102
+ void announce(id);
103
+ }
104
+ const now = dispatches.get(id) ?? row;
105
+ return { id, agent: nameOf(now.to_id), status: now.status, mode: now.mode, was, cancelled, ...(FINISHED.has(now.status) ? { result: resultText(now) } : {}) };
106
+ }
107
+ /** The tasks of an orchestrator that have not finished, oldest first, so it can tell which one to cancel. */
108
+ export function openTasks(fromId) {
109
+ return dispatches.open(fromId).map((r) => ({ id: r.id, agent: nameOf(r.to_id), status: r.status, mode: r.mode, task: r.task.length > 200 ? `${r.task.slice(0, 200)}…` : r.task, age_s: Math.max(0, Math.round((Date.now() - r.created_at) / 1000)) }));
110
+ }
77
111
  /** What a finished task says: its answer, its error, or why it stopped. */
78
112
  function resultText(row) {
79
113
  if (row.status === 'interrupted')
@@ -85,12 +119,26 @@ function resultText(row) {
85
119
  const noticeText = (row) => `Delegated task ${row.id} to ${nameOf(row.to_id)} finished (${row.status}). Result:\n${resultText(row)}`;
86
120
  const NEXT = 'Carry on with the work this task was part of and tell the person what came of it';
87
121
  const timers = new Map();
122
+ const delivering = new Set();
88
123
  /** Hands the result of a finished background task to the orchestrator, once. Tried again while the platform is not connected yet. */
89
124
  async function announce(id, tries = 0) {
125
+ // A second call while the first is still delivering (a cancel, then the end of the aborted turn) or waiting to try again does nothing.
126
+ if (delivering.has(id) || (!tries && timers.has(id)))
127
+ return;
90
128
  timers.delete(id);
91
129
  const row = dispatches.get(id);
92
130
  if (!row || row.mode !== 'background' || row.notified_at || !FINISHED.has(row.status))
93
131
  return;
132
+ delivering.add(id);
133
+ try {
134
+ await deliver(row, tries);
135
+ }
136
+ finally {
137
+ delivering.delete(id);
138
+ }
139
+ }
140
+ async function deliver(row, tries) {
141
+ const id = row.id;
94
142
  const o = row.origin ? JSON.parse(row.origin) : null;
95
143
  const to = { agent_id: row.from_id, connection_id: o?.connection_id ?? null, thread_id: o?.thread_id ?? null, req_id: o?.req_id ?? null, req_name: o?.req_name ?? null };
96
144
  const again = () => {
@@ -8,8 +8,9 @@ route('GET', '/api/fs/dirs', ({ url }) => {
8
8
  const p = resolve(url.searchParams.get('path') || homedir());
9
9
  if (!existsSync(p) || !statSync(p).isDirectory())
10
10
  throw bad('Not a folder');
11
- // `files=yml,yaml` also lists the files with those extensions (to pick a file, e.g. a compose file).
12
- const exts = (url.searchParams.get('files') ?? '').split(',').map((x) => x.trim().toLowerCase().replace(/^\./, '')).filter((x) => /^[a-z0-9]{1,8}$/.test(x));
11
+ // `files=yml,yaml` also lists the files with those extensions (to pick a file, e.g. a compose file). A name with no dot is its own
12
+ // extension, so `files=dockerfile,containerfile` lists `Dockerfile`, `Containerfile` and `web.Dockerfile`.
13
+ const exts = (url.searchParams.get('files') ?? '').split(',').map((x) => x.trim().toLowerCase().replace(/^\./, '')).filter((x) => /^[a-z0-9]{1,16}$/.test(x));
13
14
  let dirs = [], files = [];
14
15
  try {
15
16
  const all = readdirSync(p, { withFileTypes: true });
@@ -1,5 +1,5 @@
1
1
  import { agents, dispatches } from '../../db.js';
2
- import { startTask, taskStatus } from '../../dispatches.js';
2
+ import { cancelTask, openTasks, startTask, taskStatus } from '../../dispatches.js';
3
3
  import { dispatch, liveTurn, notifyAgentsChanged } from '../../runtime.js';
4
4
  import { agentOrFail } from '../../services/agents.js';
5
5
  import { body } from '../body.js';
@@ -40,4 +40,19 @@ route('POST', '/api/dispatch/status', async ({ req }) => {
40
40
  throw bad('from is required');
41
41
  return taskStatus(text(from), text(id), wait_seconds);
42
42
  });
43
+ // Cancelling answers the final state: `cancelled`, or the one the task already had (it is not an error).
44
+ route('POST', '/api/dispatch/cancel', async ({ req }) => {
45
+ const { from, id } = await body(req);
46
+ if (!from)
47
+ throw bad('from is required');
48
+ return cancelTask(text(from), text(id));
49
+ });
50
+ route('POST', '/api/dispatch/list', async ({ req }) => {
51
+ const { from } = await body(req);
52
+ if (!from)
53
+ throw bad('from is required');
54
+ return openTasks(text(from));
55
+ });
43
56
  route('GET', '/api/dispatches', () => dispatches.recent());
57
+ // From the web: the person can cancel any task, as they can stop any agent.
58
+ route('POST', '/api/dispatches/:id/cancel', ({ params }) => cancelTask(null, params[0]));
@@ -1,9 +1,11 @@
1
1
  import { notifyAgentsChanged } from '../../runtime.js';
2
2
  import { DRIVER_PROPS } from '../../objects/driver-props.js';
3
- import { composeServices, listContainers, listImages } from '../../objects/docker-info.js';
3
+ import { composeServices, inspectMachine, listContainers, listImages, listNetworks, listVolumes } from '../../objects/docker-info.js';
4
+ import { buildImage, createMade, removeMade } from '../../objects/docker.js';
5
+ import { copySource, readSource, writeSource } from '../../objects/files.js';
4
6
  import { agentObjectAction, agentObjectLogs, agentObjects } from '../../objects/agent.js';
5
7
  import { agentDb } from '../../objects/agent-db.js';
6
- import { dbColumnsOf, dbHistory, dbHistoryClear, dbRun, dbSavedAdd, dbSavedList, dbSavedRemove, dbSavedUpdate, dbSchemaOf, dbTestConfig, createObject, statsOf, httpDescribe, httpSession, httpSessionClear, httpSessionSet, httpInspect, httpExport, httpFileRead, httpFileWrite, httpFileCreate, httpRun, httpHistory, httpDocs, httpHistoryClear, httpScan, listObjects, setObjectManager, setAgentObjects, objectAction, objectServiceAction, listTemplates, createFromTemplate, objectInspect, objectLogs, removeObject, updateObject } from '../../objects/index.js';
8
+ import { dbColumnsOf, dbHistory, dbHistoryClear, dbRun, dbSavedAdd, dbSavedList, dbSavedRemove, dbSavedUpdate, dbSchemaOf, dbTestConfig, createObject, statsOf, httpDescribe, httpSession, httpSessionClear, httpSessionSet, httpInspect, httpExport, httpFileRead, httpFileWrite, httpFileCreate, httpRun, httpHistory, httpDocs, httpHistoryClear, httpScan, listObjects, setObjectManager, setAgentObjects, objectAction, objectServiceAction, composeFileCopy, composeFileRead, composeFileWrite, objectApply, objectInspect, objectManagedCopy, objectPlan, objectInspectRaw, objectLogs, removeObject, updateObject } from '../../objects/index.js';
7
9
  import { body } from '../body.js';
8
10
  import { notFound } from '../errors.js';
9
11
  import { requireLocal } from '../guards.js';
@@ -28,18 +30,43 @@ write('POST', '/api/objects', (b) => createObject(b));
28
30
  write('PATCH', '/api/objects/:id', (b, { params }) => updateObject(params[0], b));
29
31
  route('DELETE', '/api/objects/:id', async ({ req, params }) => { local(req); if (!(await removeObject(params[0])))
30
32
  throw notFound('Object not found'); return { ok: true }; });
31
- route('GET', '/api/object-templates', () => listTemplates());
32
33
  write('PUT', '/api/objects/:id/manager', (b, { params }) => { const r = setObjectManager(params[0], b.agent_id); if (r.skillAdded)
33
34
  notifyAgentsChanged(); return r; });
34
35
  write('PUT', '/api/agents/:id/resources', (b, { params }) => { const r = setAgentObjects(params[0], b.object_ids); if (r.skillAdded)
35
36
  notifyAgentsChanged(); return r; });
36
- write('POST', '/api/objects/from-template', (b) => createFromTemplate(b));
37
+ // Reads, open like the other reads of resources (the app is also reached by other names on the network). Secret-looking variables and the
38
+ // passwords of URLs come hidden in the raw one unless `reveal=1`.
37
39
  route('GET', '/api/objects/:id/inspect', ({ params, url }) => objectInspect(params[0], p(url, 'service')));
40
+ route('GET', '/api/objects/:id/inspect/raw', ({ params, url }) => objectInspectRaw(params[0], p(url, 'service'), url.searchParams.get('reveal') === '1'));
41
+ // The whole `docker inspect` of anything on this machine (container, image, volume, network).
42
+ route('GET', '/api/docker/inspect', ({ url }) => inspectMachine(url.searchParams.get('kind') ?? '', url.searchParams.get('ref') ?? '', url.searchParams.get('reveal') === '1'));
38
43
  write('POST', '/api/objects/:id/service', (b, { params }) => objectServiceAction(params[0], b.service, b.action));
39
44
  write('POST', '/api/objects/:id/action', (b, { params }) => objectAction(params[0], b.action));
40
- // What Docker knows, to suggest in the forms (containers, images, the services of a compose file).
45
+ // Changing a container hive-am created (local app only): what applying the settings in the form would do (Docker only read), then applying
46
+ // the saved ones (recreating needs `confirm: true`). An existing container is never changed: it is read to fill the form of a managed copy.
47
+ write('POST', '/api/objects/:id/docker/plan', (b, { params }) => objectPlan(params[0], b.config));
48
+ write('POST', '/api/objects/:id/docker/apply', (b, { params }) => objectApply(params[0], b));
49
+ guarded('/api/objects/:id/docker/managed-copy', ({ params }) => objectManagedCopy(params[0]));
50
+ // What Docker knows, to suggest in the forms (containers, images, volumes, networks, the services of a compose file).
41
51
  route('GET', '/api/docker/containers', () => listContainers());
42
52
  route('GET', '/api/docker/images', () => listImages());
53
+ route('GET', '/api/docker/volumes', () => listVolumes());
54
+ route('GET', '/api/docker/networks', () => listNetworks());
55
+ // What the Docker form creates when asked (local app only): a network, a volume, an image from a Dockerfile. A network or a volume is
56
+ // removed only if hive-am created it and no container uses it (checked in objects/docker.ts, against Docker).
57
+ write('POST', '/api/docker/networks', (b) => createMade('network', b.name));
58
+ write('POST', '/api/docker/volumes', (b) => createMade('volume', b.name));
59
+ guarded('/api/docker/networks/:name', ({ params }) => removeMade('network', params[0]), 'DELETE');
60
+ guarded('/api/docker/volumes/:name', ({ params }) => removeMade('volume', params[0]), 'DELETE');
61
+ write('POST', '/api/docker/images/build', (b) => buildImage(b));
62
+ // Editing the compose file of a resource (its path is the saved one) and a Dockerfile (path from the page, checked by name in objects/files.ts),
63
+ // in place or as a copy next to it. They are the person's own files, which may hold secrets: local app only, reading included. A stale etag is 409.
64
+ guarded('/api/objects/:id/docker/file', ({ params }) => composeFileRead(params[0]));
65
+ write('PUT', '/api/objects/:id/docker/file', (b, { params }) => composeFileWrite(params[0], b.content, b.etag));
66
+ write('POST', '/api/objects/:id/docker/file/copy', (b, { params }) => composeFileCopy(params[0], b));
67
+ guarded('/api/docker/dockerfile', ({ url }) => readSource('dockerfile', url.searchParams.get('path') ?? ''));
68
+ write('PUT', '/api/docker/dockerfile', (b) => writeSource('dockerfile', b.path, b.content, b.etag));
69
+ write('POST', '/api/docker/dockerfile/copy', (b) => copySource('dockerfile', b.path, b.name, b.content, b.overwrite === true));
43
70
  route('GET', '/api/docker/compose-services', ({ url }) => composeServices(url.searchParams.get('file') ?? ''));
44
71
  route('GET', '/api/objects/:id/stats', ({ params }) => statsOf(params[0]));
45
72
  route('GET', '/api/objects/:id/http', ({ params }) => httpScan(params[0]));
@@ -78,7 +78,7 @@ ${lazy.map((s) => `- **${s.name}**${s.description ? `: ${s.description}` : ''}`)
78
78
  const team = a.worker_ids.map((wid) => agents.get(wid)).filter(Boolean);
79
79
  if (team.length) {
80
80
  const roster = team.map((w) => `- **${w.name}**${w.description ? `: ${w.description}` : ''}`).join('\n');
81
- parts.push(`## Your team\nThese are your subagents — the ONLY agents you can delegate to:\n${roster}\n\nDelegate with the \`dispatch\` tool (${tn('dispatch')}) instead of doing their work yourself, then synthesize their answers. Always use it: never play a subagent's role yourself, and never use a generic built-in subagent in its place — a subagent is a separate real agent with its own session. Each delegation starts a fresh conversation for that subagent, so put all the context it needs in the task. \`dispatch\` returns at once with a task id, so you can answer the person or delegate more meanwhile, and the result reaches you as a message when the subagent finishes (you can also ask with the \`dispatch_status\` tool (${tn('dispatch_status')})). Use \`background: false\` only for a very short task whose answer you need right now: the call then waits for it. Your team can change at any time: when asked which agents you have or can delegate to, call the \`list_agents\` tool (${tn('list_agents')}) and report exactly what it returns, never an older list from memory. Never mention or try to use agents outside that list.`);
81
+ parts.push(`## Your team\nThese are your subagents — the ONLY agents you can delegate to:\n${roster}\n\nDelegate with the \`dispatch\` tool (${tn('dispatch')}) instead of doing their work yourself, then synthesize their answers. Always use it: never play a subagent's role yourself, and never use a generic built-in subagent in its place — a subagent is a separate real agent with its own session. Each delegation starts a fresh conversation for that subagent, so put all the context it needs in the task. \`dispatch\` returns at once with a task id, so you can answer the person or delegate more meanwhile, and the result reaches you as a message when the subagent finishes (you can also ask with the \`dispatch_status\` tool (${tn('dispatch_status')})). Use \`background: false\` only for a very short task whose answer you need right now: the call then waits for it. To take back a task that is no longer needed (sent by mistake, with the wrong context, or the plan changed), call the \`dispatch_cancel\` tool (${tn('dispatch_cancel')}) with its id; the \`dispatch_list\` tool (${tn('dispatch_list')}) lists your tasks that have not finished. What a cancelled subagent already changed stays in the folder, so check it before delegating again. Your team can change at any time: when asked which agents you have or can delegate to, call the \`list_agents\` tool (${tn('list_agents')}) and report exactly what it returns, never an older list from memory. Never mention or try to use agents outside that list.`);
82
82
  }
83
83
  else {
84
84
  parts.push('## Your team\nYou currently have no subagents connected to you, so you cannot delegate. If asked, say so; do not claim to know other agents.');
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { basename, dirname } from 'node:path';
3
3
  import { execCapture } from './exec.js';
4
- import { ObjectError } from './model.js';
4
+ import { CONTAINER, IMAGE, ObjectError } from './model.js';
5
5
  /**
6
- * What Docker knows, to suggest in the forms: the containers on this machine, the images, and the services of a compose file. Read-only;
7
- * when Docker is not available the lists are empty and say why.
6
+ * What Docker knows, to suggest in the forms: the containers on this machine, the images, volumes and networks (each with how many
7
+ * containers use it), and the services of a compose file. Read-only; when Docker is not available the lists are empty and say why.
8
8
  */
9
9
  const run = async (args, o = {}) => {
10
10
  const r = await execCapture('docker', args, { cwd: o.cwd, timeout: o.timeout ?? 15_000, env: { ...process.env, DOCKER_CLI_HINTS: 'false' } });
@@ -16,20 +16,81 @@ const lines = (out) => out.trim().split('\n').flatMap((l) => { try {
16
16
  catch {
17
17
  return [];
18
18
  } });
19
- export async function listContainers() {
20
- const r = await run(['ps', '-a', '--format', '{{json .}}']);
19
+ /** Docker is installed but its daemon does not answer (stopped, or no permission on its socket). */
20
+ export const NO_DAEMON = /Cannot connect to the Docker daemon|Is the docker daemon running|error during connect|permission denied while trying to connect/i;
21
+ /** Variable names whose value is hidden unless asked for (the same pattern the interface uses for the variables it shows). */
22
+ export const SECRET_NAME = /pass|secret|token|api[_-]?key|auth|credential|private/i;
23
+ export const MASK = '••••••••';
24
+ /** The password of a URL with credentials (`scheme://user:password@host`), wherever it sits in a value. */
25
+ const URL_PASSWORD = /([a-z][a-z0-9+.-]*:\/\/[^\s/:@]*:)[^\s/@]+@/gi;
26
+ /** A `NAME=value` variable with its secret hidden: the whole value when the name looks like a secret, else the password of any URL in it. */
27
+ export function maskVar(item) {
28
+ const i = item.indexOf('=');
29
+ if (i < 0)
30
+ return item;
31
+ if (SECRET_NAME.test(item.slice(0, i)))
32
+ return `${item.slice(0, i + 1)}${MASK}`;
33
+ return item.slice(0, i + 1) + item.slice(i + 1).replace(URL_PASSWORD, `$1${MASK}@`);
34
+ }
35
+ const failed = (r) => r.err.trim().split('\n').pop()?.slice(0, 200);
36
+ const list = (v) => String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean);
37
+ const labelled = (labels, re) => re.test(String(labels ?? ''));
38
+ /** One label of `docker ps` (`k=v,k=v`); a value with a comma is cut there, which these labels (ids and names) never have. */
39
+ const label = (labels, key) => new RegExp(`(?:^|,)${key.replace(/\./g, '\\.')}=([^,]*)`).exec(String(labels ?? ''))?.[1] || undefined;
40
+ const BUILTIN = ['bridge', 'host', 'none'];
41
+ let reading = null;
42
+ /**
43
+ * The containers of this machine. The form asks for containers, images, volumes and networks at once, and the last three count who uses
44
+ * them: while one `docker ps` is running, every other caller gets its answer instead of starting another one.
45
+ */
46
+ export function listContainers() {
47
+ reading ??= readContainers().finally(() => { reading = null; });
48
+ return reading;
49
+ }
50
+ async function readContainers() {
51
+ // --no-trunc: without it Docker cuts the mounts to a few characters ("/home/you/Doc…").
52
+ const r = await run(['ps', '-a', '--no-trunc', '--format', '{{json .}}']);
21
53
  if (r.code !== 0)
22
- return { available: false, error: r.err.trim().split('\n').pop()?.slice(0, 200), containers: [] };
23
- const containers = lines(r.out).map((j) => ({ id: String(j.ID), name: String(j.Names), image: String(j.Image), state: String(j.State), status: String(j.Status), ports: String(j.Ports ?? ''), managed: /(^|,)hive-am\.object=/.test(String(j.Labels ?? '')) }));
24
- return { available: true, containers: containers.sort((a, b) => a.name.localeCompare(b.name)) };
54
+ return { available: false, error: failed(r), containers: [] };
55
+ const all = lines(r.out).map((j) => ({
56
+ id: String(j.ID).slice(0, 12), name: String(j.Names), image: String(j.Image), state: String(j.State), status: String(j.Status), ports: String(j.Ports ?? ''),
57
+ managed: labelled(j.Labels, /(^|,)hive-am\.object=/), mounts: list(j.Mounts), networks: list(j.Networks),
58
+ object: label(j.Labels, 'hive-am.object'), project: label(j.Labels, 'com.docker.compose.project'), service: label(j.Labels, 'com.docker.compose.service'),
59
+ }));
60
+ return { available: true, containers: all.sort((a, b) => a.name.localeCompare(b.name)) };
25
61
  }
62
+ /** `nginx` and `nginx:latest` are the same image for counting who uses it. */
63
+ const sameImage = (ref, image) => ref === image || ref === `${image}:latest`;
26
64
  export async function listImages() {
27
- const r = await run(['images', '--format', '{{json .}}']);
65
+ const [r, c] = await Promise.all([run(['images', '--format', '{{json .}}']), listContainers()]);
28
66
  if (r.code !== 0)
29
- return { available: false, error: r.err.trim().split('\n').pop()?.slice(0, 200), images: [] };
30
- const images = lines(r.out).filter((j) => j.Repository && j.Repository !== '<none>').map((j) => ({ ref: j.Tag && j.Tag !== '<none>' ? `${j.Repository}:${j.Tag}` : String(j.Repository), id: String(j.ID), size: String(j.Size ?? ''), created: String(j.CreatedSince ?? '') }));
67
+ return { available: false, error: failed(r), images: [] };
68
+ const images = lines(r.out).filter((j) => j.Repository && j.Repository !== '<none>').map((j) => {
69
+ const ref = j.Tag && j.Tag !== '<none>' ? `${j.Repository}:${j.Tag}` : String(j.Repository);
70
+ return { ref, id: String(j.ID), size: String(j.Size ?? ''), created: String(j.CreatedSince ?? ''), inUse: c.containers.filter((x) => sameImage(ref, x.image)).length };
71
+ });
31
72
  return { available: true, images: [...new Map(images.map((i) => [i.ref, i])).values()].sort((a, b) => a.ref.localeCompare(b.ref)) };
32
73
  }
74
+ export async function listVolumes() {
75
+ const [r, c] = await Promise.all([run(['volume', 'ls', '--format', '{{json .}}']), listContainers()]);
76
+ if (r.code !== 0)
77
+ return { available: false, error: failed(r), volumes: [] };
78
+ const volumes = lines(r.out).map((j) => {
79
+ const name = String(j.Name);
80
+ return { name, driver: String(j.Driver ?? ''), anonymous: /^[0-9a-f]{64}$/.test(name) || labelled(j.Labels, /(^|,)com\.docker\.volume\.anonymous=/), managed: labelled(j.Labels, /(^|,)hive-am\.created=1(,|$)/), inUse: c.containers.filter((x) => x.mounts.includes(name)).length };
81
+ });
82
+ return { available: true, volumes: volumes.sort((a, b) => Number(a.anonymous) - Number(b.anonymous) || a.name.localeCompare(b.name)) };
83
+ }
84
+ export async function listNetworks() {
85
+ const [r, c] = await Promise.all([run(['network', 'ls', '--format', '{{json .}}']), listContainers()]);
86
+ if (r.code !== 0)
87
+ return { available: false, error: failed(r), networks: [] };
88
+ const networks = lines(r.out).map((j) => {
89
+ const name = String(j.Name);
90
+ return { name, id: String(j.ID), driver: String(j.Driver ?? ''), scope: String(j.Scope ?? ''), builtin: BUILTIN.includes(name), managed: labelled(j.Labels, /(^|,)hive-am\.created=1(,|$)/), inUse: c.containers.filter((x) => x.networks.includes(name)).length };
91
+ });
92
+ return { available: true, networks: networks.sort((a, b) => Number(b.builtin) - Number(a.builtin) || a.name.localeCompare(b.name)) };
93
+ }
33
94
  /** Without Docker's help: the keys under a top-level `services:` (enough for ordinary compose files). */
34
95
  function servicesByHand(text) {
35
96
  const out = [];
@@ -73,3 +134,49 @@ export async function composeServices(file) {
73
134
  catch { /* unreadable */ }
74
135
  return { services: servicesByHand(text).sort(), project, source: 'file', error: r.err.trim().split('\n').pop()?.slice(0, 200) };
75
136
  }
137
+ const KINDS = ['container', 'image', 'volume', 'network'];
138
+ /** The `NAME=value` items of `Config.Env` (and the older `ContainerConfig.Env` of an image) with their secrets hidden (`maskVar`). */
139
+ function maskEnv(items) {
140
+ let n = 0;
141
+ for (const it of items)
142
+ for (const cfg of [it?.Config, it?.ContainerConfig]) {
143
+ if (!Array.isArray(cfg?.Env))
144
+ continue;
145
+ cfg.Env = cfg.Env.map((e) => {
146
+ const s = String(e), m = maskVar(s);
147
+ if (m === s)
148
+ return e;
149
+ n++;
150
+ return m;
151
+ });
152
+ }
153
+ return n;
154
+ }
155
+ /**
156
+ * The whole `docker inspect` of a container, an image, a volume or a network, as indented JSON. The values of the variables whose name
157
+ * looks like a secret, and the passwords of URLs in the others, are hidden unless `reveal` (other fields, like labels or the command, can
158
+ * carry secrets too and are not touched).
159
+ * `available: false` when Docker is not installed or does not answer.
160
+ */
161
+ export async function inspectMachine(kind, ref, reveal = false) {
162
+ if (!KINDS.includes(kind))
163
+ throw new ObjectError('Say what to inspect: a container, an image, a volume or a network');
164
+ if (!(kind === 'image' ? IMAGE : CONTAINER).test(ref))
165
+ throw new ObjectError(`That is not a valid ${kind} name`);
166
+ const r = await run([kind, 'inspect', ref], { timeout: 20_000 });
167
+ if (r.code !== 0) {
168
+ const error = r.err.trim().split('\n').pop()?.slice(0, 200) || `docker exited with ${r.code}`;
169
+ if (/not installed/.test(r.err) || NO_DAEMON.test(r.err))
170
+ return { available: false, error, text: '', masked: 0 };
171
+ throw new ObjectError(`Docker has no ${kind} ${ref}`);
172
+ }
173
+ let items;
174
+ try {
175
+ items = JSON.parse(r.out);
176
+ }
177
+ catch {
178
+ throw new ObjectError(`docker ${kind} inspect did not return JSON`);
179
+ }
180
+ const masked = reveal ? 0 : maskEnv(items);
181
+ return { available: true, text: JSON.stringify(items, null, 2), masked };
182
+ }