hive-am 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +20 -5
  3. package/bin/hive-am.mjs +242 -31
  4. package/bin/run.mjs +135 -0
  5. package/package.json +1 -1
  6. package/server/dist/connections/manager.js +15 -3
  7. package/server/dist/connections/router.js +2 -2
  8. package/server/dist/connections/secrets.js +8 -0
  9. package/server/dist/db/migrations.js +68 -0
  10. package/server/dist/db.js +11 -10
  11. package/server/dist/efforts.js +105 -0
  12. package/server/dist/http/routes/agents.js +4 -3
  13. package/server/dist/http/routes/meta.js +12 -0
  14. package/server/dist/http/routes/notebook.js +10 -2
  15. package/server/dist/http/routes/resources.js +3 -0
  16. package/server/dist/http/routes/types.js +18 -1
  17. package/server/dist/http/validate.js +5 -2
  18. package/server/dist/index.js +4 -1
  19. package/server/dist/instructions.js +10 -2
  20. package/server/dist/objects/agent-db.js +258 -0
  21. package/server/dist/objects/database.js +496 -34
  22. package/server/dist/objects/index.js +20 -14
  23. package/server/dist/objects/runner.js +20 -0
  24. package/server/dist/objects/supervisor.js +2 -2
  25. package/server/dist/providers/kiro.js +7 -1
  26. package/server/dist/providers/opencode-server.js +11 -1
  27. package/server/dist/providers/opencode.js +4 -3
  28. package/server/dist/providers/spawn.js +38 -4
  29. package/server/dist/retention.js +50 -0
  30. package/server/dist/runtime.js +52 -21
  31. package/server/dist/services/agents.js +53 -1
  32. package/server/dist/services/notebook.js +7 -3
  33. package/server/dist/skills/defaults.js +35 -1
  34. package/server/dist/skills/notebook.js +57 -13
  35. package/server/mcp/dispatch.mjs +68 -0
  36. package/web/.next/BUILD_ID +1 -1
  37. package/web/.next/app-build-manifest.json +79 -70
  38. package/web/.next/app-path-routes-manifest.json +9 -8
  39. package/web/.next/build-manifest.json +2 -2
  40. package/web/.next/prerender-manifest.json +71 -47
  41. package/web/.next/routes-manifest.json +6 -0
  42. package/web/.next/server/app/_not-found/page.js +1 -1
  43. package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  44. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  45. package/web/.next/server/app/_not-found.html +1 -1
  46. package/web/.next/server/app/_not-found.rsc +3 -3
  47. package/web/.next/server/app/agents/[id]/page.js +4 -4
  48. package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
  49. package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
  50. package/web/.next/server/app/agents/page.js +2 -2
  51. package/web/.next/server/app/agents/page.js.nft.json +1 -1
  52. package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
  53. package/web/.next/server/app/agents.html +1 -1
  54. package/web/.next/server/app/agents.rsc +4 -4
  55. package/web/.next/server/app/chat/page.js +2 -0
  56. package/web/.next/server/app/chat/page.js.nft.json +1 -0
  57. package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -0
  58. package/web/.next/server/app/chat.html +1 -0
  59. package/web/.next/server/app/chat.meta +15 -0
  60. package/web/.next/server/app/chat.rsc +25 -0
  61. package/web/.next/server/app/connections/page.js +1 -1
  62. package/web/.next/server/app/connections/page.js.nft.json +1 -1
  63. package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
  64. package/web/.next/server/app/connections.html +1 -1
  65. package/web/.next/server/app/connections.rsc +4 -4
  66. package/web/.next/server/app/index.html +1 -1
  67. package/web/.next/server/app/index.rsc +4 -4
  68. package/web/.next/server/app/page.js +1 -1
  69. package/web/.next/server/app/page.js.nft.json +1 -1
  70. package/web/.next/server/app/page_client-reference-manifest.js +1 -1
  71. package/web/.next/server/app/relations/page.js +2 -2
  72. package/web/.next/server/app/relations/page.js.nft.json +1 -1
  73. package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
  74. package/web/.next/server/app/relations.html +1 -1
  75. package/web/.next/server/app/relations.rsc +4 -4
  76. package/web/.next/server/app/resources/[id]/page.js +2 -2
  77. package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -1
  78. package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
  79. package/web/.next/server/app/resources/page.js +2 -2
  80. package/web/.next/server/app/resources/page.js.nft.json +1 -1
  81. package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
  82. package/web/.next/server/app/resources.html +1 -1
  83. package/web/.next/server/app/resources.rsc +4 -4
  84. package/web/.next/server/app/schedules/page.js +1 -1
  85. package/web/.next/server/app/schedules/page.js.nft.json +1 -1
  86. package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  87. package/web/.next/server/app/schedules.html +1 -1
  88. package/web/.next/server/app/schedules.rsc +4 -4
  89. package/web/.next/server/app/sessions/page.js +2 -2
  90. package/web/.next/server/app/sessions/page.js.nft.json +1 -1
  91. package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  92. package/web/.next/server/app/sessions.html +1 -1
  93. package/web/.next/server/app/sessions.rsc +4 -4
  94. package/web/.next/server/app/skills/page.js +1 -1
  95. package/web/.next/server/app/skills/page.js.nft.json +1 -1
  96. package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
  97. package/web/.next/server/app/skills.html +1 -1
  98. package/web/.next/server/app/skills.rsc +4 -4
  99. package/web/.next/server/app/types/page.js +1 -1
  100. package/web/.next/server/app/types/page.js.nft.json +1 -1
  101. package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
  102. package/web/.next/server/app/types.html +1 -1
  103. package/web/.next/server/app/types.rsc +4 -4
  104. package/web/.next/server/app-paths-manifest.json +9 -8
  105. package/web/.next/server/chunks/143.js +1 -1
  106. package/web/.next/server/chunks/148.js +1 -0
  107. package/web/.next/server/chunks/213.js +6 -0
  108. package/web/.next/server/chunks/274.js +1 -1
  109. package/web/.next/server/chunks/368.js +1 -0
  110. package/web/.next/server/chunks/403.js +1 -0
  111. package/web/.next/server/chunks/{790.js → 435.js} +2 -2
  112. package/web/.next/server/chunks/505.js +1 -6
  113. package/web/.next/server/chunks/612.js +1 -1
  114. package/web/.next/server/pages/404.html +1 -1
  115. package/web/.next/server/pages/500.html +1 -1
  116. package/web/.next/server/server-reference-manifest.json +1 -1
  117. package/web/.next/static/UDA414PFPu9ZROltNmjbX/_buildManifest.js +1 -0
  118. package/web/.next/static/chunks/{316-26da9cc233ba7557.js → 107-77dd87cc2ca4f941.js} +1 -1
  119. package/web/.next/static/chunks/281-30e32d932b8b5a75.js +1 -0
  120. package/web/.next/static/chunks/391-18768499e741112e.js +1 -0
  121. package/web/.next/static/chunks/517-b3b1c9044d2ef23c.js +1 -0
  122. package/web/.next/static/chunks/587-5b511f5f93910180.js +1 -0
  123. package/web/.next/static/chunks/606-14fb5aaa4d0ff22d.js +1 -0
  124. package/web/.next/static/chunks/656-f3cc78328603241a.js +1 -0
  125. package/web/.next/static/chunks/{705-780e48e55b0346bd.js → 705-ce86175dea00e6a9.js} +1 -1
  126. package/web/.next/static/chunks/757-03361e7a97a605a2.js +1 -0
  127. package/web/.next/static/chunks/app/agents/[id]/page-638e12ba3cb813c3.js +1 -0
  128. package/web/.next/static/chunks/app/agents/page-9da499db6d22731f.js +1 -0
  129. package/web/.next/static/chunks/app/chat/page-383bc24e72dab7fa.js +1 -0
  130. package/web/.next/static/chunks/app/connections/{page-eb11cfb1ffeab3ca.js → page-06bd2d7d9495126a.js} +1 -1
  131. package/web/.next/static/chunks/app/layout-fad968a635f9a4b3.js +1 -0
  132. package/web/.next/static/chunks/app/page-0062ebc30322b1ae.js +1 -0
  133. package/web/.next/static/chunks/app/relations/page-02710fe1f8a670a9.js +1 -0
  134. package/web/.next/static/chunks/app/resources/[id]/{page-bf05836f97248ca7.js → page-fc494e85b01e8cb7.js} +1 -1
  135. package/web/.next/static/chunks/app/resources/{page-800f774e5cacf4c9.js → page-61cccd8fdcc2f6c8.js} +1 -1
  136. package/web/.next/static/chunks/app/sessions/page-85638705fdc18174.js +1 -0
  137. package/web/.next/static/chunks/app/types/page-8e25f503bdcd72ad.js +1 -0
  138. package/web/.next/static/css/dfcaa19a8d46e60d.css +1 -0
  139. package/web/.next/trace +30 -29
  140. package/web/.next/types/app/chat/page.ts +84 -0
  141. package/web/.next/types/routes.d.ts +2 -1
  142. package/web/.next/types/validator.ts +9 -0
  143. package/web/.next/server/chunks/306.js +0 -1
  144. package/web/.next/server/chunks/451.js +0 -1
  145. package/web/.next/server/chunks/638.js +0 -1
  146. package/web/.next/server/chunks/802.js +0 -1
  147. package/web/.next/static/aSYzbHk1M5YeS_d3SQKTZ/_buildManifest.js +0 -1
  148. package/web/.next/static/chunks/281-d8a0dcfaa6d064e4.js +0 -1
  149. package/web/.next/static/chunks/291-23f314877fa39054.js +0 -1
  150. package/web/.next/static/chunks/304-681348099f874a30.js +0 -1
  151. package/web/.next/static/chunks/491-28f093772bd460ac.js +0 -1
  152. package/web/.next/static/chunks/517-373a80c8ba432bdc.js +0 -1
  153. package/web/.next/static/chunks/562-7c2e33d838e869bf.js +0 -1
  154. package/web/.next/static/chunks/581-3ad5c4343dad3f50.js +0 -1
  155. package/web/.next/static/chunks/844-519177e6198ca84f.js +0 -1
  156. package/web/.next/static/chunks/app/agents/[id]/page-c005161cec5527d8.js +0 -1
  157. package/web/.next/static/chunks/app/agents/page-9bb8cba69776c719.js +0 -1
  158. package/web/.next/static/chunks/app/layout-49b46c9b5aa512a8.js +0 -1
  159. package/web/.next/static/chunks/app/page-da042d146caeeab6.js +0 -1
  160. package/web/.next/static/chunks/app/relations/page-a44fd515a3b0525e.js +0 -1
  161. package/web/.next/static/chunks/app/sessions/page-a7534ebd90e982d6.js +0 -1
  162. package/web/.next/static/chunks/app/types/page-ebd37e220f77c1f3.js +0 -1
  163. package/web/.next/static/css/d42799b7e6a0e8a2.css +0 -1
  164. /package/web/.next/static/{aSYzbHk1M5YeS_d3SQKTZ → UDA414PFPu9ZROltNmjbX}/_ssgManifest.js +0 -0
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { DATA_DIR, agents, resolved } from '../db.js';
4
- import { OBJECTS_SKILL_ID } from '../skills/defaults.js';
4
+ import { DATABASES_SKILL_ID, OBJECTS_SKILL_ID } from '../skills/defaults.js';
5
5
  import { bus } from '../runtime.js';
