hive-am 1.5.0 → 1.7.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 (142) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +5 -4
  3. package/package.json +1 -1
  4. package/server/dist/connections/format.js +26 -0
  5. package/server/dist/connections/index.js +2 -0
  6. package/server/dist/connections/manager.js +3 -3
  7. package/server/dist/connections/router.js +42 -12
  8. package/server/dist/connections/secrets.js +1 -1
  9. package/server/dist/connections/slack.js +488 -0
  10. package/server/dist/connections/store.js +2 -0
  11. package/server/dist/db/migrations.js +12 -0
  12. package/server/dist/db.js +15 -3
  13. package/server/dist/dispatches.js +126 -0
  14. package/server/dist/http/routes/orchestration.js +13 -2
  15. package/server/dist/http/validate.js +22 -2
  16. package/server/dist/index.js +2 -0
  17. package/server/dist/instructions.js +1 -1
  18. package/server/dist/retention.js +4 -2
  19. package/server/dist/runtime.js +17 -6
  20. package/server/dist/services/connections.js +18 -3
  21. package/server/dist/skills/defaults.js +6 -4
  22. package/server/dist/watch.js +1 -1
  23. package/server/mcp/dispatch.mjs +48 -5
  24. package/web/.next/BUILD_ID +1 -1
  25. package/web/.next/app-build-manifest.json +91 -75
  26. package/web/.next/app-path-routes-manifest.json +5 -5
  27. package/web/.next/build-manifest.json +2 -2
  28. package/web/.next/prerender-manifest.json +61 -61
  29. package/web/.next/server/app/_not-found/page.js +2 -2
  30. package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  31. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  32. package/web/.next/server/app/_not-found.html +1 -1
  33. package/web/.next/server/app/_not-found.rsc +3 -3
  34. package/web/.next/server/app/agents/[id]/page.js +4 -4
  35. package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
  36. package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
  37. package/web/.next/server/app/agents/page.js +2 -2
  38. package/web/.next/server/app/agents/page.js.nft.json +1 -1
  39. package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
  40. package/web/.next/server/app/agents.html +1 -1
  41. package/web/.next/server/app/agents.rsc +21 -18
  42. package/web/.next/server/app/chat/page.js +2 -2
  43. package/web/.next/server/app/chat/page.js.nft.json +1 -1
  44. package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -1
  45. package/web/.next/server/app/chat.html +1 -1
  46. package/web/.next/server/app/chat.rsc +4 -4
  47. package/web/.next/server/app/connections/page.js +2 -2
  48. package/web/.next/server/app/connections/page.js.nft.json +1 -1
  49. package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
  50. package/web/.next/server/app/connections.html +1 -1
  51. package/web/.next/server/app/connections.rsc +4 -4
  52. package/web/.next/server/app/index.html +1 -1
  53. package/web/.next/server/app/index.rsc +4 -4
  54. package/web/.next/server/app/page.js +2 -2
  55. package/web/.next/server/app/page.js.nft.json +1 -1
  56. package/web/.next/server/app/page_client-reference-manifest.js +1 -1
  57. package/web/.next/server/app/relations/page.js +2 -2
  58. package/web/.next/server/app/relations/page.js.nft.json +1 -1
  59. package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
  60. package/web/.next/server/app/relations.html +1 -1
  61. package/web/.next/server/app/relations.rsc +4 -4
  62. package/web/.next/server/app/resources/[id]/page.js +2 -2
  63. package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -1
  64. package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
  65. package/web/.next/server/app/resources/page.js +2 -2
  66. package/web/.next/server/app/resources/page.js.nft.json +1 -1
  67. package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
  68. package/web/.next/server/app/resources.html +1 -1
  69. package/web/.next/server/app/resources.rsc +4 -4
  70. package/web/.next/server/app/schedules/page.js +2 -2
  71. package/web/.next/server/app/schedules/page.js.nft.json +1 -1
  72. package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  73. package/web/.next/server/app/schedules.html +1 -1
  74. package/web/.next/server/app/schedules.rsc +4 -4
  75. package/web/.next/server/app/sessions/page.js +2 -2
  76. package/web/.next/server/app/sessions/page.js.nft.json +1 -1
  77. package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  78. package/web/.next/server/app/sessions.html +1 -1
  79. package/web/.next/server/app/sessions.rsc +4 -4
  80. package/web/.next/server/app/skills/page.js +2 -2
  81. package/web/.next/server/app/skills/page.js.nft.json +1 -1
  82. package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
  83. package/web/.next/server/app/skills.html +1 -1
  84. package/web/.next/server/app/skills.rsc +4 -4
  85. package/web/.next/server/app/types/page.js +2 -2
  86. package/web/.next/server/app/types/page.js.nft.json +1 -1
  87. package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
  88. package/web/.next/server/app/types.html +1 -1
  89. package/web/.next/server/app/types.rsc +4 -4
  90. package/web/.next/server/app-paths-manifest.json +5 -5
  91. package/web/.next/server/chunks/{505.js → 111.js} +1 -1
  92. package/web/.next/server/chunks/143.js +1 -1
  93. package/web/.next/server/chunks/716.js +2 -2
  94. package/web/.next/server/chunks/741.js +2 -0
  95. package/web/.next/server/chunks/837.js +1 -0
  96. package/web/.next/server/chunks/{435.js → 939.js} +3 -3
  97. package/web/.next/server/chunks/944.js +1 -0
  98. package/web/.next/server/pages/404.html +1 -1
  99. package/web/.next/server/pages/500.html +1 -1
  100. package/web/.next/server/server-reference-manifest.json +1 -1
  101. package/web/.next/static/chunks/162-68ba190086f5ad39.js +1 -0
  102. package/web/.next/static/chunks/214-35b88db02113ae02.js +1 -0
  103. package/web/.next/static/chunks/281-a855bf409ef9eebd.js +1 -0
  104. package/web/.next/static/chunks/350-bdf6b8994a28f2b4.js +1 -0
  105. package/web/.next/static/chunks/491-196bcb3dd11bb460.js +1 -0
  106. package/web/.next/static/chunks/517-8f2db464d10a4624.js +1 -0
  107. package/web/.next/static/chunks/{586-f7017d585a84738d.js → 586-4f67b823df060b46.js} +1 -1
  108. package/web/.next/static/chunks/{587-5b511f5f93910180.js → 587-3c53d91a1c4f99d6.js} +1 -1
  109. package/web/.next/static/chunks/642-dc874c8caf203ee1.js +1 -0
  110. package/web/.next/static/chunks/771-db19da9b1a24820c.js +1 -0
  111. package/web/.next/static/chunks/928-09d4a58e977641dc.js +1 -0
  112. package/web/.next/static/chunks/app/agents/[id]/page-9f191f25a366b26b.js +1 -0
  113. package/web/.next/static/chunks/app/agents/layout-71dd98d696935e29.js +1 -0
  114. package/web/.next/static/chunks/app/connections/page-b0ea2eae62cef74e.js +1 -0
  115. package/web/.next/static/chunks/app/layout-cb5af29b0742c513.js +1 -0
  116. package/web/.next/static/chunks/app/page-5e547e87de23d9fa.js +1 -0
  117. package/web/.next/static/chunks/app/resources/[id]/page-1e2afddcb1f04884.js +1 -0
  118. package/web/.next/static/chunks/app/resources/{page-61cccd8fdcc2f6c8.js → page-195b0af83b8ace0d.js} +1 -1
  119. package/web/.next/static/chunks/app/sessions/{page-85638705fdc18174.js → page-f5a98ec3eaffd7a6.js} +1 -1
  120. package/web/.next/static/chunks/app/types/page-c4c2b2217c4667e8.js +1 -0
  121. package/web/.next/static/css/f5438d03b796884e.css +1 -0
  122. package/web/.next/trace +30 -30
  123. package/web/.next/types/routes.d.ts +2 -1
  124. package/web/.next/types/validator.ts +9 -0
  125. package/web/.next/server/chunks/274.js +0 -1
  126. package/web/.next/server/chunks/612.js +0 -1
  127. package/web/.next/static/chunks/107-77dd87cc2ca4f941.js +0 -1
  128. package/web/.next/static/chunks/281-30e32d932b8b5a75.js +0 -1
  129. package/web/.next/static/chunks/350-0fe82e622e7a670f.js +0 -1
  130. package/web/.next/static/chunks/391-18768499e741112e.js +0 -1
  131. package/web/.next/static/chunks/517-b3b1c9044d2ef23c.js +0 -1
  132. package/web/.next/static/chunks/656-f3cc78328603241a.js +0 -1
  133. package/web/.next/static/chunks/705-ce86175dea00e6a9.js +0 -1
  134. package/web/.next/static/chunks/app/agents/[id]/page-638e12ba3cb813c3.js +0 -1
  135. package/web/.next/static/chunks/app/connections/page-06bd2d7d9495126a.js +0 -1
  136. package/web/.next/static/chunks/app/layout-fad968a635f9a4b3.js +0 -1
  137. package/web/.next/static/chunks/app/page-0062ebc30322b1ae.js +0 -1
  138. package/web/.next/static/chunks/app/resources/[id]/page-fc494e85b01e8cb7.js +0 -1
  139. package/web/.next/static/chunks/app/types/page-8e25f503bdcd72ad.js +0 -1
  140. package/web/.next/static/css/dfcaa19a8d46e60d.css +0 -1
  141. /package/web/.next/static/{UDA414PFPu9ZROltNmjbX → gqk-RaeJBRWW1cWGRixT2}/_buildManifest.js +0 -0
  142. /package/web/.next/static/{UDA414PFPu9ZROltNmjbX → gqk-RaeJBRWW1cWGRixT2}/_ssgManifest.js +0 -0
