@hybridlabor-api/aos 4.14.1 → 4.16.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/.claude/hooks/aos-bus.mjs +146 -0
- package/.claude/hooks/go-gate.mjs +2 -1
- package/.opencode/plugins/bdb-aos.js +111 -5
- package/bin/aos-acp.mjs +7 -3
- package/bin/aos-doctor.mjs +21 -6
- package/docs/master-session-acp.md +15 -0
- package/docs/sessions/audit-agents.md +2 -0
- package/installer.js +341 -163
- package/package.json +3 -2
- package/scripts/validate-skills.mjs +29 -6
- package/skills/basic/godmode-shipping/SKILL.md +0 -1
- package/skills/basic/master-session/SKILL.md +1 -1
- package/skills/basic/startcycle/SKILL.md +0 -1
- package/skills/basic/startcycle-graph/SKILL.md +0 -1
- package/skills/global_config/ask-tim/SKILL.md +0 -1
- package/skills/global_config/grill-me/SKILL.md +0 -1
- package/skills/global_config/grill-with-docs/SKILL.md +0 -1
- package/skills/playbooks/pb-bug-fix/SKILL.md +48 -0
- package/skills/playbooks/pb-ci-fix/SKILL.md +0 -1
- package/skills/playbooks/pb-clip-from-moodboard/SKILL.md +55 -0
- package/skills/playbooks/pb-crew-call-sheet/SKILL.md +40 -0
- package/skills/playbooks/pb-deploy-saas/SKILL.md +44 -0
- package/skills/playbooks/pb-docs-site/SKILL.md +44 -0
- package/skills/playbooks/pb-event-tracker/SKILL.md +0 -1
- package/skills/playbooks/pb-focus-chunks/SKILL.md +40 -0
- package/skills/playbooks/pb-handover/SKILL.md +41 -0
- package/skills/playbooks/pb-harness-work/SKILL.md +64 -0
- package/skills/playbooks/pb-health-weekly/SKILL.md +43 -0
- package/skills/playbooks/pb-idea-to-launch/SKILL.md +51 -0
- package/skills/playbooks/pb-image-to-3d/SKILL.md +46 -0
- package/skills/playbooks/pb-inbox-zero/SKILL.md +39 -0
- package/skills/playbooks/pb-invoice-check/SKILL.md +41 -0
- package/skills/playbooks/pb-landing-page/SKILL.md +46 -0
- package/skills/playbooks/pb-launch-video/SKILL.md +49 -0
- package/skills/playbooks/pb-machine-setup/SKILL.md +44 -0
- package/skills/playbooks/pb-master/SKILL.md +53 -0
- package/skills/playbooks/pb-meeting-actions/SKILL.md +0 -1
- package/skills/playbooks/pb-newsletter/SKILL.md +43 -0
- package/skills/playbooks/pb-offer/SKILL.md +39 -0
- package/skills/playbooks/pb-open-source/SKILL.md +50 -0
- package/skills/playbooks/pb-pcb-to-case/SKILL.md +46 -0
- package/skills/playbooks/pb-project-new/SKILL.md +0 -1
- package/skills/playbooks/pb-redesign-app/SKILL.md +47 -0
- package/skills/playbooks/pb-release-aos/SKILL.md +50 -0
- package/skills/playbooks/pb-security-sweep/SKILL.md +48 -0
- package/skills/playbooks/pb-ship/SKILL.md +49 -0
- package/skills/playbooks/pb-show-build/SKILL.md +48 -0
- package/skills/playbooks/pb-social-pack/SKILL.md +47 -0
- package/skills/playbooks/pb-todo/SKILL.md +41 -0
- package/skills/playbooks/pb-week-plan/SKILL.md +0 -1
- package/skills/playbooks/pb-worktrees-land/SKILL.md +52 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// aos-bus spike: master -> running OpenCode session via per-session inbox files.
|
|
3
|
+
// Library + CLI. The receiver (OpenCode plugin) injects each message as a synthetic + ignored part pair.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, utimesSync, writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { homedir, userInfo } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { slug } from "./go-gate.mjs";
|
|
13
|
+
|
|
14
|
+
const MAX_SEND = 8 * 1024;
|
|
15
|
+
const MAX_FILE = 16 * 1024;
|
|
16
|
+
|
|
17
|
+
export function busPaths(name) {
|
|
18
|
+
const n = slug(String(name ?? ""));
|
|
19
|
+
if (!n || n.startsWith(".")) throw new Error("invalid session name");
|
|
20
|
+
const root = join(homedir(), ".aos", "bus");
|
|
21
|
+
return { name: n, root, sessions: join(root, "sessions"), reg: join(root, "sessions", `${n}.json`), inbox: join(root, "inbox", n) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ensurePrivateDir(dir) {
|
|
25
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
chmodSync(dir, 0o700);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Returns null when usable, else the refusal reason.
|
|
30
|
+
export function checkPrivateDir(dir) {
|
|
31
|
+
let st;
|
|
32
|
+
try { st = lstatSync(dir); } catch { return `${dir} does not exist`; }
|
|
33
|
+
if (!st.isDirectory()) return `${dir} is not a real directory`;
|
|
34
|
+
if (st.uid !== process.getuid()) return `${dir} is not owned by the current user`;
|
|
35
|
+
if (st.mode & 0o077) return `${dir} is accessible to others (mode ${(st.mode & 0o777).toString(8)})`;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function registerSession({ name, sessionID, cwd }) {
|
|
40
|
+
const p = busPaths(name);
|
|
41
|
+
ensurePrivateDir(join(p.root, "inbox"));
|
|
42
|
+
ensurePrivateDir(p.sessions);
|
|
43
|
+
ensurePrivateDir(p.inbox);
|
|
44
|
+
writeFileSync(p.reg, JSON.stringify({ name: p.name, harness: "opencode", pid: process.pid, cwd, sessionID, inbox: p.inbox, ts: Date.now() }), { mode: 0o600 });
|
|
45
|
+
return p.name;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function unregisterSession(name, pid = process.pid) {
|
|
49
|
+
try {
|
|
50
|
+
const p = busPaths(name);
|
|
51
|
+
if (JSON.parse(readFileSync(p.reg, "utf8")).pid === pid) unlinkSync(p.reg);
|
|
52
|
+
} catch { /* already gone */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A registration is live only while its owner keeps touching it: a pid check alone passes
|
|
56
|
+
// for a reused pid, and a killed or exited TUI leaves the file behind (no exit hook fires).
|
|
57
|
+
export const STALE_MS = Number(process.env.AOS_BUS_STALE_MS) || 15_000;
|
|
58
|
+
|
|
59
|
+
export function touchSession(name, pid = process.pid) {
|
|
60
|
+
try {
|
|
61
|
+
const p = busPaths(name);
|
|
62
|
+
if (JSON.parse(readFileSync(p.reg, "utf8")).pid !== pid) return false;
|
|
63
|
+
const now = new Date();
|
|
64
|
+
utimesSync(p.reg, now, now);
|
|
65
|
+
return true;
|
|
66
|
+
} catch { return false; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } };
|
|
70
|
+
|
|
71
|
+
export function listSessions() {
|
|
72
|
+
const dir = join(homedir(), ".aos", "bus", "sessions");
|
|
73
|
+
const out = [];
|
|
74
|
+
let files = [];
|
|
75
|
+
try { files = readdirSync(dir).filter((f) => f.endsWith(".json")); } catch { /* none */ }
|
|
76
|
+
for (const f of files) {
|
|
77
|
+
try {
|
|
78
|
+
const file = join(dir, f);
|
|
79
|
+
const r = JSON.parse(readFileSync(file, "utf8"));
|
|
80
|
+
if (Number.isInteger(r.pid) && alive(r.pid) && Date.now() - lstatSync(file).mtimeMs < STALE_MS) out.push(r);
|
|
81
|
+
} catch { /* corrupt entry */ }
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Throws with the refusal reason; returns the message file path.
|
|
87
|
+
export function sendMessage(name, text, { from, wake = false } = {}) {
|
|
88
|
+
const p = busPaths(name);
|
|
89
|
+
if (!listSessions().some((s) => s.name === p.name)) throw new Error(`session "${p.name}" is not registered or not alive`);
|
|
90
|
+
const bad = checkPrivateDir(p.inbox);
|
|
91
|
+
if (bad) throw new Error(bad);
|
|
92
|
+
text = String(text ?? "");
|
|
93
|
+
if (Buffer.byteLength(text) > MAX_SEND) throw new Error(`message exceeds ${MAX_SEND} bytes`);
|
|
94
|
+
from ||= process.env.AOS_SESSION_NAME || `${userInfo().username}:${process.ppid}`;
|
|
95
|
+
const base = `${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
96
|
+
const tmp = join(p.inbox, `${base}.tmp`);
|
|
97
|
+
const file = join(p.inbox, `${base}.json`);
|
|
98
|
+
writeFileSync(tmp, JSON.stringify({ v: 1, from, text, ts: Date.now(), ...(wake ? { wake: true } : {}) }), { mode: 0o600 });
|
|
99
|
+
renameSync(tmp, file);
|
|
100
|
+
return file;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readInbox(name) {
|
|
104
|
+
const p = busPaths(name);
|
|
105
|
+
if (checkPrivateDir(p.inbox)) return [];
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const f of readdirSync(p.inbox).filter((x) => x.endsWith(".json")).sort()) {
|
|
108
|
+
const file = join(p.inbox, f);
|
|
109
|
+
try {
|
|
110
|
+
const st = lstatSync(file);
|
|
111
|
+
if (!st.isFile() || st.uid !== process.getuid() || st.size > MAX_FILE) throw new Error("rejected");
|
|
112
|
+
const m = JSON.parse(readFileSync(file, "utf8"));
|
|
113
|
+
if (typeof m?.text !== "string") throw new Error("rejected");
|
|
114
|
+
const from = String(m.from ?? "").replace(/[\x00-\x1f\x7f\]]/g, "").slice(0, 64) || "unknown";
|
|
115
|
+
out.push({ file, from, text: m.text, wake: m.wake === true, uid: st.uid, ts: Number.isFinite(m.ts) ? m.ts : undefined, mtimeMs: st.mtimeMs });
|
|
116
|
+
} catch {
|
|
117
|
+
try { renameSync(file, `${file}.rejected`); } catch { /* gone */ }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const isMain = () => { try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } };
|
|
124
|
+
|
|
125
|
+
if (isMain()) {
|
|
126
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
127
|
+
const opts = {}; const words = [];
|
|
128
|
+
for (let i = 0; i < rest.length; i++) {
|
|
129
|
+
if (rest[i] === "--from") opts.from = rest[++i];
|
|
130
|
+
else if (rest[i] === "--wake") opts.wake = true;
|
|
131
|
+
else words.push(rest[i]);
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
if (cmd === "send" && words.length >= 2) {
|
|
135
|
+
console.log(sendMessage(words[0], words.slice(1).join(" "), opts));
|
|
136
|
+
} else if (cmd === "list") {
|
|
137
|
+
for (const s of listSessions()) console.log(`${s.name}\tpid ${s.pid}\t${s.cwd}`);
|
|
138
|
+
} else {
|
|
139
|
+
console.error("usage: aos-bus send <name> <text...> [--from x] [--wake] | aos-bus list");
|
|
140
|
+
process.exit(2);
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error(`aos-bus: ${e.message}`);
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -150,7 +150,8 @@ function opencodeLastUser(sessionId) {
|
|
|
150
150
|
if (!msg) return null;
|
|
151
151
|
const text = db.prepare("SELECT data FROM part WHERE message_id = ? ORDER BY id").all(msg.id)
|
|
152
152
|
.map((r) => JSON.parse(r.data))
|
|
153
|
-
|
|
153
|
+
// synthetic = model-only, ignored = display-only, aos_bus = bus delivery: none is a human turn.
|
|
154
|
+
.filter((p) => p?.type === "text" && !p.synthetic && !p.ignored && !p.metadata?.aos_bus && typeof p.text === "string")
|
|
154
155
|
.map((p) => p.text)
|
|
155
156
|
.join("\n")
|
|
156
157
|
.trim();
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* on unguarded destructive actions.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { existsSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
|
|
21
21
|
// Decision logic lives in the shared hooks: installed copy next to this file
|
|
@@ -23,8 +23,8 @@ import path from 'node:path';
|
|
|
23
23
|
// so a broken installed hook fails loudly instead of silently falling back.
|
|
24
24
|
const HOOKS = existsSync(new URL('./aos-hooks/go-gate.mjs', import.meta.url)) ? './aos-hooks/' : '../../.claude/hooks/';
|
|
25
25
|
const hook = (f) => import(new URL(HOOKS + f, import.meta.url).href);
|
|
26
|
-
const [{ GUARDED_PATTERNS, tokenGrantsGo }, { issueGoToken }, { checkConventionalCommit }, { envFileReason }, { buildMemoryBlock }] =
|
|
27
|
-
await Promise.all(['go-gate.mjs', 'go-token.mjs', 'conventional-commits.mjs', 'env-file-protection.mjs', 'memb-inject.mjs'].map(hook));
|
|
26
|
+
const [{ GUARDED_PATTERNS, tokenGrantsGo }, { issueGoToken }, { checkConventionalCommit }, { envFileReason }, { buildMemoryBlock }, bus] =
|
|
27
|
+
await Promise.all(['go-gate.mjs', 'go-token.mjs', 'conventional-commits.mjs', 'env-file-protection.mjs', 'memb-inject.mjs', 'aos-bus.mjs'].map(hook));
|
|
28
28
|
|
|
29
29
|
// ---------------------------------------------------------------------------
|
|
30
30
|
// Graph gate (W-5, W-6)
|
|
@@ -130,6 +130,15 @@ async function ownSessionName(client, sessionID) {
|
|
|
130
130
|
} catch { return ''; }
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Only plain text parts are human turns: synthetic = model-only, ignored = display-only,
|
|
134
|
+
// aos_bus = bus delivery (both copies). Each excluded on its own, so safety never hangs on one flag.
|
|
135
|
+
const isHumanText = (p) => p && p.type === 'text' && typeof p.text === 'string' && !p.synthetic && !p.ignored && !p.metadata?.aos_bus;
|
|
136
|
+
const isNonHuman = (p) => p && (p.synthetic || p.ignored || p.metadata?.aos_bus);
|
|
137
|
+
|
|
138
|
+
// A plugin reload (SIGUSR2) re-runs init on the cached module in the same process:
|
|
139
|
+
// stop the previous instance's timers and keep its bus names alive. Keyed by directory.
|
|
140
|
+
const busInstances = new Map();
|
|
141
|
+
|
|
133
142
|
// Best-effort fire-and-forget telemetry for the AOS live map (agenttrail daemon).
|
|
134
143
|
// 300 ms timeout per request, all errors swallowed, never throws. Self-contained
|
|
135
144
|
// on purpose: the plugin must not import from mcps/.
|
|
@@ -199,6 +208,94 @@ export default async function bdbAosPlugin(input) {
|
|
|
199
208
|
if (!sessions.has(id)) sessions.set(id, { prompt: '', parentID: null, identitySent: false });
|
|
200
209
|
return sessions.get(id);
|
|
201
210
|
};
|
|
211
|
+
// aos-bus: bus name -> sessionID of the root session that owns it.
|
|
212
|
+
const prevBus = busInstances.get(directory);
|
|
213
|
+
for (const t of prevBus?.timers || []) clearInterval(t);
|
|
214
|
+
const busNames = prevBus?.names || new Map();
|
|
215
|
+
const busTimers = [];
|
|
216
|
+
busInstances.set(directory, { timers: busTimers, names: busNames });
|
|
217
|
+
const every = (ms, fn) => { const t = setInterval(fn, ms); t.unref?.(); busTimers.push(t); };
|
|
218
|
+
const BUS_PROMPT_TIMEOUT_MS = Number(process.env.AOS_BUS_PROMPT_TIMEOUT_MS) || 10 * 60 * 1000;
|
|
219
|
+
const BUS_BUSY_MAX_MS = Number(process.env.AOS_BUS_BUSY_MAX_MS) || 10 * 60 * 1000;
|
|
220
|
+
const ensureBus = async (id, parentID) => {
|
|
221
|
+
const s = sess(id);
|
|
222
|
+
if (s.bus || !id) return;
|
|
223
|
+
s.bus = true;
|
|
224
|
+
try {
|
|
225
|
+
if (parentID === undefined) {
|
|
226
|
+
const res = await input.client?.session?.get?.({ path: { id } });
|
|
227
|
+
if (!res?.data) { s.bus = false; return; }
|
|
228
|
+
parentID = res.data.parentID ?? null;
|
|
229
|
+
}
|
|
230
|
+
if (parentID) return;
|
|
231
|
+
const name = bus.registerSession({ name: process.env.AOS_SESSION_NAME || id, sessionID: id, cwd: directory });
|
|
232
|
+
busNames.set(name, id);
|
|
233
|
+
} catch (e) {
|
|
234
|
+
console.error(`[aos-bus] registration failed: ${e?.message}`);
|
|
235
|
+
s.bus = /invalid session name/.test(e?.message);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
const dropBus = (id) => {
|
|
239
|
+
for (const [name, sid] of busNames) if (sid === id) { bus.unregisterSession(name); busNames.delete(name); }
|
|
240
|
+
};
|
|
241
|
+
process.on('exit', () => { for (const name of busNames.keys()) bus.unregisterSession(name); });
|
|
242
|
+
|
|
243
|
+
// Heartbeat on its own timer (the poll loop can sit in one prompt for minutes); readers drop a
|
|
244
|
+
// registration whose mtime is older than bus.STALE_MS. touchSession only touches this pid's files.
|
|
245
|
+
every(5000, () => { for (const name of busNames.keys()) bus.touchSession(name); });
|
|
246
|
+
|
|
247
|
+
const busyFirst = new Map(); // inbox file -> time of its first busy attempt
|
|
248
|
+
let polling = false;
|
|
249
|
+
every(1000, async () => {
|
|
250
|
+
if (polling || !busNames.size) return;
|
|
251
|
+
polling = true;
|
|
252
|
+
try {
|
|
253
|
+
for (const [name, sessionID] of busNames) {
|
|
254
|
+
// Only the registered owner (this pid + this session) may consume the inbox.
|
|
255
|
+
let reg; try { reg = JSON.parse(readFileSync(bus.busPaths(name).reg, 'utf8')); } catch {}
|
|
256
|
+
if (reg?.pid !== process.pid || reg?.sessionID !== sessionID) continue;
|
|
257
|
+
for (const m of bus.readInbox(name)) {
|
|
258
|
+
// Claim atomically: with two live pollers (ownership handover) only one rename wins.
|
|
259
|
+
const claim = `${m.file.slice(0, -'.json'.length)}.inflight`;
|
|
260
|
+
try { renameSync(m.file, claim); } catch { continue; }
|
|
261
|
+
// The gate checks this cache before any DB lookup; a stale human GO must not authorize a woken turn.
|
|
262
|
+
sess(sessionID).prompt = '';
|
|
263
|
+
try {
|
|
264
|
+
let timer;
|
|
265
|
+
const text = `[aos-bus from ${m.from}] ${m.text}`;
|
|
266
|
+
const metadata = { aos_bus: { from: m.from, uid: m.uid, ts: m.ts } };
|
|
267
|
+
const res = await Promise.race([input.client.session.prompt({
|
|
268
|
+
path: { id: sessionID },
|
|
269
|
+
query: directory ? { directory } : undefined,
|
|
270
|
+
body: {
|
|
271
|
+
noReply: !m.wake,
|
|
272
|
+
// OpenCode 1.18.30: the TUI hides synthetic parts and the model never sees ignored
|
|
273
|
+
// ones, so one copy each. Neither counts as a human turn (isHumanText).
|
|
274
|
+
parts: [
|
|
275
|
+
{ type: 'text', text, synthetic: true, metadata },
|
|
276
|
+
{ type: 'text', text, ignored: true, metadata },
|
|
277
|
+
],
|
|
278
|
+
},
|
|
279
|
+
}), new Promise((_, rej) => { timer = setTimeout(() => rej(Object.assign(new Error('aos-bus prompt timeout'), { timedOut: true })), BUS_PROMPT_TIMEOUT_MS); timer.unref?.(); })]).finally(() => clearTimeout(timer));
|
|
280
|
+
if (res?.error) throw res.error;
|
|
281
|
+
unlinkSync(claim);
|
|
282
|
+
busyFirst.delete(m.file);
|
|
283
|
+
input.client.tui?.showToast?.({ body: { title: 'aos-bus', message: `from ${m.from}`, variant: 'info', duration: 15000 } })?.catch?.(() => {});
|
|
284
|
+
} catch (e) {
|
|
285
|
+
// Busy: hand the file back and retry next tick, up to BUS_BUSY_MAX_MS after the first busy attempt.
|
|
286
|
+
const isBusy = /busy/i.test(`${e?.name} ${e?.message} ${e?.data?.message}`);
|
|
287
|
+
const first = busyFirst.get(m.file) ?? Date.now();
|
|
288
|
+
if (isBusy) busyFirst.set(m.file, first);
|
|
289
|
+
if (isBusy && Date.now() - first < BUS_BUSY_MAX_MS) { try { renameSync(claim, m.file); } catch {} break; }
|
|
290
|
+
busyFirst.delete(m.file);
|
|
291
|
+
// A timed-out prompt may still complete: delivery state unknown, so not .failed.
|
|
292
|
+
try { renameSync(claim, `${m.file}${e?.timedOut ? '.timeout' : '.failed'}`); } catch {}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch {} finally { polling = false; }
|
|
297
|
+
// ponytail: 1 s poll; fs.watch if latency matters. A wake turn is awaited, so it delays later messages until it ends.
|
|
298
|
+
});
|
|
202
299
|
const trail = (hook_event_name, session_id, extra = {}) =>
|
|
203
300
|
void postTrail({ hook_event_name, session_id, cwd: directory, agent: 'opencode', ...extra });
|
|
204
301
|
|
|
@@ -212,10 +309,14 @@ export default async function bdbAosPlugin(input) {
|
|
|
212
309
|
if (event?.type === 'session.created' && props.info?.id) {
|
|
213
310
|
sess(props.info.id).parentID = props.info.parentID ?? null;
|
|
214
311
|
trail('SessionStart', props.info.id);
|
|
312
|
+
void ensureBus(props.info.id, props.info.parentID ?? null);
|
|
313
|
+
} else if (event?.type === 'session.deleted' && props.info?.id) {
|
|
314
|
+
dropBus(props.info.id);
|
|
215
315
|
} else if (event?.type === 'permission.updated') {
|
|
216
316
|
trail('Notification', props.sessionID, { message: props.title });
|
|
217
317
|
} else if (event?.type === 'session.idle' && props.sessionID) {
|
|
218
318
|
trail(sess(props.sessionID).parentID ? 'SubagentStop' : 'Stop', props.sessionID);
|
|
319
|
+
void ensureBus(props.sessionID);
|
|
219
320
|
}
|
|
220
321
|
} catch {}
|
|
221
322
|
|
|
@@ -246,10 +347,15 @@ export default async function bdbAosPlugin(input) {
|
|
|
246
347
|
},
|
|
247
348
|
|
|
248
349
|
'chat.message': async (msgInput, msgOutput) => {
|
|
249
|
-
const textParts = (msgOutput.parts || []).filter(
|
|
350
|
+
const textParts = (msgOutput.parts || []).filter(isHumanText);
|
|
250
351
|
const fullText = textParts.map((p) => p.text).join('\n').trim();
|
|
251
352
|
const s = sess(msgInput.sessionID);
|
|
353
|
+
void ensureBus(msgInput.sessionID);
|
|
252
354
|
s.prompt = fullText;
|
|
355
|
+
// No human text (aos-bus pair, nudges): never a human GO, skip memB/pipeline/token.
|
|
356
|
+
const parts = msgOutput.parts || [];
|
|
357
|
+
const texts = parts.filter((p) => p && p.type === 'text');
|
|
358
|
+
if ((parts.length > 0 && parts.every((p) => p && p.synthetic)) || (texts.length > 0 && texts.every(isNonHuman))) { s.prompt = ''; return; }
|
|
253
359
|
|
|
254
360
|
try {
|
|
255
361
|
if (!s.parentID) issueGoToken(fullText, { session_id: msgInput.sessionID, message_id: msgInput.messageID || msgOutput.message?.id });
|
|
@@ -333,7 +439,7 @@ export default async function bdbAosPlugin(input) {
|
|
|
333
439
|
const m = msgs[i];
|
|
334
440
|
if ((m.info?.role ?? m.role) === 'user') {
|
|
335
441
|
const parts = m.parts || [];
|
|
336
|
-
const text = parts.filter(
|
|
442
|
+
const text = parts.filter(isHumanText).map((p) => p.text).join('\n').trim();
|
|
337
443
|
authorized = text.toUpperCase() === 'GO';
|
|
338
444
|
break;
|
|
339
445
|
}
|
package/bin/aos-acp.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// aos-acp <claude|codex|opencode|agy> --name <worker> [--cwd <dir>]
|
|
10
10
|
// (--prompt "<text>" | --prompt-file <f>) [--allow-default deny|allow]
|
|
11
11
|
// [--go-wait <sec>] [--consume|--no-consume] [--log <file>]
|
|
12
|
-
// [--timeout <sec>] [--cmd "<custom agent command>"]
|
|
12
|
+
// [--model <id>] [--timeout <sec>] [--cmd "<custom agent command>"]
|
|
13
13
|
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
15
|
import { appendFileSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
|
@@ -130,8 +130,9 @@ export async function run(opts) {
|
|
|
130
130
|
? "agy has no sanctioned ACP adapter (antigravity-acp breaches Google's Antigravity terms); delegate via the mcsc skill instead"
|
|
131
131
|
: `unknown adapter "${opts.adapter}" (claude|codex|opencode)`);
|
|
132
132
|
if (adapter?.cwdFlag && !opts.cmd) cmd.push(adapter.cwdFlag, opts.cwd);
|
|
133
|
+
if (/fable/i.test(opts.model || "")) throw new Error(`model "${opts.model}" rejected: workers run on opus, sonnet or haiku only`);
|
|
133
134
|
const consume = opts.consume ?? adapter?.consume ?? true;
|
|
134
|
-
log("start", { adapter: opts.adapter, cmd: cmd.join(" "), cwd: opts.cwd, allow_default: opts.allowDefault, consume });
|
|
135
|
+
log("start", { adapter: opts.adapter, cmd: cmd.join(" "), cwd: opts.cwd, allow_default: opts.allowDefault, consume, model: opts.model || "adapter default" });
|
|
135
136
|
|
|
136
137
|
let sessionId;
|
|
137
138
|
const out = opts.stdout || process.stdout;
|
|
@@ -169,6 +170,8 @@ export async function run(opts) {
|
|
|
169
170
|
log("initialized", { agent: init?.agentInfo, auth: init?.authMethods });
|
|
170
171
|
({ sessionId } = await client.request("session/new", { cwd: opts.cwd, mcpServers: [] }));
|
|
171
172
|
log("session", { sessionId });
|
|
173
|
+
// ACP session config option "model": claude-agent-acp, codex-acp and opencode all implement it.
|
|
174
|
+
if (opts.model) await client.request("session/set_config_option", { sessionId, configId: "model", value: opts.model });
|
|
172
175
|
const res = await client.request("session/prompt", { sessionId, prompt: [{ type: "text", text: opts.prompt }] });
|
|
173
176
|
out.write("\n");
|
|
174
177
|
log("done", { stopReason: res?.stopReason });
|
|
@@ -194,12 +197,13 @@ export function parseArgs(argv) {
|
|
|
194
197
|
else if (a === "--go-wait") o.goWait = Number(v), i++;
|
|
195
198
|
else if (a === "--timeout") o.timeout = Number(v), i++;
|
|
196
199
|
else if (a === "--log") o.log = v, i++;
|
|
200
|
+
else if (a === "--model") o.model = v, i++;
|
|
197
201
|
else if (a === "--cmd") o.cmd = v, i++;
|
|
198
202
|
else if (a === "--consume") o.consume = true;
|
|
199
203
|
else if (a === "--no-consume") o.consume = false;
|
|
200
204
|
else throw new Error(`unknown argument ${a}`);
|
|
201
205
|
}
|
|
202
|
-
if (!o.adapter || !o.name || !o.prompt) throw new Error("usage: aos-acp <claude|codex|opencode> --name <worker> --prompt <text> [--cwd <dir>] [--allow-default deny|allow] [--go-wait <sec>] [--no-consume] [--log <file>] [--timeout <sec>]");
|
|
206
|
+
if (!o.adapter || !o.name || !o.prompt) throw new Error("usage: aos-acp <claude|codex|opencode> --name <worker> --prompt <text> [--cwd <dir>] [--allow-default deny|allow] [--go-wait <sec>] [--no-consume] [--model <id>] [--log <file>] [--timeout <sec>]");
|
|
203
207
|
if (!["deny", "allow"].includes(o.allowDefault)) throw new Error("--allow-default must be deny or allow");
|
|
204
208
|
return o;
|
|
205
209
|
}
|
package/bin/aos-doctor.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
10
|
import { connect } from 'node:net';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
11
12
|
import os from 'node:os';
|
|
12
13
|
import path from 'node:path';
|
|
13
14
|
|
|
@@ -300,6 +301,20 @@ async function checkDaemonsAndModules() {
|
|
|
300
301
|
? 'Enable AO in the installer or run: ao service install'
|
|
301
302
|
: 'AO binary is arm64 macOS native; build from source on other platforms: github.com/hybridlabor-api/bdb-agent-orchestrator', false);
|
|
302
303
|
|
|
304
|
+
// Installed ao vs a local AO checkout's HEAD, read from the binary's embedded build info.
|
|
305
|
+
const aoCheckout = firstExisting([h('dev', 'agents', 'bdb-agent-orchestrator'), h('dev', 'bdb-dev', 'bdb-agent-orchestrator')]);
|
|
306
|
+
if (aoBin && aoCheckout && existsSync(path.join(aoCheckout, '.git'))) {
|
|
307
|
+
const { readGoBuildInfo } = createRequire(import.meta.url)('../installer.js');
|
|
308
|
+
const build = readGoBuildInfo(aoBin.replace(/^~/, HOME));
|
|
309
|
+
let head = null;
|
|
310
|
+
try { head = execFileSync('git', ['--no-optional-locks', '-C', aoCheckout, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch {}
|
|
311
|
+
const same = !!(build && head && build.revision === head && !build.modified);
|
|
312
|
+
add('daemons', 'AO revision vs checkout', same,
|
|
313
|
+
!build ? 'Installed ao has no readable build revision'
|
|
314
|
+
: `Installed ${build.revision.slice(0, 9)}${build.modified ? ' (dirty build)' : ''} · checkout HEAD ${head ? head.slice(0, 9) : 'unknown'}`,
|
|
315
|
+
`Rebuild from a clean checkout: cd ${tilde(path.join(aoCheckout, 'backend'))} && go build -ldflags='-s -w' -o ~/.local/bin/ao.new ./cmd/ao && mv ~/.local/bin/ao.new ~/.local/bin/ao && ao service install`, true);
|
|
316
|
+
}
|
|
317
|
+
|
|
303
318
|
// Code-signing check for AO on macOS
|
|
304
319
|
if (IS_MAC && aoBin) {
|
|
305
320
|
let signed = false;
|
|
@@ -318,15 +333,15 @@ function printAoAnnouncement() {
|
|
|
318
333
|
console.log(`
|
|
319
334
|
╭──────────────────────────────────────────────────────────────────────────────╮
|
|
320
335
|
│ │
|
|
321
|
-
│
|
|
336
|
+
│ BDB AGENT ORCHESTRATOR APP — FINAL BETA NOW AVAILABLE │
|
|
322
337
|
│ │
|
|
323
|
-
│
|
|
324
|
-
│
|
|
338
|
+
│ The next generation of cross-harness multi-agent orchestration │
|
|
339
|
+
│ is now open to every user as a final beta. │
|
|
325
340
|
│ │
|
|
326
341
|
│ • Dashboard & WebUI: http://localhost:3101 │
|
|
327
|
-
│ • Service
|
|
328
|
-
│ • Quick
|
|
329
|
-
│ • Features:
|
|
342
|
+
│ • Service command: ao service install (enables the background service) │
|
|
343
|
+
│ • Quick launch: ao open or ao service status │
|
|
344
|
+
│ • Features: session telemetry, live AgentTrail, multi-workspaces │
|
|
330
345
|
│ │
|
|
331
346
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
332
347
|
`);
|
|
@@ -49,3 +49,18 @@ Double gate: opencode and claude workers also run their own AOS gate inside the
|
|
|
49
49
|
- OpenCode token path end to end needs the installed plugin updated; verified by unit test only.
|
|
50
50
|
- Neither live adapter sent `session/request_permission` for a shell command in the smoke runs (the inner gate answered first); the mapping is covered by the fake-agent tests.
|
|
51
51
|
- One `aos-acp` process per worker; no resume (`session/load`) and no concurrency cap yet.
|
|
52
|
+
|
|
53
|
+
## Bus (spike): messaging a hand-started OpenCode session
|
|
54
|
+
|
|
55
|
+
A master can drop a message into a running OpenCode session (no `--port`, no restart). OpenCode only.
|
|
56
|
+
|
|
57
|
+
- **Paths:** registry `~/.aos/bus/sessions/<name>.json` (0600), inbox `~/.aos/bus/inbox/<name>/` (0700), one `<ms>-<rand>.json` per message. `<name>` is `slug(AOS_SESSION_NAME)`, else the OpenCode session id. The inbox path is always derived from the name, never from the registry file.
|
|
58
|
+
- **CLI:** `aos-bus send <name> <text...> [--from x] [--wake]`, `aos-bus list`. Exit 2 with a reason on refusal (unregistered or dead pid, inbox not a private real dir owned by you, text over 8 KiB).
|
|
59
|
+
- **Injection:** the plugin polls the inbox every 1 s and calls `client.session.prompt` with `noReply: true` (`false` with `--wake`) and one text part `[aos-bus from <from>] <text>`, `synthetic: true`, `metadata.aos_bus`. The API has no non-user message, so the stored row is `role: user`; exclusion from GO rests on the part flag.
|
|
60
|
+
- **GO exclusion (3 layers):** `synthetic: true` (existing `!synthetic` filters in `chat.message`, the `session.messages` fallback and `opencodeLastUser`); the `[aos-bus from ...]` prefix, so the text can never be `GO` or `GO <name>`; the plugin clears its cached prompt before each injection and returns early from `chat.message` for all-synthetic messages.
|
|
61
|
+
- **Fail-closed side effect:** a bus message becomes the newest user row, so the last human `GO` no longer opens that session's gate, and a pending OpenCode-issued `GO <worker>` token for it is invalidated. The human re-types GO.
|
|
62
|
+
- **Delivery rules:** only the registered owner (pid + session) polls. Each file is claimed by renaming `<x>.json` to `<x>.inflight` before the prompt, so a message is delivered by at most one process; a failed claim means someone else has it. Success deletes the claim. A busy session gets the file renamed back to `.json` and retried every tick for up to 10 min counted from the first busy attempt (`AOS_BUS_BUSY_MAX_MS`), then `.failed`. A prompt not finished within 10 min (`AOS_BUS_PROMPT_TIMEOUT_MS`) is parked as `.timeout`: the prompt is not aborted, so delivery state is unknown and it must not be resent blindly. Any other error parks the file as `.failed` (not delivered).
|
|
63
|
+
- **Smoke:** `tests/aos-bus-smoke.sh` (opt-in, isolated HOME/XDG, `opencode serve`).
|
|
64
|
+
- **[verified]** `synthetic: true`, prefix and `metadata.aos_bus` persist in `opencode.db`; no token file; `tokenGrantsGo` rejects a token pointing at the bus message.
|
|
65
|
+
- **[open]** TUI mode (toast, plugin client without `--port`), `--wake` (needs model credentials), real busy-session behaviour.
|
|
66
|
+
- **Out of scope:** Claude Code receivers (native `SendMessage`), Codex/agy, AO daemon, slash commands, agenttrail roster, replies/acks/expiry, `fs.watch`, authenticating `from` beyond file-owner uid.
|
|
@@ -105,6 +105,8 @@ Additionally the parser at `installer.js:1857` uses `block.match(/([A-Za-z0-9_]+
|
|
|
105
105
|
The same ruleset exists in `CLAUDE.md`, `GEMINI.md`, `agent.md`, `.codex-plugin/system.md`, `.cursor/rules/000_global_rules.mdc`, and (partially) `.roomodes`. `GEMINI.md` is missing two clauses that `CLAUDE.md` has ("Plans are not approval", "No inheritance, no silent retries"). `CODEX.md` contains no rules at all. There is no single source of truth; the installer regenerates *some* targets from `agent.md` but the root `CLAUDE.md`/`GEMINI.md` are separately maintained.
|
|
106
106
|
|
|
107
107
|
### F-07 — No `disable-model-invocation`, no argument contract *(P1)*
|
|
108
|
+
|
|
109
|
+
> **Superseded (2026-10).** The flag was later set on pipeline and role skills and playbooks, which stopped the Master and every orchestrator from starting `/startcycle` through the Skill tool. It was removed again; go-gate guards the side effects and `validate-skills` (E-DMI01, E-PB06) keeps pipeline skills model-invocable. Kept only on `bdb-updater` and `triage`.
|
|
108
110
|
B1: *"Use `disable-model-invocation: true` for workflows with side effects that you want to trigger manually."*
|
|
109
111
|
|
|
110
112
|
`/startcycle`, `/ship`, `/build` and `bdbsaastraining` all have side effects (git commit, git push, npm publish, cloud calls). **0 of 134** skills set `disable-model-invocation`. Only 2 skills use `$ARGUMENTS`. Frontmatter census:
|