6
6
  import { renderTemplate, listTemplates } from './templates.js';
7
7
  import { composeServiceAction, inspectDocker, recreateDocker, dockerLogs, dockerState, removeDocker, restartDocker, startDocker, stopDocker } from './docker.js';
@@ -10,7 +10,7 @@ import { dbColumns, dbQuery, dbSchema, dbState, dbTest, forgetDatabase } from '.
10
10
  import { addSaved, clearQueries, forgetQueries, listQueries, listSaved, recordQuery, removeSaved, updateSaved } from './db-history.js';
11
11
  import { exportCollection, parseHttpFile, createHttpFile, exportHttp, describeHttp, dropSession, inspectHttp, readHttpFile, runHttp, scanHttp, writeHttpFile, sessionClear, sessionOf, sessionSet, sessionView } from './http.js';
12
12
  import { ObjectError, parseDatabase } from './model.js';
13
- import { forgetServer, serverLogs, serverState, startServer, stopServer } from './runner.js';
13
+ import { forgetServer, serverLogs, restartServer, serverState, startServer, stopServer } from './runner.js';
14
14
  import { forgetSupervision, resetSupervision, supervise } from './supervisor.js';
15
15
  import { maskConfig, objectsStore } from './store.js';
16
16
  export { ObjectError } from './model.js';