@@ -0,0 +1,126 @@
1
+ import { agents, dispatches } from './db.js';
2
+ import { threads } from './connections/store.js';
3
+ import { liveOrigin, notifyAgentsChanged, startDispatch } from './runtime.js';
4
+ import { deliverTo } from './wake.js';
5
+ import { DomainError } from './errors.js';
6
+ /**
7
+ * Delegations that outlive a request. A long task held on one HTTP request broke when the client gave up waiting (Node's fetch stops at
8
+ * five minutes without headers), and the orchestrator lost the answer. Now a task is stored and queued at once and its result is read
9
+ * from the `dispatches` table: in the background the orchestrator is told when it ends, in the place where it asked (the chat thread or
10
+ * its web conversation); the synchronous `dispatch` tool polls `dispatch_status` with short calls until it ends.
11
+ */
12
+ export class DispatchError extends DomainError {
13
+ }
14
+ export const MAX_BACKGROUND = 10; // unfinished background tasks per orchestrator
15
+ export const MAX_WAIT_S = 60; // the longest one `dispatch_status` call waits
16
+ const POLL_MS = 250;
17
+ const RETRY_MS = Number(process.env.HIVE_AM_WAKE_RETRY_MS) || 5000; // wait before trying a delivery again (as wake-ups do)
18
+ const FINISHED = new Set(['done', 'failed', 'interrupted']);
19
+ const NO_TEXT = '(the subagent finished without a text answer)';
20
+ const nameOf = (id) => agents.get(id)?.name ?? 'a removed agent';
21
+ /** Starts a delegation without waiting for it. In the background, its end is announced to the orchestrator; a synchronous one is polled. */
22
+ export async function startTask(fromId, workerName, task, background) {
23
+ if (background && dispatches.unfinished(fromId) >= MAX_BACKGROUND)
24
+ throw new DispatchError(`You already have ${MAX_BACKGROUND} background tasks that have not finished, the most allowed. Wait for one to finish (you are told, or ask with dispatch_status) before sending another.`);
25
+ let origin = null;
26
+ if (background) {
27
+ // The answer goes back where the orchestrator was asked, as a wake-up does: the chat thread it is answering now, or its web conversation.
28
+ const o = liveOrigin(fromId);
29
+ const thread = o ? threads.get(o.threadId) : undefined;
30
+ if (o && thread)
31
+ origin = JSON.stringify({ connection_id: o.connectionId, thread_id: thread.id, req_id: o.group && o.userId ? o.userId : null, req_name: o.group && o.userId ? o.userName : null });
32
+ }
33
+ let started;
34
+ try {
35
+ started = startDispatch(fromId, workerName, task, background ? 'background' : 'sync', origin);
36
+ }
37
+ catch (e) {
38
+ throw new DispatchError(e instanceof Error ? e.message : String(e));
39
+ }
40
+ if (background)
41
+ void started.done.then(() => announce(started.id), () => announce(started.id));
42
+ notifyAgentsChanged(); // the interface reloads the dispatch log
43
+ // The worker's turn starts on the next tick when it is free: answer the state it is really in (running, or queued behind other work).
44
+ await new Promise((r) => setImmediate(r));
45
+ return { id: started.id, agent: started.worker.name, status: dispatches.get(started.id)?.status ?? 'queued', mode: background ? 'background' : 'sync' };
46
+ }
47
+ /**
48
+ * The state of a task, for the orchestrator that started it. With `waitSeconds` it waits up to that long for the end (at most MAX_WAIT_S,
49
+ * so no single request is long) and answers the state it has then. A finished result read here counts as read.
50
+ */
51
+ export async function taskStatus(fromId, id, waitSeconds) {
52
+ if (!id)
53
+ throw new DispatchError('id is required');
54
+ let row = dispatches.get(id);
55
+ if (!row)
56
+ throw new DispatchError(`There is no delegated task with id "${id}".`, 'not_found');
57
+ if (row.from_id !== fromId)
58
+ throw new DispatchError(`Task "${id}" was not started by you.`, 'forbidden');
59
+ const asked = waitSeconds === undefined || waitSeconds === null || waitSeconds === '' ? 0 : Number(waitSeconds);
60
+ if (!Number.isFinite(asked) || asked < 0)
61
+ throw new DispatchError(`wait_seconds must be a number from 0 to ${MAX_WAIT_S}.`);
62
+ const wait = Math.min(asked, MAX_WAIT_S);
63
+ const until = Date.now() + wait * 1000;
64
+ while (!FINISHED.has(row.status) && Date.now() < until) {
65
+ await new Promise((r) => setTimeout(r, Math.min(POLL_MS, until - Date.now())));
66
+ row = dispatches.get(id) ?? row;
67
+ }
68
+ const out = { id, agent: nameOf(row.to_id), status: row.status, mode: row.mode };
69
+ if (FINISHED.has(row.status)) {
70
+ out.result = resultText(row);
71
+ dispatches.markRead(id);
72
+ }
73
+ if (asked > MAX_WAIT_S)
74
+ out.note = `wait_seconds is capped at ${MAX_WAIT_S}: waited ${MAX_WAIT_S} s at most.`;
75
+ return out;
76
+ }
77
+ /** What a finished task says: its answer, its error, or why it stopped. */
78
+ function resultText(row) {
79
+ if (row.status === 'interrupted')
80
+ return `hive-am restarted while ${nameOf(row.to_id)} was working on it; check the branch or the files and send the task again if needed.`;
81
+ if (row.status === 'failed')
82
+ return row.result || 'The turn failed without saying why.';
83
+ return row.result || NO_TEXT;
84
+ }
85
+ const noticeText = (row) => `Delegated task ${row.id} to ${nameOf(row.to_id)} finished (${row.status}). Result:\n${resultText(row)}`;
86
+ const NEXT = 'Carry on with the work this task was part of and tell the person what came of it';
87
+ const timers = new Map();
88
+ /** Hands the result of a finished background task to the orchestrator, once. Tried again while the platform is not connected yet. */
89
+ async function announce(id, tries = 0) {
90
+ timers.delete(id);
91
+ const row = dispatches.get(id);
92
+ if (!row || row.mode !== 'background' || row.notified_at || !FINISHED.has(row.status))
93
+ return;
94
+ const o = row.origin ? JSON.parse(row.origin) : null;
95
+ const to = { agent_id: row.from_id, connection_id: o?.connection_id ?? null, thread_id: o?.thread_id ?? null, req_id: o?.req_id ?? null, req_name: o?.req_name ?? null };
96
+ const again = () => {
97
+ if (tries >= 12) {
98
+ dispatches.markNotified(id);
99
+ return;
100
+ }
101
+ const t = setTimeout(() => { void announce(id, tries + 1); }, RETRY_MS);
102
+ t.unref?.();
103
+ timers.set(id, t);
104
+ };
105
+ try {
106
+ // The row is marked before the turn starts, so a restart in between cannot announce it twice.
107
+ const mention = to.req_name ? `hive-am mentions ${to.req_name} at the start of your first reply, so do not mention them yourself. ` : '';
108
+ const out = await deliverTo(to, `${noticeText(row)}\n\n${mention}${NEXT} with channel_reply.`, `🔔 ${noticeText(row)}\n\n${NEXT}.`, () => { dispatches.markNotified(id); });
109
+ if (out === 'retry')
110
+ again();
111
+ // Nobody to tell (the thread is gone, muted or reassigned): the result stays readable with dispatch_status.
112
+ else if (out === 'dropped')
113
+ dispatches.markNotified(id);
114
+ }
115
+ catch (e) {
116
+ console.warn(`[dispatch] the result of task ${id} could not be handed to ${nameOf(row.from_id)}: ${e instanceof Error ? e.message : e}`);
117
+ if (!dispatches.get(id)?.notified_at)
118
+ again();
119
+ }
120
+ }
121
+ /** At startup: tasks left queued or running are interrupted, and every finished background task not announced yet is announced now. */
122
+ export function armDispatchNotices() {
123
+ dispatches.interruptLeftovers();
124
+ for (const row of dispatches.undelivered())
125
+ void announce(row.id);
126
+ }
@@ -1,10 +1,11 @@
1
1
  import { agents, dispatches } from '../../db.js';
