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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/agent-cards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions docs/self-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
56 changes: 33 additions & 23 deletions hooks/agent-cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"),
Expand All @@ -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 })],
}
}

Expand Down Expand Up @@ -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 })}` : "",
}),
}
}
}

Expand Down Expand Up @@ -304,12 +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] = 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"),
$.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)
Expand All @@ -332,12 +338,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
}
Expand Down Expand Up @@ -438,17 +446,19 @@ 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 = promptLang(await $.env.get("LICH_PROMPT_LANG"))
const argv = worker.shared
? [state.lich, "close", worker.name]
: [state.lich, "control", worker.name, "abort"]
let outcome
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)
Expand Down
15 changes: 6 additions & 9 deletions hooks/edit-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 })
}

/**
Expand Down Expand Up @@ -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, promptLang(await $.env.get("LICH_PROMPT_LANG")))
showEditBy($, previous, who)
$.clock.after(previous.at + RECENT_MS - now, () => clearEditBy($, previous))
}
Expand Down
25 changes: 12 additions & 13 deletions hooks/mod-control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand All @@ -58,6 +50,7 @@ const ANSWER_LIMIT = 16000
* turnId?: string,
* model?: string,
* effort?: Effort,
* lang?: string,
* }} State
*/

Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 = promptLang(await $.env.get("LICH_PROMPT_LANG"))
const link = state.link
if (link) $.clock.after(0, () => void poll($, state, link))
}
Expand Down
Loading
Loading