Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **The agent-facing mod text also comes in Simplified Chinese and Spanish.**
`LICH_PROMPT_LANG` values `zh-CN` and `es` select the new catalogs in
`hooks/prompt-text.js`, translated from the English source; literals such as
`[lich]` and tool and command names stay as they are.

- **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,
Expand Down
90 changes: 89 additions & 1 deletion hooks/prompt-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,96 @@ export const ptBR = {
controlQueued: "{queued} está na fila nesta sessão e roda quando este turno terminar. Encerre o turno agora.",
}

/** @type {Record<string, string>} */
export const zhCN = {
askPreamble:
"这是在你的回合之外、你工作期间提出的旁路问题。它不会打断你的回合,你的回答也不会加入对话。" +
"工具不可用:不要调用任何工具。请仅根据对话中已有的内容,用纯文本简要回答。问题:",
onBranch: "在分支 {branch} 上",
whereBranch: "在分支 {branch} 上的 {path} 中,不在当前 checkout 里",
whereShared: "在同一个 checkout {path} 中,并且和你一样编辑其中的文件",
backgrounded:
'agent 作为 lich 会话 "{label}" 运行,{where}。' +
"它的完整报告会作为 [lich] 备注自动出现在此提示中,而不是 task notification,因此无需调用 " +
"wait_for_answer(调用仍然有效)。SendMessage 无法联系该会话;send_to_session 或 lich send 可以。",
completedBranch:
'工作位于分支 {branch} 上的 {path}(lich 会话 "{label}"),不在当前 checkout 里。' +
"请通过 send_to_session 或 lich send 联系该会话,不要用 SendMessage。",
completedShared:
'工作位于同一个 checkout {path}(lich 会话 "{label}")中。' +
"请通过 send_to_session 或 lich send 联系该会话,不要用 SendMessage。",
denyNeverReached: '任务从未送达 "{label}"({status}):请打开它的卡片。',
denyAnswered: 'lich 针对 "{label}"{branchPart} 返回了 "{status}":请打开它的卡片。',
denyInterrupted:
"已中断;某个 lich 会话{branchPart}可能已经拿到任务,它发送的报告会作为 [lich] 备注出现在这里。",
denyStopFailed: 'lich 无法停止 "{label}":{reason}',
lichExited: "lich 退出,退出码 {code}",
editNoteJustNow: "不到一分钟前",
editNoteAgo: "{minutes} 分钟前",
editNote:
"{path} 也被 lich 会话 {who} 在 {at}({ago})编辑过,该会话与你共用此 checkout,可能仍在处理它。" +
"在基于该文件继续之前请重新读取,并通过 send_to_session 或 lich send 与该会话协调,不要撤销它的改动。",
skillNote:
"\n\n在此会话中,内置斜杠命令(compact、clear 等)也可以在这里指定,只要用户要求你运行它:" +
"它会进入队列,在你的回合结束后运行,因此请在之后立即结束你的回合。",
controlNote:
"\n\n在此会话中,action 为 command 时可以把会话自身作为目标:传入 session " +
'"{sessionId}"。该命令会进入队列,在你的回合结束后运行,因此请在之后立即结束你的回合。',
denyDefaultSaved:
"在会话内部运行 /{name} 会把它保存为每个新 Claude Code 会话的默认值。" +
"请让用户通过 lich 来设置,lich 的 {name} 覆盖只对此会话生效。",
skillQueued: "{queued} 已进入队列,将在本回合结束后运行。现在请结束本回合。",
controlQueued: "{queued} 已在此会话中排队,将在本回合结束后运行。现在请结束本回合。",
}

