summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 09:28:30 +0900
committerAdam Malczewski <[email protected]>2026-06-01 09:28:30 +0900
commit21cdb1199599c4dc6e2a941e52713ba6511cd675 (patch)
treee3711384f16c476bf5d8def148dc922f9ceb5f3d /packages/api/src
parent5e72191cac9469c2ade91aaba1e62f69fa1ad94a (diff)
downloaddispatch-21cdb1199599c4dc6e2a941e52713ba6511cd675.tar.gz
dispatch-21cdb1199599c4dc6e2a941e52713ba6511cd675.zip
feat(api): wire notification dispatcher into app + /notifications routes
PermissionManager: add onPromptAdded(listener) callback. Fires exactly once per unique pending prompt id, even when broadcastPending is called repeatedly for unrelated mutations (e.g. another prompt resolving while this one is still pending). app.ts: instantiate NotificationDispatcher, attach to both AgentManager and PermissionManager. Tab-title lookup via core's getTab so the notifications carry human-readable context instead of raw UUIDs. routes/notifications.ts: - GET /notifications — current config (auth token redacted) plus the event-type catalog and defaults - PUT /notifications — partial update; auth token semantics are undefined=keep, ''=clear, otherwise replace - POST /notifications/test — sends a test notification with the current config (rejects if disabled or topic invalid) Tests: - new permission-manager.test.ts covers the onPromptAdded contract (one-fire-per-prompt, dedup across rebroadcasts, unsubscribe, listener throws don't break siblings) - existing routes.test.ts gets stubs for the new core notification exports so the @dispatch/core mock stays complete
Diffstat (limited to 'packages/api/src')
-rw-r--r--packages/api/src/app.ts18
-rw-r--r--packages/api/src/permission-manager.ts55
-rw-r--r--packages/api/src/routes/notifications.ts81
3 files changed, 154 insertions, 0 deletions
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index 19cc193..24cef24 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -1,3 +1,4 @@
+import { getTab, NotificationDispatcher } from "@dispatch/core";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { AgentManager } from "./agent-manager.js";
@@ -5,12 +6,28 @@ import { PermissionManager } from "./permission-manager.js";
import { agentsRoutes } from "./routes/agents.js";
import { configRoutes } from "./routes/config.js";
import { modelsRoutes, startWakeScheduler } from "./routes/models.js";
+import { notificationsRoutes } from "./routes/notifications.js";
import { skillsRoutes } from "./routes/skills.js";
import { tabsRoutes } from "./routes/tabs.js";
export const permissionManager = new PermissionManager();
export const agentManager = new AgentManager(permissionManager);
+// ntfy.sh push notifications. The dispatcher reads its config from the
+// `settings` table on every send, so config changes apply immediately —
+// no restart, no re-attach needed.
+export const notificationDispatcher = new NotificationDispatcher({
+ getTabTitle: (tabId) => {
+ try {
+ return getTab(tabId)?.title ?? null;
+ } catch {
+ return null;
+ }
+ },
+});
+notificationDispatcher.attachToAgentManager(agentManager);
+notificationDispatcher.attachToPermissionManager(permissionManager);
+
export const app = new Hono();
app.use(
@@ -112,6 +129,7 @@ app.route("/skills", skillsRoutes);
app.route("/models", modelsRoutes);
app.route("/tabs", tabsRoutes);
app.route("/agents", agentsRoutes);
+app.route("/notifications", notificationsRoutes);
// Start the wake scheduler on boot (restores persisted schedule)
startWakeScheduler();
diff --git a/packages/api/src/permission-manager.ts b/packages/api/src/permission-manager.ts
index d98dc52..3a24d03 100644
--- a/packages/api/src/permission-manager.ts
+++ b/packages/api/src/permission-manager.ts
@@ -5,9 +5,25 @@ import {
type Ruleset,
} from "@dispatch/core";
+/**
+ * Listener fired exactly once per newly-created pending prompt. Used by
+ * the notification dispatcher so that a permission request triggers a
+ * push notification on the user's phone (without re-firing every time
+ * the pending list mutates for an unrelated reason).
+ */
+export type PromptAddedListener = (prompt: {
+ id: string;
+ permission: string;
+ description: string;
+ metadata: Record<string, unknown>;
+}) => void;
+
export class PermissionManager {
private service = new PermissionService();
private wsClients: Map<string, (data: unknown) => void> = new Map();
+ private promptAddedListeners: Set<PromptAddedListener> = new Set();
+ /** Ids that have already been broadcast as "added" — guards against re-emits. */
+ private announcedPromptIds: Set<string> = new Set();
registerClient(id: string, send: (data: unknown) => void): void {
this.wsClients.set(id, send);
@@ -25,6 +41,33 @@ export class PermissionManager {
for (const send of this.wsClients.values()) {
send(message);
}
+
+ // Detect newly-added prompts (ids present now that weren't before) and
+ // fire `promptAddedListeners` once for each. Resolved/rejected ids are
+ // pruned from `announcedPromptIds` so a future prompt that reuses an
+ // id (theoretical, given the monotonic counter) would still notify.
+ const currentIds = new Set(pending.map((p) => p.id));
+ for (const id of this.announcedPromptIds) {
+ if (!currentIds.has(id)) this.announcedPromptIds.delete(id);
+ }
+ for (const p of pending) {
+ if (this.announcedPromptIds.has(p.id)) continue;
+ this.announcedPromptIds.add(p.id);
+ for (const listener of this.promptAddedListeners) {
+ try {
+ listener({
+ id: p.id,
+ permission: p.request.permission,
+ description: p.request.description,
+ metadata: p.request.metadata,
+ });
+ } catch (err) {
+ console.warn(
+ `[permission] promptAdded listener threw: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+ }
}
async ask(request: PermissionRequest, rulesets: Ruleset[] = []): Promise<PermissionReply> {
@@ -45,4 +88,16 @@ export class PermissionManager {
getService(): PermissionService {
return this.service;
}
+
+ /**
+ * Subscribe to "a new prompt is now pending" events. Fires once per
+ * unique prompt id, even if `broadcastPending` is called repeatedly
+ * for unrelated mutations. Returns an unsubscribe function.
+ */
+ onPromptAdded(listener: PromptAddedListener): () => void {
+ this.promptAddedListeners.add(listener);
+ return () => {
+ this.promptAddedListeners.delete(listener);
+ };
+ }
}
diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts
new file mode 100644
index 0000000..57519bc
--- /dev/null
+++ b/packages/api/src/routes/notifications.ts
@@ -0,0 +1,81 @@
+// `/notifications` — ntfy.sh config + test-send route.
+
+import {
+ defaultNtfyConfig,
+ loadNtfyConfig,
+ type NotificationEventType,
+ NTFY_EVENT_TYPES,
+ type NtfyConfig,
+ normalizeNtfyConfig,
+ redactNtfyConfig,
+ saveNtfyConfig,
+ sendNtfy,
+ validateTopicUrl,
+} from "@dispatch/core";
+import { Hono } from "hono";
+
+export const notificationsRoutes = new Hono();
+
+notificationsRoutes.get("/", (c) => {
+ const config = loadNtfyConfig();
+ return c.json({
+ config: redactNtfyConfig(config),
+ eventTypes: NTFY_EVENT_TYPES,
+ defaults: defaultNtfyConfig(),
+ });
+});
+
+notificationsRoutes.put("/", async (c) => {
+ const body = await c.req.json<Partial<NtfyConfig> & { authToken?: string }>();
+ const existing = loadNtfyConfig();
+
+ // `authToken === ""` ⇒ explicit clear; `authToken === undefined` ⇒ keep
+ // the existing token (the GET response redacts it, so the frontend doesn't
+ // have it to send back). Any other string ⇒ replace.
+ let nextAuthToken = existing.authToken;
+ if (typeof body.authToken === "string") nextAuthToken = body.authToken;
+
+ const merged = normalizeNtfyConfig({
+ enabled: typeof body.enabled === "boolean" ? body.enabled : existing.enabled,
+ topicUrl: typeof body.topicUrl === "string" ? body.topicUrl : existing.topicUrl,
+ authToken: nextAuthToken,
+ events: { ...existing.events, ...(body.events ?? {}) },
+ });
+
+ if (merged.enabled) {
+ const err = validateTopicUrl(merged.topicUrl);
+ if (err) return c.json({ error: err }, 400);
+ }
+
+ saveNtfyConfig(merged);
+ return c.json({ config: redactNtfyConfig(merged) });
+});
+
+notificationsRoutes.post("/test", async (c) => {
+ const config = loadNtfyConfig();
+ if (!config.enabled) {
+ return c.json({ ok: false, error: "Notifications are disabled" }, 400);
+ }
+ const err = validateTopicUrl(config.topicUrl);
+ if (err) return c.json({ ok: false, error: err }, 400);
+
+ // Use a real event type so the per-event toggle is honored when wiring
+ // is tested end-to-end; pick `turn-completed` since it's the most
+ // common enabled-by-default event.
+ const eventType: NotificationEventType = "turn-completed";
+ if (!config.events[eventType]) {
+ return c.json(
+ { ok: false, error: `Event type "${eventType}" is disabled — enable it to test.` },
+ 400,
+ );
+ }
+
+ const result = await sendNtfy(config, {
+ type: eventType,
+ title: "Dispatch test notification",
+ message: "If you can see this, ntfy.sh notifications are wired up correctly.",
+ tags: ["bell"],
+ });
+ if (!result.ok) return c.json(result, 502);
+ return c.json(result);
+});