hive-am 1.1.0 → 1.2.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.
- package/CHANGELOG.md +83 -1
- package/README.md +9 -1
- package/package.json +13 -2
- package/server/dist/api.js +42 -762
- package/server/dist/connections/channel-skill.js +13 -1
- package/server/dist/connections/files.js +45 -1
- package/server/dist/connections/manager.js +41 -3
- package/server/dist/connections/outbound.js +2 -1
- package/server/dist/connections/prompt.js +4 -0
- package/server/dist/connections/router.js +3 -2
- package/server/dist/connections/store.js +4 -43
- package/server/dist/connections/telegram.js +43 -3
- package/server/dist/cron.js +2 -1
- package/server/dist/db/json.js +16 -0
- package/server/dist/db/migrations.js +199 -0
- package/server/dist/db.js +98 -196
- package/server/dist/errors.js +11 -0
- package/server/dist/git/browse.js +99 -0
- package/server/dist/git/ops.js +136 -19
- package/server/dist/git/repo.js +2 -1
- package/server/dist/history/claude.js +21 -21
- package/server/dist/history/index.js +17 -9
- package/server/dist/history/kiro.js +17 -12
- package/server/dist/history/lines.js +12 -0
- package/server/dist/history/opencode.js +18 -1
- package/server/dist/http/body.js +46 -0
- package/server/dist/http/errors.js +21 -0
- package/server/dist/http/guards.js +44 -0
- package/server/dist/http/router.js +24 -0
- package/server/dist/http/routes/agents.js +103 -0
- package/server/dist/http/routes/channel.js +16 -0
- package/server/dist/http/routes/colonies.js +9 -0
- package/server/dist/http/routes/connections.js +36 -0
- package/server/dist/http/routes/fs.js +22 -0
- package/server/dist/http/routes/git.js +90 -0
- package/server/dist/http/routes/meta.js +11 -0
- package/server/dist/http/routes/notebook.js +34 -0
- package/server/dist/http/routes/orchestration.js +32 -0
- package/server/dist/http/routes/resources.js +68 -0
- package/server/dist/http/routes/schedules.js +40 -0
- package/server/dist/http/routes/skills.js +47 -0
- package/server/dist/http/routes/terminals.js +18 -0
- package/server/dist/http/routes/types.js +36 -0
- package/server/dist/http/validate.js +65 -0
- package/server/dist/index.js +67 -5
- package/server/dist/instructions.js +8 -2
- package/server/dist/objects/agent.js +49 -0
- package/server/dist/objects/database.js +451 -0
- package/server/dist/objects/db-history.js +79 -0
- package/server/dist/objects/docker-info.js +75 -0
- package/server/dist/objects/docker.js +285 -0
- package/server/dist/objects/driver-props.js +196 -0
- package/server/dist/objects/exec.js +16 -0
- package/server/dist/objects/http-history.js +44 -0
- package/server/dist/objects/http-script.js +644 -0
- package/server/dist/objects/http.js +735 -0
- package/server/dist/objects/index.js +445 -0
- package/server/dist/objects/model.js +250 -0
- package/server/dist/objects/runner.js +211 -0
- package/server/dist/objects/stats.js +106 -0
- package/server/dist/objects/store.js +115 -0
- package/server/dist/objects/supervisor.js +111 -0
- package/server/dist/objects/templates.js +67 -0
- package/server/dist/providers/claude.js +3 -13
- package/server/dist/providers/index.js +7 -1
- package/server/dist/providers/kiro.js +2 -5
- package/server/dist/providers/opencode.js +2 -5
- package/server/dist/providers/preamble.js +2 -0
- package/server/dist/providers/spawn.js +31 -3
- package/server/dist/runtime.js +8 -6
- package/server/dist/schedules.js +2 -21
- package/server/dist/seed.js +15 -11
- package/server/dist/services/agents.js +90 -0
- package/server/dist/services/colonies.js +38 -0
- package/server/dist/services/connections.js +76 -0
- package/server/dist/services/notebook.js +22 -0
- package/server/dist/services/providers.js +27 -0
- package/server/dist/services/schedules.js +18 -0
- package/server/dist/sessions-summary.js +51 -0
- package/server/dist/skills/defaults.js +37 -0
- package/server/dist/skills/notebook.js +3 -8
- package/server/dist/terminals.js +177 -0
- package/server/dist/wake.js +13 -16
- package/server/dist/watch.js +2 -8
- package/server/mcp/dispatch.mjs +70 -1
- package/web/.next/BUILD_ID +1 -1
- package/web/.next/app-build-manifest.json +150 -98
- package/web/.next/app-path-routes-manifest.json +7 -5
- package/web/.next/build-manifest.json +14 -14
- package/web/.next/prerender-manifest.json +67 -43
- package/web/.next/react-loadable-manifest.json +26 -1
- package/web/.next/required-server-files.json +13 -1
- package/web/.next/routes-manifest.json +26 -0
- package/web/.next/server/app/_not-found/page.js +2 -2
- package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/_not-found.html +1 -1
- package/web/.next/server/app/_not-found.rsc +12 -11
- package/web/.next/server/app/agents/[id]/page.js +4 -4
- package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
- package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents/page.js +2 -2
- package/web/.next/server/app/agents/page.js.nft.json +1 -1
- package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents.html +1 -1
- package/web/.next/server/app/agents.rsc +14 -13
- package/web/.next/server/app/connections/page.js +2 -2
- package/web/.next/server/app/connections/page.js.nft.json +1 -1
- package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/connections.html +1 -1
- package/web/.next/server/app/connections.rsc +14 -13
- package/web/.next/server/app/index.html +1 -1
- package/web/.next/server/app/index.rsc +14 -13
- package/web/.next/server/app/page.js +2 -2
- package/web/.next/server/app/page.js.nft.json +1 -1
- package/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations/page.js +2 -2
- package/web/.next/server/app/relations/page.js.nft.json +1 -1
- package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations.html +1 -1
- package/web/.next/server/app/relations.rsc +14 -13
- package/web/.next/server/app/resources/[id]/page.js +2 -0
- package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -0
- package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -0
- package/web/.next/server/app/resources/page.js +2 -0
- package/web/.next/server/app/resources/page.js.nft.json +1 -0
- package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -0
- package/web/.next/server/app/resources.html +1 -0
- package/web/.next/server/app/resources.meta +15 -0
- package/web/.next/server/app/resources.rsc +25 -0
- package/web/.next/server/app/schedules/page.js +2 -2
- package/web/.next/server/app/schedules/page.js.nft.json +1 -1
- package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/schedules.html +1 -1
- package/web/.next/server/app/schedules.rsc +14 -13
- package/web/.next/server/app/sessions/page.js +2 -2
- package/web/.next/server/app/sessions/page.js.nft.json +1 -1
- package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/sessions.html +1 -1
- package/web/.next/server/app/sessions.rsc +14 -13
- package/web/.next/server/app/skills/page.js +2 -2
- package/web/.next/server/app/skills/page.js.nft.json +1 -1
- package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/skills.html +1 -1
- package/web/.next/server/app/skills.rsc +14 -13
- package/web/.next/server/app/types/page.js +2 -2
- package/web/.next/server/app/types/page.js.nft.json +1 -1
- package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/types.html +1 -1
- package/web/.next/server/app/types.rsc +14 -13
- package/web/.next/server/app-paths-manifest.json +7 -5
- package/web/.next/server/chunks/143.js +1 -1
- package/web/.next/server/chunks/274.js +1 -0
- package/web/.next/server/chunks/306.js +1 -0
- package/web/.next/server/chunks/423.js +1 -0
- package/web/.next/server/chunks/505.js +6 -0
- package/web/.next/server/chunks/555.js +1 -0
- package/web/.next/server/chunks/612.js +1 -0
- package/web/.next/server/chunks/638.js +1 -0
- package/web/.next/server/chunks/692.js +18 -0
- package/web/.next/server/chunks/716.js +2 -0
- package/web/.next/server/chunks/790.js +11 -0
- package/web/.next/server/chunks/802.js +1 -0
- package/web/.next/server/chunks/890.js +1 -1
- package/web/.next/server/chunks/919.js +1 -0
- package/web/.next/server/chunks/950.js +6 -0
- package/web/.next/server/middleware-build-manifest.js +1 -1
- package/web/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/web/.next/server/next-font-manifest.js +1 -1
- package/web/.next/server/next-font-manifest.json +1 -1
- package/web/.next/server/pages/404.html +1 -1
- package/web/.next/server/pages/500.html +1 -1
- package/web/.next/server/server-reference-manifest.json +1 -1
- package/web/.next/static/chunks/0937d497-114a2a3de515565e.js +1 -0
- package/web/.next/static/chunks/176-aeaf907a6ea5627d.js +1 -0
- package/web/.next/static/chunks/291-efae53ea5f7ee92d.js +1 -0
- package/web/.next/static/chunks/2b9ff8ee-969e65cc354b2150.js +1 -0
- package/web/.next/static/chunks/30-9adcbe92b15a6815.js +1 -0
- package/web/.next/static/chunks/316-26da9cc233ba7557.js +1 -0
- package/web/.next/static/chunks/350-56599dc3bf06066d.js +1 -0
- package/web/.next/static/chunks/486-13e08542f3a1cbac.js +1 -0
- package/web/.next/static/chunks/491-6b4e6201f5110dff.js +1 -0
- package/web/.next/static/chunks/517-ccaf8cc5c733cbd8.js +1 -0
- package/web/.next/static/chunks/545.bfbd92b7632c09de.js +1 -0
- package/web/.next/static/chunks/{805-d946ebedb7e85f73.js → 562-7c2e33d838e869bf.js} +1 -1
- package/web/.next/static/chunks/581-3ad5c4343dad3f50.js +1 -0
- package/web/.next/static/chunks/586-f7017d585a84738d.js +1 -0
- package/web/.next/static/chunks/657.b4c39e676c3e20dc.js +1 -0
- package/web/.next/static/chunks/701.6abcaf5e60463eef.js +6 -0
- package/web/.next/static/chunks/705-a16e1fe09be1ac7f.js +1 -0
- package/web/.next/static/chunks/706-fa2be0f10ffebe6a.js +1 -0
- package/web/.next/static/chunks/837-33c7480524f48577.js +1 -0
- package/web/.next/static/chunks/839-23c5508622fae942.js +1 -0
- package/web/.next/static/chunks/844-fe55292e0701f436.js +1 -0
- package/web/.next/static/chunks/89-b194fcffe5120c44.js +1 -0
- package/web/.next/static/chunks/97.594543c2196de524.js +1 -0
- package/web/.next/static/chunks/app/_not-found/page-238a7b80bda6b4ae.js +1 -0
- package/web/.next/static/chunks/app/agents/[id]/page-4af6c246c4ca7a97.js +1 -0
- package/web/.next/static/chunks/app/agents/page-f4c4c80f955f3967.js +1 -0
- package/web/.next/static/chunks/app/connections/page-0936beaf02d863c6.js +1 -0
- package/web/.next/static/chunks/app/layout-87ebb70bd9cb8443.js +1 -0
- package/web/.next/static/chunks/app/page-a7f292117b7d76bf.js +1 -0
- package/web/.next/static/chunks/app/relations/page-fba25393c8688714.js +1 -0
- package/web/.next/static/chunks/app/resources/[id]/page-d110d89bb8b5fca7.js +1 -0
- package/web/.next/static/chunks/app/resources/page-bbd0b450d3f3131d.js +1 -0
- package/web/.next/static/chunks/app/schedules/page-a1124397fc760413.js +1 -0
- package/web/.next/static/chunks/app/sessions/page-0e322811b0cde724.js +1 -0
- package/web/.next/static/chunks/app/skills/page-22ea0ef89d9f7a13.js +1 -0
- package/web/.next/static/chunks/app/types/page-18c4e1db2724d41d.js +1 -0
- package/web/.next/static/chunks/fcafe1ac.366e4950d3b0d4b7.js +18 -0
- package/web/.next/static/chunks/framework-bbac72adbbe2d328.js +1 -0
- package/web/.next/static/chunks/main-app-6086068e5fb5eacd.js +1 -0
- package/web/.next/static/chunks/main-c15f3fee674e0303.js +1 -0
- package/web/.next/static/chunks/pages/_app-d1670a2c6ce392fc.js +1 -0
- package/web/.next/static/chunks/pages/_error-95aac014d202fa7a.js +1 -0
- package/web/.next/static/chunks/webpack-ef515e41a83a16ac.js +1 -0
- package/web/.next/static/css/945795da867f5250.css +1 -0
- package/web/.next/static/css/dd4e32c285f75776.css +1 -0
- package/web/.next/static/p4S3i9kvO359GW91DQiHu/_buildManifest.js +1 -0
- package/web/.next/trace +29 -23
- package/web/.next/types/app/resources/[id]/page.ts +84 -0
- package/web/.next/types/app/resources/page.ts +84 -0
- package/web/.next/types/routes.d.ts +6 -2
- package/web/.next/types/validator.ts +18 -0
- package/web/next.config.mjs +4 -0
- package/web/.next/server/chunks/247.js +0 -1
- package/web/.next/server/chunks/484.js +0 -11
- package/web/.next/server/chunks/851.js +0 -1
- package/web/.next/server/chunks/932.js +0 -2
- package/web/.next/static/_z2hBOHhwiR5ytdI_8alh/_buildManifest.js +0 -1
- package/web/.next/static/chunks/0937d497-44abb0f9355ace92.js +0 -1
- package/web/.next/static/chunks/2b9ff8ee-c3b61ac1e608c57d.js +0 -1
- package/web/.next/static/chunks/463-6ae35cdefdc59169.js +0 -1
- package/web/.next/static/chunks/493-50d2adf9fd5ec680.js +0 -1
- package/web/.next/static/chunks/517-eb26c72f6059aa26.js +0 -1
- package/web/.next/static/chunks/589-8bf536f331de4269.js +0 -1
- package/web/.next/static/chunks/657.f1e03b7b65eba1d9.js +0 -1
- package/web/.next/static/chunks/687-3a4205f51be38602.js +0 -1
- package/web/.next/static/chunks/837-fd7623601dc78ed8.js +0 -1
- package/web/.next/static/chunks/850-318f32484989dd59.js +0 -1
- package/web/.next/static/chunks/89-ace2e1aa63babfdf.js +0 -1
- package/web/.next/static/chunks/951-e250b541b67da7ce.js +0 -1
- package/web/.next/static/chunks/app/_not-found/page-bbd54d7b9a55d7ae.js +0 -1
- package/web/.next/static/chunks/app/agents/[id]/page-a8b7a43d2c18483f.js +0 -1
- package/web/.next/static/chunks/app/agents/page-e9b17f1c3439ef98.js +0 -1
- package/web/.next/static/chunks/app/connections/page-477f36fcd5b13d78.js +0 -1
- package/web/.next/static/chunks/app/layout-a2d38e3de5911b1b.js +0 -1
- package/web/.next/static/chunks/app/page-6fc87fc5a128a204.js +0 -1
- package/web/.next/static/chunks/app/relations/page-0355a93a8eb67989.js +0 -1
- package/web/.next/static/chunks/app/schedules/page-39befd15b3b20586.js +0 -1
- package/web/.next/static/chunks/app/sessions/page-beae9a74a07d69e0.js +0 -1
- package/web/.next/static/chunks/app/skills/page-4f839ac913e26d32.js +0 -1
- package/web/.next/static/chunks/app/types/page-4f6b99af3232f861.js +0 -1
- package/web/.next/static/chunks/framework-0e68884e76d9a8d3.js +0 -1
- package/web/.next/static/chunks/main-app-d6f73ccd41f325c5.js +0 -1
- package/web/.next/static/chunks/main-cd22509db15764e2.js +0 -1
- package/web/.next/static/chunks/pages/_app-0f75d689c738a6fc.js +0 -1
- package/web/.next/static/chunks/pages/_error-d481f6d49fb4455d.js +0 -1
- package/web/.next/static/chunks/webpack-d40de72e52f65f79.js +0 -1
- package/web/.next/static/css/da22f0402b1c40ae.css +0 -1
- /package/web/.next/static/{_z2hBOHhwiR5ytdI_8alh → p4S3i9kvO359GW91DQiHu}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { colonies, db } from '../db.js';
|
|
3
|
+
import { parseJson } from '../db/json.js';
|
|
4
|
+
import { ObjectError, kindOf, parseConfig, parseName, isKind } from './model.js';
|
|
5
|
+
// The tables (colony_objects, object_agents) are created by db/migrations.ts.
|
|
6
|
+
/** The secret values of an HTTP object stay on the server: what the interface receives has them empty (and `set` says there is one). */
|
|
7
|
+
export function maskConfig(o) {
|
|
8
|
+
if (o.kind === 'database') {
|
|
9
|
+
const { password, ...rest } = o.config;
|
|
10
|
+
return { ...o, config: { ...rest, password: '', passwordSet: !!password } };
|
|
11
|
+
}
|
|
12
|
+
if (o.kind !== 'http')
|
|
13
|
+
return o;
|
|
14
|
+
const c = o.config;
|
|
15
|
+
if (!c.variables)
|
|
16
|
+
return o;
|
|
17
|
+
const variables = Object.fromEntries(Object.entries(c.variables).map(([env, vars]) => [env, Object.fromEntries(Object.entries(vars).map(([k, v]) => [k, v.secret ? { value: '', secret: true, set: v.value !== '' } : v]))]));
|
|
18
|
+
return { ...o, config: { ...c, variables } };
|
|
19
|
+
}
|
|
20
|
+
/** Saving a secret that was left empty keeps the value that was already stored. */
|
|
21
|
+
function keepSecrets(old, incoming) {
|
|
22
|
+
for (const [env, vars] of Object.entries((incoming?.variables ?? {}))) {
|
|
23
|
+
for (const [k, v] of Object.entries(vars ?? {}))
|
|
24
|
+
if (v?.secret && !v.value && old?.variables?.[env]?.[k]?.secret)
|
|
25
|
+
v.value = old.variables[env][k].value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** A database saved with the password left empty keeps the one it had (unless the engine changed: then it was for another server). */
|
|
29
|
+
function keepPassword(old, incoming) {
|
|
30
|
+
if ((incoming.password === undefined || incoming.password === '') && old.password && incoming.engine === old.engine)
|
|
31
|
+
incoming.password = old.password;
|
|
32
|
+
}
|
|
33
|
+
// A damaged config must not take the whole list down: it reads as empty (a cluster as one without members) until the person saves it again.
|
|
34
|
+
const row = (r) => ({ ...r, config: parseJson(r.config, r.kind === 'cluster' ? { members: [] } : {}, `resource ${r.id} config`) });
|
|
35
|
+
/** A cluster groups servers and containers of its own colony; nothing else, and not itself. */
|
|
36
|
+
function checkMembers(kind, colony, config, selfId) {
|
|
37
|
+
if (kind !== 'cluster')
|
|
38
|
+
return;
|
|
39
|
+
for (const id of config.members) {
|
|
40
|
+
const m = objectsStore.get(id);
|
|
41
|
+
if (!m || m.id === selfId)
|
|
42
|
+
throw new ObjectError('One of the resources it groups does not exist');
|
|
43
|
+
if (m.kind !== 'server' && m.kind !== 'docker')
|
|
44
|
+
throw new ObjectError(`"${m.name}" cannot be grouped: only servers and Docker containers can`);
|
|
45
|
+
if ((m.colony_id ?? null) !== (colony ?? null))
|
|
46
|
+
throw new ObjectError(`"${m.name}" is not in the same colony as the cluster`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export const objectsStore = {
|
|
50
|
+
list: () => db.prepare('SELECT * FROM colony_objects ORDER BY name').all().map(row),
|
|
51
|
+
get(id) { const r = db.prepare('SELECT * FROM colony_objects WHERE id=?').get(id); return r ? row(r) : undefined; },
|
|
52
|
+
byName(name) { const r = db.prepare('SELECT * FROM colony_objects WHERE name=?').get(name); return r ? row(r) : undefined; },
|
|
53
|
+
create(input) {
|
|
54
|
+
const p = { ...input, kind: kindOf(input.kind) };
|
|
55
|
+
if (!isKind(p.kind))
|
|
56
|
+
throw new ObjectError('Unknown kind of resource');
|
|
57
|
+
const name = parseName(p.name), config = parseConfig(p.kind, p.config);
|
|
58
|
+
if (objectsStore.byName(name))
|
|
59
|
+
throw new ObjectError(`There is already a resource named "${name}"`);
|
|
60
|
+
const colony = p.colony_id ? String(p.colony_id) : null;
|
|
61
|
+
if (colony && !colonies.get(colony))
|
|
62
|
+
throw new ObjectError('That colony no longer exists');
|
|
63
|
+
checkMembers(p.kind, colony, config);
|
|
64
|
+
const id = randomUUID(), now = Date.now();
|
|
65
|
+
db.prepare('INSERT INTO colony_objects (id,colony_id,kind,name,config,created_at,updated_at) VALUES (?,?,?,?,?,?,?)').run(id, colony, p.kind, name, JSON.stringify(config), now, now);
|
|
66
|
+
return objectsStore.get(id);
|
|
67
|
+
},
|
|
68
|
+
update(id, p) {
|
|
69
|
+
const cur = objectsStore.get(id);
|
|
70
|
+
if (!cur)
|
|
71
|
+
throw new ObjectError('Resource not found');
|
|
72
|
+
const name = p.name !== undefined ? parseName(p.name) : cur.name;
|
|
73
|
+
const other = objectsStore.byName(name);
|
|
74
|
+
if (other && other.id !== id)
|
|
75
|
+
throw new ObjectError(`There is already a resource named "${name}"`);
|
|
76
|
+
if (cur.kind === 'http' && p.config)
|
|
77
|
+
keepSecrets(cur.config, p.config);
|
|
78
|
+
if (cur.kind === 'database' && p.config)
|
|
79
|
+
keepPassword(cur.config, p.config);
|
|
80
|
+
const config = p.config !== undefined ? parseConfig(cur.kind, p.config) : cur.config;
|
|
81
|
+
const colony = p.colony_id === undefined ? cur.colony_id : p.colony_id ? String(p.colony_id) : null;
|
|
82
|
+
if (colony && !colonies.get(colony))
|
|
83
|
+
throw new ObjectError('That colony no longer exists');
|
|
84
|
+
checkMembers(cur.kind, colony, config, id);
|
|
85
|
+
// Moving an object to another colony takes it out of the clusters it was grouped by.
|
|
86
|
+
db.transaction(() => {
|
|
87
|
+
if (colony !== cur.colony_id)
|
|
88
|
+
objectsStore.ungroup(id);
|
|
89
|
+
db.prepare('UPDATE colony_objects SET name=?, colony_id=?, config=?, updated_at=? WHERE id=?').run(name, colony, JSON.stringify(config), Date.now(), id);
|
|
90
|
+
})();
|
|
91
|
+
return objectsStore.get(id);
|
|
92
|
+
},
|
|
93
|
+
/** The agent chosen to look after each object (object id → agent id). */
|
|
94
|
+
managers() {
|
|
95
|
+
const m = new Map();
|
|
96
|
+
for (const r of db.prepare('SELECT object_id, agent_id FROM object_agents').all())
|
|
97
|
+
if (!m.has(r.object_id))
|
|
98
|
+
m.set(r.object_id, r.agent_id);
|
|
99
|
+
return m;
|
|
100
|
+
},
|
|
101
|
+
setManager(id, agentId) {
|
|
102
|
+
db.transaction(() => {
|
|
103
|
+
db.prepare('DELETE FROM object_agents WHERE object_id=?').run(id);
|
|
104
|
+
if (agentId)
|
|
105
|
+
db.prepare('INSERT INTO object_agents (object_id, agent_id) VALUES (?,?)').run(id, agentId);
|
|
106
|
+
})();
|
|
107
|
+
},
|
|
108
|
+
/** Takes an object out of every cluster that groups it. */
|
|
109
|
+
ungroup(id) {
|
|
110
|
+
for (const b of objectsStore.list().filter((x) => x.kind === 'cluster' && x.config.members.includes(id))) {
|
|
111
|
+
db.prepare('UPDATE colony_objects SET config=?, updated_at=? WHERE id=?').run(JSON.stringify({ members: b.config.members.filter((m) => m !== id) }), Date.now(), b.id);
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
remove(id) { return db.transaction(() => { objectsStore.ungroup(id); return db.prepare('DELETE FROM colony_objects WHERE id=?').run(id).changes > 0; })(); },
|
|
115
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { connect } from 'node:net';
|
|
3
|
+
import { noteServer, readSaved, rotateLog } from './runner.js';
|
|
4
|
+
/**
|
|
5
|
+
* What keeps a server object in good shape without anyone looking: it restarts it when it ends by itself (never, after a failure, or
|
|
6
|
+
* always) with a longer wait each time and a limit, it asks a health check whether a running server is really working (and can restart it
|
|
7
|
+
* when it is not), and it keeps its log from growing. Stopping it yourself is never undone: a stopped server stays stopped.
|
|
8
|
+
*/
|
|
9
|
+
const BASE_MS = () => Number(process.env.HIVE_AM_OBJECT_BACKOFF_MS) || 1000;
|
|
10
|
+
const WINDOW_MS = () => Number(process.env.HIVE_AM_OBJECT_RESTART_WINDOW_MS) || 600_000;
|
|
11
|
+
const MAX_WAIT_MS = 30_000;
|
|
12
|
+
const all = new Map();
|
|
13
|
+
const get = (id) => { let s = all.get(id); if (!s)
|
|
14
|
+
all.set(id, s = { restarts: [], pendingExit: 0, nextAt: 0, gaveUp: false, fails: 0, lastCheck: 0, detail: '', startedAt: 0 }); return s; };
|
|
15
|
+
/** A person started or stopped it: the automatic restarts begin again from nothing. */
|
|
16
|
+
export const resetSupervision = (id) => { all.delete(id); };
|
|
17
|
+
export const forgetSupervision = resetSupervision;
|
|
18
|
+
async function check(cfg, h) {
|
|
19
|
+
const timeout = (h.timeoutSec ?? 5) * 1000;
|
|
20
|
+
try {
|
|
21
|
+
if (h.kind === 'http') {
|
|
22
|
+
const r = await fetch(h.target, { signal: AbortSignal.timeout(timeout), redirect: 'manual' });
|
|
23
|
+
return r.status >= 200 && r.status < 400 ? { ok: true, detail: '' } : { ok: false, detail: `${h.target} answered ${r.status}` };
|
|
24
|
+
}
|
|
25
|
+
if (h.kind === 'tcp') {
|
|
26
|
+
const [host, p] = h.target.includes(':') ? [h.target.split(':')[0], Number(h.target.split(':').pop())] : ['127.0.0.1', Number(h.target)];
|
|
27
|
+
const up = await new Promise((resolve) => { const s = connect({ host, port: p }, () => { s.destroy(); resolve(true); }); s.setTimeout(timeout, () => { s.destroy(); resolve(false); }); s.on('error', () => resolve(false)); });
|
|
28
|
+
return up ? { ok: true, detail: '' } : { ok: false, detail: `nothing answers on ${h.target}` };
|
|
29
|
+
}
|
|
30
|
+
return await new Promise((resolve) => {
|
|
31
|
+
const c = spawn('sh', ['-c', h.target], { cwd: cfg.cwd, env: { ...process.env, ...cfg.env }, stdio: 'ignore' });
|
|
32
|
+
const t = setTimeout(() => { try {
|
|
33
|
+
c.kill('SIGKILL');
|
|
34
|
+
}
|
|
35
|
+
catch { /* gone */ } resolve({ ok: false, detail: `the check took more than ${timeout / 1000} s` }); }, timeout);
|
|
36
|
+
c.on('exit', (code) => { clearTimeout(t); resolve(code === 0 ? { ok: true, detail: '' } : { ok: false, detail: `the check exited with ${code}` }); });
|
|
37
|
+
c.on('error', () => { clearTimeout(t); resolve({ ok: false, detail: 'the check could not run' }); });
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
return { ok: false, detail: `${h.target}: ${e.name === 'TimeoutError' ? 'no answer in time' : e.message}` };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Takes the state of a server (alive or not) and returns what it really is, doing what the configuration asks. */
|
|
45
|
+
export async function supervise(o, cfg, state, act) {
|
|
46
|
+
rotateLog(o.id, cfg);
|
|
47
|
+
const S = get(o.id), now = Date.now(), saved = readSaved(o.id);
|
|
48
|
+
const max = cfg.maxRestarts ?? 5;
|
|
49
|
+
S.restarts = S.restarts.filter((t) => now - t < WINDOW_MS());
|
|
50
|
+
const budget = () => S.restarts.length < max;
|
|
51
|
+
const wait = () => Math.min(MAX_WAIT_MS, BASE_MS() * 2 ** S.restarts.length);
|
|
52
|
+
// Running: is it working?
|
|
53
|
+
if ((state.status === 'running') && cfg.health) {
|
|
54
|
+
if (S.startedAt !== saved?.startedAt) {
|
|
55
|
+
S.startedAt = saved?.startedAt ?? 0;
|
|
56
|
+
S.fails = 0;
|
|
57
|
+
S.lastCheck = 0;
|
|
58
|
+
}
|
|
59
|
+
if (now - S.lastCheck >= (cfg.health.intervalSec ?? 15) * 1000) {
|
|
60
|
+
S.lastCheck = now;
|
|
61
|
+
const r = await check(cfg, cfg.health);
|
|
62
|
+
if (r.ok) {
|
|
63
|
+
S.fails = 0;
|
|
64
|
+
S.detail = '';
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
S.fails++;
|
|
68
|
+
S.detail = r.detail;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (S.fails >= (cfg.health.retries ?? 3)) {
|
|
72
|
+
if (cfg.health.restartWhenUnhealthy && budget()) {
|
|
73
|
+
S.restarts.push(now);
|
|
74
|
+
S.fails = 0;
|
|
75
|
+
S.lastCheck = now;
|
|
76
|
+
noteServer(o.id, `unhealthy (${S.detail}): restarting (${S.restarts.length}/${max})`);
|
|
77
|
+
await act.restart().catch(() => undefined);
|
|
78
|
+
return { status: 'starting', detail: `was unhealthy: restarting (${S.restarts.length}/${max})` };
|
|
79
|
+
}
|
|
80
|
+
return { status: 'error', pid: state.pid, since: state.since, detail: `unhealthy: ${S.detail}${cfg.health.restartWhenUnhealthy ? ` (gave up after ${max} restarts)` : ''}` };
|
|
81
|
+
}
|
|
82
|
+
return state;
|
|
83
|
+
}
|
|
84
|
+
if (state.status === 'running' || state.status === 'starting')
|
|
85
|
+
return state;
|
|
86
|
+
// Not running. It ended by itself, and the person did not stop it?
|
|
87
|
+
const policy = cfg.restart ?? 'no';
|
|
88
|
+
const ended = saved && !saved.stopped && saved.exit;
|
|
89
|
+
if (!ended || policy === 'no')
|
|
90
|
+
return state;
|
|
91
|
+
const failed = ended.code !== 0 || !!ended.signal;
|
|
92
|
+
if (!(policy === 'always' || failed))
|
|
93
|
+
return state;
|
|
94
|
+
if (S.gaveUp)
|
|
95
|
+
return { ...state, detail: `${state.detail ?? 'ended'} · gave up after ${max} restarts` };
|
|
96
|
+
if (S.pendingExit !== ended.at) {
|
|
97
|
+
S.pendingExit = ended.at;
|
|
98
|
+
S.nextAt = now + wait();
|
|
99
|
+
}
|
|
100
|
+
if (!budget()) {
|
|
101
|
+
S.gaveUp = true;
|
|
102
|
+
noteServer(o.id, `gave up: it ended ${max} times in ${Math.round(WINDOW_MS() / 60000)} minutes`);
|
|
103
|
+
return { ...state, detail: `${state.detail ?? 'ended'} · gave up after ${max} restarts` };
|
|
104
|
+
}
|
|
105
|
+
if (now < S.nextAt)
|
|
106
|
+
return { ...state, detail: `${state.detail ?? 'ended'} · restarting in ${Math.ceil((S.nextAt - now) / 1000)} s (${S.restarts.length + 1}/${max})` };
|
|
107
|
+
S.restarts.push(now);
|
|
108
|
+
noteServer(o.id, `ended by itself: restarting (${S.restarts.length}/${max})`);
|
|
109
|
+
await act.start().catch((e) => noteServer(o.id, `could not restart: ${e instanceof Error ? e.message : e}`));
|
|
110
|
+
return { status: 'starting', detail: `restarted (${S.restarts.length}/${max})` };
|
|
111
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { ObjectError } from './model.js';
|
|
3
|
+
const q = (v) => JSON.stringify(v);
|
|
4
|
+
const version = (def) => ({ key: 'version', label: 'Version (image tag)', kind: 'text', default: def });
|
|
5
|
+
const port = (key, label, def) => ({ key, label, kind: 'port', default: def });
|
|
6
|
+
const text = (key, label, def) => ({ key, label, kind: 'text', default: def });
|
|
7
|
+
const secret = (key, label) => ({ key, label, kind: 'secret', default: '' });
|
|
8
|
+
export const TEMPLATES = [
|
|
9
|
+
{ id: 'redis', name: 'Redis', description: 'In-memory cache and queue', fields: [version('7-alpine'), port('port', 'Port on this machine', '6379')],
|
|
10
|
+
compose: (v) => `services:\n redis:\n image: redis:${v.version}\n ports: [${q(`${v.port}:6379`)}]\n volumes: [redis-data:/data]\n restart: unless-stopped\nvolumes:\n redis-data:\n` },
|
|
11
|
+
{ id: 'valkey', name: 'Valkey', description: 'Open-source Redis-compatible store', fields: [version('8-alpine'), port('port', 'Port on this machine', '6379')],
|
|
12
|
+
compose: (v) => `services:\n valkey:\n image: valkey/valkey:${v.version}\n ports: [${q(`${v.port}:6379`)}]\n volumes: [valkey-data:/data]\n restart: unless-stopped\nvolumes:\n valkey-data:\n` },
|
|
13
|
+
{ id: 'postgres', name: 'PostgreSQL', description: 'Relational database', fields: [version('16-alpine'), port('port', 'Port on this machine', '5432'), text('user', 'User', 'app'), secret('password', 'Password'), text('database', 'Database', 'app')],
|
|
14
|
+
compose: (v) => `services:\n postgres:\n image: postgres:${v.version}\n environment:\n POSTGRES_USER: ${q(v.user)}\n POSTGRES_PASSWORD: ${q(v.password)}\n POSTGRES_DB: ${q(v.database)}\n ports: [${q(`${v.port}:5432`)}]\n volumes: [pg-data:/var/lib/postgresql/data]\n healthcheck:\n test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]\n interval: 10s\n retries: 5\n restart: unless-stopped\nvolumes:\n pg-data:\n` },
|
|
15
|
+
{ id: 'mariadb', name: 'MariaDB', description: 'MySQL-compatible database', fields: [version('11'), port('port', 'Port on this machine', '3306'), secret('password', 'Root password'), text('database', 'Database', 'app')],
|
|
16
|
+
compose: (v) => `services:\n mariadb:\n image: mariadb:${v.version}\n environment:\n MARIADB_ROOT_PASSWORD: ${q(v.password)}\n MARIADB_DATABASE: ${q(v.database)}\n ports: [${q(`${v.port}:3306`)}]\n volumes: [maria-data:/var/lib/mysql]\n restart: unless-stopped\nvolumes:\n maria-data:\n` },
|
|
17
|
+
{ id: 'mysql', name: 'MySQL', description: 'Relational database', fields: [version('8'), port('port', 'Port on this machine', '3306'), secret('password', 'Root password'), text('database', 'Database', 'app')],
|
|
18
|
+
compose: (v) => `services:\n mysql:\n image: mysql:${v.version}\n environment:\n MYSQL_ROOT_PASSWORD: ${q(v.password)}\n MYSQL_DATABASE: ${q(v.database)}\n ports: [${q(`${v.port}:3306`)}]\n volumes: [mysql-data:/var/lib/mysql]\n restart: unless-stopped\nvolumes:\n mysql-data:\n` },
|
|
19
|
+
{ id: 'mongodb', name: 'MongoDB', description: 'Document database', fields: [version('7'), port('port', 'Port on this machine', '27017'), text('user', 'User', 'app'), secret('password', 'Password')],
|
|
20
|
+
compose: (v) => `services:\n mongo:\n image: mongo:${v.version}\n environment:\n MONGO_INITDB_ROOT_USERNAME: ${q(v.user)}\n MONGO_INITDB_ROOT_PASSWORD: ${q(v.password)}\n ports: [${q(`${v.port}:27017`)}]\n volumes: [mongo-data:/data/db]\n restart: unless-stopped\nvolumes:\n mongo-data:\n` },
|
|
21
|
+
{ id: 'rabbitmq', name: 'RabbitMQ', description: 'Message broker with its web panel', fields: [version('3-management'), port('port', 'AMQP port', '5672'), port('webPort', 'Web panel port', '15672'), text('user', 'User', 'app'), secret('password', 'Password')],
|
|
22
|
+
compose: (v) => `services:\n rabbitmq:\n image: rabbitmq:${v.version}\n environment:\n RABBITMQ_DEFAULT_USER: ${q(v.user)}\n RABBITMQ_DEFAULT_PASS: ${q(v.password)}\n ports: [${q(`${v.port}:5672`)}, ${q(`${v.webPort}:15672`)}]\n volumes: [rabbit-data:/var/lib/rabbitmq]\n restart: unless-stopped\nvolumes:\n rabbit-data:\n` },
|
|
23
|
+
{ id: 'minio', name: 'MinIO', description: 'S3-compatible object storage', fields: [version('latest'), port('port', 'API port', '9000'), port('webPort', 'Console port', '9001'), text('user', 'User', 'minio'), secret('password', 'Password')],
|
|
24
|
+
compose: (v) => `services:\n minio:\n image: minio/minio:${v.version}\n command: server /data --console-address ":9001"\n environment:\n MINIO_ROOT_USER: ${q(v.user)}\n MINIO_ROOT_PASSWORD: ${q(v.password)}\n ports: [${q(`${v.port}:9000`)}, ${q(`${v.webPort}:9001`)}]\n volumes: [minio-data:/data]\n restart: unless-stopped\nvolumes:\n minio-data:\n` },
|
|
25
|
+
{ id: 'nginx', name: 'Nginx', description: 'Web server', fields: [version('alpine'), port('port', 'Port on this machine', '8080')],
|
|
26
|
+
compose: (v) => `services:\n nginx:\n image: nginx:${v.version}\n ports: [${q(`${v.port}:80`)}]\n restart: unless-stopped\n` },
|
|
27
|
+
{ id: 'mailpit', name: 'Mailpit', description: 'Catches the emails your app sends, with a web inbox', fields: [version('latest'), port('webPort', 'Inbox port', '8025'), port('port', 'SMTP port', '1025')],
|
|
28
|
+
compose: (v) => `services:\n mailpit:\n image: axllent/mailpit:${v.version}\n ports: [${q(`${v.webPort}:8025`)}, ${q(`${v.port}:1025`)}]\n restart: unless-stopped\n` },
|
|
29
|
+
{ id: 'adminer', name: 'Adminer', description: 'Web panel for databases', fields: [version('latest'), port('port', 'Port on this machine', '8081')],
|
|
30
|
+
compose: (v) => `services:\n adminer:\n image: adminer:${v.version}\n ports: [${q(`${v.port}:8080`)}]\n restart: unless-stopped\n` },
|
|
31
|
+
];
|
|
32
|
+
/** The catalog for the interface; each password starts with a random value. */
|
|
33
|
+
export const listTemplates = () => TEMPLATES.map(({ compose: _c, ...t }) => ({ ...t, fields: t.fields.map((f) => (f.kind === 'secret' ? { ...f, default: randomBytes(9).toString('base64url') } : f)) }));
|
|
34
|
+
/** Checks the values for a template (missing ones take the default) and returns the compose file. */
|
|
35
|
+
export function renderTemplate(id, given) {
|
|
36
|
+
const t = TEMPLATES.find((x) => x.id === id);
|
|
37
|
+
if (!t)
|
|
38
|
+
throw new ObjectError('Unknown template');
|
|
39
|
+
const v = {};
|
|
40
|
+
for (const f of t.fields) {
|
|
41
|
+
const raw = String(given[f.key] ?? '').trim() || f.default;
|
|
42
|
+
if (!raw)
|
|
43
|
+
throw new ObjectError(`${f.label} is required`);
|
|
44
|
+
if (f.kind === 'port') {
|
|
45
|
+
const n = Number(raw);
|
|
46
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535)
|
|
47
|
+
throw new ObjectError(`${f.label} must be a port between 1 and 65535`);
|
|
48
|
+
v[f.key] = String(n);
|
|
49
|
+
}
|
|
50
|
+
else if (f.key === 'version') {
|
|
51
|
+
if (!/^[\w][\w.-]{0,60}$/.test(raw))
|
|
52
|
+
throw new ObjectError('The version is not a valid image tag');
|
|
53
|
+
v[f.key] = raw;
|
|
54
|
+
}
|
|
55
|
+
else if (f.kind === 'secret') {
|
|
56
|
+
if (!/^[\w.@%+=-]{1,64}$/.test(raw))
|
|
57
|
+
throw new ObjectError(`${f.label}: use letters, numbers and . @ % + = - _ (up to 64)`);
|
|
58
|
+
v[f.key] = raw;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
if (!/^[\w.@-]{1,64}$/.test(raw))
|
|
62
|
+
throw new ObjectError(`${f.label}: use letters, numbers and . @ - _ (up to 64)`);
|
|
63
|
+
v[f.key] = raw;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return t.compose(v);
|
|
67
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { flattenText, spawnJson } from './spawn.js';
|
|
4
4
|
import { DATA_DIR } from '../db.js';
|
|
5
5
|
import { dispatchMcpConfig } from '../mcp-config.js';
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
@@ -38,10 +38,7 @@ export async function* runClaude(o) {
|
|
|
38
38
|
const toolNames = new Map();
|
|
39
39
|
let streamedText = false;
|
|
40
40
|
let sawSession = false;
|
|
41
|
-
for await (const
|
|
42
|
-
const ev = safeJson(line);
|
|
43
|
-
if (!ev)
|
|
44
|
-
continue;
|
|
41
|
+
for await (const ev of spawnJson({ cmd: 'claude', args, cwd: agent.cwd, stdin: prompt, signal: o.signal })) {
|
|
45
42
|
if (ev.session_id && !sawSession) {
|
|
46
43
|
sawSession = true;
|
|
47
44
|
yield { t: 'session', sessionId: ev.session_id };
|
|
@@ -73,7 +70,7 @@ export async function* runClaude(o) {
|
|
|
73
70
|
if (Array.isArray(content))
|
|
74
71
|
for (const b of content) {
|
|
75
72
|
if (b.type === 'tool_result')
|
|
76
|
-
yield { t: 'tool_result', id: b.tool_use_id, output:
|
|
73
|
+
yield { t: 'tool_result', id: b.tool_use_id, output: flattenText(b.content), error: !!b.is_error };
|
|
77
74
|
}
|
|
78
75
|
}
|
|
79
76
|
else if (ev.type === 'result') {
|
|
@@ -91,10 +88,3 @@ export async function* runClaude(o) {
|
|
|
91
88
|
}
|
|
92
89
|
yield { t: 'done', ok: true };
|
|
93
90
|
}
|
|
94
|
-
function flatten(c) {
|
|
95
|
-
if (typeof c === 'string')
|
|
96
|
-
return c;
|
|
97
|
-
if (Array.isArray(c))
|
|
98
|
-
return c.map((x) => (typeof x === 'string' ? x : x?.text ?? '')).join('\n');
|
|
99
|
-
return c == null ? '' : JSON.stringify(c);
|
|
100
|
-
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { runClaude } from './claude.js';
|
|
2
2
|
import { runOpencode } from './opencode.js';
|
|
3
3
|
import { runKiro } from './kiro.js';
|
|
4
|
+
export const providers = {
|
|
5
|
+
claude: { id: 'claude', run: runClaude },
|
|
6
|
+
opencode: { id: 'opencode', run: runOpencode },
|
|
7
|
+
kiro: { id: 'kiro', run: runKiro },
|
|
8
|
+
};
|
|
9
|
+
/** The adapters as plain functions (what `runtime.ts` and the image reader call; tests may replace one). */
|
|
4
10
|
export const runners = {
|
|
5
|
-
claude:
|
|
11
|
+
claude: (o) => providers.claude.run(o), opencode: (o) => providers.opencode.run(o), kiro: (o) => providers.kiro.run(o),
|
|
6
12
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnJson } from './spawn.js';
|
|
2
2
|
import { withInstructions } from './preamble.js';
|
|
3
3
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
@@ -40,10 +40,7 @@ export async function* runKiro(o) {
|
|
|
40
40
|
args.push(prompt);
|
|
41
41
|
let sawSession = false;
|
|
42
42
|
const open = new Set();
|
|
43
|
-
for await (const
|
|
44
|
-
const ev = safeJson(line);
|
|
45
|
-
if (!ev)
|
|
46
|
-
continue;
|
|
43
|
+
for await (const ev of spawnJson({ cmd: 'kiro-cli', args, cwd: agent.cwd, signal: o.signal })) {
|
|
47
44
|
const d = ev.data ?? {};
|
|
48
45
|
if (d.sessionId && !sawSession) {
|
|
49
46
|
sawSession = true;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DATA_DIR } from '../db.js';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
const INBOX_ROOT = join(DATA_DIR, 'inbox');
|
|
4
|
-
import {
|
|
4
|
+
import { spawnJson } from './spawn.js';
|
|
5
5
|
import { dispatchMcpConfigOpencode } from '../mcp-config.js';
|
|
6
6
|
import { opencodeSessionDir } from '../history/opencode.js';
|
|
7
7
|
import { withInstructions } from './preamble.js';
|
|
@@ -70,10 +70,7 @@ export async function* runOpencode(o) {
|
|
|
70
70
|
const toolStarted = new Set();
|
|
71
71
|
let sawSession = false;
|
|
72
72
|
try {
|
|
73
|
-
for await (const
|
|
74
|
-
const ev = safeJson(line);
|
|
75
|
-
if (!ev)
|
|
76
|
-
continue;
|
|
73
|
+
for await (const ev of spawnJson({ cmd: 'opencode', args, cwd: agent.cwd, env, signal: o.signal })) {
|
|
77
74
|
if (ev.sessionID && !sawSession) {
|
|
78
75
|
sawSession = true;
|
|
79
76
|
yield { t: 'session', sessionId: ev.sessionID };
|
|
@@ -11,3 +11,5 @@ export function withInstructions(prompt, instructions, resumed, refresh) {
|
|
|
11
11
|
return `<instructions update="true">\nYour configuration changed since the last message. These instructions replace any earlier ones; use them from now on.\n\n${instructions}\n</instructions>\n\n${prompt}`;
|
|
12
12
|
return prompt;
|
|
13
13
|
}
|
|
14
|
+
/** Takes the instructions block off the front of a user message read back from a CLI's own history: it is ours, not what the person wrote. */
|
|
15
|
+
export const stripInstructions = (text) => text.replace(/^<instructions[^>]*>[\s\S]*?<\/instructions>\s*/, '');
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createInterface } from 'node:readline';
|
|
3
|
+
const KILL_GRACE_MS = Number(process.env.HIVE_AM_KILL_GRACE_MS) || 5000;
|
|
3
4
|
/** Spawns a CLI and yields its stdout lines. Throws with stderr tail if it exits non-zero and nothing aborted it. */
|
|
4
5
|
export async function* spawnLines(o) {
|
|
5
6
|
const child = spawn(o.cmd, o.args, {
|
|
@@ -8,8 +9,17 @@ export async function* spawnLines(o) {
|
|
|
8
9
|
env: { ...process.env, PWD: o.cwd, ...o.env },
|
|
9
10
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
10
11
|
});
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
// SIGTERM first; a CLI that ignores it (busy inside a tool) is cut off after a grace period so a stopped turn never leaves a process behind.
|
|
13
|
+
let hardKill;
|
|
14
|
+
const kill = () => {
|
|
15
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
16
|
+
return;
|
|
17
|
+
if (!child.killed)
|
|
18
|
+
child.kill('SIGTERM');
|
|
19
|
+
hardKill ??= setTimeout(() => { if (child.exitCode === null && child.signalCode === null)
|
|
20
|
+
child.kill('SIGKILL'); }, KILL_GRACE_MS);
|
|
21
|
+
hardKill.unref?.();
|
|
22
|
+
};
|
|
13
23
|
o.signal.addEventListener('abort', kill, { once: true });
|
|
14
24
|
let stderr = '';
|
|
15
25
|
child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
|
|
@@ -17,8 +27,10 @@ export async function* spawnLines(o) {
|
|
|
17
27
|
child.on('error', (e) => { stderr += String(e); res(-1); });
|
|
18
28
|
// 'exit' (not 'close'): a daemon the CLI forks (e.g. opencode's background service) can keep
|
|
19
29
|
// inheriting our stdout pipe forever, so 'close' would never fire. Drain briefly, then cut the pipe.
|
|
20
|
-
child.on('exit', (c) => {
|
|
30
|
+
child.on('exit', (c) => { if (hardKill)
|
|
31
|
+
clearTimeout(hardKill); setTimeout(() => child.stdout.destroy(), 250); res(c); });
|
|
21
32
|
});
|
|
33
|
+
child.stdin.on('error', () => { });
|
|
22
34
|
if (o.stdin !== undefined)
|
|
23
35
|
child.stdin.end(o.stdin);
|
|
24
36
|
else
|
|
@@ -44,3 +56,19 @@ export function safeJson(line) {
|
|
|
44
56
|
return null;
|
|
45
57
|
}
|
|
46
58
|
}
|
|
59
|
+
/** The JSON objects a CLI prints, one per line; lines that are not JSON (banners, warnings) are skipped. */
|
|
60
|
+
export async function* spawnJson(o) {
|
|
61
|
+
for await (const line of spawnLines(o)) {
|
|
62
|
+
const ev = safeJson(line);
|
|
63
|
+
if (ev)
|
|
64
|
+
yield ev;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The text of a tool result, which CLIs send as a string, a list of blocks or any other JSON. */
|
|
68
|
+
export function flattenText(c) {
|
|
69
|
+
if (typeof c === 'string')
|
|
70
|
+
return c;
|
|
71
|
+
if (Array.isArray(c))
|
|
72
|
+
return c.map((x) => (typeof x === 'string' ? x : x?.text ?? '')).join('\n');
|
|
73
|
+
return c == null ? '' : JSON.stringify(c);
|
|
74
|
+
}
|
package/server/dist/runtime.js
CHANGED
|
@@ -58,17 +58,19 @@ async function execute(agentId, prompt, source, from, dispatchId, origin) {
|
|
|
58
58
|
agents.setStatus(agentId, 'running');
|
|
59
59
|
emit({ kind: 'turn_start', agentId, turnId, prompt, source, from, channel: origin && { platform: origin.platform, place: origin.place, user: origin.userName } });
|
|
60
60
|
emit({ kind: 'status', agentId, status: 'running', queued: s.queued });
|
|
61
|
-
const base = composeInstructions(agent, delegated);
|
|
62
|
-
const nbVersion = notebooks.get(agentId).version;
|
|
63
|
-
const instructions = [base, notebookBlock(agent)].filter(Boolean).join('\n\n');
|
|
64
|
-
const hash = createHash('sha1').update(base).digest('hex').slice(0, 16);
|
|
65
|
-
// The conversation is re-sent the instructions when its configuration changed, or when the notes were edited behind its back (by the user, or in another conversation).
|
|
66
|
-
const refreshInstructions = !delegated && !!agent.session_id && (raw.instr_hash !== hash || notebooks.get(agentId).seen < nbVersion);
|
|
67
61
|
let text = '';
|
|
68
62
|
let summary;
|
|
69
63
|
let error;
|
|
64
|
+
let hash = '', nbVersion = 0;
|
|
70
65
|
const push = (event) => { s.live?.events.push(event); emit({ kind: 'event', agentId, turnId, event }); };
|
|
71
66
|
try {
|
|
67
|
+
// 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
|
+
const base = composeInstructions(agent, delegated);
|
|
69
|
+
nbVersion = notebooks.get(agentId).version;
|
|
70
|
+
const instructions = [base, notebookBlock(agent)].filter(Boolean).join('\n\n');
|
|
71
|
+
hash = createHash('sha1').update(base).digest('hex').slice(0, 16);
|
|
72
|
+
// The conversation is re-sent the instructions when its configuration changed, or when the notes were edited behind its back (by the user, or in another conversation).
|
|
73
|
+
const refreshInstructions = !delegated && !!agent.session_id && (raw.instr_hash !== hash || notebooks.get(agentId).seen < nbVersion);
|
|
72
74
|
const stream = runners[agent.provider]({
|
|
73
75
|
agent, prompt, signal: controller.signal,
|
|
74
76
|
instructions, refreshInstructions,
|
package/server/dist/schedules.js
CHANGED
|
@@ -5,12 +5,13 @@ import { adapterFor } from './connections/manager.js';
|
|
|
5
5
|
import { deliverScheduled } from './connections/router.js';
|
|
6
6
|
import { connections, threads } from './connections/store.js';
|
|
7
7
|
import { liveOrigin, liveSource, queueDepth, sendTurn } from './runtime.js';
|
|
8
|
+
import { DomainError } from './errors.js';
|
|
8
9
|
/**
|
|
9
10
|
* Recurring schedules an agent sets for itself ("every weekday at 9, check the QA logs and tell me"). Like a wake-up, a run reaches the agent
|
|
10
11
|
* in the place where the schedule was made: the same chat thread, or its own web conversation. Cost is bounded: a minimum interval between runs,
|
|
11
12
|
* a limit per agent, no catching up after an outage (a missed run is skipped, never replayed) and no run while the agent is already behind.
|
|
12
13
|
*/
|
|
13
|
-
export class ScheduleError extends
|
|
14
|
+
export class ScheduleError extends DomainError {
|
|
14
15
|
}
|
|
15
16
|
const MS_PER_MINUTE = Number(process.env.HIVE_AM_WAKE_MS_PER_MINUTE) || 60_000; // tests shrink a "minute" (only for "every N minutes")
|
|
16
17
|
export const MIN_INTERVAL_MINUTES = Number(process.env.HIVE_AM_SCHEDULE_MIN_MINUTES) || 15;
|
|
@@ -19,26 +20,6 @@ const MISSED_GRACE_MS = 5 * 60_000; // a run later than this (the server was dow
|
|
|
19
20
|
const BUSY_QUEUE = 2; // skip a run when this many messages already wait for the agent
|
|
20
21
|
const KEEP_RUNS = 50;
|
|
21
22
|
const MAX_TIMER_MS = 2_147_483_647;
|
|
22
|
-
db.exec(`
|
|
23
|
-
CREATE TABLE IF NOT EXISTS agent_schedules (
|
|
24
|
-
id TEXT PRIMARY KEY, agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
25
|
-
connection_id TEXT, thread_id TEXT REFERENCES threads(id) ON DELETE CASCADE,
|
|
26
|
-
kind TEXT NOT NULL, expr TEXT NOT NULL, tz TEXT NOT NULL, note TEXT NOT NULL,
|
|
27
|
-
enabled INTEGER NOT NULL DEFAULT 1, next_due INTEGER, created_at INTEGER NOT NULL,
|
|
28
|
-
last_fired_at INTEGER, fire_count INTEGER NOT NULL DEFAULT 0, last_status TEXT, last_detail TEXT
|
|
29
|
-
);
|
|
30
|
-
CREATE TABLE IF NOT EXISTS schedule_runs (
|
|
31
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT, schedule_id TEXT NOT NULL REFERENCES agent_schedules(id) ON DELETE CASCADE,
|
|
32
|
-
fired_at INTEGER NOT NULL, status TEXT NOT NULL, detail TEXT, duration_ms INTEGER
|
|
33
|
-
);
|
|
34
|
-
CREATE INDEX IF NOT EXISTS idx_schedule_runs ON schedule_runs (schedule_id, id);`);
|
|
35
|
-
// Who asked (only kept when it was in a group): each run mentions them. Older databases get the columns here.
|
|
36
|
-
for (const col of ['req_id TEXT', 'req_name TEXT']) {
|
|
37
|
-
try {
|
|
38
|
-
db.exec(`ALTER TABLE agent_schedules ADD COLUMN ${col}`);
|
|
39
|
-
}
|
|
40
|
-
catch { /* already there */ }
|
|
41
|
-
}
|
|
42
23
|
const fromRow = (r) => ({ ...r, enabled: !!r.enabled });
|
|
43
24
|
const store = {
|
|
44
25
|
get: (id) => { const r = db.prepare('SELECT * FROM agent_schedules WHERE id=?').get(id); return r ? fromRow(r) : undefined; },
|
package/server/dist/seed.js
CHANGED
|
@@ -4,30 +4,34 @@ import { NOTEBOOK_SKILL_ID } from './skills/defaults.js';
|
|
|
4
4
|
export function seedIfEmpty() {
|
|
5
5
|
if (types.list().length || agents.list().length || skills.list().some((s) => !s.id.startsWith('default-')))
|
|
6
6
|
return;
|
|
7
|
-
const s1 = skills.create({
|
|
8
|
-
name: 'concise-reports', description: 'Short, structured final answers',
|
|
9
|
-
content: 'Finish every task with a report: what you did, what you found, what is left. Use short bullet points. No filler.',
|
|
10
|
-
});
|
|
11
|
-
const s2 = skills.create({
|
|
12
|
-
name: 'careful-reviewer', description: 'Review code for bugs before style',
|
|
13
|
-
content: 'When reviewing, rank findings by severity. Report concrete failure scenarios, not preferences. Never edit files unless asked.',
|
|
14
|
-
});
|
|
15
7
|
types.create({
|
|
16
8
|
name: 'Queen', role: 'orchestrator', provider: 'claude', model: 'sonnet', permission: 'acceptEdits', color: '#E8A317',
|
|
17
9
|
description: 'Plans the work and delegates it to workers',
|
|
18
10
|
system_prompt: 'You are an orchestrator. Break the request into independent tasks, delegate each to the best subagent, then merge their answers into one clear result.',
|
|
19
|
-
skill_ids: [
|
|
11
|
+
skill_ids: [NOTEBOOK_SKILL_ID],
|
|
20
12
|
});
|
|
21
13
|
types.create({
|
|
22
14
|
name: 'Builder', role: 'worker', provider: 'claude', model: 'sonnet', permission: 'acceptEdits', color: '#D97757',
|
|
23
15
|
description: 'Implements features and fixes in the codebase',
|
|
24
16
|
system_prompt: 'You implement changes carefully and verify them by running the project’s tests when they exist.',
|
|
25
|
-
skill_ids: [
|
|
17
|
+
skill_ids: [NOTEBOOK_SKILL_ID],
|
|
26
18
|
});
|
|
27
19
|
types.create({
|
|
28
20
|
name: 'Reviewer', role: 'worker', provider: 'opencode', model: '', permission: 'plan', color: '#3E7BFA',
|
|
29
21
|
description: 'Reads code and reports problems without changing it',
|
|
30
22
|
system_prompt: 'You review code. You never modify files.',
|
|
31
|
-
skill_ids: [
|
|
23
|
+
skill_ids: [],
|
|
32
24
|
});
|
|
33
25
|
}
|
|
26
|
+
/** The two example skills older versions created on the first run are gone; where they are still as they were made, they are removed. */
|
|
27
|
+
const STARTER = [
|
|
28
|
+
{ name: 'concise-reports', content: 'Finish every task with a report: what you did, what you found, what is left. Use short bullet points. No filler.' },
|
|
29
|
+
{ name: 'careful-reviewer', content: 'When reviewing, rank findings by severity. Report concrete failure scenarios, not preferences. Never edit files unless asked.' },
|
|
30
|
+
];
|
|
31
|
+
export function dropStarterSkills() {
|
|
32
|
+
for (const d of STARTER) {
|
|
33
|
+
const s = skills.list().find((x) => x.name === d.name && x.content === d.content && !x.id.startsWith('default-'));
|
|
34
|
+
if (s)
|
|
35
|
+
skills.remove(s.id);
|
|
36
|
+
}
|
|
37
|
+
}
|