/** @type {Record<string, string>} */
export const es = {
askPreamble:
"Esta es una pregunta lateral hecha desde fuera de tu turno, mientras trabajas. No " +
"interrumpe tu turno y tu respuesta no se añade a la conversación. Las herramientas no están " +
"disponibles: no llames a ninguna. Responde con lo que la conversación ya contiene, " +
"en texto plano y de forma breve. Pregunta: ",
onBranch: "en la rama {branch}",
whereBranch: "en la rama {branch} en {path}, no en este checkout",
whereShared: "en este mismo checkout, {path}, y edita sus archivos igual que tú",
backgrounded:
'El agente se ejecuta como la sesión lich "{label}", {where}. ' +
"Su informe completo llega a este prompt por sí solo como una nota [lich], no como task notification, así que no " +
"hace falta llamar a wait_for_answer (sigue funcionando). SendMessage no puede llegar a esa sesión; " +
"send_to_session o lich send sí.",
completedBranch:
'El trabajo está en la rama {branch} en {path} (sesión lich "{label}"), no en este checkout. ' +
"Comunícate con esa sesión con send_to_session o lich send, no con SendMessage.",
completedShared:
'El trabajo está en este mismo checkout, {path} (sesión lich "{label}"). ' +
"Comunícate con esa sesión con send_to_session o lich send, no con SendMessage.",
denyNeverReached: 'la tarea nunca llegó a "{label}" ({status}): abre su tarjeta.',
denyAnswered: 'lich respondió "{status}" sobre "{label}"{branchPart}: abre su tarjeta.',
denyInterrupted:
"interrumpido; puede que una sesión lich{branchPart} ya tenga la tarea, y el informe que envíe llega aquí como una nota [lich].",
denyStopFailed: 'lich no pudo detener "{label}": {reason}',
lichExited: "lich terminó con {code}",
editNoteJustNow: "hace menos de un minuto",
editNoteAgo: "hace {minutes} min",
editNote:
"{path} también fue editado por la sesión lich {who} en {at} ({ago}), que comparte este checkout y puede " +
"seguir trabajando en él. Vuelve a leer el archivo antes de continuar a partir de él y coordínate con esa sesión " +
"mediante send_to_session o lich send en lugar de deshacer su cambio.",
skillNote:
"\n\nEn esta sesión, un slash command integrado (compact, clear y similares) también puede nombrarse aquí, " +
"cuando el usuario te pida ejecutarlo: se pone en cola y se ejecuta cuando termina tu turno, así que termina tu turno justo después.",
controlNote:
"\n\nEn esta sesión, la acción command acepta la propia sesión como destino: pasa session " +
'"{sessionId}". El comando se pone en cola y se ejecuta cuando termina tu turno, así que termina tu turno justo después.',
denyDefaultSaved:
"/{name} ejecutado desde dentro de la sesión se guardaría como valor predeterminado de toda nueva sesión de Claude Code. " +
"Pide al usuario que lo defina desde lich, cuyo override de {name} vale solo para esta sesión.",
skillQueued: "{queued} está en cola y se ejecuta cuando termine este turno. Termina el turno ahora.",
controlQueued: "{queued} está en cola en esta sesión y se ejecuta cuando termine este turno. Termina el turno ahora.",
}

/** @type {Record<string, Record<string, string>>} */
const catalogs = { en, "pt-BR": ptBR }
const catalogs = { en, "pt-BR": ptBR, "zh-CN": zhCN, es }

