@litfamily/litopencode 1.0.25 → 1.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ are tracked separately from this product history.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 1.0.27 - 2026-10-18
9
+
10
+ - When a startup update fails and the rollback leaves the old setup working, OpenCode no longer reruns the snapshot and npm install on every start. Startup leaves that same version alone for a day; an explicit install still runs it, and a newer release is tried at once. The receipt names the failed step, a timeout or a missing command, without command output. `litopencode doctor` now reports the update state, its cause, what the next start will run ("installed X, running Y") and a remedy for each cause, and says it cannot see inside a running OpenCode.
11
+
12
+ - The optional Jev skill hint is gone. In testing it improved skill choice by no more than run-to-run noise, and in real use the model followed none of the 45 hints it received. LitOpenCode no longer contacts TypeSafe, adds no hint line or toast, and `litopencode doctor` no longer reports it. `LITOPENCODE_JEV`, the other `LITOPENCODE_JEV_*` variables and `TYPESAFE_API_KEY` are now ignored and can be deleted, and so can an old debug trace at `.litopencode/logs/jev-skill-hint.jsonl`.
13
+
14
+ - lit-loop now loads the skill that matches a request before it inspects the workspace; plain questions and requests no listed skill covers still load none. The review-work trigger now names reviewing a patch, diff, PR or open changes, with Korean cues, and the workflow-loop trigger names carrying one feature, fix or ticket through build, tests, real-surface checks and cleanup.
15
+
16
+ - A request whose deliverable is a Markdown or plain-text file, such as `report.md` or `slides.md`, now gets ordinary Markdown. lit-docx and lit-pptx no longer write their converter source (YAML front matter and `:::` blocks) into those files or start a DOCX or PPTX build; they keep that source format only for files they render.
17
+
18
+ ## 1.0.26 - 2026-10-16
19
+
20
+ - The write guard now refuses more of the lines that tell a reader what a document is not for, such as `This table is not a ranking.` or `이 문서는 제품을 비교하려는 것이 아닙니다.` These forms came from real sessions. Bold-label callouts such as `> **주의:** ...` are now read too, while ordinary quoted text is still skipped, and lines from manuals and lab reports that only look similar stay allowed. Files saved through `apply_patch` are now checked before they land, the same way the write and edit tools already were.
21
+
8
22
  ## 1.0.25 - 2026-10-15
9
23
 
10
24
  - The write guard now refuses sentences that say what a document is not for, such as `동일 조건의 독립 성능 비교로 쓰지 않음.` or an English line saying a result is not a benchmark. When the model writes one with the write or edit tool, the call is refused and the model rewrites it. Three new rules cover Korean pledges, a Korean pledge repeated through a document, and the English forms, and the status-negation warning now also reads `쓰지 않음`, the polite endings and bold-wrapped endings. Legal and financial notices, safety warnings, operating rules, method lines and real negative findings stay untouched, and the rules still finish in milliseconds on very long runs of spaces. The lit-docx and lit-pptx skills now ask for a real limit once, as a fact beside the number it affects, and keep anything about what the document is not for in the reply.
package/README-Ko-KR.md CHANGED
@@ -1,13 +1,13 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitOpenCode 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/cover-motion.webp" width="100%" alt="LitFamily 모션 커버: 다섯 로봇 패널이 차례로 켜지고, LitOpenCode 로봇의 눈과 테두리가 빛난 뒤 LITFAMILY와 KEEP THE WORK LIT. 문구가 밝아지는 영상" /></picture></p>
2
2
 
3
3
  # LitOpenCode
4
4
 
5
5
  **LitOpenCode는 OpenCode용 워크플로 플러그인입니다. 프롬프트에 `lit`만 붙이면 계획하고, 만들고, 확인하고, 기록까지 남겨서 다음 세션이 어디서 멈췄는지 바로 압니다.**
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/assets/readme/badge-version.svg" alt="1.0.25" /></a>
8
+ <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/badge-version.svg" alt="1.0.27" /></a>
9
9
  <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://img.shields.io/npm/dm/@litfamily/litopencode?label=downloads&color=D7F75B" alt="월간 npm 다운로드 수" /></a>
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/assets/readme/badge-license.svg" alt="MIT 라이선스" /></a>
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/badge-license.svg" alt="MIT 라이선스" /></a>
11
11
  <a href="https://github.com/wjgoarxiv/litopencode"><img src="https://img.shields.io/github/stars/wjgoarxiv/litopencode?style=social" alt="GitHub 스타" /></a>
12
12
  </p>
13
13
 
@@ -57,9 +57,9 @@ npm uninstall -g @litfamily/litopencode
57
57
  ## 더 보기
58
58
 
