hive-am 1.4.0 → 1.6.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 (169) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/README.md +6 -9
  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/format.js +26 -0
  7. package/server/dist/connections/index.js +2 -0
  8. package/server/dist/connections/manager.js +18 -6
  9. package/server/dist/connections/router.js +43 -13
  10. package/server/dist/connections/secrets.js +9 -1
  11. package/server/dist/connections/slack.js +488 -0
  12. package/server/dist/connections/store.js +2 -0
  13. package/server/dist/db/migrations.js +80 -0
  14. package/server/dist/db.js +26 -13
  15. package/server/dist/dispatches.js +126 -0
  16. package/server/dist/efforts.js +105 -0
  17. package/server/dist/http/routes/agents.js +4 -3
  18. package/server/dist/http/routes/meta.js +12 -0
  19. package/server/dist/http/routes/notebook.js +10 -2
  20. package/server/dist/http/routes/orchestration.js +13 -2
  21. package/server/dist/http/routes/resources.js +3 -0
  22. package/server/dist/http/routes/types.js +18 -1
  23. package/server/dist/http/validate.js +27 -4
  24. package/server/dist/index.js +6 -1
  25. package/server/dist/instructions.js +11 -3
  26. package/server/dist/objects/agent-db.js +258 -0
  27. package/server/dist/objects/database.js +496 -34
  28. package/server/dist/objects/index.js +16 -8
  29. package/server/dist/providers/kiro.js +7 -1
  30. package/server/dist/providers/opencode-server.js +11 -1
  31. package/server/dist/providers/opencode.js +4 -3
  32. package/server/dist/providers/spawn.js +38 -4
  33. package/server/dist/retention.js +52 -0
  34. package/server/dist/runtime.js +69 -27
  35. package/server/dist/services/agents.js +53 -1
  36. package/server/dist/services/connections.js +18 -3
  37. package/server/dist/services/notebook.js +7 -3
  38. package/server/dist/skills/defaults.js +41 -5
  39. package/server/dist/skills/notebook.js +57 -13
  40. package/server/dist/watch.js +1 -1
  41. package/server/mcp/dispatch.mjs +116 -5
  42. package/web/.next/BUILD_ID +1 -1
  43. package/web/.next/app-build-manifest.json +98 -82
  44. package/web/.next/app-path-routes-manifest.json +7 -7
  45. package/web/.next/build-manifest.json +2 -2
  46. package/web/.next/prerender-manifest.json +51 -51
  47. package/web/.next/server/app/_not-found/page.js +2 -2
  48. package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  49. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  50. package/web/.next/server/app/_not-found.html +1 -1
  51. package/web/.next/server/app/_not-found.rsc +3 -3
  52. package/web/.next/server/app/agents/[id]/page.js +4 -4
  53. package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
  54. package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
  55. package/web/.next/server/app/agents/page.js +2 -2
  56. package/web/.next/server/app/agents/page.js.nft.json +1 -1
  57. package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
  58. package/web/.next/server/app/agents.html +1 -1
  59. package/web/.next/server/app/agents.rsc +21 -18
  60. package/web/.next/server/app/chat/page.js +2 -2
  61. package/web/.next/server/app/chat/page.js.nft.json +1 -1
  62. package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -1
  63. package/web/.next/server/app/chat.html +1 -1
  64. package/web/.next/server/app/chat.rsc +4 -4
  65. package/web/.next/server/app/connections/page.js +2 -2
  66. package/web/.next/server/app/connections/page.js.nft.json +1 -1
  67. package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
  68. package/web/.next/server/app/connections.html +1 -1
  69. package/web/.next/server/app/connections.rsc +4 -4
  70. package/web/.next/server/app/index.html +1 -1
  71. package/web/.next/server/app/index.rsc +4 -4
  72. package/web/.next/server/app/page.js +2 -2
  73. package/web/.next/server/app/page.js.nft.json +1 -1
  74. package/web/.next/server/app/page_client-reference-manifest.js +1 -1
  75. package/web/.next/server/app/relations/page.js +2 -2
  76. package/web/.next/server/app/relations/page.js.nft.json +1 -1
  77. package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
  78. package/web/.next/server/app/relations.html +1 -1
  79. package/web/.next/server/app/relations.rsc +4 -4
  80. package/web/.next/server/app/resources/[id]/page.js +2 -2
  81. package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -1
  82. package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
  83. package/web/.next/server/app/resources/page.js +2 -2
  84. package/web/.next/server/app/resources/page.js.nft.json +1 -1
  85. package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
  86. package/web/.next/server/app/resources.html +1 -1
  87. package/web/.next/server/app/resources.rsc +4 -4
  88. package/web/.next/server/app/schedules/page.js +2 -2
  89. package/web/.next/server/app/schedules/page.js.nft.json +1 -1
  90. package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  91. package/web/.next/server/app/schedules.html +1 -1
  92. package/web/.next/server/app/schedules.rsc +4 -4
  93. package/web/.next/server/app/sessions/page.js +2 -2
  94. package/web/.next/server/app/sessions/page.js.nft.json +1 -1
  95. package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  96. package/web/.next/server/app/sessions.html +1 -1
  97. package/web/.next/server/app/sessions.rsc +4 -4
  98. package/web/.next/server/app/skills/page.js +2 -2
  99. package/web/.next/server/app/skills/page.js.nft.json +1 -1
  100. package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
  101. package/web/.next/server/app/skills.html +1 -1
  102. package/web/.next/server/app/skills.rsc +4 -4
  103. package/web/.next/server/app/types/page.js +2 -2
  104. package/web/.next/server/app/types/page.js.nft.json +1 -1
  105. package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
  106. package/web/.next/server/app/types.html +1 -1
  107. package/web/.next/server/app/types.rsc +4 -4
  108. package/web/.next/server/app-paths-manifest.json +7 -7
  109. package/web/.next/server/chunks/{505.js → 111.js} +1 -1
  110. package/web/.next/server/chunks/143.js +1 -1
  111. package/web/.next/server/chunks/213.js +1 -1
  112. package/web/.next/server/chunks/368.js +1 -1
  113. package/web/.next/server/chunks/403.js +1 -1
  114. package/web/.next/server/chunks/612.js +1 -1
  115. package/web/.next/server/chunks/741.js +2 -0
  116. package/web/.next/server/chunks/837.js +1 -0
  117. package/web/.next/server/chunks/{435.js → 939.js} +3 -3
  118. package/web/.next/server/pages/404.html +1 -1
  119. package/web/.next/server/pages/500.html +1 -1
  120. package/web/.next/server/server-reference-manifest.json +1 -1
  121. package/web/.next/static/chunks/162-68ba190086f5ad39.js +1 -0
  122. package/web/.next/static/chunks/214-35b88db02113ae02.js +1 -0
  123. package/web/.next/static/chunks/281-4506bc98b7111729.js +1 -0
  124. package/web/.next/static/chunks/491-196bcb3dd11bb460.js +1 -0
  125. package/web/.next/static/chunks/517-8f2db464d10a4624.js +1 -0
  126. package/web/.next/static/chunks/{586-f7017d585a84738d.js → 586-4f67b823df060b46.js} +1 -1
  127. package/web/.next/static/chunks/587-3c53d91a1c4f99d6.js +1 -0
  128. package/web/.next/static/chunks/606-14fb5aaa4d0ff22d.js +1 -0
  129. package/web/.next/static/chunks/642-dc874c8caf203ee1.js +1 -0
  130. package/web/.next/static/chunks/{705-f81face97b1bb1af.js → 705-ce86175dea00e6a9.js} +1 -1
  131. package/web/.next/static/chunks/757-03361e7a97a605a2.js +1 -0
  132. package/web/.next/static/chunks/771-db19da9b1a24820c.js +1 -0
  133. package/web/.next/static/chunks/app/agents/[id]/page-803d53aedd10c8e5.js +1 -0
  134. package/web/.next/static/chunks/app/agents/layout-71dd98d696935e29.js +1 -0
  135. package/web/.next/static/chunks/app/agents/page-9da499db6d22731f.js +1 -0
  136. package/web/.next/static/chunks/app/chat/{page-5261b12b7d50f5d1.js → page-383bc24e72dab7fa.js} +1 -1
  137. package/web/.next/static/chunks/app/connections/page-b0ea2eae62cef74e.js +1 -0
  138. package/web/.next/static/chunks/app/layout-cb5af29b0742c513.js +1 -0
  139. package/web/.next/static/chunks/app/page-5e547e87de23d9fa.js +1 -0
  140. package/web/.next/static/chunks/app/relations/page-02710fe1f8a670a9.js +1 -0
  141. package/web/.next/static/chunks/app/resources/[id]/page-1e2afddcb1f04884.js +1 -0
  142. package/web/.next/static/chunks/app/resources/{page-61cccd8fdcc2f6c8.js → page-195b0af83b8ace0d.js} +1 -1
  143. package/web/.next/static/chunks/app/sessions/page-85638705fdc18174.js +1 -0
  144. package/web/.next/static/chunks/app/types/page-c4c2b2217c4667e8.js +1 -0
  145. package/web/.next/static/css/c7faf690373117bb.css +1 -0
  146. package/web/.next/trace +30 -30
  147. package/web/.next/types/routes.d.ts +2 -1
  148. package/web/.next/types/validator.ts +9 -0
  149. package/web/.next/server/chunks/274.js +0 -1
  150. package/web/.next/static/chunks/281-113274e705940a55.js +0 -1
  151. package/web/.next/static/chunks/316-26da9cc233ba7557.js +0 -1
  152. package/web/.next/static/chunks/391-18768499e741112e.js +0 -1
  153. package/web/.next/static/chunks/517-373a80c8ba432bdc.js +0 -1
  154. package/web/.next/static/chunks/562-7c2e33d838e869bf.js +0 -1
  155. package/web/.next/static/chunks/587-8e4d2be01ba7d48f.js +0 -1
  156. package/web/.next/static/chunks/606-c9a19918b02a98d0.js +0 -1
  157. package/web/.next/static/chunks/656-f537149fe28f79aa.js +0 -1
  158. package/web/.next/static/chunks/app/agents/[id]/page-6ed6edb3e74b0ebe.js +0 -1
  159. package/web/.next/static/chunks/app/agents/page-9bb8cba69776c719.js +0 -1
  160. package/web/.next/static/chunks/app/connections/page-eb11cfb1ffeab3ca.js +0 -1
  161. package/web/.next/static/chunks/app/layout-fad968a635f9a4b3.js +0 -1
  162. package/web/.next/static/chunks/app/page-99e0351e6ccaa384.js +0 -1
  163. package/web/.next/static/chunks/app/relations/page-a44fd515a3b0525e.js +0 -1
  164. package/web/.next/static/chunks/app/resources/[id]/page-fc494e85b01e8cb7.js +0 -1
  165. package/web/.next/static/chunks/app/sessions/page-a7534ebd90e982d6.js +0 -1
  166. package/web/.next/static/chunks/app/types/page-ebd37e220f77c1f3.js +0 -1
  167. package/web/.next/static/css/582723fcea1f9887.css +0 -1
  168. /package/web/.next/static/{YT6HM6qmegMJaBMX5QvuP → tdnUXVa01m25EjOobxVd1}/_buildManifest.js +0 -0
  169. /package/web/.next/static/{YT6HM6qmegMJaBMX5QvuP → tdnUXVa01m25EjOobxVd1}/_ssgManifest.js +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,55 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.6.0] - 2026-10-20
