Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions src/rules-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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');
Expand All @@ -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;
Expand Down Expand Up @@ -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');
}
Expand Down
10 changes: 7 additions & 3 deletions src/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,16 +910,20 @@ 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);
});

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);
});
}
Expand Down
17 changes: 13 additions & 4 deletions src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
});

Expand Down
Loading