OpenCode-style lean harness: small system prompt, cache-stable context, OpenCode compaction, build/plan modes, token instrumentation

OpenCode's harness (branch v2) as a Claude Code mod: a short fixed system prompt, a cache-stable context, OpenCode's tool set and compaction, build/plan agents, and token/cache instrumentation. Ports OpenCode's prompts and algorithms under the MIT License (NOTICE.md). Not affiliated with Anthropic or the OpenCode authors.
/plugin install feathercode --marketplace square3ang/feathercode
or for one session from a checkout: claude --plugin-dir <path>/plugin.
| Command | What it does |
|---|---|
/feathercode-stats | prints requests, input/output, cache read/write, hit %, cache breaks with candidate causes, prompt and tool sizes |
/feathercode-panel | the same in a pane; prints the text where no UI is attached |
/plan | switches to the plan agent (read-only; see tool.check below) |
/build | switches back to the build agent (the default) |
If /plan or /build is already taken, they register as /fc-plan and /fc-build.
/config)| Option | Default | |
|---|---|---|
features | all | comma list of prompt,cache,tools,modes,agents,compact, or observe (instrumentation only) |
compaction_auto | true | compact automatically at OpenCode's threshold |
keep_tokens | 15000 | newest conversation kept verbatim when compacting |
compaction_buffer | 0 | compact at window − buffer; 0 = window − max(10%, 16k) |
compaction_tail | text | text: recent part flattened into the checkpoint; messages: kept verbatim |
compaction_prune | false | clear old tool outputs after a turn (OpenCode v1 prune) |
panel | false | open the stats pane at start |
Every hook below is in hooks/register.tsx. None of them ever answers allow to a permission question: permission decisions stay with the user, their settings and the permission mode.
| Hook | What it decides | When |
|---|---|---|
tool.check | denies Edit, Write and NotebookEdit whose file_path / notebook_path is outside <project root>/.opencode/plan, with the reason Cannot use <tool> to modify files outside the Plan directory: <dir> | only in plan mode (after /plan, until /build), feature modes on. For every other tool, in build mode, and for a path inside the plan directory, it returns Claude Code's own verdict unchanged. If the hook fails, its .catch denies those three edit tools and returns Claude Code's verdict for everything else. |
agent.offer | hides the built-in agent types Explore, general-purpose, Plan and claude from the model | only once feathercode:explore and feathercode:general registered (feature agents); they replace them. Other agent types are offered as usual. |
session.compact | answers compaction with OpenCode v2's summary (<conversation-checkpoint> + the newest keep_tokens of conversation) instead of Claude Code's; skips precompute (ahead-of-time) compaction; with compaction_auto off, skips automatic compaction; with compaction_prune on, answers the prune request by replacing old tool outputs with [Old tool result content cleared] | every compaction of the main conversation (/compact, automatic, the mod's own trigger), feature compact on. A subagent's compaction is left to Claude Code. |
tool.call | nothing: counts the call for the stats and passes it on unchanged | every tool call |
command.run | answers the mod's own commands above | when you run them |
prompt.compose | replaces Claude Code's own system prompt sections with one OpenCode section (keeps Claude Code's one-line security policy and every other mod's sections) | every system prompt render, feature prompt on, no output style selected |
prompt.context | leaves out the userEmail block | the first message's context, feature prompt on |
prompt.attachment | leaves out the todo and token-count reminders and the git status; shortens each skill description to its first sentence | as Claude Code injects them, feature cache on; text from settings hooks and other mods passes untouched |
tool.describe | rewrites the descriptions of Bash, Read, Edit, Write, Glob, Grep, Skill, Agent and AskUserQuestion to OpenCode's wording; moves heavy tools (Workflow, ScheduleWakeup, Monitor, Cron*, worktree, notification tools, ...) behind ToolSearch, where they stay callable | once per tool per session, feature tools on; tools of MCP servers and other plugins untouched |
<plugin folder>/logs/<session id>.jsonl (<session id>.<n>.jsonl past 3.5 MB), by $.fs.write in flush(). It is read back by $.fs.read in loadCtx() when a session resumes. The mod does not create or edit any build, start-up, settings or instructions file. In plan mode the model may write plan files under <project root>/.opencode/plan with its own Write tool, through the normal permission check./compact, through $.command.run, only when the session runs headless (claude -p, the SDK) and Claude Code refuses $.session.compact() between turns there. It is queued after a turn in which the context reached the compaction threshold (compaction_auto), or, with compaction_prune on, when old tool outputs would free more than 20k tokens. Interactive sessions call $.session.compact() directly instead.$.model.fork (and, when nothing can be forked yet, $.model.complete with the session's model) to write the compaction summary of the conversation, on the session's own Claude client.$.session.append adds one reminder message when you switch between /plan and /build.$.store keeps the current mode per session id, so --continue / --resume restore it..claude-plugin/plugin.json manifest and options
.claude-plugin/icon.png icon
hooks/register.tsx every hook and every $ call
hooks/lib/ pure logic: prompts, compaction, prune, stats, tools, modes
tests/ claude plugin test
logs/ written at run time (session logs)hooks/register.tsx 517 lines1// feathercode — an OpenCode-style harness for Claude Code.
2// Portions ported from OpenCode (https://github.com/anomalyco/opencode, branch v2),
3// Copyright (c) 2025 opencode, MIT License. See ../NOTICE.md.
4//
5// Every hook and every `$` call lives in this file (the engine follows `$`
6// only into functions declared here); ./lib holds the pure logic.
7import type { EngineInterface, Register, SessionCompactInput, SessionCompactResult, SessionMessage } from 'claude-code'
8
9import {
10 NUDGE,
11 applyPrune,
12 buildPrompt,
13 ceiling,
14 checkpoint,
15 hasTemplate,
16 isCheckpoint,
17 messageToText,
18 messageTokens,
19 planPrune,
20 splitConversation,
21 type Msg,
22} from './lib/compaction'
23import { resolveConfig, type Config, type Feature } from './lib/config'
24import { attachLog, createLog, takeFlush, writeLog, type JsonlLog } from './lib/log'
25import { isInside, planDir, type Mode } from './lib/modes'
26import {
27 DROPPED_ATTACHMENTS,
28 EXPLORE_DESCRIPTION,
29 EXPLORE_PROMPT,
30 GENERAL_DESCRIPTION,
31 PLAN_LEAVE,
32 TOOL_DESCRIPTIONS,
33 compactSkillListing,
34 findPolicy,
35 planDenied,
36 planEnter,
37 stripGitStatus,
38 systemPrompt,
39} from './lib/prompts'
40import { Stats, formatStats } from './lib/stats'
41import { deferralFor } from './lib/tools'
42
43const PANE = 'feathercode-stats'
44/** /compact instructions that ask for a prune (headless sessions queue one). */
45const PRUNE_MARK = '__feathercode_prune__'
46/** Built-in agent types the feathercode ones replace. */
47const REPLACED_AGENTS = new Set(['Explore', 'general-purpose', 'Plan', 'claude'])
48/** Fallback for the engine's security line until a compose has shown it. */
49const POLICY_FALLBACK =
50 'IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.'
51
52type Ctx = {
53 cfg: Config
54 log: JsonlLog
55 stats: Stats
56 /** The session's project root; the plan directory lives under it. */
57 root: string
58 sessionId: string
59 mode: Mode
60 policy?: string
61 commands: { plan: string; build: string }
62 agentsReady: boolean
63 pendingPrune: boolean
64 compacting: boolean
65 /** The last main-loop request's input + cache read/write + output (v2's measured size). */
66 lastMainTokens: number
67}
68
69export const register: Register = (on, options) => {
70 const o = options as Record<string, unknown>
71 const ctx: Ctx = {
72 cfg: resolveConfig(o),
73 log: createLog(),
74 stats: new Stats(),
75 root: '.',
76 sessionId: '',
77 mode: 'build',
78 commands: { plan: 'plan', build: 'build' },
79 agentsReady: false,
80 pendingPrune: false,
81 compacting: false,
82 lastMainTokens: 0,
83 }
84 const has = (f: Feature) => ctx.cfg.features.has(f)
85
86 // ---- session ---------------------------------------------------------------
87
88 on('session.start', async ($, e, next) => {
89 await loadCtx($, ctx)
90 await $.command.register({ name: 'feathercode-stats', description: 'Token and prompt-cache usage of this session' })
91 await $.command.register({ name: 'feathercode-panel', description: 'Show token and prompt-cache usage in a pane' })
92 if (has('modes')) {
93 await registerModeCommands($, ctx)
94 await restoreMode($, ctx)
95 }
96 if (has('agents')) await registerAgents($, ctx)
97 if (ctx.cfg.panel && (await $.session.surfaces()).length > 0) void $.ui.open({ id: PANE, title: 'feathercode' })
98 return next(e)
99 })
100
101 on('session.end', async ($, e, next) => {
102 writeLog(ctx.log, { t: Date.now(), ev: 'end', reason: e.reason, ...ctx.stats.totals(), sysChanges: ctx.stats.sysChanges })
103 await flush($, ctx)
104 return next(e)
105 })
106
107 on('session.measure', async ($, e, next) => {
108 if (e.changed.includes('context')) {
109 writeLog(ctx.log, { t: Date.now(), ev: 'measure', tokens: e.context.tokens, window: e.context.window, percent: e.context.percent })
110 if (has('compact')) await maybeCompact($, ctx, Math.max(e.context.tokens ?? 0, ctx.lastMainTokens), e.context.window)
111 }
112 return next(e)
113 })
114
115 // ---- system prompt (phase 1) -----------------------------------------------
116
117 on('prompt.compose', async ($, e, next) => {
118 const r = await next(e)
119 if (!('sections' in r) || !r.sections) return r
120 let out = r
121 if (has('prompt') && e.outputStyle === null && !e.traits.includes('bare')) {
122 ctx.policy = findPolicy(r.sections.map(s => s.text)) ?? ctx.policy
123 const others = r.sections.filter(s => s.id.includes(':') && !s.id.startsWith('feathercode:'))
124 out = {
125 sections: [
126 { id: 'feathercode:system', text: systemPrompt(e.tools, ctx.policy ?? POLICY_FALLBACK), scope: 'shared' as const },
127 ...others.filter(s => s.scope === 'shared'),
128 ...others.filter(s => s.scope === 'session'),
129 ],
130 }
131 }
132 const rec = ctx.stats.compose(out.sections)
133 if (rec) writeLog(ctx.log, { t: Date.now(), ev: 'compose', model: e.model, traits: e.traits, tools: e.tools.length, engine: r.sections.map(s => ({ id: s.id, chars: s.text.length })), ...rec })
134 return out
135 })
136
137 on('prompt.context', async ($, e, next) => {
138 const r = await next(e)
139 const out = has('prompt') ? { ...r, blocks: r.blocks.filter(b => b.name !== 'userEmail') } : r
140 writeLog(ctx.log, { t: Date.now(), ev: 'context', blocks: out.blocks.map(b => ({ name: b.name, chars: b.text.length })) })
141 return out
142 })
143
144 // ---- attachments (phase 2) -------------------------------------------------
145
146 on('prompt.attachment', async ($, e, next) => {
147 const r = await next(e)
148 let text = r.text
149 if (has('cache') && text !== null && e.origin.kind === 'engine') {
150 if (DROPPED_ATTACHMENTS.has(e.type)) text = null
151 else if (e.type === 'skill_listing') text = compactSkillListing(text)
152 else if (e.type === 'session_context') text = stripGitStatus(text) ?? null
153 }
154 const chars = text === null ? null : text.length
155 ctx.stats.attachment(e.type, e.agentId, chars)
156 writeLog(ctx.log, { t: Date.now(), ev: 'attach', type: e.type, loop: e.agentId ?? 'main', origin: e.origin.kind, inChars: e.text.length, chars })
157 return { text }
158 })
159
160 // ---- tools (phase 3) -------------------------------------------------------
161
162 on('tool.describe', async ($, e, next) => {
163 const r = await next(e)
164 let out = r
165 if (has('tools') && e.provider.plugin === 'engine') {
166 const description = TOOL_DESCRIPTIONS[e.tool] ?? r.description
167 const isDeferred = deferralFor(e.tool, r.isDeferred ?? e.isDeferred === true, { pin: new Set() })
168 out = isDeferred === undefined ? { ...r, description } : { ...r, description, isDeferred }
169 }
170 const deferred = out.isDeferred ?? e.isDeferred === true
171 if (ctx.stats.describe(e.tool, out.description.length, deferred)) {
172 writeLog(ctx.log, {
173 t: Date.now(),
174 ev: 'describe',
175 tool: e.tool,
176 provider: e.provider.plugin,
177 inChars: e.description.length,
178 inDeferred: e.isDeferred === true,
179 chars: out.description.length,
180 deferred,
181 })
182 }
183 return out
184 })
185
186 on('tool.call', async ($, e, next) => {
187 ctx.stats.toolCall(e.tool, e.agentId, e as unknown as Record<string, unknown>)
188 writeLog(ctx.log, { t: Date.now(), ev: 'tool', tool: e.tool, loop: e.agentId ?? 'main' })
189 return next(e)
190 })
191
192 // ---- build / plan (phase 4) ------------------------------------------------
193
194 // Plan mode: Edit / Write / NotebookEdit only inside the plan directory.
195 // The hook never answers allow itself: outside plan mode, for other tools
196 // and for a path inside the plan directory it returns the engine's own
197 // verdict (`next(e)`); otherwise it denies. The event is read, never passed
198 // on or written: the path is read here as a string.
199 on('tool.check', async ($, e, next) => {
200 const isEdit = e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit'
201 if (!isEdit || !has('modes')) return next(e)
202 if (ctx.mode !== 'plan') return next(e)
203 const raw =
204 e.tool === 'NotebookEdit'
205 ? (e.input as { notebook_path?: unknown } | null)?.notebook_path
206 : (e.input as { file_path?: unknown } | null)?.file_path
207 const dir = planDir(ctx.root)
208 if (typeof raw === 'string' && isInside(raw, dir)) return next(e)
209 return { decision: 'deny', reason: planDenied(e.tool, dir) }
210 }).catch(($, e, next) =>
211 e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit'
212 ? { decision: 'deny' as const, reason: 'feathercode: plan mode check failed' }
213 : next(e),
214 )
215
216 on('command.run', { command: 'plan' }, async $ => ({ text: await switchMode($, ctx, 'plan') }))
217 on('command.run', { command: 'build' }, async $ => ({ text: await switchMode($, ctx, 'build') }))
218 on('command.run', { command: 'fc-plan' }, async $ => ({ text: await switchMode($, ctx, 'plan') }))
219 on('command.run', { command: 'fc-build' }, async $ => ({ text: await switchMode($, ctx, 'build') }))
220
221 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
222 if (!has('modes') || e.props.hasSurvey) return next(e)
223 if (ctx.mode !== 'plan') return next(e)
224 const { Box, Text, Button } = $.ui.resolve(e)
225 return (
226 <Box>
227 <Text color="yellow" bold>
228 plan
229 </Text>
230 <Text dimColor> read-only: edits only in {planDir(ctx.root)} </Text>
231 <Button key="build" label="Switch to build" onPress={() => switchMode($, ctx, 'build')} />
232 </Box>
233 )
234 })
235
236 // ---- subagents (phase 4) ---------------------------------------------------
237
238 on('agent.offer', async ($, e, next) => {
239 if (has('agents') && ctx.agentsReady && REPLACED_AGENTS.has(e.agent) && e.provider.plugin === 'engine') {
240 return { isOffered: false }
241 }
242 return next(e)
243 })
244
245 // ---- compaction (phase 5) --------------------------------------------------
246
247 on('session.compact', async ($, e, next) => {
248 const r = has('compact') ? await compact($, ctx, e, next) : await next(e)
249 const isSkip = 'skip' in r && typeof r.skip === 'string'
250 const before = 'tokensBefore' in r ? r.tokensBefore : undefined
251 const after = 'tokensAfter' in r ? r.tokensAfter : undefined
252 if (e.trigger !== 'precompute' && !isSkip) ctx.stats.compact(e.trigger, e.agentId, before, after)
253 writeLog(ctx.log, {
254 t: Date.now(),
255 ev: 'compact',
256 trigger: e.trigger,
257 loop: e.agentId ?? 'main',
258 before,
259 after,
260 messagesIn: e.messages.length,
261 messagesOut: r.messages?.length,
262 skip: isSkip ? r.skip : undefined,
263 })
264 await flush($, ctx)
265 return r
266 })
267
268 // ---- turns -----------------------------------------------------------------
269
270 on('turn.step', async function* ($, e, next) {
271 const started = Date.now()
272 const r = yield* next(e)
273 const rec = ctx.stats.step(e, r.usage, started, Date.now())
274 if (e.agentId === undefined && r.usage) ctx.lastMainTokens = rec.input + rec.cacheRead + rec.cacheWrite + rec.output
275 writeLog(ctx.log, { t: started, ev: 'step', ...rec, ms: Date.now() - started, stop: r.stopReason, tools: r.toolUses.map(t => t.name) })
276 void flush($, ctx)
277 return r
278 })
279
280 on('turn.complete', async ($, e, next) => {
281 writeLog(ctx.log, { t: Date.now(), ev: 'turn', loop: e.agentId ?? 'main', turnId: e.turnId, ms: e.durationMs, reason: e.reason })
282 if (e.agentId === undefined && ctx.cfg.compactionPrune && has('compact')) ctx.pendingPrune = true
283 $.ui.invalidate('ui.render')
284 await flush($, ctx)
285 return next(e)
286 })
287
288 // ---- stats -----------------------------------------------------------------
289
290 on('command.run', { command: 'feathercode-stats' }, async () => ({ text: statsText(ctx) }))
291
292 on('command.run', { command: 'feathercode-panel' }, async $ => {
293 if ((await $.session.surfaces()).length === 0) return { text: statsText(ctx) }
294 await $.ui.open({ id: PANE, title: 'feathercode' })
295 return { text: 'feathercode panel opened.' }
296 })
297
298 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
299 const { Box, Text } = $.ui.resolve(e)
300 const lines = statsText(ctx).split('\n')
301 return (
302 <Box flexDirection="column">
303 {lines.map(line => (
304 <Text>{line}</Text>
305 ))}
306 </Box>
307 )
308 })
309}
310
311// ---- helpers (all `$` use stays in this file) ---------------------------------
312
313async function loadCtx($: EngineInterface, ctx: Ctx): Promise<void> {
314 ctx.root = await $.session.root()
315 ctx.sessionId = await $.session.id()
316 let existing: string | undefined
317 try {
318 const text = await $.fs.read(`${$.plugin.root}/logs/${ctx.sessionId}.jsonl`)
319 if (typeof text === 'string') existing = text
320 } catch {
321 existing = undefined
322 }
323 attachLog(ctx.log, ctx.sessionId, existing)
324 const v = await $.session.version()
325 writeLog(ctx.log, {
326 t: Date.now(),
327 ev: 'start',
328 session: ctx.sessionId,
329 version: v.version,
330 model: await $.session.model(),
331 surfaces: await $.session.surfaces(),
332 cwd: await $.session.cwd(),
333 features: [...ctx.cfg.features],
334 })
335 await flush($, ctx)
336}
337
338/**
339 * The one file the mod writes: its JSONL log, `<plugin>/logs/<session>.jsonl`
340 * (`<session>.<n>.jsonl` past 3.5 MB).
341 */
342async function flush($: EngineInterface, ctx: Ctx): Promise<void> {
343 const w = takeFlush(ctx.log)
344 if (!w) return ctx.log.writing
345 ctx.log.writing = ctx.log.writing.then(() => $.fs.write(`${$.plugin.root}/logs/${w.file}`, w.text)).catch(() => undefined)
346 return ctx.log.writing
347}
348
349function statsText(ctx: Ctx): string {
350 return formatStats(ctx.stats, ctx.log.file ? `<plugin>/logs/${ctx.log.file}` : undefined, [
351 `features: ${[...ctx.cfg.features].join(',') || 'observe only'} | mode: ${ctx.mode}`,
352 ])
353}
354
355/** `/plan` and `/build`; a built-in of that name keeps it, so fall back to fc-*. */
356async function registerModeCommands($: EngineInterface, ctx: Ctx): Promise<void> {
357 try {
358 await $.command.register({ name: 'plan', description: 'Plan agent: read-only, edits only in the plan directory (OpenCode plan)' })
359 } catch {
360 ctx.commands.plan = 'fc-plan'
361 await $.command.register({ name: 'fc-plan', description: 'Plan agent: read-only, edits only in the plan directory (OpenCode plan)' })
362 }
363 try {
364 await $.command.register({ name: 'build', description: 'Build agent: the default, full tool access (OpenCode build)' })
365 } catch {
366 ctx.commands.build = 'fc-build'
367 await $.command.register({ name: 'fc-build', description: 'Build agent: the default, full tool access (OpenCode build)' })
368 }
369}
370
371/** Switches agent: records the mode and appends v2's one-shot reminder. */
372async function switchMode($: EngineInterface, ctx: Ctx, mode: Mode): Promise<string> {
373 if (ctx.mode === mode) return `Already in ${mode} mode.`
374 ctx.mode = mode
375 await $.store.set(`mode:${ctx.sessionId}`, mode)
376 $.ui.invalidate('ui.render')
377 const dir = planDir(ctx.root)
378 const reminder = mode === 'plan' ? planEnter(dir) : PLAN_LEAVE
379 try {
380 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: reminder }] } })
381 } catch (err) {
382 writeLog(ctx.log, { t: Date.now(), ev: 'error', where: 'switchMode.append', error: String(err) })
383 }
384 writeLog(ctx.log, { t: Date.now(), ev: 'mode', mode })
385 ctx.stats.noteAll(`mode→${mode}`)
386 return mode === 'plan'
387 ? `Plan mode: read-only. Edits are allowed only in ${dir}. /${ctx.commands.build} to switch back.`
388 : `Build mode: full tool access. /${ctx.commands.plan} for read-only planning.`
389}
390
391/** A resumed session (`--continue`, `--resume`) gets its mode back. */
392async function restoreMode($: EngineInterface, ctx: Ctx): Promise<void> {
393 const saved = await $.store.get(`mode:${ctx.sessionId}`)
394 if (saved === 'plan' || saved === 'build') ctx.mode = saved
395}
396
397async function registerAgents($: EngineInterface, ctx: Ctx): Promise<void> {
398 try {
399 await $.agent.register({
400 name: 'explore',
401 description: EXPLORE_DESCRIPTION,
402 prompt: EXPLORE_PROMPT,
403 tools: ['Bash', 'Glob', 'Grep', 'Read', 'WebFetch', 'WebSearch'],
404 model: 'inherit',
405 })
406 await $.agent.register({
407 name: 'general',
408 description: GENERAL_DESCRIPTION,
409 prompt: systemPrompt(['Bash', 'Write', 'Edit'], ctx.policy ?? POLICY_FALLBACK),
410 disallowedTools: ['AskUserQuestion', 'Agent'],
411 model: 'inherit',
412 })
413 ctx.agentsReady = true
414 } catch (err) {
415 writeLog(ctx.log, { t: Date.now(), ev: 'error', where: 'registerAgents', error: String(err) })
416 }
417}
418
419/** v2's trigger: between turns, once the context reaches the ceiling; prune when it frees enough. */
420async function maybeCompact($: EngineInterface, ctx: Ctx, tokens: number, window: number): Promise<void> {
421 if (ctx.compacting) return
422 const limit = ceiling(window, ctx.cfg.compactionBuffer || undefined)
423 const due = ctx.cfg.compactionAuto && tokens >= limit
424 if (!due && ctx.pendingPrune) {
425 const current = await $.session.messages()
426 if ('deny' in current || planPrune(current as readonly Msg[]).ids.size === 0) ctx.pendingPrune = false
427 }
428 if (!due && !ctx.pendingPrune) return
429 ctx.compacting = true
430 const reason = due ? 'ceiling' : 'prune'
431 try {
432 if (due) ctx.pendingPrune = false
433 writeLog(ctx.log, { t: Date.now(), ev: 'compact-request', reason, tokens, limit })
434 await $.session.compact()
435 } catch (err) {
436 // Headless (-p / SDK) sessions refuse $.session.compact between turns; a
437 // queued /compact runs as a turn of its own, with the prune marker as its
438 // instructions when that is what was asked.
439 writeLog(ctx.log, { t: Date.now(), ev: 'error', where: 'maybeCompact', error: String(err) })
440 if (/headless/.test(String(err))) {
441 try {
442 await $.command.run({ command: 'compact', args: reason === 'prune' ? PRUNE_MARK : '' })
443 writeLog(ctx.log, { t: Date.now(), ev: 'compact-request', reason, via: 'command' })
444 } catch (err2) {
445 writeLog(ctx.log, { t: Date.now(), ev: 'error', where: 'maybeCompact.command', error: String(err2) })
446 }
447 }
448 } finally {
449 ctx.compacting = false
450 }
451}
452
453/** OpenCode v2 compaction in place of the engine's (prune: v1, opt-in). */
454async function compact(
455 $: EngineInterface,
456 ctx: Ctx,
457 e: SessionCompactInput,
458 next: (e: SessionCompactInput) => Promise<SessionCompactResult>,
459): Promise<SessionCompactResult> {
460 if (e.trigger === 'precompute') return { skip: 'feathercode compacts on demand' }
461 const messages = e.messages as readonly Msg[]
462 if ((e.trigger === 'plugin' && ctx.pendingPrune) || e.instructions === PRUNE_MARK) {
463 ctx.pendingPrune = false
464 const plan = planPrune(messages)
465 if (plan.ids.size === 0) return { skip: 'nothing to prune' }
466 const pruned = applyPrune(messages, plan)
467 writeLog(ctx.log, { t: Date.now(), ev: 'prune', outputs: plan.ids.size, tokens: plan.tokens })
468 return { messages: pruned as SessionMessage[] }
469 }
470 if (e.trigger === 'auto' && !ctx.cfg.compactionAuto) return { skip: 'auto compaction is off (feathercode compaction_auto)' }
471 // A subagent's loop: fork reads only the main thread, so the engine compacts it.
472 if (e.agentId !== undefined) return next(e)
473
474 const split = splitConversation(messages, ctx.cfg.keepTokens)
475 if (!split || split.start === 0) return { skip: 'nothing to compact' }
476 const update = isCheckpoint(messages[0])
477 const keptFrom = split.start < messages.length ? messages[split.start]!.text.trim().split('\n')[0]!.slice(0, 80) : undefined
478 const prompt = buildPrompt(update, keptFrom, e.instructions)
479
480 let summary: string | undefined
481 let r = await $.model.fork({ prompt })
482 if (r.isAnswered && !hasTemplate(r.text)) r = await $.model.fork({ prompt: `${prompt}\n\n${NUDGE}` })
483 if (r.isAnswered) summary = r.text
484 writeLog(ctx.log, {
485 t: Date.now(),
486 ev: 'summary',
487 via: 'fork',
488 ok: r.isAnswered,
489 reason: r.isAnswered ? undefined : r.reason,
490 usage: 'usage' in r ? r.usage : undefined,
491 })
492 if (summary === undefined) {
493 // v2's own fallback form: the older part flattened into one message.
494 const older = messages.slice(0, split.start).map(messageToText).filter(Boolean).join('\n\n')
495 const c = await $.model.complete({
496 model: await $.session.model(),
497 system: systemPrompt(['Bash', 'Write', 'Edit'], ctx.policy ?? POLICY_FALLBACK),
498 prompt: `${older}\n\n${buildPrompt(update, undefined, e.instructions)}`,
499 maxTokens: 8000,
500 })
501 writeLog(ctx.log, { t: Date.now(), ev: 'summary', via: 'complete', ok: c.isAnswered, usage: c.usage })
502 if (!c.isAnswered) return next(e)
503 summary = c.text
504 }
505
506 const reminder = ctx.mode === 'plan' && ctx.cfg.features.has('modes') ? `\n\n${planEnter(planDir(ctx.root))}` : ''
507 const tokensBefore = messages.reduce((n, m) => n + messageTokens(m), 0)
508 let out: Msg[]
509 if (ctx.cfg.compactionTail === 'messages') {
510 out = [{ role: 'user', text: checkpoint(summary, '') + reminder, toolUses: [] }, ...messages.slice(split.start)]
511 } else {
512 out = [{ role: 'user', text: checkpoint(summary, split.recent) + reminder, toolUses: [] }]
513 }
514 const tokensAfter = out.reduce((n, m) => n + messageTokens(m), 0)
515 return { messages: out as SessionMessage[], tokensBefore, tokensAfter }
516}
517hooks/lib/compaction.ts 277 lines1// Compaction ported from OpenCode, branch v2 (commit 0d07f91):
2// packages/core/src/session/compaction.ts (template, rules, prompt, split, ceiling)
3// packages/core/src/session/runner/to-llm-message.ts (checkpoint wrapper)
4// and prune from OpenCode dev (v1, packages/opencode/src/session/compaction.ts),
5// which v2 removed; kept here as an opt-in option.
6// Copyright (c) 2025 opencode, MIT License (see ../../NOTICE.md).
7
8/** The transcript row shape `session.compact` hands a hook (SessionMessage). */
9export type Msg = {
10 role: 'user' | 'assistant'
11 text: string
12 toolUses: { tool_use_id: string; tool: string; input: Record<string, unknown>; text?: string; isError?: true }[]
13 toolResults?: { tool_use_id: string; text: string; isError: boolean }[]
14 handle?: string
15}
16
17export const DEFAULT_KEEP_TOKENS = 15_000
18const RESERVE_MIN = 16_000
19const TOOL_OUTPUT_MAX_CHARS = 1_250
20
21export const SUMMARY_TEMPLATE = `You MUST use this format for your response (you may omit sections that aren't applicable). Do not include the <template> tags in your response.
22<template>
23## Objective
24- [one or two brief sentences describing what the user is trying to accomplish]
25
26## Requirements
27- [constraints, preferences, requirements, and scope boundaries stated by the user, or "(none)"]
28
29## Decisions
30- [decisions already made and why, or "(none)"]
31
32## Work State
33Break the objective into smaller goals and report which are completed, which are being worked on, and which are blocked.
34### Completed
35- [goals that have been completed; otherwise "(none)"]
36
37### Active
38- [goals currently being worked on; otherwise "(none)"]
39
40### Blocked
41- [anything blocking progress, and why; otherwise "(none)"]
42
43## Next Move
441. [ordered list of next actions, or "(none)"]
45
46## Relevant Files
47List the files and directories, other than the current working directory, that another agent would need to open to continue this work. Include at most 15, most important first. Do not list every file that was read or changed. Include paths outside the current working directory when relevant. If none, write "(none)".
48- \`[file or directory path]\`: [brief reason it matters]
49
50## Important Context
51- [facts the next agent cannot continue without and cannot easily find on its own; or "(none)"]
52</template>`
53
54export const SUMMARY_RULES = `Rules:
55- Keep each section concise. Use terse, single-line bullets, not prose paragraphs or nested lists.
56- Prefer short references over detailed restatement. It is fine to leave out information the next agent can recover from the code or the files listed above.
57- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers.
58- Carry forward only user questions or requests that remain unanswered or require further action. Do not repeat ones that newer history has answered or resolved. Preserve exact wording when carrying one forward.
59- Preserve consequential workflow state, including whether changes are uncommitted, committed, pushed, under review, or merged.
60- Do not mention the summary process or that context was compacted.`
61
62export const NUDGE =
63 'The previous response did not fill in the required summary template. Do not call tools. Return the summary as text using the exact section headings from the template.'
64
65const CHECKPOINT_OPEN = '<conversation-checkpoint>'
66
67/**
68 * buildPrompt (v2). `keptFrom`: the opening of the newest user message kept
69 * verbatim, so the fork (which sees the whole transcript) leaves it out.
70 * `instructions`: what the person typed after /compact.
71 */
72export function buildPrompt(update: boolean, keptFrom?: string, instructions?: string): string {
73 const shared = [
74 'Summarize only what the user and the assistant said and did. Leave out instructions and setup the assistant was given rather than told by the user: repository conventions, instruction files such as AGENTS.md or CLAUDE.md, and environment details like the session ID. The next agent receives current versions of all of these separately.',
75 SUMMARY_TEMPLATE,
76 SUMMARY_RULES,
77 'Do not continue the task or call tools.',
78 'Return only the structured summary in the requested format. Do not include a preamble, explanation, or other commentary.',
79 ]
80 const scope = keptFrom
81 ? [`The conversation from the user message beginning "${keptFrom}" onward is kept verbatim after the summary; summarize only what comes before it.`]
82 : []
83 const focus = instructions?.trim() ? [`Additional focus requested by the user: ${instructions.trim()}`] : []
84 if (!update) {
85 return [
86 'You MUST summarize the conversation above into a structured summary that will be given to another agent to resume the work.',
87 ...scope,
88 ...focus,
89 ...shared,
90 ].join('\n\n')
91 }
92 return [
93 'Update the existing checkpoint in the conversation above into one consolidated summary.',
94 ...scope,
95 ...focus,
96 'Newer history always takes precedence over the existing checkpoint. Preserve previous information unless newer history clearly contradicts, supersedes, resolves, or makes it stale. If something is no longer relevant to continuing the work, you may remove it.',
97 'Incorporate newer requirements, decisions, progress, and context. Reconcile Work State and Next Move: move completed work out of Active, remove resolved blockers and answered questions, and preserve unresolved or pending work.',
98 'Return only the updated Markdown sections. Do not reproduce the `<conversation-checkpoint>`, `<summary>`, or `<recent-context>` wrapper tags from the previous checkpoint.',
99 ...shared,
100 ].join('\n\n')
101}
102
103/** The checkpoint message (to-llm-message.ts). */
104export function checkpoint(summary: string, recent: string): string {
105 return [
106 CHECKPOINT_OPEN,
107 'The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.',
108 '',
109 '<summary>',
110 summary.trim(),
111 '</summary>',
112 ...(recent ? ['', '<recent-context>', recent, '</recent-context>'] : []),
113 '</conversation-checkpoint>',
114 ].join('\n')
115}
116
117/** True when the reply follows the template (any of its `##` headings). */
118export function hasTemplate(text: string): boolean {
119 return /^## (Objective|Requirements|Decisions|Work State|Next Move|Relevant Files|Important Context)\b/m.test(text)
120}
121
122export function isCheckpoint(m: Msg | undefined): boolean {
123 return m !== undefined && m.role === 'user' && m.text.trimStart().startsWith(CHECKPOINT_OPEN)
124}
125
126/** A message's full size in tokens: text, tool inputs, results (chars/4). */
127export function messageTokens(m: Msg): number {
128 let n = m.text.length
129 for (const u of m.toolUses) n += JSON.stringify(u.input).length + (u.text?.length ?? 0)
130 for (const r of m.toolResults ?? []) n += r.text.length
131 return Math.ceil(n / 4)
132}
133
134/** chars/4, as util/token.ts estimates. */
135export function estimate(text: string): number {
136 return Math.ceil(text.length / 4)
137}
138
139function truncate(value: string): string {
140 if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
141 return Array.from(value).slice(0, TOOL_OUTPUT_MAX_CHARS).join('') + '\n[truncated]'
142}
143
144/** A user row a person (or a plugin) wrote, not a tool-result carrier. */
145export function isPrompt(m: Msg): boolean {
146 return m.role === 'user' && (m.toolResults?.length ?? 0) === 0 && m.text.trim() !== '' && !isCheckpoint(m)
147}
148
149/** messageToText (v2), over Claude Code's message shape. */
150export function messageToText(m: Msg): string {
151 if (isCheckpoint(m)) return ''
152 if (m.role === 'user') {
153 if ((m.toolResults?.length ?? 0) > 0) return '' // shown with the assistant's call
154 return m.text.trim() ? `[User]: ${m.text}` : ''
155 }
156 const parts: string[] = []
157 if (m.text.trim()) parts.push(`[Assistant]: ${m.text}`)
158 for (const use of m.toolUses) {
159 parts.push(`[Assistant tool call]: ${use.tool}(${JSON.stringify(use.input)})`)
160 if (use.text !== undefined) parts.push(use.isError ? `[Tool error]: ${truncate(use.text)}` : `[Tool result]: ${truncate(use.text)}`)
161 }
162 return parts.join('\n')
163}
164
165export type Split = {
166 /** Index of the first message kept as recent (messages.length: none). */
167 start: number
168 /** The recent part, flattened. */
169 recent: string
170}
171
172/**
173 * splitConversation + recentStart (v2): keep the newest entries within `keep`
174 * tokens (always the newest one), snapped back to a user prompt so a call
175 * and its result stay together; everything fitting keeps the latest exchange
176 * only. Undefined when there is nothing to compact.
177 */
178export function splitConversation(messages: readonly Msg[], keep: number): Split | undefined {
179 const entries = messages.flatMap((m, index) => {
180 const text = messageToText(m)
181 return text ? [{ m, text, index }] : []
182 })
183 if (entries.length === 0) return undefined
184 let total = 0
185 let dropped = entries.length
186 while (dropped > 0) {
187 const next = total + estimate(entries[dropped - 1]!.text)
188 if (next > keep) break
189 total = next
190 dropped--
191 }
192 dropped = Math.min(dropped, entries.length - 1)
193 const isUserAt = (i: number) => isPrompt(entries[i]!.m)
194 let boundary = -1
195 for (let i = dropped; i >= 0; i--) if (isUserAt(i)) {
196 boundary = i
197 break
198 }
199 if (boundary <= 0) {
200 let latest = -1
201 for (let i = entries.length - 1; i >= 0; i--) if (isUserAt(i)) {
202 latest = i
203 break
204 }
205 const previous = messages.find(isCheckpoint)
206 boundary = latest > 0 ? latest : previous && /<recent-context>/.test(previous.text) ? 0 : entries.length
207 }
208 const recent = entries.slice(boundary)
209 return {
210 start: recent[0]?.index ?? messages.length,
211 recent: recent.map(e => e.text).join('\n\n'),
212 }
213}
214
215/** calculateCeiling (v2): window − buffer, else window − max(10%, 16k). */
216export function ceiling(window: number, buffer?: number): number {
217 if (window <= 0) return Number.POSITIVE_INFINITY
218 if (buffer !== undefined && buffer > 0) return window - buffer
219 return window - Math.max(Math.floor(window * 0.1), window >= 32_000 ? RESERVE_MIN : 0)
220}
221
222// ---- prune (OpenCode v1; v2 removed it) -------------------------------------
223
224export const PRUNE_PROTECT = 40_000
225export const PRUNE_MINIMUM = 20_000
226export const PRUNE_PROTECTED_TOOLS: ReadonlySet<string> = new Set(['Skill'])
227export const PRUNED = '[Old tool result content cleared]'
228
229export type PrunePlan = { ids: Set<string>; tokens: number }
230
231/**
232 * Walks back from the newest message, skipping the latest two user turns;
233 * stops at a checkpoint or an output already cleared. Tool outputs past the
234 * newest PRUNE_PROTECT tokens are cleared, if that frees over PRUNE_MINIMUM.
235 */
236export function planPrune(messages: readonly Msg[]): PrunePlan {
237 const toolOf = new Map<string, string>()
238 for (const m of messages) for (const u of m.toolUses) toolOf.set(u.tool_use_id, u.tool)
239 let turns = 0
240 let total = 0
241 let pruned = 0
242 const ids = new Set<string>()
243 outer: for (let i = messages.length - 1; i >= 0; i--) {
244 const m = messages[i]!
245 if (isPrompt(m)) turns++
246 if (turns < 2) continue
247 if (isCheckpoint(m)) break
248 for (const r of [...(m.toolResults ?? [])].reverse()) {
249 if (r.text === PRUNED) break outer
250 if (PRUNE_PROTECTED_TOOLS.has(toolOf.get(r.tool_use_id) ?? '')) continue
251 const n = estimate(r.text)
252 total += n
253 if (total > PRUNE_PROTECT) {
254 pruned += n
255 ids.add(r.tool_use_id)
256 }
257 }
258 }
259 return pruned > PRUNE_MINIMUM ? { ids, tokens: pruned } : { ids: new Set(), tokens: 0 }
260}
261
262/** The messages with the planned outputs cleared; touched rows lose their handle. */
263export function applyPrune(messages: readonly Msg[], plan: PrunePlan): Msg[] {
264 if (plan.ids.size === 0) return [...messages]
265 return messages.map(m => {
266 const hit = m.toolResults?.some(r => plan.ids.has(r.tool_use_id))
267 const usesHit = m.toolUses.some(u => plan.ids.has(u.tool_use_id))
268 if (!hit && !usesHit) return m
269 const { handle: _drop, ...rest } = m
270 return {
271 ...rest,
272 toolResults: m.toolResults?.map(r => (plan.ids.has(r.tool_use_id) ? { ...r, text: PRUNED } : r)),
273 toolUses: m.toolUses.map(u => (plan.ids.has(u.tool_use_id) && u.text !== undefined ? { ...u, text: PRUNED } : u)),
274 }
275 })
276}
277hooks/lib/config.ts 41 lines1export type Feature = 'prompt' | 'cache' | 'tools' | 'modes' | 'agents' | 'compact'
2
3export const ALL_FEATURES: readonly Feature[] = ['prompt', 'cache', 'tools', 'modes', 'agents', 'compact']
4
5export type Config = {
6 features: ReadonlySet<Feature>
7 compactionAuto: boolean
8 compactionPrune: boolean
9 keepTokens: number
10 /** 0: OpenCode's max(10%, 16k) reserve. */
11 compactionBuffer: number
12 compactionTail: 'text' | 'messages'
13 panel: boolean
14}
15
16export function parseFeatures(text: string | undefined): Set<Feature> {
17 const raw = (text ?? 'all').trim().toLowerCase()
18 if (raw === '' || raw === 'all') return new Set(ALL_FEATURES)
19 if (raw === 'observe' || raw === 'none') return new Set()
20 const out = new Set<Feature>()
21 for (const part of raw.split(',')) {
22 const f = part.trim() as Feature
23 if (ALL_FEATURES.includes(f)) out.add(f)
24 }
25 return out
26}
27
28/** The userConfig values, defaults filled in. */
29export function resolveConfig(o: Record<string, unknown>): Config {
30 const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) ? v : d)
31 return {
32 features: parseFeatures(typeof o.features === 'string' ? o.features : 'all'),
33 compactionAuto: o.compaction_auto !== false,
34 compactionPrune: o.compaction_prune === true,
35 keepTokens: num(o.keep_tokens, 15_000),
36 compactionBuffer: num(o.compaction_buffer, 0),
37 compactionTail: o.compaction_tail === 'messages' ? 'messages' : 'text',
38 panel: o.panel === true,
39 }
40}
41hooks/lib/log.ts 58 lines1/**
2 * JSONL logger, one file per session under `<plugin>/logs/`. $.fs has no append, so the session's lines are held in memory
3 * and the whole file is rewritten on each flush (writes are chained). One file
4 * per session id; a reload re-reads what is already there. Past MAX_BYTES the
5 * log rolls to `<id>.<n>.jsonl`.
6 */
7const MAX_BYTES = 3_500_000
8
9export type JsonlLog = {
10 lines: string[]
11 /** The file name under `<plugin>/logs/`. */
12 file?: string
13 base: string
14 writing: Promise<void>
15 dirty: boolean
16 bytes: number
17 part: number
18}
19
20export function createLog(): JsonlLog {
21 return { lines: [], base: '', writing: Promise.resolve(), dirty: false, bytes: 0, part: 0 }
22}
23
24export function writeLog(log: JsonlLog, record: Record<string, unknown>): void {
25 const line = JSON.stringify(record)
26 log.lines.push(line)
27 log.bytes += line.length + 1
28 log.dirty = true
29}
30
31/** The path of the next write and its text; rolls the log past MAX_BYTES. */
32export function takeFlush(log: JsonlLog): { file: string; text: string } | undefined {
33 if (!log.file || !log.dirty) return undefined
34 log.dirty = false
35 const file = log.file
36 const text = log.lines.join('\n') + '\n'
37 if (log.bytes > MAX_BYTES) {
38 log.part += 1
39 log.file = `${log.base}.${log.part}.jsonl`
40 log.lines = []
41 log.bytes = 0
42 }
43 return { file, text }
44}
45
46/** Sets the file for a session and merges lines already written there. */
47export function attachLog(log: JsonlLog, sessionId: string, existing: string | undefined): void {
48 log.base = sessionId.replace(/[^A-Za-z0-9._-]/g, '_')
49 log.file = `${log.base}.jsonl`
50 if (existing && existing.length > 0) {
51 log.lines = existing
52 .split('\n')
53 .filter(l => l.length > 0)
54 .concat(log.lines)
55 log.bytes = existing.length
56 }
57}
58hooks/lib/modes.ts 36 lines1// Build / plan agents, as OpenCode v2 defines them (plugin/plan.ts):
2// plan denies the edit permission (edit, write, patch) except in the plan
3// directory (Claude Code's Edit, Write, NotebookEdit); the shell and
4// subagents are not restricted.
5// Copyright (c) 2025 opencode, MIT License (see ../../NOTICE.md).
6
7export type Mode = 'build' | 'plan'
8
9/**
10 * The plan directory, under the project root (OpenCode v1's `.opencode/plans`
11 * placement; v2 uses `~/.opencode/plan`, which would need the home directory).
12 */
13export function planDir(root: string): string {
14 return `${root.replace(/[\\/]$/, '')}/.opencode/plan`
15}
16
17/** Lexically normalised absolute path (`.`/`..` resolved), or undefined. */
18export function normalize(path: string): string | undefined {
19 if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) return undefined
20 const sep = path.includes('\\') && !path.includes('/') ? '\\' : '/'
21 const parts: string[] = []
22 for (const p of path.split(/[\\/]+/)) {
23 if (p === '' || p === '.') continue
24 if (p === '..') parts.pop()
25 else parts.push(p)
26 }
27 return path.startsWith('/') ? '/' + parts.join('/') : parts.join(sep)
28}
29
30/** Whether `path` lies inside `dir` (both lexically normalised, absolute). */
31export function isInside(path: string, dir: string): boolean {
32 const target = normalize(path)
33 const root = normalize(dir)
34 return target !== undefined && root !== undefined && target.startsWith(root + '/')
35}
36hooks/lib/prompts.ts 185 lines1// Prompt texts ported from OpenCode, branch v2 (commit 0d07f91, 2.0.24):
2// packages/core/src/session/runner/prompt/system.txt
3// packages/core/src/session/system-prompt.ts (tool guidance)
4// packages/core/src/plugin/system-prompt/anthropic.txt
5// packages/core/src/plugin/plan.ts (plan reminders)
6// packages/core/src/plugin/agent.ts (explore prompt, descriptions)
7// packages/core/src/skill/instructions.ts (skill listing)
8// packages/core/src/tool/plugin/*.ts (tool descriptions)
9// Copyright (c) 2025 opencode, MIT License (see ../../NOTICE.md).
10// Adapted: Claude Code tool and parameter names (Bash, Edit `old_string`,
11// ...), Claude Code tool behaviour (read-before-edit, background agents),
12// and the harness name.
13
14/** Tool guidance lines (session/system-prompt.ts), by Claude Code tool name. */
15export function toolGuidance(tools: readonly string[]): string {
16 const lines: string[] = []
17 if (tools.includes('Bash')) {
18 lines.push(
19 '- Prefer dedicated tools over shell commands; fall back to the shell when a tool cannot do what you need.',
20 "- Do not chain shell commands with separators like `echo \"====\";` or `printf '---'`; the output becomes noisy in a way that makes the user's side of the conversation worse.",
21 )
22 }
23 if (tools.includes('Write')) {
24 lines.push('- Use the Write tool to create files or completely replace their content. Prefer using the Edit tool for targeted changes.')
25 }
26 if (tools.includes('Edit')) {
27 lines.push(
28 '- Use the Edit tool for targeted changes to existing text files. It replaces the exact text in `old_string` with `new_string`, and the values must differ. By default, `old_string` must occur exactly once. If it occurs multiple times, include more surrounding context to make it unique or set `replace_all` to true to replace every occurrence.',
29 )
30 }
31 return lines.join('\n')
32}
33
34/**
35 * System block 0: system.txt with tool guidance, then anthropic.txt.
36 * `policy` is the engine's own safety line, kept verbatim when present.
37 */
38export function systemPrompt(tools: readonly string[], policy?: string): string {
39 const guidance = toolGuidance(tools)
40 return [
41 'You are an AI agent running in a coding agent harness. Help the user accomplish their goals using the tools you have available.',
42 ...(policy ? ['', policy] : []),
43 '',
44 '# Harness',
45 '- Responses are rendered as GitHub-flavored Markdown.',
46 '- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.',
47 '- A denied tool call means the user declined it; adjust instead of retrying it unchanged.',
48 '- Prefer parallelizing independent tool calls.',
49 ...(guidance ? [guidance] : []),
50 '',
51 '# Communication',
52 '- Use clear file paths when referring to files.',
53 '- Keep responses clear and concise, and avoid unnecessary technical jargon.',
54 '',
55 '# Working in codebases',
56 '- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.',
57 '- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.',
58 '',
59 '# Code comments',
60 'By default, match the surrounding comment density: where the code has none, add none. Use comments sparingly, only where they are appropriate, such as for behavior that is not obvious from the code itself. Instructions from the user or the project take precedence over this guidance.',
61 ].join('\n')
62}
63
64/** The engine's security policy line, found in its own sections. */
65export function findPolicy(texts: readonly string[]): string | undefined {
66 for (const t of texts) {
67 const m = /^IMPORTANT: Assist with authorized security testing[^\n]*$/m.exec(t)
68 if (m) return m[0]
69 }
70 return undefined
71}
72
73/** Plan mode reminders (plugin/plan.ts), sent once per switch. */
74export function planEnter(directory: string): string {
75 return [
76 '<system-reminder>',
77 'You are in Plan mode. Discuss the plan with the user directly in the conversation. Do not create or update plan files unless the user explicitly asks you to; when they do, write them only in:',
78 directory,
79 '',
80 'Do not modify any other files or ask a subagent to do so.',
81 '',
82 'You remain in Plan mode until the user switches agents. If the user asks you to implement changes, do not do so. Tell them they need to switch agents.',
83 '</system-reminder>',
84 ].join('\n')
85}
86
87export const PLAN_LEAVE = [
88 '<system-reminder>',
89 'You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.',
90 '</system-reminder>',
91].join('\n')
92
93export function planDenied(tool: string, directory: string): string {
94 return `Cannot use ${tool} to modify files outside the Plan directory: ${directory}`
95}
96
97/** Explore subagent (plugin/agent.ts PROMPT_EXPLORE). */
98export const EXPLORE_PROMPT = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
99
100Guidelines:
101- Your role is EXCLUSIVELY to search and analyze
102- Parallelize independent tool calls for searches and reads whenever possible
103- Adapt your search approach based on the thoroughness level specified by the caller
104- Return file paths as absolute paths in your final response
105- You MUST NOT create, modify, delete, move, or copy files, including temporary files and reports
106- Shell commands MUST be read-only. NEVER run commands that write files or change system state
107
108Complete the user's search request efficiently and report your findings clearly.`
109
110export const EXPLORE_DESCRIPTION =
111 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
112
113export const GENERAL_DESCRIPTION =
114 'General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.'
115
116/** Tool descriptions (tool/plugin/*.ts), adapted to Claude Code's parameters. */
117export const TOOL_DESCRIPTIONS: Readonly<Record<string, string>> = {
118 Read:
119 'Read the contents of a file. Supports text files, images, PDFs (`pages`, required past 10 pages) and Jupyter notebooks; `file_path` must be absolute. Each text line is prefixed by its 1-based line number; the prefix is for reference and is not part of the file content. Use offset and limit to read large files in sections (default and cap: 2000 lines). Prefer one larger read over many small slices, and use grep to find specific content in large files.',
120 Edit:
121 'Edit the contents of a file by finding and replacing exact text. The file must have been read in this conversation first. When editing text from Read output, preserve the exact indentation (tabs or spaces) and omit the line-number prefix. Never include the prefix in old_string or new_string. The edit fails if old_string is not found. By default, old_string must identify a UNIQUE location. Multiple matches FAIL unless replace_all is true. Add more surrounding context to disambiguate, or set replace_all to true to replace every occurrence. Use replace_all when the change should apply to every occurrence, such as renaming a variable.',
122 Write:
123 'Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically. An existing file must be read first.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the Edit tool instead.',
124 Bash:
125 'Execute a bash command and return its output. Quote file paths containing spaces or special characters. Prefer dedicated tools over shell commands when possible. The working directory persists between calls; shell state does not. `timeout` is in milliseconds (default 120000, max 600000). `run_in_background` runs the command detached and returns immediately; you will be notified when it completes. Do not poll for completion. Interactive commands (`git rebase -i`) are not supported. Commit or push only when the user asks; when a system-reminder gives git attribution lines, end commit messages and PR bodies with them.',
126 Glob: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
127 Grep:
128 "Search file contents using ripgrep's regular expression syntax. Use it to locate specific code, symbols, or text patterns, and narrow searches with `path`, `glob` or `type`. Returns matching file paths, line numbers, or line previews depending on `output_mode`.",
129 Skill:
130 "Load a specialized skill's instructions and resources into the current conversation when the task at hand matches its description.\n\nThe `skill` name must match an available skill or a skill explicitly referenced by the user; `args` passes optional arguments.",
131 Agent: [
132 'Spawns an agent to work on the specified task; choose its type with `subagent_type` from the agent types listed in the conversation.',
133 "New agents start with fresh context, so include all relevant context and instructions in `prompt`.",
134 'Agents run in the background by default and you are notified when one finishes; pass `run_in_background: false` when your next step depends on its result.',
135 'Use background only for independent work that can run while you continue elsewhere. Never predict a pending agent\'s result.',
136 "The agent's final report is not shown to the user; relay what matters.",
137 ].join('\n'),
138 AskUserQuestion: `Use this tool when you need to ask the user questions during execution. This allows you to:
1391. Gather user preferences or requirements
1402. Clarify ambiguous instructions
1413. Get decisions on implementation choices as you work
1424. Offer choices to the user about what direction to take.
143
144Usage notes:
145- An "Other" option is added automatically; don't include a separate option for free form answers
146- Set \`multiSelect: true\` to allow selecting more than one option
147- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`,
148}
149
150/**
151 * The engine's skill listing, each description cut to its first sentence
152 * (at most `max` chars); the header and the skill names stay as they are.
153 */
154export function compactSkillListing(text: string, max = 200): string {
155 return text
156 .split('\n')
157 .map(line => {
158 const m = /^(- \S+(?: \([^)]*\))?: )(.*)$/.exec(line)
159 if (!m) return line
160 const desc = m[2]!
161 const sentence = /^(.+?[.!?])(\s|$)/.exec(desc)?.[1] ?? desc
162 const cut = sentence.length > max ? sentence.slice(0, max - 1).replace(/\s+\S*$/, '') + '…' : sentence
163 return m[1] + cut
164 })
165 .join('\n')
166}
167
168/**
169 * The engine's `session_context` attachment without its git status (v2 sends
170 * none); undefined when nothing else is left in it.
171 */
172export function stripGitStatus(text: string): string | undefined {
173 if (!text.includes('# gitStatus')) return text
174 const out = text.replace(/# gitStatus\n[\s\S]*?(?=\n# |\n\S[^\n]*attached this context automatically|$)/, '').trim()
175 const rest = out
176 .split('\n')
177 .filter(l => !/^As you answer the user's questions, you can use the following context:$/.test(l) && !/attached this context automatically/.test(l))
178 .join('\n')
179 .trim()
180 return rest === '' ? undefined : out
181}
182
183/** Engine attachments OpenCode v2 has no counterpart for: dropped. */
184export const DROPPED_ATTACHMENTS: ReadonlySet<string> = new Set(['todo_reminder', 'total_tokens_reminder'])
185hooks/lib/stats.ts 232 lines1/**
2 * Observation state (pure): what the request is made of (system sections,
3 * tool descriptions, injected attachments) and what each request cost (input /
4 * output / cache read / cache write). A request whose cache read falls short of
5 * the previous request's whole prompt in the same loop is a cache break; the
6 * events seen in between are kept as its candidate causes.
7 */
8
9export function hash(text: string): string {
10 let h = 0x811c9dc5
11 for (let i = 0; i < text.length; i++) {
12 h ^= text.charCodeAt(i)
13 h = Math.imul(h, 0x01000193)
14 }
15 return (h >>> 0).toString(16).padStart(8, '0')
16}
17
18export type Usage = {
19 input_tokens: number
20 output_tokens: number
21 cache_read_input_tokens: number
22 cache_creation_input_tokens: number
23}
24
25export type StepInput = {
26 turnId: string
27 index: number
28 model: string
29 effort?: string | number
30 messageCount: number
31 agentId?: string
32}
33
34export type StepRecord = {
35 loop: string
36 turnId: string
37 index: number
38 model: string
39 effort?: string | number
40 messages: number
41 input: number
42 output: number
43 cacheRead: number
44 cacheWrite: number
45 isBreak: boolean
46 lost: number
47 causes: string[]
48 gapMs: number
49}
50
51type LoopState = {
52 lastTotal: number
53 lastModel: string
54 lastEffort?: string | number
55 lastSys: string
56 lastTools: number
57 lastAt: number
58 pending: string[]
59}
60
61export type Section = { id: string; scope: string; text: string }
62
63/** Shortfall tolerated before a request counts as a cache break, in tokens. */
64export const BREAK_TOLERANCE = 1024
65/** The prompt cache lifetime Claude Code requests (1h); a gap past it explains a miss. */
66export const CACHE_TTL_MS = 60 * 60_000
67
68export class Stats {
69 steps: StepRecord[] = []
70 attachments = new Map<string, { count: number; chars: number; dropped: number }>()
71 sections: { id: string; scope: string; chars: number }[] = []
72 sysHash = ''
73 sysChanges = 0
74 tools = new Map<string, { chars: number; deferred: boolean }>()
75 toolSearches = 0
76 compactions: { trigger: string; before?: number; after?: number }[] = []
77 private loops = new Map<string, LoopState>()
78
79 private loop(id: string): LoopState {
80 let s = this.loops.get(id)
81 if (!s) {
82 s = { lastTotal: 0, lastModel: '', lastSys: '', lastTools: 0, lastAt: 0, pending: [] }
83 this.loops.set(id, s)
84 }
85 return s
86 }
87
88 note(loopId: string | undefined, what: string): void {
89 const s = this.loop(loopId ?? 'main')
90 if (s.pending.length < 40) s.pending.push(what)
91 }
92
93 noteAll(what: string): void {
94 if (this.loops.size === 0) this.loop('main')
95 for (const s of this.loops.values()) if (s.pending.length < 40) s.pending.push(what)
96 }
97
98 /** A rendered system prompt; returns the log record when it changed. */
99 compose(sections: readonly Section[]): Record<string, unknown> | undefined {
100 const h = hash(sections.map(s => `${s.scope}\u0000${s.id}\u0000${s.text}`).join('\u0001'))
101 if (h === this.sysHash) return undefined
102 if (this.sysHash !== '') {
103 this.sysChanges++
104 this.noteAll('system prompt changed')
105 }
106 this.sysHash = h
107 this.sections = sections.map(s => ({ id: s.id, scope: s.scope, chars: s.text.length }))
108 return { hash: h, sections: this.sections }
109 }
110
111 /** A tool description; true the first time the tool is seen. */
112 describe(tool: string, chars: number, deferred: boolean): boolean {
113 const isNew = !this.tools.has(tool)
114 this.tools.set(tool, { chars, deferred })
115 return isNew
116 }
117
118 attachment(type: string, loopId: string | undefined, chars: number | null): void {
119 const a = this.attachments.get(type) ?? { count: 0, chars: 0, dropped: 0 }
120 a.count++
121 if (chars === null) a.dropped++
122 else a.chars += chars
123 this.attachments.set(type, a)
124 this.note(loopId, `attachment:${type}${chars === null ? '(dropped)' : ''}`)
125 }
126
127 toolCall(tool: string, loopId: string | undefined, input: Record<string, unknown>): void {
128 if (tool === 'ToolSearch') {
129 this.toolSearches++
130 this.note(loopId, `ToolSearch(${String(input.query ?? '')})`)
131 } else if (tool === 'Skill') {
132 this.note(loopId, `Skill(${String(input.skill ?? '')})`)
133 } else if (tool === 'EnterPlanMode' || tool === 'ExitPlanMode') {
134 this.note(loopId, tool)
135 }
136 }
137
138 compact(trigger: string, loopId: string | undefined, before?: number, after?: number): void {
139 this.compactions.push({ trigger, before, after })
140 this.note(loopId, `compaction(${trigger})`)
141 }
142
143 /** One finished model request. */
144 step(e: StepInput, usage: Usage | null, startedAt: number, endedAt: number): StepRecord {
145 const loopId = e.agentId ?? 'main'
146 const s = this.loop(loopId)
147 const rec: StepRecord = {
148 loop: loopId,
149 turnId: e.turnId,
150 index: e.index,
151 model: e.model,
152 effort: e.effort,
153 messages: e.messageCount,
154 input: usage?.input_tokens ?? 0,
155 output: usage?.output_tokens ?? 0,
156 cacheRead: usage?.cache_read_input_tokens ?? 0,
157 cacheWrite: usage?.cache_creation_input_tokens ?? 0,
158 isBreak: false,
159 lost: 0,
160 causes: [],
161 gapMs: s.lastAt > 0 ? startedAt - s.lastAt : 0,
162 }
163 if (usage && s.lastTotal > 0 && rec.cacheRead + BREAK_TOLERANCE < s.lastTotal) {
164 rec.isBreak = true
165 rec.lost = s.lastTotal - rec.cacheRead
166 const causes = [...s.pending]
167 if (s.lastModel && s.lastModel !== e.model) causes.push(`model ${s.lastModel}→${e.model}`)
168 if (s.lastEffort !== e.effort) causes.push(`effort ${String(s.lastEffort)}→${String(e.effort)}`)
169 if (s.lastSys && s.lastSys !== this.sysHash) causes.push('system prompt hash')
170 if (s.lastTools && s.lastTools !== this.tools.size) causes.push(`tool set ${s.lastTools}→${this.tools.size}`)
171 if (rec.gapMs > CACHE_TTL_MS) causes.push(`idle ${Math.round(rec.gapMs / 60_000)}m (TTL)`)
172 rec.causes = causes
173 }
174 if (usage) {
175 s.lastTotal = rec.input + rec.cacheRead + rec.cacheWrite
176 s.lastModel = e.model
177 s.lastEffort = e.effort
178 s.lastSys = this.sysHash
179 s.lastTools = this.tools.size
180 s.pending = []
181 }
182 s.lastAt = endedAt
183 this.steps.push(rec)
184 return rec
185 }
186
187 totals() {
188 const t = { requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, breaks: 0, lost: 0 }
189 for (const s of this.steps) {
190 t.requests++
191 t.input += s.input
192 t.output += s.output
193 t.cacheRead += s.cacheRead
194 t.cacheWrite += s.cacheWrite
195 if (s.isBreak) {
196 t.breaks++
197 t.lost += s.lost
198 }
199 }
200 return t
201 }
202}
203
204export function formatStats(stats: Stats, logPath: string | undefined, extra: string[] = []): string {
205 const t = stats.totals()
206 const prompt = t.input + t.cacheRead + t.cacheWrite
207 const hit = prompt > 0 ? ((100 * t.cacheRead) / prompt).toFixed(1) : '0.0'
208 const shared = stats.sections.filter(s => s.scope === 'shared').reduce((n, s) => n + s.chars, 0)
209 const session = stats.sections.filter(s => s.scope === 'session').reduce((n, s) => n + s.chars, 0)
210 const listed = [...stats.tools.values()].filter(t => !t.deferred)
211 const lines = [
212 'feathercode stats',
213 ...extra,
214 `requests ${t.requests} | input ${t.input} | output ${t.output} | cache read ${t.cacheRead} | cache write ${t.cacheWrite} | hit ${hit}%`,
215 `cache breaks ${t.breaks} (~${t.lost} tokens re-written) | ToolSearch calls ${stats.toolSearches} | compactions ${stats.compactions.length}`,
216 `system prompt: ${stats.sections.length} sections, shared ${shared} chars, session ${session} chars, changed ${stats.sysChanges}x`,
217 `tools: ${listed.length} listed (${listed.reduce((n, t) => n + t.chars, 0)} desc chars), ${stats.tools.size - listed.length} deferred`,
218 ]
219 const att = [...stats.attachments.entries()].sort((a, b) => b[1].chars - a[1].chars)
220 if (att.length > 0) {
221 lines.push(
222 'attachments: ' +
223 att.map(([k, v]) => `${k}x${v.count} (${v.chars}c${v.dropped ? `, ${v.dropped} dropped` : ''})`).join(', '),
224 )
225 }
226 for (const b of stats.steps.filter(s => s.isBreak).slice(-5)) {
227 lines.push(` break ${b.loop} ${b.turnId.slice(0, 8)}/${b.index}: lost ${b.lost}, causes: ${b.causes.join('; ') || 'unknown'}`)
228 }
229 if (logPath) lines.push(`log: ${logPath}`)
230 return lines.join('\n')
231}
232hooks/lib/tools.ts 93 lines1/**
2 * Which built-in tools stay in the prompt's tool list and which wait behind
3 * ToolSearch. OpenCode's model sees a small fixed set (bash, read, edit,
4 * write, glob, grep, task, todowrite, webfetch, websearch, skill, question);
5 * the light Claude Code equivalents of those stay listed, everything heavier
6 * (background, orchestration, worktree, notification, scheduling) is deferred:
7 * still callable, its schema loaded by name when the model asks.
8 *
9 * A tool deferred here and later loaded by ToolSearch adds its schema to the
10 * request; bench measures whether that breaks the cache (stage 2).
11 */
12
13/** Kept in the listed set (OpenCode's own set, by Claude Code name). */
14export const KEEP_LISTED: ReadonlySet<string> = new Set([
15 'Bash',
16 'Read',
17 'Edit',
18 'Write',
19 'Glob',
20 'Grep',
21 'Agent',
22 'TodoWrite',
23 'WebFetch',
24 'WebSearch',
25 'Skill',
26 'AskUserQuestion',
27 'ToolSearch',
28])
29
30/** Built-ins moved behind ToolSearch. */
31export const DEFER: ReadonlySet<string> = new Set([
32 'Workflow',
33 'ScheduleWakeup',
34 'ListAgents',
35 'ReportFindings',
36 'Monitor',
37 'CronCreate',
38 'CronDelete',
39 'CronList',
40 'RemoteTrigger',
41 'SendMessage',
42 'PushNotification',
43 'EnterWorktree',
44 'ExitWorktree',
45 'DesignSync',
46 'NotebookEdit',
47 'TaskStop',
48 'TaskCreate',
49 'TaskGet',
50 'TaskList',
51 'TaskUpdate',
52 'EnterPlanMode',
53 'ExitPlanMode',
54 'LSP',
55 'SendUserMessage',
56 'SendUserFile',
57 'SendFile',
58 'ListConnectors',
59 'ListPlugins',
60 'ListSkills',
61 'SearchPlugins',
62 'SearchSkills',
63 'SearchMcpRegistry',
64 'SuggestConnectors',
65 'SuggestPluginInstall',
66 'SuggestSkills',
67 'ReadMcpResourceTool',
68 'ListMcpResourcesTool',
69 'ReadMcpResourceDirTool',
70 'WaitForMcpServers',
71 'Poll',
72 'GetTask',
73 'FetchInboxMessage',
74 'ReadNotifications',
75 'EndConversation',
76 'ProposeGoal',
77])
78
79/** Tools a pinned list keeps in front even when ToolSearch would load them. */
80export type ToolPolicy = { pin: ReadonlySet<string> }
81
82/**
83 * The deferral decision for one tool: true/false to move it, undefined to
84 * leave the engine's choice. `pin` names deferred tools measured to break the
85 * cache when loaded (stage 2), which then stay listed.
86 */
87export function deferralFor(tool: string, engineDeferred: boolean, policy: ToolPolicy): boolean | undefined {
88 if (policy.pin.has(tool)) return false
89 if (KEEP_LISTED.has(tool)) return undefined // never deferred by us; the engine's own choice stands
90 if (DEFER.has(tool)) return engineDeferred ? undefined : true
91 return undefined
92}
93