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.
- package/CHANGELOG.md +47 -1
- package/README.md +20 -5
- package/bin/hive-am.mjs +242 -31
- package/bin/run.mjs +135 -0
- package/package.json +1 -1
- package/server/dist/connections/manager.js +15 -3
- package/server/dist/connections/router.js +2 -2
- package/server/dist/connections/secrets.js +8 -0
- package/server/dist/db/migrations.js +68 -0
- package/server/dist/db.js +11 -10
- package/server/dist/efforts.js +105 -0
- package/server/dist/http/routes/agents.js +4 -3
- package/server/dist/http/routes/meta.js +12 -0
- package/server/dist/http/routes/notebook.js +10 -2
- package/server/dist/http/routes/resources.js +3 -0
- package/server/dist/http/routes/types.js +18 -1
- package/server/dist/http/validate.js +5 -2
- package/server/dist/index.js +4 -1
- package/server/dist/instructions.js +10 -2
- package/server/dist/objects/agent-db.js +258 -0
- package/server/dist/objects/database.js +496 -34
- package/server/dist/objects/index.js +20 -14
- package/server/dist/objects/runner.js +20 -0
- package/server/dist/objects/supervisor.js +2 -2
- package/server/dist/providers/kiro.js +7 -1
- package/server/dist/providers/opencode-server.js +11 -1
- package/server/dist/providers/opencode.js +4 -3
- package/server/dist/providers/spawn.js +38 -4
- package/server/dist/retention.js +50 -0
- package/server/dist/runtime.js +52 -21
- package/server/dist/services/agents.js +53 -1
- package/server/dist/services/notebook.js +7 -3
- package/server/dist/skills/defaults.js +35 -1
- package/server/dist/skills/notebook.js +57 -13
- package/server/mcp/dispatch.mjs +68 -0
- package/web/.next/BUILD_ID +1 -1
- package/web/.next/app-build-manifest.json +79 -70
- package/web/.next/app-path-routes-manifest.json +9 -8
- package/web/.next/build-manifest.json +2 -2
- package/web/.next/prerender-manifest.json +71 -47
- package/web/.next/routes-manifest.json +6 -0
- package/web/.next/server/app/_not-found/page.js +1 -1
- 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 +3 -3
- 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 +4 -4
- package/web/.next/server/app/chat/page.js +2 -0
- package/web/.next/server/app/chat/page.js.nft.json +1 -0
- package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -0
- package/web/.next/server/app/chat.html +1 -0
- package/web/.next/server/app/chat.meta +15 -0
- package/web/.next/server/app/chat.rsc +25 -0
- package/web/.next/server/app/connections/page.js +1 -1
- 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 +4 -4
- package/web/.next/server/app/index.html +1 -1
- package/web/.next/server/app/index.rsc +4 -4
- package/web/.next/server/app/page.js +1 -1
- 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 +4 -4
- package/web/.next/server/app/resources/[id]/page.js +2 -2
- package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -1
- package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/resources/page.js +2 -2
- package/web/.next/server/app/resources/page.js.nft.json +1 -1
- package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/resources.html +1 -1
- package/web/.next/server/app/resources.rsc +4 -4
- package/web/.next/server/app/schedules/page.js +1 -1
- 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 +4 -4
- 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 +4 -4
- package/web/.next/server/app/skills/page.js +1 -1
- 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 +4 -4
- package/web/.next/server/app/types/page.js +1 -1
- 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 +4 -4
- package/web/.next/server/app-paths-manifest.json +9 -8
- package/web/.next/server/chunks/143.js +1 -1
- package/web/.next/server/chunks/148.js +1 -0
- package/web/.next/server/chunks/213.js +6 -0
- package/web/.next/server/chunks/274.js +1 -1
- package/web/.next/server/chunks/368.js +1 -0
- package/web/.next/server/chunks/403.js +1 -0
- package/web/.next/server/chunks/{790.js → 435.js} +2 -2
- package/web/.next/server/chunks/505.js +1 -6
- package/web/.next/server/chunks/612.js +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/UDA414PFPu9ZROltNmjbX/_buildManifest.js +1 -0
- package/web/.next/static/chunks/{316-26da9cc233ba7557.js → 107-77dd87cc2ca4f941.js} +1 -1
- package/web/.next/static/chunks/281-30e32d932b8b5a75.js +1 -0
- package/web/.next/static/chunks/391-18768499e741112e.js +1 -0
- package/web/.next/static/chunks/517-b3b1c9044d2ef23c.js +1 -0
- package/web/.next/static/chunks/587-5b511f5f93910180.js +1 -0
- package/web/.next/static/chunks/606-14fb5aaa4d0ff22d.js +1 -0
- package/web/.next/static/chunks/656-f3cc78328603241a.js +1 -0
- package/web/.next/static/chunks/{705-780e48e55b0346bd.js → 705-ce86175dea00e6a9.js} +1 -1
- package/web/.next/static/chunks/757-03361e7a97a605a2.js +1 -0
- package/web/.next/static/chunks/app/agents/[id]/page-638e12ba3cb813c3.js +1 -0
- package/web/.next/static/chunks/app/agents/page-9da499db6d22731f.js +1 -0
- package/web/.next/static/chunks/app/chat/page-383bc24e72dab7fa.js +1 -0
- package/web/.next/static/chunks/app/connections/{page-eb11cfb1ffeab3ca.js → page-06bd2d7d9495126a.js} +1 -1
- package/web/.next/static/chunks/app/layout-fad968a635f9a4b3.js +1 -0
- package/web/.next/static/chunks/app/page-0062ebc30322b1ae.js +1 -0
- package/web/.next/static/chunks/app/relations/page-02710fe1f8a670a9.js +1 -0
- package/web/.next/static/chunks/app/resources/[id]/{page-bf05836f97248ca7.js → page-fc494e85b01e8cb7.js} +1 -1
- package/web/.next/static/chunks/app/resources/{page-800f774e5cacf4c9.js → page-61cccd8fdcc2f6c8.js} +1 -1
- package/web/.next/static/chunks/app/sessions/page-85638705fdc18174.js +1 -0
- package/web/.next/static/chunks/app/types/page-8e25f503bdcd72ad.js +1 -0
- package/web/.next/static/css/dfcaa19a8d46e60d.css +1 -0
- package/web/.next/trace +30 -29
- package/web/.next/types/app/chat/page.ts +84 -0
- package/web/.next/types/routes.d.ts +2 -1
- package/web/.next/types/validator.ts +9 -0
- package/web/.next/server/chunks/306.js +0 -1
- package/web/.next/server/chunks/451.js +0 -1
- package/web/.next/server/chunks/638.js +0 -1
- package/web/.next/server/chunks/802.js +0 -1
- package/web/.next/static/aSYzbHk1M5YeS_d3SQKTZ/_buildManifest.js +0 -1
- package/web/.next/static/chunks/281-d8a0dcfaa6d064e4.js +0 -1
- package/web/.next/static/chunks/291-23f314877fa39054.js +0 -1
- package/web/.next/static/chunks/304-681348099f874a30.js +0 -1
- package/web/.next/static/chunks/491-28f093772bd460ac.js +0 -1
- package/web/.next/static/chunks/517-373a80c8ba432bdc.js +0 -1
- package/web/.next/static/chunks/562-7c2e33d838e869bf.js +0 -1
- package/web/.next/static/chunks/581-3ad5c4343dad3f50.js +0 -1
- package/web/.next/static/chunks/844-519177e6198ca84f.js +0 -1
- package/web/.next/static/chunks/app/agents/[id]/page-c005161cec5527d8.js +0 -1
- package/web/.next/static/chunks/app/agents/page-9bb8cba69776c719.js +0 -1
- package/web/.next/static/chunks/app/layout-49b46c9b5aa512a8.js +0 -1
- package/web/.next/static/chunks/app/page-da042d146caeeab6.js +0 -1
- package/web/.next/static/chunks/app/relations/page-a44fd515a3b0525e.js +0 -1
- package/web/.next/static/chunks/app/sessions/page-a7534ebd90e982d6.js +0 -1
- package/web/.next/static/chunks/app/types/page-ebd37e220f77c1f3.js +0 -1
- package/web/.next/static/css/d42799b7e6a0e8a2.css +0 -1
- /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:
|
|
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
|
|
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
|
-
/**
|
|
368
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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;
|
package/server/dist/runtime.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
//
|
|
50
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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) {
|