SLOPSHOPPER

feathercode

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

newpanebandguardcommandprompt
★ 1v0.1.0MITupdated 2026-10-08square3ang/feathercode/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · feathercode
│ ┃ feathercode ✕ › fix the failing auth test and add an audit log call │ ┃ feathercode stats │ ┃ features: ⏺ Read(src/auth.ts) │ ┃ prompt,cache,tools,modes,agents,compact | ⎿ Read 6 lines │ ┃ mode: build ⏺ Update(src/auth.ts) │ ┃ requests 0 | input 0 | output 0 | cache read ⎿ Added 2 lines, removed 1 line │ ┃ 0 | cache write 0 | hit 0.0% ⏺ Bash(bun test) │ ┃ cache breaks 0 (~0 tokens re-written) | ⎿ 3 pass, 1 fail │ ┃ ToolSearch calls 0 | compactions 0 │ ┃ system prompt: 0 sections, shared 0 chars, ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ session 0 chars, changed 0x │ ┃ tools: 0 listed (0 desc chars), 0 deferred ✻ Worked for 42s · done 4:20 PM │ ┃ log: <plugin>/logs/preview-session.jsonl │ › /feathercode-stats │ ⎿ feathercode: feathercode stats │ ⎿ feathercode: features: prompt,cache,tools,modes,agents,compact | │ ⎿ feathercode: requests 0 | input 0 | output 0 | cache read 0 | ca │ ⎿ feathercode: cache breaks 0 (~0 tokens re-written) | ToolSearch │ ⎿ feathercode: system prompt: 0 sections, shared 0 chars, session │ ⎿ feathercode: tools: 0 listed (0 desc chars), 0 deferred │ │ plan read-only: edits only in /work/app/.opencode/plan [ Switch to build ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
plan read-only: edits only in /work/app/.opencode/plan [ Switch to build ]
Pane · feathercode
feathercode stats features: prompt,cache,tools,modes,agents,compact | mode: build requests 0 | input 0 | output 0 | cache read 0 | cache write 0 | hit 0.0% cache breaks 0 (~0 tokens re-written) | ToolSearch calls 0 | compactions 0 system prompt: 0 sections, shared 0 chars, session 0 chars, changed 0x tools: 0 listed (0 desc chars), 0 deferred log: <plugin>/logs/preview-session.jsonl
README

feathercode

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.

Install

/plugin install feathercode --marketplace square3ang/feathercode

or for one session from a checkout: claude --plugin-dir <path>/plugin.

Commands

CommandWhat it does
/feathercode-statsprints requests, input/output, cache read/write, hit %, cache breaks with candidate causes, prompt and tool sizes
/feathercode-panelthe same in a pane; prints the text where no UI is attached
/planswitches to the plan agent (read-only; see tool.check below)
/buildswitches back to the build agent (the default)

If /plan or /build is already taken, they register as /fc-plan and /fc-build.

Options (/config)

OptionDefault
featuresallcomma list of prompt,cache,tools,modes,agents,compact, or observe (instrumentation only)
compaction_autotruecompact automatically at OpenCode's threshold
keep_tokens15000newest conversation kept verbatim when compacting
compaction_buffer0compact at window − buffer; 0 = window − max(10%, 16k)
compaction_tailtexttext: recent part flattened into the checkpoint; messages: kept verbatim
compaction_prunefalseclear old tool outputs after a turn (OpenCode v1 prune)
panelfalseopen the stats pane at start

Hooks that decide something

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.

HookWhat it decidesWhen
tool.checkdenies 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.offerhides the built-in agent types Explore, general-purpose, Plan and claude from the modelonly once feathercode:explore and feathercode:general registered (feature agents); they replace them. Other agent types are offered as usual.
session.compactanswers 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.callnothing: counts the call for the stats and passes it on unchangedevery tool call
command.runanswers the mod's own commands abovewhen you run them
prompt.composereplaces 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.contextleaves out the userEmail blockthe first message's context, feature prompt on
prompt.attachmentleaves out the todo and token-count reminders and the git status; shortens each skill description to its first sentenceas Claude Code injects them, feature cache on; text from settings hooks and other mods passes untouched
tool.describerewrites 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 callableonce per tool per session, feature tools on; tools of MCP servers and other plugins untouched

Calls that act outside the conversation

  • Files written: only the mod's own log, <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.
  • Commands run: one, /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 calls: $.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.
  • Conversation rows: $.session.append adds one reminder message when you switch between /plan and /build.
  • Stored values: $.store keeps the current mode per session id, so --continue / --resume restore it.
  • No credentials, no environment variables and no network calls of the mod's own: it reads nothing from the environment and sends nothing anywhere but the session's own model requests.

Files

.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)
Source 8 files
hooks/register.tsx 517 lines
1// 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}
517
hooks/lib/compaction.ts 277 lines
1// 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}
277
hooks/lib/config.ts 41 lines
1export 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}
41
hooks/lib/log.ts 58 lines
1/**
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}
58
hooks/lib/modes.ts 36 lines
1// 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}
36
hooks/lib/prompts.ts 185 lines
1// 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'])
185
hooks/lib/stats.ts 232 lines
1/**
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}
232
hooks/lib/tools.ts 93 lines
1/**
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