/**
* The text `key` names in `lang`, its slots filled from `vars`. English stands
Expand Down
165 changes: 164 additions & 1 deletion tests/prompt-text.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'

import { en, ptBR, say, promptLang } from '../hooks/prompt-text.js'
import { en, ptBR, zhCN, es, 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'
Expand Down Expand Up @@ -215,3 +215,166 @@ test('mod-control: the side question is prefaced in pt-BR', async () => {
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?'))
})

// ------------------------------------------------------- zh-CN and es catalogs --

const NEW_LOCALES = { 'zh-CN': zhCN, es }

for (const [tag, catalog] of Object.entries(NEW_LOCALES)) {
test(`${tag}: same keys, same slots and the literal names as English`, () => {
assert.deepEqual(Object.keys(catalog).sort(), Object.keys(en).sort())
for (const key of Object.keys(en)) {
assert.deepEqual(slots(catalog[key]), slots(en[key]), key)
for (const literal of LITERALS) {
if (en[key].includes(literal)) assert.ok(catalog[key].includes(literal), `${key} lost ${literal}`)
}
}
})

test(`${tag}: LICH_PROMPT_LANG selects the catalog`, () => {
assert.equal(promptLang(tag), tag)
assert.notEqual(say(tag, 'lichExited', { code: 2 }), 'lich exited 2')
})
}

// What each hook renders per locale, one text per hook. The hook is driven the
// way the pt-BR tests above drive it, with only the language swapped.
const EXPECTED = {
'zh-CN': {
background: /^agent 作为 lich 会话 "w" 运行,在同一个 checkout \/wt\/w 中/,
done: /^feito\n\n工作位于同一个 checkout \/wt\/w(lich 会话 "w")中。/,
deny: '任务从未送达 "w"(unread):请打开它的卡片。',
stop: 'lich 无法停止 "w":boom',
edit: /^\/r\/a\.js 也被 lich 会话 lich-a 在 2026-10-05T12:00:00\.000Z(3 分钟前)编辑过/,
skill: /^base\n\n在此会话中,内置斜杠命令/,
control: /传入 session "lich-1"。/,
queued: '/compact 已进入队列,将在本回合结束后运行。现在请结束本回合。',
saved: /^在会话内部运行 \/model/,
ask: /^这是在你的回合之外/,
},
es: {
background: /^El agente se ejecuta como la sesión lich "w", en este mismo checkout, \/wt\/w/,
done: /^feito\n\nEl trabajo está en este mismo checkout, \/wt\/w \(sesión lich "w"\)\./,
deny: 'la tarea nunca llegó a "w" (unread): abre su tarjeta.',
stop: 'lich no pudo detener "w": boom',
edit: /^\/r\/a\.js también fue editado por la sesión lich lich-a en 2026-10-05T12:00:00\.000Z \(hace 3 min\)/,
skill: /^base\n\nEn esta sesión, un slash command integrado/,
control: /pasa session "lich-1"\./,
queued: '/compact está en cola y se ejecuta cuando termine este turno. Termina el turno ahora.',
saved: /^\/model ejecutado desde dentro de la sesión/,
ask: /^Esta es una pregunta lateral hecha desde fuera de tu turno/,
},
}

for (const [tag, want] of Object.entries(EXPECTED)) {
const LANG = { LICH_PROMPT_LANG: tag }

test(`${tag}: agent-cards renders the background note, the completed addendum and the denies`, async () => {
const OPENED = { name: 'w-1', label: 'w', path: '/wt/w', id: 'x' }
const env = { ...LANG, LICH_BIN: '/lich', LICH_SESSION_ID: 'lich-1' }
const call = async (status, tool = 'Agent', stderr = '') => {
const hooks = hooksOf(registerAgentCards)
const runs = [
exits(2, JSON.stringify({ ...OPENED, delivery: { ticket: 't', target: 'w', status, answer: 'feito' } })),
exits(1, '', 'boom'),
]
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' }
const first = await hooks.get('tool.call Agent')($, e, next)
if (tool === 'Agent') return first
return hooks.get('tool.call TaskStop')($, { tool: 'TaskStop', task_id: 'w-1' }, next)
}

assert.match((await call('pending')).context[0], want.background)
assert.match((await call('answered')).result.content[0].text, want.done)
assert.equal((await call('unread')).deny, want.deny)
assert.equal((await call('pending', 'TaskStop')).deny, want.stop)
})

test(`${tag}: edit-guard renders the note about another session`, async () => {
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 = { ...LANG, 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 * 60_000
const result = await session('lich-b')()
assert.match(result.context[0], want.edit)
assert.match(result.context[0], /send_to_session o lich send|send_to_session 或 lich send/)
})

test(`${tag}: self-command renders the tool notes and the queued answer`, async () => {
const hooks = hooksOf(registerSelfCommand)
const env = { ...LANG, 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, want.skill)
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, want.control)

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], want.queued)
const denied = await hooks.get('tool.call Skill')($, { tool: 'Skill', skill: 'model', args: 'opus' }, async () => refused)
assert.match(denied.deny, want.saved)
})

test(`${tag}: mod-control prefaces the side question`, async () => {
const hooks = hooksOf(registerModControl)
const forks = []
const env = { ...LANG, 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
return { status: 200, ok: true, headers: {}, text: JSON.stringify([{ id: 'm1', kind: 'ask', question: 'q?' }]) }
},
},
model: { fork: async (args) => (forks.push(args), { 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, want.ask)
assert.ok(forks[0].prompt.endsWith('q?'))
})
}
Loading