10
+
11
+ ### Added
12
+
13
+ - **Fold the main menu to icons.** A button next to the hive-am name folds the sidebar from 220 px to a 64 px column of icons, giving the chat and every other screen more room. Each icon keeps its name as a tooltip and for screen readers, counts stay as small badges and the EN/ES switch stays at hand. The choice is remembered and applied before the page paints, so a reload never flashes the wide menu. On narrow screens (under 760 px) the menu stays the top bar with its labels.
14
+ - **Fold the colony groups of the chat's agent list.** Each colony heading in the side list of the chat is now a button that folds or unfolds its group (mouse or keyboard), and the folded groups are remembered. A folded group still shows the agent you are on and a pulsing dot when one of its agents is working; while searching every group with a match is open. The collapsed list (avatars only) keeps the groups apart with a compact heading per colony (its initials in its color and the count, the full name on hover) that folds the same groups. Orchestrators come first in each group in both modes, the headings are larger and easier to read, and a hive with no agents says "No agents yet" instead of an empty search message.
15
+ - **Slack connections.** A connection can now link an agent to a Slack app, with everything Telegram does: DMs and channels, the allow list (Slack member and channel ids), files in and out, reactions (by Slack name) and the shared single conversation. It runs over **Socket Mode**, so no public URL is needed and hive-am can stay on a laptop; the socket is refreshed and reconnected on its own, and a wrong token says which one. The agent answers **in a thread opened on the message** and keeps answering there; scheduled messages, reminders and the `wake_when_done` notice go to the channel. Replies are sent as `mrkdwn`. The connection form has a working platform selector, the two tokens and Slack's own words; the manual explains how to create the app. Tested against a fake Slack (`sim-slack.ts`).
16
+ - **`/help` and `/id` commands, and `/clear` and `/reset` as other names for `/new`**, on Telegram and Slack alike. `/id` answers the sender's id and the chat's, so an admin can add them. On Slack they come as slash commands (`/hive status`, `/hive-status` or `/status`).
17
+ - **Limit who can talk inside an authorized group or channel.** Each allowed Telegram group or Slack channel now has a mode in the connection form: **Everyone in it** (the default, and what every saved chat keeps, with no migration) or **Only these people**, with its own list of person ids (Telegram user ids, Slack member ids `U…` or `W…`). In a restricted chat only those people can talk, in every topic and thread of it; a member is an admin (`/new`) only if they are also on the connection's people list with **Admin**. Anyone else is ignored, and if they call the agent they are told that the chat only admits its list, with their id and the chat's. With "Listens and chimes in" their messages do not reach the agent even as context. DMs are unchanged. The server refuses an unknown mode, more than 50 members per chat and ids that do not look like the platform's. Tested in `test-chat-access.ts` (no model).
18
+ - **Delegate in the background.** `dispatch` now returns at once with a task id by default (`background` is true unless you pass `background: false` to wait for the answer), the orchestrator can keep answering the person or delegate to other subagents, and when the subagent finishes the orchestrator receives a message with the task id, the subagent and the result, in the place where it was asked (its web conversation, or the same Telegram or Slack thread). The new `dispatch_status` tool reads the state of a task (queued, running, done, failed or interrupted) and its result, and can wait up to 60 seconds for the end. The result lives in hive-am, not in the call: it is announced exactly once, even across a restart; a task left running by a restart is marked interrupted and its orchestrator is told to check the work and send it again; pruning keeps a result nobody has had yet. An orchestrator can have up to 10 unfinished background tasks. The orchestrator's instructions tell it to wait with `background: false` only for a very short task whose answer it needs right now. Tested in `test-dispatch-background.ts` (no model).
19
+
20
+ ### Changed
21
+
22
+ - **The agent and agent-type forms are one component.** The types screen now renders the agent form (`AgentForm`) in type mode instead of its own copy of the fields, so a new agent field is written once. Nothing visible changes, and saving a type sends only the fields a type stores.
23
+ - **The connection's people list is for direct messages, and each authorized group follows its own rule.** In the form it is now **People (direct messages)**, apart from **Groups** and **Allowed groups** (**Allowed channels** on Slack), each with its own help. The people list still talks in DMs and in groups that are not listed, but no longer opens a group limited to **Only these people**: only that group's members get in, and an empty member list admits nobody. If you already limited a group and someone on the people list talked there, add them to the group's list too. Rows wrap in a narrow drawer.
24
+ - **Someone without access is told at every message aimed at the agent**, not only the first time, so the bot no longer looks dead. To keep a stranger from flooding a chat, it sends at most 5 notices per person and chat and 20 per chat in a minute, then stays silent. The notice of a limited chat now only points to the chat's members ("channel" on Slack).
25
+
26
+ ### Fixed
27
+
28
+ - **Picking an agent in the chat's side list no longer sends the list back to the top.** The whole agent page was remounted for each agent, list included, so after scrolling down and choosing an agent the list jumped to the start and the chosen agent went out of view. The list now stays mounted (it keeps its scroll and its search text) while the chat still resets its draft, tab and viewed session, and opening or reloading an agent further down scrolls the list just enough to show it.
29
+ - **A long answer from a delegated task, a wake-up or a schedule is no longer refused in a chat.** These notices went through the 8000 character limit of incoming messages, so a subagent's answer longer than that came back as "Message too long." and never reached the orchestrator in its Telegram or Slack thread (it stayed readable with `dispatch_status`). The limit now applies only to what people write.
30
+ - **A delegation longer than five minutes no longer fails with `fetch failed` and loses the answer.** The synchronous `dispatch` held one HTTP request until the subagent finished, and Node's `fetch` gives up after 300 seconds without an answer. It now starts the task and waits with short `dispatch_status` calls (25 seconds each), so it returns the subagent's answer however long it takes. The orchestrator's own CLI is still cut after 10 minutes of silence while it waits on a tool, which is why `dispatch` goes in the background by default. The Recent delegations list shows a task waiting behind a busy subagent as queued (it used to look failed), and its status on hover.
31
+
32
+ ## [1.5.0] - 2026-10-19
33
+
34
+ ### Added
35
+
36
+ - **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.
37
+ - **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.
38
+ - **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.
39
+ - 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.
40
+ - 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.
41
+ - `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.
42
+
43
+ ### Changed
44
+
45
+ - 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.
46
+ - Settings → Sessions of an agent, and the header of the Sessions screen, show the full session id, with a button to copy it.
47
+
48
+ ### Fixed
49
+
50
+ - 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.
51
+ - 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.
52
+ - 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.
53
+ - 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.
54
+ - 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.
55
+ - 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 `--`.
56
+ - 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.
57
+
9
58
  ## [1.4.0] - 2026-10-15
10
59
 
11
60
  ### Added
@@ -186,7 +235,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
186
235
  - Complete documentation in English and Spanish.
187
236
  - npm package with the `hive-am` command.
188
237
 
189
- [Unreleased]: https://github.com/hughcbscrt/hive-am/compare/v1.4.0...HEAD
238
+ [Unreleased]: https://github.com/hughcbscrt/hive-am/compare/v1.6.0...HEAD
239
+ [1.6.0]: https://github.com/hughcbscrt/hive-am/compare/v1.5.0...v1.6.0
240
+ [1.5.0]: https://github.com/hughcbscrt/hive-am/compare/v1.4.0...v1.5.0
190
241
  [1.4.0]: https://github.com/hughcbscrt/hive-am/compare/v1.3.0...v1.4.0
191
242
  [1.3.0]: https://github.com/hughcbscrt/hive-am/compare/v1.2.2...v1.3.0
192
243
  [1.2.2]: https://github.com/hughcbscrt/hive-am/compare/v1.2.1...v1.2.2
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" />
@@ -37,10 +33,11 @@ have installed; it does not replace them and it never copies your conversations
37
33
  - **Skills.** Ten skills ship with hive-am (all ordinary: edit or delete them). Assign them to an agent, a type or a colony, and choose per assignment whether they are **always** in the instructions or loaded **on demand** with a `skill_read` tool. Searchable picker with token weights.
38
34
  - **Notebook.** An agent can keep a persistent notebook of what it learns (no credentials, no duplicates, every note with its source). You can read and edit it from the agent's settings.
39
35
  - **Telegram.** Link a bot to an agent: private chats, groups and topics, one shared session across all of them. In groups the agent can listen to everything and speak only when it adds something; people can mute it per thread and bring it back.
36
+ - **Slack.** The same over Socket Mode (no public URL): DMs and channels, the agent answers in a thread opened on the message, scheduled messages land in the channel, `/hive` slash commands.
40
37
  - **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
38
  - **Permissions that hold.** *Read-only*, *Edit files* and *Full access* are enforced by the CLI itself in all three providers.
42
39
  - **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.
40
+ - **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
41
  - **Terminals.** A bottom panel (Ctrl+`) with real shells and object logs, reachable only from this machine.
