@agentpulselabs/cli-plugin-pulse 1.0.0 → 1.1.3
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/README.md +55 -0
- package/package.json +7 -4
- package/src/clients/hub-bridge.mjs +99 -0
- package/src/clients/hub.mjs +324 -0
- package/src/commands/pulse/cutover.js +63 -0
- package/src/commands/pulse/doctor.js +1 -0
- package/src/commands/pulse/import.js +62 -0
- package/src/commands/pulse/log-write.js +26 -0
- package/src/commands/pulse/parity.js +20 -0
- package/src/commands/pulse/shadow.js +19 -0
- package/src/daemon/main.js +20 -12
- package/src/hub/core/auth.mjs +80 -0
- package/src/hub/core/changes.mjs +38 -0
- package/src/hub/core/conversations.mjs +121 -0
- package/src/hub/core/db.mjs +263 -0
- package/src/hub/core/edgeIds.mjs +9 -0
- package/src/hub/core/hub.mjs +20 -0
- package/src/hub/core/ids.mjs +39 -0
- package/src/hub/core/leases.mjs +55 -0
- package/src/hub/core/messaging.mjs +322 -0
- package/src/hub/core/registry.mjs +281 -0
- package/src/hub/core/ticks.mjs +58 -0
- package/src/hub/core/wake.mjs +125 -0
- package/src/hub/home.mjs +16 -0
- package/src/hub/hooks/hub-bridge.mjs +167 -0
- package/src/hub/keepawake/agent.mjs +96 -0
- package/src/hub/lib/brief.mjs +133 -0
- package/src/hub/lib/channels.mjs +268 -0
- package/src/hub/lib/chat.mjs +163 -0
- package/src/hub/lib/closure.mjs +92 -0
- package/src/hub/lib/collect-worker.mjs +20 -0
- package/src/hub/lib/collect.mjs +342 -0
- package/src/hub/lib/deliveries.mjs +222 -0
- package/src/hub/lib/git.mjs +93 -0
- package/src/hub/lib/me.mjs +123 -0
- package/src/hub/lib/personas.mjs +143 -0
- package/src/hub/lib/status.mjs +147 -0
- package/src/hub/lib/store.mjs +106 -0
- package/src/hub/lib/titles.mjs +29 -0
- package/src/hub/lib/transcript-feed.mjs +72 -0
- package/src/hub/lib/transcripts.mjs +364 -0
- package/src/hub/lib/writing.mjs +18 -0
- package/src/hub/projections/deliveries.mjs +31 -0
- package/src/hub/projections/graph.mjs +149 -0
- package/src/hub/runners/drivers.mjs +162 -0
- package/src/hub/workers/collector.mjs +128 -0
- package/src/hub/workers/manager.mjs +366 -0
- package/src/hub/workers/runner.mjs +145 -0
- package/src/lib/application.js +82 -34
- package/src/lib/channelScanner.js +99 -0
- package/src/lib/clientInstaller.js +111 -0
- package/src/lib/cloudFirstCommands.js +293 -0
- package/src/lib/contract.sha256 +1 -1
- package/src/lib/cutover.js +411 -0
- package/src/lib/cutoverRequest.js +485 -0
- package/src/lib/cutoverWiring.js +140 -0
- package/src/lib/daemonLock.js +2 -0
- package/src/lib/daemonLoop.js +5 -2
- package/src/lib/daemonProcess.js +57 -6
- package/src/lib/daemonRuntime.js +8 -0
- package/src/lib/doctorWarnings.js +53 -0
- package/src/lib/errors.js +14 -0
- package/src/lib/hubDbReader.js +132 -0
- package/src/lib/hubMapping.js +108 -0
- package/src/lib/hubScan.js +611 -0
- package/src/lib/hubSnapshot.js +64 -0
- package/src/lib/mirrorContract.js +1 -1
- package/src/lib/mirrorService.js +8 -1
- package/src/lib/mirrorSource.js +13 -38
- package/src/lib/nodeFloor.js +19 -0
- package/src/lib/outboxFlusher.js +75 -0
- package/src/lib/pairingLifecycle.js +1 -1
- package/src/lib/reconcile.js +316 -0
- package/src/lib/reconcileView.js +129 -0
- package/src/lib/runnerStore.js +418 -0
- package/src/lib/serviceManager.js +64 -1
- package/src/lib/shadowService.js +435 -0
- package/src/lib/sqlite.js +28 -0
- package/src/lib/tokenStore.js +43 -4
- package/src/lib/transport.js +53 -5
- package/src/lib/transportContract.js +3 -0
- package/src/lib/uploadRedactor.js +102 -0
- package/src/runner/applier.js +297 -0
- package/src/runner/bridge.js +289 -0
- package/src/runner/busIdentity.js +499 -0
- package/src/runner/capture.js +103 -0
- package/src/runner/captureMapping.js +21 -0
- package/src/runner/channelDiscipline.js +425 -0
- package/src/runner/deliveryStates.js +220 -0
- package/src/runner/facade.js +876 -0
- package/src/runner/hubCapture.js +160 -0
- package/src/runner/lease.js +46 -0
- package/src/runner/main.js +35 -0
- package/src/runner/render.js +91 -0
- package/src/runner/runnerChild.js +57 -0
- package/src/runner/runnerHub.js +104 -0
- package/src/runner/runnerProcess.js +124 -0
- package/src/runner/runnerSchema.js +51 -0
- package/src/runner/runnerService.js +149 -0
- package/src/runner/spool.js +86 -0
- package/src/runner/spoolInto.js +22 -0
- package/src/runner/statusPage.js +30 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// agents-hub core/wake.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Waking a stopped subagent so it can read your message. Nothing outside Cursor can start a turn in a chat; a finished
|
|
3
|
+
// subagent can be resumed only by the session that started it (Task tool, by id). So the hub asks that parent, once,
|
|
4
|
+
// and again after WAKE_AGAIN_MS if your message is still waiting.
|
|
5
|
+
import { ADDRESS, managerChats } from './ids.mjs';
|
|
6
|
+
import { edgeId } from './edgeIds.mjs';
|
|
7
|
+
|
|
8
|
+
export const WAKE_AGAIN_MS = 15 * 60e3;
|
|
9
|
+
/** No hook event for this long: the subagent is not running (long commands can be silent for minutes). */
|
|
10
|
+
export const STOPPED_MS = 5 * 60e3;
|
|
11
|
+
|
|
12
|
+
const wakeText = (name, id) => `The user sent a direct message to your subagent ${name || id} (${id}), which looks stopped. `
|
|
13
|
+
+ `Resume it now with the Task tool (resume: "${id}", run_in_background: true). Its message is already queued in its own bus inbox; `
|
|
14
|
+
+ 'tell it to answer that message and then carry on if work remains. If it is in fact still running, do nothing. Do not answer in its place.';
|
|
15
|
+
|
|
16
|
+
/** Ask a subagent's parent to resume it. Returns the wake record, or null when it has no parent the hub can reach. */
|
|
17
|
+
export function requestWake(hub, childId, { name = null, now = Date.now() } = {}) {
|
|
18
|
+
const { registry, messaging } = hub;
|
|
19
|
+
const child = registry.get(childId);
|
|
20
|
+
if (!child || !child.parent_id || !ADDRESS.test(child.parent_id) || child.parent_id === childId) return null;
|
|
21
|
+
if (registry.fact('run', childId)) return null;
|
|
22
|
+
const prior = registry.fact('wake', childId);
|
|
23
|
+
if (prior && now - prior.at < WAKE_AGAIN_MS) return prior;
|
|
24
|
+
const env = messaging.publish({
|
|
25
|
+
id: edgeId('w', childId, Math.floor(now / WAKE_AGAIN_MS)), from: 'manager', fromName: 'Manager', topic: `agent/${child.parent_id}`, type: 'WAKE_CHILD', needsAck: true,
|
|
26
|
+
body: wakeText(name || child.persona_name || child.role, childId),
|
|
27
|
+
}, registry.directory());
|
|
28
|
+
if (env.error || !env.to.length) return null;
|
|
29
|
+
const record = { at: now, parent: child.parent_id, messageId: env.id };
|
|
30
|
+
registry.setFact('wake', childId, record);
|
|
31
|
+
return record;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The scheduled nudge reaches agents heard from this recently, at most NUDGE_CAP per tick. */
|
|
35
|
+
export const NUDGE_LIVE_MS = 3 * 3600e3;
|
|
36
|
+
export const NUDGE_CAP = 50;
|
|
37
|
+
|
|
38
|
+
export const nudgeDue = (last, everyMin, now) => everyMin > 0 && (!Number.isFinite(last) || last > now || now - last >= everyMin * 60e3);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Who gets this tick's scheduled nudge: agents and orchestrators heard from within NUDGE_LIVE_MS that are not done,
|
|
42
|
+
* not excluded and not still holding an earlier scheduled nudge; the longest silent first.
|
|
43
|
+
*/
|
|
44
|
+
export function nudgeTargets({ actors, seen, done = new Set(), pending = new Set(), exclude = new Set(), now, cap = NUDGE_CAP }) {
|
|
45
|
+
return actors
|
|
46
|
+
.filter((a) => (a.node_type === 'agent' || a.node_type === 'orchestrator') && ADDRESS.test(a.id) && a.id !== 'user' && a.id !== 'manager')
|
|
47
|
+
.filter((a) => !done.has(a.id) && !pending.has(a.id) && !exclude.has(a.id))
|
|
48
|
+
.map((a) => ({ id: a.id, seenAt: seen.get(a.id) || 0 }))
|
|
49
|
+
.filter((a) => a.seenAt > 0 && now - a.seenAt < NUDGE_LIVE_MS)
|
|
50
|
+
.sort((a, b) => a.seenAt - b.seenAt)
|
|
51
|
+
.slice(0, cap);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const nudgeText = (every) => `Scheduled check-in from the hub (every ${every} min). Status is mandatory: post your current state in your task channel now. `
|
|
55
|
+
+ 'Post `STATUS` if you are working or waiting (say on whom), `BLOCKER` if you are stuck, or your terminal post '
|
|
56
|
+
+ '(`DONE`, `FINAL: PASS` / `FINAL: FAIL`, `STOPPED — <reason>`, `HANDED OVER — <to whom>`) if you have finished. '
|
|
57
|
+
+ 'If you have no task channel yet, open `<repo>/.agents/<task>/channel.md` with a `KICKOFF` post stating your task. Then ack this message.';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Agents finished by what the Board shows: a stream whose final review passed, or a done agent row or delivery. Rows
|
|
61
|
+
* and deliveries are only as fresh as the last collect, so one older than the agent's last sign of life does not count.
|
|
62
|
+
*/
|
|
63
|
+
function doneIds(registry, actors, seen) {
|
|
64
|
+
const rows = new Map(registry.facts('agent-row').map((r) => [r.key, r]));
|
|
65
|
+
const deliveries = new Map(registry.facts('delivery').map((d) => [d.key, d]));
|
|
66
|
+
const done = new Set();
|
|
67
|
+
for (const a of actors) {
|
|
68
|
+
const row = rows.get(a.id);
|
|
69
|
+
const key = a.stream_key || (row && row.streamKey);
|
|
70
|
+
const stream = key && registry.fact('stream', key);
|
|
71
|
+
const d = a.node_type === 'orchestrator' && deliveries.get(a.id);
|
|
72
|
+
const current = (f) => f && f.status === 'done' && f.updated_at >= (seen.get(a.id) || 0);
|
|
73
|
+
if ((stream && stream.done) || current(row) || current(d)) done.add(a.id);
|
|
74
|
+
}
|
|
75
|
+
return done;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Send the scheduled nudge when it is due. The last run is kept in hub.db so a restart keeps the cadence. */
|
|
79
|
+
export function scheduledNudge(hub, { everyMin, now = Date.now(), skip = null, released = null } = {}) {
|
|
80
|
+
const { db, registry, messaging } = hub;
|
|
81
|
+
const last = (registry.fact('wake', 'scheduled') || {}).at;
|
|
82
|
+
if (!nudgeDue(last, everyMin, now)) return null;
|
|
83
|
+
const actors = registry.all();
|
|
84
|
+
const seen = new Map(actors.map((a) => [a.id, a.last_at || 0]));
|
|
85
|
+
for (const [id, p] of registry.presences()) seen.set(id, Math.max(seen.get(id) || 0, p.last_seen));
|
|
86
|
+
// Scheduled and Board nudges ask for an ack; the manager worker's own NUDGE posts do not, so they never hold one back.
|
|
87
|
+
const pending = new Set(db.prepare(`SELECT DISTINCT d.recipient AS id FROM messages m JOIN deliveries d ON d.message_id = m.id
|
|
88
|
+
WHERE m.created_at > ? AND m.expires_at > ? AND m.sender = 'manager' AND m.type = 'NUDGE' AND m.needs_reply = 1
|
|
89
|
+
AND d.state IN ('queued','leased','delivered')`).all(now - NUDGE_LIVE_MS, now).map((r) => r.id));
|
|
90
|
+
const exclude = new Set(registry.facts('run').map((r) => r.key));
|
|
91
|
+
for (const chat of managerChats(registry.fact('manager', 'config'))) exclude.add(chat);
|
|
92
|
+
if (skip) for (const id of skip(last)) exclude.add(id);
|
|
93
|
+
if (released) for (const id of released()) pending.delete(id);
|
|
94
|
+
const targets = nudgeTargets({ actors, seen, done: doneIds(registry, actors, seen), pending, exclude, now });
|
|
95
|
+
const dir = targets.length ? registry.directory(now) : [];
|
|
96
|
+
const sent = [];
|
|
97
|
+
for (const t of targets) {
|
|
98
|
+
const env = messaging.publish({
|
|
99
|
+
id: edgeId('n', Math.floor(now / (everyMin * 60e3)), t.id), from: 'manager', fromName: 'Manager', topic: `agent/${t.id}`, type: 'NUDGE', body: nudgeText(everyMin),
|
|
100
|
+
needsAck: true, ttlMs: NUDGE_LIVE_MS, meta: { scheduled: true },
|
|
101
|
+
}, dir);
|
|
102
|
+
if (!env.error && env.to.length) sent.push(t.id);
|
|
103
|
+
}
|
|
104
|
+
registry.setFact('wake', 'scheduled', { at: now, sent: sent.length });
|
|
105
|
+
registry.appendEvent({ at: now, kind: 'WAKE', actor: 'manager', payload: {
|
|
106
|
+
from: 'manager', to: '*', type: 'WAKE', count: sent.length,
|
|
107
|
+
text: `Scheduled nudge (every ${everyMin} min): ${sent.length} agent${sent.length === 1 ? '' : 's'}`,
|
|
108
|
+
} });
|
|
109
|
+
return sent;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** For every subagent with a message from you still queued and no hook event since it arrived, ask its parent once. */
|
|
113
|
+
export function sweepWakes(hub, now = Date.now()) {
|
|
114
|
+
const { db, registry } = hub;
|
|
115
|
+
const rows = db.prepare(`SELECT d.recipient AS id, MIN(m.created_at) AS first FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
116
|
+
WHERE m.sender = 'user' AND d.state = 'queued' AND m.expires_at > ? AND m.created_at < ? GROUP BY d.recipient`).all(now, now - 60e3);
|
|
117
|
+
const woken = [];
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
const seen = registry.presence(r.id);
|
|
120
|
+
if (seen && (seen.last_seen > r.first || now - seen.last_seen < STOPPED_MS)) continue;
|
|
121
|
+
const w = requestWake(hub, r.id, { now });
|
|
122
|
+
if (w && w.at === now) woken.push(r.id);
|
|
123
|
+
}
|
|
124
|
+
return woken;
|
|
125
|
+
}
|
package/src/hub/home.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Where the ported agents-hub core finds the hub it serves. Only the runner child process loads the core, and it sets
|
|
2
|
+
// both variables before it starts; an unset value is a hard error, never a default inside the plugin tree, so the core
|
|
3
|
+
// can't create state next to the plugin or open the frozen hub.db by accident.
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
function required(name) {
|
|
7
|
+
const value = process.env[name];
|
|
8
|
+
if (!value) throw new Error(`${name} is not set: the hub core runs only inside the Pulse runner process`);
|
|
9
|
+
return path.resolve(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The agents-hub folder whose state/ (token, personas, settings, markers, manager files) the runner keeps using. */
|
|
13
|
+
export const hubHome = () => required('PULSE_HUB_HOME');
|
|
14
|
+
|
|
15
|
+
/** The runner's SQLite file; it holds the hub ledger tables in place of state/hub.db. */
|
|
16
|
+
export const runnerDb = () => required('PULSE_RUNNER_DB');
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// agents-hub hooks/hub-bridge.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
3
|
+
/**
|
|
4
|
+
* Thin Cursor hook adapter over the hub SQLite ledger.
|
|
5
|
+
* Reply rules:
|
|
6
|
+
* 1. Agent-to-agent mail is never auto-captured. The agent answers with `hub bus reply --id <m>`.
|
|
7
|
+
* 2. A direct message from you is shown mid-turn as a side chat (postToolUse): the agent answers it with
|
|
8
|
+
* `bus reply` and keeps working. Nothing it writes is auto-captured for it.
|
|
9
|
+
* 3. A message from you still unanswered when the turn ends comes back as the next prompt (stop hook);
|
|
10
|
+
* only then is the next response captured as the reply to those message ids.
|
|
11
|
+
* 4. Other mail shown mid-turn includes the id and the reply command. Nothing is auto-captured.
|
|
12
|
+
* Always fails open: any problem prints {} so the chat is never blocked.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { openHub } from '../core/hub.mjs';
|
|
18
|
+
import { hubHome } from '../home.mjs';
|
|
19
|
+
import { CHANNEL_POST_RULE, DECISION_RULE, WRITING_GUIDE } from '../lib/writing.mjs';
|
|
20
|
+
import { isFyi } from '../core/ids.mjs';
|
|
21
|
+
|
|
22
|
+
const STATE = path.join(hubHome(), 'state');
|
|
23
|
+
const ID = /^[A-Za-z0-9._-]{1,200}$/;
|
|
24
|
+
const REPLY_MAX = 8000;
|
|
25
|
+
/** Cursor stops honouring a stop hook's followup_message after this many automatic follow-ups in a row. */
|
|
26
|
+
const STOP_LOOP_LIMIT = 5;
|
|
27
|
+
const CLI = path.join(hubHome(), 'hub.mjs');
|
|
28
|
+
|
|
29
|
+
const done = (o = {}) => { process.stdout.write(JSON.stringify(o)); process.exit(0); };
|
|
30
|
+
const clock = (t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
31
|
+
|
|
32
|
+
function splitReaction(raw) {
|
|
33
|
+
const m = String(raw).match(/^\s*\**React\**:\s*(\p{Extended_Pictographic}(?:\uFE0F|\u200D\p{Extended_Pictographic}|\p{Emoji_Modifier})*)\s*(?:\n|$)/u);
|
|
34
|
+
return m ? { reaction: m[1], text: raw.slice(m[0].length).trim() } : { reaction: null, text: raw };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function replyHint(ids) {
|
|
38
|
+
return ids.map((id) => `node ${CLI} bus reply --id ${id} --as <your-address> "<answer>"`).join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A multi-line markdown answer survives the shell only inside a quoted heredoc. */
|
|
42
|
+
function markdownReplyHint(ids) {
|
|
43
|
+
return ids.map((id) => `node ${CLI} bus reply --id ${id} --as <your-address> "$(cat <<'EOF'\n<your answer in markdown>\nEOF\n)"`).join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const mailLines = (pending) => pending.map((m) => `[${clock(m.at)}] ${m.fromName || m.from || 'User'} · ${m.type || 'MESSAGE'} · ${m.topic || 'direct'} · id ${m.id}\n${m.text}`).join('\n\n');
|
|
47
|
+
|
|
48
|
+
/** How to answer the user: the agent's persona voice (attached to each direct message) and the markdown guide. */
|
|
49
|
+
function howToAnswer(pending) {
|
|
50
|
+
const voice = pending.map((m) => m.meta && m.meta.voice).find((v) => typeof v === 'string' && v);
|
|
51
|
+
return [voice, WRITING_GUIDE].filter(Boolean).join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function formatMail(pending, { autoCapture }) {
|
|
55
|
+
const parts = mailLines(pending);
|
|
56
|
+
if (autoCapture) {
|
|
57
|
+
return `Messages delivered by the Agent Hub bus (answer these in this turn; the hub will capture this reply only for these ids):\n${howToAnswer(pending)}\n\n${parts}`;
|
|
58
|
+
}
|
|
59
|
+
return `Messages from the Agent Hub bus. They are not auto-captured. Reply with:\n${replyHint(pending.map((m) => m.id))}\n\n${parts}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatSideChat(pending) {
|
|
63
|
+
return 'Side chat from the user while you work. Answer now in one short reply each (status, a question, a quick fact), '
|
|
64
|
+
+ 'then carry on with your task exactly where you were; do not stop or change course unless the message asks you to.\n'
|
|
65
|
+
+ `${howToAnswer(pending)}\nReply with:\n${markdownReplyHint(pending.map((m) => m.id))}\n\n${mailLines(pending)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function intro(input, id) {
|
|
69
|
+
const roots = Array.isArray(input.workspace_roots) ? input.workspace_roots : [];
|
|
70
|
+
if (!roots.some((r) => String(r).startsWith('/Volumes/docs/My Experiments'))) return null;
|
|
71
|
+
const marker = path.join(STATE, 'live', `${id}.introduced`);
|
|
72
|
+
if (fs.existsSync(marker)) return null;
|
|
73
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true, mode: 0o700 });
|
|
74
|
+
fs.writeFileSync(marker, String(Date.now()), { mode: 0o600 });
|
|
75
|
+
return `Agent Hub connected. Your unique bus address is ${id}. Use the always-applied Agent Hub rule for topics and commands. `
|
|
76
|
+
+ `Register your assigned role when convenient. Direct messages from the user arrive between your steps as a side chat: `
|
|
77
|
+
+ `answer them with the reply command and keep working; any left unanswered come back when your turn ends. `
|
|
78
|
+
+ `Other mail includes a reply command and is never auto-captured. Your role and delivery gates do not change. `
|
|
79
|
+
+ `${WRITING_GUIDE} ${CHANNEL_POST_RULE} ${DECISION_RULE}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function handleHook(input, hub) {
|
|
83
|
+
const event = input.hook_event_name;
|
|
84
|
+
const id = input.conversation_id;
|
|
85
|
+
if (!event || !ID.test(String(id || ''))) return {};
|
|
86
|
+
const { registry, messaging } = hub;
|
|
87
|
+
registry.ensure(id, { source: 'hook' });
|
|
88
|
+
registry.heartbeat(id, {
|
|
89
|
+
event,
|
|
90
|
+
tool: typeof input.tool_name === 'string' ? input.tool_name.slice(0, 60) : null,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (event === 'afterAgentResponse') {
|
|
94
|
+
const waiting = messaging.awaiting(id).filter((w) => w.sender === 'user');
|
|
95
|
+
if (waiting.length && typeof input.text === 'string' && input.text.trim()) {
|
|
96
|
+
const { reaction, text } = splitReaction(input.text);
|
|
97
|
+
waiting.forEach((w, i) => {
|
|
98
|
+
messaging.ack(id, w.id, {
|
|
99
|
+
reply: i === waiting.length - 1 ? text.slice(0, REPLY_MAX) : null,
|
|
100
|
+
reaction: i === waiting.length - 1 ? reaction : null,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (event === 'postToolUse' || event === 'postToolUseFailure') {
|
|
108
|
+
const introduction = intro(input, id);
|
|
109
|
+
const pending = messaging.peek(id);
|
|
110
|
+
const others = pending.filter((m) => m.from !== 'user');
|
|
111
|
+
const fromUser = pending.filter((m) => m.from === 'user');
|
|
112
|
+
if (others.length) messaging.markDelivered(id, others.map((m) => m.id), { via: 'postToolUse', awaitingReply: false });
|
|
113
|
+
if (fromUser.length) messaging.markDelivered(id, fromUser.map((m) => m.id), { via: 'side', awaitingReply: false });
|
|
114
|
+
if (!others.length && !fromUser.length && !introduction) return {};
|
|
115
|
+
const parts = [introduction];
|
|
116
|
+
if (fromUser.length) parts.push(formatSideChat(fromUser));
|
|
117
|
+
if (others.length) parts.push(formatMail(others, { autoCapture: false }));
|
|
118
|
+
return { additional_context: parts.filter(Boolean).join('\n\n') };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (event === 'stop') {
|
|
122
|
+
messaging.releaseAwaiting(id);
|
|
123
|
+
const willRun = (input.status === undefined || input.status === 'completed') && !(Number(input.loop_count) >= STOP_LOOP_LIMIT);
|
|
124
|
+
if (!willRun) return {};
|
|
125
|
+
const pending = messaging.peek(id);
|
|
126
|
+
const unanswered = messaging.sideUnanswered(id);
|
|
127
|
+
const fromUser = pending.filter((m) => m.from === 'user');
|
|
128
|
+
const others = pending.filter((m) => m.from !== 'user');
|
|
129
|
+
if (fromUser.length || unanswered.length) {
|
|
130
|
+
const claimed = pending.length ? messaging.claim(id, { via: 'stop', limit: pending.length }) : [];
|
|
131
|
+
const userMail = [...unanswered, ...claimed.filter((m) => m.from === 'user')];
|
|
132
|
+
messaging.markDelivered(id, userMail.map((m) => m.id), { via: 'stop', awaitingReply: true });
|
|
133
|
+
const otherMail = claimed.filter((m) => m.from !== 'user');
|
|
134
|
+
if (otherMail.length) messaging.markDelivered(id, otherMail.map((m) => m.id), { via: 'stop', awaitingReply: false });
|
|
135
|
+
const parts = [formatMail(userMail, { autoCapture: true })];
|
|
136
|
+
if (otherMail.length) parts.push(formatMail(otherMail, { autoCapture: false }));
|
|
137
|
+
return { followup_message: parts.join('\n\n---\n\n') };
|
|
138
|
+
}
|
|
139
|
+
// Only mail that asks for an answer is worth a new turn; an FYI reply waits for this chat's next step.
|
|
140
|
+
const actionable = others.filter((m) => !isFyi(m));
|
|
141
|
+
if (actionable.length) {
|
|
142
|
+
const claimed = messaging.claim(id, { via: 'stop', limit: pending.length }).filter((m) => actionable.some((a) => a.id === m.id));
|
|
143
|
+
messaging.markDelivered(id, claimed.map((m) => m.id), { via: 'stop', awaitingReply: false });
|
|
144
|
+
if (claimed.length) return { followup_message: formatMail(claimed, { autoCapture: false }) };
|
|
145
|
+
}
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function main(input) {
|
|
152
|
+
try {
|
|
153
|
+
const hub = openHub(STATE);
|
|
154
|
+
done(handleHook(input, hub));
|
|
155
|
+
} catch {
|
|
156
|
+
done();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const invoked = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
161
|
+
if (invoked) {
|
|
162
|
+
let raw = '';
|
|
163
|
+
process.stdin.setEncoding('utf8');
|
|
164
|
+
process.stdin.on('data', (c) => { raw += c; });
|
|
165
|
+
process.stdin.on('end', () => { try { main(JSON.parse(raw || '{}')); } catch { done(); } });
|
|
166
|
+
setTimeout(() => done(), 4000).unref();
|
|
167
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// agents-hub keepawake/agent.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// Keeps the Mac awake for the hub. Every 30 s it reads the keep-awake mode and holds exactly one `caffeinate` while the
|
|
3
|
+
// Mac should stay awake, reporting to state/keepawake-status.json (read by store.keepAwakeStatus). Runs as the
|
|
4
|
+
// LaunchAgent that keepawake/install.mjs loads.
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { KEEPAWAKE_STATUS, readSettings } from '../lib/store.mjs';
|
|
11
|
+
import { runnerDb } from '../home.mjs';
|
|
12
|
+
|
|
13
|
+
export const TICK_MS = 30e3;
|
|
14
|
+
export const AGENTS_RECENT_MS = 10 * 60e3;
|
|
15
|
+
const DB = runnerDb();
|
|
16
|
+
|
|
17
|
+
/** `always` holds the Mac awake, `agents` only while an agent was heard from in the last 10 min, anything else never. */
|
|
18
|
+
export function shouldStayAwake(mode, { lastAgentAt = 0, now }) {
|
|
19
|
+
if (mode === 'always') return true;
|
|
20
|
+
if (mode === 'agents') return lastAgentAt > 0 && now - lastAgentAt < AGENTS_RECENT_MS;
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** When any agent or orchestrator last reached the hub, from hub.db opened read-only; 0 when unknown. */
|
|
25
|
+
export function lastAgentAt(file = DB) {
|
|
26
|
+
let db = null;
|
|
27
|
+
try {
|
|
28
|
+
db = new DatabaseSync(file, { readOnly: true });
|
|
29
|
+
const r = db.prepare(`SELECT MAX(p.last_seen) AS at FROM presence p JOIN actors a ON a.id = p.actor_id
|
|
30
|
+
WHERE a.node_type IN ('agent', 'orchestrator')`).get();
|
|
31
|
+
return Number(r && r.at) || 0;
|
|
32
|
+
} catch { return 0; } finally { try { if (db) db.close(); } catch { /* closed */ } }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* -ims prevents idle, system and disk sleep but lets the display sleep. -w ties the child to this process, so even a
|
|
37
|
+
* killed helper never leaves a caffeinate behind.
|
|
38
|
+
*/
|
|
39
|
+
export const CAFFEINATE_ARGS = (pid = process.pid) => ['-ims', '-w', String(pid)];
|
|
40
|
+
const caffeinate = () => spawn('/usr/bin/caffeinate', CAFFEINATE_ARGS(), { stdio: 'ignore' });
|
|
41
|
+
|
|
42
|
+
/** Holds at most one caffeinate child: started when asked to stay awake, killed when not, restarted if it died. */
|
|
43
|
+
export function createKeeper({ start = caffeinate, now = Date.now } = {}) {
|
|
44
|
+
let child = null;
|
|
45
|
+
let since = null;
|
|
46
|
+
const drop = (c) => { if (child === c) { child = null; since = null; } };
|
|
47
|
+
return {
|
|
48
|
+
set(awake) {
|
|
49
|
+
if (awake && !child) {
|
|
50
|
+
const c = start();
|
|
51
|
+
child = c;
|
|
52
|
+
since = now();
|
|
53
|
+
c.on('exit', () => drop(c));
|
|
54
|
+
c.on('error', (e) => { console.error(`caffeinate failed: ${e.message}`); drop(c); });
|
|
55
|
+
} else if (!awake && child) {
|
|
56
|
+
const c = child;
|
|
57
|
+
drop(c);
|
|
58
|
+
c.kill();
|
|
59
|
+
}
|
|
60
|
+
return { running: !!child, since };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function writeStatus(file, status) {
|
|
66
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
67
|
+
fs.writeFileSync(tmp, JSON.stringify(status));
|
|
68
|
+
fs.renameSync(tmp, file);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function main() {
|
|
72
|
+
const keeper = createKeeper();
|
|
73
|
+
const tick = () => {
|
|
74
|
+
try {
|
|
75
|
+
const { mode } = readSettings().keepAwake;
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const state = keeper.set(shouldStayAwake(mode, { lastAgentAt: mode === 'agents' ? lastAgentAt() : 0, now }));
|
|
78
|
+
writeStatus(KEEPAWAKE_STATUS, { ...state, mode, heartbeat: now, pid: process.pid });
|
|
79
|
+
} catch (e) { console.error(`keep-awake tick failed: ${e.message}`); }
|
|
80
|
+
};
|
|
81
|
+
tick();
|
|
82
|
+
const timer = setInterval(tick, TICK_MS);
|
|
83
|
+
const quit = () => {
|
|
84
|
+
clearInterval(timer);
|
|
85
|
+
keeper.set(false);
|
|
86
|
+
try { writeStatus(KEEPAWAKE_STATUS, { running: false, since: null, heartbeat: 0, stoppedAt: Date.now() }); } catch { /* best effort */ }
|
|
87
|
+
process.exit(0);
|
|
88
|
+
};
|
|
89
|
+
process.on('SIGTERM', quit);
|
|
90
|
+
process.on('SIGINT', quit);
|
|
91
|
+
process.on('exit', () => keeper.set(false));
|
|
92
|
+
console.log(`keep-awake helper running (pid ${process.pid})`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const invoked = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
96
|
+
if (invoked) main();
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// agents-hub lib/brief.mjs at 6b2fb0b. Ported verbatim; every change is a declared seam (test/hub-verbatim.test.js).
|
|
2
|
+
// The written brief for a work stream: brief.md beside the channel, the spec its KICKOFF names, or the KICKOFF body.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parsePosts } from './channels.mjs';
|
|
6
|
+
|
|
7
|
+
const MAX_CHARS = 12000;
|
|
8
|
+
const MAX_SECTIONS = 24;
|
|
9
|
+
const SPEC_PATH = /(?:^|\s)(?:Spec:\s*)?(docs\/[A-Za-z0-9._/-]+\.md)/;
|
|
10
|
+
const H2 = /^(#{1,2})\s+(.+)$/;
|
|
11
|
+
const NAMED = /^(?:\*\*(.+?)\*\*|([A-Z][A-Za-z0-9 /&-]{2,48}):)\s*$/;
|
|
12
|
+
/** Standing protocol sections — not what the agent was asked to build. */
|
|
13
|
+
const SKIP = /^(workflow\b.*|hard rules|standing rules|browser\b.*|return\b.*|protocol|never|unattended|communicating)$/i;
|
|
14
|
+
|
|
15
|
+
function safeFile(root, rel) {
|
|
16
|
+
if (!rel || rel.includes('\0') || rel.includes('..')) return null;
|
|
17
|
+
let realRoot;
|
|
18
|
+
try { realRoot = fs.realpathSync(root); } catch { return null; }
|
|
19
|
+
const candidate = path.resolve(root, rel);
|
|
20
|
+
let real;
|
|
21
|
+
try { real = fs.realpathSync(candidate); } catch { return null; }
|
|
22
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) return null;
|
|
23
|
+
try { if (!fs.statSync(real).isFile()) return null; } catch { return null; }
|
|
24
|
+
return real;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** At most `bytes` of a regular file; never blocks on a FIFO or reads a device. */
|
|
28
|
+
export function readCapped(file, bytes = MAX_CHARS * 4) {
|
|
29
|
+
let fd;
|
|
30
|
+
try {
|
|
31
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
|
32
|
+
if (!fs.fstatSync(fd).isFile()) return null;
|
|
33
|
+
const buf = Buffer.alloc(bytes);
|
|
34
|
+
const n = fs.readSync(fd, buf, 0, bytes, 0);
|
|
35
|
+
return buf.subarray(0, n).toString('utf8');
|
|
36
|
+
} catch { return null; } finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const readText = (file) => readCapped(file);
|
|
40
|
+
|
|
41
|
+
/** Split a markdown brief into a title and ## / named sections. */
|
|
42
|
+
export function parseBriefSections(text, { maxChars = MAX_CHARS } = {}) {
|
|
43
|
+
const raw = String(text || '').replace(/\r/g, '').slice(0, maxChars);
|
|
44
|
+
const titleMatch = raw.match(/^#\s+(.+)$/m);
|
|
45
|
+
const title = titleMatch ? titleMatch[1].replace(/[*`]/g, '').trim() : null;
|
|
46
|
+
const sections = [];
|
|
47
|
+
let heading = null;
|
|
48
|
+
let buf = [];
|
|
49
|
+
const flush = () => {
|
|
50
|
+
const body = buf.join('\n').trim();
|
|
51
|
+
buf = [];
|
|
52
|
+
if (!heading) {
|
|
53
|
+
if (!body) return;
|
|
54
|
+
heading = 'Overview';
|
|
55
|
+
}
|
|
56
|
+
if (body) sections.push({ heading, body });
|
|
57
|
+
};
|
|
58
|
+
for (const line of raw.split('\n')) {
|
|
59
|
+
const h2 = line.match(H2);
|
|
60
|
+
if (h2) {
|
|
61
|
+
const name = h2[2].replace(/[*`]/g, '').trim();
|
|
62
|
+
if (h2[1] === '#' && title && name === title) continue;
|
|
63
|
+
if (h2[1] === '#' && !title) {
|
|
64
|
+
flush();
|
|
65
|
+
heading = name;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (h2[1] === '##') {
|
|
69
|
+
flush();
|
|
70
|
+
heading = name;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const named = line.match(NAMED);
|
|
75
|
+
if (named) {
|
|
76
|
+
flush();
|
|
77
|
+
heading = (named[1] || named[2]).replace(/[*`]/g, '').trim();
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
buf.push(line);
|
|
81
|
+
}
|
|
82
|
+
flush();
|
|
83
|
+
const kept = sections.filter((s) => !SKIP.test(s.heading)).slice(0, MAX_SECTIONS);
|
|
84
|
+
return { title, sections: kept.length ? kept : sections.slice(0, MAX_SECTIONS) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const hasBody = (b) => !!(b && b.sections && b.sections.some((s) => s.body && s.body.trim()));
|
|
88
|
+
|
|
89
|
+
function fromMarkdown(file, source) {
|
|
90
|
+
const text = readText(file);
|
|
91
|
+
if (!text) return null;
|
|
92
|
+
const parsed = parseBriefSections(text);
|
|
93
|
+
if (!parsed.title && !parsed.sections.length) return null;
|
|
94
|
+
return { source, ...parsed };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function kickoffOf(channelFile) {
|
|
98
|
+
try {
|
|
99
|
+
const st = fs.statSync(channelFile);
|
|
100
|
+
const posts = parsePosts(fs.readFileSync(channelFile, 'utf8'), st.mtimeMs, undefined, { full: true });
|
|
101
|
+
return posts.find((p) => /^KICKOFF/.test(p.type.toUpperCase())) || null;
|
|
102
|
+
} catch { return null; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The task the agents were given for this channel. Prefers brief.md, then the spec named in KICKOFF, then the
|
|
107
|
+
* KICKOFF body. `query` is an optional agent transcript first prompt used when those files are thin.
|
|
108
|
+
*/
|
|
109
|
+
export function readTaskBrief(channelFile, { query = null } = {}) {
|
|
110
|
+
const dir = path.dirname(channelFile);
|
|
111
|
+
const briefFile = safeFile(dir, 'brief.md');
|
|
112
|
+
const briefMd = briefFile ? fromMarkdown(briefFile, 'brief.md') : null;
|
|
113
|
+
const kick = kickoffOf(channelFile);
|
|
114
|
+
const kickText = kick ? [kick.head.replace(/^KICKOFF\b[:\s—-]*/i, '').trim(), kick.body].filter(Boolean).join('\n').trim() : '';
|
|
115
|
+
const specRel = kickText.match(SPEC_PATH);
|
|
116
|
+
let spec = null;
|
|
117
|
+
if (specRel) {
|
|
118
|
+
const repoRoot = path.resolve(dir, '..', '..');
|
|
119
|
+
const file = safeFile(repoRoot, specRel[1]);
|
|
120
|
+
if (file) spec = fromMarkdown(file, specRel[1]);
|
|
121
|
+
}
|
|
122
|
+
const kickBrief = kickText
|
|
123
|
+
? { source: 'kickoff', title: briefMd && briefMd.title, ...parseBriefSections(kickText) }
|
|
124
|
+
: null;
|
|
125
|
+
if (kickBrief && !kickBrief.sections.length && kickText.length > 40) {
|
|
126
|
+
kickBrief.sections = [{ heading: 'Kickoff', body: kickText }];
|
|
127
|
+
}
|
|
128
|
+
const fromQuery = query ? { source: 'transcript', ...parseBriefSections(query) } : null;
|
|
129
|
+
if (fromQuery && !fromQuery.sections.length && query && query.trim()) {
|
|
130
|
+
fromQuery.sections = [{ heading: 'Details', body: query.trim().slice(0, MAX_CHARS) }];
|
|
131
|
+
}
|
|
132
|
+
return [briefMd, spec, fromQuery, kickBrief].find(hasBody) || spec || briefMd || fromQuery || kickBrief || null;
|
|
133
|
+
}
|