hive-am 1.3.0 → 1.5.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 (164) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +20 -5
  3. package/bin/hive-am.mjs +242 -31
  4. package/bin/run.mjs +135 -0
  5. package/package.json +1 -1
  6. package/server/dist/connections/manager.js +15 -3
  7. package/server/dist/connections/router.js +2 -2
  8. package/server/dist/connections/secrets.js +8 -0
  9. package/server/dist/db/migrations.js +68 -0
  10. package/server/dist/db.js +11 -10
  11. package/server/dist/efforts.js +105 -0
  12. package/server/dist/http/routes/agents.js +4 -3
  13. package/server/dist/http/routes/meta.js +12 -0
  14. package/server/dist/http/routes/notebook.js +10 -2
  15. package/server/dist/http/routes/resources.js +3 -0
  16. package/server/dist/http/routes/types.js +18 -1
  17. package/server/dist/http/validate.js +5 -2
  18. package/server/dist/index.js +4 -1
  19. package/server/dist/instructions.js +10 -2
  20. package/server/dist/objects/agent-db.js +258 -0
  21. package/server/dist/objects/database.js +496 -34
  22. package/server/dist/objects/index.js +20 -14
  23. package/server/dist/objects/runner.js +20 -0
  24. package/server/dist/objects/supervisor.js +2 -2
  25. package/server/dist/providers/kiro.js +7 -1
  26. package/server/dist/providers/opencode-server.js +11 -1
  27. package/server/dist/providers/opencode.js +4 -3
  28. package/server/dist/providers/spawn.js +38 -4
  29. package/server/dist/retention.js +50 -0
  30. package/server/dist/runtime.js +52 -21
  31. package/server/dist/services/agents.js +53 -1
  32. package/server/dist/services/notebook.js +7 -3
  33. package/server/dist/skills/defaults.js +35 -1
  34. package/server/dist/skills/notebook.js +57 -13
  35. package/server/mcp/dispatch.mjs +68 -0
  36. package/web/.next/BUILD_ID +1 -1
  37. package/web/.next/app-build-manifest.json +79 -70
  38. package/web/.next/app-path-routes-manifest.json +9 -8
  39. package/web/.next/build-manifest.json +2 -2
  40. package/web/.next/prerender-manifest.json +71 -47
  41. package/web/.next/routes-manifest.json +6 -0
  42. package/web/.next/server/app/_not-found/page.js +1 -1
  43. package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  44. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  45. package/web/.next/server/app/_not-found.html +1 -1
  46. package/web/.next/server/app/_not-found.rsc +3 -3
  47. package/web/.next/server/app/agents/[id]/page.js +4 -4
  48. package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
  49. package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
  50. package/web/.next/server/app/agents/page.js +2 -2
  51. package/web/.next/server/app/agents/page.js.nft.json +1 -1
  52. package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
  53. package/web/.next/server/app/agents.html +1 -1
  54. package/web/.next/server/app/agents.rsc +4 -4
  55. package/web/.next/server/app/chat/page.js +2 -0
  56. package/web/.next/server/app/chat/page.js.nft.json +1 -0
  57. package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -0
  58. package/web/.next/server/app/chat.html +1 -0
  59. package/web/.next/server/app/chat.meta +15 -0
  60. package/web/.next/server/app/chat.rsc +25 -0
  61. package/web/.next/server/app/connections/page.js +1 -1
  62. package/web/.next/server/app/connections/page.js.nft.json +1 -1
  63. package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
  64. package/web/.next/server/app/connections.html +1 -1
  65. package/web/.next/server/app/connections.rsc +4 -4
  66. package/web/.next/server/app/index.html +1 -1
  67. package/web/.next/server/app/index.rsc +4 -4
  68. package/web/.next/server/app/page.js +1 -1
  69. package/web/.next/server/app/page.js.nft.json +1 -1
  70. package/web/.next/server/app/page_client-reference-manifest.js +1 -1
  71. package/web/.next/server/app/relations/page.js +2 -2
  72. package/web/.next/server/app/relations/page.js.nft.json +1 -1
  73. package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
  74. package/web/.next/server/app/relations.html +1 -1
  75. package/web/.next/server/app/relations.rsc +4 -4
  76. package/web/.next/server/app/resources/[id]/page.js +2 -2
  77. package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -1
  78. package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
  79. package/web/.next/server/app/resources/page.js +2 -2
  80. package/web/.next/server/app/resources/page.js.nft.json +1 -1
  81. package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
  82. package/web/.next/server/app/resources.html +1 -1
  83. package/web/.next/server/app/resources.rsc +4 -4
  84. package/web/.next/server/app/schedules/page.js +1 -1
  85. package/web/.next/server/app/schedules/page.js.nft.json +1 -1
  86. package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  87. package/web/.next/server/app/schedules.html +1 -1
  88. package/web/.next/server/app/schedules.rsc +4 -4
  89. package/web/.next/server/app/sessions/page.js +2 -2
  90. package/web/.next/server/app/sessions/page.js.nft.json +1 -1
  91. package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  92. package/web/.next/server/app/sessions.html +1 -1
  93. package/web/.next/server/app/sessions.rsc +4 -4
  94. package/web/.next/server/app/skills/page.js +1 -1
  95. package/web/.next/server/app/skills/page.js.nft.json +1 -1
  96. package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
  97. package/web/.next/server/app/skills.html +1 -1
  98. package/web/.next/server/app/skills.rsc +4 -4
  99. package/web/.next/server/app/types/page.js +1 -1
  100. package/web/.next/server/app/types/page.js.nft.json +1 -1
  101. package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
  102. package/web/.next/server/app/types.html +1 -1
  103. package/web/.next/server/app/types.rsc +4 -4
  104. package/web/.next/server/app-paths-manifest.json +9 -8
  105. package/web/.next/server/chunks/143.js +1 -1
  106. package/web/.next/server/chunks/148.js +1 -0
  107. package/web/.next/server/chunks/213.js +6 -0
  108. package/web/.next/server/chunks/274.js +1 -1
  109. package/web/.next/server/chunks/368.js +1 -0
  110. package/web/.next/server/chunks/403.js +1 -0
  111. package/web/.next/server/chunks/{790.js → 435.js} +2 -2
  112. package/web/.next/server/chunks/505.js +1 -6
  113. package/web/.next/server/chunks/612.js +1 -1
  114. package/web/.next/server/pages/404.html +1 -1
  115. package/web/.next/server/pages/500.html +1 -1
  116. package/web/.next/server/server-reference-manifest.json +1 -1
  117. package/web/.next/static/UDA414PFPu9ZROltNmjbX/_buildManifest.js +1 -0
  118. package/web/.next/static/chunks/{316-26da9cc233ba7557.js → 107-77dd87cc2ca4f941.js} +1 -1
  119. package/web/.next/static/chunks/281-30e32d932b8b5a75.js +1 -0
  120. package/web/.next/static/chunks/391-18768499e741112e.js +1 -0
  121. package/web/.next/static/chunks/517-b3b1c9044d2ef23c.js +1 -0
  122. package/web/.next/static/chunks/587-5b511f5f93910180.js +1 -0
  123. package/web/.next/static/chunks/606-14fb5aaa4d0ff22d.js +1 -0
  124. package/web/.next/static/chunks/656-f3cc78328603241a.js +1 -0
  125. package/web/.next/static/chunks/{705-780e48e55b0346bd.js → 705-ce86175dea00e6a9.js} +1 -1
  126. package/web/.next/static/chunks/757-03361e7a97a605a2.js +1 -0
  127. package/web/.next/static/chunks/app/agents/[id]/page-638e12ba3cb813c3.js +1 -0
  128. package/web/.next/static/chunks/app/agents/page-9da499db6d22731f.js +1 -0
  129. package/web/.next/static/chunks/app/chat/page-383bc24e72dab7fa.js +1 -0
  130. package/web/.next/static/chunks/app/connections/{page-eb11cfb1ffeab3ca.js → page-06bd2d7d9495126a.js} +1 -1
  131. package/web/.next/static/chunks/app/layout-fad968a635f9a4b3.js +1 -0
  132. package/web/.next/static/chunks/app/page-0062ebc30322b1ae.js +1 -0
  133. package/web/.next/static/chunks/app/relations/page-02710fe1f8a670a9.js +1 -0
  134. package/web/.next/static/chunks/app/resources/[id]/{page-bf05836f97248ca7.js → page-fc494e85b01e8cb7.js} +1 -1
  135. package/web/.next/static/chunks/app/resources/{page-800f774e5cacf4c9.js → page-61cccd8fdcc2f6c8.js} +1 -1
  136. package/web/.next/static/chunks/app/sessions/page-85638705fdc18174.js +1 -0
  137. package/web/.next/static/chunks/app/types/page-8e25f503bdcd72ad.js +1 -0
  138. package/web/.next/static/css/dfcaa19a8d46e60d.css +1 -0
  139. package/web/.next/trace +30 -29
  140. package/web/.next/types/app/chat/page.ts +84 -0
  141. package/web/.next/types/routes.d.ts +2 -1
  142. package/web/.next/types/validator.ts +9 -0
  143. package/web/.next/server/chunks/306.js +0 -1
  144. package/web/.next/server/chunks/451.js +0 -1
  145. package/web/.next/server/chunks/638.js +0 -1
  146. package/web/.next/server/chunks/802.js +0 -1
  147. package/web/.next/static/aSYzbHk1M5YeS_d3SQKTZ/_buildManifest.js +0 -1
  148. package/web/.next/static/chunks/281-d8a0dcfaa6d064e4.js +0 -1
  149. package/web/.next/static/chunks/291-23f314877fa39054.js +0 -1
  150. package/web/.next/static/chunks/304-681348099f874a30.js +0 -1
  151. package/web/.next/static/chunks/491-28f093772bd460ac.js +0 -1
  152. package/web/.next/static/chunks/517-373a80c8ba432bdc.js +0 -1
  153. package/web/.next/static/chunks/562-7c2e33d838e869bf.js +0 -1
  154. package/web/.next/static/chunks/581-3ad5c4343dad3f50.js +0 -1
  155. package/web/.next/static/chunks/844-519177e6198ca84f.js +0 -1
  156. package/web/.next/static/chunks/app/agents/[id]/page-c005161cec5527d8.js +0 -1
  157. package/web/.next/static/chunks/app/agents/page-9bb8cba69776c719.js +0 -1
  158. package/web/.next/static/chunks/app/layout-49b46c9b5aa512a8.js +0 -1
  159. package/web/.next/static/chunks/app/page-da042d146caeeab6.js +0 -1
  160. package/web/.next/static/chunks/app/relations/page-a44fd515a3b0525e.js +0 -1
  161. package/web/.next/static/chunks/app/sessions/page-a7534ebd90e982d6.js +0 -1
  162. package/web/.next/static/chunks/app/types/page-ebd37e220f77c1f3.js +0 -1
  163. package/web/.next/static/css/d42799b7e6a0e8a2.css +0 -1
  164. /package/web/.next/static/{aSYzbHk1M5YeS_d3SQKTZ → UDA414PFPu9ZROltNmjbX}/_ssgManifest.js +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,50 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.5.0] - 2026-10-19
