Compare commits

..
Author SHA1 Message Date
Frank 38bf9c4897 docs: record omp subagent fan-out caps and per-endpoint in-flight ceilings
Codifies the limits set in agent/config.yml 2026-10-19: task concurrency 2,
no nested subagent spawns (maxRecursionDepth 1 - and why 0 must never be
set), 30-min runtime / 120-request budgets, isolation opt-in enabled,
completion probes off, 600s tool timeout ceiling, and per-provider
in-flight caps (archon 3, dragoon 2, zealot 3) keyed by models.yml provider
ids with their per-machine scope caveat. Small docs-only change; no issue
opened per trunk-based small-fix exemption.
2026-10-19 00:22:17 -07:00
6 changed files with 167 additions and 329 deletions
+43 -206
View File
@@ -7,35 +7,28 @@
* agent/models.yml) could only ever produce files. This extension adds the * agent/models.yml) could only ever produce files. This extension adds the
* missing path — the TUI speaks with the cloned voices: * missing path — the TUI speaks with the cloned voices:
* *
* /say open the options menu
* /say <text> speak once through the speakers * /say <text> speak once through the speakers
* /say on | off arm/disarm auto-speak of the assistant's final answer * /say on | off auto-speak the assistant's final answer each turn
* /say menu the options menu (voice, output device, player, cap)
* /say voice <name> choose a voice (empty = whatever the server defaults to) * /say voice <name> choose a voice (empty = whatever the server defaults to)
* /say voices list the voices frank-tts reports * /say voices list the voices frank-tts reports
* /say stop cut off the current utterance * /say stop cut off the current utterance
* /say status voice, endpoint, player and device in use * /say status voice, endpoint and player in use
* /say help the command list
* *
* The endpoint comes from agent/models.yml — the provider block carrying * The endpoint comes from agent/models.yml — the provider block carrying
* `api: openai-speech`, i.e. the same row the speech role resolves — so this * `api: openai-speech`, i.e. the same row the speech role resolves — so this
* file carries no host address and no secret. Menu choices persist to * file carries no host address and no secret. Environment overrides:
* ~/.omp/cache/dankvoice.json (runtime state, never tracked). Environment
* overrides win over the file, which wins over defaults:
* *
* DANKVOICE_URL speech base URL (…/v1) instead of models.yml * DANKVOICE_URL speech base URL (…/v1) instead of models.yml
* DANKVOICE_MODEL served model id (default: the id in that block) * DANKVOICE_MODEL served model id (default: the id in that block)
* DANKVOICE_CONFIG settings file (default ~/.omp/cache/dankvoice.json) * DANKVOICE_VOICE default voice name
* DANKVOICE_PLAYER player binary, skipping detection
* DANKVOICE_VOICE voice name ("" = server default)
* DANKVOICE_MAX spoken-character cap per utterance (default 700)
* DANKVOICE_AUTO=1 start with auto-speak armed * DANKVOICE_AUTO=1 start with auto-speak armed
* DANKVOICE_MAX spoken-character cap per utterance (default 700)
* DANKVOICE_PLAYER player binary, skipping detection
* DANKVOICE_MODELS path to models.yml (default ~/.omp/agent/models.yml)
* *
* Utterances are synthesized to a temp WAV in $TMPDIR; the file is removed * Utterances are synthesized to a temp WAV in $TMPDIR; the file is removed
* when its player process exits. A new utterance interrupts the previous one * when its player process exits. A new utterance interrupts the previous one
* instead of queueing behind it. Output routing: a saved sink becomes * instead of queueing behind it.
* `pw-play --target <sink>` or `paplay --device=<sink>` (ffplay has no device
* concept and ignores it); with no saved sink the player's own default applies.
*/ */
import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { spawn, spawnSync, type ChildProcess } from "node:child_process";
@@ -69,7 +62,6 @@ export interface Player {
} }
export type SayAction = export type SayAction =
| { kind: "menu" }
| { kind: "help" } | { kind: "help" }
| { kind: "speak"; text: string } | { kind: "speak"; text: string }
| { kind: "on" } | { kind: "on" }
@@ -83,8 +75,7 @@ export type SayAction =
export function parseSayArgs(raw: string): SayAction { export function parseSayArgs(raw: string): SayAction {
const args = raw.trim(); const args = raw.trim();
const lower = args.toLowerCase(); const lower = args.toLowerCase();
if (lower === "" || lower === "menu") return { kind: "menu" }; if (lower === "" || lower === "help") return { kind: "help" };
if (lower === "help") return { kind: "help" };
if (lower === "on") return { kind: "on" }; if (lower === "on") return { kind: "on" };
if (lower === "off") return { kind: "off" }; if (lower === "off") return { kind: "off" };
if (lower === "stop") return { kind: "stop" }; if (lower === "stop") return { kind: "stop" };
@@ -171,18 +162,17 @@ export function parseVoices(payload: unknown): string[] {
return names.filter((name) => name.length > 0); return names.filter((name) => name.length > 0);
} }
/** Known players, PipeWire first (the workstations run WirePlumber); first existing wins. */ /** Player preference: PipeWire first (this fleet runs WirePlumber), then Pulse, then ffmpeg. */
export function availablePlayers(lookup: (command: string) => string | null): string[] { export function detectPlayer(lookup: (command: string) => string | null): Player | null {
return ["pw-play", "paplay", "ffplay"].filter((command) => lookup(command) !== null); const candidates: Player[] = [
{ command: "pw-play", args: [] },
{ command: "paplay", args: [] },
{ command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] },
];
for (const candidate of candidates) {
if (lookup(candidate.command)) return candidate;
} }
return null;
/** One player invocation: the saved sink becomes each player's routing dialect. */
export function playerFor(command: string, sink: string): Player {
const base = command.split("/").pop() ?? command;
if (base === "ffplay") return { command, args: ["-nodisp", "-autoexit", "-loglevel", "quiet"] };
if (!sink) return { command, args: [] };
if (base === "paplay") return { command, args: [`--device=${sink}`] };
return { command, args: ["--target", sink] };
} }
/** What a speech engine should say: prose only, no fences, no markup, bounded. */ /** What a speech engine should say: prose only, no fences, no markup, bounded. */
@@ -227,62 +217,11 @@ export function lastAssistantText(messages: readonly unknown[]): string {
return ""; return "";
} }
/** Menu choices, persisted between sessions; every field optional. */
export interface Settings {
voice?: string;
player?: string;
sink?: string;
maxChars?: number;
auto?: boolean;
}
/** Where menu choices live: DANKVOICE_CONFIG, else ~/.omp/cache/dankvoice.json. */
export function settingsPath(env: NodeJS.ProcessEnv): string {
return env.DANKVOICE_CONFIG?.trim() || join(env.HOME ?? "", ".omp", "cache", "dankvoice.json");
}
export function readSettings(path: string): Settings {
try {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const raw = parsed as Record<string, unknown>;
const settings: Settings = {};
if (typeof raw.voice === "string") settings.voice = raw.voice;
if (typeof raw.player === "string") settings.player = raw.player;
if (typeof raw.sink === "string") settings.sink = raw.sink;
if (typeof raw.maxChars === "number" && Number.isFinite(raw.maxChars) && raw.maxChars > 0) settings.maxChars = Math.floor(raw.maxChars);
if (typeof raw.auto === "boolean") settings.auto = raw.auto;
return settings;
} catch {
return {};
}
}
/** A sink row from `wpctl list audio sinks`: `116<TAB>alsa_output…<TAB>audio/sink<TAB>*`. */
export interface SinkRow {
id: string;
name: string;
}
export function parseSinkList(output: string): SinkRow[] {
const rows: SinkRow[] = [];
for (const line of output.split("\n")) {
const cols = line.split("\t").map((col) => col.trim());
if (cols.length < 3 || !cols[2]?.includes("audio/sink")) continue;
const id = cols[0] ?? "";
const name = cols[1] ?? "";
if (!id || !name) continue;
rows.push({ id, name });
}
return rows;
}
// ------------------------------------------------------------- live wiring // ------------------------------------------------------------- live wiring
interface Config { interface Config {
target?: SpeechTarget; target?: SpeechTarget;
player?: Player; player?: Player;
sink: string;
maxChars: number; maxChars: number;
auto: boolean; auto: boolean;
voice: string; voice: string;
@@ -303,10 +242,8 @@ function readModelsYml(): string {
} }
function loadConfig(): Config { function loadConfig(): Config {
const env = process.env; const overrideUrl = process.env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
const settings = readSettings(settingsPath(env)); const modelOverride = process.env.DANKVOICE_MODEL?.trim();
const overrideUrl = env.DANKVOICE_URL?.trim().replace(/\/+$/, "");
const modelOverride = env.DANKVOICE_MODEL?.trim();
const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(readModelsYml()); const fromYml = overrideUrl ? undefined : resolveSpeechEndpoint(readModelsYml());
const target: SpeechTarget | undefined = overrideUrl const target: SpeechTarget | undefined = overrideUrl
? { ? {
@@ -318,25 +255,17 @@ function loadConfig(): Config {
: fromYml : fromYml
? { ...fromYml, model: modelOverride || fromYml.model } ? { ...fromYml, model: modelOverride || fromYml.model }
: undefined; : undefined;
const playerCommand = env.DANKVOICE_PLAYER?.trim() || settings.player || availablePlayers(lookupOnPath)[0]; const playerOverride = process.env.DANKVOICE_PLAYER?.trim();
const sink = settings.sink ?? ""; const maxChars = Number(process.env.DANKVOICE_MAX);
const maxChars = Number(env.DANKVOICE_MAX);
return { return {
target, target,
player: playerCommand ? playerFor(playerCommand, sink) : undefined, player: playerOverride ? { command: playerOverride, args: [] } : (detectPlayer(lookupOnPath) ?? undefined),
sink, maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : DEFAULT_MAX_CHARS,
maxChars: Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : (settings.maxChars ?? DEFAULT_MAX_CHARS), auto: process.env.DANKVOICE_AUTO === "1",
auto: env.DANKVOICE_AUTO === "1" || (settings.auto ?? false), voice: process.env.DANKVOICE_VOICE?.trim() ?? "",
voice: env.DANKVOICE_VOICE?.trim() ?? settings.voice ?? "",
}; };
} }
/** PipeWire sinks via wpctl; empty when wireplumber is unreachable. */
function listSinks(): SinkRow[] {
const probe = spawnSync("wpctl", ["list", "audio", "sinks"], { encoding: "utf8" });
return probe.status === 0 ? parseSinkList(probe.stdout) : [];
}
/** Synthesize one utterance into a WAV inside `dir`; throws with the endpoint's own message. */ /** Synthesize one utterance into a WAV inside `dir`; throws with the endpoint's own message. */
async function synthesize(target: SpeechTarget, body: Record<string, string>, dir: string, timeoutMs: number): Promise<string> { async function synthesize(target: SpeechTarget, body: Record<string, string>, dir: string, timeoutMs: number): Promise<string> {
const res = await fetch(target.speechUrl, { const res = await fetch(target.speechUrl, {
@@ -422,39 +351,6 @@ export default function dankvoice(pi: ExtensionAPI) {
const missingTarget = () => "dankvoice: no `api: openai-speech` provider in agent/models.yml (set DANKVOICE_URL)"; const missingTarget = () => "dankvoice: no `api: openai-speech` provider in agent/models.yml (set DANKVOICE_URL)";
const persistSettings = (ctx: ExtensionContext) => {
const settings: Settings = {
voice: state.voice,
player: cfg.player?.command ?? "",
sink: cfg.sink,
maxChars: cfg.maxChars,
auto: state.auto,
};
try {
writeFileSync(settingsPath(process.env), `${JSON.stringify(settings, null, 2)}\n`);
} catch (error) {
ctx.ui.notify(`dankvoice: settings not saved: ${error instanceof Error ? error.message : String(error)}`, "warning");
}
};
const setVoice = (ctx: ExtensionContext, name: string) => {
state.voice = name;
reflectStatus();
ctx.ui.notify(name ? `dankvoice: voice = ${name}` : "dankvoice: voice = whatever frank-tts defaults to", "info");
persistSettings(ctx);
};
const setAuto = (ctx: ExtensionContext, on: boolean) => {
state.auto = on;
if (!on) stop();
reflectStatus();
ctx.ui.notify(
on ? `dankvoice: auto-speak armed (${voiceLabel()} @ ${cfg.target?.speechUrl ?? "no endpoint"})` : "dankvoice: auto-speak off",
"info",
);
persistSettings(ctx);
};
async function speak(ctx: ExtensionContext, text: string) { async function speak(ctx: ExtensionContext, text: string) {
const spoken = stripForSpeech(text, cfg.maxChars); const spoken = stripForSpeech(text, cfg.maxChars);
if (!spoken) return; if (!spoken) return;
@@ -482,99 +378,40 @@ export default function dankvoice(pi: ExtensionAPI) {
} }
} }
async function openMenu(ctx: ExtensionContext): Promise<void> {
const options = [
`Auto-speak: ${state.auto ? "on" : "off"} (toggle)`,
`Voice: ${voiceLabel()} (change)`,
`Output device: ${cfg.sink || "system default"} (change)`,
`Player: ${cfg.player?.command ?? "none"} (change)`,
`Spoken cap: ${cfg.maxChars} chars (change)`,
"Speak a test phrase",
];
const choice = await ctx.ui.select("dankvoice — options", options);
if (!choice) return;
if (choice === options[0]) {
setAuto(ctx, !state.auto);
return;
}
if (choice === options[1]) {
if (!cfg.target) {
ctx.ui.notify(missingTarget(), "error");
return;
}
let voices: string[];
try {
voices = await fetchVoices(cfg.target);
} catch (error) {
ctx.ui.notify(`dankvoice: ${error instanceof Error ? error.message : String(error)}`, "error");
return;
}
const picked = await ctx.ui.select("dankvoice — voice", ["server default", ...voices]);
if (picked === undefined) return;
setVoice(ctx, picked === "server default" ? "" : picked);
return;
}
if (choice === options[2]) {
const picked = await ctx.ui.select("dankvoice — output device", ["system default", ...listSinks().map((sink) => sink.name)]);
if (picked === undefined) return;
cfg.sink = picked === "system default" ? "" : picked;
if (cfg.player) cfg.player = playerFor(cfg.player.command, cfg.sink);
ctx.ui.notify(`dankvoice: output device = ${cfg.sink || "system default"}`, "info");
persistSettings(ctx);
return;
}
if (choice === options[3]) {
const choices = [...new Set([cfg.player?.command, ...availablePlayers(lookupOnPath)].filter((c): c is string => Boolean(c)))];
if (choices.length === 0) {
ctx.ui.notify("dankvoice: no audio player found (pw-play, paplay or ffplay)", "error");
return;
}
const picked = await ctx.ui.select("dankvoice — player", choices);
if (picked === undefined) return;
cfg.player = playerFor(picked, cfg.sink);
ctx.ui.notify(`dankvoice: player = ${picked}`, "info");
persistSettings(ctx);
return;
}
if (choice === options[4]) {
const picked = await ctx.ui.select("dankvoice — spoken cap", ["300", "700", "1500", "3000"]);
const next = Number(picked);
if (!Number.isFinite(next) || next <= 0) return;
cfg.maxChars = next;
ctx.ui.notify(`dankvoice: cap = ${next} chars`, "info");
persistSettings(ctx);
return;
}
await speak(ctx, `dankvoice: ${voiceLabel()} through ${cfg.player?.command ?? "no player"}, checking.`);
}
pi.registerCommand("say", { pi.registerCommand("say", {
description: description:
"Speak with the fleet's cloned voices (frank-tts): /say (menu) | <text> | on | off | menu | voice <name> | voices | stop | status", "Speak with the fleet's cloned voices (frank-tts): /say <text> | on | off | voice <name> | voices | stop | status",
handler: async (args: string, ctx) => { handler: async (args: string, ctx) => {
state.lastCtx = ctx; state.lastCtx = ctx;
const action = parseSayArgs(args); const action = parseSayArgs(args);
switch (action.kind) { switch (action.kind) {
case "menu":
await openMenu(ctx);
return;
case "help": case "help":
ctx.ui.notify( ctx.ui.notify(
"dankvoice: /say (menu) · /say <text> · /say on|off (auto-speak answers) · /say voice <name> · /say voices · /say stop · /say status", "dankvoice: /say <text> · /say on|off (auto-speak answers) · /say voice <name> · /say voices · /say stop · /say status",
"info", "info",
); );
return; return;
case "on": case "on":
setAuto(ctx, true); state.auto = true;
reflectStatus();
ctx.ui.notify(`dankvoice: auto-speak armed (${voiceLabel()} @ ${cfg.target?.speechUrl ?? "no endpoint"})`, "info");
return; return;
case "off": case "off":
setAuto(ctx, false); state.auto = false;
stop();
reflectStatus();
ctx.ui.notify("dankvoice: auto-speak off", "info");
return; return;
case "stop": case "stop":
stop(); stop();
return; return;
case "voice": case "voice":
setVoice(ctx, action.name); state.voice = action.name;
reflectStatus();
ctx.ui.notify(
action.name ? `dankvoice: voice = ${action.name}` : "dankvoice: voice = whatever frank-tts defaults to",
"info",
);
return; return;
case "voices": { case "voices": {
if (!cfg.target) { if (!cfg.target) {
@@ -598,7 +435,7 @@ export default function dankvoice(pi: ExtensionAPI) {
} }
case "status": case "status":
ctx.ui.notify( ctx.ui.notify(
`dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · device ${cfg.sink || "system default"} · auto-speak ${state.auto ? "on" : "off"} · cap ${cfg.maxChars} chars`, `dankvoice: voice ${voiceLabel()} · ${cfg.target?.speechUrl ?? "NO ENDPOINT"} · player ${cfg.player ? cfg.player.command : "NONE"} · auto-speak ${state.auto ? "on" : "off"} · cap ${cfg.maxChars} chars`,
"info", "info",
); );
return; return;
+9 -27
View File
@@ -16,10 +16,10 @@ timestamp: 2026-10-18
# dankvoice # dankvoice
`agent/extensions/dankvoice.ts` adds cloned-voice speech to the interactive `agent/extensions/dankvoice.ts` adds cloned-voice speech to the interactive
harness: `/say` opens the options menu, `/say <text>` speaks through the harness: `/say <text>` speaks through the speakers and `/say on` narrates the
speakers and `/say on` narrates the assistant's final answer after every assistant's final answer after every turn, both served by
turn, all served by **frank-tts** (Dankclaw :7772, the `speech` model role **frank-tts** (Dankclaw :7772, the `speech` model role in
in [agent/models.yml](/core/index.md)). [agent/models.yml](/core/index.md)).
## Why it exists ## Why it exists
@@ -39,19 +39,12 @@ local speech and the cloned voices carry the real narration.
| Command | Effect | | Command | Effect |
|---|---| |---|---|
| `/say` \| `/say menu` | options menu (`ctx.ui.select`): auto-speak toggle, voice, output device, player, spoken cap, test phrase |
| `/say <text>` | synthesize once and play; interrupts whatever is playing | | `/say <text>` | synthesize once and play; interrupts whatever is playing |
| `/say on` \| `/say off` | arm/disarm auto-speak of the assistant's final text at `agent_end`; the footer shows `🔊 <voice>` while armed | | `/say on` \| `/say off` | arm/disarm auto-speak of the assistant's final text at `agent_end`; the footer shows `🔊 <voice>` while armed |
| `/say voice <name>` | pick a voice; an empty name defers to the server's own default | | `/say voice <name>` | pick a voice; an empty name defers to the server's own default |
| `/say voices` | list what frank-tts reports | | `/say voices` | list what frank-tts reports |
| `/say stop` | cut the current utterance | | `/say stop` | cut the current utterance |
| `/say status` \| `/say help` | endpoint, voice, player, device, cap | | `/say status` \| `/say help` | endpoint, voice, player, cap |
Menu choices persist to `~/.omp/cache/dankvoice.json` (runtime state, never
tracked); the environment overrides the file, the file overrides defaults.
"Speak a test phrase" synthesizes a short line through the current voice,
player and device — the audible proof whenever a session "has say on" but
stays silent.
Auto-speak is skipped in `print`/`json` mode so benches and pipelines stay Auto-speak is skipped in `print`/`json` mode so benches and pipelines stay
silent. Markdown is stripped before synthesis — fenced code is dropped, links silent. Markdown is stripped before synthesis — fenced code is dropped, links
@@ -70,21 +63,16 @@ resolves, so no host address and no credential appears in this file. From
|---|---|---| |---|---|---|
| `DANKVOICE_URL` | models.yml | speech base URL (`…/v1`), bypasses the YAML read | | `DANKVOICE_URL` | models.yml | speech base URL (`…/v1`), bypasses the YAML read |
| `DANKVOICE_MODEL` | the id in that block (`frank-tts`) | model named in the request body | | `DANKVOICE_MODEL` | the id in that block (`frank-tts`) | model named in the request body |
| `DANKVOICE_CONFIG` | `~/.omp/cache/dankvoice.json` | persisted menu choices (voice, player, sink, cap, auto) |
| `DANKVOICE_VOICE` | empty (server default) | default voice name | | `DANKVOICE_VOICE` | empty (server default) | default voice name |
| `DANKVOICE_AUTO=1` | off | start with auto-speak armed | | `DANKVOICE_AUTO=1` | off | start with auto-speak armed |
| `DANKVOICE_MAX` | `700` | spoken characters per utterance | | `DANKVOICE_MAX` | `700` | spoken characters per utterance |
| `DANKVOICE_PLAYER` | detect | player binary, skipping detection | | `DANKVOICE_PLAYER` | detect | player binary, skipping detection |
| `DANKVOICE_MODELS` | `~/.omp/agent/models.yml` | YAML to read the endpoint from | | `DANKVOICE_MODELS` | `~/.omp/agent/models.yml` | YAML to read the endpoint from |
Player detection order is `pw-play` → `paplay` → `ffplay` (PipeWire first: Player detection order is `pw-play` → `paplay` → `ffplay -nodisp -autoexit`
the workstations run WirePlumber). A saved output device routes the player (PipeWire first: the workstations run WirePlumber). Each utterance is written
explicitly — `pw-play --target <sink>` or `paplay --device=<sink>`; ffplay to `$TMPDIR/dankvoice-*/say-<ms>.wav` and the file plus its directory are
has no device concept and ignores it — while an unset device leaves the removed when the player process exits.
player's own default. Sinks are enumerated with `wpctl list audio sinks`;
if wireplumber is unreachable the menu offers only "system default". Each
utterance is written to `$TMPDIR/dankvoice-*/say-<ms>.wav` and the file plus
its directory are removed when the player process exits.
## Verified facts (2026-10-18) ## Verified facts (2026-10-18)
@@ -113,11 +101,6 @@ its directory are removed when the player process exits.
(1.20 s WAV), and an unknown voice name answers with the roster instead of (1.20 s WAV), and an unknown voice name answers with the roster instead of
speaking. An `agent_end` carrying `willContinue` (the harness scheduled an speaking. An `agent_end` carrying `willContinue` (the harness scheduled an
auto-retry) is never narrated — only the terminal settle speaks. auto-retry) is never narrated — only the terminal settle speaks.
* **Menu** — verified via driver scenarios 2026-10-18 (Refs #23): the menu
lists auto-speak/voice/device/player/cap/test-phrase; a voice chosen in the
menu is used by later utterances and survives a fresh load via the settings
file; a device chosen in the menu appears in the player invocation as
`--target <sink>`.
## Limits ## Limits
@@ -142,7 +125,6 @@ its directory are removed when the player process exits.
`@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts` `@earendil-works/pi-coding-agent` `dist/core/extensions/types.d.ts`
(`ExtensionAPI.on/registerCommand`, `ExtensionMode = "tui" | "rpc" | "json" | "print"`). (`ExtensionAPI.on/registerCommand`, `ExtensionMode = "tui" | "rpc" | "json" | "print"`).
* Gitea issue #21 on `dank/command-center` (the work item this page ships with). * Gitea issue #21 on `dank/command-center` (the work item this page ships with).
* Gitea issue #23 on `dank/command-center` (the options-menu work item).
## See Also ## See Also
-15
View File
@@ -11,21 +11,6 @@ timestamp: 2026-08-28
# Bundle Change Log # Bundle Change Log
## 2026-10-18 — dankvoice: `/say` options menu with output-device routing (Refs #23)
Added `/say menu` (bare `/say` now opens it; `help` keeps the text listing), the
pi-peon-style settings surface: auto-speak toggle, voice picked from the frank-tts
roster, **output device** (PipeWire sinks via `wpctl` → `pw-play --target` /
`paplay --device=`), player binary, spoken-char cap, and a "speak a test phrase"
utterance that proves the audio path audibly. Choices persist to
`~/.omp/cache/dankvoice.json` (`DANKVOICE_CONFIG`); env now wins over that file,
which wins over defaults — answering the silent-session incident where
PipeWire's remembered Bluetooth route played synthesized answers into nowhere.
`detectPlayer` becomes `availablePlayers` + `playerFor`. Tests gain a fixture
`wpctl`, a scripted `select` in the driver, and three menu scenarios: 15 green
in `tests/dankvoice.bats`, 51 green suite-wide.
## 2026-10-18 — dankvoice: the cloned voices finally speak in the TUI (Refs #21) ## 2026-10-18 — dankvoice: the cloned voices finally speak in the TUI (Refs #21)
Nothing in the harness could pronounce them: the TUI vocalizer and `omp say` gate `--voice` against the bundled Kokoro enum (`omp say --voice narrator1 "test"` → `error: Expected --voice to be one of: af_heart, …`), so frank-tts was reachable only as a file. Added `agent/extensions/dankvoice.ts` — `/say <text>`, `/say on|off` (narrate the final answer at `agent_end`), `/say voice|voices|stop|status` — synthesizing over the OpenAI speech route and playing through `pw-play`/`paplay`/`ffplay`, with the endpoint read from the `api: openai-speech` row of `agent/models.yml` (no host, no secret in the file). Verified live in a real TUI session on the workstation: roster `GET /v1/audio/voices` → `narrator1, narrator2, narrator3`; `/say` produced 99,884 B / 2.08 s and 69,164 B / 1.44 s WAVs handed to the player, temp dirs cleaned afterwards; `/say on` armed the footer (`🔊 narrator1`) and each following finished turn spoke its answer (1.20 s WAV), an unknown voice name answered with the roster instead of speaking, and an `agent_end` carrying `willContinue` (the harness scheduled an auto-retry) is deliberately never narrated. Extension discovery itself proved with a probe file in `~/.omp/agent/extensions/` loaded by a fresh `omp -p` run. Reference page [dankvoice](core/dankvoice.md), registered in the core index; black-box seam `tests/dankvoice.bats` (12 tests). Nothing in the harness could pronounce them: the TUI vocalizer and `omp say` gate `--voice` against the bundled Kokoro enum (`omp say --voice narrator1 "test"` → `error: Expected --voice to be one of: af_heart, …`), so frank-tts was reachable only as a file. Added `agent/extensions/dankvoice.ts` — `/say <text>`, `/say on|off` (narrate the final answer at `agent_end`), `/say voice|voices|stop|status` — synthesizing over the OpenAI speech route and playing through `pw-play`/`paplay`/`ffplay`, with the endpoint read from the `api: openai-speech` row of `agent/models.yml` (no host, no secret in the file). Verified live in a real TUI session on the workstation: roster `GET /v1/audio/voices` → `narrator1, narrator2, narrator3`; `/say` produced 99,884 B / 2.08 s and 69,164 B / 1.44 s WAVs handed to the player, temp dirs cleaned afterwards; `/say on` armed the footer (`🔊 narrator1`) and each following finished turn spoke its answer (1.20 s WAV), an unknown voice name answered with the roster instead of speaking, and an `agent_end` carrying `willContinue` (the harness scheduled an auto-retry) is deliberately never narrated. Extension discovery itself proved with a probe file in `~/.omp/agent/extensions/` loaded by a fresh `omp -p` run. Reference page [dankvoice](core/dankvoice.md), registered in the core index; black-box seam `tests/dankvoice.bats` (12 tests).
+2 -1
View File
@@ -28,7 +28,8 @@
## Subagent Architecture ## Subagent Architecture
- [Pi Subagents](/pi/packages/pi-subagents.md) — Core subagent delegation system. Chains, parallel execution, TUI clarification. - [OMP Subagent Limits & Fan-out Policy](/pi/subagent-limits.md) — Live caps: concurrency 2, no nested spawns (and the `maxRecursionDepth: 0` trap), per-endpoint in-flight ceilings, isolation policy.
- [Pi Subagents](/pi/packages/pi-subagents.md) — (historical) pi-era delegation system. Chains, parallel execution, TUI clarification.
## Architecture ## Architecture
+97
View File
@@ -0,0 +1,97 @@
---
type: reference
title: OMP Subagent Limits & Fan-out Policy
description: The live caps on omp subagents — concurrency 2, no nested spawns, per-endpoint in-flight ceilings, 30-min runtime, soft request budget, and the isolated-workspace policy; includes the maxRecursionDepth=0 trap.
tags:
- omp
- subagents
- config
- fleet
timestamp: 2026-10-19
---
# OMP Subagent Limits & Fan-out Policy
Enforced caps on subagents spawned through the `task` tool / eval `agent()`+`workpool()`
from this workstation (config root `agent/config.yml`, machine-live tier — the live-sync
heartbeat commits it). Set 2026-10-19; values verified against the live config and the
enforcement code in the installed bundle (`@oh-my-pi/pi-coding-agent/dist/cli.js`).
## The caps in force
| Key | Value | Default | Effect |
|---|---|---|---|
| `task.maxConcurrency` | 2 | 32 | Session-scoped semaphore; excess spawns queue, never drop. |
| `task.maxRecursionDepth` | 1 | 2 | Subagents cannot spawn their own subagents. See depth semantics. |
| `task.maxRuntimeMs` | 1800000 | 0 (off) | Hard 30-min wall clock per subagent; overrun = `aborted`. |
| `task.softRequestBudget` | 120 | 200 | Wrap-up notice at 120 assistant requests; force-stopped to yield at 180 (1.5×). |
| `task.agentIdleTtlMs` | 420000 | same | Finished subagents parked to disk after 7 min idle (revivable via `agent://`). |
| `task.enableEffort` | false | false | Per-spawn `effort` hint hidden from the schema. |
| `task.isolation.enabled` | true | false | `isolated: true` spawns run in a copy of the checkout; only successful patches land (merge=patch, apply). Opt-in per spawn. |
| `task.completionProbe` | false | true | No periodic completeness side-requests into running subagents. |
| `tools.maxTimeout` | 600 | 0 (off) | No agent may request a tool timeout above 10 min; kills `timeout: 0` runaways. |
| `providers.maxInFlightRequests` | see below | {} (unlimited) | Per-provider ceiling on concurrent LLM requests, shared across local omp processes. |
## Depth semantics — never set `maxRecursionDepth: 0`
Bundle predicate (`U_`): a session at `taskDepth` *s* keeps the `task` tool iff
`n < 0 || s < n` where `n = maxRecursionDepth`. The preflight repeats the check
(`Cannot spawn another agent at task depth s; maximum depth is n`).
- `n = 1` → main (depth 0) spawns; children (depth 1) have `task` stripped. **This is
"no nested subagents".**
- `n = 0` → `0 < 0` fails → **not even the main session can spawn anything.** Don't.
- `n < 0` → cap disabled.
## Per-endpoint in-flight ceilings
```yaml
providers:
maxInFlightRequests:
"llamacpp (dankserver)": 3 # main session + up to 2 task/reviewer subagents
"llamacpp (dankclaw)": 2 # frank-dragoon: security-reviewer + fallbacks
"llamacpp (dankclaw-3090)": 3 # frank-zealot: scout/sonic/librarian/designer + smol/tiny/plan/commit/vision roles
```
- Keys are **provider ids** = the quoted block labels in `agent/models.yml`
(one provider per host:port). The consumer does a flat map lookup by provider id;
absent or ≤0 = unlimited, otherwise clamped to ≥1.
- Scope is **per machine, per config root**: it bounds what *this workstation's* omp
processes put on each endpoint. Other workstations, OpenWebUI, and any non-omp
client are not counted. Requests past the ceiling queue, never fail.
- `web/searxng` intentionally uncapped.
## Isolation note
`task.isolation.enabled: true` makes the per-spawn `isolated` flag available — it is
**opt-in per spawn**, not a blanket sandbox. Requires a git repo; unavailable in plan
mode; baseline snapshot capped at 1 GiB; backends resolve via `isolation.backend: auto`
(overlayfs/reflink/rcopy chain on this box). Given *the checkout is the deployment*,
prefer `isolated: true` for any subagent that edits tracked files.
## Deliberately not set
- `bash.gitGuard` — offered 2026-10-19, declined; destructive-git protection stays opt-in.
- `task.disabledAgents` — no bundled agent type warrants a ban.
- `task.agentAccountPools`, `tier.subagent` — no-op against keyless local endpoints.
- `PI_TASK_MAX_OUTPUT_BYTES` / `PI_TASK_MAX_OUTPUT_LINES` — 500 KB / 5000 line defaults are sane.
- `before_subagent_spawn` hook — programmable spawn policy; revisit if these caps prove insufficient.
## Verification
`cfg://task`, `cfg://providers/maxInFlightRequests` reflect all rows above; persisted
copies in `agent/config.yml`. Depth semantics confirmed against the shipped bundle, not
just the setting description.
# Citations
- `agent/config.yml` (live config, this repo)
- `agent/models.yml` — provider id block labels
- `@oh-my-pi/pi-coding-agent/dist/cli.js` — `U_` depth predicate, in-flight map lookup
- omp docs: `omp://tools/task.md` § Limits & Caps
# See Also
- [/pi/statusline.md](/pi/statusline.md) — the omp-era bundle-audit page pattern this page follows
- [/pi/packages/pi-subagents.md](/pi/packages/pi-subagents.md) — retired pi-era subagent system (historical)
- [/system/index.md](/system/index.md) — fleet endpoints these ceilings target
+11 -75
View File
@@ -3,19 +3,15 @@
# #
# The extension's contract, from outside: it registers a `say` command and an # The extension's contract, from outside: it registers a `say` command and an
# `agent_end` listener; what is observable is (a) the JSON it POSTs to the # `agent_end` listener; what is observable is (a) the JSON it POSTs to the
# speech route, (b) the file it hands to the player, and (c) the notifications # speech route, (b) the file it hands to the player, and (c) the notification
# and select-lists it renders. A fixture HTTP server stands in for frank-tts # text it prints. A fixture HTTP server stands in for frank-tts (it answers the
# (it answers the speech route with a fixed WAV, the roster route with a JSON # speech route with a fixed WAV, the roster route with a JSON array, and
# array, and everything else with 404), a stub player stands in for pw-play # everything else with 404) and a stub player stands in for pw-play: it records
# (it records its full argv and copies the .wav it was given), and a fixture # the path it was given and copies the bytes it was told to play.
# `wpctl` in $WORK/bin stands in for wireplumber's sink enumeration.
# #
# Driven by env: DV_EXT (extension under test), DV_STUB / DV_STUB_LOG / DV_COPY # Driven by env: DV_EXT (extension under test), DV_STUB / DV_STUB_LOG / DV_COPY
# (player fixture), DV_PWPLAY (stub aliased as pw-play), DV_WPCTL (the sink # (player fixture), DV_MODELS (models.yml fixture), DV_EMPTY_BIN (a PATH with no
# name the fixture wpctl reports), DV_MODELS (models.yml fixture), # players), DV_MODE (the harness mode reported to the extension).
# DANKVOICE_CONFIG (settings file), DV_PLAYER_BIN (player command override),
# DV_SELECT (pipe-separated answers fed to ctx.ui.select in order), DV_MODE
# (the harness mode reported to the extension).
setup() { setup() {
REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" REPO="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
@@ -30,27 +26,12 @@ setup() {
cat > "$DV_STUB" <<'SH' cat > "$DV_STUB" <<'SH'
#!/usr/bin/env bash #!/usr/bin/env bash
printf '%s\n' "$*" >> "$DV_STUB_LOG" printf '%s\n' "$1" >> "$DV_STUB_LOG"
for arg in "$@"; do case "$arg" in *.wav) cp "$arg" "$DV_COPY" ;; esac; done cp "$1" "$DV_COPY"
sleep 0.1 sleep 0.1
SH SH
chmod +x "$DV_STUB" chmod +x "$DV_STUB"
# the same stub reachable under the canonical player names, plus a fixture
# wpctl so output-device enumeration is deterministic on any machine
mkdir -p "$WORK/bin"
ln -s "$DV_STUB" "$WORK/bin/pw-play"
export DV_PWPLAY="$WORK/bin/pw-play"
export DV_WPCTL="alsa_output.usb-KT.0"
cat > "$WORK/bin/wpctl" <<'WPC'
#!/usr/bin/env bash
printf '57\talsa_output.pci-0000_07_00.6.HiFi__Speaker__sink\taudio/sink\t\n116\t%s\taudio/sink\t*\n' "$DV_WPCTL"
WPC
chmod +x "$WORK/bin/wpctl"
# settings live in an isolated file so menu choices never leak between tests
export DANKVOICE_CONFIG="$WORK/settings.json"
# models.yml fixture: a chat provider first, the speech provider second # models.yml fixture: a chat provider first, the speech provider second
export DV_MODELS="$WORK/models.yml" export DV_MODELS="$WORK/models.yml"
cat > "$DV_MODELS" <<'YML' cat > "$DV_MODELS" <<'YML'
@@ -73,7 +54,6 @@ YML
// it, invoke one scenario, print what the endpoint received and what got played. // it, invoke one scenario, print what the endpoint received and what got played.
import { createServer } from "node:http"; import { createServer } from "node:http";
import { existsSync, readFileSync, statSync } from "node:fs"; import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname } from "node:path";
const scenario = process.argv[2] ?? "speak"; const scenario = process.argv[2] ?? "speak";
const AUDIO = Buffer.from([0x52, 0x49, 0x46, 0x46, 8, 0, 0, 0, 0x57, 0x41, 0x56, 0x45, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); const AUDIO = Buffer.from([0x52, 0x49, 0x46, 0x46, 8, 0, 0, 0, 0x57, 0x41, 0x56, 0x45, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
@@ -108,8 +88,7 @@ if (scenario === "endpoint") {
process.env.DANKVOICE_URL = `http://127.0.0.1:${port}/v1`; process.env.DANKVOICE_URL = `http://127.0.0.1:${port}/v1`;
} }
if (scenario === "noplayer") process.env.PATH = process.env.DV_EMPTY_BIN ?? "/nonexistent-bin"; if (scenario === "noplayer") process.env.PATH = process.env.DV_EMPTY_BIN ?? "/nonexistent-bin";
else process.env.PATH = `${dirname(process.env.DV_STUB ?? ".")}/bin:${process.env.PATH ?? ""}`; process.env.DANKVOICE_PLAYER = scenario === "noplayer" ? "" : process.env.DV_STUB;
process.env.DANKVOICE_PLAYER = scenario === "noplayer" ? "" : process.env.DV_PLAYER_BIN ?? process.env.DV_STUB;
const extension = await import(process.env.DV_EXT as string); const extension = await import(process.env.DV_EXT as string);
const commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>(); const commands = new Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>();
@@ -118,19 +97,11 @@ extension.default({
registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise<void> }) => commands.set(name, options), registerCommand: (name: string, options: { handler: (args: string, ctx: unknown) => Promise<void> }) => commands.set(name, options),
on: (event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) => events.set(event, handler), on: (event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) => events.set(event, handler),
}); });
const answers = (process.env.DV_SELECT ?? "").split("|").filter((answer) => answer !== "");
let answerIndex = 0;
const ctx = { const ctx = {
mode: process.env.DV_MODE ?? "tui", mode: process.env.DV_MODE ?? "tui",
ui: { ui: {
notify: (message: string, level?: string) => console.log(`NOTIFY ${level ?? "info"} ${message}`), notify: (message: string, level?: string) => console.log(`NOTIFY ${level ?? "info"} ${message}`),
setStatus: (key: string, text?: string) => console.log(`STATUS ${key} ${text ?? ""}`), setStatus: (key: string, text?: string) => console.log(`STATUS ${key} ${text ?? ""}`),
select: (title: string, options: string[]) => {
console.log(`SELECT ${title} ${options.join("|")}`);
const answer = answerIndex < answers.length ? answers[answerIndex] : undefined;
answerIndex += 1;
return Promise.resolve(answer ?? options[0]);
},
}, },
}; };
const say = commands.get("say"); const say = commands.get("say");
@@ -150,16 +121,6 @@ if (!say) {
await say.handler("voice narrator2", ctx); await say.handler("voice narrator2", ctx);
await say.handler("good morning", ctx); await say.handler("good morning", ctx);
break; break;
case "menu":
await say.handler("menu", ctx);
break;
case "empty":
await say.handler("", ctx);
break;
case "devicemenu":
await say.handler("menu", ctx);
await say.handler("hello world", ctx);
break;
case "auto": case "auto":
await say.handler("on", ctx); await say.handler("on", ctx);
await agentEnd?.(answer("the answer"), ctx); await agentEnd?.(answer("the answer"), ctx);
@@ -210,7 +171,6 @@ for (const call of received) {
console.log(`CALLS ${received.length}`); console.log(`CALLS ${received.length}`);
const log = process.env.DV_STUB_LOG && existsSync(process.env.DV_STUB_LOG) ? readFileSync(process.env.DV_STUB_LOG, "utf8").trim() : ""; const log = process.env.DV_STUB_LOG && existsSync(process.env.DV_STUB_LOG) ? readFileSync(process.env.DV_STUB_LOG, "utf8").trim() : "";
console.log(`PLAYS ${log === "" ? 0 : log.split("\n").length}`); console.log(`PLAYS ${log === "" ? 0 : log.split("\n").length}`);
if (log !== "") for (const line of log.split("\n")) console.log(`PLAYER ${line}`);
if (process.env.DV_COPY && existsSync(process.env.DV_COPY)) console.log(`COPIED ${statSync(process.env.DV_COPY).size}`); if (process.env.DV_COPY && existsSync(process.env.DV_COPY)) console.log(`COPIED ${statSync(process.env.DV_COPY).size}`);
server.close(); server.close();
TS TS
@@ -223,7 +183,7 @@ teardown() {
} }
say_run() { say_run() {
run env DV_SELECT="${2:-}" bun "$DV_DRIVER" "$1" run bun "$DV_DRIVER" "$1"
} }
@test "the extension registers the say command and an agent_end listener" { @test "the extension registers the say command and an agent_end listener" {
@@ -301,27 +261,3 @@ say_run() {
[[ "$output" == *"input=the settled answer"* ]] [[ "$output" == *"input=the settled answer"* ]]
[[ "$output" != *"input=not yet"* ]] [[ "$output" != *"input=not yet"* ]]
} }
@test "the menu lists every knob and a plain /say opens it" {
say_run menu
[[ "$output" == *"SELECT dankvoice — options Auto-speak: off (toggle)|Voice: server default (change)|Output device: system default (change)|Player: $DV_STUB (change)|Spoken cap: 700 chars (change)|Speak a test phrase"* ]]
[[ "$output" == *"NOTIFY info dankvoice: auto-speak armed (server default @ http://127.0.0.1:"* ]]
say_run empty
[[ "$output" == *"SELECT dankvoice — options"* ]]
}
@test "a voice picked from the menu is spoken by every later request" {
say_run menu "Voice: server default (change)|narrator2"
[[ "$output" == *"SELECT dankvoice — voice server default|narrator1|narrator2"* ]]
[[ "$output" == *"NOTIFY info dankvoice: voice = narrator2"* ]]
say_run speak
[[ "$output" == *"voice=narrator2 input=hello world"* ]]
}
@test "a device chosen from the menu becomes the player's output target" {
DV_PLAYER_BIN="$DV_PWPLAY" say_run menu "Output device: system default (change)|$DV_WPCTL"
[[ "$output" == *"SELECT dankvoice — output device system default|alsa_output.pci-0000_07_00.6.HiFi__Speaker__sink|$DV_WPCTL"* ]]
[[ "$output" == *"NOTIFY info dankvoice: output device = $DV_WPCTL"* ]]
DV_PLAYER_BIN="$DV_PWPLAY" say_run speak
[[ "$output" == *"PLAYER --target $DV_WPCTL"* ]]
}