Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/sound-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ Audio is **off by default**. In a primary Pi terminal, open `/gentle:customize`
- **Audio notifications: on / off** toggles the global switch. Highlighting or rendering the row never discovers a player or enables anything.
- **Audio: unmuted / muted** toggles this process' mute. Mute survives reload and session replacement; restarting Pi clears it. Muting/disabling discards pending events; resuming never replays them.
- **Success / Error / Attention** are the three basic types. Each shows its human selection — `success tone` / `error tone` / `attention tone`, `silence`, a local WAV basename, or `Custom (varies)` when the type's events diverge. **Enter** cycles that type between `silence → builtin:success → builtin:error → builtin:attention → silence`; on a mixed type Enter applies the recommended tone for that type. **`f`** opens an inline field to assign a literal absolute local WAV to that type, independently of the other types; the field is prefilled with the current path when the type already owns a file. **`p`** explicitly previews the type's selected sound, never an arbitrary one.
- **Advanced: show/hide per-event exceptions** reveals the per-event rows (`agent.completed`, `subagent.failed`, …) for one-off overrides. They start folded; expanding or collapsing only changes the card view and never writes configuration or reproduces sound. Each per-event row cycles and assigns its own WAV like a type row. **`f`** and **`p`** on any type or per-event row stay inside the same card; in Notifications `p` never opens the visual profiles pane.
- **Advanced: show/hide per-event exceptions** reveals the per-event rows (`agent.completed`, `context.high`, …) for one-off overrides. They start folded; expanding or collapsing only changes the card view and never writes configuration or reproduces sound. Each per-event row cycles and assigns its own WAV like a type row. **`f`** and **`p`** on any type or per-event row stay inside the same card; in Notifications `p` never opens the visual profiles pane.

**Context warning.** When context usage reaches **90%** of the active model's window, Gentle warns once per high episode. The sound is the `context.high` event: turn **Audio notifications** on, expand **Advanced**, and select its sound. A file saved before `context.high` existed stays silent for it, so select a sound on that Advanced row to hear it; its recommended tone is `builtin:attention`. The visual warning is fully independent: the **Sections → Section contextWarning** row in `/gentle:customize` (shown by default) toggles it, and neither toggle changes the other. The episode rearms when usage drops below 90%, after a successful compaction, or in a new session; a canceled or failed compaction does not rearm, and the unknown reading right after a successful compaction never repeats the warning.

The inline field and the recovery confirmation render inside the same card. **Escape** cancels the field or confirmation; a second Escape closes the card. If the field, value or `y yes` cannot be shown in full (a resize or a very small terminal), Enter or `y` is refused instead of acting on something invisible. Invalid/unreadable configuration disables automatic audio: saving in that state requires a fresh explicit inline confirmation to replace it, cancel preserves the file and settings, and a failed write does not grant consent to the next attempt. Diagnostics are generic local UI notices, not conversation messages, tools, model context or agent state.

Expand All @@ -22,11 +24,12 @@ The inline field and the recovery confirmation render inside the same card. **Es
| Main agent | `agent.completed` | `builtin:success` |
| Main agent | `agent.failed` | `builtin:error` |
| Main agent | `agent.attention` | `builtin:attention` |
| Context | `context.high` | `builtin:attention` |
| Subagent | `subagent.queued`, `subagent.running`, `subagent.waiting`, `subagent.cancelled` | Silence |
| Subagent | `subagent.completed` | `builtin:success` |
| Subagent | `subagent.failed`, `subagent.timed_out` | `builtin:error` |

`session.started` means initial process startup only, once per process, not reload/new/resume/fork. Main outcomes use settled-run evidence; `agent_end` is not success. Attention is an active main-run edge from the existing explicit intervention lifecycle (`herdr:blocked`), not every dialog. Subagent waiting does not imply attention. Restored tasks/history and unchanged snapshots are silent.
`session.started` means initial process startup only, once per process, not reload/new/resume/fork. Main outcomes use settled-run evidence; `agent_end` is not success. Attention is an active main-run edge from the existing explicit intervention lifecycle (`herdr:blocked`), not every dialog. Subagent waiting does not imply attention. Restored tasks/history and unchanged snapshots are silent. `context.high` is a local runtime signal from the context-usage probe, never a producer bus event, and fires at most once per high episode.

