diff --git a/.agents/frameworks/opencode/plugin.ts b/.agents/frameworks/opencode/plugin.ts index 2ecad45..fa73dd5 100644 --- a/.agents/frameworks/opencode/plugin.ts +++ b/.agents/frameworks/opencode/plugin.ts @@ -1,12 +1,12 @@ -import type { Plugin, TextPart } from '@opencode-ai/plugin'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import type { Plugin, TextPart } from "@opencode-ai/plugin"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; /** * Agent support plugin for Remnant. * * Responsibilities: - * 1. experimental.chat.system.transform — session-start.sh (once per session) + * 1. chat.message (first turn) — session-start.sh (once per session) * 2. chat.message — user-prompt-submit.sh (each turn) * 3. tool.execute.before — pre-tool-use.sh (project policy) * 4. tool.execute.after — post-tool-use.sh + context pressure warning @@ -26,7 +26,10 @@ const PRESSURE_THRESHOLD = 0.7; // 70% const LOCAL_WORKER_MAX_TOKENS = 1500; const LOCAL_ORCHESTRATOR_MAX_TOKENS = 2500; -function truncate(text: string, maxTokens: number): { text: string; truncated: boolean } { +function truncate( + text: string, + maxTokens: number, +): { text: string; truncated: boolean } { const maxChars = maxTokens * CHARS_PER_TOKEN; if (text.length <= maxChars) return { text, truncated: false }; return { @@ -42,14 +45,16 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => { // This makes the plugin work both as a project-local plugin and as a global // plugin installed via install.sh — in either case, hooks live in ../../hooks/ // relative to this file in the .agents/frameworks/opencode/ directory. - const hooksDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../hooks'); + const hooksDir = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../hooks", + ); // Running cumulative context size estimate (characters) let contextCharsUsed = 0; - // Track sessions that have had session-start injected (system.transform fires every turn) + // Track sessions that have had session-start injected (fires once per session) const initializedSessions = new Set(); - /** Parse the additionalContext string from a hook's JSON output. */ function parseAdditionalContext(hookOutput: string): string | undefined { try { @@ -62,7 +67,10 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => { } } - async function runHook(scriptName: string, stdinJson?: string): Promise { + async function runHook( + scriptName: string, + stdinJson?: string, + ): Promise { const script = `${hooksDir}/${scriptName}`; try { const proc = stdinJson @@ -72,61 +80,85 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => { } catch (_error) { // DEBUG: log hook failures so silent catches don't hide enforcement bugs try { - const fs = await import('node:fs'); + const fs = await import("node:fs"); fs.appendFileSync( - '/tmp/plugin-hook-errors.log', - JSON.stringify({ ts: new Date().toISOString(), script, error: String(_error) }) + '\n' + "/tmp/plugin-hook-errors.log", + JSON.stringify({ + ts: new Date().toISOString(), + script, + error: String(_error), + }) + "\n", ); } catch (_e) { // ignore } // Hooks are advisory — never block on hook failure - return ''; + return ""; } } return { - // ── 1. Session start: inject project state into system prompt ──────────── - // Uses system.transform rather than the defunct session.created event. - // Guarded by initializedSessions so it runs exactly once per session. - 'experimental.chat.system.transform': async (input, output) => { - const sessionID = input.sessionID ?? 'unknown'; - if (initializedSessions.has(sessionID)) return; - initializedSessions.add(sessionID); - const hookOutput = await runHook('session-start.sh'); - const context = parseAdditionalContext(hookOutput); - if (context) output.system.push(context); - }, + // ── 1 & 2. Session start + user prompt ────────────────────────────────── + // Session-start was previously injected via experimental.chat.system.transform + // (pushing to output.system). That caused a Jinja "System message must be at + // the beginning" error on Qwen-family local models when the orchestrator spawns + // a subagent via `task`: system.transform fires after the task prompt (a user + // message) is already in the conversation, so the system push lands at a + // non-zero position. Injecting as a synthetic text part on the first + // chat.message turn avoids the position constraint entirely. + "chat.message": async (input, output) => { + const sessionID = input.sessionID ?? "unknown"; + + // Session-start injection — runs exactly once per session, prepended so it + // reads before the user-prompt-submit nudges on the first turn. + if (!initializedSessions.has(sessionID)) { + initializedSessions.add(sessionID); + const startOutput = await runHook("session-start.sh"); + const startContext = parseAdditionalContext(startOutput); + if (startContext) { + output.parts.unshift({ + id: `prt_${crypto.randomUUID()}`, + sessionID: input.sessionID, + messageID: input.messageID ?? crypto.randomUUID(), + type: "text", + text: startContext, + synthetic: true, + }); + } + } - // ── 2. User prompt: task capture + CURRENT QUESTION + nudges ────────────── - // Equivalent to Copilot's UserPromptSubmit hook. - 'chat.message': async (input, output) => { const promptText = output.parts - .filter((p): p is TextPart => p.type === 'text') - .map(p => p.text) - .join('\n'); - const hookOutput = await runHook('user-prompt-submit.sh', JSON.stringify({ prompt: promptText })); + .filter((p): p is TextPart => p.type === "text") + .map((p) => p.text) + .join("\n"); + const hookOutput = await runHook( + "user-prompt-submit.sh", + JSON.stringify({ prompt: promptText }), + ); const context = parseAdditionalContext(hookOutput); if (context) { output.parts.push({ id: `prt_${crypto.randomUUID()}`, sessionID: input.sessionID, messageID: input.messageID ?? crypto.randomUUID(), - type: 'text', + type: "text", text: context, synthetic: true, }); } }, - // ── 3. Pre-tool-use ───────────────────────────────────────────────────── - 'tool.execute.before': async (input, output) => { + "tool.execute.before": async (input, output) => { const toolName = input.tool as string; // ── read guards ─────────────────────────────────────────────────── - if (toolName === 'read') { - const args = (output.args ?? {}) as { filePath?: string; offset?: number; limit?: number }; - const filePath = args.filePath ?? ''; + if (toolName === "read") { + const args = (output.args ?? {}) as { + filePath?: string; + offset?: number; + limit?: number; + }; + const filePath = args.filePath ?? ""; // package.json read guard: // Reading workspace package.json files auto-loads nested AGENTS.md files @@ -134,7 +166,7 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => { // Block package.json reads under apps/ and packages/ only. if (/(^|\/)(apps|packages)\/[^/]+\/package\.json$/.test(filePath)) { throw new Error( - 'BLOCKED: Reading workspace package.json files auto-loads nested AGENTS.md files and exhausts the 32K context. Use `grep_search` to find the specific field you need (e.g. a dependency version or script name) instead of reading the whole file.' + "BLOCKED: Reading workspace package.json files auto-loads nested AGENTS.md files and exhausts the 32K context. Use `grep_search` to find the specific field you need (e.g. a dependency version or script name) instead of reading the whole file.", ); } @@ -146,7 +178,7 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => { // Directory reads (e.g. `Read .`) never carry a limit — skip the guard. let isDirectory = false; try { - const { statSync } = await import('node:fs'); + const { statSync } = await import("node:fs"); isDirectory = statSync(filePath).isDirectory(); } catch (_error) { // path doesn't exist or inaccessible — treat as file @@ -158,7 +190,7 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => { throw new Error( isDocsFile ? `BLOCKED: Unbounded read (no limit) is prohibited. Specify offset and limit to read in ≤500-line chunks for docs/ files.` - : `BLOCKED: Unbounded read (no limit) is prohibited. Use grep_search first to find the relevant section, then read with offset and limit in ≤50-line chunks.` + : `BLOCKED: Unbounded read (no limit) is prohibited. Use grep_search first to find the relevant section, then read with offset and limit in ≤50-line chunks.`, ); } const lineLimit = isDocsFile ? 500 : 50; @@ -166,7 +198,7 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => { throw new Error( isDocsFile ? `BLOCKED: Read more than 500 lines at once is prohibited for docs/ files. Use offset and limit to paginate in ≤500-line chunks.` - : `BLOCKED: Read more than 50 lines at once is prohibited. Use offset and limit to paginate in ≤50-line chunks. For docs/ files the limit is 500 lines. Use grep_search first to find the right offset.` + : `BLOCKED: Read more than 50 lines at once is prohibited. Use offset and limit to paginate in ≤50-line chunks. For docs/ files the limit is 500 lines. Use grep_search first to find the right offset.`, ); } } @@ -177,12 +209,12 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => { // or long inventories inline in a task prompt causes "Unterminated string" // parse errors. Cap task prompts at 1200 chars — workers should be told // WHICH files to read, not given the contents inline. - if (toolName === 'task') { + if (toolName === "task") { const args = (output.args ?? {}) as { prompt?: string }; - const prompt = args.prompt ?? ''; + const prompt = args.prompt ?? ""; if (prompt.length > 1200) { throw new Error( - `BLOCKED (task prompt too long: ${prompt.length} chars, max 1200): Task prompts must not embed file contents, dependency lists, or long context inline — this causes JSON parse failures. Instead, tell the worker WHICH files to read and WHAT to do. Example: "Read the root package.json and all workspace package.json files, then update the Technology Stack section in README.md to match."` + `BLOCKED (task prompt too long: ${prompt.length} chars, max 1200): Task prompts must not embed file contents, dependency lists, or long context inline — this causes JSON parse failures. Instead, tell the worker WHICH files to read and WHAT to do. Example: "Read the root package.json and all workspace package.json files, then update the Technology Stack section in README.md to match."`, ); } } @@ -194,31 +226,37 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => { tool_name: toolName, tool_input: output.args ?? {}, }); - const hookResult = await runHook('pre-tool-use.sh', hookInput); + const hookResult = await runHook("pre-tool-use.sh", hookInput); // If the hook emitted a deny decision, surface it as an error if (hookResult.includes('"permissionDecision": "deny"')) { - const match = hookResult.match(/"permissionDecisionReason":\s*"([^"]+)"/); - const reason = match?.[1] ?? 'Blocked by project policy (pre-tool-use hook).'; + const match = hookResult.match( + /"permissionDecisionReason":\s*"([^"]+)"/, + ); + const reason = + match?.[1] ?? "Blocked by project policy (pre-tool-use hook)."; throw new Error(reason); } }, // ── 4. Post-tool-use ──────────────────────────────────────────────────── - 'tool.execute.after': async (input, output) => { + "tool.execute.after": async (input, output) => { const response = output.response as string | undefined; - if (typeof response === 'string') { + if (typeof response === "string") { // a) Response truncation — local agents (build/orchestrator) and any ollama/ model; // orchestrator gets a higher limit since it only reads, not edits. - const agentName = typeof input.agent === 'string' ? input.agent : ''; + const agentName = typeof input.agent === "string" ? input.agent : ""; const isLocalAgent = - agentName === 'build' || - agentName === 'orchestrator' || - (typeof input.model === 'string' && input.model.startsWith('ollama/')); + agentName === "build" || + agentName === "orchestrator" || + (typeof input.model === "string" && + input.model.startsWith("ollama/")); if (isLocalAgent) { - const isOrchestrator = agentName === 'orchestrator'; - const maxTokens = isOrchestrator ? LOCAL_ORCHESTRATOR_MAX_TOKENS : LOCAL_WORKER_MAX_TOKENS; + const isOrchestrator = agentName === "orchestrator"; + const maxTokens = isOrchestrator + ? LOCAL_ORCHESTRATOR_MAX_TOKENS + : LOCAL_WORKER_MAX_TOKENS; const { text: truncated } = truncate(response, maxTokens); output.response = truncated; } @@ -242,13 +280,13 @@ export const AgentSupportPlugin: Plugin = async ({ $, directory }) => { tool_input: input.args ?? {}, tool_response: (output.response as string).slice(0, 500), // truncated for hook }); - await runHook('post-tool-use.sh', hookInput); + await runHook("post-tool-use.sh", hookInput); } }, // ── 5. Pre-compact: export state before context summarization ───────────── - 'experimental.session.compacting': async (input, output) => { - await runHook('pre-compact.sh'); + "experimental.session.compacting": async (input, output) => { + await runHook("pre-compact.sh"); output.prompt = ` You are a context summarizer for coding sessions. Summarize only the conversation history given — do not answer it.