@@ -59,7 +59,7 @@ function clusterState(o) {
59
59
  async function serverStateSupervised(o) {
60
60
  const cfg = o.config;
61
61
  const base = await serverState(o.id, cfg);
62
- return supervise(o, cfg, base, { start: () => startServer(o.id, cfg), restart: async () => { await stopServer(o.id, cfg); await startServer(o.id, cfg); } });
62
+ return supervise(o, cfg, base, { start: () => startServer(o.id, cfg), restart: () => restartServer(o.id, cfg) });
63
63
  }
64
64
  export async function stateOf(o) {
65
65
  const s = o.kind === 'server' ? await serverStateSupervised(o)
@@ -113,10 +113,8 @@ async function act(o, action) {
113
113
  await startServer(o.id, c);
114
114
  else if (action === 'stop')
115
115
  await stopServer(o.id, c);
116
- else {
117
- await stopServer(o.id, c);
118
- await startServer(o.id, c);
119
- }
116
+ else
117
+ await restartServer(o.id, c);
120
118
  }
121
119
  else {
122
120
  const c = o.config;
@@ -363,15 +361,19 @@ export const dbSavedUpdate = (id, savedId, p) => {
363
361
  export const dbSavedRemove = (id, savedId) => { dbOf(id); if (!removeSaved(id, savedId))
364
362
  throw new ObjectError('Saved query not found'); return listSaved(id); };
365
363
  /** Only these can be looked after by an agent: it starts, stops and restarts them. */
366
- const MANAGEABLE = ['server', 'docker', 'cluster'];
367
- /** An agent without the "Colony resources" skill gets it when it is made the manager of something: without it the tools are not offered. */
368
- function ensureSkill(agentId) {
364
+ const MANAGEABLE = ['server', 'docker', 'cluster', 'database'];
365
+ /**
366
+ * An agent without the skill that goes with a resource gets it when it is made its manager: without it the tools are not offered.
367
+ * A database goes with "Database management"; the other kinds with "Colony resources".
368
+ */
369
+ function ensureSkill(agentId, kind) {
369
370
  const ag = agents.get(agentId);
370
371
  if (!ag)
371
372
  throw new ObjectError('That agent no longer exists');
372
- if (resolved(ag).skill_ids.includes(OBJECTS_SKILL_ID))
373
+ const skill = kind === 'database' ? DATABASES_SKILL_ID : OBJECTS_SKILL_ID;
374
+ if (resolved(ag).skill_ids.includes(skill))
373
375
  return false;
374
- agents.setSkills(agentId, [...ag.skill_ids, OBJECTS_SKILL_ID]);
376
+ agents.setSkills(agentId, [...ag.skill_ids, skill]);
375
377
  return true;
376
378
  }
377
379
  /** Chooses the one agent that looks after a resource (`null` leaves it to the agents of its colony). */
@@ -380,7 +382,7 @@ export function setObjectManager(id, agentId) {
380
382
  if (!MANAGEABLE.includes(o.kind))
381
383
  throw new ObjectError('This kind of resource is not looked after by agents');
382
384
  const a = agentId ? String(agentId) : null;
383
- const skillAdded = a ? ensureSkill(a) : false;
385
+ const skillAdded = a ? ensureSkill(a, o.kind) : false;
384
386
  objectsStore.setManager(id, a);
385
387
  changed();
386
388
  return { object: viewOf(o), skillAdded };
@@ -393,12 +395,16 @@ export function setAgentObjects(agentId, objectIds) {
393
395
  throw new ObjectError('Choose the resources');
394
396
  const want = new Set(objectIds.map(String));
395
397
  const managers = objectsStore.managers();
398
+ const kinds = new Set();
396
399
  for (const id of want) {
397
400
  const o = get(id);
398
401
  if (!MANAGEABLE.includes(o.kind))
399
402
  throw new ObjectError('This kind of resource is not looked after by agents');
403
+ kinds.add(o.kind === 'database' ? 'database' : 'other');
400
404
  }
401
- const skillAdded = want.size > 0 ? ensureSkill(agentId) : false;
405
+ let skillAdded = false;
406
+ for (const k of kinds)
407
+ skillAdded = ensureSkill(agentId, k === 'database' ? 'database' : 'server') || skillAdded;
402
408
  for (const [oid, aid] of managers)
403
409
  if (aid === agentId && !want.has(oid))
404
410
  objectsStore.setManager(oid, null);
@@ -139,6 +139,26 @@ export async function stopServer(id, cfg) {
139
139
  await sleep(100);
140
140
  }
141
141
  }
142
+ /**
143
+ * Stop, then start again. With a port, the old listener must be gone before the new one starts: otherwise the check on the port would
144
+ * report "running" for the old process (a stop command that does not really stop the service) while the new one failed to bind.
145
+ */
146
+ export async function restartServer(id, cfg) {
147
+ await stopServer(id, cfg);
148
+ if (cfg.port) {
149
+ const grace = cfg.stopTimeoutSec ? cfg.stopTimeoutSec * 1000 : STOP_GRACE_MS;
150
+ let waited = 0;
151
+ while (await listening(cfg.port) && waited < grace) {
152
+ await sleep(300);
153
+ waited += 300;
154
+ }
155
+ if (await listening(cfg.port)) {
156
+ note(id, `port ${cfg.port} is still in use after stopping: not starting it again. Check that the stop command really stops the service`);
157
+ throw new ObjectError(`Port ${cfg.port} is still in use after stopping, so it was not started again. Check that the stop command really stops the service.`);
158
+ }
159
+ }
160
+ await startServer(id, cfg);
161
+ }
142
162
  /** Lines the object printed. `after` is a byte offset from an earlier call; without it the last `tail` lines come back. */
143
163
  export function serverLogs(id, o) {
144
164
  const { log } = files(id);
@@ -54,8 +54,8 @@ export async function supervise(o, cfg, state, act) {
54
54
  if (S.startedAt !== saved?.startedAt) {
55
55
  S.startedAt = saved?.startedAt ?? 0;
56
56
  S.fails = 0;
57
- S.lastCheck = 0;
58
- }
57
+ S.lastCheck = now;
58
+ } // a new start: the first check comes one interval later, not right away
59
59
  if (now - S.lastCheck >= (cfg.health.intervalSec ?? 15) * 1000) {
60
60
  S.lastCheck = now;
61
61
  const r = await check(cfg, cfg.health);
@@ -1,3 +1,4 @@
1
+ import { learnEfforts } from '../efforts.js';
1
2
  import { spawnJson } from './spawn.js';
2
3
  import { withInstructions } from './preamble.js';
3
4
  import { writeFileSync, mkdirSync } from 'node:fs';
@@ -37,7 +38,8 @@ export async function* runKiro(o) {
37
38
  if (o.effort)
38
39
  args.push('--effort', o.effort);
39
40
  const prompt = withInstructions(o.prompt, o.instructions, !!agent.session_id, o.refreshInstructions);
40
- args.push(prompt);
41
+ // After `--` so a message that begins with a dash is the prompt and never an option; nothing may be pushed after it.
42
+ args.push('--', prompt);
41
43
  let sawSession = false;
42
44
  const open = new Set();
43
45
  for await (const ev of spawnJson({ cmd: 'kiro-cli', args, cwd: agent.cwd, signal: o.signal })) {
@@ -46,6 +48,10 @@ export async function* runKiro(o) {
46
48
  sawSession = true;
47
49
  yield { t: 'session', sessionId: d.sessionId };
48
50
  }
51
+ // The turn's metadata says which reasoning levels the model takes: remembered per model, so the agent form offers only those.
52
+ if (ev.type === 'metadata' && agent.model && Array.isArray(d.reasoning?.effortLevels) && d.reasoning.effortLevels.length) {
53
+ learnEfforts('kiro', { [agent.model]: d.reasoning.effortLevels.filter((l) => typeof l === 'string').map((l) => ({ id: l })) });
54
+ }
49
55
  if (ev.type === 'metadata' && (d.meteringUsage || d.contextUsagePercentage !== undefined)) {
50
56
  const credits = (d.meteringUsage ?? []).reduce((n, m) => n + (m.value ?? 0), 0);
51
57
  yield { t: 'usage', durationMs: d.turnDurationMs, usage: { credits: credits || undefined, contextPct: d.contextUsagePercentage } };
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { DATA_DIR } from '../db.js';
5
5
  import { createHash, randomBytes } from 'node:crypto';
6
6
  import { learnWindows } from '../context.js';
7
+ import { learnEfforts } from '../efforts.js';
7
8
  /**
8
9
  * One long-lived `opencode serve` per agent. A private server per turn (`opencode run --standalone`) boots from zero every time and its
9
10
  * hive MCP server connects only after the model has already taken its first step, so the first `tools.hive.*` call always fails
@@ -63,7 +64,14 @@ let windowsAt = 0;
63
64
  * Reads the context window of every model from OpenCode's catalog, using this turn's server, any running one or a short-lived one
64
65
  * (about 4 s and 150 MB, once). At most once an hour, and a failure waits ten minutes: OpenCode 1.x has no catalog endpoint at all.
65
66
  */
66
- export async function learnOpencodeWindows(cwd, access) {
67
+ export function learnOpencodeWindows(cwd, access) {
68
+ if (learning)
69
+ return learning; // a read is under way (a turn and the agent form can ask at the same time): both wait for it
70
+ learning = readCatalog(cwd, access).finally(() => { learning = null; });
71
+ return learning;
72
+ }
73
+ let learning = null;
74
+ async function readCatalog(cwd, access) {
67
75
  if (Date.now() - windowsAt < 60 * 60_000)
68
76
  return;
69
77
  windowsAt = Date.now();
@@ -107,6 +115,8 @@ export async function learnOpencodeWindows(cwd, access) {
107
115
  if (!list.length)
108
116
  throw new Error('the model catalog stayed empty');
109
117
  learnWindows('opencode', Object.fromEntries(list.map((m) => [`${m.providerID}/${m.id}`, Number(m.limit?.context)])));
118
+ // Every model carries the variants it takes (`id` goes after `#`, `settings.reasoningEffort` is the level it stands for); a model with none takes no effort.
119
+ learnEfforts('opencode', Object.fromEntries(list.map((m) => [`${m.providerID}/${m.id}`, (Array.isArray(m.variants) ? m.variants : []).map((v) => ({ id: String(v?.id), level: typeof v?.settings?.reasoningEffort === 'string' ? v.settings.reasoningEffort : undefined }))])));
110
120
  }
111
121
  catch {
112
122
  windowsAt = Date.now() - 50 * 60_000; /* try again in ten minutes */
@@ -2,7 +2,7 @@ import { DATA_DIR } from '../db.js';
2
2
  import { join } from 'node:path';
3
3
  import { execFile } from 'node:child_process';
4
4
  const INBOX_ROOT = join(DATA_DIR, 'inbox');
5
- import { spawnJson } from './spawn.js';
5
+ import { spawnJson, StalledError } from './spawn.js';
6
6
  import { dispatchMcpConfigOpencode } from '../mcp-config.js';
7
7
  import { opencodeSessionDir } from '../history/opencode.js';
8
8
  import { withInstructions } from './preamble.js';
@@ -93,7 +93,8 @@ export async function* runOpencode(o) {
93
93
  }
94
94
  else if (has('--standalone'))
95
95
  args.splice(1, 0, '--standalone'); // 1.x always runs its own server
96
- args.push(prompt);
96
+ // After `--` so a message that begins with a dash is the prompt and never an option; nothing may be pushed after it.
97
+ args.push('--', prompt);
97
98
  // Stopping a turn must really stop the model: a client that is killed would leave the server working on, so the server goes down with it.
98
99
  if (access)
99
100
  o.signal.addEventListener('abort', () => stopOpencodeServer(agent.id), { once: true });
@@ -144,7 +145,7 @@ export async function* runOpencode(o) {
144
145
  throw new Error(failure);
145
146
  }
146
147
  catch (e) {
147
- if (failure && !o.signal.aborted)
148
+ if (failure && !o.signal.aborted && !(e instanceof StalledError))
148
149
  throw new Error(failure); // OpenCode's own reason says more than "exited with code 1"
149
150
  throw e;
150
151
  }
@@ -1,6 +1,12 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
+ /** A CLI that prints nothing for this long has hung (a long turn is fine as long as it keeps emitting). */
4
+ const STALL_MS = () => Number(process.env.HIVE_AM_TURN_STALL_MS) || 10 * 60_000;
5
+ const quietFor = (ms) => ms >= 60_000 ? `${Math.round(ms / 6000) / 10} minutes` : `${Math.round(ms / 100) / 10} seconds`;
3
6
  const KILL_GRACE_MS = Number(process.env.HIVE_AM_KILL_GRACE_MS) || 5000;
7
+ /** The CLI hung and was cut off; adapters must not replace this reason with an error the dying CLI printed. */
8
+ export class StalledError extends Error {
9
+ }
4
10
  /** Spawns a CLI and yields its stdout lines. Throws with stderr tail if it exits non-zero and nothing aborted it. */
5
11
  export async function* spawnLines(o) {
6
12
  const child = spawn(o.cmd, o.args, {
@@ -21,6 +27,15 @@ export async function* spawnLines(o) {
21
27
  hardKill.unref?.();
22
28
  };
23
29
  o.signal.addEventListener('abort', kill, { once: true });
30
+ // Silence, not length: the timer restarts on every stdout line and, when it fires, ends the CLI like a Stop would, but the turn fails with its own text.
31
+ let stalled = false;
32
+ let stallTimer;
33
+ const armStall = () => {
34
+ clearTimeout(stallTimer);
35
+ stallTimer = setTimeout(() => { stalled = true; kill(); }, STALL_MS());
36
+ stallTimer.unref?.();
37
+ };
38
+ armStall();
24
39
  let stderr = '';
25
40
  child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
26
41
  const exit = new Promise((res) => {
@@ -28,7 +43,7 @@ export async function* spawnLines(o) {
28
43
  // 'exit' (not 'close'): a daemon the CLI forks (e.g. opencode's background service) can keep
29
44
  // inheriting our stdout pipe forever, so 'close' would never fire. Drain briefly, then cut the pipe.
30
45
  child.on('exit', (c) => { if (hardKill)
31
- clearTimeout(hardKill); setTimeout(() => child.stdout.destroy(), 250); res(c); });
46
+ clearTimeout(hardKill); setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); }, 250); res(c); });
32
47
  });
33
48
  child.stdin.on('error', () => { });
34
49
  if (o.stdin !== undefined)
@@ -36,15 +51,23 @@ export async function* spawnLines(o) {
36
51
  else
37
52
  child.stdin.end();
38
53
  try {
39
- for await (const line of createInterface({ input: child.stdout }))
54
+ // readline only ends on the stream's 'end'; a stream we destroy (the exit handler above) emits 'close' alone, so close it ourselves.
55
+ const rl = createInterface({ input: child.stdout });
56
+ child.stdout.on('close', () => rl.close());
57
+ for await (const line of rl) {
58
+ armStall();
40
59
  if (line.trim())
41
60
  yield line;
61
+ }
42
62
  }
43
63
  finally {
64
+ clearTimeout(stallTimer);
44
65
  o.signal.removeEventListener('abort', kill);
45
66
  kill();
46
67
  }
47
68
  const code = await exit;
69
+ if (stalled && !o.signal.aborted)
70
+ throw new StalledError(`${o.cmd} stopped answering (nothing on stdout for ${quietFor(STALL_MS())})`);
48
71
  if (code !== 0 && !o.signal.aborted)
49
72
  throw new Error(`${o.cmd} exited with code ${code}${stderr ? `: ${stderr.trim().slice(-600)}` : ''}`);
50
73
  }
@@ -56,13 +79,24 @@ export function safeJson(line) {
56
79
  return null;
57
80
  }
58
81
  }
59
- /** The JSON objects a CLI prints, one per line; lines that are not JSON (banners, warnings) are skipped. */
82
+ /**
83
+ * The JSON objects a CLI prints, one per line; lines that are not JSON (banners, warnings) are skipped.
84
+ * A CLI that exits cleanly having printed only prose (its usage text, say) has not answered: that is an error carrying its first line.
85
+ */
60
86
  export async function* spawnJson(o) {
87
+ let events = 0;
88
+ let firstNoise = '';
61
89
  for await (const line of spawnLines(o)) {
62
90
  const ev = safeJson(line);
63
- if (ev)
91
+ if (ev) {
92
+ events++;
64
93
  yield ev;
94
+ }
95
+ else
96
+ firstNoise ||= line.trim();
65
97
  }
98
+ if (!events && firstNoise && !o.signal.aborted)
99
+ throw new Error(`${o.cmd} produced no events, it printed: ${firstNoise.slice(0, 300)}`);
66
100
  }
67
101
  /** The text of a tool result, which CLIs send as a string, a list of blocks or any other JSON. */
68
102
  export function flattenText(c) {
@@ -0,0 +1,50 @@
1
+ import { db } from './db.js';
2
+ import { historySig } from './history/index.js';
3
+ import { RETENTION_DAYS } from './connections/files.js';
4
+ /**
5
+ * What hive-am keeps of its own record, and for how long. Three tables only ever grew: the messages of channel conversations, the log of
6
+ * delegations and the list of known conversations. The conversations themselves live in each CLI's own store and are never touched here:
7
+ * only hive-am's copy (the row that lists them) goes. A row is removed only when it is past its cap AND old enough, so nothing recent is lost.
8
+ */
9
+ export const KEEP_THREAD_MESSAGES = 500; // the newest of each thread...
10
+ export const MESSAGE_DAYS = 30; // ...and anything younger than this, whatever the count
11
+ export const KEEP_DISPATCHES = 200; // the screen shows 50
12
+ export const KEEP_DELEGATIONS = 50; // delegated conversations per agent (a read-only log), kept while younger than SESSION_FORGET_DAYS
13
+ export const SESSION_FORGET_DAYS = 90; // a conversation not used for this long, and that the CLI no longer has, is forgotten
14
+ // Received files live RETENTION_DAYS (connections/files.ts, 14): the record of a file on a message goes with the file.
15
+ const DAY = 86_400_000;
16
+ export function retentionPass({ now = Date.now() } = {}) {
17
+ const forgetBefore = now - SESSION_FORGET_DAYS * DAY;
18
+ // A direct conversation goes only when it is old AND its transcript is gone. `historySig` is null for a failed read too: the age is what makes that harmless.
19
+ const gone = [];
20
+ const old = db.prepare("SELECT s.agent_id, s.session_id, s.provider, s.cwd FROM agent_sessions s WHERE s.kind='direct' AND s.last_seen < ? AND s.session_id IS NOT (SELECT session_id FROM agents WHERE id=s.agent_id)")
21
+ .all(forgetBefore);
22
+ for (const s of old)
23
+ if (historySig({ provider: s.provider, cwd: s.cwd }, s.session_id) === null)
24
+ gone.push(s);
25
+ return db.transaction(() => {
26
+ const out = { messages: 0, dispatches: 0, sessions: 0, files: 0 };
27
+ // Messages: beyond the newest KEEP_THREAD_MESSAGES of their thread and older than the window.
28
+ const cut = db.prepare('SELECT id FROM thread_messages WHERE thread_id=? ORDER BY id DESC LIMIT 1 OFFSET ?');
29
+ const drop = db.prepare('DELETE FROM thread_messages WHERE thread_id=? AND id<? AND ts<?');
30
+ for (const { thread_id } of db.prepare('SELECT DISTINCT thread_id FROM thread_messages').all()) {
31
+ const edge = cut.get(thread_id, KEEP_THREAD_MESSAGES - 1);
32
+ if (edge)
33
+ out.messages += drop.run(thread_id, edge.id, now - MESSAGE_DAYS * DAY).changes;
34
+ }
35
+ // The record of a received file goes with the file.
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;
39
+ // Delegated conversations: past the newest KEEP_DELEGATIONS of their agent and not used for SESSION_FORGET_DAYS.
40
+ out.sessions += db.prepare(`DELETE FROM agent_sessions WHERE kind='delegation' AND last_seen<? AND (agent_id, session_id) IN (
41
+ 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>?)`)
42
+ .run(forgetBefore, KEEP_DELEGATIONS).changes;
43
+ // Direct conversations the CLI no longer has (the current one of an agent was left out above, and is checked again here).
44
+ const del = db.prepare("DELETE FROM agent_sessions WHERE agent_id=? AND session_id=? AND kind='direct' AND session_id IS NOT (SELECT session_id FROM agents WHERE id=?)");
45
+ for (const g of gone)
46
+ out.sessions += del.run(g.agent_id, g.session_id, g.agent_id).changes;
47
+ return out;
48
+ })();
49
+ }
50
+ export const retentionTouched = (r) => r.messages + r.dispatches + r.sessions + r.files > 0;
@@ -4,6 +4,7 @@ import { agents, dispatches, resolved } from './db.js';
4
4
  import { composeInstructions, mcpCaps, notebookBlock } from './instructions.js';
5
5
  import { runners } from './providers/index.js';
6
6
  import { notebooks } from './skills/notebook.js';
7
+ import { resolveEffort } from './efforts.js';
7
8
  export const bus = new EventEmitter();
8
9
  bus.setMaxListeners(0);
9
10
  const emit = (m) => bus.emit('msg', m);
@@ -22,6 +23,19 @@ export const liveOrigin = (id) => states.get(id)?.live?.origin;
22
23
  export const liveSource = (id) => states.get(id)?.live?.source;
23
24
  /** Whether the turn running now is the agent's own conversation (not work delegated to it by an orchestrator). */
24
25
  export const liveIsDirect = (id) => states.get(id)?.live?.source === 'user';
26
+ /**
27
+ * The reasoning effort a turn asks for: the one a connection sets for its own turns, else the agent's. One that does not apply to the agent's
28
+ * model (it changed, or the model never had that level) falls back to the CLI's own default, with a line in the log, instead of failing the turn.
29
+ */
30
+ function effortOf(agent, fromOrigin) {
31
+ const asked = fromOrigin || agent.effort;
32
+ if (!asked)
33
+ return undefined;
34
+ const use = resolveEffort(agent.provider, agent.model, asked);
35
+ if (!use)
36
+ console.warn(`[effort] ${agent.name}: "${asked}" does not apply to ${agent.model || 'the default model'} (${agent.provider}): the turn runs at the CLI's own default`);
37
+ return use || undefined;
38
+ }
25
39
  /** Queue a prompt for an agent. Turns for one agent run strictly one at a time. */
26
40
  export function sendTurn(agentId, prompt, source = 'user', from, dispatchId, origin) {
27
41
  const s = st(agentId);
@@ -34,36 +48,46 @@ export function sendTurn(agentId, prompt, source = 'user', from, dispatchId, ori
34
48
  async function execute(agentId, prompt, source, from, dispatchId, origin) {
35
49
  const s = st(agentId);
36
50
  s.queued--;
37
- const raw = agents.get(agentId);
38
- if (!raw)
39
- return { ok: false, text: '', error: 'Agent no longer exists' };
40
- const agent = resolved(raw);
41
- if (!agent.cwd)
42
- return { ok: false, text: '', error: 'This agent has no working folder. Set one on the agent or on its colony.' };
43
- const delegated = source === 'dispatch';
44
- if (delegated) {
45
- // Every delegation gets its own native session, so the agent's direct conversation never fills up with delegated work.
46
- agent.session_id = null;
51
+ let raw;
52
+ let agent;
53
+ let delegated = false;
54
+ try {
55
+ raw = agents.get(agentId);
56
+ if (!raw)
57
+ return { ok: false, text: '', error: 'Agent no longer exists' };
58
+ agent = resolved(raw);
59
+ if (!agent.cwd)
60
+ return { ok: false, text: '', error: 'This agent has no working folder. Set one on the agent or on its colony.' };
61
+ delegated = source === 'dispatch';
62
+ if (delegated) {
63
+ // Every delegation gets its own native session, so the agent's direct conversation never fills up with delegated work.
64
+ agent.session_id = null;
65
+ }
66
+ else if (raw.session_id && raw.session_cwd && raw.session_cwd !== agent.cwd) {
67
+ // Native sessions are tied to the folder they started in; if the folder changed, start a fresh one.
68
+ agents.setSession(raw, null);
69
+ agent.session_id = null;
70
+ emit({ kind: 'agents_changed' });
71
+ }
47
72
  }
48
- else if (raw.session_id && raw.session_cwd && raw.session_cwd !== agent.cwd) {
49
- // Native sessions are tied to the folder they started in; if the folder changed, start a fresh one.
50
- agents.setSession(raw, null);
51
- agent.session_id = null;
52
- emit({ kind: 'agents_changed' });
73
+ catch (e) {
74
+ // Preparing a turn can fail too (a damaged row, a database write): that is this turn's error, not a rejected promise nobody waits for.
75
+ return { ok: false, text: '', error: e instanceof Error ? e.message : String(e) };
53
76
  }
54
77
  const turnId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
55
78
  const controller = new AbortController();
56
- s.controller = controller;
57
- s.live = { turnId, prompt, events: [], source, origin };
58
- agents.setStatus(agentId, 'running');
59
- emit({ kind: 'turn_start', agentId, turnId, prompt, source, from, channel: origin && { platform: origin.platform, place: origin.place, user: origin.userName } });
60
- emit({ kind: 'status', agentId, status: 'running', queued: s.queued });
61
79
  let text = '';
62
80
  let summary;
63
81
  let error;
82
+ let events = 0;
64
83
  let hash = '', nbVersion = 0;
65
84
  const push = (event) => { s.live?.events.push(event); emit({ kind: 'event', agentId, turnId, event }); };
66
85
  try {
86
+ s.controller = controller;
87
+ s.live = { turnId, prompt, events: [], source, origin };
88
+ agents.setStatus(agentId, 'running');
89
+ emit({ kind: 'turn_start', agentId, turnId, prompt, source, from, channel: origin && { platform: origin.platform, place: origin.place, user: origin.userName } });
90
+ emit({ kind: 'status', agentId, status: 'running', queued: s.queued });
67
91
  // Composing the instructions can fail too (a damaged skill row...): that is this turn's error, and it must not leave the agent "running" forever.
68
92
  const base = composeInstructions(agent, delegated);
69
93
  nbVersion = notebooks.get(agentId).version;
@@ -74,9 +98,11 @@ async function execute(agentId, prompt, source, from, dispatchId, origin) {
74
98
  const stream = runners[agent.provider]({
75
99
  agent, prompt, signal: controller.signal,
76
100
  instructions, refreshInstructions,
77
- mcpCaps: mcpCaps(agent), effort: origin?.effort,
101
+ mcpCaps: mcpCaps(agent), effort: effortOf(agent, origin?.effort),
78
102
  });
79
103
  for await (const ev of stream) {
104
+ if (ev.t !== 'done')
105
+ events++;
80
106
  if (ev.t === 'session') {
81
107
  if (delegated) {
82
108
  agents.recordSession(agent, ev.sessionId, 'delegation', from, prompt);
@@ -107,6 +133,11 @@ async function execute(agentId, prompt, source, from, dispatchId, origin) {
107
133
  push({ t: 'error', message: error });
108
134
  }
109
135
  }
136
+ // A turn with no event of any kind (no session, text, tool or usage) never reached the model: it is not an empty answer.
137
+ if (!events && !error && !controller.signal.aborted) {
138
+ error = 'The agent produced no events, so the turn did not run';
139
+ push({ t: 'error', message: error });
140
+ }
110
141
  if (controller.signal.aborted && !error)
111
142
  push({ t: 'done', ok: false, summary: 'Stopped' });
112
143
  s.controller = null;
@@ -1,4 +1,6 @@
1
- import { agents, db, resolved } from '../db.js';
1
+ import { agents, colonies, db, resolved } from '../db.js';
2
+ import { notebooks } from '../skills/notebook.js';
3
+ import { checkEffort } from '../efforts.js';
2
4
  import { channelPermissionError } from '../connections/rules.js';
3
5
  import { liveTurn, notifyAgentsChanged, stopAgent } from '../runtime.js';
4
6
  import { findRepo } from '../git/repo.js';
@@ -20,9 +22,14 @@ function create(p) {
20
22
  notifyAgentsChanged();
21
23
  return a;
22
24
  }
25
+ /** An effort that does not apply to the provider and model is refused (an empty one always applies). */
26
+ const checkAgentEffort = (provider, model, effort) => { const why = checkEffort(provider, model, effort); if (why)
27
+ throw bad(why); };
23
28
  export function createAgent(p) {
24
29
  const name = trimmed(p.name);
25
30
  uniqueName(name);
31
+ if (p.effort !== undefined && p.provider)
32
+ checkAgentEffort(p.provider, p.model ?? '', p.effort);
26
33
  return create({ ...p, name, cwd: p.cwd ?? '' });
27
34
  }
28
35
  /** A new agent that starts from a type: the type lends everything but name, description, folder and colony. */
@@ -31,13 +38,51 @@ export function spawnFromType(t, p) {
31
38
  uniqueName(name);
32
39
  return create({
33
40
  name, description: p.description ?? t.description, role: t.role, type_id: t.id, provider: t.provider, model: t.model,
41
+ effort: checkEffort(t.provider, t.model, t.effort) === null ? t.effort : '', // a type whose level the model no longer takes starts at the default
34
42
  system_prompt: t.system_prompt, permission: t.permission, cwd: p.cwd ?? '', skill_ids: t.skill_ids, skill_loads: t.skill_loads, colony_id: p.colony_id ?? null,
35
43
  });
36
44
  }
45
+ /**
46
+ * A separate agent with the same configuration as `sourceId`: it runs in parallel with it (the turns of one agent are serialized, so conversations at the
47
+ * same time need separate agents) and READS THE SAME NOTEBOOK. It copies what the agent is (description, role, type, provider, model, prompt, permission,
48
+ * folder, overrides, browser, skills and, for an orchestrator, its team) and nothing it has done or is attached to: not its conversation or sessions,
49
+ * connections, wake-ups, schedules, watches, delegations or managed resources. Its colony is the source's unless another is given.
50
+ */
51
+ export function cloneAgent(sourceId, p) {
52
+ const src = agentOrFail(sourceId);
53
+ const name = trimmed(typeof p.name === 'string' ? p.name : '');
54
+ if (!name)
55
+ throw bad('Give the clone a name');
56
+ uniqueName(name);
57
+ const colony = p.colony_id === undefined ? src.colony_id : p.colony_id ? String(p.colony_id) : null;
58
+ if (colony && !colonies.get(colony))
59
+ throw bad('That colony no longer exists');
60
+ const clone = create({
61
+ name, description: src.description, role: src.role, type_id: src.type_id, provider: src.provider, model: src.model, effort: src.effort, system_prompt: src.system_prompt,
62
+ permission: src.permission, cwd: src.cwd, overrides: src.overrides, browser: src.browser, skill_ids: src.skill_ids, skill_loads: src.skill_loads,
63
+ worker_ids: src.role === 'orchestrator' ? src.worker_ids : [], colony_id: colony, cloned_from: src.id,
64
+ });
65
+ try {
66
+ notebooks.share(src.id, clone.id);
67
+ }
68
+ catch (e) {
69
+ agents.remove(clone.id);
70
+ throw e;
71
+ } // never leave a clone that does not read the notebook of its source
72
+ notifyAgentsChanged();
73
+ return agents.get(clone.id);
74
+ }
37
75
  export function updateAgent(id, p) {
38
76
  const cur = agentOrFail(id);
39
77
  if (p.name && p.name.toLowerCase() !== cur.name.toLowerCase())
40
78
  uniqueName(p.name, id);
79
+ // The effort has to apply to the provider and model the agent will have. One that is sent is checked; one that was saved and no longer applies
80
+ // (the provider or the model changed) falls back to the CLI's default instead of failing every turn.
81
+ const provider = p.provider ?? cur.provider, model = p.model ?? cur.model;
82
+ if (p.effort !== undefined)
83
+ checkAgentEffort(provider, model, p.effort);
84
+ else if (cur.effort && checkEffort(provider, model, cur.effort) !== null)
85
+ p = { ...p, effort: '' };
41
86
  // Changing provider invalidates the native session pointer. (A changed folder is detected when the next turn starts.)
42
87
  // All-or-nothing: a change that would leave a linked agent in Plan mode is rolled back.
43
88
  const a = db.transaction(() => {
@@ -68,6 +113,13 @@ export function sessionToRead(a, requested) {
68
113
  return sid;
69
114
  }
70
115
  /** Only the agent's own conversations can be made current; delegated ones are read-only. */
116
+ /** True when an OpenCode conversation started under a different permission than the agent has now: OpenCode keeps the tools a session started with. */
117
+ export function permissionStale(a) {
118
+ if (a.provider !== 'opencode' || !a.session_id)
119
+ return false;
120
+ const row = agents.sessions(a.id).find((s) => s.session_id === a.session_id);
121
+ return !!row && row.permission !== null && row.permission !== a.effective.permission;
122
+ }
71
123
  export function resumeSession(a, sessionId) {
72
124
  if (!agents.sessions(a.id).some((s) => s.session_id === sessionId && s.kind !== 'delegation'))
73
125
  throw bad('Only the agent’s own conversations can be made current. Delegated sessions are read-only.');
@@ -1,13 +1,17 @@
1
1
  import { agents, resolved } from '../db.js';
2
2
  import { mcpCaps } from '../instructions.js';
3
3
  import { liveOrigin } from '../runtime.js';
4
- import { NOTEBOOK_MAX } from '../skills/notebook.js';
4
+ import { NOTEBOOK_MAX, notebooks } from '../skills/notebook.js';
5
5
  import { NOTEBOOK_SKILL_ID } from '../skills/defaults.js';
6
6
  import { bad } from '../http/errors.js';
7
- /** Who a note came from, so it can be audited: the date plus, for chat turns, where and who. */
7
+ /**
8
+ * Who a note came from, so it can be audited: the date plus, for chat turns, where and who. In a notebook that several agents share it also
9
+ * says which agent wrote it: "who wrote this" is what tells a useful note from a mystery.
10
+ */
8
11
  export function noteSource(agentId) {
9
12
  const day = new Date().toISOString().slice(0, 10), o = liveOrigin(agentId);
10
- return o ? `${day} · ${o.platform}: ${o.userName}` : day;
13
+ const who = notebooks.sharedWith(agentId).length ? agents.get(agentId)?.name : undefined;
14
+ return [day, who, o ? `${o.platform}: ${o.userName}` : ''].filter(Boolean).join(' · ');
11
15
  }
12
16
  /** The agent behind a notebook tool call: it must exist and have the Notebook skill. */
13
17
  export function notebookAgent(from) {