2
+ import { startTask, taskStatus } from '../../dispatches.js';
2
3
  import { dispatch, liveTurn, notifyAgentsChanged } from '../../runtime.js';
3
4
  import { agentOrFail } from '../../services/agents.js';
4
5
  import { body } from '../body.js';
5
6
  import { bad } from '../errors.js';
6
7
  import { route } from '../router.js';
7
- import { strList } from '../validate.js';
8
+ import { strList, text } from '../validate.js';
8
9
  route('GET', '/api/orchestrators/:id/workers', ({ params }) => {
9
10
  const o = agentOrFail(params[0]);
10
11
  return o.worker_ids.map((id) => agents.get(id)).filter((w) => !!w).map((w) => ({ name: w.name, role: w.role, description: w.description, provider: w.provider, busy: !!liveTurn(w.id) }));
@@ -18,10 +19,14 @@ route('PUT', '/api/orchestrators/:id/workers', async ({ req, params }) => {
18
19
  notifyAgentsChanged();
19
20
  return agents.get(o.id);
20
21
  });
22
+ // `background: true` returns at once and the orchestrator is told the result later; `poll: true` returns at once too, for a caller that waits
23
+ // with /api/dispatch/status (the synchronous `dispatch` tool). With neither, the request is held until the worker answers, as it always was.
21
24
  route('POST', '/api/dispatch', async ({ req }) => {
22
- const { from, agent, task } = await body(req);
25
+ const { from, agent, task, background, poll } = await body(req);
23
26
  if (!from || !agent || !task)
24
27
  throw bad('from, agent and task are required');
28
+ if (background === true || poll === true)
29
+ return startTask(text(from), text(agent), text(task), background === true);
25
30
  try {
26
31
  return await dispatch(from, agent, task);
27
32
  }
@@ -29,4 +34,10 @@ route('POST', '/api/dispatch', async ({ req }) => {
29
34
  throw bad(e instanceof Error ? e.message : String(e));
30
35
  }
31
36
  });
37
+ route('POST', '/api/dispatch/status', async ({ req }) => {
38
+ const { from, id, wait_seconds } = await body(req);
39
+ if (!from)
40
+ throw bad('from is required');
41
+ return taskStatus(text(from), text(id), wait_seconds);
42
+ });
32
43
  route('GET', '/api/dispatches', () => dispatches.recent());
@@ -42,6 +42,23 @@ export function validateColony(p, partial = false) {
42
42
  checkFolder(p.cwd);
43
43
  return p;
44
44
  }
45
+ const MAX_CHATS = 50;
46
+ const MAX_CHAT_MEMBERS = 50;
47
+ /** What a person's id looks like on each platform: Telegram user ids are positive numbers; Slack member ids are `U…` (or `W…` on Enterprise Grid). */
48
+ const MEMBER_ID = { telegram: /^\d+$/, slack: /^[UW][A-Z0-9]{2,}$/ };
49
+ /** One authorized group: its mode, and when it only admits some people, who they are. */
50
+ function validateChat(kind, c) {
51
+ const r = isRecord(c) ? c : {};
52
+ if (r.mode !== undefined && r.mode !== 'everyone' && r.mode !== 'members')
53
+ throw bad('chat mode must be everyone or members');
54
+ if (r.members === undefined)
55
+ return;
56
+ if (!Array.isArray(r.members) || r.members.length > MAX_CHAT_MEMBERS)
57
+ throw bad(`a chat can list at most ${MAX_CHAT_MEMBERS} members`);
58
+ const shape = MEMBER_ID[kind];
59
+ if (shape && r.members.some((m) => !shape.test(trimmed(isRecord(m) ? m.id : m))))
60
+ throw bad('member ids must be numbers on Telegram and start with U or W on Slack');
61
+ }
45
62
  export const CONNECTION_KINDS = ['telegram', 'slack'];
46
63
  const REQUIRED = { telegram: ['token'], slack: ['bot_token', 'app_token'] };
47
64
  export function validateConnection(kind, config, agentId, enabled) {
@@ -56,8 +73,11 @@ export function validateConnection(kind, config, agentId, enabled) {
56
73
  throw bad('group_mode must be mention or open');
57
74
  if (config.vision !== undefined && config.vision !== null && !validVision(config.vision))
58
75
  throw bad('vision must be { provider, model } or empty');
59
- if (config.chats !== undefined && (!Array.isArray(config.chats) || config.chats.length > 50))
60
- throw bad('chats must be a list of at most 50 groups');
76
+ if (config.chats !== undefined && (!Array.isArray(config.chats) || config.chats.length > MAX_CHATS))
77
+ throw bad(`chats must be a list of at most ${MAX_CHATS} groups`);
78
+ if (Array.isArray(config.chats))
79
+ for (const c of config.chats)
80
+ validateChat(kind, c);
61
81
  if (config.aliases !== undefined && (!Array.isArray(config.aliases) || config.aliases.length > 10))
62
82
  throw bad('aliases must be a list of at most 10 names');
63
83
  if (agentId) {
@@ -10,6 +10,7 @@ import './connections/index.js';
10
10
  import { startAll, stopAll } from './connections/manager.js';
11
11
  import { addReactionsToChannelSkill, migrateChannelSkill } from './connections/channel-skill.js';
12
12
  import { armWakeups } from './wake.js';
13
+ import { armDispatchNotices } from './dispatches.js';
13
14
  import { armSchedules } from './schedules.js';
14
15
  import { armWatches } from './watch.js';
15
16
  import { armObjects } from './objects/index.js';
@@ -21,6 +22,7 @@ dropStarterSkills();
21
22
  migrateChannelSkill();
22
23
  addReactionsToChannelSkill();
23
24
  armWakeups();
25
+ armDispatchNotices();
24
26
  armSchedules();
25
27
  armWatches();
26
28
  armObjects();
@@ -78,7 +78,7 @@ ${lazy.map((s) => `- **${s.name}**${s.description ? `: ${s.description}` : ''}`)
78
78
  const team = a.worker_ids.map((wid) => agents.get(wid)).filter(Boolean);
79
79
  if (team.length) {
80
80
  const roster = team.map((w) => `- **${w.name}**${w.description ? `: ${w.description}` : ''}`).join('\n');
81
- parts.push(`## Your team\nThese are your subagents — the ONLY agents you can delegate to:\n${roster}\n\nDelegate with the \`dispatch\` tool (${tn('dispatch')}) instead of doing their work yourself, then synthesize their answers. Always use it: never play a subagent's role yourself, and never use a generic built-in subagent in its place — a subagent is a separate real agent with its own session. Each delegation starts a fresh conversation for that subagent, so put all the context it needs in the task. Your team can change at any time: when asked which agents you have or can delegate to, call the \`list_agents\` tool (${tn('list_agents')}) and report exactly what it returns, never an older list from memory. Never mention or try to use agents outside that list.`);
81
+ parts.push(`## Your team\nThese are your subagents — the ONLY agents you can delegate to:\n${roster}\n\nDelegate with the \`dispatch\` tool (${tn('dispatch')}) instead of doing their work yourself, then synthesize their answers. Always use it: never play a subagent's role yourself, and never use a generic built-in subagent in its place — a subagent is a separate real agent with its own session. Each delegation starts a fresh conversation for that subagent, so put all the context it needs in the task. \`dispatch\` returns at once with a task id, so you can answer the person or delegate more meanwhile, and the result reaches you as a message when the subagent finishes (you can also ask with the \`dispatch_status\` tool (${tn('dispatch_status')})). Use \`background: false\` only for a very short task whose answer you need right now: the call then waits for it. Your team can change at any time: when asked which agents you have or can delegate to, call the \`list_agents\` tool (${tn('list_agents')}) and report exactly what it returns, never an older list from memory. Never mention or try to use agents outside that list.`);
82
82
  }
83
83
  else {
84
84
  parts.push('## Your team\nYou currently have no subagents connected to you, so you cannot delegate. If asked, say so; do not claim to know other agents.');
@@ -34,8 +34,10 @@ export function retentionPass({ now = Date.now() } = {}) {
34
34
  }
35
35
  // The record of a received file goes with the file.
36
36
  out.files = db.prepare('UPDATE thread_messages SET files=NULL WHERE files IS NOT NULL AND ts<?').run(now - RETENTION_DAYS * DAY).changes;
37
- // Delegations: all but the newest KEEP_DISPATCHES (one that is still running is never one of them).
38
- out.dispatches = db.prepare("DELETE FROM dispatches WHERE status<>'running' AND id IN (SELECT id FROM dispatches ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)").run(KEEP_DISPATCHES).changes;
37
+ // Delegations: all but the newest KEEP_DISPATCHES. Never one that has not finished, nor a background one whose result nobody has had
38
+ // yet (not handed to the orchestrator, nor read with dispatch_status): that one stays until it is.
39
+ out.dispatches = db.prepare(`DELETE FROM dispatches WHERE status NOT IN ('queued','running') AND NOT (mode='background' AND notified_at IS NULL AND read_at IS NULL)
40
+ AND id IN (SELECT id FROM dispatches ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)`).run(KEEP_DISPATCHES).changes;
39
41
  // Delegated conversations: past the newest KEEP_DELEGATIONS of their agent and not used for SESSION_FORGET_DAYS.
40
42
  out.sessions += db.prepare(`DELETE FROM agent_sessions WHERE kind='delegation' AND last_seen<? AND (agent_id, session_id) IN (
41
43
  SELECT agent_id, session_id FROM (SELECT agent_id, session_id, ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY last_seen DESC, session_id) AS n FROM agent_sessions WHERE kind='delegation') WHERE n>?)`)
@@ -85,6 +85,8 @@ async function execute(agentId, prompt, source, from, dispatchId, origin) {
85
85
  try {
86
86
  s.controller = controller;
87
87
  s.live = { turnId, prompt, events: [], source, origin };
88
+ if (dispatchId)
89
+ dispatches.setRunning(dispatchId);
88
90
  agents.setStatus(agentId, 'running');
89
91
  emit({ kind: 'turn_start', agentId, turnId, prompt, source, from, channel: origin && { platform: origin.platform, place: origin.place, user: origin.userName } });
90
92
  emit({ kind: 'status', agentId, status: 'running', queued: s.queued });
@@ -157,8 +159,11 @@ export function stopAgent(agentId, clearQueue = true) {
157
159
  s.controller.abort();
158
160
  return true;
159
161
  }
160
- /** Orchestrator → worker delegation. Enforces the explicit assignment rule. */
161
- export async function dispatch(fromId, workerName, task) {
162
+ /**
163
+ * Orchestrator → worker delegation. Enforces the explicit assignment rule. The task is stored and queued on the worker at once; `done`
164
+ * settles when the worker's turn ends, after the row holds the result, so nothing depends on the caller still waiting for it.
165
+ */
166
+ export function startDispatch(fromId, workerName, task, mode = 'sync', origin = null) {
162
167
  const from = agents.get(fromId);
163
168
  if (!from)
164
169
  throw new Error('Unknown orchestrator');
@@ -167,8 +172,14 @@ export async function dispatch(fromId, workerName, task) {
167
172
  throw new Error(`No agent named "${workerName}"`);
168
173
  if (!from.worker_ids.includes(worker.id))
169
174
  throw new Error(`"${worker.name}" is not assigned to ${from.name}`);
170
- const id = dispatches.start(from.id, worker.id, task);
171
- const res = await sendTurn(worker.id, task, 'dispatch', from.id, id);
172
- dispatches.finish(id, res.ok ? 'done' : 'failed', res.text || res.error || '');
173
- return res;
175
+ const id = dispatches.start(from.id, worker.id, task, mode, origin);
176
+ const done = sendTurn(worker.id, task, 'dispatch', from.id, id).then((res) => {
177
+ dispatches.finish(id, res.ok ? 'done' : 'failed', res.text || res.error || '');
178
+ return res;
179
+ });
180
+ return { id, worker, done };
181
+ }
182
+ /** A delegation awaited to the end (the original blocking form, kept for callers that hold the request open). */
183
+ export async function dispatch(fromId, workerName, task) {
184
+ return startDispatch(fromId, workerName, task).done;
174
185
  }
@@ -5,13 +5,28 @@ import { connections } from '../connections/store.js';
5
5
  import { notifyAgentsChanged } from '../runtime.js';
6
6
  import { bad, notFound } from '../http/errors.js';
7
7
  import { CONNECTION_KINDS, isRecord, trimmed, validateConnection } from '../http/validate.js';
8
+ /** Ids with an optional name: trimmed, de-duplicated, blanks dropped. */
9
+ function cleanIds(list) {
10
+ const seen = new Set();
11
+ return list
12
+ .map((c) => { const r = isRecord(c) ? c : {}; return { id: trimmed(r.id), name: r.name ? String(r.name).trim() : undefined }; })
13
+ .filter((c) => c.id && !seen.has(c.id) && seen.add(c.id));
14
+ }
15
+ /** One group's mode: `everyone` (or nothing) keeps the entry as `{ id, name }`; `members` keeps its cleaned list; anything else is left for validation to refuse. */
16
+ function cleanChat(c) {
17
+ const r = isRecord(c) ? c : {};
18
+ const [chat] = cleanIds([r]);
19
+ if (!chat || r.mode === undefined || r.mode === null || r.mode === 'everyone')
20
+ return chat;
21
+ if (r.mode !== 'members')
22
+ return { ...chat, mode: r.mode, members: r.members };
23
+ return { ...chat, mode: 'members', members: Array.isArray(r.members) ? cleanIds(r.members) : r.members ?? [] };
24
+ }
8
25
  /** Group ids and alias names from the form: trimmed, de-duplicated, blanks dropped. */
9
26
  function cleanGroupConfig(config) {
10
27
  if (Array.isArray(config.chats)) {
11
28
  const seen = new Set();
12
- config.chats = config.chats
13
- .map((c) => { const r = isRecord(c) ? c : {}; return { id: trimmed(r.id), name: r.name ? String(r.name).trim() : undefined }; })
14
- .filter((c) => c.id && !seen.has(c.id) && seen.add(c.id));
29
+ config.chats = config.chats.map(cleanChat).filter((c) => c && !seen.has(c.id) && seen.add(c.id));
15
30
  }
16
31
  if (Array.isArray(config.aliases))
17
32
  config.aliases = [...new Set(config.aliases.map((x) => String(x).trim()).filter(Boolean))];
@@ -8,14 +8,15 @@ export const OBJECTS_SKILL_ID = 'default-objects';
8
8
  export const DATABASES_SKILL_ID = 'default-databases';
9
9
  /**
10
10
  * How to react to messages in a chat. It is part of the "Chat channels" skill; agents that already had that skill get it added once
11
- * (see `migrateChannelSkill`). Telegram's list is the one in `connections/telegram.ts` (`TELEGRAM_REACTIONS`): keep them in step.
11
+ * (see `migrateChannelSkill`). Telegram's list is the one in `connections/telegram.ts` (`TELEGRAM_REACTIONS`), Slack's the one in `connections/slack.ts`
12
+ * (`SLACK_REACTION_NAMES`): keep them in step.
12
13
  */
13
14
  export const REACTIONS_SECTION = `## Reactions
14
- - \`channel_react\` puts ONE emoji on the message you are handling. It is lighter than a reply and counts as your answer when there is nothing to add. A new reaction replaces the previous one on that message.
15
+ - \`channel_react\` puts ONE emoji on the message you are handling. It is lighter than a reply and counts as your answer when there is nothing to add. On Telegram a new reaction replaces the previous one on that message; on Slack they add up.
15
16
  - React instead of writing when a text would only be noise: 👍 or 🫡 for "understood, on it", 🙏 or ❤ for thanks, 🎉 when someone shares good news, 👀 for "I am looking at it", 👌 for "done" when nothing more needs saying.
16
17
  - Do not react instead of answering a question or a request that needs a result: say it with \`channel_reply\`. You can react first (👀) and answer when you finish.
17
18
  - Do not react to every message, do not react to chatter nobody aimed at you (in a group with \`Addressed: no\` stay silent), and never use an emoji that could read as mocking, sarcastic or as a judgement of a person.
18
- - **Each platform allows only some emoji.** On Telegram a bot can use only this list: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 😢 🎉 🤩 🙏 👌 🕊 🤡 🥱 🥴 😍 🐳 ❤‍🔥 🌚 🌭 💯 🤣 ⚡ 🍌 🏆 💔 🤨 😐 🍓 🍾 💋 😈 😴 😭 🤓 👻 👨‍💻 👀 🎃 🙈 😇 😨 🤝 ✍ 🤗 🫡 🎅 🎄 ☃ 💅 🤪 🗿 🆒 💘 🙉 🦄 😘 💊 🙊 😎 👾 🤷. The ones that fit work best: 👍 🙏 ❤ 🎉 👀 🫡 👌 🔥 💯. Common ones that are NOT allowed there: ✅ ❌ ✔ 😀 😊 😂 🚀 ⭐ (use 👌 or 👍 instead of ✅, 😁 or 🤣 instead of 😂). Stick to those if you are not sure about another platform.
19
+ - **Each platform allows only some emoji.** On Telegram a bot can use only this list: 👍 👎 ❤ 🔥 🥰 👏 😁 🤔 🤯 😱 😢 🎉 🤩 🙏 👌 🕊 🤡 🥱 🥴 😍 🐳 ❤‍🔥 🌚 🌭 💯 🤣 ⚡ 🍌 🏆 💔 🤨 😐 🍓 🍾 💋 😈 😴 😭 🤓 👻 👨‍💻 👀 🎃 🙈 😇 😨 🤝 ✍ 🤗 🫡 🎅 🎄 ☃ 💅 🤪 🗿 🆒 💘 🙉 🦄 😘 💊 🙊 😎 👾 🤷. The ones that fit work best: 👍 🙏 ❤ 🎉 👀 🫡 👌 🔥 💯. Common ones that are NOT allowed there: ✅ ❌ ✔ 😀 😊 😂 🚀 ⭐ (use 👌 or 👍 instead of ✅, 😁 or 🤣 instead of 😂). On Slack a wider set works (✅ ❌ 🚀 😂 ⭐ 😊 among them; Slack's \`:name:\` form is accepted too), and the Telegram list above works there as well. Stick to the Telegram list if you are not sure about another platform.
19
20
  - If the tool refuses the emoji (a platform, or a group that limited its reactions), it says so and lists what you can use: pick another one, or answer with text if the message needs an answer. Do not retry the same emoji.
20
21
  `;
21
22
  export const DEFAULT_SKILLS = [
@@ -60,6 +61,7 @@ Only things that will still help in a *future* conversation and that you could n
60
61
 
61
62
  ## Answering
62
63
  - **Your normal text is not delivered** to the chat. To answer, call the \`channel_reply\` tool with the text; it goes to the thread of the message you are handling. You can call it more than once (for example a short heads-up before long work, then the result).
64
+ - On Slack a conversation is a thread: your reply goes into the thread of the message you are handling (it opens one on a message written in the channel), and people who keep talking in that thread are talking to you. A wake-up or a scheduled message is posted to the channel itself, not into a thread. A DM has no threads.
63
65
  - Once you have sent your answer and have nothing else to do, end the turn at once without writing more text: it is never delivered.
64
66
  - Keep replies short and conversational. Use plain Markdown. Never use interactive question tools: if you need something, ask in the reply.
65
67
  - The header also says the current local time (\`Now: …\`). Use it to answer what time or day it is, without running \`date\`.
@@ -73,7 +75,7 @@ Only things that will still help in a *future* conversation and that you could n
73
75
  - For a picture the block may include "What the picture shows": a text written for you by an image model, because you cannot open images yourself. That is how you see the picture, so talk about it naturally ("I can see…"). Never tell the person that you received a description or that an image model was used, and never say you cannot see the image. It can contain mistakes: if a detail is unclear or missing, say it is not clear in the picture and ask for a sharper one.
74
76
 
75
77
  ## Groups: listen to everything, speak only when it helps
76
- - In groups the header also says \`Addressed: yes|no\`. **\`yes\`** means somebody called you (your name, an @mention, a reply to you, a command): answer that. **\`no\`** means people are talking among themselves and nobody asked you anything: you keep reading, and **silence is the default** — do not call \`channel_reply\` and write no text. Speak without being called only when what you add is real, verified and worth interrupting for:
78
+ - In groups the header also says \`Addressed: yes|no\`. **\`yes\`** means somebody called you (your name, an @mention, a reply to you, a message in a Slack thread you already answered, a command): answer that. **\`no\`** means people are talking among themselves and nobody asked you anything: you keep reading, and **silence is the default** — do not call \`channel_reply\` and write no text. Speak without being called only when what you add is real, verified and worth interrupting for:
77
79
  1. **A mistake**: somebody says something wrong about the project, the environment, the branch, the deploy or what you did, and you checked (files, logs, commands) that it is wrong. Correct it briefly with what is right and where you saw it.
78
80
  2. **A danger — this one you must never let pass.** Somebody suggests or is about to run something destructive or irreversible (\`rm -rf\`, deleting or overwriting data, \`DROP\`/\`TRUNCATE\`, force-push, restarting or changing a production server), uses the wrong environment (production instead of QA), or pastes a secret in the chat. Speak at once, even if the message is for somebody else and even if you were not called: say what would break, why, and the safer way, in two or three lines. Staying silent here is the worst answer.
79
81
  3. **A missing fact that changes what they do**: they are about to test something that you know is down or not deployed, or a question to the whole group that nobody answered and you answered from something you checked, in a few lines.
@@ -134,7 +134,7 @@ async function noticeNow(w, reason) {
134
134
  ? (es ? `Apareció el archivo que esperabas (${dur}). Reviso el resultado…` : `The file you were waiting for appeared (${dur}). Checking the result…`)
135
135
  : (es ? `Terminó el proceso que esperabas (PID ${w.pid}, ${dur}). Reviso el resultado…` : `The process you were waiting for (PID ${w.pid}) has finished (${dur}). Checking the result…`);
136
136
  const msg = `🔔 ${who}${body}`;
137
- const { externalId } = await adapter.send(thread.target, msg);
137
+ const { externalId } = await adapter.send(adapter.proactive?.(thread.target) ?? thread.target, msg);
138
138
  threadMessages.record(thread.id, 'out', externalId, { name: agents.get(w.agent_id)?.name }, msg);
139
139
  }
140
140
  catch (e) {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Minimal MCP stdio server for hive-am agents. Tool groups come from HIVE_CAPS:
3
- // dispatch → list_agents, dispatch (orchestrators with a team)
3
+ // dispatch → list_agents, dispatch, dispatch_status (orchestrators with a team)
4
4
  // channel → channel_reply, channel_send_file, channel_react, channel_mute (agents linked to Telegram/Slack)
5
5
  // wake → wake_me, wake_when_done, schedule_create, schedule_list, schedule_cancel (agents with the Wake-ups skill: to be woken later in the same place)
6
6
  // objects → object_list, object_logs, object_action (agents with the Colony objects skill: the servers and containers of their colony)
@@ -11,6 +11,8 @@ import { createInterface } from 'node:readline';
11
11
  const API = process.env.HIVE_AM_API ?? 'http://127.0.0.1:4400';
12
12
  const FROM = process.env.HIVE_AGENT_ID;
13
13
  const CAPS = new Set((process.env.HIVE_CAPS ?? 'dispatch').split(',').filter(Boolean));
14
+ // A synchronous dispatch waits with dispatch_status calls of this many seconds each, so no request lasts long enough to hit a client timeout.
15
+ const POLL_S = Math.min(60, Math.max(1, Number(process.env.HIVE_AM_DISPATCH_POLL_S) || 25));
14
16
 
15
17
  const allTools = [
16
18
  {
@@ -22,17 +24,32 @@ const allTools = [
22
24
  {
23
25
  cap: 'dispatch',
24
26
  name: 'dispatch',
25
- description: 'Send a self-contained task to one of your subagents and wait for its final answer. Every delegation starts a fresh conversation for the subagent, so include all the context it needs.',
27
+ description: 'Send a self-contained task to one of your subagents. By default the call returns at once with a task id and the answer arrives later as a message to you (you can also ask with dispatch_status), so you can answer the person or delegate more meanwhile. Set background: false only for a very short task whose answer you need right now: the call then waits for the final answer. Every delegation starts a fresh conversation for the subagent, so include all the context it needs.',
26
28
  inputSchema: {
27
29
  type: 'object',
28
30
  properties: {
29
31
  agent: { type: 'string', description: 'Exact subagent name from list_agents' },
30
32
  task: { type: 'string', description: 'Full instructions for the subagent; include all context it needs.' },
33
+ background: { type: 'boolean', description: 'Return at once with a task id (default true). The result is sent to you as a message when the subagent finishes. Set false to wait for the final answer in this call.' },
31
34
  },
32
35
  required: ['agent', 'task'],
33
36
  additionalProperties: false,
34
37
  },
35
38
  },
39
+ {
40
+ cap: 'dispatch',
41
+ name: 'dispatch_status',
42
+ description: 'State of a task you delegated: queued, running, done, failed or interrupted, with the result once it has finished. With wait_seconds it waits up to that long for the end before answering.',
43
+ inputSchema: {
44
+ type: 'object',
45
+ properties: {
46
+ id: { type: 'string', description: 'The task id that dispatch returned' },
47
+ wait_seconds: { type: 'number', minimum: 0, maximum: 60, description: 'Wait up to this many seconds (0 to 60, default 0) for the task to finish.' },
48
+ },
49
+ required: ['id'],
50
+ additionalProperties: false,
51
+ },
52
+ },
36
53
  {
37
54
  cap: 'channel',
38
55
  name: 'channel_reply',
@@ -106,7 +123,7 @@ const allTools = [
106
123
  {
107
124
  cap: 'channel',
108
125
  name: 'channel_react',
109
- description: 'React with ONE emoji to the message you are handling (a thumbs-up to say "got it", a heart to say thanks). It is lighter than a reply and counts as your answer when nothing else needs saying. Each platform allows only some emoji (on Telegram a fixed list: 👍 👎 ❤ 🔥 👏 🎉 🙏 👌 👀 💯 🤔 😁 😢 ✍ 🫡 …); an emoji that is not allowed is refused and the error lists the ones you can use. Follow the "Chat channels" skill for when to react.',
126
+ description: 'React with ONE emoji to the message you are handling (a thumbs-up to say "got it", a heart to say thanks). It is lighter than a reply and counts as your answer when nothing else needs saying. Each platform allows only some emoji (on Telegram a fixed list: 👍 👎 ❤ 🔥 👏 🎉 🙏 👌 👀 💯 🤔 😁 😢 ✍ 🫡 …; on Slack a wider set, by emoji or as :name:); an emoji that is not allowed is refused and the error lists the ones you can use. Follow the "Chat channels" skill for when to react.',
110
127
  inputSchema: {
111
128
  type: 'object',
112
129
  properties: { emoji: { type: 'string', description: 'A single emoji, e.g. 👍.' } },
@@ -281,6 +298,16 @@ const dbAnswer = (op, j) => {
281
298
  }
282
299
  };
283
300
 
301
+ async function taskStatus(id, waitSeconds) {
302
+ const r = await fetch(`${API}/api/dispatch/status`, {
303
+ method: 'POST', headers: { 'content-type': 'application/json' },
304
+ body: JSON.stringify({ from: FROM, id, wait_seconds: waitSeconds }),
305
+ });
306
+ const j = await r.json();
307
+ if (!r.ok) throw Object.assign(new Error(j.error ?? `dispatch_status failed (${r.status})`), { status: r.status });
308
+ return j;
309
+ }
310
+
284
311
  async function call(name, args) {
285
312
  if (!tools.some((t) => t.name === name)) throw new Error(`Unknown tool ${name}`);
286
313
  if (name === 'list_agents') {
@@ -288,13 +315,29 @@ async function call(name, args) {
288
315
  return JSON.stringify(await r.json(), null, 2);
289
316
  }
290
317
  if (name === 'dispatch') {
318
+ const background = args.background !== false;
291
319
  const r = await fetch(`${API}/api/dispatch`, {
292
320
  method: 'POST', headers: { 'content-type': 'application/json' },
293
- body: JSON.stringify({ from: FROM, agent: args.agent, task: args.task }),
321
+ body: JSON.stringify({ from: FROM, agent: args.agent, task: args.task, background, poll: !background }),
294
322
  });
295
323
  const j = await r.json();
296
324
  if (!r.ok) throw new Error(j.error ?? `dispatch failed (${r.status})`);
297
- return j.text || '(the subagent finished without a text answer)';
325
+ if (background) return `Task ${j.id} sent to ${j.agent} (${j.status}). You will be woken with the result when it finishes; you can also ask with dispatch_status.`;
326
+ // Wait for the end with short calls: the answer is read from the server's record, never from one long request.
327
+ for (let errors = 0; ;) {
328
+ let s;
329
+ try { s = await taskStatus(j.id, POLL_S); errors = 0; } catch (e) {
330
+ if (e.status || ++errors > 5) throw new Error(`${e.message}. The task ${j.id} goes on: ask for its result with dispatch_status.`);
331
+ await new Promise((ok) => setTimeout(ok, 2000));
332
+ continue;
333
+ }
334
+ if (s.status === 'done') return s.result;
335
+ if (s.status === 'failed' || s.status === 'interrupted') throw new Error(`${j.agent} did not finish the task (${s.status}): ${s.result}`);
336
+ }
337
+ }
338
+ if (name === 'dispatch_status') {
339
+ const s = await taskStatus(args.id, args.wait_seconds);
340
+ return `Task ${s.id} for ${s.agent}: ${s.status}.${s.note ? ` ${s.note}` : ''}${s.result !== undefined ? ` Result:\n${s.result}` : ' Not finished yet.'}`;
298
341
  }
299
342
  if (name === 'channel_reply') {
300
343
  const r = await fetch(`${API}/api/channel/reply`, {
@@ -1 +1 @@
1
- UDA414PFPu9ZROltNmjbX
1
+ gqk-RaeJBRWW1cWGRixT2