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
ci / check (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 12:23:17 +00:00
1 parent a4027d5626
commit a3e9a43dd6
11 files changed
+256 -13

No files matched your search

+1
View File
@@ -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)
+1
View File
@@ -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" },
+10
View File
@@ -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[] = []
+20 -1
View File
@@ -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,
+17
View File
@@ -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
+116 -11
View File
@@ -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) ?? []
}
+28
View File
@@ -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))
}
}