59
59
  - [GitHub의 사용 안내](https://github.com/wjgoarxiv/litopencode/blob/main/docs/guide-Ko-KR.md): 설치 옵션, 자동 인수인계, 문제 해결, 제거
60
- - [워크플로 상세 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/reference-Ko-KR.md) · [English reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/reference.md)
61
- - [마이그레이션 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/CHANGELOG.md) · [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/privacy.md)
62
- - [LitFamily 허브](https://github.com/wjgoarxiv/litfamily) · [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/LICENSE) · [ASCII 글꼴 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/assets/readme/JetBrainsMono-OFL.txt)
60
+ - [워크플로 상세 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/reference-Ko-KR.md) · [English reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/reference.md)
61
+ - [마이그레이션 안내](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/CHANGELOG.md) · [개인정보와 네트워크 동작](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/privacy.md)
62
+ - [LitFamily 허브](https://github.com/wjgoarxiv/litfamily) · [MIT 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/LICENSE) · [ASCII 글꼴 라이선스](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/JetBrainsMono-OFL.txt)
63
63
 
64
64
  <details>
65
65
  <summary>ASCII 로고 복사</summary>
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
- <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitOpenCode robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
1
+ <p align="center"><picture><source media="(prefers-reduced-motion: reduce)" srcset="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/cover-motion-still.webp" /><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/cover-motion.webp" width="100%" alt="LitFamily motion cover: five armored robots power on one by one, the LitOpenCode robot wakes with glowing eyes and a lit frame, then LITFAMILY and KEEP THE WORK LIT. light up." /></picture></p>
2
2
 
3
3
  # LitOpenCode
4
4
 
5
5
  **LitOpenCode is a workflow plugin for OpenCode. Add `lit` to a prompt and the work gets planned, built, checked and written down, so the next session knows where you stopped.**
6
6
 
7
7
  <p align="center">
8
- <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/assets/readme/badge-version.svg" alt="1.0.25" /></a>
8
+ <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/badge-version.svg" alt="1.0.27" /></a>
9
9
  <a href="https://www.npmjs.com/package/@litfamily/litopencode"><img src="https://img.shields.io/npm/dm/@litfamily/litopencode?label=downloads&color=D7F75B" alt="npm downloads per month" /></a>
10
- <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
10
+ <a href="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/LICENSE"><img src="https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/badge-license.svg" alt="MIT license" /></a>
11
11
  <a href="https://github.com/wjgoarxiv/litopencode"><img src="https://img.shields.io/github/stars/wjgoarxiv/litopencode?style=social" alt="GitHub stars" /></a>
12
12
  </p>
13
13
 
@@ -57,9 +57,9 @@ npm uninstall -g @litfamily/litopencode
57
57
  ## More
58
58
 
59
59
  - [Guide on GitHub](https://github.com/wjgoarxiv/litopencode/blob/main/docs/guide.md): install options, automatic handoff, troubleshooting and removal.
60
- - [Workflow reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/reference.md)
61
- - [Migration notes](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/CHANGELOG.md) · [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/privacy.md)
62
- - [LitFamily hub](https://github.com/wjgoarxiv/litfamily) · [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/LICENSE) · [ASCII font license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.25/docs/assets/readme/JetBrainsMono-OFL.txt)
60
+ - [Workflow reference](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/reference.md)
61
+ - [Migration notes](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/migration.md) · [Changelog](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/CHANGELOG.md) · [Privacy and network behavior](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/privacy.md)
62
+ - [LitFamily hub](https://github.com/wjgoarxiv/litfamily) · [MIT license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/LICENSE) · [ASCII font license](https://cdn.jsdelivr.net/npm/@litfamily/litopencode@1.0.27/docs/assets/readme/JetBrainsMono-OFL.txt)
63
63
 
64
64
  <details>
65
65
  <summary>Copy ASCII logo</summary>
@@ -67,6 +67,11 @@ const diagramCreationIntent = /^\s*(?:lit[,:]?\s+)?(?:please\s+)?(?:create|draw|
67
67
  const officeAuthoringVerb = /(?<![a-z])(?:make|create|write|draft|prepare|produce|generate|convert|edit|build)(?![a-z])|(?<![a-z])turn(?=(?:[^.!?\n]|\.(?=\S))*?(?<![a-z])into(?![a-z])(?:[^.!?\n]|\.(?=\S))*?(?<![a-z])(?:slides?|decks?|presentations?|pptx?|reports?|documents?|docx|word)(?![a-z]))|만들|작성|써줘|제작|변환|편집/u;
68
68
  const reportNoun = /(?<![a-z])(?:reports?|documents?|docx|word)(?![a-z])|보고서|리포트|기획서|제안서|문서|워드/u;
69
69
  const slidesNoun = /(?<![a-z])(?:slides?|decks?|presentations?|pptx?)(?![a-z])|발표자료|발표|슬라이드|피피티|덱/u;
70
+ // A deliverable the user asked to receive as Markdown or plain text ("saved as report.md", "in Markdown",
71
+ // "as plain text"). An input file such as "turn notes.md into slides" is not one. A named Office or PDF
72
+ // output keeps the skill route, because the skill then renders that file from its own source.
73
+ const markdownDeliverable = /(?<![a-z])(?:save|saved|saving|write|written|output|deliver|export)\s+(?:(?:it|them|this|both|each)\s+)?(?:as|to|in|into)\s+`?[\w./-]*\.(?:md|markdown|txt)(?![a-z])|(?<![a-z])(?:as|in)\s+(?:plain\s+)?markdown(?![a-z])|(?<![a-z])plain[- ]text(?![a-z])|마크다운(?:으로|\s*파일로|\s*형식)|\.md\s*(?:로|으로|파일로)\s*(?:저장|작성)/u;
74
+ const officeFileFormat = /(?<![a-z])(?:docx|pptx?|pdf|word|powerpoint)(?![a-z])|워드|파워포인트|피피티/u;
70
75
  // lit-typographic-motion (MO-C-18..24). The trigger is a creation verb plus a compound motion-video
71
76
  // noun, or plus a bare video noun that none of the five exclusions claims. Bare 모션/motion and
72
77
  // 인트로/intro never trigger alone. The verbs are the office/UI making verbs plus a small motion set
@@ -378,7 +383,12 @@ export function promptForChatActivationMode(mode, triggerText = "") {
378
383
  After the workspace check, use OpenCode's native skill tool to load lit-diagram-drawer before authoring the requested conceptual diagram. This is the one narrowly relevant skill for this bounded task. Resolve resources from the skill location returned by OpenCode, follow its verification and export steps, and inspect the final PNG. If the skill is unavailable, report that limit instead of claiming it ran.
379
384
  </lit-task-mode>`);
380
385
  }
381
- if (mode === "lit-task" && officeAuthoringVerb.test(raw)) {
386
+ if (mode === "lit-task" && officeAuthoringVerb.test(raw) && markdownDeliverable.test(raw) && !officeFileFormat.test(raw) && (reportNoun.test(raw) || slidesNoun.test(raw))) {
387
+ targetedPrompt = targetedPrompt.replace("</lit-task-mode>", `
388
+ The requested deliverable is a Markdown or plain-text file, not an Office file. Write each named file as ordinary Markdown that a reader opens directly: headings, paragraphs, lists and tables, with no YAML front matter, no \`:::\` directive blocks and no tonality, layout or title-treatment keys. Do not build a DOCX or PPTX and do not load lit-docx or lit-pptx for it; their Markdown is converter source and belongs only beside a file that skill renders.
389
+ </lit-task-mode>`);
390
+ }
391
+ else if (mode === "lit-task" && officeAuthoringVerb.test(raw)) {
382
392
  const skills = [reportNoun.test(raw) ? "lit-docx" : undefined, slidesNoun.test(raw) ? "lit-pptx" : undefined].filter(Boolean);
383
393
  if (skills.length > 0)
384
394
  targetedPrompt = targetedPrompt.replace("</lit-task-mode>", `
@@ -2,7 +2,6 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin";
2
2
  import type { SessionLookup } from "./session-lineage.ts";
3
3
  import type { BoundedAuthorityOptions } from "./bounded-authority.ts";
4
4
  import { type AutoHandoffRouteMode } from "./auto-handoff.ts";
5
- import { type JevSkillHintPart } from "./jev-skill-hint.ts";
6
5
  export { litHandoffPromptInjection, scientificVisualizationPromptInjection } from "./activation-managed-prompts.ts";
7
6
  export { litActivationBanner } from "./activation-prompt-utils.ts";
8
7
  export { litLoopPromptInjection, litPlanPromptInjection } from "./activation-primary-prompts.ts";
@@ -11,12 +10,6 @@ export { comprehendPromptInjection, debuggingPromptInjection, deepInterviewPromp
11
10
  type ChatMessageHook = NonNullable<Hooks["chat.message"]>;
12
11
  type ActivationToastClient = Pick<PluginInput["client"], "tui">;
13
12
  export declare function showActivationToast(client: ActivationToastClient | undefined, skillId: string, env?: NodeJS.ProcessEnv): void;
14
- /**
15
- * One quiet `info` toast per hinted turn; notes, `none` and silent turns never reach it. On the first
16
- * eligible turn of a session (`announce`) the standout ON notice goes out instead, with that turn's hint
17
- * as its body: the TUI keeps a single toast, so a second one would replace the notice at once.
18
- */
19
- export declare function showJevSkillHintToast(client: ActivationToastClient | undefined, hint: JevSkillHintPart | undefined, announce?: boolean): void;
20
13
  export type ChatMessageActivationOptions = {
21
14
  readonly getSession?: SessionLookup;
22
15
  readonly hostArgv?: readonly string[];
@@ -30,8 +23,5 @@ export type ChatMessageActivationOptions = {
30
23
  readonly trustedInjectedSkill?: (sessionID: string) => string | undefined;
31
24
  /** Restores that skill after the root-turn reset; the command already showed its activation toast. */
32
25
  readonly onTrustedSkillResume?: (sessionID: string, skillId: string) => void | Promise<void>;
33
- /** Consumes the mark `command.execute.before` left for this session's next message. */
34
- readonly isCommandTurn?: (sessionID: string) => boolean;
35
- readonly skillHint?: (sessionID: string, promptText: string) => Promise<JevSkillHintPart | undefined>;
36
26
  };
37
27
  export declare function createChatMessageActivationHook(projectRoot: string, options?: ChatMessageActivationOptions): ChatMessageHook;
@@ -8,7 +8,6 @@ import { isReadOnlyWorkflowFamilyPlan } from "./workflow-families.js";
8
8
  import { queryKnowledge } from "./knowledge.js";
9
9
  import { activationDiscipline } from "./activation-probe.js";
10
10
  import { micro, supportsMarkGlyphs } from "./lit-mark.js";
11
- import { jevSkillHintAwarenessText, jevSkillHintToastText } from "./jev-skill-hint.js";
12
11
  // Prompt modules preserve minimum-first, single-task or few-task planning, avoidable custom code review,
13
12
  // and external-source safety; this module only coordinates their unchanged activation surfaces.
14
13
  export { litHandoffPromptInjection, scientificVisualizationPromptInjection } from "./activation-managed-prompts.js";
@@ -35,32 +34,6 @@ export function showActivationToast(client, skillId, env = process.env) {
35
34
  // The activation prompt is the primary surface; this toast is best-effort UI feedback.
36
35
  }
37
36
  }
38
- /**
39
- * One quiet `info` toast per hinted turn; notes, `none` and silent turns never reach it. On the first
40
- * eligible turn of a session (`announce`) the standout ON notice goes out instead, with that turn's hint
41
- * as its body: the TUI keeps a single toast, so a second one would replace the notice at once.
42
- */
43
- export function showJevSkillHintToast(client, hint, announce = false) {
44
- const hinted = hint?.kind === "hint" ? jevSkillHintToastText(hint.skillId, hint.latencyMs) : undefined;
45
- let body;
46
- if (announce) {
47
- body = hinted === undefined
48
- ? { message: jevSkillHintAwarenessText, variant: "warning" }
49
- : { title: jevSkillHintAwarenessText, message: hinted, variant: "warning" };
50
- }
51
- else if (hinted !== undefined) {
52
- body = { message: hinted, variant: "info" };
53
- }
54
- else {
55
- return;
56
- }
57
- try {
58
- const pending = client?.tui?.showToast({ body });
59
- void pending?.catch(() => undefined);
60
- }
61
- catch { // no-excuse-ok: catch -- a missing or failing TUI must never block the user message.
62
- }
63
- }
64
37
  const knownSubagentAgentNames = new Set([
65
38
  ...litOpenCodeAgents.filter((agent) => agent.mode !== "primary").map((agent) => agent.id),
66
39
  "build",
@@ -92,9 +65,6 @@ export function createChatMessageActivationHook(projectRoot, options = {}) {
92
65
  if (processedOutputs.has(output))
93
66
  return;
94
67
  processedOutputs.add(output);
95
- // Command marks are keyed by session and consumed here, on every turn: the host hands this hook a
96
- // new parts array, so the array `command.execute.before` saw can never be matched by identity.
97
- const commandTurn = options.isCommandTurn?.(input.sessionID) === true;
98
68
  const trustedInjectedSkill = options.trustedInjectedSkill?.(input.sessionID);
99
69
  const agent = input.agent ?? (typeof output.message.agent === "string" ? output.message.agent : undefined);
100
70
  const childSession = await isChildSessionMessage(input.sessionID, agent, options.getSession);
@@ -161,30 +131,6 @@ export function createChatMessageActivationHook(projectRoot, options = {}) {
161
131
  // A malformed or unreadable knowledge store stays silent instead of inventing context.
162
132
  }
163
133
  }
164
- // The optional skill hint only speaks on root turns that no command or deterministic route claimed.
165
- if (!childSession && mode === undefined && options.skillHint !== undefined && !commandTurn) {
166
- const promptText = nonEmptyTextParts
167
- .filter((part) => part.synthetic !== true)
168
- .map((part) => part.text)
169
- .join("\n");
170
- try {
171
- const hint = await options.skillHint(input.sessionID, promptText);
172
- if (hint !== undefined) {
173
- output.parts.push({
174
- id: `prt_litopencode_skill_hint_${idSuffix(input.messageID ?? output.message.id)}`,
175
- sessionID: input.sessionID,
176
- messageID: input.messageID ?? output.message.id,
177
- type: "text",
178
- text: hint.text,
179
- // Sent to the model like any text part, but not shown or copied as the user's own words.
180
- synthetic: true,
181
- metadata: { litopencodeSkillHint: { kind: hint.kind, source: "chat.message" } }
182
- });
183
- }
184
- }
185
- catch { // no-excuse-ok: catch -- the optional hint must never block the user message.
186
- }
187
- }
188
134
  if (mode === undefined || childSession)
189
135
  return;
190
136
  const trigger = mode === "lit-handoff" ? "handoff" : mode === "lit-scientific-visualization" ? mode : "lit";
@@ -64,7 +64,8 @@ const litLoopPrompt = [
64
64
  interfaceWorkHandoff,
65
65
  "Do not call the task, lit, litwork, start-work, or review-work tools, and do not delegate for that path.",
66
66
  "Do not create a TODO plan, durable goal or ledger, or evidence tree. Do not preload the workflow skills or agent roster. The activation hook has already handled bare lit.",
67
- "Do not load skill documentation before inspecting the task and repository. Use the supplied acceptance criteria directly; consult at most one narrowly relevant skill only if inspection reveals a specific gap it can resolve.",
67
+ "On that bounded path, use the supplied acceptance criteria directly; consult at most one narrowly relevant skill only if inspection reveals a specific gap it can resolve.",
68
+ "Otherwise, when the request matches a skill in the available skills list, load that skill with the skill tool first, then inspect the workspace under its guidance. Plain questions, explanations, and requests no listed skill covers need no skill.",
68
69
  "Use the durable loop only when the user explicitly asks for tracked multi-turn work, or the task genuinely needs resumption across turns or independent workstreams.",
69
70
  "For a long workflow, delegate only independent slices that materially reduce work. Treat at most 20 concurrent subagents as an advisory ceiling; OpenCode exposes no verified numeric hard limit, so never claim host enforcement.",
70
71
  "When delegation is used, require each worker DoneClaim to name changed files, exact tests, real-surface evidence, risks, and a cleanup receipt. An independent verifier must confirm it before the slice becomes FullyDone.",
@@ -6,6 +6,8 @@ export declare const autoUpdateLockTimeoutMs = 2000;
6
6
  /** Keep a live owner protected for the whole bounded transaction plus one command window. */
7
7
  export declare const autoUpdateInstallLockStaleMs: number;
8
8
  export declare const autoUpdateRecursionEnv = "LITOPENCODE_AUTO_UPDATE_IN_PROGRESS";
9
+ /** After a safe rollback, OpenCode starts leave the same target alone for this long. */
10
+ export declare const autoUpdateFailureBackoffMs: number;
9
11
  type CommandOutput = {
10
12
  readonly stdout?: string;
11
13
  readonly stderr?: string;
@@ -62,6 +64,11 @@ type DoctorReceipt = {
62
64
  };
63
65
  readonly error?: string;
64
66
  };
67
+ export type AutoUpdateFailure = {
68
+ readonly step: "prepare" | "npm-install" | "install" | "doctor";
69
+ readonly timedOut: boolean;
70
+ readonly missingCommand: boolean;
71
+ };
65
72
  type AutoUpdateReceipt = {
66
73
  readonly schemaVersion: 1;
67
74
  readonly status: AutoUpdateRunResult["status"];
@@ -82,6 +89,7 @@ type AutoUpdateReceipt = {
82
89
  readonly ok: boolean;
83
90
  readonly error?: string;
84
91
  };
92
+ readonly failure?: AutoUpdateFailure;
85
93
  };
86
94
  /**
87
95
  * The staged CLI needs Node. Inside OpenCode the plugin runs on a Bun-compiled
@@ -92,6 +100,7 @@ export declare function childNodeExecutable(execPath?: string, versions?: NodeJS
92
100
  export declare function autoUpdateInstallLockPath(homeDir?: string): string;
93
101
  export declare function autoUpdateJournalPath(homeDir?: string): string;
94
102
  export declare function autoUpdateReceiptPath(homeDir?: string): string;
103
+ export declare function autoUpdateCheckPath(homeDir?: string): string;
95
104
  export declare function isAutoUpdateDisabled(env?: NodeJS.ProcessEnv, argv?: readonly string[]): boolean;
96
105
  export declare function shouldRunInteractiveAutoUpdate(input: InteractiveAutoUpdateGate): boolean;
97
106
  /**
@@ -115,11 +124,28 @@ export type AutoUpdateReceiptSummary = {
115
124
  readonly stagedStateKnown: boolean;
116
125
  readonly rollbackOk: boolean;
117
126
  readonly finishedAt: number;
127
+ readonly currentVersion?: string;
128
+ readonly targetVersion?: string;
129
+ readonly failure?: AutoUpdateFailure;
118
130
  };
119
131
  /** Read only the bounded safety summary used by doctor; never echo command output. */
120
132
  export declare function readAutoUpdateReceipt(homeDir?: string): Promise<AutoUpdateReceiptSummary | {
121
133
  readonly status: "unreadable";
122
134
  } | undefined>;
123
- export declare function runPluginAutoUpdate(): Promise<AutoUpdateRunResult>;
135
+ export type AutoUpdateCheckRecord = {
136
+ readonly schemaVersion: 1;
137
+ readonly at: number;
138
+ /** The plugin version this OpenCode start loaded, which keeps running until the next start. */
139
+ readonly runningVersion: string;
140
+ readonly status: AutoUpdateRunResult["status"];
141
+ readonly reason?: string;
142
+ readonly targetVersion?: string;
143
+ };
144
+ /** Best effort: a state root that is not safe to write never stops the plugin. */
145
+ export declare function recordAutoUpdateCheck(homeDir: string, runningVersion: string, result: AutoUpdateRunResult): Promise<void>;
146
+ export declare function readAutoUpdateCheck(homeDir?: string): Promise<AutoUpdateCheckRecord | undefined>;
147
+ export declare function runPluginAutoUpdate(options?: {
148
+ readonly homeDir?: string;
149
+ }): Promise<AutoUpdateRunResult>;
124
150
  export declare function runInteractiveAutoUpdate(input: InteractiveAutoUpdateGate): Promise<AutoUpdateRunResult>;
125
151
  export {};
@@ -15,10 +15,13 @@ export const autoUpdateLockTimeoutMs = 2_000;
15
15
  /** Keep a live owner protected for the whole bounded transaction plus one command window. */
16
16
  export const autoUpdateInstallLockStaleMs = autoUpdateTransactionBoundMs + autoUpdateTimeoutMs;
17
17
  export const autoUpdateRecursionEnv = "LITOPENCODE_AUTO_UPDATE_IN_PROGRESS";
18
+ /** After a safe rollback, OpenCode starts leave the same target alone for this long. */
19
+ export const autoUpdateFailureBackoffMs = 24 * 60 * 60 * 1000;
18
20
  const stateRootName = ".litopencode";
19
21
  const receiptFileName = "auto-update-receipt.json";
20
22
  const journalFileName = "auto-update-journal.jsonl";
21
23
  const installLockFileName = "auto-update-install.lock";
24
+ const checkFileName = "auto-update-check.json";
22
25
  const maxCommandOutputBytes = 4 * 1024 * 1024;
23
26
  const execFileAsync = promisify(execFile);
24
27
  /**
@@ -43,6 +46,9 @@ export function autoUpdateJournalPath(homeDir = os.homedir()) {
43
46
  export function autoUpdateReceiptPath(homeDir = os.homedir()) {
44
47
  return path.join(stateRoot(homeDir), receiptFileName);
45
48
  }
49
+ export function autoUpdateCheckPath(homeDir = os.homedir()) {
50
+ return path.join(stateRoot(homeDir), checkFileName);
51
+ }
46
52
  function truthyEnvironmentValue(value) {
47
53
  return value !== undefined;
48
54
  }
@@ -110,6 +116,16 @@ function commandFailure(error) {
110
116
  .filter(Boolean)
111
117
  .join("; ");
112
118
  }
119
+ /** Classify a failure by kind only; command output never reaches the receipt. */
120
+ function failureKind(step, error) {
121
+ const candidate = (typeof error === "object" && error !== null ? error : {});
122
+ const message = typeof candidate.message === "string" ? candidate.message : "";
123
+ return {
124
+ step,
125
+ timedOut: candidate.killed === true || /automatic update command exceeded/u.test(message),
126
+ missingCommand: candidate.code === "ENOENT"
127
+ };
128
+ }
113
129
  async function defaultRunCommand(command, args, options) {
114
130
  return await execFileAsync(command, [...args], options);
115
131
  }
@@ -400,6 +416,8 @@ async function updateTransaction(options, homeDir, currentVersion, targetVersion
400
416
  // resulting host state unknown until the snapshot is restored and verified.
401
417
  let stagedStateKnown = true;
402
418
  let priorStateVerified = false;
419
+ let step = "prepare";
420
+ let failure;
403
421
  try {
404
422
  await fs.mkdir(stageRoot, { recursive: true, mode: 0o700 });
405
423
  await fs.writeFile(path.join(stageRoot, "package.json"), '{"private":true}\n', { encoding: "utf8", mode: 0o600 });
@@ -423,16 +441,19 @@ async function updateTransaction(options, homeDir, currentVersion, targetVersion
423
441
  "--no-save",
424
442
  spec
425
443
  ];
444
+ step = "npm-install";
426
445
  await bounded(() => runCommand("npm", npmArgs, { cwd: stageRoot, env: childEnv, timeout: autoUpdateTimeoutMs, maxBuffer: maxCommandOutputBytes }), autoUpdateTimeoutMs);
427
446
  await appendJournal(homeDir, "npm-install", { spec });
428
447
  const entrypoint = await packageEntrypoint(stageRoot, targetVersion);
429
448
  const nodeExecutable = options.nodeExecutable ?? childNodeExecutable();
430
449
  const installArgs = [entrypoint, "install", "--root", options.configRoot, "--no-model-prompt", "--no-permission-prompt", "--no-auto-update"];
450
+ step = "install";
431
451
  stagedStateKnown = false;
432
452
  await bounded(() => runCommand(nodeExecutable, installArgs, { cwd: options.configRoot, env: childEnv, timeout: autoUpdateTimeoutMs, maxBuffer: maxCommandOutputBytes }), autoUpdateTimeoutMs);
433
453
  stagedStateKnown = true;
434
454
  await appendJournal(homeDir, "install", { spec });
435
455
  const doctorArgs = [entrypoint, "doctor", "--root", options.configRoot, "--json", "--no-auto-update"];
456
+ step = "doctor";
436
457
  stagedStateKnown = false;
437
458
  const doctorOutput = await bounded(() => runCommand(nodeExecutable, doctorArgs, { cwd: options.configRoot, env: childEnv, timeout: autoUpdateTimeoutMs, maxBuffer: maxCommandOutputBytes }), autoUpdateTimeoutMs);
438
459
  doctor = parseDoctorOutput(doctorOutput, targetVersion);
@@ -444,8 +465,8 @@ async function updateTransaction(options, homeDir, currentVersion, targetVersion
444
465
  await appendJournal(homeDir, "updated", { spec });
445
466
  }
446
467
  catch (error) {
447
- const failure = commandFailure(error);
448
- await tryAppendJournal(homeDir, "failure", { error: failure });
468
+ failure = failureKind(step, error);
469
+ await tryAppendJournal(homeDir, "failure", { error: commandFailure(error) });
449
470
  try {
450
471
  await restoreSnapshot(options.configRoot, snapshot);
451
472
  priorStateVerified = await verifySnapshot(options.configRoot, snapshot);
@@ -482,7 +503,8 @@ async function updateTransaction(options, homeDir, currentVersion, targetVersion
482
503
  priorStateVerified: status === "updated" ? true : priorStateVerified,
483
504
  stagedStateKnown,
484
505
  doctor,
485
- rollback
506
+ rollback,
507
+ ...(failure === undefined ? {} : { failure })
486
508
  };
487
509
  await writeReceipt(homeDir, receipt);
488
510
  return {
@@ -497,6 +519,20 @@ async function updateTransaction(options, homeDir, currentVersion, targetVersion
497
519
  ...(status === "rolled-back" || status === "failed" ? { rollback } : {})
498
520
  };
499
521
  }
522
+ /**
523
+ * A rollback that left the old setup proven in place is safe to wait on. Without
524
+ * this, a failing update re-ran its snapshot and npm install on every OpenCode
525
+ * start. Anything less certain keeps retrying, because a retry is the repair path.
526
+ */
527
+ async function inFailureBackoff(homeDir, targetVersion) {
528
+ const last = await readAutoUpdateReceipt(homeDir);
529
+ if (last === undefined || last.status !== "rolled-back")
530
+ return false;
531
+ if (!last.rollbackOk || !last.priorStateVerified || !last.stagedStateKnown || last.targetVersion !== targetVersion)
532
+ return false;
533
+ const age = Date.now() - last.finishedAt;
534
+ return age >= 0 && age < autoUpdateFailureBackoffMs;
535
+ }
500
536
  export async function runAutoUpdate(options) {
501
537
  const env = options.env ?? process.env;
502
538
  if (isAutoUpdateDisabled(env, options.argv))
@@ -517,6 +553,9 @@ export async function runAutoUpdate(options) {
517
553
  const comparison = compareStableVersions(targetVersion, options.currentVersion);
518
554
  if (comparison !== 1)
519
555
  return { status: "skipped", currentVersion: options.currentVersion, targetVersion, reason: "up-to-date" };
556
+ if (options.reason === "plugin" && (await inFailureBackoff(homeDir, targetVersion))) {
557
+ return { status: "skipped", currentVersion: options.currentVersion, targetVersion, reason: "backoff" };
558
+ }
520
559
  let lock;
521
560
  try {
522
561
  lock = await acquireInstallLock(homeDir);
@@ -551,6 +590,14 @@ export function autoUpdateDiagnostic(result) {
551
590
  const backup = result.backupPath === undefined ? "" : ` Retained backup: ${result.backupPath}.`;
552
591
  return `Automatic update stopped without claiming success (${result.status}). Inspect the receipt at ${receipt}.${backup}`;
553
592
  }
593
+ function isFailureRecord(value) {
594
+ if (typeof value !== "object" || value === null)
595
+ return false;
596
+ const record = value;
597
+ return ((record.step === "prepare" || record.step === "npm-install" || record.step === "install" || record.step === "doctor") &&
598
+ typeof record.timedOut === "boolean" &&
599
+ typeof record.missingCommand === "boolean");
600
+ }
554
601
  /** Read only the bounded safety summary used by doctor; never echo command output. */
555
602
  export async function readAutoUpdateReceipt(homeDir = os.homedir()) {
556
603
  const receiptPath = autoUpdateReceiptPath(homeDir);
@@ -580,7 +627,10 @@ export async function readAutoUpdateReceipt(homeDir = os.homedir()) {
580
627
  priorStateVerified: parsed.priorStateVerified,
581
628
  stagedStateKnown: parsed.stagedStateKnown,
582
629
  rollbackOk: parsed.rollback.ok,
583
- finishedAt: parsed.finishedAt
630
+ finishedAt: parsed.finishedAt,
631
+ ...(typeof parsed.currentVersion === "string" ? { currentVersion: parsed.currentVersion } : {}),
632
+ ...(typeof parsed.targetVersion === "string" ? { targetVersion: parsed.targetVersion } : {}),
633
+ ...(isFailureRecord(parsed.failure) ? { failure: { step: parsed.failure.step, timedOut: parsed.failure.timedOut, missingCommand: parsed.failure.missingCommand } } : {})
584
634
  };
585
635
  }
586
636
  catch (error) {
@@ -598,16 +648,65 @@ async function isLocalCheckout(packageRoot) {
598
648
  return false;
599
649
  }
600
650
  }
601
- export async function runPluginAutoUpdate() {
651
+ /** Best effort: a state root that is not safe to write never stops the plugin. */
652
+ export async function recordAutoUpdateCheck(homeDir, runningVersion, result) {
653
+ try {
654
+ await ensurePrivateStateRoot(homeDir);
655
+ const record = {
656
+ schemaVersion: 1,
657
+ at: Date.now(),
658
+ runningVersion,
659
+ status: result.status,
660
+ ...(result.reason === undefined ? {} : { reason: result.reason }),
661
+ ...(result.targetVersion === undefined ? {} : { targetVersion: result.targetVersion })
662
+ };
663
+ const checkPath = autoUpdateCheckPath(homeDir);
664
+ const temporary = `${checkPath}.${randomUUID()}.tmp`;
665
+ await fs.writeFile(temporary, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
666
+ await fs.rename(temporary, checkPath);
667
+ }
668
+ catch {
669
+ // The record only feeds doctor.
670
+ }
671
+ }
672
+ export async function readAutoUpdateCheck(homeDir = os.homedir()) {
673
+ try {
674
+ const checkPath = autoUpdateCheckPath(homeDir);
675
+ const stat = await fs.lstat(checkPath);
676
+ if (stat.isSymbolicLink() || !stat.isFile() || (stat.mode & 0o077) !== 0)
677
+ return undefined;
678
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid())
679
+ return undefined;
680
+ const parsed = JSON.parse(await fs.readFile(checkPath, "utf8"));
681
+ if (parsed.schemaVersion !== 1 || typeof parsed.at !== "number" || typeof parsed.runningVersion !== "string" || typeof parsed.status !== "string")
682
+ return undefined;
683
+ return {
684
+ schemaVersion: 1,
685
+ at: parsed.at,
686
+ runningVersion: parsed.runningVersion,
687
+ status: parsed.status,
688
+ ...(typeof parsed.reason === "string" ? { reason: parsed.reason } : {}),
689
+ ...(typeof parsed.targetVersion === "string" ? { targetVersion: parsed.targetVersion } : {})
690
+ };
691
+ }
692
+ catch {
693
+ return undefined;
694
+ }
695
+ }
696
+ export async function runPluginAutoUpdate(options = {}) {
602
697
  const metadata = await readPackageMetadata();
603
- if (await isLocalCheckout(metadata.packageRoot))
604
- return { status: "skipped", reason: "local-checkout" };
605
- return await runAutoUpdate({
606
- reason: "plugin",
607
- configRoot: defaultOpenCodeConfigRoot(),
608
- currentVersion: metadata.version,
609
- env: process.env
610
- });
698
+ const homeDir = options.homeDir ?? os.homedir();
699
+ const result = (await isLocalCheckout(metadata.packageRoot))
700
+ ? { status: "skipped", reason: "local-checkout" }
701
+ : await runAutoUpdate({
702
+ reason: "plugin",
703
+ homeDir,
704
+ configRoot: defaultOpenCodeConfigRoot(),
705
+ currentVersion: metadata.version,
706
+ env: process.env
707
+ });
708
+ await recordAutoUpdateCheck(homeDir, metadata.version, result);
709
+ return result;
611
710
  }
612
711
  export async function runInteractiveAutoUpdate(input) {
613
712
  if (!shouldRunInteractiveAutoUpdate(input))
@@ -1,4 +1,13 @@
1
1
  import type { CliResult } from "./types.ts";
2
+ /**
3
+ * Doctor reads the receipt and the record each OpenCode start leaves behind. It
4
+ * cannot see inside a running OpenCode, so "running" means the last start seen.
5
+ */
6
+ export declare function inspectAutoUpdate(input: {
7
+ readonly homeDir?: string;
8
+ readonly env: NodeJS.ProcessEnv;
9
+ readonly pinnedVersion: string | null;
10
+ }): Promise<Record<string, unknown>>;
2
11
  /**
3
12
  * The project the plugin would use from here: OpenCode's worktree is the git root above the working
4
13
  * directory, and without one the directory itself.