hive-am 1.0.0 → 1.1.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 +32 -1
- package/README.md +26 -4
- package/package.json +1 -1
- package/server/dist/api.js +86 -1
- package/server/dist/connections/addressing.js +87 -0
- package/server/dist/connections/channel-skill.js +50 -0
- package/server/dist/connections/fake.js +1 -0
- package/server/dist/connections/format.js +9 -1
- package/server/dist/connections/manager.js +19 -0
- package/server/dist/connections/prompt.js +8 -3
- package/server/dist/connections/rate.js +20 -0
- package/server/dist/connections/router.js +53 -21
- package/server/dist/connections/secrets.js +52 -0
- package/server/dist/connections/store.js +2 -0
- package/server/dist/connections/telegram.js +62 -11
- package/server/dist/cron.js +137 -0
- package/server/dist/history/claude.js +13 -0
- package/server/dist/index.js +8 -0
- package/server/dist/instructions.js +17 -11
- package/server/dist/providers/claude.js +2 -0
- package/server/dist/providers/kiro.js +2 -0
- package/server/dist/providers/opencode-server.js +164 -0
- package/server/dist/providers/opencode.js +62 -36
- package/server/dist/runtime.js +2 -1
- package/server/dist/schedules.js +241 -0
- package/server/dist/skills/defaults.js +83 -0
- package/server/dist/wake.js +152 -0
- package/server/dist/watch.js +207 -0
- package/server/mcp/dispatch.mjs +100 -1
- package/web/.next/BUILD_ID +1 -1
- package/web/.next/app-build-manifest.json +47 -39
- package/web/.next/app-path-routes-manifest.json +6 -5
- package/web/.next/build-manifest.json +2 -2
- package/web/.next/prerender-manifest.json +64 -40
- 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 +1 -1
- 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/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 +1 -1
- 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/schedules/page.js +2 -0
- package/web/.next/server/app/schedules/page.js.nft.json +1 -0
- package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -0
- package/web/.next/server/app/schedules.html +1 -0
- package/web/.next/server/app/schedules.meta +15 -0
- package/web/.next/server/app/schedules.rsc +24 -0
- package/web/.next/server/app/sessions/page.js +1 -1
- 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 +6 -5
- package/web/.next/server/chunks/{149.js → 484.js} +2 -2
- package/web/.next/server/chunks/851.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/_z2hBOHhwiR5ytdI_8alh/_buildManifest.js +1 -0
- package/web/.next/static/chunks/493-50d2adf9fd5ec680.js +1 -0
- package/web/.next/static/chunks/{351-65da8c1a6e25f93b.js → 589-8bf536f331de4269.js} +1 -1
- package/web/.next/static/chunks/app/agents/[id]/page-a8b7a43d2c18483f.js +1 -0
- package/web/.next/static/chunks/app/connections/page-477f36fcd5b13d78.js +1 -0
- package/web/.next/static/chunks/app/layout-a2d38e3de5911b1b.js +1 -0
- package/web/.next/static/chunks/app/schedules/page-39befd15b3b20586.js +1 -0
- package/web/.next/static/css/da22f0402b1c40ae.css +1 -0
- package/web/.next/trace +23 -23
- package/web/.next/types/app/schedules/page.ts +84 -0
- package/web/.next/types/routes.d.ts +2 -1
- package/web/.next/types/validator.ts +9 -0
- package/web/.next/static/84BpRSahg2BTwk3mX6AG8/_buildManifest.js +0 -1
- package/web/.next/static/chunks/493-92266798be872b8f.js +0 -1
- package/web/.next/static/chunks/app/agents/[id]/page-5eaf094fec246f1b.js +0 -1
- package/web/.next/static/chunks/app/connections/page-c6afd90abd82d47a.js +0 -1
- package/web/.next/static/chunks/app/layout-c415d5a847cd94b3.js +0 -1
- package/web/.next/static/css/abe1a6216491f944.css +0 -1
- /package/web/.next/static/{84BpRSahg2BTwk3mX6AG8 → _z2hBOHhwiR5ytdI_8alh}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { DATA_DIR } from '../db.js';
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
6
|
+
/**
|
|
7
|
+
* One long-lived `opencode serve` per agent. A private server per turn (`opencode run --standalone`) boots from zero every time and its
|
|
8
|
+
* hive MCP server connects only after the model has already taken its first step, so the first `tools.hive.*` call always fails
|
|
9
|
+
* ("Unknown tool") and costs a whole extra model step. A server that stays up has hive connected before the message arrives, and
|
|
10
|
+
* the turn skips OpenCode's ~4 s boot too. The server reads the agent's config (permissions, MCP) once when it starts, so a change
|
|
11
|
+
* in that config restarts it; it stops by itself after a while without turns.
|
|
12
|
+
*/
|
|
13
|
+
const IDLE_MS = 5 * 60_000;
|
|
14
|
+
/** Servers kept alive at once (each takes roughly 150 MB idle and 300 MB after a turn). The least recently used one is stopped to make room; `HIVE_AM_OPENCODE_SERVERS` changes it. */
|
|
15
|
+
const MAX_SERVERS = Math.max(1, Number(process.env.HIVE_AM_OPENCODE_SERVERS) || 2);
|
|
16
|
+
const START_TIMEOUT_MS = 20_000;
|
|
17
|
+
const MCP_WAIT_MS = 10_000;
|
|
18
|
+
const servers = new Map();
|
|
19
|
+
export function stopOpencodeServer(agentId) {
|
|
20
|
+
const s = servers.get(agentId);
|
|
21
|
+
if (!s)
|
|
22
|
+
return;
|
|
23
|
+
servers.delete(agentId);
|
|
24
|
+
savePids();
|
|
25
|
+
if (s.idle)
|
|
26
|
+
clearTimeout(s.idle);
|
|
27
|
+
if (!s.proc.killed)
|
|
28
|
+
s.proc.kill('SIGTERM');
|
|
29
|
+
}
|
|
30
|
+
const stopAll = () => { for (const id of [...servers.keys()])
|
|
31
|
+
stopOpencodeServer(id); };
|
|
32
|
+
process.on('exit', stopAll);
|
|
33
|
+
// A signal (Ctrl+C, a `tsx watch` restart, a service manager) ends the process without an 'exit' event, which would leave the servers orphaned.
|
|
34
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'])
|
|
35
|
+
process.once(sig, () => { stopAll(); process.kill(process.pid, sig); });
|
|
36
|
+
// If hive-am dies without any chance to clean up (kill -9, a crash), its servers survive it. They are listed on disk and stopped at the next start.
|
|
37
|
+
const PIDFILE = join(DATA_DIR, 'opencode-servers.json');
|
|
38
|
+
const savePids = () => { try {
|
|
39
|
+
writeFileSync(PIDFILE, JSON.stringify([...servers.values()].map((x) => x.proc.pid).filter(Boolean)));
|
|
40
|
+
}
|
|
41
|
+
catch { /* best effort */ } };
|
|
42
|
+
function reapOrphans() {
|
|
43
|
+
if (!existsSync(PIDFILE))
|
|
44
|
+
return;
|
|
45
|
+
try {
|
|
46
|
+
for (const pid of JSON.parse(readFileSync(PIDFILE, 'utf8'))) {
|
|
47
|
+
try {
|
|
48
|
+
const cmd = execFileSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
49
|
+
if (/opencode/.test(cmd) && /serve --hostname 127\.0\.0\.1/.test(cmd))
|
|
50
|
+
process.kill(pid, 'SIGTERM'); // only what is surely ours
|
|
51
|
+
}
|
|
52
|
+
catch { /* already gone */ }
|
|
53
|
+
}
|
|
54
|
+
writeFileSync(PIDFILE, '[]');
|
|
55
|
+
}
|
|
56
|
+
catch { /* unreadable list: nothing to do */ }
|
|
57
|
+
}
|
|
58
|
+
reapOrphans();
|
|
59
|
+
const auth = (password) => ({ authorization: `Basic ${Buffer.from(`opencode:${password}`).toString('base64')}` });
|
|
60
|
+
/** Waits until every MCP server the config asks for has finished connecting (connected or failed): no more "Unknown tool" at the first step. */
|
|
61
|
+
async function mcpSettled(s, signal) {
|
|
62
|
+
const until = Date.now() + MCP_WAIT_MS;
|
|
63
|
+
while (Date.now() < until && !signal.aborted) {
|
|
64
|
+
try {
|
|
65
|
+
const r = await fetch(`${s.url}/api/mcp`, { headers: auth(s.password), signal: AbortSignal.timeout(2000) });
|
|
66
|
+
if (r.ok) {
|
|
67
|
+
const j = await r.json();
|
|
68
|
+
const list = Array.isArray(j?.data) ? j.data : [];
|
|
69
|
+
// Every server the config asks for must be listed (the list is empty until OpenCode starts connecting) and done connecting.
|
|
70
|
+
const done = (name) => { const m = list.find((x) => x?.name === name); return !!m?.status?.status && m.status.status !== 'connecting' && m.status.status !== 'pending'; };
|
|
71
|
+
if (s.mcp.every(done))
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch { /* not up yet */ }
|
|
76
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function launch(agentId, cwd, config, key, signal) {
|
|
80
|
+
const password = randomBytes(18).toString('base64url');
|
|
81
|
+
const proc = spawn('opencode', ['serve', '--hostname', '127.0.0.1', '--port', '0'], {
|
|
82
|
+
cwd, stdio: ['ignore', 'pipe', 'pipe'],
|
|
83
|
+
env: { ...process.env, PWD: cwd, OPENCODE_CONFIG_CONTENT: JSON.stringify(config), OPENCODE_SERVER_PASSWORD: password },
|
|
84
|
+
});
|
|
85
|
+
const srv = { busy: 0, used: Date.now(), mcp: Object.keys(config?.mcp ?? {}), proc, url: '', password, key, ready: Promise.resolve() };
|
|
86
|
+
srv.ready = new Promise((resolve, reject) => {
|
|
87
|
+
let out = '';
|
|
88
|
+
const timer = setTimeout(() => reject(new Error('opencode serve did not start in time')), START_TIMEOUT_MS);
|
|
89
|
+
const onData = (d) => {
|
|
90
|
+
out = (out + d).slice(-2000);
|
|
91
|
+
const m = /listening on (http:\/\/\S+)/i.exec(out);
|
|
92
|
+
if (m && !srv.url) {
|
|
93
|
+
srv.url = m[1].replace(/\/$/, '');
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
resolve();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
proc.stdout.on('data', onData);
|
|
99
|
+
proc.stderr.on('data', onData); // keep draining so the pipes never fill up
|
|
100
|
+
proc.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
101
|
+
proc.on('exit', (c) => { clearTimeout(timer); if (!srv.url)
|
|
102
|
+
reject(new Error(`opencode serve exited with code ${c}`)); if (servers.get(agentId) === srv)
|
|
103
|
+
servers.delete(agentId); });
|
|
104
|
+
}).then(() => mcpSettled(srv, signal));
|
|
105
|
+
servers.set(agentId, srv);
|
|
106
|
+
savePids();
|
|
107
|
+
return srv;
|
|
108
|
+
}
|
|
109
|
+
/** The agent's server, started (or restarted when its config changed) and ready for a turn. Throws if it cannot start: callers fall back to a private server. */
|
|
110
|
+
export async function opencodeServer(agentId, cwd, config, signal) {
|
|
111
|
+
const key = createHash('sha1').update(cwd + JSON.stringify(config)).digest('hex');
|
|
112
|
+
let s = servers.get(agentId);
|
|
113
|
+
if (s && (s.key !== key || s.proc.exitCode !== null)) {
|
|
114
|
+
stopOpencodeServer(agentId);
|
|
115
|
+
s = undefined;
|
|
116
|
+
}
|
|
117
|
+
if (!s) {
|
|
118
|
+
// Room for one more: stop the least recently used server that is not in the middle of a turn.
|
|
119
|
+
while (servers.size >= MAX_SERVERS) {
|
|
120
|
+
const idle = [...servers.entries()].filter(([, x]) => x.busy === 0).sort((a, b) => a[1].used - b[1].used)[0];
|
|
121
|
+
if (!idle)
|
|
122
|
+
break;
|
|
123
|
+
stopOpencodeServer(idle[0]);
|
|
124
|
+
}
|
|
125
|
+
s = launch(agentId, cwd, config, key, signal);
|
|
126
|
+
}
|
|
127
|
+
s.busy++;
|
|
128
|
+
s.used = Date.now();
|
|
129
|
+
if (s.idle) {
|
|
130
|
+
clearTimeout(s.idle);
|
|
131
|
+
s.idle = undefined;
|
|
132
|
+
}
|
|
133
|
+
const srv = s;
|
|
134
|
+
let released = false;
|
|
135
|
+
const release = () => {
|
|
136
|
+
if (released)
|
|
137
|
+
return;
|
|
138
|
+
released = true;
|
|
139
|
+
srv.busy = Math.max(0, srv.busy - 1);
|
|
140
|
+
srv.used = Date.now();
|
|
141
|
+
// After a burst (more agents running at once than the limit), the extra servers go as soon as their turn ends instead of lingering.
|
|
142
|
+
while (servers.size > MAX_SERVERS) {
|
|
143
|
+
const lru = [...servers.entries()].filter(([, x]) => x.busy === 0).sort((x, y) => x[1].used - y[1].used)[0];
|
|
144
|
+
if (!lru)
|
|
145
|
+
break;
|
|
146
|
+
stopOpencodeServer(lru[0]);
|
|
147
|
+
}
|
|
148
|
+
if (srv.busy === 0 && servers.get(agentId) === srv) {
|
|
149
|
+
srv.idle = setTimeout(() => { if (servers.get(agentId) === srv && srv.busy === 0)
|
|
150
|
+
stopOpencodeServer(agentId); }, IDLE_MS);
|
|
151
|
+
srv.idle.unref?.();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
try {
|
|
155
|
+
await s.ready;
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
release();
|
|
159
|
+
if (servers.get(agentId) === s)
|
|
160
|
+
stopOpencodeServer(agentId);
|
|
161
|
+
throw e;
|
|
162
|
+
}
|
|
163
|
+
return { url: s.url, password: s.password, release };
|
|
164
|
+
}
|
|
@@ -5,6 +5,7 @@ import { spawnLines, safeJson } 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';
|
|
8
|
+
import { opencodeServer, stopOpencodeServer } from './opencode-server.js';
|
|
8
9
|
/**
|
|
9
10
|
* What OpenCode may do for each permission level. It has no such modes, so they are written as its own rules (a `deny` holds even with `--auto`):
|
|
10
11
|
* read-only changes nothing; editing changes files of the project (including .env, never .git or keys), runs no commands and stays in the agent's folder
|
|
@@ -29,60 +30,85 @@ export function opencodePermissions(level) {
|
|
|
29
30
|
export async function* runOpencode(o) {
|
|
30
31
|
const { agent } = o;
|
|
31
32
|
const args = ['run', '--format', 'json', '--thinking', '--auto'];
|
|
32
|
-
// `opencode run` normally talks to OpenCode's background service, which ignores the config we pass through the
|
|
33
|
-
// environment. A private (standalone) server reads it, which we need for the `dispatch` MCP and to deny `question`.
|
|
34
|
-
args.splice(1, 0, '--standalone');
|
|
35
33
|
// Sessions created while the folder was wrong (older hive-am builds) would keep running there; start fresh instead.
|
|
36
34
|
const recorded = agent.session_id ? opencodeSessionDir(agent.session_id) : null;
|
|
37
35
|
const resume = agent.session_id && (!recorded || recorded === agent.cwd) ? agent.session_id : null;
|
|
38
36
|
if (resume)
|
|
39
37
|
args.push('-s', resume);
|
|
38
|
+
// The reasoning level goes as the model's `#variant` (low / medium / high).
|
|
40
39
|
if (agent.model)
|
|
41
|
-
args.push('-m', agent.model);
|
|
40
|
+
args.push('-m', o.effort && !agent.model.includes('#') ? `${agent.model}#${o.effort}` : agent.model);
|
|
42
41
|
const prompt = withInstructions(o.prompt, o.instructions, !!resume, o.refreshInstructions);
|
|
43
|
-
args.push(prompt);
|
|
44
42
|
// Turns are non-interactive: OpenCode's `question` tool would be dismissed and end the turn with exit code 1, so
|
|
45
43
|
// deny it and the agent asks in plain text instead.
|
|
46
44
|
const config = { permission: { question: 'deny', ...opencodePermissions(agent.permission) }, ...(o.mcpCaps.length ? dispatchMcpConfigOpencode(agent.id, o.mcpCaps) : {}) };
|
|
47
45
|
const env = { OPENCODE_CONFIG_CONTENT: JSON.stringify(config) };
|
|
46
|
+
// `opencode run` normally talks to OpenCode's shared background service, which ignores the config we pass through the environment.
|
|
47
|
+
// We need that config (the `hive` MCP, `question` denied, the permission rules), so the turn goes to a server of this agent that
|
|
48
|
+
// started with it and keeps hive connected between turns. If that server cannot start, a private one-off server is used instead.
|
|
49
|
+
let access = null;
|
|
50
|
+
// Only agents answering an external chat gain enough from it (somebody is waiting for a reply and the first `channel_reply` would fail); every other agent keeps the one-off server and costs nothing between turns.
|
|
51
|
+
if (o.mcpCaps.includes('channel') && !process.env.HIVE_AM_OPENCODE_STANDALONE) {
|
|
52
|
+
try {
|
|
53
|
+
access = await opencodeServer(agent.id, agent.cwd, config, o.signal);
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
console.warn(`[opencode] no persistent server for ${agent.name}, using a private one: ${e instanceof Error ? e.message : e}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (access) {
|
|
60
|
+
args.splice(1, 0, '--server', access.url);
|
|
61
|
+
env.OPENCODE_SERVER_PASSWORD = access.password;
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
args.splice(1, 0, '--standalone');
|
|
65
|
+
args.push(prompt);
|
|
66
|
+
// 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.
|
|
67
|
+
if (access)
|
|
68
|
+
o.signal.addEventListener('abort', () => stopOpencodeServer(agent.id), { once: true });
|
|
48
69
|
const seen = new Map();
|
|
49
70
|
const toolStarted = new Set();
|
|
50
71
|
let sawSession = false;
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
sawSession
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const p = ev.part;
|
|
60
|
-
if (!p)
|
|
61
|
-
continue;
|
|
62
|
-
if (p.type === 'text' || p.type === 'reasoning') {
|
|
63
|
-
const text = p.text ?? '';
|
|
64
|
-
const prev = seen.get(p.id) ?? 0;
|
|
65
|
-
if (text.length > prev) {
|
|
66
|
-
seen.set(p.id, text.length);
|
|
67
|
-
yield { t: p.type === 'text' ? 'text' : 'thinking', delta: text.slice(prev) };
|
|
72
|
+
try {
|
|
73
|
+
for await (const line of spawnLines({ cmd: 'opencode', args, cwd: agent.cwd, env, signal: o.signal })) {
|
|
74
|
+
const ev = safeJson(line);
|
|
75
|
+
if (!ev)
|
|
76
|
+
continue;
|
|
77
|
+
if (ev.sessionID && !sawSession) {
|
|
78
|
+
sawSession = true;
|
|
79
|
+
yield { t: 'session', sessionId: ev.sessionID };
|
|
68
80
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
yield { t: 'tool', id: p.callID ?? p.id, name: p.tool ?? p.name ?? 'tool', input: st.input };
|
|
81
|
+
const p = ev.part;
|
|
82
|
+
if (!p)
|
|
83
|
+
continue;
|
|
84
|
+
if (p.type === 'text' || p.type === 'reasoning') {
|
|
85
|
+
const text = p.text ?? '';
|
|
86
|
+
const prev = seen.get(p.id) ?? 0;
|
|
87
|
+
if (text.length > prev) {
|
|
88
|
+
seen.set(p.id, text.length);
|
|
89
|
+
yield { t: p.type === 'text' ? 'text' : 'thinking', delta: text.slice(prev) };
|
|
90
|
+
}
|
|
80
91
|
}
|
|
81
|
-
if (
|
|
82
|
-
const
|
|
83
|
-
|
|
92
|
+
else if (p.type === 'step-finish') {
|
|
93
|
+
const tk = p.tokens;
|
|
94
|
+
if (tk)
|
|
95
|
+
yield { t: 'usage', cost: p.cost || undefined, usage: { input: tk.input, output: tk.output, reasoning: tk.reasoning, cacheRead: tk.cache?.read, cacheWrite: tk.cache?.write } };
|
|
96
|
+
}
|
|
97
|
+
else if (p.type === 'tool') {
|
|
98
|
+
const st = p.state ?? {};
|
|
99
|
+
if (!toolStarted.has(p.callID ?? p.id)) {
|
|
100
|
+
toolStarted.add(p.callID ?? p.id);
|
|
101
|
+
yield { t: 'tool', id: p.callID ?? p.id, name: p.tool ?? p.name ?? 'tool', input: st.input };
|
|
102
|
+
}
|
|
103
|
+
if (st.status === 'completed' || st.status === 'error') {
|
|
104
|
+
const out = typeof st.output === 'string' ? st.output : Array.isArray(st.content) ? st.content.map((c) => c.text ?? '').join('\n') : '';
|
|
105
|
+
yield { t: 'tool_result', id: p.callID ?? p.id, output: out, error: st.status === 'error' };
|
|
106
|
+
}
|
|
84
107
|
}
|
|
85
108
|
}
|
|
86
109
|
}
|
|
110
|
+
finally {
|
|
111
|
+
access?.release();
|
|
112
|
+
}
|
|
87
113
|
yield { t: 'done', ok: true };
|
|
88
114
|
}
|
package/server/dist/runtime.js
CHANGED
|
@@ -19,6 +19,7 @@ export const liveTurn = (id) => states.get(id)?.live ?? null;
|
|
|
19
19
|
export const queueDepth = (id) => states.get(id)?.queued ?? 0;
|
|
20
20
|
/** The channel message the agent is answering right now, if the running turn came from one. */
|
|
21
21
|
export const liveOrigin = (id) => states.get(id)?.live?.origin;
|
|
22
|
+
export const liveSource = (id) => states.get(id)?.live?.source;
|
|
22
23
|
/** Whether the turn running now is the agent's own conversation (not work delegated to it by an orchestrator). */
|
|
23
24
|
export const liveIsDirect = (id) => states.get(id)?.live?.source === 'user';
|
|
24
25
|
/** Queue a prompt for an agent. Turns for one agent run strictly one at a time. */
|
|
@@ -71,7 +72,7 @@ async function execute(agentId, prompt, source, from, dispatchId, origin) {
|
|
|
71
72
|
const stream = runners[agent.provider]({
|
|
72
73
|
agent, prompt, signal: controller.signal,
|
|
73
74
|
instructions, refreshInstructions,
|
|
74
|
-
mcpCaps: mcpCaps(agent),
|
|
75
|
+
mcpCaps: mcpCaps(agent), effort: origin?.effort,
|
|
75
76
|
});
|
|
76
77
|
for await (const ev of stream) {
|
|
77
78
|
if (ev.t === 'session') {
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { agents, db } from './db.js';
|
|
3
|
+
import { CronError, minGapMinutes, nextCron, nextRuns, parseCron, serverTimezone, validTimezone } from './cron.js';
|
|
4
|
+
import { adapterFor } from './connections/manager.js';
|
|
5
|
+
import { deliverScheduled } from './connections/router.js';
|
|
6
|
+
import { connections, threads } from './connections/store.js';
|
|
7
|
+
import { liveOrigin, liveSource, queueDepth, sendTurn } from './runtime.js';
|
|
8
|
+
/**
|
|
9
|
+
* 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
|
+
* 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
|
+
* 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
|
+
export class ScheduleError extends Error {
|
|
14
|
+
}
|
|
15
|
+
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
|
+
export const MIN_INTERVAL_MINUTES = Number(process.env.HIVE_AM_SCHEDULE_MIN_MINUTES) || 15;
|
|
17
|
+
export const MAX_PER_AGENT = 10;
|
|
18
|
+
const MISSED_GRACE_MS = 5 * 60_000; // a run later than this (the server was down) is skipped
|
|
19
|
+
const BUSY_QUEUE = 2; // skip a run when this many messages already wait for the agent
|
|
20
|
+
const KEEP_RUNS = 50;
|
|
21
|
+
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
|
+
const fromRow = (r) => ({ ...r, enabled: !!r.enabled });
|
|
43
|
+
const store = {
|
|
44
|
+
get: (id) => { const r = db.prepare('SELECT * FROM agent_schedules WHERE id=?').get(id); return r ? fromRow(r) : undefined; },
|
|
45
|
+
all: () => db.prepare('SELECT * FROM agent_schedules ORDER BY created_at').all().map(fromRow),
|
|
46
|
+
forAgent: (agentId) => db.prepare('SELECT * FROM agent_schedules WHERE agent_id=? ORDER BY created_at').all(agentId).map(fromRow),
|
|
47
|
+
remove: (id) => db.prepare('DELETE FROM agent_schedules WHERE id=?').run(id).changes > 0,
|
|
48
|
+
setNext: (id, next) => db.prepare('UPDATE agent_schedules SET next_due=? WHERE id=?').run(next, id),
|
|
49
|
+
setEnabled: (id, on, next) => db.prepare('UPDATE agent_schedules SET enabled=?, next_due=? WHERE id=?').run(on ? 1 : 0, next, id),
|
|
50
|
+
record(id, status, detail, durationMs, firedAt) {
|
|
51
|
+
db.prepare('INSERT INTO schedule_runs (schedule_id,fired_at,status,detail,duration_ms) VALUES (?,?,?,?,?)').run(id, firedAt, status, detail, durationMs);
|
|
52
|
+
db.prepare(`DELETE FROM schedule_runs WHERE schedule_id=? AND id NOT IN (SELECT id FROM schedule_runs WHERE schedule_id=? ORDER BY id DESC LIMIT ${KEEP_RUNS})`).run(id, id);
|
|
53
|
+
db.prepare('UPDATE agent_schedules SET last_status=?, last_detail=?, last_fired_at=?, fire_count=fire_count+? WHERE id=?').run(status, detail, firedAt, status === 'delivered' ? 1 : 0, id);
|
|
54
|
+
},
|
|
55
|
+
runs: (id, limit = KEEP_RUNS) => db.prepare('SELECT id,fired_at,status,detail,duration_ms FROM schedule_runs WHERE schedule_id=? ORDER BY id DESC LIMIT ?').all(id, limit),
|
|
56
|
+
};
|
|
57
|
+
const hhmm = (t, tz) => new Date(t).toLocaleString('en-GB', { timeZone: tz, weekday: 'short', day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
58
|
+
export const describe = (s) => s.kind === 'every' ? `every ${s.expr} min` : `${s.expr} (${s.tz})`;
|
|
59
|
+
/** Next run after `after`. "every N" keeps its rhythm from the previous due time instead of drifting by the run time. */
|
|
60
|
+
function computeNext(s, after, from) {
|
|
61
|
+
if (s.kind === 'cron')
|
|
62
|
+
return nextCron(s.expr, s.tz, after);
|
|
63
|
+
const step = Number(s.expr) * MS_PER_MINUTE;
|
|
64
|
+
let t = (from ?? after) + step;
|
|
65
|
+
if (t <= after)
|
|
66
|
+
t += Math.ceil((after - t + 1) / step) * step;
|
|
67
|
+
return t;
|
|
68
|
+
}
|
|
69
|
+
// ---- timers ----
|
|
70
|
+
const timers = new Map();
|
|
71
|
+
function arm(s) {
|
|
72
|
+
const old = timers.get(s.id);
|
|
73
|
+
if (old)
|
|
74
|
+
clearTimeout(old);
|
|
75
|
+
timers.delete(s.id);
|
|
76
|
+
if (!s.enabled || s.next_due === null)
|
|
77
|
+
return;
|
|
78
|
+
const delay = Math.max(0, Math.min(s.next_due - Date.now(), MAX_TIMER_MS));
|
|
79
|
+
const t = setTimeout(() => { if (s.next_due !== null && s.next_due - Date.now() > 1000)
|
|
80
|
+
arm(store.get(s.id) ?? s);
|
|
81
|
+
else
|
|
82
|
+
void fire(s.id); }, delay);
|
|
83
|
+
t.unref?.();
|
|
84
|
+
timers.set(s.id, t);
|
|
85
|
+
}
|
|
86
|
+
const disarm = (id) => { const t = timers.get(id); if (t)
|
|
87
|
+
clearTimeout(t); timers.delete(id); };
|
|
88
|
+
function text(s, web) {
|
|
89
|
+
const tool = web ? '' : ' with channel_reply';
|
|
90
|
+
return `${web ? '🔔 ' : ''}Scheduled task (${describe(s)}${s.req_name ? `, asked by ${s.req_name}` : ''}). Your note: ${s.note}\n\n${s.req_name ? `hive-am mentions ${s.req_name} at the start of your first reply, so do not mention them yourself. ` : ''}Do what the note says now and tell the person the result${tool}. If there is nothing worth reporting, say so in one line. You can see or stop your schedules with schedule_list / schedule_cancel.`;
|
|
91
|
+
}
|
|
92
|
+
async function fire(id) {
|
|
93
|
+
timers.delete(id);
|
|
94
|
+
const s = store.get(id);
|
|
95
|
+
if (!s || !s.enabled)
|
|
96
|
+
return;
|
|
97
|
+
const now = Date.now(), due = s.next_due ?? now;
|
|
98
|
+
const next = computeNext(s, now, s.kind === 'every' ? due : null);
|
|
99
|
+
store.setNext(id, next); // the next run is fixed before anything else can fail
|
|
100
|
+
arm({ ...s, next_due: next });
|
|
101
|
+
const skip = (status, detail) => store.record(id, status, detail, null, now);
|
|
102
|
+
const agent = agents.get(s.agent_id);
|
|
103
|
+
if (!agent) {
|
|
104
|
+
store.remove(id);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (now - due > MISSED_GRACE_MS)
|
|
108
|
+
return skip('skipped_missed', `due ${hhmm(due, s.tz)}, ${Math.round((now - due) / 60000)} min late (the server was down)`);
|
|
109
|
+
if (queueDepth(agent.id) >= BUSY_QUEUE)
|
|
110
|
+
return skip('skipped_busy', 'the agent already had messages waiting');
|
|
111
|
+
const started = Date.now();
|
|
112
|
+
try {
|
|
113
|
+
if (s.thread_id) {
|
|
114
|
+
const conn = connections.get(s.connection_id ?? ''), thread = threads.get(s.thread_id);
|
|
115
|
+
if (!conn || !thread) {
|
|
116
|
+
store.remove(id);
|
|
117
|
+
disarm(id);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!conn.enabled)
|
|
121
|
+
return skip('skipped_disabled', 'the connection is disabled');
|
|
122
|
+
if (conn.agent_id !== s.agent_id) {
|
|
123
|
+
store.setEnabled(id, false, null);
|
|
124
|
+
disarm(id);
|
|
125
|
+
return skip('skipped_reassigned', 'the connection now belongs to another agent; the schedule was paused');
|
|
126
|
+
}
|
|
127
|
+
if (thread.muted)
|
|
128
|
+
return skip('skipped_muted', 'the thread is muted');
|
|
129
|
+
const adapter = adapterFor(conn.id);
|
|
130
|
+
if (!adapter)
|
|
131
|
+
return skip('skipped_disabled', 'the connection was not running');
|
|
132
|
+
store.record(id, 'delivered', null, null, started);
|
|
133
|
+
await deliverScheduled(conn, adapter, thread, text(s, false), s.req_id ? { id: s.req_id, name: s.req_name || 'you' } : undefined);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
store.record(id, 'delivered', null, null, started);
|
|
137
|
+
await sendTurn(agent.id, text(s, true), 'user');
|
|
138
|
+
}
|
|
139
|
+
db.prepare('UPDATE schedule_runs SET duration_ms=? WHERE schedule_id=? AND fired_at=? AND status=?').run(Date.now() - started, id, started, 'delivered');
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
143
|
+
db.prepare('UPDATE schedule_runs SET status=?, detail=? WHERE schedule_id=? AND fired_at=? AND status=?').run('failed', msg, id, started, 'delivered');
|
|
144
|
+
db.prepare('UPDATE agent_schedules SET last_status=?, last_detail=?, fire_count=MAX(0,fire_count-1) WHERE id=?').run('failed', msg, id);
|
|
145
|
+
console.warn(`[schedules] run of "${s.note.slice(0, 40)}" failed: ${msg}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Arms every saved schedule (called once at startup). A run missed while the server was down is skipped, not replayed. */
|
|
149
|
+
export function armSchedules() {
|
|
150
|
+
for (const s of store.all()) {
|
|
151
|
+
if (!s.enabled)
|
|
152
|
+
continue;
|
|
153
|
+
if (s.next_due !== null && s.next_due < Date.now())
|
|
154
|
+
void fire(s.id);
|
|
155
|
+
else
|
|
156
|
+
arm(s);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
export function createSchedule(agentId, input) {
|
|
160
|
+
if (!agents.get(agentId))
|
|
161
|
+
throw new ScheduleError('Unknown agent');
|
|
162
|
+
if (liveSource(agentId) === 'dispatch')
|
|
163
|
+
throw new ScheduleError('Schedules are not available while you work on a task delegated by an orchestrator.');
|
|
164
|
+
const note = String(input.note ?? '').trim();
|
|
165
|
+
if (!note)
|
|
166
|
+
throw new ScheduleError('Say in the note what to do at each run.');
|
|
167
|
+
if (note.length > 400)
|
|
168
|
+
throw new ScheduleError('The note is too long (400 characters at most).');
|
|
169
|
+
const tz = input.timezone === undefined || input.timezone === '' ? serverTimezone() : String(input.timezone);
|
|
170
|
+
if (!validTimezone(tz))
|
|
171
|
+
throw new ScheduleError(`Unknown time zone "${tz}". Use an IANA name such as America/Mexico_City.`);
|
|
172
|
+
const hasCron = input.cron !== undefined && String(input.cron).trim() !== '', hasEvery = input.every_minutes !== undefined && input.every_minutes !== null;
|
|
173
|
+
if (hasCron === hasEvery)
|
|
174
|
+
throw new ScheduleError('Give either "cron" (for example "0 9 * * MON-FRI") or "every_minutes", not both and not neither.');
|
|
175
|
+
let kind, expr, first;
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
if (hasCron) {
|
|
178
|
+
kind = 'cron';
|
|
179
|
+
expr = String(input.cron).trim();
|
|
180
|
+
try {
|
|
181
|
+
parseCron(expr);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
throw new ScheduleError(e instanceof CronError ? e.message : String(e));
|
|
185
|
+
}
|
|
186
|
+
const gap = minGapMinutes(expr, tz, now);
|
|
187
|
+
if (gap < MIN_INTERVAL_MINUTES)
|
|
188
|
+
throw new ScheduleError(`Runs would be only ${gap} min apart; the minimum is ${MIN_INTERVAL_MINUTES} min. Choose a less frequent schedule.`);
|
|
189
|
+
first = nextCron(expr, tz, now);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
kind = 'every';
|
|
193
|
+
const m = Number(input.every_minutes);
|
|
194
|
+
if (!Number.isFinite(m) || m < MIN_INTERVAL_MINUTES || m > 60 * 24 * 31)
|
|
195
|
+
throw new ScheduleError(`every_minutes must be between ${MIN_INTERVAL_MINUTES} and ${60 * 24 * 31}.`);
|
|
196
|
+
expr = String(Math.round(m));
|
|
197
|
+
first = now + Math.round(m) * MS_PER_MINUTE;
|
|
198
|
+
}
|
|
199
|
+
if (first === null)
|
|
200
|
+
throw new ScheduleError('That schedule never runs.');
|
|
201
|
+
const origin = liveOrigin(agentId), thread = origin ? threads.get(origin.threadId) : undefined;
|
|
202
|
+
if (origin && !thread)
|
|
203
|
+
throw new ScheduleError('The thread no longer exists.');
|
|
204
|
+
const mine = store.forAgent(agentId);
|
|
205
|
+
if (mine.length >= MAX_PER_AGENT)
|
|
206
|
+
throw new ScheduleError(`You already have ${MAX_PER_AGENT} schedules. Cancel one with schedule_cancel first.`);
|
|
207
|
+
const dup = mine.find((x) => x.kind === kind && x.expr === expr && x.tz === tz && x.note.toLowerCase() === note.toLowerCase() && x.thread_id === (thread?.id ?? null));
|
|
208
|
+
if (dup)
|
|
209
|
+
return { schedule: dup, next: kind === 'cron' ? nextRuns(expr, tz, now, 3).map((t) => hhmm(t, tz)) : [hhmm(dup.next_due ?? first, tz)], where: thread ? 'thread' : 'chat' };
|
|
210
|
+
const id = randomUUID();
|
|
211
|
+
db.prepare('INSERT INTO agent_schedules (id,agent_id,connection_id,thread_id,kind,expr,tz,note,enabled,next_due,created_at,req_id,req_name) VALUES (?,?,?,?,?,?,?,?,1,?,?,?,?)')
|
|
212
|
+
.run(id, agentId, origin?.connectionId ?? null, thread?.id ?? null, kind, expr, tz, note, first, now, origin?.group && origin.userId ? origin.userId : null, origin?.group && origin.userId ? origin.userName : null);
|
|
213
|
+
const schedule = store.get(id);
|
|
214
|
+
arm(schedule);
|
|
215
|
+
return { schedule, next: kind === 'cron' ? nextRuns(expr, tz, now, 3).map((t) => hhmm(t, tz)) : [hhmm(first, tz)], where: thread ? 'thread' : 'chat' };
|
|
216
|
+
}
|
|
217
|
+
export function listFor(agentId) {
|
|
218
|
+
return store.forAgent(agentId).map((s) => ({ id: s.id, schedule: describe(s), note: s.note, enabled: s.enabled, next: s.next_due ? hhmm(s.next_due, s.tz) : null, last: s.last_status }));
|
|
219
|
+
}
|
|
220
|
+
export function cancelFor(agentId, id) {
|
|
221
|
+
const s = store.get(id);
|
|
222
|
+
if (!s || s.agent_id !== agentId)
|
|
223
|
+
throw new ScheduleError('You have no schedule with that id. Use schedule_list to see yours.');
|
|
224
|
+
disarm(id);
|
|
225
|
+
return store.remove(id);
|
|
226
|
+
}
|
|
227
|
+
export function listAll() {
|
|
228
|
+
return store.all().map((s) => ({ ...s, agent_name: agents.get(s.agent_id)?.name ?? '?', place: s.thread_id ? (threads.get(s.thread_id)?.title || 'chat') : '', description: describe(s) }));
|
|
229
|
+
}
|
|
230
|
+
export const runsOf = (id) => store.runs(id);
|
|
231
|
+
export function setEnabled(id, on) {
|
|
232
|
+
const s = store.get(id);
|
|
233
|
+
if (!s)
|
|
234
|
+
return undefined;
|
|
235
|
+
const next = on ? computeNext(s, Date.now(), null) : null;
|
|
236
|
+
store.setEnabled(id, on, next);
|
|
237
|
+
const u = store.get(id);
|
|
238
|
+
arm(u);
|
|
239
|
+
return u;
|
|
240
|
+
}
|
|
241
|
+
export function removeSchedule(id) { disarm(id); return store.remove(id); }
|