10
+
11
+ ### Added
12
+
13
+ - **Reasoning effort per agent**, in the agent form (and in agent types), right under the model, and used by **every** turn: web chat, channels, schedules, wake-ups and delegations (before, only a Telegram or Slack connection could set it). The field offers only what the provider and model take: `low`, `medium`, `high`, `xhigh` and `max` for Claude Code and Kiro (the last two could not be chosen anywhere before), and for OpenCode the variants the selected model really has, read from its catalog. Changing the provider or the model clears a level that no longer applies, and an agent left at **Default** keeps behaving as before. A connection's effort is now an override of the agent's, takes the same five levels, and an OpenCode level name becomes the variant the model has for it, so the old warning about models without that variant is gone.
14
+ - **Clone an agent**, from its card on the Colony map or the footer of its settings. A clone is a separate agent with the same configuration (prompt, skills, permissions, model, folder and, for an orchestrator, its team) that **runs in parallel** with the original, so two conversations can happen at the same time, and that **shares its notebook**: one notebook, several readers, each note saying which agent wrote it. It does not bring the connections, schedules, wake-ups or resource management of the original. A clone always shows which agent it was cloned from, with a tag on its hexagon in the Colony map, in the Agents list, in the chat header and the list beside it and in the Relations screen, and the original shows how many clones it has. A notebook is now an object of its own: deleting an agent keeps it for the others that share it (and it goes with the last one), **Clear all** says it empties it for everybody, and **Separate** gives an agent its own copy. Existing notebooks are moved without losing a row.
15
+ - **Database management** skill: an agent can use a database resource through twenty `object_db_*` tools: look at the structure (`schema`, `columns`, `describe`, `search`, `ddl`, `sample`, `explain`), run SQL (`query`, and `exec` for several statements in one transaction), keep saved queries and read the history, export a result to a file in its own folder, look at sessions and cancel one, and, with Full access, change how a resource connects, create one and delete one. What an agent may do follows its effective permission (read-only reads, Edit files also writes, `DROP` and `TRUNCATE` need Full access) and the read-only setting of the resource does not bind it. Passwords can be set but never read back: they never appear in an answer, the history, the log or a chat reply. Choosing an agent as the manager of a database gives it the skill. Tried against MariaDB, PostgreSQL, SQL Server and SQLite.
16
+ - The launcher can run hive-am in the background and manage it: `hive-am start --background` (detached, with its output in `run/hive-am.log` in the data folder), `hive-am status`, `hive-am logs [-f] [-n N]`, `hive-am stop` and `hive-am restart`. A second start refuses and names the run that is already there, and a record whose process is gone or was reused by another one is reported as stale and never signalled. Plain `hive-am` behaves as before.
17
+ - Every run of the launcher opens with a welcome that shows the version, the addresses and where the data and the log are. `hive-am version` prints the version, and `hive-am status` shows the version of the run.
18
+ - `hive-am update` looks for a newer version on npm and installs it (`--check` only reports, `--restart` also restarts a run in the background), so updating no longer needs `npm install -g` by hand. It only installs when hive-am was installed globally with npm.
19
+
20
+ ### Changed
21
+
22
+ - What hive-am keeps of its own record no longer grows for ever. Every six hours (and at start) it forgets: the messages of a channel conversation past the newest 500 once they are 30 days old, the delegations past the newest 200, the delegated conversations of an agent past the newest 50 once they have not been used for 90 days, and the conversations in the list that the CLI no longer has once they have not been used for 90 days (never an agent's current one). The record of a received file is emptied together with the file, so an agent is no longer handed a path that was deleted. The conversations themselves, in each CLI, are never touched.
23
+ - Settings → Sessions of an agent, and the header of the Sessions screen, show the full session id, with a button to copy it.
24
+
25
+ ### Fixed
26
+
27
+ - The server took the full 5 seconds to shut down and left its database unclosed, because it waited for its HTTP server to finish before dropping the WebSocket connections that were holding it open. It now drops them first and shuts down in a fraction of a second, with the write-ahead log merged.
28
+ - Changes explorer: coming back to the tab now opens the file you were reading, with the folders that lead to it open, and the tab (History, Tags, Compare or Stashes) with what it had selected. Before, a folder with changes always replaced the remembered file with the first changed one, and in a clean folder of more than 40 files the file came back with every folder closed.
29
+ - Chat: the **Jump to latest** button is no longer offered when there is nothing to scroll, and it appears for any scroll up with content out of view (a 25 px scroll up used to leave the last line cut with no button, and a streaming reply pulled the view back down). A taller draft or a resized window keeps the last message in view when you had not scrolled away.
30
+ - OpenCode: changing an agent's permission did not apply to its existing conversation, so an agent switched to Full access kept saying it had no shell. The chat now warns that the permission changed after the conversation started and offers **Apply now**, which starts a new conversation (the current one stays in Sessions). Conversations recorded before this change are not detected.
31
+ - A turn whose tool stops answering no longer holds the agent's queue forever: after 10 minutes of silence it is stopped, reported as failed, and the messages behind it run. A failure while a turn starts is now reported as that turn's error instead of losing the message.
32
+ - A message that begins with a dash (`- something`, a bullet list, `--word`) is no longer read as an option by OpenCode and Kiro, which ended the turn with no answer or an error. The prompt now goes after `--`.
33
+ - A turn that produces no events is reported as a failure, with the first line the tool printed, instead of passing as an empty answer.
34
+
35
+ ## [1.4.0] - 2026-10-15
36
+
37
+ ### Added
38
+
39
+ - **Chat** item in the side rail, above Colony. It opens the last conversation you had open (or the first agent if that one no longer exists). With no agents yet, it shows an empty state with a button to create the first one.
40
+ - **Resources strip** above each chat, folded by default. Closed, it is one line that counts the resources running, stopped or failing (failures in red). Open, it shows a card per resource with start or stop, restart and a link to its own screen. It shows the agent's resources and those of its colony, with a switch to see all of them; the choice and whether it is open are remembered.
41
+ - The README now has screenshots of the colony, chat, resources, clusters, changes explorer and HTTP requests.
42
+
43
+ ### Changed
44
+
45
+ - Servers: **Keeping it healthy** is split into **If it goes down** (restart policy and maximum restarts) and **Health check** (kind, target and restart when unhealthy). The check timing (every, wait, failures in a row) and the stop and log settings are folded, and the defaults are visible.
46
+ - Servers: a restart waits for the port to be free before starting again, so a stop command that does not really stop the service is reported instead of leaving a failed start behind.
47
+ - Servers: the first health check comes one interval after a start instead of right away.
48
+
49
+ ### Fixed
50
+
51
+ - Entering a chat no longer flashes the default layout first: saved choices (the agent list collapsed, the resources strip open) apply before the first paint, the side panel only animates when you toggle it, and the conversation fades in when its history arrives.
52
+
9
53
  ## [1.3.0] - 2026-10-14
10
54
 
11
55
  ### Added
@@ -168,7 +212,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
168
212
  - Complete documentation in English and Spanish.
169
213
  - npm package with the `hive-am` command.
170
214
 
171
- [Unreleased]: https://github.com/hughcbscrt/hive-am/compare/v1.3.0...HEAD
215
+ [Unreleased]: https://github.com/hughcbscrt/hive-am/compare/v1.5.0...HEAD
216
+ [1.5.0]: https://github.com/hughcbscrt/hive-am/compare/v1.4.0...v1.5.0
217
+ [1.4.0]: https://github.com/hughcbscrt/hive-am/compare/v1.3.0...v1.4.0
172
218
  [1.3.0]: https://github.com/hughcbscrt/hive-am/compare/v1.2.2...v1.3.0
173
219
  [1.2.2]: https://github.com/hughcbscrt/hive-am/compare/v1.2.1...v1.2.2
174
220
  [1.2.1]: https://github.com/hughcbscrt/hive-am/compare/v1.2.0...v1.2.1
package/README.md CHANGED
@@ -12,10 +12,6 @@
12
12
  <p align="center">
13
13
  <a href="https://www.npmjs.com/package/hive-am"><img alt="npm version" src="https://img.shields.io/npm/v/hive-am?style=flat-square&color=cb3837&logo=npm&logoColor=white" /></a>
14
14
  <a href="https://www.npmjs.com/package/hive-am"><img alt="npm downloads" src="https://img.shields.io/npm/dm/hive-am?style=flat-square&color=cb3837" /></a>
15
- <a href="https://github.com/hughcbscrt/hive-am/commits/main"><img alt="Last commit" src="https://img.shields.io/github/last-commit/hughcbscrt/hive-am?style=flat-square&color=bf963d" /></a>
16
- <a href="https://github.com/hughcbscrt/hive-am/pulls?q=is%3Apr"><img alt="Pull requests" src="https://img.shields.io/github/issues-pr-closed/hughcbscrt/hive-am?style=flat-square&color=bf963d" /></a>
17
- <a href="https://github.com/hughcbscrt/hive-am/issues"><img alt="Issues" src="https://img.shields.io/github/issues/hughcbscrt/hive-am?style=flat-square&color=bf963d" /></a>
18
- <a href="https://github.com/hughcbscrt/hive-am"><img alt="Repo size" src="https://img.shields.io/github/repo-size/hughcbscrt/hive-am?style=flat-square&color=bf963d" /></a>
19
15
  <a href="https://github.com/hughcbscrt/hive-am"><img alt="Top language" src="https://img.shields.io/github/languages/top/hughcbscrt/hive-am?style=flat-square&color=3178c6" /></a>
20
16
  <img alt="Node 20+" src="https://img.shields.io/badge/node-%E2%89%A520-339933?style=flat-square&logo=nodedotjs&logoColor=white" />
21
17
  <img alt="Next.js 15" src="https://img.shields.io/badge/Next.js-15-000000?style=flat-square&logo=nextdotjs&logoColor=white" />
@@ -40,10 +36,29 @@ have installed; it does not replace them and it never copies your conversations
40
36
  - **Files and images.** People send documents and photos to the agent; the agent sends files back. An optional image model can describe pictures for models that cannot see them.
41
37
  - **Permissions that hold.** *Read-only*, *Edit files* and *Full access* are enforced by the CLI itself in all three providers.
42
38
  - **Changes explorer.** A Git tab per agent: file tree with highlighting, diff viewer, history, commit, pull, push and branches.
43
- - **Resources.** Servers, Docker containers, HTTP requests (`.http` files) and clusters that group them live in a colony as hexagons: start, stop, read logs, run requests; agents can look after them.
39
+ - **Resources.** Servers, Docker containers, HTTP requests (`.http` files) and clusters that group them live in a colony as hexagons: start, stop, read logs, run requests; agents can look after them, and an agent with the Database management skill can look inside a database and run SQL, within its permission.
44
40
  - **Terminals.** A bottom panel (Ctrl+`) with real shells and object logs, reachable only from this machine.
45
41
  - **Local and private.** The server binds to `127.0.0.1`. Data lives in `~/.hive-am/`.
46
42
 
43
+ ## Screenshots
44
+
45
+ <table>
46
+ <tr>
47
+ <td width="50%"><img src="docs/manual/en/img/colonia-mapa.jpg" alt="The colony map: colonies, agents and resources as hexagons" /><br /><sub><b>The colony.</b> Agents and resources as hexagons, grouped by colony.</sub></td>
48
+ <td width="50%"><img src="docs/manual/en/img/chat-conversacion.jpg" alt="A conversation with an orchestrator" /><br /><sub><b>Chat.</b> The real session of the CLI, with tokens, cost and tool calls.</sub></td>
49
+ </tr>
50
+ <tr>
51
+ <td width="50%"><img src="docs/manual/en/img/recurso-servidor-logs.jpg" alt="A server resource with its live logs" /><br /><sub><b>Resources.</b> Servers, containers and databases with live logs.</sub></td>
52
+ <td width="50%"><img src="docs/manual/en/img/recurso-cluster.jpg" alt="A cluster that starts several resources together" /><br /><sub><b>Clusters.</b> Start and stop a group of resources in order.</sub></td>
53
+ </tr>
54
+ <tr>
55
+ <td width="50%"><img src="docs/manual/en/img/cambios-diff.jpg" alt="The changes explorer showing a diff" /><br /><sub><b>Changes.</b> Git tree, diff, history and commit per agent.</sub></td>
56
+ <td width="50%"><img src="docs/manual/en/img/http-respuesta.jpg" alt="An HTTP request and its response" /><br /><sub><b>HTTP requests.</b> Run <code>.http</code> files and read the response.</sub></td>
57
+ </tr>
58
+ </table>
59
+
60
+ More in the [user manual](docs/manual/en/README.md).
61
+
47
62
  ## Providers
48
63
 
49
64
  | Provider | Run a turn | Transcript source |
package/bin/hive-am.mjs CHANGED
@@ -1,12 +1,16 @@
1
1
  #!/usr/bin/env node
2
- // hive-am launcher: starts the API server and the web UI from the published package.
3
- import { spawn } from 'node:child_process';
4
- import { readFileSync } from 'node:fs';
2
+ // hive-am launcher: starts the API server and the web UI from the published package, in this terminal or in the background,
3
+ // and stops it, reports on it and shows its log afterwards.
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
5
7
  import { createRequire } from 'node:module';
6
- import { dirname, join } from 'node:path';
8
+ import { dirname, join, sep } from 'node:path';
7
9
  import { fileURLToPath } from 'node:url';
10
+ import * as run from './run.mjs';
8
11
 
9
- const root = join(dirname(fileURLToPath(import.meta.url)), '..');
12
+ const self = fileURLToPath(import.meta.url);
13
+ const root = join(dirname(self), '..');
10
14
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
11
15
  const args = process.argv.slice(2);
12
16
 
@@ -14,42 +18,249 @@ const HELP = `hive-am ${pkg.version}
14
18
  ${pkg.description}
15
19
 
16
20
  Usage:
17
- hive-am [start] start the server (API + WebSocket, port 4400) and the web UI (port 4401)
18
- hive-am --version print the version
19
- hive-am --help show this help
21
+ hive-am [start] start the server (API + WebSocket, port 4400) and the web UI (port 4401) in this terminal
22
+ hive-am start --background start it detached, with its output in a log file, and return
23
+ hive-am status say whether it is running, since when and whether its ports answer (exit 1 when it is not)
24
+ hive-am logs [-f] [-n 200] show the last lines of the log, or follow it
25
+ hive-am stop stop it and wait until it is gone
26
+ hive-am restart stop it and start it again in the same mode (in the background when nothing was recorded)
27
+ hive-am update [--check] look for a newer version on npm and install it (--check only says; --restart also restarts a run)
28
+ hive-am version print the version (so does --version)
29
+ hive-am --help show this help
20
30
 
21
31
  Environment:
22
- HIVE_AM_HOME data folder (default ~/.hive-am)
32
+ HIVE_AM_HOME data folder (default ~/.hive-am); the record and the log of a run are in its run/ folder
23
33
  HIVE_AM_WEB_PORT port of the web UI (default 4401)
24
34
 
25
35
  The API server always uses port 4400: the packaged UI is built to talk to it.
26
36
  Requires Node >= ${pkg.engines?.node?.replace('>=', '') ?? '20'} and at least one of the CLIs installed and logged in: claude, opencode, kiro-cli.`;
27
37
 
38
+ const COMMANDS = ['start', 'stop', 'status', 'logs', 'restart', 'update', 'version'];
39
+ const fail = (msg, code = 1) => { console.error(msg); process.exit(code); };
40
+
28
41
  if (args.includes('--help') || args.includes('-h')) { console.log(HELP); process.exit(0); }
29
- if (args.includes('--version') || args.includes('-v')) { console.log(pkg.version); process.exit(0); }
30
- if (args.length && args[0] !== 'start') { console.error(`Unknown command: ${args[0]}\n\n${HELP}`); process.exit(1); }
42
+ if (args.includes('--version') || args.includes('-v') || args[0] === 'version') { console.log(pkg.version); process.exit(0); }
43
+
44
+ const cmd = !args.length || args[0].startsWith('-') ? 'start' : args[0];
45
+ const flags = COMMANDS.includes(args[0]) ? args.slice(1) : args;
46
+ if (!COMMANDS.includes(cmd) || (args.length && args[0].startsWith('-') && !['--background', '-b', '--foreground'].includes(args[0]))) fail(`Unknown command: ${args[0]}\n\n${HELP}`);
47
+
48
+ /** `hive-am logs`: `-f` follows, `-n N` (or `-nN`, `--lines N`) is how many lines. */
49
+ function logOptions(list) {
50
+ let follow = false, lines = 200;
51
+ for (let i = 0; i < list.length; i++) {
52
+ const a = list[i];
53
+ if (a === '-f' || a === '--follow') follow = true;
54
+ else if (a === '-n' || a === '--lines') lines = Number(list[++i]);
55
+ else if (/^-n\d+$/.test(a)) lines = Number(a.slice(2));
56
+ else fail(`Unknown option for logs: ${a}\n\n${HELP}`);
57
+ }
58
+ if (!Number.isInteger(lines) || lines < 1) fail('logs: -n needs a number of lines.');
59
+ return { follow, lines };
60
+ }
61
+ const startOptions = (list) => {
62
+ for (const a of list) if (!['--background', '-b', '--foreground', '--run-background'].includes(a)) fail(`Unknown option for start: ${a}\n\n${HELP}`);
63
+ return { background: list.includes('--background') || list.includes('-b'), detached: list.includes('--run-background') };
64
+ };
65
+ if (cmd !== 'start' && cmd !== 'logs' && cmd !== 'update' && flags.length) fail(`Unknown option for ${cmd}: ${flags[0]}\n\n${HELP}`);
31
66
 
32
67
  if (process.env.HIVE_AM_PORT && process.env.HIVE_AM_PORT !== '4400') {
33
- console.error('HIVE_AM_PORT is not supported by the packaged build: the UI is built to talk to the API on port 4400.');
34
- process.exit(1);
68
+ fail('HIVE_AM_PORT is not supported by the packaged build: the UI is built to talk to the API on port 4400.');
35
69
  }
36
70
 
37
71
  const webPort = process.env.HIVE_AM_WEB_PORT ?? '4401';
38
- const next = join(dirname(createRequire(import.meta.url).resolve('next/package.json')), 'dist', 'bin', 'next');
39
-
40
- const children = [
41
- spawn(process.execPath, [join(root, 'server', 'dist', 'index.js')], { cwd: join(root, 'server'), stdio: 'inherit' }),
42
- spawn(process.execPath, [next, 'start', '-p', webPort, '-H', '127.0.0.1'], { cwd: join(root, 'web'), stdio: 'inherit' }),
43
- ];
44
-
45
- let closing = false;
46
- const stop = (code = 0) => {
47
- if (closing) return;
48
- closing = true;
49
- for (const c of children) c.kill('SIGTERM');
50
- setTimeout(() => process.exit(code), 500).unref();
51
- };
52
- for (const c of children) c.on('exit', (code) => { if (!closing) { console.error('hive-am: a process exited, stopping.'); stop(code ?? 1); } });
53
- process.on('SIGINT', () => stop(0));
54
- process.on('SIGTERM', () => stop(0));
55
- setTimeout(() => console.log(`\nhive-am is running → http://127.0.0.1:${webPort} (API on 127.0.0.1:4400)`), 2500).unref();
72
+ const apiPort = Number(process.env.HIVE_AM_LAUNCHER_API_PORT) || 4400; // only tests change it: the packaged UI always talks to 4400
73
+
74
+ /** The two processes of the app. `HIVE_AM_LAUNCHER_CHILDREN` (tests only) replaces them with `[[command, ...args], [command, ...args]]`. */
75
+ function specs() {
76
+ if (process.env.HIVE_AM_LAUNCHER_CHILDREN) return JSON.parse(process.env.HIVE_AM_LAUNCHER_CHILDREN).map(([c, ...a]) => ({ cmd: c, args: a, cwd: root }));
77
+ const next = join(dirname(createRequire(import.meta.url).resolve('next/package.json')), 'dist', 'bin', 'next');
78
+ return [
79
+ { cmd: process.execPath, args: [join(root, 'server', 'dist', 'index.js')], cwd: join(root, 'server') },
80
+ { cmd: process.execPath, args: [next, 'start', '-p', webPort, '-H', '127.0.0.1'], cwd: join(root, 'web') },
81
+ ];
82
+ }
83
+
84
+ /** Refuses to start over a run that is already there: names it, or the port something else holds. Returns the message, or null when the way is free. */
85
+ async function blocked() {
86
+ const cur = run.inspect();
87
+ if (cur.kind === 'running') return `hive-am is already running (pid ${cur.state.pid}, since ${run.clock(cur.state.startedAt)}). See: hive-am status`;
88
+ for (const [what, port] of [['web UI', webPort], ['API', apiPort]]) {
89
+ if (await run.portOpen(port)) return `Port ${port} (${what}) is already in use by something that is not a recorded hive-am run. Is hive-am running from another data folder or without the launcher?`;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /** What a run prints first: the version, where to open it and where it keeps things. In the background it is the first thing in the log. */
95
+ function welcome(mode) {
96
+ const data = run.dataDir().replace(homedir(), '~');
97
+ console.log(`
98
+ hive-am ${pkg.version}
99
+ ${pkg.description.split(':')[0]}
100
+
101
+ web http://127.0.0.1:${webPort}
102
+ api 127.0.0.1:${apiPort}
103
+ data ${data}
104
+ ${mode === 'background' ? ' log ' + run.logFile().replace(homedir(), '~') + '\n Stop it with: hive-am stop' : ' Stop it with Ctrl+C'} (more: hive-am --help)
105
+ `);
106
+ }
107
+
108
+ /** Runs the app in this process until it stops. In the background the output of both children goes to the same log. */
109
+ function serve(mode) {
110
+ const children = specs().map((s) => spawn(s.cmd, s.args, { cwd: s.cwd, stdio: 'inherit' }));
111
+ welcome(mode);
112
+ run.writeState({ pid: process.pid, stamp: run.processStamp(process.pid), mode, version: pkg.version, startedAt: Date.now(), webPort, apiPort, log: mode === 'background' ? run.logFile() : null });
113
+ process.on('exit', () => run.removeState(process.pid));
114
+
115
+ let closing = false, code = 0, left = children.length;
116
+ const finish = () => process.exit(code);
117
+ // Stopping waits for the children to be gone (the server closes its database on the way out); a child that does not go is cut off.
118
+ const stop = (c = 0) => {
119
+ if (closing) return;
120
+ closing = true; code = c;
121
+ for (const ch of children) ch.kill('SIGTERM');
122
+ setTimeout(() => { for (const ch of children) ch.kill('SIGKILL'); finish(); }, 8000).unref();
123
+ if (!left) finish();
124
+ };
125
+ for (const c of children) c.on('exit', (exitCode) => {
126
+ left--;
127
+ if (!closing) { console.error('hive-am: a process exited, stopping.'); stop(exitCode ?? 1); }
128
+ if (closing && !left) finish();
129
+ });
130
+ process.on('SIGINT', () => stop(0));
131
+ process.on('SIGTERM', () => stop(0));
132
+ process.on('SIGHUP', () => { if (mode !== 'background') stop(0); }); // a closed terminal ends a foreground run; a background one has none
133
+ if (mode === 'background') setInterval(() => run.rotateLog(), 60_000).unref();
134
+ setTimeout(() => console.log(`\nhive-am is running → http://127.0.0.1:${webPort} (API on 127.0.0.1:${apiPort})`), 2500).unref();
135
+ }
136
+
137
+ async function start(list) {
138
+ const { background, detached } = startOptions(list);
139
+ const why = await blocked();
140
+ if (why) fail(why);
141
+
142
+ if (!background) { serve(detached ? 'background' : 'foreground'); return; }
143
+
144
+ run.rotateLog();
145
+ mkdirSync(run.runDir(), { recursive: true });
146
+ const fd = openSync(run.logFile(), 'a');
147
+ // The launcher itself, again, detached and in its own process group: what `stop` signals is the process that owns the two children.
148
+ const child = spawn(process.execPath, [self, 'start', '--run-background'], { detached: true, stdio: ['ignore', fd, fd], env: process.env, cwd: process.cwd() });
149
+ closeSync(fd);
150
+ let gone = false;
151
+ child.on('exit', () => { gone = true; });
152
+ child.unref();
153
+ for (let i = 0; i < 100 && !gone; i++) {
154
+ if (run.readState()?.pid === child.pid) {
155
+ console.log(`hive-am started in the background (pid ${child.pid})\n web http://127.0.0.1:${webPort}\n api 127.0.0.1:${apiPort}\n log ${run.logFile()}\n stop hive-am stop (see also: hive-am status, hive-am logs -f)`);
156
+ process.exit(0);
157
+ }
158
+ await run.sleep(100);
159
+ }
160
+ const { text } = run.readLog({ tail: 15 });
161
+ fail(`hive-am did not start in the background.${text ? `\n\nLast lines of ${run.logFile()}:\n${text}` : ''}`);
162
+ }
163
+
164
+ async function stop() {
165
+ const cur = run.inspect();
166
+ if (cur.kind === 'none') fail('hive-am is not running.');
167
+ if (cur.kind === 'stale') { run.removeState(cur.state.pid); fail(`hive-am is not running: its record was stale (${cur.why}). The record was removed and nothing was signalled.`); }
168
+ const { state } = cur;
169
+ const { forced } = await run.stopRun(state);
170
+ if (run.isAlive(state.pid, state.stamp)) fail(`hive-am (pid ${state.pid}) did not stop, even when forced.`);
171
+ run.removeState(state.pid);
172
+ console.log(`hive-am stopped (pid ${state.pid})${forced ? ', after forcing it' : ''}.`);
173
+ process.exit(0);
174
+ }
175
+
176
+ async function status() {
177
+ const cur = run.inspect();
178
+ if (cur.kind === 'none') { console.log('not running'); process.exit(1); }
179
+ if (cur.kind === 'stale') { console.log(`not running (stale record: ${cur.why})`); process.exit(1); }
180
+ const s = cur.state;
181
+ const [web, api] = await Promise.all([run.portOpen(s.webPort), run.portOpen(s.apiPort)]);
182
+ const ports = `web http://127.0.0.1:${s.webPort} ${web ? 'up' : 'not answering'}, api 127.0.0.1:${s.apiPort} ${api ? 'up' : 'not answering'}`;
183
+ console.log(`running (pid ${s.pid}, ${s.version ? `v${s.version}, ` : ''}since ${run.clock(s.startedAt)}, ${s.mode}) ${ports}${web || api ? '' : '\nNothing answers on its ports: it may still be starting, or it is stuck.'}${s.log ? `\nlog ${s.log}` : ''}`);
184
+ process.exit(0);
185
+ }
186
+
187
+ async function logs(list) {
188
+ const { follow, lines } = logOptions(list);
189
+ const cur = run.inspect();
190
+ if (cur.kind === 'running' && cur.state.mode === 'foreground') {
191
+ console.log(`hive-am (pid ${cur.state.pid}) was started in a terminal: its output is shown there, there is no log file for this run.\nTo keep a log, start it with: hive-am start --background`);
192
+ process.exit(0);
193
+ }
194
+ const file = run.logFile();
195
+ if (!existsSync(file)) { console.log('There is no log yet. Start hive-am with: hive-am start --background'); process.exit(0); }
196
+ let { text, cursor } = run.readLog({ tail: lines });
197
+ if (text) console.log(text.replace(/\n$/, ''));
198
+ if (!follow) process.exit(0);
199
+ process.on('SIGINT', () => process.exit(0));
200
+ for (;;) {
201
+ await run.sleep(500);
202
+ const next = run.readLog({ after: cursor });
203
+ if (next.text) process.stdout.write(next.text);
204
+ cursor = next.cursor;
205
+ }
206
+ }
207
+
208
+ async function restart() {
209
+ const cur = run.inspect();
210
+ const mode = cur.state?.mode ?? 'background';
211
+ if (cur.kind === 'running') {
212
+ const { forced } = await run.stopRun(cur.state);
213
+ if (run.isAlive(cur.state.pid, cur.state.stamp)) fail(`hive-am (pid ${cur.state.pid}) did not stop, even when forced.`);
214
+ run.removeState(cur.state.pid);
215
+ console.log(`hive-am stopped (pid ${cur.state.pid})${forced ? ', after forcing it' : ''}.`);
216
+ } else console.log('hive-am was not running.');
217
+ await start(mode === 'background' ? ['--background'] : []);
218
+ }
219
+
220
+ /** Compares two `x.y.z` versions (a pre-release tag is ignored). */
221
+ const versionCmp = (a, b) => { const n = (v) => String(v).split('-')[0].split('.').map((x) => Number(x) || 0); const [x, y] = [n(a), n(b)]; for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0); return 0; };
222
+ const npm = (list, opts = {}) => spawnSync(process.env.HIVE_AM_NPM || 'npm', list, { encoding: 'utf8', timeout: 120_000, ...opts });
223
+
224
+ /** How this copy of hive-am was installed, which decides whether it can update itself. `HIVE_AM_LAUNCHER_INSTALL` (tests only) forces the answer. */
225
+ function installKind() {
226
+ if (process.env.HIVE_AM_LAUNCHER_INSTALL) return process.env.HIVE_AM_LAUNCHER_INSTALL;
227
+ if (root.includes(`${sep}_npx${sep}`)) return 'npx';
228
+ if (existsSync(join(root, '.git')) || existsSync(join(root, 'server', 'src'))) return 'source';
229
+ const g = npm(['root', '-g']).stdout?.trim();
230
+ return g && root.startsWith(g) ? 'global' : 'other';
231
+ }
232
+
233
+ async function update(list) {
234
+ for (const a of list) if (!['--check', '--restart'].includes(a)) fail(`Unknown option for update: ${a}\n\n${HELP}`);
235
+ const check = list.includes('--check'), again = list.includes('--restart');
236
+ const view = npm(['view', pkg.name, 'version']);
237
+ const latest = view.status === 0 ? view.stdout.trim() : '';
238
+ if (!latest) fail(`Could not ask npm for the latest version of ${pkg.name}${view.error ? ` (${view.error.message})` : ''}. Are you online?`);
239
+ console.log(`installed ${pkg.version}, latest ${latest}`);
240
+ if (versionCmp(latest, pkg.version) <= 0) { console.log('hive-am is up to date.'); process.exit(0); }
241
+ if (check) { console.log(`An update is available. Install it with: hive-am update`); process.exit(0); }
242
+
243
+ const kind = installKind();
244
+ if (kind === 'npx') fail('This copy runs through npx, which fetches what you ask for: start the new version with `npx hive-am@latest`.');
245
+ if (kind === 'source') fail('This is a source checkout: update it with `git pull` and `npm install`, then rebuild.');
246
+ if (kind !== 'global') fail(`hive-am was not installed globally with npm (${root}), so it cannot update itself. Install the new version the way you installed this one: npm install -g ${pkg.name}@${latest}`);
247
+
248
+ console.log(`Installing ${pkg.name}@${latest} ...`);
249
+ const r = npm(['install', '-g', `${pkg.name}@${latest}`], { stdio: 'inherit', encoding: undefined });
250
+ if (r.status !== 0) fail(`npm could not install ${pkg.name}@${latest} (exit ${r.status}). If it is a permissions error, see https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally`);
251
+ let now = latest;
252
+ try { now = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version; } catch { /* the folder was replaced: keep what npm installed */ }
253
+ console.log(`hive-am updated: ${pkg.version} -> ${now}`);
254
+ const cur = run.inspect();
255
+ if (cur.kind !== 'running') process.exit(0);
256
+ if (again) { console.log('Restarting it on the new version ...'); process.exit(spawnSync(process.execPath, [self, 'restart'], { stdio: 'inherit' }).status ?? 1); }
257
+ console.log(`hive-am (pid ${cur.state.pid}) is still running the old version. Apply the update with: hive-am restart`);
258
+ process.exit(0);
259
+ }
260
+
261
+ if (cmd === 'start') await start(flags);
262
+ else if (cmd === 'update') await update(flags);
263
+ else if (cmd === 'stop') await stop();
264
+ else if (cmd === 'status') await status();
265
+ else if (cmd === 'logs') await logs(flags);
266
+ else await restart();
package/bin/run.mjs ADDED
@@ -0,0 +1,135 @@
1
+ // What the launcher remembers about the app it started: a state file, a log, and the checks that keep it from touching the wrong process.
2
+ // Plain JavaScript on purpose, with no import from the server: the launcher has to work when the app is down, when the database is broken
3
+ // and when the data folder does not exist yet (importing the server's code would open the database and create the folder).
4
+ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, truncateSync, writeFileSync } from 'node:fs';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { createConnection } from 'node:net';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ export const LOG_MAX = 10 * 1024 * 1024; // a bigger log is set aside when the launcher starts, and while it runs in the background
11
+ export const STOP_GRACE_MS = 15_000; // how long `stop` waits for the app to go before it forces it (the server's own shutdown is bounded to 5 s)
12
+
13
+ export const dataDir = () => process.env.HIVE_AM_HOME || join(homedir(), '.hive-am');
14
+ export const runDir = () => join(dataDir(), 'run');
15
+ export const stateFile = () => join(runDir(), 'hive-am.json');
16
+ export const logFile = () => join(runDir(), 'hive-am.log');
17
+
18
+ /** An identifier of "this very process" (its start time), so that a pid the system reuses for something else is not mistaken for the app. null = not running. */
19
+ export function processStamp(pid) {
20
+ try {
21
+ const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
22
+ const rest = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
23
+ return rest[0] === 'Z' || rest[0] === 'X' ? null : rest[19] ?? null; // a finished process nobody has collected yet (zombie) is finished
24
+ } catch (e) {
25
+ if (e.code !== 'ENOENT' || existsSync('/proc/self')) return null; // Linux: no such process
26
+ }
27
+ try { // macOS and others
28
+ const out = execFileSync('ps', ['-o', 'stat=,lstart=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
29
+ if (!out || out.startsWith('Z')) return null;
30
+ return out.replace(/^\S+\s+/, '');
31
+ } catch { return null; }
32
+ }
33
+ export const isAlive = (pid, stamp) => { const s = processStamp(pid); return s !== null && (stamp == null || s === stamp); };
34
+
35
+ /** The record of a run, or null. `{ pid, stamp, mode: 'foreground' | 'background', startedAt, webPort, apiPort, log }`. */
36
+ export function readState() {
37
+ try {
38
+ const s = JSON.parse(readFileSync(stateFile(), 'utf8'));
39
+ return s && Number.isInteger(s.pid) ? s : null;
40
+ } catch { return null; }
41
+ }
42
+ export function writeState(s) {
43
+ mkdirSync(runDir(), { recursive: true });
44
+ const tmp = `${stateFile()}.${process.pid}.tmp`;
45
+ writeFileSync(tmp, JSON.stringify(s, null, 2));
46
+ renameSync(tmp, stateFile());
47
+ }
48
+ /** Removes the record, but only when it is the one of `pid` (a run that ends must not erase the record of the one that replaced it). */
49
+ export function removeState(pid) {
50
+ const s = readState();
51
+ if (s && (pid === undefined || s.pid === pid)) rmSync(stateFile(), { force: true });
52
+ }
53
+
54
+ /**
55
+ * What the record says about the app: `none` (no record), `running` (the process in it is alive and is the same one) or `stale`
56
+ * (the pid is gone, or it now belongs to another process: the record is never followed).
57
+ */
58
+ export function inspect() {
59
+ const state = readState();
60
+ if (!state) return { kind: 'none' };
61
+ const now = processStamp(state.pid);
62
+ if (now === null) return { kind: 'stale', state, why: `pid ${state.pid} is not running` };
63
+ if (state.stamp && now !== state.stamp) return { kind: 'stale', state, why: `pid ${state.pid} now belongs to a different process` };
64
+ return { kind: 'running', state };
65
+ }
66
+
67
+ /** Whether something accepts connections on a local port. */
68
+ export function portOpen(port, host = '127.0.0.1', timeoutMs = 500) {
69
+ return new Promise((resolve) => {
70
+ const s = createConnection({ port: Number(port), host });
71
+ const done = (v) => { s.destroy(); resolve(v); };
72
+ s.setTimeout(timeoutMs, () => done(false));
73
+ s.once('connect', () => done(true));
74
+ s.once('error', () => done(false));
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Keeps the log from growing for ever. The run writes with O_APPEND to its own file, so the old part is copied aside and the file is
80
+ * emptied in place (a few lines printed in between can be lost; it is a log, not a ledger).
81
+ */
82
+ export function rotateLog(file = logFile(), max = LOG_MAX) {
83
+ try {
84
+ if (statSync(file).size <= max) return false;
85
+ copyFileSync(file, `${file}.1`); truncateSync(file, 0);
86
+ return true;
87
+ } catch { return false; }
88
+ }
89
+
90
+ /** The lines of the log. `after` is a byte offset from an earlier call; without it the last `tail` lines come back. */
91
+ export function readLog({ tail = 200, after, file = logFile() } = {}) {
92
+ let size = 0;
93
+ try { size = statSync(file).size; } catch { return { text: '', cursor: 0 }; }
94
+ const MAX = 256 * 1024;
95
+ const lines = Math.max(1, Math.min(tail, 5000));
96
+ let from, reset = false;
97
+ if (after !== undefined && Number.isFinite(after)) {
98
+ if (after > size) { from = Math.max(0, size - MAX); reset = true; } // the file was cut or replaced
99
+ else from = Math.max(after, size - MAX);
100
+ } else from = Math.max(0, size - lines * 200);
101
+ const len = size - from;
102
+ if (len <= 0) return { text: '', cursor: size };
103
+ const buf = Buffer.alloc(len);
104
+ const fd = openSync(file, 'r');
105
+ try { readSync(fd, buf, 0, len, from); } finally { closeSync(fd); }
106
+ let text = buf.toString('utf8');
107
+ if (after === undefined) {
108
+ const all = text.split('\n');
109
+ if (from > 0) all.shift(); // the first line may be cut in half
110
+ text = all.slice(-lines).join('\n');
111
+ }
112
+ return { text, cursor: size, reset: reset || undefined };
113
+ }
114
+
115
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
116
+
117
+ /** Waits until the process is gone. Returns false when it is still there after `ms`. */
118
+ export async function waitGone(pid, stamp, ms) {
119
+ const end = Date.now() + ms;
120
+ while (Date.now() < end) { if (!isAlive(pid, stamp)) return true; await sleep(100); }
121
+ return !isAlive(pid, stamp);
122
+ }
123
+
124
+ /** SIGTERM to the launcher (it forwards it to the two processes it owns), then SIGKILL to its whole group when it does not go. */
125
+ export async function stopRun(state, graceMs = STOP_GRACE_MS) {
126
+ const { pid, stamp } = state;
127
+ try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
128
+ if (await waitGone(pid, stamp, graceMs)) return { forced: false };
129
+ // A background run is its own process group (the launcher is its leader): the group takes the children with it.
130
+ try { process.kill(state.mode === 'background' ? -pid : pid, 'SIGKILL'); } catch { /* gone in between */ }
131
+ await waitGone(pid, stamp, 3000);
132
+ return { forced: true };
133
+ }
134
+
135
+ export const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hive-am",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "A colony of coding agents: orchestrators and subagents over Claude Code, OpenCode and Kiro, with real session resume, a browser UI and Telegram.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { agents } from '../db.js';
3
3
  import { liveOrigin, notifyAgentsChanged } from '../runtime.js';
4
4
  import { cleanInbox } from './files.js';
5
+ import { retentionPass, retentionTouched } from '../retention.js';
5
6
  import { resolveSendable } from './outbound.js';
6
7
  import { syncChannelSkill } from './channel-skill.js';
7
8
  import { handleInbound } from './router.js';
@@ -59,10 +60,21 @@ export async function stopConnection(id) {
59
60
  catch { /* already down */ }
60
61
  }
61
62
  let cleaner;
62
- export const startAll = () => {
63
- // Received files are kept for a while, then removed.
63
+ /** Received files are kept for a while, then removed; and what hive-am keeps of its own record is aged out (retention.ts). */
64
+ function housekeeping() {
64
65
  cleanInbox();
65
- cleaner ??= setInterval(() => cleanInbox(), 6 * 3_600_000).unref();
66
+ try {
67
+ const r = retentionPass();
68
+ if (retentionTouched(r))
69
+ console.log(`[retention] removed ${r.messages} messages, ${r.dispatches} dispatches, ${r.sessions} sessions; emptied ${r.files} file records`);
70
+ }
71
+ catch (e) {
72
+ console.error('[retention]', e);
73
+ }
74
+ }
75
+ export const startAll = () => {
76
+ housekeeping();
77
+ cleaner ??= setInterval(housekeeping, 6 * 3_600_000).unref();
66
78
  return Promise.all(connections.list().filter((c) => c.enabled).map((c) => startConnection(c.id)));
67
79
  };
68
80
  export const stopAll = () => Promise.all([...running.keys()].map(stopConnection));
@@ -12,7 +12,7 @@ const DEFAULT_RATE = 20; // messages per user per minute
12
12
  const DEFAULT_CHATTER_PER_MINUTE = 10; // times per minute that chatter nobody aimed at the agent may wake it, per thread
13
13
  /** Sent once when a turn aimed at the agent ended without a reply. */
14
14
  const NUDGE = '[hive:channel] System reminder: your last turn ended without calling `channel_reply`, so the person received nothing. If you had an answer, send it now with `channel_reply` (if the tool says it is unknown, call it again: it was still connecting). If you chose not to answer, say so in one short sentence with `channel_reply`.';
15
- export const EFFORTS = ['low', 'medium', 'high'];
15
+ import { validEffort } from '../efforts.js';
16
16
  const QUIET_MS = 4000; // group chatter is handed over once it pauses this long, so a burst is one turn
17
17
  const LONG_TURN_MS = 45_000; // an unaddressed turn this long did real work: if it never replied, remind it
18
18
  const QUIET_RETRIES = 15; // while the agent is busy the hand-over waits (about a minute); after this it is left as context for the next turn
@@ -205,7 +205,7 @@ async function deliver(conn, adapter, thread, m, msgId, addressed, scheduled = f
205
205
  await reply(say(conn, T.queued(ahead)));
206
206
  const unseen = threadMessages.unseen(thread.id, fresh.seen_id, msgId).map((r) => ({ name: r.user_name ?? r.user_id ?? '?', text: r.text, files: r.files ? parseJson(r.files, undefined, `thread message ${r.id} files`) : undefined }));
207
207
  threads.markSeen(thread.id, msgId);
208
- const origin = { connectionId: conn.id, threadId: thread.id, externalKey: m.externalKey, platform: conn.kind, place: m.place, userName: m.userName, userId: m.userId, group: !!m.group, mention: m.mention, addressed, replied: false, messageId: m.externalId, effort: EFFORTS.includes(conn.config.effort) ? conn.config.effort : undefined };
208
+ const origin = { connectionId: conn.id, threadId: thread.id, externalKey: m.externalKey, platform: conn.kind, place: m.place, userName: m.userName, userId: m.userId, group: !!m.group, mention: m.mention, addressed, replied: false, messageId: m.externalId, effort: validEffort(conn.config.effort) ? conn.config.effort : undefined };
209
209
  if (addressed)
210
210
  await adapter.busy(m.target, 'working', m.externalId).catch(() => undefined);
211
211
  const startedAt = Date.now();
@@ -1,4 +1,5 @@
1
1
  import { connections } from './store.js';
2
+ import { objectsStore } from '../objects/store.js';
2
3
  /**
3
4
  * Last line of defence for text an agent sends to a chat. The instructions tell the agent never to share secrets, but a model can be
4
5
  * talked into it (by anyone in a group, or by an admin who does not realise what the output holds), so the text is checked here
@@ -31,6 +32,13 @@ function knownSecrets() {
31
32
  if (typeof v === 'string' && v.length >= 8)
32
33
  out.add(v);
33
34
  }
35
+ // The password of a database resource: an agent can use it to connect, but it must never be repeated in a chat.
36
+ for (const o of objectsStore.list())
37
+ if (o.kind === 'database') {
38
+ const pw = o.config.password;
39
+ if (typeof pw === 'string' && pw.length >= 6)
40
+ out.add(pw);
41
+ }
34
42
  for (const [k, v] of Object.entries(process.env))
35
43
  if (v && v.length >= 12 && SECRET_ENV.test(k))
36
44
  out.add(v);