@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.
Files changed (51) hide show
  1. package/.claude/hooks/aos-bus.mjs +146 -0
  2. package/.claude/hooks/go-gate.mjs +2 -1
  3. package/.opencode/plugins/bdb-aos.js +111 -5
  4. package/bin/aos-acp.mjs +7 -3
  5. package/bin/aos-doctor.mjs +21 -6
  6. package/docs/master-session-acp.md +15 -0
  7. package/docs/sessions/audit-agents.md +2 -0
  8. package/installer.js +341 -163
  9. package/package.json +3 -2
  10. package/scripts/validate-skills.mjs +29 -6
  11. package/skills/basic/godmode-shipping/SKILL.md +0 -1
  12. package/skills/basic/master-session/SKILL.md +1 -1
  13. package/skills/basic/startcycle/SKILL.md +0 -1
  14. package/skills/basic/startcycle-graph/SKILL.md +0 -1
  15. package/skills/global_config/ask-tim/SKILL.md +0 -1
  16. package/skills/global_config/grill-me/SKILL.md +0 -1
  17. package/skills/global_config/grill-with-docs/SKILL.md +0 -1
  18. package/skills/playbooks/pb-bug-fix/SKILL.md +48 -0
  19. package/skills/playbooks/pb-ci-fix/SKILL.md +0 -1
  20. package/skills/playbooks/pb-clip-from-moodboard/SKILL.md +55 -0
  21. package/skills/playbooks/pb-crew-call-sheet/SKILL.md +40 -0
  22. package/skills/playbooks/pb-deploy-saas/SKILL.md +44 -0
  23. package/skills/playbooks/pb-docs-site/SKILL.md +44 -0
  24. package/skills/playbooks/pb-event-tracker/SKILL.md +0 -1
  25. package/skills/playbooks/pb-focus-chunks/SKILL.md +40 -0
  26. package/skills/playbooks/pb-handover/SKILL.md +41 -0
  27. package/skills/playbooks/pb-harness-work/SKILL.md +64 -0
  28. package/skills/playbooks/pb-health-weekly/SKILL.md +43 -0
  29. package/skills/playbooks/pb-idea-to-launch/SKILL.md +51 -0
  30. package/skills/playbooks/pb-image-to-3d/SKILL.md +46 -0
  31. package/skills/playbooks/pb-inbox-zero/SKILL.md +39 -0
  32. package/skills/playbooks/pb-invoice-check/SKILL.md +41 -0
  33. package/skills/playbooks/pb-landing-page/SKILL.md +46 -0
  34. package/skills/playbooks/pb-launch-video/SKILL.md +49 -0
  35. package/skills/playbooks/pb-machine-setup/SKILL.md +44 -0
  36. package/skills/playbooks/pb-master/SKILL.md +53 -0
  37. package/skills/playbooks/pb-meeting-actions/SKILL.md +0 -1
  38. package/skills/playbooks/pb-newsletter/SKILL.md +43 -0
  39. package/skills/playbooks/pb-offer/SKILL.md +39 -0
  40. package/skills/playbooks/pb-open-source/SKILL.md +50 -0
  41. package/skills/playbooks/pb-pcb-to-case/SKILL.md +46 -0
  42. package/skills/playbooks/pb-project-new/SKILL.md +0 -1
  43. package/skills/playbooks/pb-redesign-app/SKILL.md +47 -0
  44. package/skills/playbooks/pb-release-aos/SKILL.md +50 -0
  45. package/skills/playbooks/pb-security-sweep/SKILL.md +48 -0
  46. package/skills/playbooks/pb-ship/SKILL.md +49 -0
  47. package/skills/playbooks/pb-show-build/SKILL.md +48 -0
  48. package/skills/playbooks/pb-social-pack/SKILL.md +47 -0
  49. package/skills/playbooks/pb-todo/SKILL.md +41 -0
  50. package/skills/playbooks/pb-week-plan/SKILL.md +0 -1
  51. 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
- .filter((p) => p?.type === "text" && !p.synthetic && typeof p.text === "string")
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((p) => p && p.type === 'text' && typeof p.text === 'string' && !p.synthetic);
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((p) => p.type === 'text' && !p.synthetic).map((p) => p.text).join('\n').trim();
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
  }
@@ -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
- │ 🚀 BDB AGENT ORCHESTRATOR APP — FINALE BETA JETZT VERFÜGBAR! │
336
+ │ BDB AGENT ORCHESTRATOR APP — FINAL BETA NOW AVAILABLE │
322
337
  │ │
323
- │ Die nächste Generation der Cross-Harness Multi-Agenten-Orchestrierung │
324
- │ ist jetzt als finale Beta für alle User freigeschaltet. │
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-Befehl: ao service install (Hintergrunddienst aktivieren) │
328
- │ • Quick Launch: ao open oder ao service status │
329
- │ • Features: Session-Telemetrie, Live-AgentTrail & Multi-Workspaces│
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: