diff --git a/README.md b/README.md index 8a6dff7..06892e4 100644 --- a/README.md +++ b/README.md @@ -239,13 +239,16 @@ back when it writes again. Detection is exact-match: a terminal keystroke racing a phone message can never false-flag. Muted projects are never flagged. -Agents without that hook (Cursor, Windsurf, Copilot, Cline, Aider, OpenCode) have one +Agents without that hook (Cursor, Windsurf, Copilot, Cline, Aider) have one other way in: a `hwip_ask` answer reporting `hwipState: "REMOTE"`. Their rule files therefore keep the after-real-work `hwip_ask` — the "Entering REMOTE without a prompt hook" preamble in `src/templates.ts` — so the phone always has a button to tap that starts the loop. The one exception is an answer sent with the phone's "send and exit": that turn carries out the instruction and -ends without an ask. Agents with the hook drop that +ends without an ask. OpenCode has no prompt hook either but leaves the +preamble out: its after-work ask reached the phone on turns typed at the +terminal, so it owes no ask in NORMAL, and a phone message to an OpenCode +session gets the completion push rather than buttons. Agents with the hook drop that obligation while at the terminal — and their rule file carries only the NORMAL branch of the core (about a quarter of it), since it is read on every turn; the hook injects the REMOTE sections in full on the turn a phone diff --git a/src/rules-sync.test.ts b/src/rules-sync.test.ts index f0ef334..ce1a36e 100644 --- a/src/rules-sync.test.ts +++ b/src/rules-sync.test.ts @@ -43,7 +43,8 @@ describe('rules-sync: generated core is wired into the templates', () => { // hwip_ask. That is safe exactly where a prompt-submit hook can announce the // phone message that starts REMOTE. Only Gemini, Codex and Pi have that hook; // for every other agent a hwip_ask answer is the sole way in, so their rule -// file must keep asking after real work or the phone loop can never begin. +// file must keep asking after real work or the phone loop can never begin — +// except OpenCode, which drops that door (see its test below). describe('rules-sync: REMOTE entry for agents without a prompt-submit hook', () => { const ENTRY = 'Entering REMOTE without a prompt hook'; @@ -55,7 +56,6 @@ describe('rules-sync: REMOTE entry for agents without a prompt-submit hook', () tmpl.COPILOT_RULE, tmpl.CLINE_RULE, tmpl.AIDER_RULE, - tmpl.OPENCODE_RULE, ]) { expect(rule).toContain(ENTRY); expect(rule).toContain('overrides the "In NORMAL, end with nothing" clause'); @@ -73,6 +73,18 @@ describe('rules-sync: REMOTE entry for agents without a prompt-submit hook', () expect(tmpl.PI_RULE).not.toContain(ENTRY); }); + // OpenCode has no prompt hook either, but the preamble made it end NORMAL + // turns typed at the terminal with a hwip_ask that buzzed the phone + // (2026-10-29, OpenCode 2.1.3). Its 2.x plugin API has no per-turn hook to + // add one, so OpenCode gives up the ask-to-enter door instead: NORMAL + // owes nothing, and REMOTE still starts from an ask the agent chose to send. + it('OpenCode does not order a hwip_ask after NORMAL work', async () => { + const tmpl = await import('./templates.js'); + expect(tmpl.OPENCODE_RULE).not.toContain(ENTRY); + expect(tmpl.OPENCODE_RULE).not.toContain('overrides the "In NORMAL, end with nothing" clause'); + expect(tmpl.OPENCODE_RULE).toContain('In NORMAL, end with nothing'); + }); + it('the preamble sits before the core so the override reads first', async () => { const tmpl = await import('./templates.js'); const rule = tmpl.CURSOR_RULE; @@ -118,7 +130,7 @@ describe('rules-sync: prompt-hook agents carry the NORMAL branch only', () => { // the exception the preamble would re-open the loop the user just left. it('agents without the hook exempt send-and-exit from the preamble ask', async () => { const tmpl = await import('./templates.js'); - for (const rule of [tmpl.CURSOR_RULE, tmpl.WINDSURF_RULE, tmpl.COPILOT_RULE, tmpl.CLINE_RULE, tmpl.AIDER_RULE, tmpl.OPENCODE_RULE]) { + for (const rule of [tmpl.CURSOR_RULE, tmpl.WINDSURF_RULE, tmpl.COPILOT_RULE, tmpl.CLINE_RULE, tmpl.AIDER_RULE]) { // The preamble is hard-wrapped, so match on collapsed whitespace. expect(rule.replace(/\s+/g, ' ')).toContain('end without `hwip_ask`, however substantial the work'); } diff --git a/src/templates.test.ts b/src/templates.test.ts index 697ed13..bdfb375 100644 --- a/src/templates.test.ts +++ b/src/templates.test.ts @@ -910,6 +910,10 @@ describe('templates.ts: the pi extension runs subagent panes view-only-by-push', describe('templates.ts: rules agree with the remote-hook registry', () => { const RULES = Object.entries(templates).filter(([name]) => name.endsWith('_RULE')) as [string, string][]; const NO_HOOK_MARKER = 'Entering REMOTE without a prompt hook'; + // Hook-less agents that leave the preamble out on purpose: their NORMAL + // ask reached the phone while the user sat at the terminal (templates.ts + // OPENCODE_RULE says why). + const PREAMBLE_OPT_OUT = ['opencode']; it('collects every rule', () => { expect(RULES.length).toBeGreaterThanOrEqual(9); @@ -917,9 +921,9 @@ describe('templates.ts: rules agree with the remote-hook registry', () => { for (const [name, rule] of RULES) { const id = name.replace(/_RULE$/, '').toLowerCase(); - const hasPromptHook = (REMOTE_HOOK_AGENTS as readonly string[]).includes(id); - it(`${name} ${hasPromptHook ? 'omits' : 'carries'} the no-hook REMOTE entry preamble`, () => { - if (hasPromptHook) expect(rule).not.toContain(NO_HOOK_MARKER); + const omits = (REMOTE_HOOK_AGENTS as readonly string[]).includes(id) || PREAMBLE_OPT_OUT.includes(id); + it(`${name} ${omits ? 'omits' : 'carries'} the no-hook REMOTE entry preamble`, () => { + if (omits) expect(rule).not.toContain(NO_HOOK_MARKER); else expect(rule).toContain(NO_HOOK_MARKER); }); } diff --git a/src/templates.ts b/src/templates.ts index f1e5ce4..ca475db 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -20,7 +20,8 @@ // hook note (a phone message) or by a `hwip_ask` answer. Only Gemini, Codex // and Pi have that hook (remoteHookCmd below). Every other agent's only door is the // ask itself — so for them the NORMAL branch must still send one after real -// work, or the phone loop can never begin. That is REMOTE_ENTRY_NO_HOOK. +// work, or the phone loop can never begin. That is REMOTE_ENTRY_NO_HOOK +// (OpenCode excepted — OPENCODE_RULE says why). // // The same axis decides how much of the core the static rule file carries. // That file is read on every turn, and a session is NORMAL nearly all of the @@ -138,7 +139,7 @@ operations (file reads, simple searches). Set priority "high" for errors/blockers.`; // REMOTE-entry preamble — agents with NO prompt-submit hook (Cursor, Windsurf, -// Copilot, Cline, Aider, OpenCode). The shared core scopes Rules 1/2/8/9 to REMOTE +// Copilot, Cline, Aider; OpenCode opts out, see OPENCODE_RULE). The shared core scopes Rules 1/2/8/9 to REMOTE // and tells a NORMAL session it owes no `hwip_ask`. That is right where a // hook can announce the phone message that starts REMOTE; here nothing can, // and the only remaining entry is a `hwip_ask` answer reporting REMOTE. @@ -294,10 +295,18 @@ export const PI_RULE = buildRule({ core: PROMPT_HOOK_CORE, }); -/** OpenCode — managed block in ~/.config/opencode/AGENTS.md. Stop hook via plugin, no prompt hook (v1). */ +/** + * OpenCode — managed block in ~/.config/opencode/AGENTS.md. Stop hook via plugin, no prompt hook. + * + * No REMOTE_ENTRY_NO_HOOK, unlike the other hook-less agents: its after-work + * ask landed on the phone while the user sat at the terminal (2026-10-29, + * OpenCode 2.1.3), and the 2.x plugin API (event stream only) has no per-turn + * hook to tell the two apart. So a NORMAL session owes no ask, and REMOTE + * starts only from an ask the agent chose to send. Full core, because no hook + * delivers the REMOTE sections on entry. + */ export const OPENCODE_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY, - remoteEntry: REMOTE_ENTRY_NO_HOOK, core: HWIP_CORE_HOOK_DRIVEN, });