From a3e9a43dd6250ed51ecdb02227fb3d55b264c28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 10 Nov 2026 12:23:17 +0000 Subject: [PATCH] Parity gaps: the finished-reply notice (bell and OSC 9 while unfocused; notify: false), /rename, /search over this machine's sessions, /memory shows the server's memory Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +++ schema/config.schema.json | 4 ++ src/config/schema.ts | 1 + src/config/settings.ts | 1 + src/mcp/index.ts | 10 +++ src/tool/session_search.ts | 21 +++++- src/tui/app.tsx | 17 +++++ src/tui/commands.ts | 127 +++++++++++++++++++++++++++++++++---- src/tui/notify.ts | 28 ++++++++ tests/parity-cli.test.ts | 48 ++++++++++++++ tests/schedules.test.ts | 2 +- 11 files changed, 256 insertions(+), 13 deletions(-) create mode 100644 src/tui/notify.ts create mode 100644 tests/parity-cli.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d529979..b5efafa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,16 @@ Harness spec 3.5.0: scheduled work on a linked computer. the current folder), `pause`, `resume`, `run` (now, without using up the next scheduled run) and `history`. It uses this computer's sign-in; a server too old to allow that says so. +- **A notice when a reply finishes** — or when the agent waits for your answer — while the + terminal is not focused: the bell, and a desktop notification (OSC 9, in terminals that show + one) naming the session, never what the reply says. `notify: false` (or `/settings`) turns it + off. +- **`/rename `** names this session — in `/sessions`, and in the web UI when it is shared. +- **`/search <words>`** finds a past session in this project by what was said in it (add `all` + for every project) and resumes the one you pick. +- **`/memory` shows the server's memory** when the server keeps it (`library: lembas`): its + entries beside this machine's files, each one to change or forget, through the server. + ### Changed - `team_add` under a sub-manager says where the report goes: "Its report goes to <sub-manager> diff --git a/schema/config.schema.json b/schema/config.schema.json index 7fc2082..fad792b 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -56,6 +56,10 @@ "description": "false: leave the mouse to the terminal — its own selection and Ctrl+Shift+C work without Shift, and the wheel no longer scrolls the transcript (pgup/pgdn do). Default true.", "type": "boolean" }, + "notify": { + "description": "When a reply finishes, or the agent waits for you, while the terminal is not focused: ring the bell and ask the terminal for a desktop notification (OSC 9) naming the session — never what the reply says. Default true; false: neither.", + "type": "boolean" + }, "icons": { "description": "emoji (default), or plain: one-column symbols for a terminal font that has no emoji.", "type": "string", diff --git a/src/config/schema.ts b/src/config/schema.ts index db4d9b5..174676f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -241,6 +241,7 @@ export const Config = z.strictObject({ skin: z.string().optional().describe("The old name of `theme`; still read when `theme` is not set."), busy_input: z.enum(["steer", "queue"]).optional().describe("A message sent while the agent works: steer (default) — it goes in at the next step, after the running tools finish, and the agent carries on with it; queue — it is sent as the next prompt once the task is done."), mouse: z.boolean().optional().describe("false: leave the mouse to the terminal — its own selection and Ctrl+Shift+C work without Shift, and the wheel no longer scrolls the transcript (pgup/pgdn do). Default true."), + notify: z.boolean().optional().describe("When a reply finishes, or the agent waits for you, while the terminal is not focused: ring the bell and ask the terminal for a desktop notification (OSC 9) naming the session — never what the reply says. Default true; false: neither."), icons: z.enum(["emoji", "plain"]).optional().describe("emoji (default), or plain: one-column symbols for a terminal font that has no emoji."), personality: z .enum(PERSONALITIES) diff --git a/src/config/settings.ts b/src/config/settings.ts index d692a50..add67b6 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -60,6 +60,7 @@ export const SETTINGS: SettingDef[] = [ { key: "theme_background", kind: "enum", values: ["theme", "terminal"], agent: true, scopes: USER, fallback: "theme" }, { key: "busy_input", kind: "enum", values: ["steer", "queue"], agent: true, scopes: USER, fallback: "steer" }, { key: "icons", kind: "enum", values: ["emoji", "plain"], agent: true, scopes: USER, fallback: "emoji" }, + { key: "notify", kind: "boolean", agent: true, scopes: USER, fallback: "true", hint: "bell and desktop notification when a reply finishes or the agent waits for you while the terminal is not focused" }, { key: "mouse", kind: "boolean", agent: true, scopes: ["global"], reload: true, fallback: "true" }, { key: "compaction.auto_at", kind: "number", agent: true, scopes: ALL, fallback: "0.85" }, { key: "search.order", kind: "list", values: ["searxng", "firecrawl", "ddg"], agent: true, scopes: ALL, fallback: "those configured, then ddg" }, diff --git a/src/mcp/index.ts b/src/mcp/index.ts index f017b93..5aef031 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -371,6 +371,16 @@ export class McpManager { return r.contents.map((x) => ("text" in x && typeof x.text === "string" ? x.text : "")).join("\n").trim() } + /** A tool called directly (not by the model): `/memory` changing the LLeMbas server's memory. + * The text it answered; a tool error is thrown with its text. */ + async callText(server: string, tool: string, args: Record<string, unknown>, scope?: string): Promise<string> { + const c = await this.clientOf(server) + const r = (await c.callTool({ name: tool, arguments: args, ...(scope ? { _meta: { lembas: { scope } } } : {}) })) as { content?: { type: string; text?: string }[]; isError?: boolean } + const text = (r.content ?? []).map((x) => (x.type === "text" && typeof x.text === "string" ? x.text : "")).join("\n").trim() + if (r.isError) throw new Error(text || `${tool} failed`) + return text + } + /** Every connected server's tools as LLeMbas CLI tools, and list/read tools for resources. */ tools(): Tool[] { const out: Tool[] = [] diff --git a/src/tool/session_search.ts b/src/tool/session_search.ts index 6193132..8f44de2 100644 --- a/src/tool/session_search.ts +++ b/src/tool/session_search.ts @@ -51,7 +51,7 @@ const line = (r: Row, n: number) => { } const header = (s: SessionRow) => `${s.id} · ${when(s.created)} · ${s.root}${s.title ? ` · "${s.title}"` : ""} · ${s.model}` -function snippet(text: string, query: string): string { +export function snippet(text: string, query: string): string { const terms = (query.match(/"[^"]*"|\S+/g) ?? []).map((t) => t.replace(/^"|"$|\*$/g, "").toLowerCase()).filter((t) => t && !/^(and|or|not)$/.test(t)) const low = text.toLowerCase() const at = Math.max(0, Math.min(...terms.map((t) => low.indexOf(t)).filter((i) => i >= 0), text.length)) @@ -65,6 +65,25 @@ function around(rows: Row[], id: number, window: number): Row[] { return rows.slice(Math.max(0, i - window), i + window + 1) } +/** `/search`: the sessions whose messages match `query` (FTS5, the person's words made safe; all + * of them, else any), the best hit of each, ranked. `root`: this project's only. */ +export function searchSessions(store: Store, query: string, o: { root?: string; exclude?: string[]; limit?: number } = {}): { id: string; title: string; updated: number; model: string; role: string; snippet: string }[] { + let match = ftsQuery(query) + if (!match) return [] + const find = (m: string) => store.hits(m, { exclude: o.exclude, root: o.root, perSession: true, limit: o.limit ?? 20 }) + let hits = find(match) + if (!hits.length && anyOf(match)) { + match = anyOf(match)! + hits = find(match) + } + return hits.flatMap((h) => { + const s = store.session(h.session_id) + if (!s) return [] + const m = readable(h.json) + return [{ id: s.id, title: s.title, updated: s.updated, model: s.model, role: m.role, snippet: snippet(m.text, match) }] + }) +} + export const sessionSearchTool = defineTool({ ...described("session_search"), purpose: false, diff --git a/src/tui/app.tsx b/src/tui/app.tsx index ce5dd3a..66e5ef3 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -17,6 +17,7 @@ import { PlanPanel } from "./components/plan.tsx" import { JobView } from "./components/jobview.tsx" import { SubagentView } from "./components/subagent.tsx" import { Bus } from "../bus/index.ts" +import { FocusWatch, noticeSequence, noticeText } from "./notify.ts" import { Prompt, type PaletteEntry } from "./components/prompt.tsx" import { FileIndex } from "../project/files.ts" import { attachmentsFor } from "../project/attach.ts" @@ -79,6 +80,22 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () => // model switched with git. app.bus.on((e) => (e.type === "mode" || e.type === "done" || e.type === "team") && refresh()) app.bus.on((e) => e.type === "flash" && flash(e.text, 6000)) + // The finished-reply notice (notify.ts): a reply that ends, or an answer the agent waits for, + // while the terminal is not focused — the bell and a desktop notification naming the session. + const focus = new FocusWatch((ev, fn) => renderer.on(ev, fn)) + const notice = (what: "finished" | "waiting" | "failed") => { + if (focus.focused || app.loaded.config.notify === false) return + const id = app.engine.sessionId + const title = id ? app.store?.session(id)?.title : undefined + const seq = noticeSequence(noticeText(title, what)) + const r = renderer as unknown as { writeOut?: (s: string) => void } + try { + if (r.writeOut) r.writeOut(seq) + else process.stdout.write(seq) + } catch {} + } + app.bus.on((e) => e.type === "done" && e.reason !== "cancelled" && notice(e.reason === "error" ? "failed" : "finished")) + createEffect(() => (view.state.pending || view.state.question || view.state.plan) && notice("waiting")) // A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here. app.bus.on((e) => { if (e.type !== "setting") return diff --git a/src/tui/commands.ts b/src/tui/commands.ts index c449950..a116ae7 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -515,6 +515,59 @@ export const COMMANDS: Command[] = [ void c.share.remote(a !== "off").then((m) => (c.view.info(m), c.refresh())) }, }, + { + name: "rename", + args: "<title>", + description: "name this session (here, in /sessions, and in the web UI when it is shared)", + run(c, arg) { + const title = arg.trim().replace(/\s+/g, " ").slice(0, 200) + const id = c.app.engine.sessionId + const now = (id && c.app.store?.session(id)?.title) || "" + if (!id) return c.view.info("nothing to name yet: this session has no messages", "warn") + if (!title) return c.view.info(`usage: /rename <title>${now ? ` — now "${now}"` : ""}`, "warn") + c.app.rename(title) + c.view.info(`named "${title}"`) + c.refresh() + }, + }, + { + name: "search", + args: "<words> [all]", + description: "find a past session by what was said in it (this project's; add `all` for every project) and resume it", + async run(c, arg) { + const store = c.app.store + if (!store) return c.view.info("sessions are not being recorded here", "warn") + const words = arg.trim().split(/\s+/).filter(Boolean) + const everywhere = words.at(-1) === "all" + const query = (everywhere ? words.slice(0, -1) : words).join(" ") + if (!query) return c.view.info('usage: /search <words> [all] — plain words, "a phrase", OR, NOT', "warn") + const { searchSessions } = await import("../tool/session_search.ts") + let found: ReturnType<typeof searchSessions> + try { + found = searchSessions(store, query, { root: everywhere ? undefined : c.app.project.root, exclude: c.app.engine.sessionId ? [c.app.engine.sessionId] : [] }) + } catch (e) { + return c.view.info(`/search: that could not be searched (${(e as Error).message}) — use plain words, "quoted phrases", OR and NOT`, "warn") + } + if (!found.length) return c.view.info(`nothing found for "${query}"${everywhere ? "" : " in this project's sessions — /search " + query + " all looks in every project"}`) + const other = (id: string) => store.session(id)?.root !== c.app.project.root + c.openDialog({ + title: `${found.length} session${found.length === 1 ? "" : "s"} for "${query}"`, + width: 100, + options: found.map((f) => ({ + label: f.title || firstUserLine(c, f.id) || f.id, + value: f.id, + description: `${new Date(f.updated).toISOString().slice(0, 16).replace("T", " ")}${other(f.id) ? ` · ${store.session(f.id)?.root}` : ""} · ${f.role}: ${f.snippet}`, + })), + onSelect: async (id) => { + if (other(id)) return c.view.info(`that session is in ${store.session(id)?.root} — resume it there (lembas -c, /sessions)`, "warn") + const why = await (c.share?.release(id) ?? Promise.resolve(undefined)) + if (why) return c.view.info(`not resumed: ${why}`, "warn") + c.view.load(c.app.resume(id)) + c.view.info(`resumed ${id}`) + }, + }) + }, + }, { name: "delete", description: "delete this session (here and in the web UI) and start a new one", @@ -767,24 +820,63 @@ export const COMMANDS: Command[] = [ }, { name: "memory", - description: "what is remembered across sessions — pick an entry to change or forget it", - run(c) { + description: "what is remembered across sessions — the server's memory when it keeps it, and this machine's files — pick an entry to change or forget it", + async run(c) { const stores = [ { key: "user", title: "user", store: c.app.memory, target: "user" as const }, { key: "memory", title: "notes", store: c.app.memory, target: "memory" as const }, ...(c.app.projectMemory ? [{ key: "project", title: "project", store: c.app.projectMemory, target: "memory" as const }] : []), ] - const entries = stores.flatMap((s) => s.store.entries(s.target).map((text, i) => ({ s, text, value: `${s.key}:${i}` }))) + // library: lembas — the account's memory on the server (harness acp.md *Memory*), read + // through its /mcp as the prompt reads it, and changed with its memory tool. + const { LIBRARY_SERVER } = await import("../mcp/index.ts") + const { MEMORY_RESOURCE } = await import("../app.ts") + const onServer = c.app.mcp.servers.has(LIBRARY_SERVER) + const scope = c.app.engine.o.toolCtx.libraryScope + let server: string[] = [] + let serverError = "" + if (onServer) { + try { + server = serverEntries(await c.app.mcp.readResource(LIBRARY_SERVER, MEMORY_RESOURCE, scope)) + } catch (e) { + serverError = (e as Error).message + } + } + type Entry = { title: string; text: string; value: string; change: (op: { action: "replace" | "remove"; content?: string }) => Promise<string> | string } + const local: Entry[] = stores.flatMap((s) => + s.store.entries(s.target).map((text, i) => ({ + title: s.title, + text, + value: `${s.key}:${i}`, + change: (op: { action: "replace" | "remove"; content?: string }) => { + const r = s.store.apply(s.target, [{ ...op, old_text: text }]) + if (!r.ok) throw new Error(r.message) + return r.usage + }, + })), + ) + const remote: Entry[] = server.map((text, i) => ({ + title: "server", + text, + value: `server:${i}`, + change: (op: { action: "replace" | "remove"; content?: string }) => c.app.mcp.callText(LIBRARY_SERVER, "memory", { target: "user", action: op.action, old_text: text, ...(op.content !== undefined ? { content: op.content } : {}) }, scope), + })) + const entries = [...remote, ...local] const files = stores.map((s) => `${s.title}: ${s.store.file(s.target)} (${s.store.usage(s.target)})`).join("\n") - if (!entries.length) return c.view.info(`nothing remembered yet — the agent saves facts with its memory tool, or tell it "remember that …"\n${files}`) - const change = (e: (typeof entries)[number], op: { action: "replace" | "remove"; content?: string }) => { - const r = e.s.store.apply(e.s.target, [{ ...op, old_text: e.text }]) - c.view.info(r.ok ? `${e.s.title}: ${op.action === "remove" ? "forgotten" : "changed"} — the agent sees it from the next session (${r.usage})` : r.message, r.ok ? "info" : "warn") + const where = onServer ? `the server's memory (${serverError ? `could not be read: ${serverError}` : `${server.length} entr${server.length === 1 ? "y" : "ies"}`}) is what sessions here use; this machine's files:\n${files}` : `the files, which you can also edit by hand (entries are separated by a line with §):\n${files}` + if (!entries.length) return c.view.info(`nothing remembered yet — the agent saves facts with its memory tool, or tell it "remember that …"\n${where}`) + const change = async (e: Entry, op: { action: "replace" | "remove"; content?: string }) => { + try { + const said = await e.change(op) + c.view.info(`${e.title}: ${op.action === "remove" ? "forgotten" : "changed"} — the agent sees it from the next session${said && e.title !== "server" ? ` (${said})` : ""}`) + } catch (err) { + c.view.info(`${e.title}: ${(err as Error).message}`, "warn") + } } c.openDialog({ title: "Memory — enter to change or forget", filter: true, - options: entries.map((e) => ({ label: `[${e.s.title}] ${e.text.replace(/\s+/g, " ").slice(0, 90)}`, value: e.value })), + options: entries.map((e) => ({ label: `[${e.title}] ${e.text.replace(/\s+/g, " ").slice(0, 90)}`, value: e.value })), onSelect: (v) => { const e = entries.find((x) => x.value === v)! c.openDialog({ @@ -793,12 +885,12 @@ export const COMMANDS: Command[] = [ { label: "Forget it", value: "remove" }, { label: "Keep it", value: "keep" }, ], - onSelect: (a) => a === "remove" && change(e, { action: "remove" }), - custom: { label: "replace with", onSubmit: (text) => change(e, { action: "replace", content: text }) }, + onSelect: (a) => void (a === "remove" && change(e, { action: "remove" })), + custom: { label: "replace with", onSubmit: (text) => void change(e, { action: "replace", content: text }) }, }) }, }) - c.view.info(`the files, which you can also edit by hand (entries are separated by a line with §):\n${files}`) + c.view.info(where) }, }, { @@ -1367,6 +1459,19 @@ export function findCommand(name: string): Command | undefined { /** The sessions /sessions offers: this project's, newest first, not the one open here — and only * those somebody said something in, as `lembas sessions` and the web UI list them: every * start and /new makes one, and the empty ones crowded out the rest as rows named by their id. */ +/** The server's memory block (`lembas://memory`) as entries: each `- ` line starts one, and the + * lines under it belong to it; the block's own "(…N more" line is not an entry. */ +export function serverEntries(block: string): string[] { + const out: string[] = [] + for (const line of block.split("\n")) { + if (line.startsWith("- ")) { + if (/^- \(…\d+ more/.test(line)) continue + out.push(line.slice(2)) + } else if (out.length && line.trim()) out[out.length - 1] += `\n${line}` + } + return out.map((e) => e.trim()).filter(Boolean) +} + function otherSessions(c: CommandCtx) { return c.app.store?.sessions(40, c.app.project.root, true).filter((s) => s.id !== c.app.engine.sessionId) ?? [] } diff --git a/src/tui/notify.ts b/src/tui/notify.ts new file mode 100644 index 0000000..e309d88 --- /dev/null +++ b/src/tui/notify.ts @@ -0,0 +1,28 @@ +// The finished-reply notice: when a reply finishes, or the agent waits for an answer, while the +// terminal is not focused, the bell rings and the terminal is asked for a desktop notification +// (OSC 9 — iTerm2, WezTerm, kitty, Windows Terminal, foot and others show it; the rest ignore it). +// It names the session and what happened, never what the reply says: a desktop notification may +// be seen by anyone near the screen. `notify: false` turns it off. + +/** The words of the notice: the session's title (or "LLeMbas") and what happened. */ +export function noticeText(title: string | undefined, what: "finished" | "waiting" | "failed"): string { + const name = (title ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 80) || "LLeMbas" + const said = what === "finished" ? "finished" : what === "waiting" ? "waits for you" : "stopped with an error" + return `${name}: ${said}` +} + +/** The bytes sent to the terminal: OSC 9 with the text, then the bell. */ +export function noticeSequence(text: string): string { + return `\x1b]9;${text.replace(/[\u0000-\u001f\u007f-\u009f;]/g, " ")}\x07\x07` +} + +/** Follows the terminal's focus (OpenTUI's focus and blur events, from focus reporting) and says + * whether a notice is due: only once the terminal has said it lost focus — a terminal that + * reports nothing is taken as focused, so it never rings for nothing. */ +export class FocusWatch { + focused = true + constructor(on?: (event: "focus" | "blur", fn: () => void) => void) { + on?.("focus", () => (this.focused = true)) + on?.("blur", () => (this.focused = false)) + } +} diff --git a/tests/parity-cli.test.ts b/tests/parity-cli.test.ts new file mode 100644 index 0000000..c9ff9fd --- /dev/null +++ b/tests/parity-cli.test.ts @@ -0,0 +1,48 @@ +// The CLI's gaps from the feature-parity audit: the finished-reply notice, /search over its own +// sessions, /rename, and the server's memory in /memory. +import { expect, test } from "bun:test" +import { Store } from "../src/session/store.ts" +import { searchSessions } from "../src/tool/session_search.ts" +import { serverEntries } from "../src/tui/commands.ts" +import { FocusWatch, noticeSequence, noticeText } from "../src/tui/notify.ts" + +test("the notice names the session and what happened, never the reply, without control bytes", () => { + expect(noticeText("Fix the router", "finished")).toBe("Fix the router: finished") + expect(noticeText(undefined, "waiting")).toBe("LLeMbas: waits for you") + expect(noticeText("a\u001b]0;evil\u0007b", "failed")).toBe("a ]0;evil b: stopped with an error") + expect(noticeSequence("x; y")).toBe("\x1b]9;x y\x07\x07") +}) + +test("FocusWatch: focused until the terminal says it lost focus", () => { + const handlers: Record<string, () => void> = {} + const w = new FocusWatch((ev, fn) => (handlers[ev] = fn)) + expect(w.focused).toBe(true) + handlers.blur!() + expect(w.focused).toBe(false) + handlers.focus!() + expect(w.focused).toBe(true) +}) + +test("searchSessions: the best hit per session, this project's or every one, the current left out", () => { + const store = new Store(":memory:") + const a = store.createSession("/p", "m") + const b = store.createSession("/p", "m") + const c = store.createSession("/q", "m") + store.append(a.id, { role: "user", parts: [{ type: "text", text: "the waybread recipe needs honey" }] }) + store.append(b.id, { role: "user", parts: [{ type: "text", text: "fix the router" }] }) + store.append(b.id, { role: "assistant", parts: [{ type: "text", text: "the router now handles waybread routes" }] }) + store.append(c.id, { role: "user", parts: [{ type: "text", text: "waybread in another project" }] }) + const here = searchSessions(store, "waybread", { root: "/p" }) + expect(here.map((h) => h.id).sort()).toEqual([a.id, b.id].sort()) + expect(here.find((h) => h.id === b.id)!.snippet).toContain("waybread") + expect(searchSessions(store, "waybread").length).toBe(3) + expect(searchSessions(store, "waybread", { root: "/p", exclude: [a.id] }).map((h) => h.id)).toEqual([b.id]) + // All the words first; any of them when that finds nothing. + expect(searchSessions(store, "honey nothingelse", { root: "/p" }).map((h) => h.id)).toEqual([a.id]) + expect(searchSessions(store, " ")).toEqual([]) +}) + +test("serverEntries: the memory block's `- ` lines, continuation lines kept, the 'more' line not an entry", () => { + expect(serverEntries("- likes tea\n- works on\n two lines\n- (…3 more, see your settings)")).toEqual(["likes tea", "works on\n two lines"]) + expect(serverEntries("")).toEqual([]) +}) diff --git a/tests/schedules.test.ts b/tests/schedules.test.ts index 5f7fb9d..7f5ffa1 100644 --- a/tests/schedules.test.ts +++ b/tests/schedules.test.ts @@ -50,7 +50,7 @@ function server(o: { appOnly?: boolean } = {}) { async fetch(req) { const u = new URL(req.url) if (req.headers.get("authorization") !== "Bearer lmb_key") return new Response("no", { status: 401 }) - const body = req.method === "GET" ? null : await req.json().catch(() => null) + const body: any = req.method === "GET" ? null : await req.json().catch(() => null) seen.push({ method: req.method, path: u.pathname + u.search, body }) // An older server: /api/v2 for an app's token only. if (o.appOnly) return Response.json({ detail: "This token does not have the app scope." }, { status: 403 })