45
42
  - **Local and private.** The server binds to `127.0.0.1`. Data lives in `~/.hive-am/`.
46
43
 
@@ -119,14 +116,14 @@ Data lives in `~/.hive-am/hive-am.db` (override with `HIVE_AM_HOME`). The server
119
116
  In *Edit files* the agent cannot touch `.git` or private keys (`*.pem`, `*.key`) and cannot write outside its folder. Details and the
120
117
  few differences between providers are in [docs/06](docs/reference/en/06-providers.md) and [docs/12](docs/reference/en/12-operations-and-troubleshooting.md).
121
118
 
122
- ## Connections (Telegram)
119
+ ## Connections (Telegram and Slack)
123
120
 
124
- 1. Create a bot with @BotFather and copy its token.
125
- 2. In **Connections**, add a Telegram connection, pick the agent that answers, and list the people (and groups) allowed to talk to it.
121
+ 1. Create a bot with @BotFather and copy its token, or create a Slack app with Socket Mode and copy its bot token (`xoxb-`) and app token (`xapp-`) ([manual, chapter 9](docs/manual/en/09-telegram-connections.md#slack)).
122
+ 2. In **Connections**, add a Telegram or Slack connection, pick the agent that answers, and list the people (and groups or channels) allowed to talk to it.
126
123
  3. Optional: set the group mode (`mention` or `open`), an alias, whether files are accepted, and an image model.
127
124
 
128
125
  The agent answers through a `channel_reply` tool: its plain text is never delivered. Only authorized people and groups reach it,
129
- checked on the server. The bot token is stored in `~/.hive-am/hive-am.db` and never returned by the API; see the note about it in
126
+ checked on the server. Tokens are stored in `~/.hive-am/hive-am.db` and never returned by the API; see the note about it in
130
127
  [docs/14](docs/reference/en/14-external-connections.md).
131
128
 
132
129
  ## How it is built
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.4.0",
3
+ "version": "1.6.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",
@@ -37,3 +37,29 @@ export function mdToTelegramHtml(md) {
37
37
  s = s.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>');
38
38
  return s.replace(/\u0000(\d+)\u0000/g, (_m, i) => stash[Number(i)]);
39
39
  }
40
+ /** Slack's three control characters; everything else goes as it is. */
41
+ export const escapeSlack = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
42
+ /**
43
+ * Markdown → Slack's `mrkdwn`: `*bold*`, `_italic_`, `~strike~`, code as it is, `<url|label>` links, headings as bold lines.
44
+ * Slack's own tokens the agent may write (`<@U123>`, `<#C123>`, `<!here>`, `<https://…|…>`) are kept; any other `&`, `<`, `>` is escaped.
45
+ */
46
+ export function mdToSlackMrkdwn(md) {
47
+ md = emojiPresentation(md);
48
+ const stash = [];
49
+ const keep = (s) => `\u0000${stash.push(s) - 1}\u0000`;
50
+ let s = md.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_m, code) => keep(`\`\`\`\n${escapeSlack(code.replace(/\n$/, ''))}\n\`\`\``));
51
+ s = s.replace(/`([^`\n]+)`/g, (_m, code) => keep(`\`${escapeSlack(code)}\``));
52
+ s = s.replace(/<(?:[@#][A-Z0-9]+(?:\|[^>\n]*)?|![a-z]+(?:\^[A-Z0-9]+)?(?:\|[^>\n]*)?|(?:https?|mailto):[^\s|>]+(?:\|[^>\n]+)?)>/g, (t) => keep(t));
53
+ s = s.replace(/\[([^\]\n]+)\]\(((?:https?:\/\/|mailto:)[^\s)]+)\)/g, (_m, label, url) => keep(`<${url}|${escapeSlack(label).replace(/\|/g, '¦')}>`));
54
+ s = escapeSlack(s);
55
+ s = s.replace(/^#{1,6}\s+(.+)$/gm, (_m, h) => keep(`*${h.replace(/\*\*/g, '').trim()}*`));
56
+ s = s.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/gm, '$1_$2_');
57
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, (_m, b) => keep(`*${b}*`)).replace(/__([^_\n]+)__/g, (_m, b) => keep(`*${b}*`));
58
+ s = s.replace(/~~([^~\n]+)~~/g, '~$1~');
59
+ s = s.replace(/^(\s*)[-*]\s+/gm, '$1• ');
60
+ s = s.replace(/^&gt;\s?/gm, '> ');
61
+ // Stashed pieces can hold other stashed pieces (a link inside a heading): resolve until none is left.
62
+ for (let i = 0; i < 3 && s.includes('\u0000'); i++)
63
+ s = s.replace(/\u0000(\d+)\u0000/g, (_m, n) => stash[Number(n)]);
64
+ return s;
65
+ }
@@ -1,3 +1,5 @@
1
1
  import { registerFactory } from './manager.js';
2
+ import { SlackAdapter } from './slack.js';
2
3
  import { TelegramAdapter } from './telegram.js';
3
4
  registerFactory('telegram', (c) => new TelegramAdapter(c));
5
+ registerFactory('slack', (c) => new SlackAdapter(c));