From 75a363a1d47d20d0f8869271d22941b5d199011c Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 9 Oct 2026 23:41:00 -0300 Subject: [PATCH 1/2] feat(mods): agent-facing text follows LICH_PROMPT_LANG (en, pt-BR) --- CHANGELOG.md | 8 ++ hooks/agent-cards.js | 55 ++++++---- hooks/edit-guard.js | 15 +-- hooks/mod-control.js | 25 ++--- hooks/prompt-text.js | 130 ++++++++++++++++++++++ hooks/self-command.js | 22 ++-- tests/prompt-text.test.mjs | 217 +++++++++++++++++++++++++++++++++++++ 7 files changed, 412 insertions(+), 60 deletions(-) create mode 100644 hooks/prompt-text.js create mode 100644 tests/prompt-text.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c34a648..4d4df68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **The text the mods add for the agent follows lich's prompt language.** The + side-question preamble, the subagent card notes and denies, the edit guard's + note and the self-command notes now come in English or Brazilian Portuguese, + chosen by `LICH_PROMPT_LANG`, which lich exports into every session; an + empty or unknown value is English. `[lich]`, tool and command names and + status values stay literal, and `Successfully stopped task` keeps Claude + Code's own wording. The texts live in `hooks/prompt-text.js`. + - **A lich subagent worker whose turn ends blank now says so.** A final turn whose message is blank, with nothing left running in the background, posts `unanswered: "blank"` to `/mod/answer`, so the caller hears the errand ended diff --git a/hooks/agent-cards.js b/hooks/agent-cards.js index a462910..731981e 100644 --- a/hooks/agent-cards.js +++ b/hooks/agent-cards.js @@ -22,6 +22,7 @@ // Every function that takes `$` is declared at the top level: the loader // refuses a module that hands `$` to a nested function or keeps it in a variable. +import { promptLang, say } from "./prompt-text.js" import { setStatusPart } from "./status-line.js" // lich's `promptLimit` (internal/relay). lich checks it only after the session @@ -187,11 +188,12 @@ async function openWorker($, lich, e, branch, base) { * @param {AgentCall} e * @param {Opened} opened * @param {string} branch + * @param {string} lang */ -function backgrounded(e, opened, branch) { +function backgrounded(e, opened, branch, lang) { const where = branch - ? `on branch ${branch} in ${opened.path}, not in this checkout` - : `in this same checkout, ${opened.path}, and edits its files as you do` + ? say(lang, "whereBranch", { branch, path: opened.path }) + : say(lang, "whereShared", { path: opened.path }) return { result: { status: /** @type {const} */ ("async_launched"), @@ -200,12 +202,7 @@ function backgrounded(e, opened, branch) { prompt: e.prompt, outputFile: "", }, - context: [ - `The agent runs as the lich session "${opened.label}", ${where}. ` + - `Its full report arrives at this prompt on its own as a [lich] note, not as a task notification, so there ` + - `is no need to call wait_for_answer (it still works). SendMessage cannot reach that session; ` + - `send_to_session or lich send can.`, - ], + context: [say(lang, "backgrounded", { label: opened.label, where })], } } @@ -249,28 +246,35 @@ function completed(e, opened, branch, text, durationMs) { * @param {string} branch * @param {Report} report * @param {number} durationMs + * @param {string} lang */ -function answerFor(e, opened, branch, report, durationMs) { +function answerFor(e, opened, branch, report, durationMs, lang) { switch (report.status) { // A worker whose turn ended unanswered is usually still at it: it left a // command running in the background and resumes when it finishes, and its // report still arrives as a [lich] note. case "pending": case "unanswered": - return backgrounded(e, opened, branch) + return backgrounded(e, opened, branch, lang) case "answered": { - const where = - (branch - ? `The work is on branch ${branch} in ${opened.path} (lich session "${opened.label}"), not in this checkout. ` - : `The work is in this same checkout, ${opened.path} (lich session "${opened.label}"). `) + - `Reach that session with send_to_session or lich send, not SendMessage.` + const where = say(lang, branch ? "completedBranch" : "completedShared", { + branch, + path: opened.path, + label: opened.label, + }) return { result: completed(e, opened, branch, `${report.answer}\n\n${where}`, durationMs) } } case "unread": case "undelivered": - return { deny: `the task never reached "${opened.label}" (${report.status}): open its card.` } + return { deny: say(lang, "denyNeverReached", { label: opened.label, status: report.status }) } default: - return { deny: `lich answered "${report.status}" about "${opened.label}"${branch ? `, on branch ${branch}` : ""}: open its card.` } + return { + deny: say(lang, "denyAnswered", { + status: report.status, + label: opened.label, + branchPart: branch ? `, ${say(lang, "onBranch", { branch })}` : "", + }), + } } } @@ -304,11 +308,12 @@ function runNatively($, e, next, reason) { */ async function runAsSession($, state, e, next) { if (!isDelegable(e, next, state)) return next(e) - const [lich, session, cards, depth] = await Promise.all([ + const [lich, session, cards, depth, lang] = await Promise.all([ $.env.get("LICH_BIN"), $.env.get("LICH_SESSION_ID"), $.env.get("LICH_SUBAGENT_CARDS"), $.env.get("LICH_SUBAGENT_DEPTH"), + promptLang($), ]) if (!lich || !session) return next(e) if (cards === CARDS_OFF) { @@ -332,12 +337,14 @@ async function runAsSession($, state, e, next) { } catch (error) { if (next.signal.aborted) { return { - deny: `interrupted; a lich session${branch ? ` on branch ${branch}` : ""} may already have the task, and a report it sends arrives here as a [lich] note.`, + deny: say(lang, "denyInterrupted", { + branchPart: branch ? ` ${say(lang, "onBranch", { branch })}` : "", + }), } } return runNatively($, e, next, messageOf(error)) } - const answer = answerFor(e, opened, branch, opened.delivery, (await $.clock.now()) - startedMs) + const answer = answerFor(e, opened, branch, opened.delivery, (await $.clock.now()) - startedMs, lang) if (opened.delivery.status === "pending") trackWorker($, state, lich, opened, e, branch) return answer } @@ -438,6 +445,7 @@ async function stopWorker($, state, e, next) { const id = e.task_id ?? e.shell_id ?? "" const worker = state.workers.get(id) if (worker === undefined) return next(e) + const lang = await promptLang($) const argv = worker.shared ? [state.lich, "close", worker.name] : [state.lich, "control", worker.name, "abort"] @@ -445,10 +453,11 @@ async function stopWorker($, state, e, next) { try { outcome = await $.process.run(argv) } catch (error) { - return { deny: `lich could not stop "${worker.label}": ${messageOf(error)}` } + return { deny: say(lang, "denyStopFailed", { label: worker.label, reason: messageOf(error) }) } } if (outcome.exitCode !== 0) { - return { deny: `lich could not stop "${worker.label}": ${outcome.stderr.trim() || `lich exited ${outcome.exitCode}`}` } + const reason = outcome.stderr.trim() || say(lang, "lichExited", { code: outcome.exitCode }) + return { deny: say(lang, "denyStopFailed", { label: worker.label, reason }) } } state.workers.delete(id) showWorkers($, state) diff --git a/hooks/edit-guard.js b/hooks/edit-guard.js index 2ca231c..8ff9bf3 100644 --- a/hooks/edit-guard.js +++ b/hooks/edit-guard.js @@ -11,6 +11,7 @@ // Every function that takes `$` is declared here at the top level: the loader // refuses a module that hands `$` to a nested function. +import { promptLang, say } from "./prompt-text.js" import { setStatusPart } from "./status-line.js" // Long enough to span the other session's turn that made the edit, short @@ -105,16 +106,12 @@ async function labelOf($, id) { * @param {Marker} marker * @param {string} who * @param {number} now + * @param {string} lang */ -function noteFor(marker, who, now) { +function noteFor(marker, who, now, lang) { const minutes = Math.round((now - marker.at) / 60_000) - const ago = minutes === 0 ? "under a minute ago" : `${minutes} min ago` - return ( - `${marker.path} was also edited by the lich session ${who} at ` + - `${new Date(marker.at).toISOString()} (${ago}), which shares this checkout and may still be ` + - `working on it. Re-read the file before building on it, and coordinate with that session through ` + - `send_to_session or lich send rather than undoing its change.` - ) + const ago = minutes === 0 ? say(lang, "editNoteJustNow") : say(lang, "editNoteAgo", { minutes }) + return say(lang, "editNote", { path: marker.path, who, at: new Date(marker.at).toISOString(), ago }) } /** @@ -195,7 +192,7 @@ async function guard($, e, next) { const now = await $.clock.now() if (isNews(previous, session, now)) { const who = await whoMarked($, previous) - note = noteFor(previous, who, now) + note = noteFor(previous, who, now, await promptLang($)) showEditBy($, previous, who) $.clock.after(previous.at + RECENT_MS - now, () => clearEditBy($, previous)) } diff --git a/hooks/mod-control.js b/hooks/mod-control.js index 9a7e7b9..7db3975 100644 --- a/hooks/mod-control.js +++ b/hooks/mod-control.js @@ -12,6 +12,8 @@ // function that takes it is declared here at the top level: the loader refuses // a module that hands `$` to a nested function. +import { promptLang, say } from "./prompt-text.js" + // Sent as X-Lich-Plugin on every request; bumped at release (CLAUDE.md, Release). const PLUGIN_VERSION = "0.19.2" @@ -27,16 +29,6 @@ const MAX_BACKOFF_MS = 10000 // the hook, so a level outside these is refused here, where lich hears of it. const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]) -// Put before an `ask`'s question. Without it, a fork made while a turn runs -// reaches for a tool, is refused (a fork has none) and answers in a second -// request, at twice the latency and the uncached tokens: measured on Claude -// Code 2.1.289. -const ASK_PREAMBLE = - "This is a side question asked from outside your turn, while you work. It does not " + - "interrupt your turn and your answer is not added to the conversation. Tools are " + - "unavailable: do not call any tool. Answer from what the conversation already holds, " + - "in plain text, briefly. Question: " - // The contract cuts an answer at this many UTF-16 units, which keeps an ack // under lich's 64 KiB body limit; a fork has no length bound of its own. const ANSWER_LIMIT = 16000 @@ -58,6 +50,7 @@ const ANSWER_LIMIT = 16000 * turnId?: string, * model?: string, * effort?: Effort, + * lang?: string, * }} State */ @@ -178,7 +171,7 @@ async function apply($, state, command) { await $.command.run({ command: command.name ?? "", args: command.args }) return case "ask": - return { answer: await answer($, command.question ?? "") } + return { answer: await answer($, command.question ?? "", state.lang) } default: throw new Error("unknown kind") } @@ -213,9 +206,14 @@ function escapeXml(value) { * * @param {Engine} $ * @param {string} question + * @param {string} lang */ -async function answer($, question) { - const reply = await $.model.fork({ prompt: ASK_PREAMBLE + question }) +async function answer($, question, lang) { + // The preamble goes before the question: without it, a fork made while a turn + // runs reaches for a tool, is refused (a fork has none) and answers in a second + // request, at twice the latency and the uncached tokens: measured on Claude + // Code 2.1.289. + const reply = await $.model.fork({ prompt: say(lang, "askPreamble") + question }) if (!reply.isAnswered) { throw new Error(reply.reason === "api-error" ? `api-error ${reply.status} ${reply.error}` : reply.reason) } @@ -234,6 +232,7 @@ export function register(on) { // session's variables, so it would also take the parent's commands. if (e.isInteractive && state.link === undefined) { state.link = await linkFromEnv($) + state.lang = await promptLang($) const link = state.link if (link) $.clock.after(0, () => void poll($, state, link)) } diff --git a/hooks/prompt-text.js b/hooks/prompt-text.js new file mode 100644 index 0000000..04c0eec --- /dev/null +++ b/hooks/prompt-text.js @@ -0,0 +1,130 @@ +// The agent-facing text the hooks add to a session, in every language lich can +// talk to its agents in. lich exports the user's prompt language into every +// session as LICH_PROMPT_LANG (`internal/prompt` there); an empty or unknown +// value is English, as it is for lich itself. +// +// A text is a template with `{name}` slots. Tool and command names, the `[lich]` +// marker and the status enums are literal in every locale: the model matches on +// them, and so does the contract. + +const ENV_VAR = "LICH_PROMPT_LANG" + +/** @type {Record} */ +export const en = { + askPreamble: + "This is a side question asked from outside your turn, while you work. It does not " + + "interrupt your turn and your answer is not added to the conversation. Tools are " + + "unavailable: do not call any tool. Answer from what the conversation already holds, " + + "in plain text, briefly. Question: ", + onBranch: "on branch {branch}", + whereBranch: "on branch {branch} in {path}, not in this checkout", + whereShared: "in this same checkout, {path}, and edits its files as you do", + backgrounded: + 'The agent runs as the lich session "{label}", {where}. ' + + "Its full report arrives at this prompt on its own as a [lich] note, not as a task notification, so there " + + "is no need to call wait_for_answer (it still works). SendMessage cannot reach that session; " + + "send_to_session or lich send can.", + completedBranch: + 'The work is on branch {branch} in {path} (lich session "{label}"), not in this checkout. ' + + "Reach that session with send_to_session or lich send, not SendMessage.", + completedShared: + 'The work is in this same checkout, {path} (lich session "{label}"). ' + + "Reach that session with send_to_session or lich send, not SendMessage.", + denyNeverReached: 'the task never reached "{label}" ({status}): open its card.', + denyAnswered: 'lich answered "{status}" about "{label}"{branchPart}: open its card.', + denyInterrupted: + "interrupted; a lich session{branchPart} may already have the task, and a report it sends arrives here as a [lich] note.", + denyStopFailed: 'lich could not stop "{label}": {reason}', + lichExited: "lich exited {code}", + editNoteJustNow: "under a minute ago", + editNoteAgo: "{minutes} min ago", + editNote: + "{path} was also edited by the lich session {who} at {at} ({ago}), which shares this checkout and may " + + "still be working on it. Re-read the file before building on it, and coordinate with that session through " + + "send_to_session or lich send rather than undoing its change.", + skillNote: + "\n\nIn this session a built-in slash command (compact, clear, and the like) can be named here too, " + + "when the user asks you to run it: it is queued and runs once your turn ends, so end your turn right after.", + controlNote: + "\n\nIn this session, action command is accepted with this session itself as the target: pass session " + + '"{sessionId}". The command is queued and runs once your turn ends, so end your turn right after.', + denyDefaultSaved: + "/{name} run from inside the session would be saved as the default for every new Claude Code " + + "session. Ask the user to set it from lich, whose {name} override applies to this session only.", + skillQueued: "{queued} is queued and runs once this turn ends. End the turn now.", + controlQueued: "{queued} is queued on this session and runs once this turn ends. End the turn now.", +} + +/** @type {Record} */ +export const ptBR = { + askPreamble: + "Esta é uma pergunta lateral feita de fora do seu turno, enquanto você trabalha. Ela não " + + "interrompe o seu turno e a sua resposta não entra na conversa. As ferramentas estão " + + "indisponíveis: não chame nenhuma. Responda com o que a conversa já contém, " + + "em texto simples e de forma breve. Pergunta: ", + onBranch: "na branch {branch}", + whereBranch: "na branch {branch} em {path}, não neste checkout", + whereShared: "neste mesmo checkout, {path}, e edita os arquivos dele como você", + backgrounded: + 'O agente roda como a sessão lich "{label}", {where}. ' + + "O relatório completo chega neste prompt por conta própria como uma nota [lich], não como task notification, " + + "então não é preciso chamar wait_for_answer (ele ainda funciona). SendMessage não alcança essa sessão; " + + "send_to_session ou lich send alcançam.", + completedBranch: + 'O trabalho está na branch {branch} em {path} (sessão lich "{label}"), não neste checkout. ' + + "Fale com essa sessão por send_to_session ou lich send, não por SendMessage.", + completedShared: + 'O trabalho está neste mesmo checkout, {path} (sessão lich "{label}"). ' + + "Fale com essa sessão por send_to_session ou lich send, não por SendMessage.", + denyNeverReached: 'a tarefa nunca chegou a "{label}" ({status}): abra o card dela.', + denyAnswered: 'o lich respondeu "{status}" sobre "{label}"{branchPart}: abra o card dela.', + denyInterrupted: + "interrompido; uma sessão lich{branchPart} pode já ter a tarefa, e o relatório que ela enviar chega aqui como nota [lich].", + denyStopFailed: 'o lich não conseguiu parar "{label}": {reason}', + lichExited: "o lich saiu com {code}", + editNoteJustNow: "há menos de um minuto", + editNoteAgo: "há {minutes} min", + editNote: + "{path} também foi editado pela sessão lich {who} em {at} ({ago}), que divide este checkout e pode " + + "ainda estar trabalhando nele. Releia o arquivo antes de continuar a partir dele e combine com essa sessão por " + + "send_to_session ou lich send em vez de desfazer a mudança dela.", + skillNote: + "\n\nNesta sessão um slash command embutido (compact, clear e afins) também pode ser nomeado aqui, " + + "quando o usuário pedir para você executá-lo: ele entra na fila e roda quando o seu turno terminar, então encerre o turno logo em seguida.", + controlNote: + "\n\nNesta sessão, a ação command aceita a própria sessão como alvo: passe session " + + '"{sessionId}". O comando entra na fila e roda quando o seu turno terminar, então encerre o turno logo em seguida.', + denyDefaultSaved: + "/{name} executado de dentro da sessão seria salvo como padrão de toda nova sessão do Claude Code. " + + "Peça ao usuário para definir pelo lich, cujo override de {name} vale só para esta sessão.", + skillQueued: "{queued} está na fila e roda quando este turno terminar. Encerre o turno agora.", + controlQueued: "{queued} está na fila nesta sessão e roda quando este turno terminar. Encerre o turno agora.", +} + +/** @type {Record>} */ +const catalogs = { en, "pt-BR": ptBR } + +/** + * The text `key` names in `lang`, its slots filled from `vars`. English stands + * in for an unknown language; a key missing from English throws, which the + * parity test turns into a failure long before a session sees it. + * + * @param {string | undefined} lang + * @param {string} key + * @param {Record} [vars] + */ +export function say(lang, key, vars = {}) { + const template = (catalogs[lang ?? "en"] ?? en)[key] ?? en[key] + if (template === undefined) throw new Error(`no prompt text named "${key}"`) + return template.replace(/\{(\w+)\}/g, (slot, name) => String(vars[name] ?? slot)) +} + +/** + * The prompt language this session was started with. + * + * @param {import('claude-code').EngineInterface} $ + */ +export async function promptLang($) { + const tag = await $.env.get(ENV_VAR) + return tag && tag in catalogs ? tag : "en" +} diff --git a/hooks/self-command.js b/hooks/self-command.js index c638e49..58fb927 100644 --- a/hooks/self-command.js +++ b/hooks/self-command.js @@ -15,14 +15,12 @@ // Every function that takes `$` is declared here at the top level: the loader // refuses a module that hands `$` to a nested function. +import { promptLang, say } from "./prompt-text.js" + // Run through a mod, these save what they set as the default for every new // session (measured on Claude Code 2.1.288 and 2.1.289). const SAVES_A_DEFAULT = new Set(["model", "effort"]) -const SKILL_NOTE = - "\n\nIn this session a built-in slash command (compact, clear, and the like) can be named here too, " + - "when the user asks you to run it: it is queued and runs once your turn ends, so end your turn right after." - // The name Claude Code gives lich's tool (measured on 2.1.295 with lich's MCP // server). const CONTROL_TOOL = "mcp__lich__control_session" @@ -31,11 +29,6 @@ const WHOAMI_TIMEOUT_MS = 2_000 // `list_sessions` leaves this session out, so the model does not know its own // label; the note names it by `LICH_SESSION_ID`, which lich takes as a target too. -/** @param {string} sessionId */ -const controlNote = (sessionId) => - `\n\nIn this session, action command is accepted with this session itself as the target: pass session ` + - `"${sessionId}". The command is queued and runs once your turn ends, so end your turn right after.` - /** * @typedef {import('claude-code').EngineInterface} Engine * @typedef {{ sessionId: string | undefined }} State @@ -80,8 +73,7 @@ async function queueBuiltin($, name, args) { if (SAVES_A_DEFAULT.has(name)) { return { deny: - `/${name} run from inside the session would be saved as the default for every new Claude Code ` + - `session. Ask the user to set it from lich, whose ${name} override applies to this session only.`, + say(await promptLang($), "denyDefaultSaved", { name }), } } if (!(await isBuiltin($, name))) return undefined @@ -127,7 +119,7 @@ export function register(on) { on("tool.describe", { tool: "Skill" }, async ($, e, next) => { const described = await next(e) if (!state.sessionId) return described - return { ...described, description: described.description + SKILL_NOTE } + return { ...described, description: described.description + say(await promptLang($), "skillNote") } }) // The Skill tool runs first: a skill, or a built-in it serves as a prompt @@ -141,14 +133,14 @@ export function register(on) { if (typeof queued !== "string") return queued return { result: { success: true, commandName: name }, - context: [`${queued} is queued and runs once this turn ends. End the turn now.`], + context: [say(await promptLang($), "skillQueued", { queued })], } }) on("tool.describe", { tool: CONTROL_TOOL }, async ($, e, next) => { const described = await next(e) if (!state.sessionId) return described - return { ...described, description: described.description + controlNote(state.sessionId) } + return { ...described, description: described.description + say(await promptLang($), "controlNote", { sessionId: state.sessionId }) } }) // lich answers first: a command for another session is its own. Only its @@ -161,6 +153,6 @@ export function register(on) { const queued = await queueBuiltin($, bareName(e.value), e.args?.trim() ?? "") if (queued === undefined) return native if (typeof queued !== "string") return queued - return { result: `${queued} is queued on this session and runs once this turn ends. End the turn now.` } + return { result: say(await promptLang($), "controlQueued", { queued }) } }) } diff --git a/tests/prompt-text.test.mjs b/tests/prompt-text.test.mjs new file mode 100644 index 0000000..da56928 --- /dev/null +++ b/tests/prompt-text.test.mjs @@ -0,0 +1,217 @@ +// Pins the agent-facing text catalog (hooks/prompt-text.js) and that each hook +// renders it in the prompt language lich exports as LICH_PROMPT_LANG. The +// English texts are pinned verbatim by each hook's own suite; this one holds +// the catalogs level with each other and runs one text per hook in pt-BR. +// +// Run: node --test tests/ + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { en, ptBR, say, promptLang } from '../hooks/prompt-text.js' +import { register as registerAgentCards } from '../hooks/agent-cards.js' +import { register as registerEditGuard } from '../hooks/edit-guard.js' +import { register as registerSelfCommand } from '../hooks/self-command.js' +import { register as registerModControl } from '../hooks/mod-control.js' + +const slots = (text) => [...text.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort() + +// Names the model or the contract matches on: kept literal in every locale. +const LITERALS = [ + '[lich]', + 'send_to_session', + 'wait_for_answer', + 'SendMessage', + 'lich send', + 'mcp__lich__control_session', + 'subagent/', +] + +test('every English text has a pt-BR twin and the other way round', () => { + assert.deepEqual(Object.keys(ptBR).sort(), Object.keys(en).sort()) +}) + +test('a translation fills the same slots as the English text', () => { + for (const key of Object.keys(en)) assert.deepEqual(slots(ptBR[key]), slots(en[key]), key) +}) + +test('names that must stay literal are kept in every translation', () => { + for (const key of Object.keys(en)) { + for (const literal of LITERALS) { + if (en[key].includes(literal)) assert.ok(ptBR[key].includes(literal), `${key} lost ${literal}`) + } + } +}) + +test('an unknown or absent language says the English text', () => { + for (const lang of [undefined, '', 'fr', 'PT-br']) assert.equal(say(lang, 'lichExited', { code: 2 }), 'lich exited 2') + assert.equal(say('pt-BR', 'lichExited', { code: 2 }), 'o lich saiu com 2') +}) + +test('a key no catalog has is an error, not an empty text', () => { + assert.throws(() => say('en', 'nope'), /no prompt text named "nope"/) +}) + +test('the language is read from LICH_PROMPT_LANG, English when unset or unknown', async () => { + const lang = (value) => promptLang({ env: { get: async (n) => (n === 'LICH_PROMPT_LANG' ? value : undefined) } }) + assert.equal(await lang('pt-BR'), 'pt-BR') + assert.equal(await lang('en'), 'en') + assert.equal(await lang(undefined), 'en') + assert.equal(await lang('klingon'), 'en') +}) + +// ------------------------------------------------------------ per hook, pt-BR -- + +const PT = { LICH_PROMPT_LANG: 'pt-BR' } + +function hooksOf(register) { + const hooks = new Map() + register((event, ...rest) => { + const matcher = rest.length > 1 ? rest[0] : {} + hooks.set(`${event} ${matcher.tool ?? ''}`.trim(), rest.at(-1)) + }) + return hooks +} + +const pass = (value) => async () => value +const exits = (code, stdout = '', stderr = '') => ({ exitCode: code, stdout, stderr }) + +test('agent-cards: the background note, the completed addendum and the denies speak pt-BR', async () => { + const OPENED = { name: 'w-1', label: 'w', path: '/wt/w', id: 'x' } + const env = { ...PT, LICH_BIN: '/lich', LICH_SESSION_ID: 'lich-1' } + const call = async (status, { stderr = '' } = {}) => { + const hooks = hooksOf(registerAgentCards) + const runs = [exits(2, JSON.stringify({ ...OPENED, delivery: { ticket: 't', target: 'w', status, answer: 'feito' } }), stderr)] + const $ = { + env: { get: async (n) => env[n] }, + clock: { now: async () => 0, after: () => ({ cancel() {} }) }, + process: { run: async () => runs.shift() }, + ui: { toast() {}, invalidate() {} }, + } + await hooks.get('session.start')($, { isInteractive: true }, async (x) => x) + const next = Object.assign(pass({ result: 'native' }), { origin: { plugin: 'engine' }, signal: new AbortController().signal }) + const e = { tool: 'Agent', tool_use_id: 't1', description: 'd', prompt: 'p', subagent_type: 'general-purpose' } + return hooks.get('tool.call Agent')($, e, next) + } + + const background = await call('pending') + assert.match(background.context[0], /^O agente roda como a sessão lich "w", neste mesmo checkout, \/wt\/w/) + assert.match(background.context[0], /\[lich\].*wait_for_answer.*SendMessage.*send_to_session ou lich send/) + + const done = await call('answered') + assert.match(done.result.content[0].text, /^feito\n\nO trabalho está neste mesmo checkout, \/wt\/w \(sessão lich "w"\)\./) + + assert.equal((await call('unread')).deny, 'a tarefa nunca chegou a "w" (unread): abra o card dela.') +}) + +test('agent-cards: TaskStop failure speaks pt-BR, the success message stays Claude Code\'s own wording', async () => { + const env = { ...PT, LICH_BIN: '/lich', LICH_SESSION_ID: 'lich-1' } + const hooks = hooksOf(registerAgentCards) + const runs = [ + exits(2, JSON.stringify({ name: 'w-1', label: 'w', path: '/w', delivery: { ticket: 't', target: 'w', status: 'pending', answer: '' } })), + exits(1, '', 'boom'), + exits(0, 'ok'), + ] + const $ = { + env: { get: async (n) => env[n] }, + clock: { now: async () => 0, after: () => ({ cancel() {} }) }, + process: { run: async () => runs.shift() }, + ui: { toast() {}, invalidate() {} }, + } + await hooks.get('session.start')($, { isInteractive: true }, async (x) => x) + const next = Object.assign(pass({ result: 'native' }), { origin: { plugin: 'engine' }, signal: new AbortController().signal }) + await hooks.get('tool.call Agent')($, { tool: 'Agent', tool_use_id: 't1', description: 'd', prompt: 'p', subagent_type: 'general-purpose' }, next) + + const failed = await hooks.get('tool.call TaskStop')($, { tool: 'TaskStop', task_id: 'w-1' }, next) + assert.equal(failed.deny, 'o lich não conseguiu parar "w": boom') + const stopped = await hooks.get('tool.call TaskStop')($, { tool: 'TaskStop', task_id: 'w-1' }, next) + assert.equal(stopped.result.message, 'Successfully stopped task: w-1 (d)') +}) + +test('edit-guard: the note about another session speaks pt-BR', async () => { + const MIN = 60_000 + const T0 = Date.parse('2026-10-05T12:00:00.000Z') + const disk = new Map() + let now = T0 + const hooks = hooksOf(registerEditGuard) + const session = (id) => { + const env = { ...PT, LICH_SESSION_ID: id } + const $ = { + env: { get: async (n) => env[n] }, + clock: { now: async () => now, after: () => ({ cancel() {} }) }, + ui: { log() {}, invalidate() {} }, + process: { run: async () => exits(0, '/r/.git\n') }, + fs: { + exists: async (p) => disk.has(p), + read: async (p) => disk.get(p), + write: async (p, t) => void disk.set(p, t), + }, + } + return () => hooks.get('tool.call Edit')($, { tool: 'Edit', file_path: '/r/a.js' }, pass({ result: {}, text: 'ok' })) + } + await session('lich-a')() + now = T0 + 3 * MIN + const result = await session('lich-b')() + assert.match(result.context[0], /^\/r\/a\.js também foi editado pela sessão lich lich-a em 2026-10-05T12:00:00\.000Z \(há 3 min\)/) + assert.match(result.context[0], /send_to_session ou lich send/) +}) + +test('self-command: the tool notes and the queued answers speak pt-BR', async () => { + const hooks = hooksOf(registerSelfCommand) + const env = { ...PT, LICH_SESSION_ID: 'lich-1', LICH_BIN: '/lich' } + const $ = { + env: { get: async (n) => env[n] }, + clock: { after() {} }, + command: { list: async () => [{ name: 'compact', source: 'builtin' }, { name: 'model', source: 'builtin' }], run: async () => ({}) }, + ui: { toast() {}, invalidate() {} }, + process: { run: async () => exits(0, JSON.stringify({ label: 'x', name: 'y' })) }, + } + await hooks.get('session.start')($, { isInteractive: true }, async (e) => e) + + const skill = await hooks.get('tool.describe Skill')($, { tool: 'Skill', description: 'base' }, async (e) => e) + assert.match(skill.description, /^base\n\nNesta sessão um slash command embutido/) + const control = await hooks.get('tool.describe mcp__lich__control_session')( + $, + { tool: 'mcp__lich__control_session', description: 'base' }, + async (e) => e, + ) + assert.match(control.description, /passe session "lich-1"\./) + + const refused = { isError: true, result: 'compact is a built-in CLI command, not a skill' } + const queued = await hooks.get('tool.call Skill')($, { tool: 'Skill', skill: 'compact' }, async () => refused) + assert.equal(queued.context[0], '/compact está na fila e roda quando este turno terminar. Encerre o turno agora.') + + const denied = await hooks.get('tool.call Skill')($, { tool: 'Skill', skill: 'model', args: 'opus' }, async () => refused) + assert.match(denied.deny, /^\/model executado de dentro da sessão/) +}) + +test('mod-control: the side question is prefaced in pt-BR', async () => { + const hooks = hooksOf(registerModControl) + const forks = [] + const env = { ...PT, LICH_PORT: '1', LICH_TOKEN: 't', LICH_SESSION_ID: 's' } + let served = false + const $ = { + env: { get: async (n) => env[n] }, + clock: { after: (ms, fn) => setImmediate(fn) }, + http: { + fetch: async (url) => { + if (url.includes('/mod/acks')) return { status: 204, ok: true, headers: {}, text: '' } + if (served) return new Promise(() => {}) + served = true + const body = [{ id: 'm1', kind: 'ask', question: 'o que faz?' }] + return { status: 200, ok: true, headers: {}, text: JSON.stringify(body) } + }, + }, + model: { + fork: async (args) => { + forks.push(args) + return { isAnswered: true, text: 'a', usage: {} } + }, + }, + } + await hooks.get('session.start')($, { isInteractive: true }, async (e) => e) + for (let i = 0; i < 50 && forks.length === 0; i++) await new Promise((r) => setImmediate(r)) + assert.equal(forks.length, 1) + assert.match(forks[0].prompt, /^Esta é uma pergunta lateral feita de fora do seu turno/) + assert.ok(forks[0].prompt.endsWith('Pergunta: o que faz?')) +}) From 932a59aa6b2b31e589bcc0e360aec2f05bd99ef8 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 9 Oct 2026 23:43:13 -0300 Subject: [PATCH 2/2] fix(mods): read LICH_PROMPT_LANG at the hook's call site The engine never follows $ across an import, so promptLang($) in prompt-text.js kept every mod from loading (claude plugin test: 0/33). promptLang now maps a plain LICH_PROMPT_LANG value to a catalog language and each hook reads the env itself. Docs for agent-cards and self-command name the language. --- docs/agent-cards.md | 2 ++ docs/self-command.md | 2 ++ hooks/agent-cards.js | 7 ++++--- hooks/edit-guard.js | 2 +- hooks/mod-control.js | 2 +- hooks/prompt-text.js | 10 ++++------ hooks/self-command.js | 10 +++++----- tests/prompt-text.test.mjs | 2 +- 8 files changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/agent-cards.md b/docs/agent-cards.md index 29c175c..3cbb6b1 100644 --- a/docs/agent-cards.md +++ b/docs/agent-cards.md @@ -6,6 +6,8 @@ asking session. It follows the native agent's semantics: it works in the asking session's checkout, or in a worktree of its own when the call asks for `isolation: "worktree"`. +The text this mod adds for the agent follows `LICH_PROMPT_LANG`, the prompt language lich exports into every session (English when unset or unknown; catalog in `hooks/prompt-text.js`). + There is no HTTP contract behind it. It drives the lich CLI the asking session already has, `$LICH_BIN open`, so what it reads is what that command prints (`docs/cli.md` in the lich repository: `open --json`, Exit status). diff --git a/docs/self-command.md b/docs/self-command.md index cbf0a09..bb4a13b 100644 --- a/docs/self-command.md +++ b/docs/self-command.md @@ -6,6 +6,8 @@ behind it and nothing is sent to lich: lich's `control` refuses a session controlling itself on purpose (`docs/ceilings.md` in lich, "A session cannot control itself"), and the mod runs in the session's own process anyway. +The text this mod adds for the agent follows `LICH_PROMPT_LANG`, the prompt language lich exports into every session (English when unset or unknown; catalog in `hooks/prompt-text.js`). + | client | Claude Code | Codex | Antigravity | opencode | omp | Crush | |-------------------------|---------------------------------------------------------|---------------------|---------------------|---------------------|---------------------|---------------------| | `hooks/self-command.js` | mod, registered by `hooks/lich.js`, the `modules` entry | none: no mod system | none: no mod system | none: no mod system | none: no mod system | none: no mod system | diff --git a/hooks/agent-cards.js b/hooks/agent-cards.js index 731981e..d63d8b1 100644 --- a/hooks/agent-cards.js +++ b/hooks/agent-cards.js @@ -308,13 +308,14 @@ function runNatively($, e, next, reason) { */ async function runAsSession($, state, e, next) { if (!isDelegable(e, next, state)) return next(e) - const [lich, session, cards, depth, lang] = await Promise.all([ + const [lich, session, cards, depth, langTag] = await Promise.all([ $.env.get("LICH_BIN"), $.env.get("LICH_SESSION_ID"), $.env.get("LICH_SUBAGENT_CARDS"), $.env.get("LICH_SUBAGENT_DEPTH"), - promptLang($), + $.env.get("LICH_PROMPT_LANG"), ]) + const lang = promptLang(langTag) if (!lich || !session) return next(e) if (cards === CARDS_OFF) { if (!isWorkerDepth(depth)) return next(e) @@ -445,7 +446,7 @@ async function stopWorker($, state, e, next) { const id = e.task_id ?? e.shell_id ?? "" const worker = state.workers.get(id) if (worker === undefined) return next(e) - const lang = await promptLang($) + const lang = promptLang(await $.env.get("LICH_PROMPT_LANG")) const argv = worker.shared ? [state.lich, "close", worker.name] : [state.lich, "control", worker.name, "abort"] diff --git a/hooks/edit-guard.js b/hooks/edit-guard.js index 8ff9bf3..9668931 100644 --- a/hooks/edit-guard.js +++ b/hooks/edit-guard.js @@ -192,7 +192,7 @@ async function guard($, e, next) { const now = await $.clock.now() if (isNews(previous, session, now)) { const who = await whoMarked($, previous) - note = noteFor(previous, who, now, await promptLang($)) + note = noteFor(previous, who, now, promptLang(await $.env.get("LICH_PROMPT_LANG"))) showEditBy($, previous, who) $.clock.after(previous.at + RECENT_MS - now, () => clearEditBy($, previous)) } diff --git a/hooks/mod-control.js b/hooks/mod-control.js index 7db3975..f5b1762 100644 --- a/hooks/mod-control.js +++ b/hooks/mod-control.js @@ -232,7 +232,7 @@ export function register(on) { // session's variables, so it would also take the parent's commands. if (e.isInteractive && state.link === undefined) { state.link = await linkFromEnv($) - state.lang = await promptLang($) + state.lang = promptLang(await $.env.get("LICH_PROMPT_LANG")) const link = state.link if (link) $.clock.after(0, () => void poll($, state, link)) } diff --git a/hooks/prompt-text.js b/hooks/prompt-text.js index 04c0eec..dd33f9f 100644 --- a/hooks/prompt-text.js +++ b/hooks/prompt-text.js @@ -7,8 +7,6 @@ // marker and the status enums are literal in every locale: the model matches on // them, and so does the contract. -const ENV_VAR = "LICH_PROMPT_LANG" - /** @type {Record} */ export const en = { askPreamble: @@ -120,11 +118,11 @@ export function say(lang, key, vars = {}) { } /** - * The prompt language this session was started with. + * The catalog language for a LICH_PROMPT_LANG value. The hook reads the env + * itself: the engine never follows $ across an import. * - * @param {import('claude-code').EngineInterface} $ + * @param {string | undefined} tag */ -export async function promptLang($) { - const tag = await $.env.get(ENV_VAR) +export function promptLang(tag) { return tag && tag in catalogs ? tag : "en" } diff --git a/hooks/self-command.js b/hooks/self-command.js index 58fb927..fc2c5de 100644 --- a/hooks/self-command.js +++ b/hooks/self-command.js @@ -73,7 +73,7 @@ async function queueBuiltin($, name, args) { if (SAVES_A_DEFAULT.has(name)) { return { deny: - say(await promptLang($), "denyDefaultSaved", { name }), + say(promptLang(await $.env.get("LICH_PROMPT_LANG")), "denyDefaultSaved", { name }), } } if (!(await isBuiltin($, name))) return undefined @@ -119,7 +119,7 @@ export function register(on) { on("tool.describe", { tool: "Skill" }, async ($, e, next) => { const described = await next(e) if (!state.sessionId) return described - return { ...described, description: described.description + say(await promptLang($), "skillNote") } + return { ...described, description: described.description + say(promptLang(await $.env.get("LICH_PROMPT_LANG")), "skillNote") } }) // The Skill tool runs first: a skill, or a built-in it serves as a prompt @@ -133,14 +133,14 @@ export function register(on) { if (typeof queued !== "string") return queued return { result: { success: true, commandName: name }, - context: [say(await promptLang($), "skillQueued", { queued })], + context: [say(promptLang(await $.env.get("LICH_PROMPT_LANG")), "skillQueued", { queued })], } }) on("tool.describe", { tool: CONTROL_TOOL }, async ($, e, next) => { const described = await next(e) if (!state.sessionId) return described - return { ...described, description: described.description + say(await promptLang($), "controlNote", { sessionId: state.sessionId }) } + return { ...described, description: described.description + say(promptLang(await $.env.get("LICH_PROMPT_LANG")), "controlNote", { sessionId: state.sessionId }) } }) // lich answers first: a command for another session is its own. Only its @@ -153,6 +153,6 @@ export function register(on) { const queued = await queueBuiltin($, bareName(e.value), e.args?.trim() ?? "") if (queued === undefined) return native if (typeof queued !== "string") return queued - return { result: say(await promptLang($), "controlQueued", { queued }) } + return { result: say(promptLang(await $.env.get("LICH_PROMPT_LANG")), "controlQueued", { queued }) } }) } diff --git a/tests/prompt-text.test.mjs b/tests/prompt-text.test.mjs index da56928..795a847 100644 --- a/tests/prompt-text.test.mjs +++ b/tests/prompt-text.test.mjs @@ -53,7 +53,7 @@ test('a key no catalog has is an error, not an empty text', () => { }) test('the language is read from LICH_PROMPT_LANG, English when unset or unknown', async () => { - const lang = (value) => promptLang({ env: { get: async (n) => (n === 'LICH_PROMPT_LANG' ? value : undefined) } }) + const lang = async (value) => promptLang(value) assert.equal(await lang('pt-BR'), 'pt-BR') assert.equal(await lang('en'), 'en') assert.equal(await lang(undefined), 'en')