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
@@ -0,0 +1,488 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import WebSocket from 'ws';
3
+ import { MAX_FILE_BYTES } from './files.js';
4
+ import { mdToSlackMrkdwn, splitMessage } from './format.js';
5
+ import { threads } from './store.js';
6
+ const MAX_PART = 3500; // Slack takes far more, but long messages are cut in the timeline: keep each part readable
7
+ const MIN_GAP_MS = 1000; // Slack allows about one message per second per channel
8
+ const CALL_TIMEOUT_MS = Number(process.env.HIVE_AM_SLACK_TIMEOUT_MS) || 30_000;
9
+ const UPLOAD_TIMEOUT_MS = 120_000;
10
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
11
+ const HELLO_TIMEOUT_MS = 15_000; // a socket that does not say hello in this long is not going to
12
+ const PING_EVERY_MS = 30_000; // a half-open socket never errors by itself: ping it, and drop it when it does not answer
13
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
14
+ const within = (ms, signal) => (signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms));
15
+ const timedOut = (e) => e instanceof Error && e.name === 'TimeoutError';
16
+ /**
17
+ * The emoji the agent may react with on Slack, and the name Slack knows each one by (`reactions.add` takes names, not characters).
18
+ * Slack accepts any standard emoji; this list is the useful ones, without the rude.
19
+ */
20
+ export const SLACK_REACTION_NAMES = {
21
+ '👍': '+1', '👎': '-1', '❤': 'heart', '🔥': 'fire', '👏': 'clap', '😁': 'grin', '😂': 'joy', '🤣': 'rolling_on_the_floor_laughing', '😅': 'sweat_smile',
22
+ '🤔': 'thinking_face', '🤯': 'exploding_head', '😱': 'scream', '😢': 'cry', '😭': 'sob', '🎉': 'tada', '🤩': 'star-struck', '🙏': 'pray', '👌': 'ok_hand',
23
+ '😍': 'heart_eyes', '💯': '100', '⚡': 'zap', '🏆': 'trophy', '💔': 'broken_heart', '😐': 'neutral_face', '😴': 'sleeping', '👀': 'eyes', '🙈': 'see_no_evil',
24
+ '😇': 'innocent', '🤝': 'handshake', '✍': 'writing_hand', '🤗': 'hugging_face', '🫡': 'saluting_face', '😎': 'sunglasses', '🤷': 'shrug', '🤓': 'nerd_face',
25
+ '✅': 'white_check_mark', '☑': 'ballot_box_with_check', '✔': 'heavy_check_mark', '❌': 'x', '⚠': 'warning', '🚀': 'rocket', '👋': 'wave', '🙌': 'raised_hands',
26
+ '💪': 'muscle', '⏳': 'hourglass_flowing_sand', '🐛': 'bug', '📌': 'pushpin', '⭐': 'star', '🙂': 'slightly_smiling_face', '😊': 'blush', '😉': 'wink', '🆗': 'ok',
27
+ '➕': 'heavy_plus_sign', '🧐': 'face_with_monocle', '💡': 'bulb', '📝': 'memo', '🔍': 'mag', '🛠': 'hammer_and_wrench', '🥳': 'partying_face', '😬': 'grimacing',
28
+ // The rest of Telegram's list, so an agent that learnt that list is never refused on Slack.
29
+ '🥰': 'smiling_face_with_3_hearts', '🕊': 'dove_of_peace', '🤡': 'clown_face', '🥱': 'yawning_face', '🥴': 'woozy_face', '🐳': 'whale', '❤‍🔥': 'heart_on_fire',
30
+ '🌚': 'new_moon_with_face', '🌭': 'hotdog', '🍌': 'banana', '🤨': 'face_with_raised_eyebrow', '🍓': 'strawberry', '🍾': 'champagne', '💋': 'kiss', '😈': 'smiling_imp',
31
+ '👻': 'ghost', '👨‍💻': 'male-technologist', '🎃': 'jack_o_lantern', '😨': 'fearful', '🎅': 'santa', '🎄': 'christmas_tree', '☃': 'snowman', '💅': 'nail_care',
32
+ '🤪': 'zany_face', '🗿': 'moyai', '🆒': 'cool', '💘': 'cupid', '🙉': 'hear_no_evil', '🦄': 'unicorn_face', '😘': 'kissing_heart', '💊': 'pill', '🙊': 'speak_no_evil',
33
+ '👾': 'space_invader', '🤷‍♂': 'man-shrugging', '🤷‍♀': 'woman-shrugging',
34
+ };
35
+ export const SLACK_REACTIONS = Object.keys(SLACK_REACTION_NAMES);
36
+ const NAME_OF = new Map(Object.entries(SLACK_REACTION_NAMES).map(([e, n]) => [e.replace(/️/g, ''), n]));
37
+ const KNOWN_NAMES = new Set([...Object.values(SLACK_REACTION_NAMES), 'thumbsup', 'thumbsdown']);
38
+ /** The commands hive-am understands (router.ts). On Slack they may come as their own slash command, as `/hive-status`, or as `/hive status`. */
39
+ const COMMANDS = new Set(['new', 'clear', 'reset', 'stop', 'status', 'mute', 'unmute', 'help', 'id']);
40
+ export function slashToCommand(command, text) {
41
+ const name = command.replace(/^\//, '').toLowerCase(), args = text.trim();
42
+ if (COMMANDS.has(name))
43
+ return { name, args };
44
+ const suffix = /[-_](\w+)$/.exec(name)?.[1];
45
+ if (suffix && COMMANDS.has(suffix))
46
+ return { name: suffix, args };
47
+ const [first = 'help', ...rest] = args ? args.split(/\s+/) : [];
48
+ return { name: first.replace(/^\//, '').toLowerCase(), args: rest.join(' ') };
49
+ }
50
+ class SlackError extends Error {
51
+ code;
52
+ method;
53
+ retryAfter;
54
+ constructor(msg, code, method, retryAfter) {
55
+ super(msg);
56
+ this.code = code;
57
+ this.method = method;
58
+ this.retryAfter = retryAfter;
59
+ }
60
+ }
61
+ const AUTH_ERRORS = new Set(['invalid_auth', 'not_authed', 'account_inactive', 'token_revoked', 'token_expired']);
62
+ /** What a Slack API error means to the person setting up the connection. */
63
+ function readable(e, method = e.method) {
64
+ if (AUTH_ERRORS.has(e.code))
65
+ return method === 'apps.connections.open' ? 'Invalid app token' : 'Invalid bot token';
66
+ if (e.code === 'not_allowed_token_type')
67
+ return method === 'apps.connections.open' ? 'The app token must be an app-level token (xapp-…) with connections:write' : 'The bot token must be a bot token (xoxb-…)';
68
+ if (e.code === 'missing_scope')
69
+ return `The Slack app lacks a permission for ${method}: add the scopes from the manual and reinstall it`;
70
+ if (e.code === 'not_in_channel')
71
+ return 'The app is not in this channel: invite it with /invite @your-app';
72
+ if (e.code === 'channel_not_found')
73
+ return 'Slack does not know this channel (or the app cannot see it)';
74
+ return e.message;
75
+ }
76
+ /** The files of a Slack message (they come in the event itself, so there is nothing to assemble). The download URL is the attachment's id. */
77
+ function attachmentsOf(ev) {
78
+ const out = [];
79
+ for (const f of (Array.isArray(ev.files) ? ev.files : [])) {
80
+ const url = f.url_private_download ?? f.url_private;
81
+ if (!url || f.mode === 'tombstone' || f.file_access === 'check_file_info')
82
+ continue;
83
+ const mime = String(f.mimetype ?? '');
84
+ const kind = f.subtype === 'slack_audio' ? 'voice' : mime.startsWith('image/') ? 'image' : mime.startsWith('audio/') ? 'audio' : mime.startsWith('video/') ? 'video' : 'document';
85
+ out.push({ id: url, name: f.name ?? f.title ?? `file-${f.id}`, kind, mime: mime || undefined, size: f.size });
86
+ }
87
+ return out;
88
+ }
89
+ /** Slack Web API over plain `fetch` and Socket Mode over a WebSocket: events come through a socket this machine opens, so no public URL is needed. */
90
+ export class SlackAdapter {
91
+ conn;
92
+ kind = 'slack';
93
+ state = { state: 'connecting' };
94
+ me = null;
95
+ stopped = false;
96
+ abort = new AbortController();
97
+ ws = null;
98
+ reconnecting = false;
99
+ pinger;
100
+ alive = true;
101
+ onMessage = null;
102
+ order = Promise.resolve(); // events are parsed one after another, so they reach the router in the order they came
103
+ lastSend = new Map();
104
+ names = new Map(); // user id → display name
105
+ places = new Map(); // channel id → `#name`
106
+ constructor(conn) {
107
+ this.conn = conn;
108
+ }
109
+ get base() { return (this.conn.config.api_base || 'https://slack.com/api').replace(/\/$/, ''); }
110
+ /** One Web API call, form-encoded (every method takes it; objects go as JSON, as Slack expects). */
111
+ async api(method, params = {}, token = this.conn.config.bot_token) {
112
+ const body = new URLSearchParams();
113
+ for (const [k, v] of Object.entries(params))
114
+ if (v !== undefined && v !== null)
115
+ body.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
116
+ let res;
117
+ try {
118
+ res = await fetch(`${this.base}/${method}`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/x-www-form-urlencoded' }, body, signal: within(CALL_TIMEOUT_MS, this.abort.signal) });
119
+ }
120
+ catch (e) {
121
+ throw timedOut(e) ? new SlackError(`Slack ${method} did not answer in time`, 'timeout', method) : e;
122
+ }
123
+ if (res.status === 429)
124
+ throw new SlackError(`Slack ${method} is rate limited`, 'ratelimited', method, Number(res.headers.get('retry-after')) || 1);
125
+ const j = await res.json().catch(() => ({}));
126
+ if (!j.ok)
127
+ throw new SlackError(j.error ? `Slack ${method}: ${j.error}` : `Slack ${method} failed (${res.status})`, j.error ?? String(res.status), method);
128
+ return j;
129
+ }
130
+ async start(onMessage) {
131
+ this.stopped = false;
132
+ this.abort = new AbortController();
133
+ this.onMessage = onMessage;
134
+ clearInterval(this.pinger);
135
+ this.pinger = setInterval(() => {
136
+ const ws = this.ws;
137
+ if (!ws || ws.readyState !== WebSocket.OPEN)
138
+ return;
139
+ if (!this.alive) {
140
+ ws.terminate();
141
+ return;
142
+ } // `close` follows and reconnects
143
+ this.alive = false;
144
+ ws.ping();
145
+ }, PING_EVERY_MS);
146
+ this.pinger.unref?.();
147
+ try {
148
+ this.me = await this.api('auth.test');
149
+ await this.connect();
150
+ }
151
+ catch (e) {
152
+ this.state = { state: 'error', detail: e instanceof SlackError ? readable(e) : e.message };
153
+ if (!(e instanceof SlackError && AUTH_ERRORS.has(e.code)))
154
+ this.reconnect(); // a network problem at start: keep trying, like Telegram's polling does
155
+ throw new Error(this.state.detail);
156
+ }
157
+ }
158
+ async stop() {
159
+ this.stopped = true;
160
+ this.abort.abort();
161
+ clearInterval(this.pinger);
162
+ const ws = this.ws;
163
+ this.ws = null;
164
+ ws?.terminate();
165
+ this.onMessage = null;
166
+ this.state = { state: 'stopped' };
167
+ }
168
+ status() { return this.state; }
169
+ connected() { return { state: 'connected', detail: `@${this.me?.user} · ${this.me?.team}`, lastEventAt: this.state.lastEventAt }; }
170
+ /** Opens a socket (`apps.connections.open` gives its URL) and resolves on Slack's `hello`. The previous socket, if any, is closed only then, so a refresh loses nothing. */
171
+ async connect() {
172
+ const { url } = await this.api('apps.connections.open', {}, this.conn.config.app_token);
173
+ if (this.stopped)
174
+ return;
175
+ const ws = new WebSocket(url);
176
+ await new Promise((resolve, reject) => {
177
+ let hello = false;
178
+ const timer = setTimeout(() => { ws.terminate(); reject(new Error('Slack did not open the socket in time')); }, HELLO_TIMEOUT_MS);
179
+ ws.on('message', (data) => {
180
+ let f;
181
+ try {
182
+ f = JSON.parse(String(data));
183
+ }
184
+ catch {
185
+ return;
186
+ }
187
+ if (f.type === 'hello') {
188
+ hello = true;
189
+ clearTimeout(timer);
190
+ const old = this.ws;
191
+ this.ws = ws;
192
+ this.alive = true;
193
+ this.state = this.connected();
194
+ if (old && old !== ws)
195
+ old.close();
196
+ resolve();
197
+ return;
198
+ }
199
+ this.frame(ws, f);
200
+ });
201
+ ws.on('pong', () => { this.alive = true; });
202
+ ws.on('error', (e) => { if (!hello) {
203
+ clearTimeout(timer);
204
+ reject(e);
205
+ }
206
+ else
207
+ console.warn(`[slack] ${this.conn.name}: socket error: ${e.message}`); });
208
+ ws.on('close', () => {
209
+ if (!hello) {
210
+ clearTimeout(timer);
211
+ reject(new Error('Slack closed the socket before it opened'));
212
+ return;
213
+ }
214
+ if (this.ws === ws) {
215
+ this.ws = null;
216
+ this.reconnect();
217
+ } // a socket replaced by a newer one closes quietly
218
+ });
219
+ });
220
+ if (this.stopped)
221
+ ws.terminate();
222
+ }
223
+ /** Opens a new socket, waiting longer after each failure. A rejected token stops it: retrying will not fix that. */
224
+ reconnect() {
225
+ if (this.stopped || this.reconnecting)
226
+ return;
227
+ this.reconnecting = true;
228
+ void (async () => {
229
+ let wait = 1000;
230
+ while (!this.stopped) {
231
+ try {
232
+ if (!this.me)
233
+ this.me = await this.api('auth.test');
234
+ await this.connect();
235
+ break;
236
+ }
237
+ catch (e) {
238
+ if (this.stopped)
239
+ break;
240
+ this.state = { state: 'error', detail: e instanceof SlackError ? readable(e) : e.message };
241
+ if (e instanceof SlackError && AUTH_ERRORS.has(e.code))
242
+ break;
243
+ await sleep(e instanceof SlackError && e.retryAfter ? e.retryAfter * 1000 : wait);
244
+ wait = Math.min(wait * 2, 30_000);
245
+ }
246
+ }
247
+ this.reconnecting = false;
248
+ })();
249
+ }
250
+ /** One frame of the socket. Acknowledged first, so a slow turn never makes Slack send it again. */
251
+ frame(ws, f) {
252
+ if (f.envelope_id)
253
+ ws.send(JSON.stringify({ envelope_id: f.envelope_id }));
254
+ if (f.type === 'disconnect') {
255
+ if (this.ws === ws)
256
+ this.reconnect();
257
+ return;
258
+ } // Slack refreshes the socket every few hours
259
+ const parsed = f.type === 'events_api' ? () => this.parseEvent(f.payload?.event) : f.type === 'slash_commands' ? () => this.parseCommand(f.payload) : null;
260
+ if (!parsed)
261
+ return;
262
+ this.order = this.order.then(async () => {
263
+ const m = await parsed();
264
+ if (!m || !this.onMessage)
265
+ return;
266
+ this.state = { ...this.state, lastEventAt: Date.now() };
267
+ void this.onMessage(m).catch((e) => console.error('[slack] handler failed:', e));
268
+ }).catch((e) => console.error(`[slack] ${this.conn.name}: could not read an event:`, e));
269
+ }
270
+ async userName(id) {
271
+ if (!id)
272
+ return '';
273
+ const known = this.names.get(id);
274
+ if (known)
275
+ return known;
276
+ try {
277
+ const { user } = await this.api('users.info', { user: id });
278
+ const name = user?.profile?.display_name || user?.real_name || user?.name || id;
279
+ this.names.set(id, name);
280
+ return name;
281
+ }
282
+ catch {
283
+ return id;
284
+ }
285
+ }
286
+ async placeOf(channel, type) {
287
+ if (type === 'im')
288
+ return 'DM';
289
+ const known = this.places.get(channel);
290
+ if (known)
291
+ return known;
292
+ let place = type === 'mpim' ? 'group DM' : `#${channel}`;
293
+ try {
294
+ const { channel: c } = await this.api('conversations.info', { channel });
295
+ if (c?.name && type !== 'mpim')
296
+ place = `#${c.name}`;
297
+ }
298
+ catch { /* without channels:read the id has to do */ }
299
+ this.places.set(channel, place);
300
+ return place;
301
+ }
302
+ /** Slack's markup the agent should read as plain text: `<@U1>` → `@name`, `<#C1|deploys>` → `#deploys`, `<url|label>` → `label (url)`. */
303
+ async plain(text) {
304
+ const ids = [...new Set([...text.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].map((x) => x[1]))];
305
+ const named = new Map(await Promise.all(ids.map(async (id) => [id, await this.userName(id)])));
306
+ return text
307
+ .replace(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g, (_m, id) => `@${named.get(id) ?? id}`)
308
+ .replace(/<#[CG][A-Z0-9]+\|([^>]*)>/g, '#$1').replace(/<#([CG][A-Z0-9]+)>/g, '#$1')
309
+ .replace(/<!(here|channel|everyone)(?:\|[^>]*)?>/g, '@$1').replace(/<!subteam\^[A-Z0-9]+\|([^>]*)>/g, '$1')
310
+ .replace(/<((?:https?|mailto):[^|>]+)\|([^>]+)>/g, '$2 ($1)').replace(/<((?:https?|mailto):[^>]+)>/g, '$1')
311
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
312
+ }
313
+ /** A Slack message event → what the router understands; null when it is not for us. */
314
+ async parseEvent(ev) {
315
+ if (!ev || !this.me || (ev.type !== 'message' && ev.type !== 'app_mention'))
316
+ return null;
317
+ if (ev.bot_id || ev.user === this.me.user_id || !ev.user || ev.hidden)
318
+ return null;
319
+ if (ev.subtype && ev.subtype !== 'file_share' && ev.subtype !== 'thread_broadcast')
320
+ return null; // edits, deletions, joins…
321
+ const channel = String(ev.channel ?? '');
322
+ const type = ev.channel_type ?? (channel.startsWith('D') ? 'im' : 'channel');
323
+ const dm = type === 'im';
324
+ const raw = ev.text ?? '';
325
+ const self = new RegExp(`<@${this.me.user_id}(?:\\|[^>]*)?>`, 'g');
326
+ const mentioned = ev.type === 'app_mention' || self.test(raw);
327
+ const attachments = attachmentsOf(ev);
328
+ const text = (await this.plain(raw.replace(self, ''))).trim();
329
+ if (!text && !attachments.length)
330
+ return null;
331
+ // One conversation is one Slack thread: a message in the timeline is the root of its own thread.
332
+ const root = String(ev.thread_ts ?? ev.ts);
333
+ const key = dm ? channel : `${channel}:${root}`;
334
+ const inThread = !!ev.thread_ts && ev.thread_ts !== ev.ts;
335
+ const cmd = /^\/(\w+)(?:\s+([\s\S]*))?$/.exec(text);
336
+ const ownThread = inThread && (ev.parent_user_id === this.me.user_id || threads.answered(this.conn.id, key));
337
+ const addressed = dm || mentioned || !!cmd || ownThread;
338
+ // Meant for another person of the channel: an @mention of somebody else (and not of the agent).
339
+ let directedAt;
340
+ if (!dm && !mentioned) {
341
+ const other = [...raw.matchAll(/<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g)].map((x) => x[1]).find((id) => id !== ev.user && id !== this.me.user_id);
342
+ if (other)
343
+ directedAt = await this.userName(other);
344
+ }
345
+ return {
346
+ externalId: String(ev.ts),
347
+ externalKey: key,
348
+ userId: String(ev.user),
349
+ userName: await this.userName(String(ev.user)),
350
+ text,
351
+ target: dm ? { chat: channel } : { chat: channel, thread: root },
352
+ place: await this.placeOf(channel, type),
353
+ command: cmd ? { name: cmd[1].toLowerCase(), args: (cmd[2] ?? '').trim() } : undefined,
354
+ group: !dm,
355
+ addressed,
356
+ ...(directedAt ? { directedAt } : {}),
357
+ ...(attachments.length ? { attachments } : {}),
358
+ };
359
+ }
360
+ /** A slash command. Slack sends no thread with it, so the answer goes to the channel (or the DM) it was typed in. */
361
+ async parseCommand(p) {
362
+ if (!p?.command || !p.channel_id || !p.user_id)
363
+ return null;
364
+ const channel = String(p.channel_id), dm = channel.startsWith('D');
365
+ const type = dm ? 'im' : p.channel_name === 'mpdm' || String(p.channel_name ?? '').startsWith('mpdm-') ? 'mpim' : 'channel';
366
+ const command = slashToCommand(String(p.command), String(p.text ?? ''));
367
+ return {
368
+ externalId: `cmd-${p.trigger_id ?? Date.now()}`,
369
+ externalKey: channel,
370
+ userId: String(p.user_id),
371
+ userName: await this.userName(String(p.user_id)),
372
+ text: `/${command.name}${command.args ? ` ${command.args}` : ''}`,
373
+ target: { chat: channel },
374
+ place: dm ? 'DM' : p.channel_name && p.channel_name !== 'privategroup' && type === 'channel' ? `#${p.channel_name}` : await this.placeOf(channel, type),
375
+ command,
376
+ group: !dm,
377
+ addressed: true,
378
+ };
379
+ }
380
+ /** Keeps one channel's messages spaced out and honours Slack's `Retry-After` when it pushes back. */
381
+ async withGap(chat, fn) {
382
+ for (let attempt = 0;; attempt++) {
383
+ const gap = MIN_GAP_MS - (Date.now() - (this.lastSend.get(chat) ?? 0));
384
+ if (gap > 0)
385
+ await sleep(gap);
386
+ this.lastSend.set(chat, Date.now());
387
+ try {
388
+ return await fn();
389
+ }
390
+ catch (e) {
391
+ if (e instanceof SlackError && e.code === 'ratelimited' && attempt < 3) {
392
+ await sleep(Math.min(e.retryAfter ?? 1, 30) * 1000);
393
+ continue;
394
+ }
395
+ throw e instanceof SlackError ? new Error(readable(e)) : e;
396
+ }
397
+ }
398
+ }
399
+ async send(to, text) {
400
+ let last = '';
401
+ for (const part of splitMessage(text, MAX_PART)) {
402
+ const sent = await this.withGap(to.chat, () => this.api('chat.postMessage', { channel: to.chat, thread_ts: to.thread, text: mdToSlackMrkdwn(part), unfurl_links: false, unfurl_media: false }));
403
+ last = String(sent.ts);
404
+ }
405
+ return { externalId: last };
406
+ }
407
+ /** Slack has no «typing…» for bots, and a reaction on every message would be noise: nothing is shown while a turn runs. */
408
+ async busy() { }
409
+ /** Scheduled messages and notices go to the channel's timeline, not into the thread they were asked from. A DM has no threads. */
410
+ proactive(to) { return { chat: to.chat }; }
411
+ /** `<@U123>` notifies the person. */
412
+ mention(u) { return `<@${u.id}>`; }
413
+ /** What the agent may react with on Slack; each emoji goes to `reactions.add` by its name. `:name:` is accepted too. */
414
+ reactions = SLACK_REACTIONS;
415
+ async react(to, ref, emoji) {
416
+ const wanted = emoji.replace(/️/g, '').trim();
417
+ const byName = /^:?([a-z0-9_+-]+):?$/.exec(wanted)?.[1];
418
+ const name = byName && KNOWN_NAMES.has(byName) ? byName : NAME_OF.get(wanted);
419
+ if (!name)
420
+ throw new Error(`“${emoji}” is not one of the reactions hive-am uses on Slack. The ones you can use: ${SLACK_REACTIONS.join(' ')}`);
421
+ if (!ref || ref.startsWith('sched-') || ref.startsWith('cmd-'))
422
+ throw new Error('There is no message to react to.');
423
+ try {
424
+ await this.api('reactions.add', { channel: to.chat, timestamp: ref, name });
425
+ }
426
+ catch (e) {
427
+ if (e instanceof SlackError && e.code === 'already_reacted')
428
+ return;
429
+ if (e instanceof SlackError && e.code === 'invalid_name')
430
+ throw new Error(`Slack does not know the emoji “${emoji}”. Try another one, or answer with text.`);
431
+ throw e instanceof SlackError ? new Error(readable(e)) : e;
432
+ }
433
+ }
434
+ /** Slack's upload in three steps: ask for an upload URL, send the bytes there, then share the file in the channel (and thread) with the caption. */
435
+ async sendFile(to, file, caption) {
436
+ const bytes = readFileSync(file.path);
437
+ try {
438
+ const up = await this.api('files.getUploadURLExternal', { filename: file.name, length: bytes.length });
439
+ const res = await fetch(up.upload_url, { method: 'POST', headers: { 'content-type': file.mime || 'application/octet-stream' }, body: bytes, signal: within(UPLOAD_TIMEOUT_MS, this.abort.signal) });
440
+ if (!res.ok)
441
+ throw new Error(`Slack did not take the file (${res.status})`);
442
+ await this.withGap(to.chat, () => this.api('files.completeUploadExternal', {
443
+ files: [{ id: up.file_id, title: file.name }], channel_id: to.chat, thread_ts: to.thread, initial_comment: caption ? mdToSlackMrkdwn(caption) : undefined,
444
+ }));
445
+ return { externalId: up.file_id };
446
+ }
447
+ catch (e) {
448
+ throw e instanceof SlackError ? new Error(readable(e)) : timedOut(e) ? new Error('Slack did not take the file in time') : e;
449
+ }
450
+ }
451
+ /** Files are private: the URL needs the bot token (and the `files:read` scope; without it Slack answers with a login page). */
452
+ async download(att, dest) {
453
+ const res = await fetch(att.id, { headers: { authorization: `Bearer ${this.conn.config.bot_token}` }, signal: within(DOWNLOAD_TIMEOUT_MS, this.abort.signal) });
454
+ if (!res.ok)
455
+ throw new Error(`download failed (${res.status})`);
456
+ if (/text\/html/i.test(res.headers.get('content-type') ?? '') && !/html/i.test(att.mime ?? ''))
457
+ throw new Error('Slack answered with a login page: the app needs the files:read scope');
458
+ if (Number(res.headers.get('content-length') ?? 0) > MAX_FILE_BYTES)
459
+ throw new Error('too big (limit 20 MB)');
460
+ const buf = Buffer.from(await res.arrayBuffer());
461
+ if (buf.length > MAX_FILE_BYTES)
462
+ throw new Error('too big (limit 20 MB)');
463
+ writeFileSync(dest, buf);
464
+ return buf.length;
465
+ }
466
+ /** Checks the bot token and greets each allowed person by DM. */
467
+ async test(userIds) {
468
+ let me;
469
+ try {
470
+ me = await this.api('auth.test');
471
+ }
472
+ catch (e) {
473
+ throw e instanceof SlackError ? new Error(readable(e)) : e;
474
+ }
475
+ const failed = [];
476
+ for (const id of userIds) {
477
+ try {
478
+ const { channel } = await this.api('conversations.open', { users: id });
479
+ await this.send({ chat: channel.id }, `✅ hive-am is connected as @${me.user} in ${me.team}.`);
480
+ }
481
+ catch (e) {
482
+ failed.push(`${id}: ${e instanceof SlackError ? readable(e) : e.message}`);
483
+ }
484
+ }
485
+ const ok = userIds.length - failed.length;
486
+ return `@${me.user} · ${me.team} · greeted ${ok} of ${userIds.length}${failed.length ? ` (${failed.join('; ')})` : ''}`;
487
+ }
488
+ }
@@ -46,6 +46,8 @@ export const threads = {
46
46
  .run(id, connectionId, externalKey, info.title, JSON.stringify(info.target), info.user, t, t);
47
47
  return threads.get(id);
48
48
  },
49
+ /** The agent already wrote in this thread (Slack: people who keep talking in a thread it answered are talking to it). */
50
+ answered: (connectionId, externalKey) => !!db.prepare("SELECT 1 FROM threads t JOIN thread_messages m ON m.thread_id=t.id WHERE t.connection_id=? AND t.external_key=? AND m.direction='out' LIMIT 1").get(connectionId, externalKey),
49
51
  setMuted: (id, muted) => { db.prepare('UPDATE threads SET muted=? WHERE id=?').run(muted ? 1 : 0, id); },
50
52
  markSeen: (id, upTo) => { db.prepare('UPDATE threads SET seen_id=MAX(seen_id, ?) WHERE id=?').run(upTo, id); },
51
53
  };
@@ -189,6 +189,86 @@ CREATE TABLE IF NOT EXISTS object_agents (
189
189
  addColumn(db, 'agents', 'browser INTEGER NOT NULL DEFAULT 0');
190
190
  },
191
191
  },
192
+ {
193
+ version: 4, name: 'session-permission',
194
+ up(db) {
195
+ // The effective permission an OpenCode session started with. NULL for sessions recorded before this column existed (unknown).
196
+ addColumn(db, 'agent_sessions', 'permission TEXT');
197
+ },
198
+ },
199
+ {
200
+ version: 5, name: 'dispatch-created-index',
201
+ up(db) {
202
+ // The retention pass orders the delegation log by this when it keeps the newest ones.
203
+ db.exec('CREATE INDEX IF NOT EXISTS idx_dispatches_created ON dispatches (created_at, id)');
204
+ },
205
+ },
206
+ {
207
+ version: 6, name: 'shared-notebook',
208
+ up(db) {
209
+ // A notebook stops belonging to one agent: it is its own row, with readers (an agent and its clones read and write the same one).
210
+ // `seen` moves to the reader, because each agent has its own live conversation and must be sent the text again on its own.
211
+ db.exec(`
212
+ CREATE TABLE IF NOT EXISTS notebooks (
213
+ id TEXT PRIMARY KEY, content TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 0,
214
+ updated_at INTEGER NOT NULL DEFAULT 0, updated_by TEXT NOT NULL DEFAULT ''
215
+ );
216
+ CREATE TABLE IF NOT EXISTS notebook_readers (
217
+ agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
218
+ notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
219
+ seen INTEGER NOT NULL DEFAULT 0
220
+ );
221
+ -- A notebook with no reader left goes with its last reader, however that reader goes (deleting the agent, or moving to another notebook).
222
+ CREATE TRIGGER IF NOT EXISTS notebook_orphan AFTER DELETE ON notebook_readers
223
+ BEGIN
224
+ DELETE FROM notebooks WHERE id = OLD.notebook_id AND NOT EXISTS (SELECT 1 FROM notebook_readers WHERE notebook_id = OLD.notebook_id);
225
+ END;
226
+ `);
227
+ // Every notebook of the old table becomes its own notebook with its agent as the only reader, keeping text, version, who wrote last and `seen`.
228
+ const old = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='agent_notebooks'").get();
229
+ if (old) {
230
+ const rows = db.prepare('SELECT agent_id, content, version, seen, updated_at, updated_by FROM agent_notebooks').all();
231
+ const has = db.prepare('SELECT 1 FROM notebook_readers WHERE agent_id=?');
232
+ const insNb = db.prepare('INSERT INTO notebooks (id, content, version, updated_at, updated_by) VALUES (?,?,?,?,?)');
233
+ const insReader = db.prepare('INSERT INTO notebook_readers (agent_id, notebook_id, seen) VALUES (?,?,?)');
234
+ for (const r of rows) {
235
+ if (has.get(r.agent_id))
236
+ continue; // already moved (the migration ran before)
237
+ const id = `nb-${r.agent_id}`;
238
+ insNb.run(id, r.content, r.version, r.updated_at, r.updated_by);
239
+ insReader.run(r.agent_id, id, r.seen);
240
+ }
241
+ db.exec('DROP TABLE agent_notebooks');
242
+ }
243
+ },
244
+ },
245
+ {
246
+ version: 7, name: 'agent-cloned-from',
247
+ up(db) {
248
+ // The agent a clone was made from, so the interface can say it (the shared notebook says it only until the clone separates). NULL for any other agent, and when the source is deleted.
249
+ addColumn(db, 'agents', 'cloned_from TEXT REFERENCES agents(id) ON DELETE SET NULL');
250
+ },
251
+ },
252
+ {
253
+ version: 8, name: 'reasoning-effort',
254
+ up(db) {
255
+ // The reasoning effort of an agent (and of the type it is made from): '' is the CLI's own default, which is what every existing agent keeps.
256
+ addColumn(db, 'agents', "effort TEXT NOT NULL DEFAULT ''");
257
+ addColumn(db, 'agent_types', "effort TEXT NOT NULL DEFAULT ''");
258
+ },
259
+ },
260
+ {
261
+ version: 9, name: 'dispatch-background',
262
+ up(db) {
263
+ // A delegation can run in the background: the orchestrator is told the result where it asked (`origin`, JSON of the chat thread, NULL for
264
+ // the web conversation) and `notified_at` records that the message was handed over, `read_at` that it read the result itself.
265
+ // Every existing row was a synchronous one, whose answer went back on the request: none of them is ever announced.
266
+ addColumn(db, 'dispatches', "mode TEXT NOT NULL DEFAULT 'sync'");
267
+ addColumn(db, 'dispatches', 'origin TEXT');
268
+ addColumn(db, 'dispatches', 'notified_at INTEGER');
269
+ addColumn(db, 'dispatches', 'read_at INTEGER');
270
+ },
271
+ },
192
272
  ];
193
273
  export const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
194
274
  /** Brings a database up to the current schema. Returns the versions it applied (empty when it was already current). */