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 `** 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
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, scope?: string): Promise {
+ 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: "",
+ 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 ${now ? ` — now "${now}"` : ""}`, "warn")
+ c.app.rename(title)
+ c.view.info(`named "${title}"`)
+ c.refresh()
+ },
+ },
+ {
+ name: "search",
+ args: " [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 [all] — plain words, "a phrase", OR, NOT', "warn")
+ const { searchSessions } = await import("../tool/session_search.ts")
+ let found: ReturnType
+ 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 }
+ 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 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 })