**Shutdown limitation:** `session.shutdown` remains in the schema for compatibility but is excluded from the runtime/UI catalog. Lazy playback cannot reliably finish during immediate cleanup without delaying exit. The historical best-effort proposal is not a promise of a quit sound; shutdown cancels audio instead.

Expand All @@ -52,6 +55,7 @@ Complete default schema (the shutdown entry is inert):
"agent.failed": "builtin:error",
"agent.cancelled": null,
"agent.attention": "builtin:attention",
"context.high": "builtin:attention",
"subagent.queued": null,
"subagent.running": null,
"subagent.waiting": null,
Expand All @@ -64,7 +68,7 @@ Complete default schema (the shutdown entry is inert):
}
```

Unknown keys/schema/events are rejected. `enabled` must be boolean; backend must be `auto`; timing values are integer milliseconds: minimum interval 0–60000, coalesce window 0–2000, inclusive. Zero disables that timing window, not the TTL. Writes use an exclusive 0600 temporary file beside the target followed by atomic rename. Changes apply to the live owner immediately and invalidate stale pending mappings. Direct external edits are read on the next session attachment/reload, not polled in the background.
Unknown keys/schema/events are rejected. `enabled` must be boolean; backend must be `auto`; timing values are integer milliseconds: minimum interval 0–60000, coalesce window 0–2000, inclusive. Zero disables that timing window, not the TTL. Writes use an exclusive 0600 temporary file beside the target followed by atomic rename. Changes apply to the live owner immediately and invalidate stale pending mappings. Direct external edits are read on the next session attachment/reload, not polled in the background. A file saved before `context.high` existed keeps that event silent; selecting a sound on its Advanced row is the only explicit way to enable it.

## Local WAV security

Expand All @@ -78,7 +82,7 @@ Playback revalidates and snapshots the bytes in a private 0700 temporary directo

## Noise and retention

Only the primary **TUI** owner plays. Child processes, print/JSON and **all RPC**, including interactive RPC hosts, are silent. There is no desktop notification or BEL fallback. Two independent Pi processes can overlap; serialization is per process.
Only the primary **TUI** owner plays. Child processes, print/JSON and **all RPC**, including interactive RPC hosts, are silent. The context visual warning shares that owner guard, so it is also primary-TUI only. There is no desktop notification or BEL fallback. Two independent Pi processes can overlap; serialization is per process.

Ráfagas coalesce into at most one pending candidate, in event priority order: failed/timed_out > main attention > completed > other. Changing the sound does not change event priority. Equal priority selects the latest sound but keeps the first coalesce deadline. Starts respect the minimum interval. Pending events have a **2000 ms** freshness budget (including slow discovery/validation and minimum-interval waits). That budget pauses only while another sound has actually started playing, so a long WAV does not silently discard the next candidate; it resumes with the remaining budget when playback settles. Already stale incoming events are still rejected. Waiting behind discovery that has not started playback does not pause expiry. There is still only one pending candidate, not a growing queue. Preview remains subject to context, file validation, serialization and timeout.

Expand Down
42 changes: 39 additions & 3 deletions extensions/gentle-notifications.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,52 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { CONTEXT_HIGH_PERCENT, ContextHighLatch } from "../lib/context-threshold.ts";
import { MainNotificationRun, NOTIFICATION_ATTENTION_EVENT, NOTIFICATION_SOURCE_EVENT, isNotificationSourceEvent } from "../lib/notification-events.ts";
import { claimNotificationOwner, type NotificationServiceDependencies } from "../lib/notification-service.ts";
import { resolveVisualSettings } from "../lib/visual-customization-policy.ts";

/**
* Extension-only additions to the notification service dependencies. The visual
* preference store (`visual-customization.json`) owns `contextWarning`; it is
* never part of the notification schema, and the two warnings toggle independently.
*/
export interface NotificationExtensionDependencies extends NotificationServiceDependencies {
/** Reads the visual context-warning preference; defaults to the visual policy store. */
visualNotice?: () => boolean;
}

/** Default directory loader is the sole audio owner; customize will retrieve its shared facade. */
export function createNotificationExtension(pi: ExtensionAPI, deps: NotificationServiceDependencies = {}): () => void {
export function createNotificationExtension(pi: ExtensionAPI, deps: NotificationExtensionDependencies = {}): () => void {
const nativeOff: (() => void)[] = []; const busOff: (() => void)[] = [];
const stopBus = () => { for (const off of busOff.splice(0)) off(); };
const owner = claimNotificationOwner(() => {
stopBus(); main.reset(); runId = undefined;
for (const off of nativeOff.splice(0)) off();
}, deps);
const main = new MainNotificationRun(owner.now);
const contextHigh = new ContextHighLatch();
const visualNotice = deps.visualNotice ?? (() => resolveVisualSettings().settings.visibility.contextWarning);
let runId: string | undefined; let sessionId: string | undefined; let attachedAt = 0;
let contextRunId: string | undefined; let contextSequence = 0;
const valid = (ctx: ExtensionContext) => owner.allowed(ctx) && ctx.sessionManager.getSessionId() === sessionId;
/** One sound and one optional visual notice per high episode; the latch owns rearming. */
const evaluateContextHigh = (ctx: ExtensionContext): void => {
if (!valid(ctx) || !sessionId || !contextRunId) return;
const reading = ctx.getContextUsage?.()?.percent ?? null;
if (!contextHigh.observe(reading)) return;
owner.enqueue({ sessionId, runId: contextRunId, sequence: ++contextSequence,
event: "context.high", occurredAt: owner.now() });
if (!visualNotice()) return;
const percent = reading ?? CONTEXT_HIGH_PERCENT;
try { ctx.ui.notify(`Context usage is at ${Math.round(percent)}% (\u2265 ${CONTEXT_HIGH_PERCENT}%); consider compacting before the next turn.`, "warning"); }
catch { /* A local visual diagnostic cannot fail a run. */ }
};
const remember = (off: unknown) => { if (typeof off === "function") nativeOff.push(off as () => void); };
remember(pi.on("session_start", (event, ctx) => {
if (!owner.current()) return;
stopBus(); main.reset(); runId = undefined;
const nextSession = ctx.sessionManager.getSessionId();
sessionId = nextSession;
if (nextSession !== sessionId) { contextHigh.rearm(); contextSequence = 0; }
sessionId = nextSession; contextRunId = `${nextSession}:context`;
if (!owner.attach(ctx)) return;
attachedAt = owner.now();
busOff.push(pi.events.on(NOTIFICATION_SOURCE_EVENT, data => {
Expand All @@ -40,6 +68,7 @@ export function createNotificationExtension(pi: ExtensionAPI, deps: Notification
remember(pi.on("agent_start", (_event, ctx) => {
if (!valid(ctx)) return;
const occurrence = main.begin(sessionId!); runId = occurrence.runId; owner.enqueue(occurrence);
evaluateContextHigh(ctx);
}));
// Native events carry no run identity. Capture the local token synchronously: no awaits
// here, no late callback ever searches for a replacement token. before_settle is latest/final evidence.
Expand All @@ -48,18 +77,25 @@ export function createNotificationExtension(pi: ExtensionAPI, deps: Notification
if (valid(ctx) && token) main.beforeSettle(sessionId!, token, event.outcome);
}));
remember(pi.on("turn_end", (event, ctx) => {
if (!valid(ctx)) return;
const token = runId;
if (valid(ctx) && token) main.turnEnd(sessionId!, token, event.outcome);
if (token) main.turnEnd(sessionId!, token, event.outcome);
evaluateContextHigh(ctx);
}));
remember(pi.on("agent_settled", (_event, ctx) => {
const token = runId;
if (!valid(ctx) || !token) return;
const occurrence = main.settled(sessionId!, token); runId = undefined;
if (occurrence) owner.enqueue(occurrence);
}));
// Model changes move the window: re-evaluate without resetting the episode.
remember(pi.on("model_select", (_event, ctx) => evaluateContextHigh(ctx)));
// Only a completed compaction reduced the context; canceled/failed attempts keep the latch.
remember(pi.on("session_compact", (_event, ctx) => { if (valid(ctx)) contextHigh.rearm(); }));
remember(pi.on("session_shutdown", () => {
if (!owner.current()) return;
stopBus(); main.reset(); runId = undefined; owner.shutdown();
sessionId = undefined;
// No quit sound: mandatory immediate cancellation must not wait for lazy detection.
// Native lifecycle hooks remain dormant for the next session; lease retirement removes them.
}));
Expand Down
26 changes: 26 additions & 0 deletions lib/context-threshold.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Fixed product threshold for the high-context warning. The latch lives outside
* the notification and visual stores so both warnings share one episode.
*/
export const CONTEXT_HIGH_PERCENT = 90;

/**
* One warning per high episode. `observe` returns true exactly once while usage
* stays at or above the threshold; a finite reading below it rearms the latch.
* An unknown reading (`null`, which Pi reports right after a successful
* compaction until the next assistant response) preserves the latch so the same
* episode cannot warn twice. A successful compaction or a new session calls
* `rearm` explicitly; a canceled or failed compaction must not, because the
* context was never reduced.
*/
export class ContextHighLatch {
private high = false;
observe(percent: number | null | undefined): boolean {
if (typeof percent !== "number" || !Number.isFinite(percent)) return false;
if (percent < CONTEXT_HIGH_PERCENT) { this.high = false; return false; }
if (this.high) return false;
this.high = true;
return true;
}
rearm(): void { this.high = false; }
}
6 changes: 4 additions & 2 deletions lib/notification-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@ import type { TaskStatus } from "./agents-protocol.ts";
export const NOTIFICATION_SCHEMA = "gentle-shell.notifications/v1";
/** Contract vocabulary, NOT proof of runtime support. Adapters must verify producers before UI exposure. */
const SUBAGENT_STATUSES = ["queued", "running", "waiting", "completed", "failed", "cancelled", "timed_out"] as const satisfies readonly TaskStatus[];
export type NotificationEvent = "session.started" | "session.shutdown"
export type NotificationEvent = "session.started" | "session.shutdown" | "context.high"
| `agent.${"started" | "completed" | "failed" | "cancelled" | "attention"}` | `subagent.${TaskStatus}`;
export const NOTIFICATION_EVENTS: readonly NotificationEvent[] = [
"session.started", "session.shutdown", "agent.started", "agent.completed", "agent.failed", "agent.cancelled", "agent.attention",
...SUBAGENT_STATUSES.map(status => `subagent.${status}` as const),
// Context is a main-process runtime signal, not a producer bus origin.
"context.high",
];
export const BUILTIN_NOTIFICATION_IDS = ["success", "error", "attention"] as const;
export type NotificationSound = `builtin:${typeof BUILTIN_NOTIFICATION_IDS[number]}` | `file:${string}` | null;
/** Priority belongs to the event, never the selected sound. waiting does not imply attention. */
export const NOTIFICATION_PRIORITY: Readonly<Record<NotificationEvent, number>> = Object.fromEntries(
NOTIFICATION_EVENTS.map(event => [event, event.endsWith(".failed") || event.endsWith(".timed_out") ? 3
: event === "agent.attention" ? 2 : event.endsWith(".completed") ? 1 : 0]),
: event === "agent.attention" || event === "context.high" ? 2 : event.endsWith(".completed") ? 1 : 0]),
) as Record<NotificationEvent, number>;
/** Integer milliseconds, inclusive bounds. Zero permits disabling a timing window. TTL belongs to scheduler. */
export const NOTIFICATION_TIMING_LIMITS = {
Expand Down
Loading
Loading