@zeph-to/cli 2.8.2 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -141,12 +141,13 @@ an answerable `zeph_ask`).
141
141
  | Claude Code | `UserPromptSubmit` → plugin's `zeph-remote.sh` | Zeph plugin |
142
142
  | Gemini CLI | `BeforeAgent` → `zeph remote-hook gemini` | `zeph setup` |
143
143
  | Codex CLI | `UserPromptSubmit` → `zeph remote-hook codex` | `zeph setup` |
144
+ | Pi | `before_agent_start` → `zeph remote-hook pi` (via extension) | `zeph setup` |
144
145
  | Cursor CLI | — none yet | — |
145
146
 
146
147
  Detection is exact-match: a terminal keystroke racing a phone message
147
148
  can never false-flag. Muted projects are never flagged.
148
149
 
149
- Agents without that hook (Cursor, Windsurf, Copilot, Cline, Aider) have one
150
+ Agents without that hook (Cursor, Windsurf, Copilot, Cline, Aider, OpenCode) have one
150
151
  other way in: a `zeph_ask` answer that is not a Done-like button. Their rule
151
152
  files therefore keep the after-real-work `zeph_ask` — the "Entering REMOTE
152
153
  without a prompt hook" preamble in `src/templates.ts` — so the phone always
@@ -218,6 +219,8 @@ block here.
218
219
  zeph cursor # cursor-agent → tmux session "zeph-<project>"
219
220
  zeph gemini # gemini → tmux session "zeph-<project>"
220
221
  zeph hermes # hermes → tmux session "zeph-<project>"
222
+ zeph pi # pi → tmux session "zeph-<project>"
223
+ zeph opencode # opencode → tmux session "zeph-<project>"
221
224
  ```
222
225
 
223
226
  `zeph cursor` runs **`cursor-agent`**, Cursor's terminal agent — a
@@ -602,10 +605,17 @@ this order — first hit wins:
602
605
  It is now `quiet`, so upgrading turns the routine per-turn push off until
603
606
  you run `/zeph-normal`. Row 4 is why the hooks this CLI installs are
604
607
  unaffected: they name `normal` themselves, since a hook that supplies no
605
- turn counts also supplies no `high` marker, and `quiet` would make it
606
- permanently silent rather than merely quieter. Row 4 sits *below* the
607
- state files on purpose — the flag names a default, it does not override a
608
- dial the user set.
608
+ `high` marker would be permanently silent under `quiet` rather than
609
+ merely quieter. Row 4 sits *below* the state files on purpose — the flag
610
+ names a default, it does not override a dial the user set.
611
+
612
+ Under `normal` the JSON hook configs push on every turn, because they see
613
+ no per-tool events and so pass no counts — the gate then assumes real
614
+ work. The Pi extension and the OpenCode plugin do see those events, so
615
+ they pass real `--tools` / `--nonreadonly` counts and go quiet where the
616
+ others cannot: a turn with no tool calls, a turn with exactly one (a
617
+ lone edit included — the gate wants two), and a turn whose calls were
618
+ all reads. `/zeph-loud` still pushes on all of them.
609
619
 
610
620
  A dial file that exists but reads empty resolves to `normal`, not to row
611
621
  5: an empty file is a failed write, and resolving breakage to silence
@@ -712,6 +722,8 @@ try {
712
722
  | Copilot CLI | Session end hook + rules |
713
723
  | Cline | Rules file (`~/.cline/rules/zeph.md`) |
714
724
  | Aider | Conventions file + `read:` directive in `~/.aider.conf.yml` |
725
+ | Pi | Extension (settle hook + remote detection) + rules — no MCP (pi has none; rules map zeph tools to the CLI) |
726
+ | OpenCode | MCP server + idle-notify plugin + rules |
715
727
 
716
728
  For remote-control via `zeph listener` the per-agent setup is the same
717
729
  across CC/Codex/Gemini — the wrapper just spawns them in a named tmux
@@ -1 +1 @@
1
- {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../src/agents.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,KAAK;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,OAAO,CAAC;CACrB;AAID,eAAO,MAAM,UAAU,GAAI,KAAK,MAAM,KAAG,OAOxC,CAAC;AAEF,eAAO,MAAM,YAAY,QAAO,KAAK,EASpC,CAAC"}
1
+ {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../src/agents.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,KAAK;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,OAAO,CAAC;CACrB;AAID,eAAO,MAAM,UAAU,GAAI,KAAK,MAAM,KAAG,OAOxC,CAAC;AAEF,eAAO,MAAM,YAAY,QAAO,KAAK,EAWpC,CAAC"}
package/dist/agents.js CHANGED
@@ -25,5 +25,7 @@ const detectAgents = () => [
25
25
  { name: 'Copilot CLI', id: 'copilot', detected: (0, fs_1.existsSync)((0, path_1.join)(HOME, '.copilot')) },
26
26
  { name: 'Cline', id: 'cline', detected: (0, fs_1.existsSync)((0, path_1.join)(HOME, '.cline')) },
27
27
  { name: 'Aider', id: 'aider', detected: (0, exports.hasCommand)('aider') },
28
+ { name: 'Pi', id: 'pi', detected: (0, fs_1.existsSync)((0, path_1.join)(HOME, '.pi')) || (0, exports.hasCommand)('pi') },
29
+ { name: 'OpenCode', id: 'opencode', detected: (0, fs_1.existsSync)((0, path_1.join)(HOME, '.config', 'opencode')) || (0, exports.hasCommand)('opencode') },
28
30
  ];
29
31
  exports.detectAgents = detectAgents;
package/dist/cli.js CHANGED
@@ -279,8 +279,8 @@ const handleNotify = async (args) => {
279
279
  // With no dial the mode falls back to --pushmode-default, then to quiet.
280
280
  if (args.auto === true) {
281
281
  const verdict = (0, gate_js_1.decidePush)({
282
- toolCount: gateCount(args.tools, gate_js_1.GATE_DEFAULTS.toolCount),
283
- nonReadonlyCount: gateCount(args.nonreadonly, gate_js_1.GATE_DEFAULTS.nonReadonlyCount),
282
+ toolCount: gateCount(args[gate_js_1.TOOL_COUNT_FLAG], gate_js_1.GATE_DEFAULTS.toolCount),
283
+ nonReadonlyCount: gateCount(args[gate_js_1.NONREADONLY_COUNT_FLAG], gate_js_1.GATE_DEFAULTS.nonReadonlyCount),
284
284
  alreadyAsked: gate_js_1.GATE_DEFAULTS.alreadyAsked,
285
285
  marker: (0, gate_js_1.normalizeMarker)(typeof args.marker === 'string' ? args.marker : undefined),
286
286
  pushMode: (0, gate_js_1.autoPushMode)(projectDir, args[gate_js_1.PUSHMODE_DEFAULT_FLAG]),
package/dist/gate.d.ts CHANGED
@@ -88,6 +88,15 @@ export declare const PUSHMODE_DEFAULT: GatePushMode;
88
88
  * read back in cli.ts — shared so the two can never drift apart.
89
89
  */
90
90
  export declare const PUSHMODE_DEFAULT_FLAG = "pushmode-default";
91
+ /**
92
+ * `notify` flags carrying the turn's tool counts. Only the drop-in artifacts
93
+ * (pi extension, opencode plugin) can supply them — they are the only installed
94
+ * hooks that see per-tool events. Shared for the same reason as the flag above:
95
+ * templates.ts writes them, cli.ts reads them, and a rename on one side alone
96
+ * would send `gateCount` back to GATE_DEFAULTS with nothing to show for it.
97
+ */
98
+ export declare const TOOL_COUNT_FLAG = "tools";
99
+ export declare const NONREADONLY_COUNT_FLAG = "nonreadonly";
91
100
  /**
92
101
  * The user's session push-mode dial (/zeph-quiet | /zeph-loud | /zeph-normal).
93
102
  *
@@ -1 +1 @@
1
- {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAyBA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAC3D,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,gBAAgB,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,YAAY,EAAE,OAAO,CAAC;IACtB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,YAAY,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;CAC7B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa;;;;CAIhB,CAAC;AAEX,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,UACS,CAAC;AAEpE,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,YACR,CAAC;AAErD,eAAO,MAAM,UAAU,GAAI,OAAO,SAAS,KAAG,WAW7C,CAAC;AAeF,eAAO,MAAM,QAAQ,QAAO,MACoD,CAAC;AA2BjF,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAOlD,CAAC;AAWF,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,KAAG,MAA4C,CAAC;AAE7F;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,MAG1B,CAAC;AAiBnB;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,QAAQ,CAAC;AAEpC,8EAA8E;AAC9E,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,MACJ,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,EAAE,MAAK,MAAM,MAAiB,KAAG,OAsB1E,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,EAAE,MAAK,MAAM,MAAiB,KAAG,IAS7E,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,KAAG,IAQ/C,CAAC;AAEF,0DAA0D;AAC1D,eAAO,MAAM,OAAO,GAAI,KAAK,MAAM,KAAG,OAGrC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,EAAE,YAAsB,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,qBAAqB,CAAC;AAExD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,YAAY,GACvB,KAAK,MAAM,EACX,WAAU,YAA+B,KACxC,YAUF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,GAAG,OAAO,GAAG,SAAS,KAAG,YACW,CAAC"}
1
+ {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAyBA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAC3D,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,gBAAgB,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,YAAY,EAAE,OAAO,CAAC;IACtB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,YAAY,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;CAC7B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa;;;;CAIhB,CAAC;AAEX,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,UACS,CAAC;AAEpE,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,YACR,CAAC;AAErD,eAAO,MAAM,UAAU,GAAI,OAAO,SAAS,KAAG,WAW7C,CAAC;AAeF,eAAO,MAAM,QAAQ,QAAO,MACoD,CAAC;AA2BjF,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAOlD,CAAC;AAWF,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,KAAG,MAA4C,CAAC;AAE7F;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,MAG1B,CAAC;AAiBnB;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,QAAQ,CAAC;AAEpC,8EAA8E;AAC9E,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,MACJ,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,EAAE,MAAK,MAAM,MAAiB,KAAG,OAsB1E,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,EAAE,MAAK,MAAM,MAAiB,KAAG,IAS7E,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,KAAG,IAQ/C,CAAC;AAEF,0DAA0D;AAC1D,eAAO,MAAM,OAAO,GAAI,KAAK,MAAM,KAAG,OAGrC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,EAAE,YAAsB,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,qBAAqB,CAAC;AAExD;;;;;;GAMG;AACH,eAAO,MAAM,eAAe,UAAU,CAAC;AACvC,eAAO,MAAM,sBAAsB,gBAAgB,CAAC;AAEpD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,YAAY,GACvB,KAAK,MAAM,EACX,WAAU,YAA+B,KACxC,YAUF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,GAAG,OAAO,GAAG,SAAS,KAAG,YACW,CAAC"}
package/dist/gate.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.autoPushMode = exports.readPushMode = exports.PUSHMODE_DEFAULT_FLAG = exports.PUSHMODE_DEFAULT = exports.isMuted = exports.clearRemoteActive = exports.touchRemoteActive = exports.isRemoteActive = exports.remoteStatePath = exports.REMOTE_TTL_SEC = exports.remoteDigest = exports.remoteMarkerPath = exports.projectHash = exports.stateDir = exports.decidePush = exports.normalizePushMode = exports.normalizeMarker = exports.GATE_DEFAULTS = void 0;
3
+ exports.autoPushMode = exports.readPushMode = exports.NONREADONLY_COUNT_FLAG = exports.TOOL_COUNT_FLAG = exports.PUSHMODE_DEFAULT_FLAG = exports.PUSHMODE_DEFAULT = exports.isMuted = exports.clearRemoteActive = exports.touchRemoteActive = exports.isRemoteActive = exports.remoteStatePath = exports.REMOTE_TTL_SEC = exports.remoteDigest = exports.remoteMarkerPath = exports.projectHash = exports.stateDir = exports.decidePush = exports.normalizePushMode = exports.normalizeMarker = exports.GATE_DEFAULTS = void 0;
4
4
  /**
5
5
  * Push-gate decision — the portable half of the Zeph Stop-hook logic.
6
6
  *
@@ -244,6 +244,15 @@ exports.PUSHMODE_DEFAULT = 'quiet';
244
244
  * read back in cli.ts — shared so the two can never drift apart.
245
245
  */
246
246
  exports.PUSHMODE_DEFAULT_FLAG = 'pushmode-default';
247
+ /**
248
+ * `notify` flags carrying the turn's tool counts. Only the drop-in artifacts
249
+ * (pi extension, opencode plugin) can supply them — they are the only installed
250
+ * hooks that see per-tool events. Shared for the same reason as the flag above:
251
+ * templates.ts writes them, cli.ts reads them, and a rename on one side alone
252
+ * would send `gateCount` back to GATE_DEFAULTS with nothing to show for it.
253
+ */
254
+ exports.TOOL_COUNT_FLAG = 'tools';
255
+ exports.NONREADONLY_COUNT_FLAG = 'nonreadonly';
247
256
  /**
248
257
  * The user's session push-mode dial (/zeph-quiet | /zeph-loud | /zeph-normal).
249
258
  *
@@ -12,6 +12,21 @@ export declare const shouldReauth: (currentKey: string | undefined, relogin: boo
12
12
  /** Exported for tests (installGemini shells out to the real `gemini` binary,
13
13
  * so the merge semantics can't be covered through it deterministically). */
14
14
  export declare const mergeJsonFile: (filePath: string, patch: Record<string, unknown>) => void;
15
+ /**
16
+ * opencode.json's own MCP schema — top-level `mcp` key (not `mcpServers`),
17
+ * `command` as ARRAY (binary + args), `type: "local"`. No `environment` —
18
+ * opencode is a terminal app that inherits shell env, and the MCP server
19
+ * falls back to ~/.zeph/config.json anyway (an unexpanded "${ZEPH_API_KEY}"
20
+ * literal would shadow that fallback with junk). Exported for tests (shape pin).
21
+ */
22
+ export declare const OPENCODE_MCP_ENTRY: {
23
+ type: string;
24
+ command: string[];
25
+ enabled: boolean;
26
+ };
27
+ /** Add the zeph entry under `key` in an MCP registry JSON file, preserving
28
+ * everything else in it. Exported for tests (merge semantics). */
29
+ export declare const injectMcpEntry: (filePath: string, key: string, entry: Record<string, unknown>) => void;
15
30
  /**
16
31
  * Resolve agents from a non-interactive `--only cursor,gemini` flag.
17
32
  * Matches on agent id; unknown ids are silently dropped. Exported for
@@ -1 +1 @@
1
- {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AA6BzC;;;GAGG;AACH;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAI,YAAY,MAAM,GAAG,SAAS,EAAE,SAAS,OAAO,KAAG,OACxD,CAAC;AAoBzB;6EAC6E;AAC7E,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,EAAE,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAqBhF,CAAC;AAgQF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,KAAK,EAAE,EAAE,MAAM,MAAM,KAAG,KAAK,EAKxE,CAAC;AA8EF;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,GAC/B,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,gBAAgB,OAAO,KACtB,KAAK,GAAG,IAAI,GAAG,KAKjB,CAAC;AAgCF,eAAO,MAAM,aAAa,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAuH1F,CAAC"}
1
+ {"version":3,"file":"installer.d.ts","sourceRoot":"","sources":["../src/installer.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AA+BzC;;;GAGG;AACH;;;;GAIG;AACH,eAAO,MAAM,YAAY,GAAI,YAAY,MAAM,GAAG,SAAS,EAAE,SAAS,OAAO,KAAG,OACxD,CAAC;AAoBzB;6EAC6E;AAC7E,eAAO,MAAM,aAAa,GAAI,UAAU,MAAM,EAAE,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAqBhF,CAAC;AAgDF;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB;;;;CAI9B,CAAC;AAEF;mEACmE;AACnE,eAAO,MAAM,cAAc,GAAI,UAAU,MAAM,EAAE,KAAK,MAAM,EAAE,OAAO,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,IAQ9F,CAAC;AAqPF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,UAAU,KAAK,EAAE,EAAE,MAAM,MAAM,KAAG,KAAK,EAKxE,CAAC;AA8EF;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,GAC/B,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,EACtC,gBAAgB,OAAO,KACtB,KAAK,GAAG,IAAI,GAAG,KAKjB,CAAC;AAgCF,eAAO,MAAM,aAAa,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAuH1F,CAAC"}
package/dist/installer.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleInstall = exports.serviceInstallChoice = exports.filterAgentsByIds = exports.mergeJsonFile = exports.shouldReauth = void 0;
3
+ exports.handleInstall = exports.serviceInstallChoice = exports.filterAgentsByIds = exports.injectMcpEntry = exports.OPENCODE_MCP_ENTRY = exports.mergeJsonFile = exports.shouldReauth = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const fs_1 = require("fs");
6
6
  const os_1 = require("os");
@@ -104,26 +104,41 @@ const addAiderReadDirective = (confPath, conventionsPath) => {
104
104
  (0, fs_1.writeFileSync)(confPath, (base ? `${base}\n\n` : '') + line);
105
105
  };
106
106
  // ── Per-Agent Installers ─────────────────────────────────────────
107
- const injectMcpJson = (filePath) => {
107
+ // Pass through env explicitly so the MCP server doesn't have to rely on
108
+ // process-env inheritance (which behaves differently per IDE — Cursor and
109
+ // Windsurf spawn the MCP from a graphical context that may not inherit
110
+ // shell env). Mirrors plugin/.mcp.json.
111
+ const MCP_SERVERS_ENTRY = {
112
+ command: 'npx',
113
+ args: ['-y', '@zeph-to/mcp-server'],
114
+ env: { ZEPH_API_KEY: '${ZEPH_API_KEY}' },
115
+ };
116
+ /**
117
+ * opencode.json's own MCP schema — top-level `mcp` key (not `mcpServers`),
118
+ * `command` as ARRAY (binary + args), `type: "local"`. No `environment` —
119
+ * opencode is a terminal app that inherits shell env, and the MCP server
120
+ * falls back to ~/.zeph/config.json anyway (an unexpanded "${ZEPH_API_KEY}"
121
+ * literal would shadow that fallback with junk). Exported for tests (shape pin).
122
+ */
123
+ exports.OPENCODE_MCP_ENTRY = {
124
+ type: 'local',
125
+ command: ['npx', '-y', '@zeph-to/mcp-server'],
126
+ enabled: true,
127
+ };
128
+ /** Add the zeph entry under `key` in an MCP registry JSON file, preserving
129
+ * everything else in it. Exported for tests (merge semantics). */
130
+ const injectMcpEntry = (filePath, key, entry) => {
108
131
  let data = {};
109
132
  try {
110
133
  data = JSON.parse((0, fs_1.readFileSync)(filePath, 'utf-8'));
111
134
  }
112
135
  catch { /* new file */ }
113
- if (!data.mcpServers)
114
- data.mcpServers = {};
115
- // Pass through env explicitly so the MCP server doesn't have to rely on
116
- // process-env inheritance (which behaves differently per IDE — Cursor and
117
- // Windsurf spawn the MCP from a graphical context that may not inherit
118
- // shell env). Mirrors plugin/.mcp.json.
119
- data.mcpServers.zeph = {
120
- command: 'npx',
121
- args: ['-y', '@zeph-to/mcp-server'],
122
- env: { ZEPH_API_KEY: '${ZEPH_API_KEY}' },
123
- };
124
- (0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
125
- (0, fs_1.writeFileSync)(filePath, JSON.stringify(data, null, 2) + '\n');
136
+ if (!isPlainObject(data[key]))
137
+ data[key] = {};
138
+ data[key].zeph = entry;
139
+ writeFile(filePath, JSON.stringify(data, null, 2));
126
140
  };
141
+ exports.injectMcpEntry = injectMcpEntry;
127
142
  const installClaude = () => {
128
143
  try {
129
144
  (0, child_process_1.execSync)('claude plugin marketplace add zeph-to/plugin', { stdio: 'pipe' });
@@ -138,7 +153,7 @@ const installClaude = () => {
138
153
  };
139
154
  const installCursor = () => {
140
155
  try {
141
- injectMcpJson((0, path_1.join)(HOME, '.cursor', 'mcp.json'));
156
+ (0, exports.injectMcpEntry)((0, path_1.join)(HOME, '.cursor', 'mcp.json'), 'mcpServers', MCP_SERVERS_ENTRY);
142
157
  ok('MCP server added');
143
158
  }
144
159
  catch {
@@ -161,7 +176,7 @@ const installCursor = () => {
161
176
  };
162
177
  const installWindsurf = () => {
163
178
  try {
164
- injectMcpJson((0, path_1.join)(HOME, '.codeium', 'windsurf', 'mcp_config.json'));
179
+ (0, exports.injectMcpEntry)((0, path_1.join)(HOME, '.codeium', 'windsurf', 'mcp_config.json'), 'mcpServers', MCP_SERVERS_ENTRY);
165
180
  ok('MCP server added');
166
181
  }
167
182
  catch {
@@ -283,6 +298,49 @@ const installAider = () => {
283
298
  fail(`Config wiring failed. Manual: add "read: ${conventionsPath}" to ~/.aider.conf.yml`);
284
299
  }
285
300
  };
301
+ const installPi = () => {
302
+ try {
303
+ // Drop-in auto-load; full-file overwrite is the idempotency model
304
+ // (the header comment is the ownership marker). No MCP — pi has none;
305
+ // PI_RULE maps the zeph_* tools to the CLI instead.
306
+ writeFile((0, path_1.join)(HOME, '.pi', 'agent', 'extensions', 'zeph.ts'), templates_js_1.PI_EXTENSION);
307
+ ok('Extension added (settle notify + remote detection)');
308
+ }
309
+ catch {
310
+ fail('Extension install failed. Manual: save the zeph extension to ~/.pi/agent/extensions/zeph.ts');
311
+ }
312
+ try {
313
+ // Pi loads ~/.pi/agent/AGENTS.md as global context.
314
+ writeManagedRule((0, path_1.join)(HOME, '.pi', 'agent', 'AGENTS.md'), templates_js_1.PI_RULE);
315
+ ok('Rules added to AGENTS.md');
316
+ }
317
+ catch {
318
+ fail('Rule install failed. Manual: add zeph rules to ~/.pi/agent/AGENTS.md');
319
+ }
320
+ };
321
+ const installOpencode = () => {
322
+ try {
323
+ (0, exports.injectMcpEntry)((0, path_1.join)(HOME, '.config', 'opencode', 'opencode.json'), 'mcp', exports.OPENCODE_MCP_ENTRY);
324
+ ok('MCP server added');
325
+ }
326
+ catch {
327
+ fail('MCP injection failed. Manual: add zeph to ~/.config/opencode/opencode.json');
328
+ }
329
+ try {
330
+ writeFile((0, path_1.join)(HOME, '.config', 'opencode', 'plugins', 'zeph.ts'), templates_js_1.OPENCODE_PLUGIN);
331
+ ok('Idle-notify plugin added');
332
+ }
333
+ catch {
334
+ fail('Plugin install failed. Manual: save the zeph plugin to ~/.config/opencode/plugins/zeph.ts');
335
+ }
336
+ try {
337
+ writeManagedRule((0, path_1.join)(HOME, '.config', 'opencode', 'AGENTS.md'), templates_js_1.OPENCODE_RULE);
338
+ ok('Rules added to AGENTS.md');
339
+ }
340
+ catch {
341
+ fail('Rule install failed. Manual: add zeph rules to ~/.config/opencode/AGENTS.md');
342
+ }
343
+ };
286
344
  const AGENT_INSTALLERS = {
287
345
  claude: installClaude,
288
346
  cursor: installCursor,
@@ -292,6 +350,8 @@ const AGENT_INSTALLERS = {
292
350
  copilot: installCopilot,
293
351
  cline: installCline,
294
352
  aider: installAider,
353
+ pi: installPi,
354
+ opencode: installOpencode,
295
355
  };
296
356
  // One-line summary of what each agent's installer does — shown in the
297
357
  // interactive plan before anything is written.
@@ -304,6 +364,8 @@ const AGENT_PLAN_LABELS = {
304
364
  copilot: 'Copilot CLI — hooks + rules',
305
365
  cline: 'Cline — rules',
306
366
  aider: 'Aider — conventions',
367
+ pi: 'Pi — extension + rules',
368
+ opencode: 'OpenCode — MCP + plugin + rules',
307
369
  };
308
370
  // ── Agent selection ──────────────────────────────────────────────
309
371
  /**
@@ -186,6 +186,16 @@ declare const REMOTE_AGENT_TABLE: readonly [{
186
186
  readonly binary: "hermes";
187
187
  readonly subcommands: readonly ["hermes"];
188
188
  readonly resolveSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => string | null;
189
+ }, {
190
+ readonly kind: "pi";
191
+ readonly displayName: "Pi";
192
+ readonly binary: "pi";
193
+ readonly subcommands: readonly ["pi"];
194
+ }, {
195
+ readonly kind: "opencode";
196
+ readonly displayName: "OpenCode";
197
+ readonly binary: "opencode";
198
+ readonly subcommands: readonly ["opencode"];
189
199
  }];
190
200
  /** Closed union of remote-controllable agent kinds, derived from the table above. */
191
201
  export type AgentKind = (typeof REMOTE_AGENT_TABLE)[number]['kind'];
@@ -1 +1 @@
1
- {"version":3,"file":"remote-agents.d.ts","sourceRoot":"","sources":["../src/remote-agents.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"remote-agents.d.ts","sourceRoot":"","sources":["../src/remote-agents.ts"],"names":[],"mappings":"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"}
@@ -478,6 +478,18 @@ const REMOTE_AGENT_TABLE = [
478
478
  // `<timestamp>_<hash>` string, and nothing on the wire consumes it yet.
479
479
  resolveSessionName: exports.detectHermesSessionName,
480
480
  },
481
+ {
482
+ kind: 'pi',
483
+ displayName: 'Pi',
484
+ binary: 'pi',
485
+ subcommands: ['pi'],
486
+ },
487
+ {
488
+ kind: 'opencode',
489
+ displayName: 'OpenCode',
490
+ binary: 'opencode',
491
+ subcommands: ['opencode'],
492
+ },
481
493
  ];
482
494
  exports.REMOTE_AGENTS = REMOTE_AGENT_TABLE;
483
495
  const findAgentBySubcommand = (cmd) => exports.REMOTE_AGENTS.find((a) => a.subcommands.includes(cmd));
@@ -1,6 +1,6 @@
1
1
  /** Agents whose prompt-submit hooks this command serves. Claude Code is
2
2
  * NOT here — its hook ships with the plugin (hooks/zeph-remote.sh). */
3
- export declare const REMOTE_HOOK_AGENTS: readonly ["gemini", "codex"];
3
+ export declare const REMOTE_HOOK_AGENTS: readonly ["gemini", "codex", "pi"];
4
4
  export type RemoteHookAgent = (typeof REMOTE_HOOK_AGENTS)[number];
5
5
  export declare const isRemoteHookAgent: (raw: string) => raw is RemoteHookAgent;
6
6
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"remote-hook.d.ts","sourceRoot":"","sources":["../src/remote-hook.ts"],"names":[],"mappings":"AAyCA;wEACwE;AACxE,eAAO,MAAM,kBAAkB,8BAA+B,CAAC;AAC/D,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAoClE,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,KAAG,GAAG,IAAI,eACE,CAAC;AAE1D;;;GAGG;AACH,eAAO,MAAM,aAAa,GACxB,OAAO,eAAe,EACtB,OAAO,MAAM,EACb,MAAK,MAAM,CAAC,UAAwB,EACpC,MAAK,MAAM,MAAiB,KAC3B,MAAM,GAAG,IA0CX,CAAC"}
1
+ {"version":3,"file":"remote-hook.d.ts","sourceRoot":"","sources":["../src/remote-hook.ts"],"names":[],"mappings":"AA0CA;wEACwE;AACxE,eAAO,MAAM,kBAAkB,oCAAqC,CAAC;AACrE,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAqClE,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,KAAG,GAAG,IAAI,eACE,CAAC;AAE1D;;;GAGG;AACH,eAAO,MAAM,aAAa,GACxB,OAAO,eAAe,EACtB,OAAO,MAAM,EACb,MAAK,MAAM,CAAC,UAAwB,EACpC,MAAK,MAAM,MAAiB,KAC3B,MAAM,GAAG,IA0CX,CAAC"}
@@ -3,8 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runRemoteHook = exports.isRemoteHookAgent = exports.REMOTE_HOOK_AGENTS = void 0;
4
4
  /**
5
5
  * `zeph remote-hook <agent>` — prompt-submit hook handler for agents whose
6
- * hooks the cli installs directly: Gemini CLI (BeforeAgent) and Codex CLI
7
- * (UserPromptSubmit). TS twin of the Claude Code plugin's
6
+ * hooks the cli installs directly: Gemini CLI (BeforeAgent), Codex CLI
7
+ * (UserPromptSubmit), and Pi (before_agent_start, consumed by the zeph
8
+ * extension itself). TS twin of the Claude Code plugin's
8
9
  * hooks/zeph-remote.sh (ADR-0002).
9
10
  *
10
11
  * The listener records every phone→pane text injection as a one-shot
@@ -13,7 +14,7 @@ exports.runRemoteHook = exports.isRemoteHookAgent = exports.REMOTE_HOOK_AGENTS =
13
14
  * record — same project (cksum of the cwd), fresh (≤15 min), and
14
15
  * byte-identical trimmed text — the hook emits additionalContext telling
15
16
  * the model the user is driving the session from their phone, which enters
16
- * sticky REMOTE mode (Rule 9).
17
+ * sticky REMOTE mode.
17
18
  *
18
19
  * Detection is exact: a terminal keystroke racing a phone message can
19
20
  * never false-match. Parity with the writer (listener.ts writeRemoteMarker)
@@ -35,12 +36,13 @@ const config_js_1 = require("./config.js");
35
36
  const gate_js_1 = require("./gate.js");
36
37
  /** Agents whose prompt-submit hooks this command serves. Claude Code is
37
38
  * NOT here — its hook ships with the plugin (hooks/zeph-remote.sh). */
38
- exports.REMOTE_HOOK_AGENTS = ['gemini', 'codex'];
39
+ exports.REMOTE_HOOK_AGENTS = ['gemini', 'codex', 'pi'];
39
40
  /** Per-agent hookEventName echoed back in hookSpecificOutput — each CLI
40
41
  * validates it against the event that invoked the hook. */
41
42
  const HOOK_EVENT_NAME = {
42
43
  gemini: 'BeforeAgent',
43
44
  codex: 'UserPromptSubmit',
45
+ pi: 'before_agent_start',
44
46
  };
45
47
  /**
46
48
  * Freshness window (seconds), same as the bash reader: deliberately
@@ -52,7 +54,7 @@ const HOOK_EVENT_NAME = {
52
54
  const FRESH_WINDOW_SEC = 900;
53
55
  const TWO_WAY_CONTEXT = `# System note (Zeph remote-origin detect)
54
56
 
55
- This user message arrived from the user's phone via Zeph agent chat (verified by the listener — exact text match). The user is driving this session remotely and is NOT at the terminal. Enter sticky REMOTE mode now (CORE_RULES Rule 9): end EVERY response with \`zeph_ask\` (buttons + free-text) until the user exits — an exit signal (done/stop/exit), or a prompt they type at the terminal, which this hook will tell you about. Plain-text questions are invisible to them.`;
57
+ This user message arrived from the user's phone via Zeph agent chat (verified by the listener — exact text match). The user is driving this session remotely and is NOT at the terminal. Enter sticky REMOTE mode now (the "Sticky REMOTE mode" rule in your Zeph rules): end EVERY response with \`zeph_ask\` (buttons + free-text) until the user exits — an exit signal (done/stop/exit), or a prompt they type at the terminal, which this hook will tell you about. Plain-text questions are invisible to them.`;
56
58
  const ONE_WAY_CONTEXT = `# System note (Zeph remote-origin detect)
57
59
 
58
60
  This user message arrived from the user's phone via Zeph agent chat (verified by the listener — exact text match), but no hook id is configured (neither \`ZEPH_HOOK_ID\` nor \`hookId\` in ~/.zeph/config.json), so two-way tools (zeph_ask/zeph_prompt/zeph_input) are unavailable. Make your final message self-contained — the completion push is the user's only feedback channel. If you have not already mentioned it this session, tell the user once that running \`npx @zeph-to/cli setup\` upgrades this into a two-way remote session (buttons + text replies from the phone).`;
@@ -64,7 +66,7 @@ This user message arrived from the user's phone via Zeph agent chat (verified by
64
66
  */
65
67
  const EXIT_CONTEXT = `# System note (Zeph)
66
68
 
67
- The user typed this prompt at the terminal, so this session has LEFT sticky REMOTE mode — answer normally (CORE_RULES Rule 4) and do not end this response with \`zeph_ask\` just to keep the loop alive. Rule 3 still holds: if you actually ask the user something, ask it with \`zeph_ask\`. Re-entry is automatic the moment they send another message from their phone.`;
69
+ The user typed this prompt at the terminal, so this session has LEFT sticky REMOTE mode — answer normally (the NORMAL branch of your Zeph rules) and do not end this response with \`zeph_ask\` just to keep the loop alive. One rule still holds: if you actually ask the user something, ask it with \`zeph_ask\`. Re-entry is automatic the moment they send another message from their phone.`;
68
70
  const isRemoteHookAgent = (raw) => exports.REMOTE_HOOK_AGENTS.includes(raw);
69
71
  exports.isRemoteHookAgent = isRemoteHookAgent;
70
72
  /**
@@ -85,7 +87,7 @@ const runRemoteHook = (agent, stdin, env = process.env, now = Date.now) => {
85
87
  // `cwd` keys every state file, so without it there is nothing to look up.
86
88
  if (!cwd)
87
89
  return null;
88
- // Mute outranks everything (Rule 12) — stay silent and leave both the marker
90
+ // Mute outranks everything (the mute rule) — stay silent and leave both the marker
89
91
  // and the state untouched (the next inject overwrites the marker anyway).
90
92
  if ((0, gate_js_1.isMuted)(cwd))
91
93
  return null;
@@ -12,6 +12,10 @@ export declare const COPILOT_RULE: string;
12
12
  export declare const CLINE_RULE: string;
13
13
  /** Aider — standalone conventions file via .aider.conf.yml `read:` (no Stop hook, no prompt hook). */
14
14
  export declare const AIDER_RULE: string;
15
+ /** Pi — managed block in ~/.pi/agent/AGENTS.md. Extension = Stop-equivalent + prompt hook (PI_EXTENSION). */
16
+ export declare const PI_RULE: string;
17
+ /** OpenCode — managed block in ~/.config/opencode/AGENTS.md. Stop hook via plugin, no prompt hook (v1). */
18
+ export declare const OPENCODE_RULE: string;
15
19
  export declare const CURSOR_HOOKS: string;
16
20
  export declare const WINDSURF_HOOKS: string;
17
21
  export declare const GEMINI_HOOKS: {
@@ -65,6 +69,10 @@ export declare const CODEX_HOOKS: {
65
69
  */
66
70
  export declare const isZephHookGroup: (group: unknown) => boolean;
67
71
  export declare const COPILOT_HOOKS: string;
72
+ /** Pi extension source — written to ~/.pi/agent/extensions/zeph.ts (drop-in auto-load). */
73
+ export declare const PI_EXTENSION: string;
74
+ /** OpenCode plugin source — written to ~/.config/opencode/plugins/zeph.ts (drop-in auto-load). */
75
+ export declare const OPENCODE_PLUGIN: string;
68
76
  export declare const ZEPH_MARK_START = "<!-- ZEPH:START \u2014 managed by @zeph-to/cli, do not edit between markers -->";
69
77
  export declare const ZEPH_MARK_END = "<!-- ZEPH:END -->";
70
78
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AA0IA,6FAA6F;AAC7F,eAAO,MAAM,WAAW,QAKtB,CAAC;AAEH,6FAA6F;AAC7F,eAAO,MAAM,aAAa,QAIxB,CAAC;AAEH,0FAA0F;AAC1F,eAAO,MAAM,WAAW,QAAyE,CAAC;AAElG,uFAAuF;AACvF,eAAO,MAAM,UAAU,QAAyE,CAAC;AAEjG,oGAAoG;AACpG,eAAO,MAAM,YAAY,QAIvB,CAAC;AAEH,gFAAgF;AAChF,eAAO,MAAM,UAAU,QAIrB,CAAC;AAEH,sGAAsG;AACtG,eAAO,MAAM,UAAU,QAIrB,CAAC;AAIH,eAAO,MAAM,YAAY,QAKd,CAAC;AAEZ,eAAO,MAAM,cAAc,QAOhB,CAAC;AAEZ,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;CAuBxB,CAAC;AAYF,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;CASvB,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,OAAO,KAAG,OAUhD,CAAC;AAEF,eAAO,MAAM,aAAa,QASf,CAAC;AASZ,eAAO,MAAM,eAAe,oFAA+E,CAAC;AAC5G,eAAO,MAAM,aAAa,sBAAsB,CAAC;AAQjD;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,EAAE,MAAM,MAAM,KAAG,MAWnE,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,KAAG,MAOrD,CAAC"}
1
+ {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AA4KA,6FAA6F;AAC7F,eAAO,MAAM,WAAW,QAKtB,CAAC;AAEH,6FAA6F;AAC7F,eAAO,MAAM,aAAa,QAIxB,CAAC;AAEH,0FAA0F;AAC1F,eAAO,MAAM,WAAW,QAAyE,CAAC;AAElG,uFAAuF;AACvF,eAAO,MAAM,UAAU,QAAyE,CAAC;AAEjG,oGAAoG;AACpG,eAAO,MAAM,YAAY,QAIvB,CAAC;AAEH,gFAAgF;AAChF,eAAO,MAAM,UAAU,QAIrB,CAAC;AAEH,sGAAsG;AACtG,eAAO,MAAM,UAAU,QAIrB,CAAC;AAEH,6GAA6G;AAC7G,eAAO,MAAM,OAAO,QAIlB,CAAC;AAEH,2GAA2G;AAC3G,eAAO,MAAM,aAAa,QAIxB,CAAC;AAIH,eAAO,MAAM,YAAY,QAKd,CAAC;AAEZ,eAAO,MAAM,cAAc,QAOhB,CAAC;AAEZ,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;CAuBxB,CAAC;AAYF,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;CASvB,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,eAAe,GAAI,OAAO,OAAO,KAAG,OAUhD,CAAC;AAEF,eAAO,MAAM,aAAa,QASf,CAAC;AAQZ,2FAA2F;AAC3F,eAAO,MAAM,YAAY,QAsDxB,CAAC;AAEF,kGAAkG;AAClG,eAAO,MAAM,eAAe,QAwG3B,CAAC;AASF,eAAO,MAAM,eAAe,oFAA+E,CAAC;AAC5G,eAAO,MAAM,aAAa,sBAAsB,CAAC;AAQjD;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,EAAE,MAAM,MAAM,KAAG,MAWnE,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,KAAG,MAOrD,CAAC"}
package/dist/templates.js CHANGED
@@ -6,7 +6,8 @@
6
6
  // generated core (src/zeph-core.generated.ts) plus a per-agent
7
7
  // notification preamble:
8
8
  //
9
- // - Hook-driven agents (Cursor, Windsurf, Gemini, Codex, Copilot) have
9
+ // - Hook-driven agents (Cursor, Windsurf, Gemini, Codex, Copilot, Pi,
10
+ // OpenCode) have
10
11
  // a Stop-equivalent hook installed that auto-pushes on completion, so
11
12
  // they must NOT manually call zeph_notify for "done".
12
13
  // - Rule-only agents (Cline, Aider) have no Stop hook, so they DO call
@@ -16,16 +17,16 @@
16
17
  // all of them — that is the whole point of the shared generated core.
17
18
  //
18
19
  // One more axis, and it decides who can enter REMOTE at all: the core's
19
- // Rules 3/4/10/11 are REMOTE-scoped, and REMOTE is entered by a prompt-submit
20
- // hook note (a phone message) or by a `zeph_ask` answer. Only Gemini and Codex
21
- // have that hook (remoteHookCmd below). Every other agent's only door is the
20
+ // Rules 1/2/8/9 are REMOTE-scoped, and REMOTE is entered by a prompt-submit
21
+ // hook note (a phone message) or by a `zeph_ask` answer. Only Gemini, Codex
22
+ // and Pi have that hook (remoteHookCmd below). Every other agent's only door is the
22
23
  // ask itself — so for them the NORMAL branch must still send one after real
23
24
  // work, or the phone loop can never begin. That is REMOTE_ENTRY_NO_HOOK.
24
25
  //
25
26
  // Keeping this in one place means a rule change lands everywhere at once
26
27
  // and the agents can't drift apart.
27
28
  Object.defineProperty(exports, "__esModule", { value: true });
28
- exports.removeManagedBlock = exports.upsertManagedBlock = exports.ZEPH_MARK_END = exports.ZEPH_MARK_START = exports.COPILOT_HOOKS = exports.isZephHookGroup = exports.CODEX_HOOKS = exports.GEMINI_HOOKS = exports.WINDSURF_HOOKS = exports.CURSOR_HOOKS = exports.AIDER_RULE = exports.CLINE_RULE = exports.COPILOT_RULE = exports.CODEX_RULE = exports.GEMINI_RULE = exports.WINDSURF_RULE = exports.CURSOR_RULE = void 0;
29
+ exports.removeManagedBlock = exports.upsertManagedBlock = exports.ZEPH_MARK_END = exports.ZEPH_MARK_START = exports.OPENCODE_PLUGIN = exports.PI_EXTENSION = exports.COPILOT_HOOKS = exports.isZephHookGroup = exports.CODEX_HOOKS = exports.GEMINI_HOOKS = exports.WINDSURF_HOOKS = exports.CURSOR_HOOKS = exports.OPENCODE_RULE = exports.PI_RULE = exports.AIDER_RULE = exports.CLINE_RULE = exports.COPILOT_RULE = exports.CODEX_RULE = exports.GEMINI_RULE = exports.WINDSURF_RULE = exports.CURSOR_RULE = void 0;
29
30
  const gate_js_1 = require("./gate.js");
30
31
  const zeph_core_generated_js_1 = require("./zeph-core.generated.js");
31
32
  // Graceful resolution: prefer the installed `zeph` CLI, but fall back to
@@ -35,8 +36,9 @@ const zeph_core_generated_js_1 = require("./zeph-core.generated.js");
35
36
  // pattern in plugin/hooks/zeph-{stop,ask}.sh.
36
37
  //
37
38
  // `--auto` applies the shared push-gate before sending (see src/gate.ts):
38
- // the push still fires (gate defaults assume real work), and the
39
- // /zeph-quiet | /zeph-loud dial works for every hook-driven agent.
39
+ // a caller that names no turn counts still pushes (gate defaults assume real
40
+ // work), and the /zeph-quiet | /zeph-loud dial works for every hook-driven
41
+ // agent. The two drop-in artifacts do name counts — see `turnFacts` below.
40
42
  //
41
43
  // `--pushmode-default normal` is what keeps the first half of that true. The
42
44
  // built-in default for a project with no dial is quiet, and quiet only lets a
@@ -47,8 +49,23 @@ const zeph_core_generated_js_1 = require("./zeph-core.generated.js");
47
49
  // Older installed `zeph` versions parse both flags as unknown booleans and
48
50
  // ignore them — graceful backward compatibility, and for `--pushmode-default`
49
51
  // specifically the old build's behavior was already "normal when no dial".
50
- const NOTIFY_CMD = '$(command -v zeph || echo "npx -y @zeph-to/cli") notify --title "Task done" --auto '
51
- + `--${gate_js_1.PUSHMODE_DEFAULT_FLAG} normal 2>/dev/null || true`;
52
+ //
53
+ // `turnFacts` carries the `--tools` / `--nonreadonly` counts, which only the
54
+ // two drop-in artifacts can supply — a JSON hook config sees no per-tool
55
+ // events, so it passes nothing and keeps riding GATE_DEFAULTS.
56
+ const notifyCmd = (turnFacts = '') => '$(command -v zeph || echo "npx -y @zeph-to/cli") notify --title "Task done" --auto'
57
+ + turnFacts
58
+ + ` --${gate_js_1.PUSHMODE_DEFAULT_FLAG} normal 2>/dev/null || true`;
59
+ // The same command as a JS template literal, so an artifact's runtime counters
60
+ // interpolate into it. JSON.stringify would emit a double-quoted string and
61
+ // ship the placeholders verbatim. Safe as long as the command holds no
62
+ // backtick and no `${` of its own — `$(command -v zeph …)` is shell, not JS.
63
+ const notifyCmdLiteral = (turnFacts) => '`' + notifyCmd(turnFacts) + '`';
64
+ // The turn-fact fragment, as JS template-literal placeholders. Both artifacts
65
+ // name their counters `tools` / `nonReadonly`, so one fragment serves both —
66
+ // templates.test.ts pins those identifiers, since a rename on the artifact side
67
+ // would leave `${tools}` unresolved and kill the push with a ReferenceError.
68
+ const TURN_FACT_FLAGS = ` --${gate_js_1.TOOL_COUNT_FLAG} \${tools} --${gate_js_1.NONREADONLY_COUNT_FLAG} \${nonReadonly}`;
52
69
  // Prompt-submit hook command — remote-origin detection (ADR-0002). Reads
53
70
  // the hook JSON on stdin and prints additionalContext JSON on a marker
54
71
  // match (see src/remote-hook.ts). stdout IS the hook response, so only
@@ -81,7 +98,7 @@ refactor, multi-file changes) call zeph_notify. Skip it for trivial
81
98
  operations (file reads, simple searches). Set priority "high" for
82
99
  errors/blockers.`;
83
100
  // REMOTE-entry preamble — agents with NO prompt-submit hook (Cursor, Windsurf,
84
- // Copilot, Cline, Aider). The shared core scopes Rules 3/4/10/11 to REMOTE
101
+ // Copilot, Cline, Aider, OpenCode). The shared core scopes Rules 1/2/8/9 to REMOTE
85
102
  // and tells a NORMAL session it owes no `zeph_ask`. That is right where a
86
103
  // hook can announce the phone message that starts REMOTE; here nothing can,
87
104
  // and the only remaining entry is a `zeph_ask` answer that is not Done-like.
@@ -91,7 +108,7 @@ errors/blockers.`;
91
108
  // says about NORMAL still holds (no ask on trivial turns, questions may go
92
109
  // to the local picker, no `zeph_ask` just to mark a turn finished).
93
110
  //
94
- // This overrides the core's Rule 4 NORMAL clause for these agents. It is a
111
+ // This overrides the core's Rule 2 NORMAL clause for these agents. It is a
95
112
  // per-agent preamble, not a fork of the core, for the reason the core's
96
113
  // header gives: the rule text must stay one thing.
97
114
  const REMOTE_ENTRY_NO_HOOK = `## Entering REMOTE without a prompt hook
@@ -99,17 +116,30 @@ const REMOTE_ENTRY_NO_HOOK = `## Entering REMOTE without a prompt hook
99
116
  This agent has no prompt-submit hook, so nothing can tell you when a
100
117
  message arrived from the user's phone. The ONLY way this session enters
101
118
  REMOTE is a \`zeph_ask\` answer that is not a Done-like button. So — and
102
- this overrides the "In NORMAL, end with nothing" clause of Rule 4 below —
119
+ this overrides the "In NORMAL, end with nothing" clause below —
103
120
  **after substantial work in NORMAL, end the response with \`zeph_ask\`**:
104
121
  2–4 \`actions\` carrying the next-step candidates plus a Done-like
105
122
  \`fallback\`, \`timeout\` 300–600 s. "Substantial" = file changes, commits,
106
123
  builds, tests, deploys, destructive ops, milestone completions. Skip it on
107
124
  trivial turns (read-only exploration, a mid-step in an approved plan, a
108
- typo-sized fix). Once the answer reports \`zephState: "REMOTE"\`, Rule 9
109
- takes over.`;
125
+ typo-sized fix). Once the answer reports \`zephState: "REMOTE"\`, sticky REMOTE
126
+ mode takes over.`;
127
+ // Tool-access preamble — pi only.
128
+ const PI_TOOL_ACCESS = `## Zeph tools via the CLI (no MCP)
129
+
130
+ Pi has no MCP support, so the zeph_* tools these rules name are not in your
131
+ tool list. Wherever a rule says to call one, run the zeph CLI with your bash
132
+ tool instead — same semantics:
133
+
134
+ - zeph_ask → \`zeph ask --title "…" --body "…" --actions "id:Label,id2:Label2" --timeout 300\`
135
+ Blocks until answered; prints one JSON line. \`answered: false\` (timeout /
136
+ unreachable) is a Done-like outcome — treat it as NORMAL.
137
+ - zeph_notify → \`zeph notify --title "…" --body "…" [--priority high]\`
138
+ - AskUserQuestion → pi's own terminal prompt.`;
110
139
  /** Assemble a full rule document from optional frontmatter + preambles + core. */
111
140
  const buildRule = (opts) => {
112
141
  const fm = opts.frontmatter ? `${opts.frontmatter}\n\n` : '';
142
+ const tools = opts.toolAccess ? `${opts.toolAccess}\n\n` : '';
113
143
  const entry = opts.remoteEntry ? `${opts.remoteEntry}\n\n` : '';
114
144
  return `${fm}# Zeph — Remote-Control Rules
115
145
 
@@ -119,7 +149,7 @@ the user.
119
149
 
120
150
  ${opts.notify}
121
151
 
122
- ${entry}${opts.core}
152
+ ${tools}${entry}${opts.core}
123
153
  `;
124
154
  };
125
155
  // ── Per-agent rule documents ─────────────────────────────────────
@@ -162,17 +192,29 @@ exports.AIDER_RULE = buildRule({
162
192
  remoteEntry: REMOTE_ENTRY_NO_HOOK,
163
193
  core: zeph_core_generated_js_1.ZEPH_CORE_RULE_ONLY,
164
194
  });
195
+ /** Pi — managed block in ~/.pi/agent/AGENTS.md. Extension = Stop-equivalent + prompt hook (PI_EXTENSION). */
196
+ exports.PI_RULE = buildRule({
197
+ notify: HOOK_DRIVEN_NOTIFY,
198
+ toolAccess: PI_TOOL_ACCESS,
199
+ core: zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN,
200
+ });
201
+ /** OpenCode — managed block in ~/.config/opencode/AGENTS.md. Stop hook via plugin, no prompt hook (v1). */
202
+ exports.OPENCODE_RULE = buildRule({
203
+ notify: HOOK_DRIVEN_NOTIFY,
204
+ remoteEntry: REMOTE_ENTRY_NO_HOOK,
205
+ core: zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN,
206
+ });
165
207
  // ── Hook configs ─────────────────────────────────────────────────
166
208
  exports.CURSOR_HOOKS = JSON.stringify({
167
209
  version: 1,
168
210
  hooks: {
169
- stop: [{ command: NOTIFY_CMD }],
211
+ stop: [{ command: notifyCmd() }],
170
212
  },
171
213
  }, null, 2);
172
214
  exports.WINDSURF_HOOKS = JSON.stringify({
173
215
  hooks: {
174
216
  post_cascade_response: [{
175
- command: NOTIFY_CMD,
217
+ command: notifyCmd(),
176
218
  show_output: false,
177
219
  }],
178
220
  },
@@ -195,7 +237,7 @@ exports.GEMINI_HOOKS = {
195
237
  hooks: [{
196
238
  name: 'zeph-notify',
197
239
  type: 'command',
198
- command: NOTIFY_CMD,
240
+ command: notifyCmd(),
199
241
  }],
200
242
  }],
201
243
  },
@@ -217,7 +259,7 @@ exports.CODEX_HOOKS = {
217
259
  hooks: [{ type: 'command', command: remoteHookCmd('codex'), timeout: 5 }],
218
260
  }],
219
261
  Stop: [{
220
- hooks: [{ type: 'command', command: NOTIFY_CMD }],
262
+ hooks: [{ type: 'command', command: notifyCmd() }],
221
263
  }],
222
264
  },
223
265
  };
@@ -245,11 +287,178 @@ exports.COPILOT_HOOKS = JSON.stringify({
245
287
  hooks: {
246
288
  sessionEnd: [{
247
289
  type: 'command',
248
- bash: NOTIFY_CMD,
290
+ bash: notifyCmd(),
249
291
  timeoutSec: 10,
250
292
  }],
251
293
  },
252
294
  }, null, 2);
295
+ // ── Drop-in artifact sources (pi extension, opencode plugin) ─────
296
+ //
297
+ // Not named *_HOOKS: those exports are JSON configs that templates.test.ts
298
+ // JSON.parses — these are TS source, auto-collected there as *_EXTENSION /
299
+ // *_PLUGIN for the same quiet-default guard, at the string level.
300
+ /** Pi extension source — written to ~/.pi/agent/extensions/zeph.ts (drop-in auto-load). */
301
+ exports.PI_EXTENSION = `// Generated by @zeph-to/cli — reinstall overwrites; \`zeph uninstall\` removes.
302
+ import { spawn } from "node:child_process";
303
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
304
+
305
+ const sh = (cmd: string, cwd: string, stdin?: string): Promise<string> =>
306
+ new Promise((resolve) => {
307
+ const child = spawn("sh", ["-c", cmd], { cwd, stdio: ["pipe", "pipe", "ignore"] });
308
+ let out = "";
309
+ child.stdout.on("data", (d) => (out += d.toString()));
310
+ child.on("error", () => resolve(""));
311
+ child.on("close", () => resolve(out));
312
+ child.stdin.end(stdin ?? "");
313
+ });
314
+
315
+ // Read-only tools, mirroring what the Claude Code Stop hook excludes
316
+ // (Read/Grep/Glob). Names read from the installed package's dist/core/tools on
317
+ // 2026-08-28: bash, edit, find, grep, ls, powershell, read, write — \`find\` is
318
+ // pi's Glob. Anything absent here, MCP tools included, counts as real work, so
319
+ // an unknown tool errs toward pushing rather than toward silence.
320
+ const READ_ONLY = new Set(["read", "grep", "find", "ls"]);
321
+
322
+ export default function (pi: ExtensionAPI) {
323
+ // Turn facts for the push gate. One agent per process, so plain counters
324
+ // suffice — no session keying.
325
+ let tools = 0;
326
+ let nonReadonly = 0;
327
+
328
+ pi.on("tool_execution_end", (event) => {
329
+ tools += 1;
330
+ if (!READ_ONLY.has(event.toolName)) nonReadonly += 1;
331
+ });
332
+ // Stop-equivalent: agent_settled fires once per user turn, after retries/compaction.
333
+ // Fire-and-forget: never block pi's turn-end on the notify network call.
334
+ pi.on("agent_settled", (_event, ctx) => {
335
+ const child = spawn("sh", ["-c", ${notifyCmdLiteral(TURN_FACT_FLAGS)}], { cwd: ctx.cwd, stdio: "ignore", detached: true });
336
+ child.on("error", () => {});
337
+ child.unref();
338
+ });
339
+ // Prompt-submit: remote-origin detection (ADR-0002) — additionalContext comes
340
+ // back as a persistent injected message (this extension is the consumer).
341
+ // Doubles as the turn boundary: this is where the counters start over.
342
+ pi.on("before_agent_start", async (event, ctx) => {
343
+ tools = 0;
344
+ nonReadonly = 0;
345
+ const out = await sh(${JSON.stringify(remoteHookCmd('pi'))}, ctx.cwd,
346
+ JSON.stringify({ prompt: event.prompt, cwd: ctx.cwd }));
347
+ try {
348
+ const context = JSON.parse(out)?.hookSpecificOutput?.additionalContext;
349
+ if (typeof context === "string" && context) {
350
+ return { message: { customType: "zeph-remote", content: context, display: false } };
351
+ }
352
+ } catch { /* silent no-op — the hook only ever adds context */ }
353
+ });
354
+ }
355
+ `;
356
+ /** OpenCode plugin source — written to ~/.config/opencode/plugins/zeph.ts (drop-in auto-load). */
357
+ exports.OPENCODE_PLUGIN = `// Generated by @zeph-to/cli — reinstall overwrites; \`zeph uninstall\` removes.
358
+ import { spawn } from "node:child_process";
359
+ import type { Plugin } from "@opencode-ai/plugin";
360
+
361
+ // Read-only tools, mirroring what the Claude Code Stop hook excludes
362
+ // (Read/Grep/Glob). Names read from the installed opencode binary's tool
363
+ // registry on 2026-08-28: read, list, glob, grep, webfetch, websearch, task,
364
+ // shell, bash, edit, write, patch, skill, lsp, todowrite. Anything absent
365
+ // here, MCP tools included, counts as real work — an unknown tool errs toward
366
+ // pushing rather than toward silence.
367
+ const READ_ONLY = new Set(["read", "grep", "glob", "list"]);
368
+
369
+ type TurnFacts = { tools: number; nonReadonly: number };
370
+ const zeroFacts = (): TurnFacts => ({ tools: 0, nonReadonly: 0 });
371
+
372
+ // Typed against the real plugin contract, the way the pi extension is: the
373
+ // hook names and payload shapes this file asserts by hand are then checked by
374
+ // the compiler, so an upstream rename fails loudly instead of silently
375
+ // handing every handler an \`any\`.
376
+ export const ZephPlugin: Plugin = async ({ client, directory }) => {
377
+ // Turn facts, keyed by session. The plugin instance is per-project, not
378
+ // per-session, and the \`task\` tool runs subagents in their own child
379
+ // sessions with their own full lifecycle — one shared counter would bill
380
+ // their tool calls to whatever else the project had running.
381
+ const facts = new Map<string, TurnFacts>();
382
+
383
+ // A child session's id is all \`session.idle\` carries; the parent link lives
384
+ // on the session record. This is the one blocking call in the idle handler —
385
+ // the notify spawn below stays fire-and-forget, but the push decision cannot
386
+ // be made without knowing whether this session is somebody's subagent. The
387
+ // generated client defaults to \`throwOnError: false\`, so a non-2xx arrives as
388
+ // a result with no \`data\` rather than as a throw; both paths land on
389
+ // undefined, which pushes — the same direction every other unknown in this
390
+ // file leans.
391
+ const parentOf = async (id: string): Promise<string | undefined> => {
392
+ try {
393
+ return (await client.session.get({ path: { id } })).data?.parentID;
394
+ } catch {
395
+ return undefined;
396
+ }
397
+ };
398
+
399
+ return {
400
+ // Turn start. Create-if-absent, never reset: this fires per *message*, and
401
+ // a second message queued while the agent is still working would otherwise
402
+ // zero the counts already earned. session.idle deletes the entry, so the
403
+ // next turn always starts from zero anyway.
404
+ "chat.message": async ({ sessionID }) => {
405
+ if (!facts.has(sessionID)) facts.set(sessionID, zeroFacts());
406
+ },
407
+ "tool.execute.after": async ({ tool, sessionID }) => {
408
+ const entry = facts.get(sessionID) ?? zeroFacts();
409
+ entry.tools += 1;
410
+ if (!READ_ONLY.has(tool)) entry.nonReadonly += 1;
411
+ facts.set(sessionID, entry);
412
+ },
413
+ // Stop-equivalent. Unlike the named hooks above, session.idle arrives only
414
+ // through the generic \`event\` hook — there is no per-event key for it, so
415
+ // filter by type.
416
+ event: async ({ event }) => {
417
+ // A deleted session never idles again, so nothing would ever consume its
418
+ // entry. Drop it here or it outlives the turn for the life of the
419
+ // process. An *aborted* turn needs no such handling: the entry survives
420
+ // to the session's next chat.message, which adds to it rather than
421
+ // resetting, so the interrupted work is reported with the retry.
422
+ if (event.type === "session.deleted") {
423
+ facts.delete(event.properties.info.id);
424
+ return;
425
+ }
426
+ if (event.type !== "session.idle") return;
427
+ const { sessionID } = event.properties;
428
+ // No entry means no user message was seen for this session, so there is
429
+ // no turn to report — the zeroes gate the push out, which is correct.
430
+ const settled = facts.get(sessionID) ?? zeroFacts();
431
+ facts.delete(sessionID);
432
+
433
+ // A \`task\` subagent settles first, in its own session. Pushing there
434
+ // would announce "Task done" for a slice of a turn still in flight, and
435
+ // leave the parent crediting the whole delegation as the single
436
+ // \`task\` call it saw — one tool, below the gate, so the real
437
+ // completion would go silent. Roll the counts up and stay quiet.
438
+ const parentID = await parentOf(sessionID);
439
+ const parent = parentID === undefined ? undefined : facts.get(parentID);
440
+ if (parent) {
441
+ parent.tools += settled.tools;
442
+ parent.nonReadonly += settled.nonReadonly;
443
+ return;
444
+ }
445
+ // Falling through means no live parent entry to roll into — either this
446
+ // is a top-level session, or a \`background\` task outlived the turn that
447
+ // spawned it and the parent settled and pushed already, or the lookup
448
+ // above failed. The three are indistinguishable from here, and all three
449
+ // want the same thing: report this session's own work rather than write
450
+ // it into an entry nobody will ever read. Never conjure that entry — that
451
+ // would leak one per background task.
452
+
453
+ const { tools, nonReadonly } = settled;
454
+ // Fire-and-forget: never block opencode's event pipeline on the notify call.
455
+ const child = spawn("sh", ["-c", ${notifyCmdLiteral(TURN_FACT_FLAGS)}], { cwd: directory, stdio: "ignore", detached: true });
456
+ child.on("error", () => {});
457
+ child.unref();
458
+ },
459
+ };
460
+ };
461
+ `;
253
462
  // ── Marker-section helpers for shared global rule files ──────────
254
463
  //
255
464
  // Windsurf / Gemini / Codex all use a single shared global rule file
@@ -1,3 +1,7 @@
1
+ /** Remove just the `zeph` entry from an MCP registry JSON file. `key` is the
2
+ * container: `mcpServers` for Cursor/Windsurf, top-level `mcp` for
3
+ * opencode.json (opencode's own schema). Exported for tests. */
4
+ export declare const rmMcpEntry: (filePath: string, dry: boolean, key?: string) => string | null;
1
5
  /** Remove just the zeph-written entries from Gemini's settings.json.
2
6
  * Exported for tests (gemini is PATH-detected, so the full-uninstall
3
7
  * suite can't reach this deterministically). */
@@ -1 +1 @@
1
- {"version":3,"file":"uninstall.d.ts","sourceRoot":"","sources":["../src/uninstall.ts"],"names":[],"mappings":"AAwGA;;iDAEiD;AACjD,eAAO,MAAM,YAAY,GAAI,UAAU,MAAM,EAAE,KAAK,OAAO,KAAG,MAAM,GAAG,IAWtE,CAAC;AAEF;;;gDAGgD;AAChD,eAAO,MAAM,WAAW,GAAI,UAAU,MAAM,EAAE,KAAK,OAAO,KAAG,MAAM,GAAG,IAerE,CAAC;AAqEF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAU,KAAK,OAAO,KAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAM3E,CAAC;AAIF,eAAO,MAAM,eAAe,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAoC5F,CAAC"}
1
+ {"version":3,"file":"uninstall.d.ts","sourceRoot":"","sources":["../src/uninstall.ts"],"names":[],"mappings":"AA4BA;;iEAEiE;AACjE,eAAO,MAAM,UAAU,GAAI,UAAU,MAAM,EAAE,KAAK,OAAO,EAAE,YAAkB,KAAG,MAAM,GAAG,IAexF,CAAC;AA4DF;;iDAEiD;AACjD,eAAO,MAAM,YAAY,GAAI,UAAU,MAAM,EAAE,KAAK,OAAO,KAAG,MAAM,GAAG,IAWtE,CAAC;AAEF;;;gDAGgD;AAChD,eAAO,MAAM,WAAW,GAAI,UAAU,MAAM,EAAE,KAAK,OAAO,KAAG,MAAM,GAAG,IAerE,CAAC;AA8EF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAAU,KAAK,OAAO,KAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAM3E,CAAC;AAIF,eAAO,MAAM,eAAe,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAoC5F,CAAC"}
package/dist/uninstall.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.handleUninstall = exports.removeServiceStep = exports.rmCodexHook = exports.rmGeminiHook = void 0;
3
+ exports.handleUninstall = exports.removeServiceStep = exports.rmCodexHook = exports.rmGeminiHook = exports.rmMcpEntry = void 0;
4
4
  const child_process_1 = require("child_process");
5
5
  const fs_1 = require("fs");
6
6
  const os_1 = require("os");
@@ -25,8 +25,10 @@ const rmFile = (filePath, dry) => {
25
25
  (0, fs_1.rmSync)(filePath, { force: true });
26
26
  return `${verb(dry)} ${filePath}`;
27
27
  };
28
- /** Remove just the `zeph` entry from an mcpServers JSON file. */
29
- const rmMcpEntry = (filePath, dry) => {
28
+ /** Remove just the `zeph` entry from an MCP registry JSON file. `key` is the
29
+ * container: `mcpServers` for Cursor/Windsurf, top-level `mcp` for
30
+ * opencode.json (opencode's own schema). Exported for tests. */
31
+ const rmMcpEntry = (filePath, dry, key = 'mcpServers') => {
30
32
  if (!(0, fs_1.existsSync)(filePath))
31
33
  return null;
32
34
  let data;
@@ -36,7 +38,7 @@ const rmMcpEntry = (filePath, dry) => {
36
38
  catch {
37
39
  return null;
38
40
  }
39
- const servers = data.mcpServers;
41
+ const servers = data[key];
40
42
  if (!servers || !('zeph' in servers))
41
43
  return null;
42
44
  if (!dry) {
@@ -45,6 +47,7 @@ const rmMcpEntry = (filePath, dry) => {
45
47
  }
46
48
  return `${verb(dry)} zeph from ${filePath}`;
47
49
  };
50
+ exports.rmMcpEntry = rmMcpEntry;
48
51
  /** Strip the <!-- ZEPH:START/END --> block from a shared rule file. */
49
52
  const stripManagedRule = (filePath, dry) => {
50
53
  if (!(0, fs_1.existsSync)(filePath))
@@ -185,12 +188,12 @@ const AGENT_UNINSTALLERS = {
185
188
  }
186
189
  },
187
190
  cursor: (dry) => runSteps([
188
- () => rmMcpEntry((0, path_1.join)(HOME, '.cursor', 'mcp.json'), dry),
191
+ () => (0, exports.rmMcpEntry)((0, path_1.join)(HOME, '.cursor', 'mcp.json'), dry),
189
192
  () => rmFile((0, path_1.join)(HOME, '.cursor', 'hooks.json'), dry),
190
193
  () => rmFile((0, path_1.join)(HOME, '.cursor', 'rules', 'zeph.mdc'), dry),
191
194
  ]),
192
195
  windsurf: (dry) => runSteps([
193
- () => rmMcpEntry((0, path_1.join)(HOME, '.codeium', 'windsurf', 'mcp_config.json'), dry),
196
+ () => (0, exports.rmMcpEntry)((0, path_1.join)(HOME, '.codeium', 'windsurf', 'mcp_config.json'), dry),
194
197
  () => rmFile((0, path_1.join)(HOME, '.codeium', 'windsurf', 'hooks.json'), dry),
195
198
  () => stripManagedRule((0, path_1.join)(HOME, '.codeium', 'windsurf', 'memories', 'global_rules.md'), dry),
196
199
  ]),
@@ -235,6 +238,15 @@ const AGENT_UNINSTALLERS = {
235
238
  () => rmFile((0, path_1.join)(HOME, '.zeph', 'aider-conventions.md'), dry),
236
239
  () => rmAiderReadDirective((0, path_1.join)(HOME, '.aider.conf.yml'), dry),
237
240
  ]),
241
+ pi: (dry) => runSteps([
242
+ () => rmFile((0, path_1.join)(HOME, '.pi', 'agent', 'extensions', 'zeph.ts'), dry),
243
+ () => stripManagedRule((0, path_1.join)(HOME, '.pi', 'agent', 'AGENTS.md'), dry),
244
+ ]),
245
+ opencode: (dry) => runSteps([
246
+ () => (0, exports.rmMcpEntry)((0, path_1.join)(HOME, '.config', 'opencode', 'opencode.json'), dry, 'mcp'),
247
+ () => rmFile((0, path_1.join)(HOME, '.config', 'opencode', 'plugins', 'zeph.ts'), dry),
248
+ () => stripManagedRule((0, path_1.join)(HOME, '.config', 'opencode', 'AGENTS.md'), dry),
249
+ ]),
238
250
  };
239
251
  /**
240
252
  * Remove the login-time LaunchAgent, if one is installed.
@@ -1 +1 @@
1
- {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AA0CA,eAAO,MAAM,YAAY,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAiFzF,CAAC"}
1
+ {"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AA4CA,eAAO,MAAM,YAAY,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAiFzF,CAAC"}
package/dist/verify.js CHANGED
@@ -38,6 +38,8 @@ const AGENT_RULE_PRESENT = {
38
38
  copilot: () => (0, fs_1.existsSync)((0, path_1.join)(HOME, '.copilot', 'instructions', 'zeph.instructions.md')),
39
39
  cline: () => (0, fs_1.existsSync)((0, path_1.join)(HOME, '.cline', 'rules', 'zeph.md')),
40
40
  aider: () => (0, fs_1.existsSync)((0, path_1.join)(HOME, '.zeph', 'aider-conventions.md')),
41
+ pi: () => hasManagedBlock((0, path_1.join)(HOME, '.pi', 'agent', 'AGENTS.md')),
42
+ opencode: () => hasManagedBlock((0, path_1.join)(HOME, '.config', 'opencode', 'AGENTS.md')),
41
43
  };
42
44
  const handleVerify = async (args) => {
43
45
  const doPing = args.ping === true;
@@ -1,7 +1,7 @@
1
1
  /** sha256 over the plugin manifest + extracted rule text at generation time. */
2
- export declare const ZEPH_CORE_SOURCE_HASH = "1ab10b4a3d38dd8ffdb924056b775c16883579724edbd0ab5f25fe0a30191c02";
3
- /** Shared rule core for agents with a Stop-equivalent hook (Cursor, Windsurf, Gemini, Codex, Copilot). */
4
- export declare const ZEPH_CORE_HOOK_DRIVEN = "### When zeph_ask is MANDATORY\n\n3. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input \u2014 confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" \u2014 the FINAL tool call MUST be `zeph_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `zeph_ask` there blocks the turn until someone answers on a device or the timeout expires \u2014 that cost is exactly what NORMAL exists to avoid.\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n4. **In REMOTE, `zeph_ask` is the DEFAULT end of EVERY response** \u2014 not conditioned on the work being substantial (Rule 9).\n\n **In NORMAL, end with nothing.** The Stop hook's push \u2014 tuned by the user's dial and your Push Signal \u2014 is the completion signal. Do not chain a `zeph_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `zeph_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n5. When you do ask, prefer `zeph_ask` over `zeph_prompt`/`zeph_input` \u2014 it combines buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2\u20134 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id \u2014 never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done \u2014 what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n zeph_ask({\n title: \"Slice done \u2014 next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n6. Rule 9 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n7. A `zeph_ask` response IS a direct user instruction. Execute it immediately \u2014 do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n8. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `zeph_ask` first \u2014 title \"About to force-push main \u2014 proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 9)\n\n9. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky \u2014 every response ends with `zeph_ask` until the user exits. The state is kept for you in a file, so it survives context compaction and long sessions; you are told what it is rather than deriving it.\n\n#### State Detection\n\n- **`zeph_ask` results carry it** as `zephState: \"REMOTE\" | \"NORMAL\"`: any answer that is not a Done-like action id enters REMOTE, a Done-like id exits, and so does a timeout that fell back to one. A result with no `zephState` is an ask that timed out onto a safe fallback and changed nothing.\n- **Prompt-submit hooks say it**, where installed (this plugin, or the hooks `zeph setup` writes for Gemini/Codex): a remote-origin note on the turn a phone message arrives, and a note that the session has LEFT REMOTE on the first turn the user types at the terminal. A phone answer to a `zeph_ask` comes back as a `tool_result` and never reaches a prompt hook, so a prompt with no phone marker is demonstrably the user's own keyboard \u2014 staying in REMOTE would answer the terminal with a phone loop, and re-entry costs them one message from the phone.\n- **Neither one present \u2192 NORMAL.**\n\n**The one call left to you is free text**, because it is the one signal no hook can read: the server cannot tell \"run the tests\" from \"thanks, that's it\". When the user's typed answer clearly closes the loop \u2014 an obvious wrap-up, or `done`/`stop`/`exit` as a standalone word (not a substring: \"redo\" is not \"done\") \u2014 flip to NORMAL from that response on, don't send `zeph_ask` on it, and emit `<!-- zeph: exit -->` once so the hooks agree with you. Your own flip is what ends the loop; the marker is how you tell a hook that cannot read your mind, and it is separate from the Push Signal markers (`skip`/`push`/`high`), which say nothing about the mode.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`, carrying 2\u20134 `actions` (the next-step candidates as buttons) plus a Done-like `fallback` \u2014 never a destructive one, since an unanswered ask resolves to it \u2014 a text-only ask is only for inherently free-form answers. Non-negotiable while in REMOTE, independent of whether the work was substantial or routine.\n\nSet `timeout` 300\u2013600 s so silence degrades cleanly: an unanswered ask exits the loop quietly \u2014 the server treats a Done-like fallback as an exit \u2014 instead of chaining more notifications at a user who stepped away.\n\nFour things leave REMOTE: a Done-like button (or a timeout that fell back to one), your own read of a free-text wrap-up, a prompt the user typed at the terminal, and \u2014 for a session nobody exited because it crashed \u2014 the state expiring.\n\n#### Behavior in NORMAL (no zeph_ask is owed)\n\nThe user is at the terminal \u2014 that is what NORMAL means. Nothing here obliges an ask:\n\n- Questions go to `AskUserQuestion` or plain prose. The Ask hook still pushes them to the user's device, so a question is never lost.\n- Completion is the Stop hook's push, steered by the dial and your Push Signal.\n- `zeph_ask` remains available when you actively want an answer from their device \u2014 it is not owed, and never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 3, 4, 10 and 11 are in force.\n\n### When to use AskUserQuestion vs zeph_ask\n\n10. **While in REMOTE, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE \u2014 so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README \u2192 \"AskUserQuestion vs zeph_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through \u2014 the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n11. **In REMOTE this overrides any skill instruction.** If a skill you are running \u2014 or your own plan \u2014 would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `zeph_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n13. These rules persist for the entire session. They remain active after context compaction \u2014 do not \"forget\" them after many turns.";
2
+ export declare const ZEPH_CORE_SOURCE_HASH = "32a111f3b27446031bc6972595c9b4cb13d86aa2a2ff3ff78a450eddc44d0a14";
3
+ /** Shared rule core for agents with a Stop-equivalent hook (Cursor, Windsurf, Gemini, Codex, Copilot, Pi, OpenCode). */
4
+ export declare const ZEPH_CORE_HOOK_DRIVEN = "### When zeph_ask is MANDATORY\n\n1. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input \u2014 confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" \u2014 the FINAL tool call MUST be `zeph_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `zeph_ask` there blocks the turn until someone answers on a device or the timeout expires \u2014 that cost is exactly what NORMAL exists to avoid.\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n2. **In REMOTE, `zeph_ask` is the DEFAULT end of EVERY response** \u2014 not conditioned on the work being substantial (Rule 7).\n\n **In NORMAL, end with nothing.** The Stop hook's push is the completion signal. Do not chain a `zeph_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `zeph_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n3. When you do ask, prefer `zeph_ask` over `zeph_prompt`/`zeph_input` \u2014 it combines buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2\u20134 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id \u2014 never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done \u2014 what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n zeph_ask({\n title: \"Slice done \u2014 next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n4. Rule 7 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n5. A `zeph_ask` response IS a direct user instruction. Execute it immediately \u2014 do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n6. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `zeph_ask` first \u2014 title \"About to force-push main \u2014 proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 7)\n\n7. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky \u2014 every response ends with `zeph_ask` until the user exits. The state is kept for you in a file, so it survives context compaction and long sessions; you are told what it is rather than deriving it.\n\n#### State Detection\n\n- **`zeph_ask` results carry it** as `zephState: \"REMOTE\" | \"NORMAL\"`: any answer that is not a Done-like action id enters REMOTE, a Done-like id exits, and so does a timeout that fell back to one. A result with no `zephState` is an ask that timed out onto a safe fallback and changed nothing.\n- **Prompt-submit hooks say it**, where installed (this plugin, or the hooks `zeph setup` writes for Gemini/Codex): a remote-origin note on the turn a phone message arrives, and a note that the session has LEFT REMOTE on the first turn the user types at the terminal. A phone answer to a `zeph_ask` comes back as a `tool_result` and never reaches a prompt hook, so a prompt with no phone marker is demonstrably the user's own keyboard \u2014 staying in REMOTE would answer the terminal with a phone loop, and re-entry costs them one message from the phone.\n- **Neither one present \u2192 NORMAL.**\n\n**The one call left to you is free text**, because it is the one signal no hook can read: the server cannot tell \"run the tests\" from \"thanks, that's it\". When the user's typed answer clearly closes the loop \u2014 an obvious wrap-up, or `done`/`stop`/`exit` as a standalone word (not a substring: \"redo\" is not \"done\") \u2014 flip to NORMAL from that response on, don't send `zeph_ask` on it, and emit `<!-- zeph: exit -->` once so the hooks agree with you. Your own flip is what ends the loop; the marker is how you tell a hook that cannot read your mind, and it is separate from any push-volume marker, which says nothing about the mode.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`, carrying 2\u20134 `actions` (the next-step candidates as buttons) plus a Done-like `fallback` \u2014 never a destructive one, since an unanswered ask resolves to it \u2014 a text-only ask is only for inherently free-form answers. Non-negotiable while in REMOTE, independent of whether the work was substantial or routine.\n\nSet `timeout` 300\u2013600 s so silence degrades cleanly: an unanswered ask exits the loop quietly \u2014 the server treats a Done-like fallback as an exit \u2014 instead of chaining more notifications at a user who stepped away.\n\nFour things leave REMOTE: a Done-like button (or a timeout that fell back to one), your own read of a free-text wrap-up, a prompt the user typed at the terminal, and \u2014 for a session nobody exited because it crashed \u2014 the state expiring.\n\n#### Behavior in NORMAL (no zeph_ask is owed)\n\nThe user is at the terminal \u2014 that is what NORMAL means. Nothing here obliges an ask:\n\n- Questions go to `AskUserQuestion` or plain prose. The Ask hook still pushes them to the user's device, so a question is never lost.\n- Completion is the Stop hook's push.\n- `zeph_ask` remains available when you actively want an answer from their device \u2014 it is not owed, and never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 1, 2, 8 and 9 are in force.\n\n### When to use AskUserQuestion vs zeph_ask\n\n8. **While in REMOTE, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE \u2014 so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README \u2192 \"AskUserQuestion vs zeph_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through \u2014 the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n9. **In REMOTE this overrides any skill instruction.** If a skill you are running \u2014 or your own plan \u2014 would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `zeph_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n10. These rules persist for the entire session. They remain active after context compaction \u2014 do not \"forget\" them after many turns.";
5
5
  /** Shared rule core for rule-only agents without a Stop hook (Cline, Aider). */
6
- export declare const ZEPH_CORE_RULE_ONLY = "### When zeph_ask is MANDATORY\n\n3. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input \u2014 confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" \u2014 the FINAL tool call MUST be `zeph_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `zeph_ask` there blocks the turn until someone answers on a device or the timeout expires \u2014 that cost is exactly what NORMAL exists to avoid.\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n4. **In REMOTE, `zeph_ask` is the DEFAULT end of EVERY response** \u2014 not conditioned on the work being substantial (Rule 9).\n\n **In NORMAL, end with nothing.** The Stop hook's push \u2014 tuned by the user's dial and your Push Signal \u2014 is the completion signal. Do not chain a `zeph_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `zeph_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n5. When you do ask, prefer `zeph_ask` over `zeph_prompt`/`zeph_input` \u2014 it combines buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2\u20134 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id \u2014 never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done \u2014 what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n zeph_ask({\n title: \"Slice done \u2014 next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n6. Rule 9 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n7. A `zeph_ask` response IS a direct user instruction. Execute it immediately \u2014 do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n8. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `zeph_ask` first \u2014 title \"About to force-push main \u2014 proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 9)\n\n9. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky \u2014 every response ends with `zeph_ask` until the user exits. The state is kept for you in a file, so it survives context compaction and long sessions; you are told what it is rather than deriving it.\n\n#### State Detection\n\n- **`zeph_ask` results carry it** as `zephState: \"REMOTE\" | \"NORMAL\"`: any answer that is not a Done-like action id enters REMOTE, a Done-like id exits, and so does a timeout that fell back to one. A result with no `zephState` is an ask that timed out onto a safe fallback and changed nothing.\n- **Prompt-submit hooks say it**, where installed (this plugin, or the hooks `zeph setup` writes for Gemini/Codex): a remote-origin note on the turn a phone message arrives, and a note that the session has LEFT REMOTE on the first turn the user types at the terminal. A phone answer to a `zeph_ask` comes back as a `tool_result` and never reaches a prompt hook, so a prompt with no phone marker is demonstrably the user's own keyboard \u2014 staying in REMOTE would answer the terminal with a phone loop, and re-entry costs them one message from the phone.\n- **Neither one present \u2192 NORMAL.**\n\n**The one call left to you is free text**, because it is the one signal no hook can read: the server cannot tell \"run the tests\" from \"thanks, that's it\". When the user's typed answer clearly closes the loop \u2014 an obvious wrap-up, or `done`/`stop`/`exit` as a standalone word (not a substring: \"redo\" is not \"done\") \u2014 flip to NORMAL from that response on, don't send `zeph_ask` on it, and emit `<!-- zeph: exit -->` once so the hooks agree with you. Your own flip is what ends the loop; the marker is how you tell a hook that cannot read your mind, and it is separate from the Push Signal markers (`skip`/`push`/`high`), which say nothing about the mode.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`, carrying 2\u20134 `actions` (the next-step candidates as buttons) plus a Done-like `fallback` \u2014 never a destructive one, since an unanswered ask resolves to it \u2014 a text-only ask is only for inherently free-form answers. Non-negotiable while in REMOTE, independent of whether the work was substantial or routine.\n\nSet `timeout` 300\u2013600 s so silence degrades cleanly: an unanswered ask exits the loop quietly \u2014 the server treats a Done-like fallback as an exit \u2014 instead of chaining more notifications at a user who stepped away.\n\nFour things leave REMOTE: a Done-like button (or a timeout that fell back to one), your own read of a free-text wrap-up, a prompt the user typed at the terminal, and \u2014 for a session nobody exited because it crashed \u2014 the state expiring.\n\n#### Behavior in NORMAL (no zeph_ask is owed)\n\nThe user is at the terminal \u2014 that is what NORMAL means. Nothing here obliges an ask:\n\n- Questions go to `AskUserQuestion` or plain prose. The Ask hook still pushes them to the user's device, so a question is never lost.\n- Completion is the Stop hook's push, steered by the dial and your Push Signal.\n- `zeph_ask` remains available when you actively want an answer from their device \u2014 it is not owed, and never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 3, 4, 10 and 11 are in force.\n\n### When to use AskUserQuestion vs zeph_ask\n\n10. **While in REMOTE, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE \u2014 so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README \u2192 \"AskUserQuestion vs zeph_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through \u2014 the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n11. **In REMOTE this overrides any skill instruction.** If a skill you are running \u2014 or your own plan \u2014 would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `zeph_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n13. These rules persist for the entire session. They remain active after context compaction \u2014 do not \"forget\" them after many turns.";
6
+ export declare const ZEPH_CORE_RULE_ONLY = "### When zeph_ask is MANDATORY\n\n1. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input \u2014 confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" \u2014 the FINAL tool call MUST be `zeph_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `zeph_ask` there blocks the turn until someone answers on a device or the timeout expires \u2014 that cost is exactly what NORMAL exists to avoid.\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n2. **In REMOTE, `zeph_ask` is the DEFAULT end of EVERY response** \u2014 not conditioned on the work being substantial (Rule 7).\n\n **In NORMAL, end with nothing.** The Stop hook's push is the completion signal. Do not chain a `zeph_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `zeph_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n3. When you do ask, prefer `zeph_ask` over `zeph_prompt`/`zeph_input` \u2014 it combines buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2\u20134 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id \u2014 never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done \u2014 what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n zeph_ask({\n title: \"Slice done \u2014 next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n4. Rule 7 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n5. A `zeph_ask` response IS a direct user instruction. Execute it immediately \u2014 do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n6. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `zeph_ask` first \u2014 title \"About to force-push main \u2014 proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 7)\n\n7. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky \u2014 every response ends with `zeph_ask` until the user exits. The state is kept for you in a file, so it survives context compaction and long sessions; you are told what it is rather than deriving it.\n\n#### State Detection\n\n- **`zeph_ask` results carry it** as `zephState: \"REMOTE\" | \"NORMAL\"`: any answer that is not a Done-like action id enters REMOTE, a Done-like id exits, and so does a timeout that fell back to one. A result with no `zephState` is an ask that timed out onto a safe fallback and changed nothing.\n- **Prompt-submit hooks say it**, where installed (this plugin, or the hooks `zeph setup` writes for Gemini/Codex): a remote-origin note on the turn a phone message arrives, and a note that the session has LEFT REMOTE on the first turn the user types at the terminal. A phone answer to a `zeph_ask` comes back as a `tool_result` and never reaches a prompt hook, so a prompt with no phone marker is demonstrably the user's own keyboard \u2014 staying in REMOTE would answer the terminal with a phone loop, and re-entry costs them one message from the phone.\n- **Neither one present \u2192 NORMAL.**\n\n**The one call left to you is free text**, because it is the one signal no hook can read: the server cannot tell \"run the tests\" from \"thanks, that's it\". When the user's typed answer clearly closes the loop \u2014 an obvious wrap-up, or `done`/`stop`/`exit` as a standalone word (not a substring: \"redo\" is not \"done\") \u2014 flip to NORMAL from that response on, don't send `zeph_ask` on it, and emit `<!-- zeph: exit -->` once so the hooks agree with you. Your own flip is what ends the loop; the marker is how you tell a hook that cannot read your mind, and it is separate from any push-volume marker, which says nothing about the mode.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`, carrying 2\u20134 `actions` (the next-step candidates as buttons) plus a Done-like `fallback` \u2014 never a destructive one, since an unanswered ask resolves to it \u2014 a text-only ask is only for inherently free-form answers. Non-negotiable while in REMOTE, independent of whether the work was substantial or routine.\n\nSet `timeout` 300\u2013600 s so silence degrades cleanly: an unanswered ask exits the loop quietly \u2014 the server treats a Done-like fallback as an exit \u2014 instead of chaining more notifications at a user who stepped away.\n\nFour things leave REMOTE: a Done-like button (or a timeout that fell back to one), your own read of a free-text wrap-up, a prompt the user typed at the terminal, and \u2014 for a session nobody exited because it crashed \u2014 the state expiring.\n\n#### Behavior in NORMAL (no zeph_ask is owed)\n\nThe user is at the terminal \u2014 that is what NORMAL means. Nothing here obliges an ask:\n\n- Questions go to `AskUserQuestion` or plain prose. The Ask hook still pushes them to the user's device, so a question is never lost.\n- Completion is the Stop hook's push.\n- `zeph_ask` remains available when you actively want an answer from their device \u2014 it is not owed, and never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 1, 2, 8 and 9 are in force.\n\n### When to use AskUserQuestion vs zeph_ask\n\n8. **While in REMOTE, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE \u2014 so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README \u2192 \"AskUserQuestion vs zeph_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through \u2014 the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n9. **In REMOTE this overrides any skill instruction.** If a skill you are running \u2014 or your own plan \u2014 would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `zeph_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n10. These rules persist for the entire session. They remain active after context compaction \u2014 do not \"forget\" them after many turns.";
7
7
  //# sourceMappingURL=zeph-core.generated.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"zeph-core.generated.d.ts","sourceRoot":"","sources":["../src/zeph-core.generated.ts"],"names":[],"mappings":"AAMA,gFAAgF;AAChF,eAAO,MAAM,qBAAqB,qEAAqE,CAAC;AAExG,0GAA0G;AAC1G,eAAO,MAAM,qBAAqB,4gQAA61P,CAAC;AAEh4P,gFAAgF;AAChF,eAAO,MAAM,mBAAmB,4gQAA61P,CAAC"}
1
+ {"version":3,"file":"zeph-core.generated.d.ts","sourceRoot":"","sources":["../src/zeph-core.generated.ts"],"names":[],"mappings":"AAMA,gFAAgF;AAChF,eAAO,MAAM,qBAAqB,qEAAqE,CAAC;AAExG,wHAAwH;AACxH,eAAO,MAAM,qBAAqB,24PAAsuP,CAAC;AAEzwP,gFAAgF;AAChF,eAAO,MAAM,mBAAmB,24PAAsuP,CAAC"}
@@ -7,8 +7,8 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.ZEPH_CORE_RULE_ONLY = exports.ZEPH_CORE_HOOK_DRIVEN = exports.ZEPH_CORE_SOURCE_HASH = void 0;
9
9
  /** sha256 over the plugin manifest + extracted rule text at generation time. */
10
- exports.ZEPH_CORE_SOURCE_HASH = "1ab10b4a3d38dd8ffdb924056b775c16883579724edbd0ab5f25fe0a30191c02";
11
- /** Shared rule core for agents with a Stop-equivalent hook (Cursor, Windsurf, Gemini, Codex, Copilot). */
12
- exports.ZEPH_CORE_HOOK_DRIVEN = "### When zeph_ask is MANDATORY\n\n3. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input — confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" — the FINAL tool call MUST be `zeph_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `zeph_ask` there blocks the turn until someone answers on a device or the timeout expires — that cost is exactly what NORMAL exists to avoid.\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n4. **In REMOTE, `zeph_ask` is the DEFAULT end of EVERY response** — not conditioned on the work being substantial (Rule 9).\n\n **In NORMAL, end with nothing.** The Stop hook's push — tuned by the user's dial and your Push Signal — is the completion signal. Do not chain a `zeph_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `zeph_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n5. When you do ask, prefer `zeph_ask` over `zeph_prompt`/`zeph_input` — it combines buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2–4 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id — never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done — what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n zeph_ask({\n title: \"Slice done — next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n6. Rule 9 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n7. A `zeph_ask` response IS a direct user instruction. Execute it immediately — do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n8. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `zeph_ask` first — title \"About to force-push main — proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 9)\n\n9. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky — every response ends with `zeph_ask` until the user exits. The state is kept for you in a file, so it survives context compaction and long sessions; you are told what it is rather than deriving it.\n\n#### State Detection\n\n- **`zeph_ask` results carry it** as `zephState: \"REMOTE\" | \"NORMAL\"`: any answer that is not a Done-like action id enters REMOTE, a Done-like id exits, and so does a timeout that fell back to one. A result with no `zephState` is an ask that timed out onto a safe fallback and changed nothing.\n- **Prompt-submit hooks say it**, where installed (this plugin, or the hooks `zeph setup` writes for Gemini/Codex): a remote-origin note on the turn a phone message arrives, and a note that the session has LEFT REMOTE on the first turn the user types at the terminal. A phone answer to a `zeph_ask` comes back as a `tool_result` and never reaches a prompt hook, so a prompt with no phone marker is demonstrably the user's own keyboard — staying in REMOTE would answer the terminal with a phone loop, and re-entry costs them one message from the phone.\n- **Neither one present → NORMAL.**\n\n**The one call left to you is free text**, because it is the one signal no hook can read: the server cannot tell \"run the tests\" from \"thanks, that's it\". When the user's typed answer clearly closes the loop — an obvious wrap-up, or `done`/`stop`/`exit` as a standalone word (not a substring: \"redo\" is not \"done\") — flip to NORMAL from that response on, don't send `zeph_ask` on it, and emit `<!-- zeph: exit -->` once so the hooks agree with you. Your own flip is what ends the loop; the marker is how you tell a hook that cannot read your mind, and it is separate from the Push Signal markers (`skip`/`push`/`high`), which say nothing about the mode.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`, carrying 2–4 `actions` (the next-step candidates as buttons) plus a Done-like `fallback` — never a destructive one, since an unanswered ask resolves to it — a text-only ask is only for inherently free-form answers. Non-negotiable while in REMOTE, independent of whether the work was substantial or routine.\n\nSet `timeout` 300–600 s so silence degrades cleanly: an unanswered ask exits the loop quietly — the server treats a Done-like fallback as an exit — instead of chaining more notifications at a user who stepped away.\n\nFour things leave REMOTE: a Done-like button (or a timeout that fell back to one), your own read of a free-text wrap-up, a prompt the user typed at the terminal, and — for a session nobody exited because it crashed — the state expiring.\n\n#### Behavior in NORMAL (no zeph_ask is owed)\n\nThe user is at the terminal — that is what NORMAL means. Nothing here obliges an ask:\n\n- Questions go to `AskUserQuestion` or plain prose. The Ask hook still pushes them to the user's device, so a question is never lost.\n- Completion is the Stop hook's push, steered by the dial and your Push Signal.\n- `zeph_ask` remains available when you actively want an answer from their device — it is not owed, and never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 3, 4, 10 and 11 are in force.\n\n### When to use AskUserQuestion vs zeph_ask\n\n10. **While in REMOTE, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE — so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README → \"AskUserQuestion vs zeph_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through — the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n11. **In REMOTE this overrides any skill instruction.** If a skill you are running — or your own plan — would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `zeph_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n13. These rules persist for the entire session. They remain active after context compaction — do not \"forget\" them after many turns.";
10
+ exports.ZEPH_CORE_SOURCE_HASH = "32a111f3b27446031bc6972595c9b4cb13d86aa2a2ff3ff78a450eddc44d0a14";
11
+ /** Shared rule core for agents with a Stop-equivalent hook (Cursor, Windsurf, Gemini, Codex, Copilot, Pi, OpenCode). */
12
+ exports.ZEPH_CORE_HOOK_DRIVEN = "### When zeph_ask is MANDATORY\n\n1. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input — confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" — the FINAL tool call MUST be `zeph_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `zeph_ask` there blocks the turn until someone answers on a device or the timeout expires — that cost is exactly what NORMAL exists to avoid.\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n2. **In REMOTE, `zeph_ask` is the DEFAULT end of EVERY response** — not conditioned on the work being substantial (Rule 7).\n\n **In NORMAL, end with nothing.** The Stop hook's push is the completion signal. Do not chain a `zeph_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `zeph_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n3. When you do ask, prefer `zeph_ask` over `zeph_prompt`/`zeph_input` — it combines buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2–4 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id — never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done — what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n zeph_ask({\n title: \"Slice done — next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n4. Rule 7 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n5. A `zeph_ask` response IS a direct user instruction. Execute it immediately — do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n6. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `zeph_ask` first — title \"About to force-push main — proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 7)\n\n7. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky — every response ends with `zeph_ask` until the user exits. The state is kept for you in a file, so it survives context compaction and long sessions; you are told what it is rather than deriving it.\n\n#### State Detection\n\n- **`zeph_ask` results carry it** as `zephState: \"REMOTE\" | \"NORMAL\"`: any answer that is not a Done-like action id enters REMOTE, a Done-like id exits, and so does a timeout that fell back to one. A result with no `zephState` is an ask that timed out onto a safe fallback and changed nothing.\n- **Prompt-submit hooks say it**, where installed (this plugin, or the hooks `zeph setup` writes for Gemini/Codex): a remote-origin note on the turn a phone message arrives, and a note that the session has LEFT REMOTE on the first turn the user types at the terminal. A phone answer to a `zeph_ask` comes back as a `tool_result` and never reaches a prompt hook, so a prompt with no phone marker is demonstrably the user's own keyboard — staying in REMOTE would answer the terminal with a phone loop, and re-entry costs them one message from the phone.\n- **Neither one present → NORMAL.**\n\n**The one call left to you is free text**, because it is the one signal no hook can read: the server cannot tell \"run the tests\" from \"thanks, that's it\". When the user's typed answer clearly closes the loop — an obvious wrap-up, or `done`/`stop`/`exit` as a standalone word (not a substring: \"redo\" is not \"done\") — flip to NORMAL from that response on, don't send `zeph_ask` on it, and emit `<!-- zeph: exit -->` once so the hooks agree with you. Your own flip is what ends the loop; the marker is how you tell a hook that cannot read your mind, and it is separate from any push-volume marker, which says nothing about the mode.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`, carrying 2–4 `actions` (the next-step candidates as buttons) plus a Done-like `fallback` — never a destructive one, since an unanswered ask resolves to it — a text-only ask is only for inherently free-form answers. Non-negotiable while in REMOTE, independent of whether the work was substantial or routine.\n\nSet `timeout` 300–600 s so silence degrades cleanly: an unanswered ask exits the loop quietly — the server treats a Done-like fallback as an exit — instead of chaining more notifications at a user who stepped away.\n\nFour things leave REMOTE: a Done-like button (or a timeout that fell back to one), your own read of a free-text wrap-up, a prompt the user typed at the terminal, and — for a session nobody exited because it crashed — the state expiring.\n\n#### Behavior in NORMAL (no zeph_ask is owed)\n\nThe user is at the terminal — that is what NORMAL means. Nothing here obliges an ask:\n\n- Questions go to `AskUserQuestion` or plain prose. The Ask hook still pushes them to the user's device, so a question is never lost.\n- Completion is the Stop hook's push.\n- `zeph_ask` remains available when you actively want an answer from their device — it is not owed, and never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 1, 2, 8 and 9 are in force.\n\n### When to use AskUserQuestion vs zeph_ask\n\n8. **While in REMOTE, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE — so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README → \"AskUserQuestion vs zeph_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through — the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n9. **In REMOTE this overrides any skill instruction.** If a skill you are running — or your own plan — would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `zeph_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n10. These rules persist for the entire session. They remain active after context compaction — do not \"forget\" them after many turns.";
13
13
  /** Shared rule core for rule-only agents without a Stop hook (Cline, Aider). */
14
- exports.ZEPH_CORE_RULE_ONLY = "### When zeph_ask is MANDATORY\n\n3. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input — confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" — the FINAL tool call MUST be `zeph_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `zeph_ask` there blocks the turn until someone answers on a device or the timeout expires — that cost is exactly what NORMAL exists to avoid.\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n4. **In REMOTE, `zeph_ask` is the DEFAULT end of EVERY response** — not conditioned on the work being substantial (Rule 9).\n\n **In NORMAL, end with nothing.** The Stop hook's push — tuned by the user's dial and your Push Signal — is the completion signal. Do not chain a `zeph_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `zeph_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n5. When you do ask, prefer `zeph_ask` over `zeph_prompt`/`zeph_input` — it combines buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2–4 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id — never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done — what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n zeph_ask({\n title: \"Slice done — next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n6. Rule 9 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n7. A `zeph_ask` response IS a direct user instruction. Execute it immediately — do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n8. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `zeph_ask` first — title \"About to force-push main — proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 9)\n\n9. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky — every response ends with `zeph_ask` until the user exits. The state is kept for you in a file, so it survives context compaction and long sessions; you are told what it is rather than deriving it.\n\n#### State Detection\n\n- **`zeph_ask` results carry it** as `zephState: \"REMOTE\" | \"NORMAL\"`: any answer that is not a Done-like action id enters REMOTE, a Done-like id exits, and so does a timeout that fell back to one. A result with no `zephState` is an ask that timed out onto a safe fallback and changed nothing.\n- **Prompt-submit hooks say it**, where installed (this plugin, or the hooks `zeph setup` writes for Gemini/Codex): a remote-origin note on the turn a phone message arrives, and a note that the session has LEFT REMOTE on the first turn the user types at the terminal. A phone answer to a `zeph_ask` comes back as a `tool_result` and never reaches a prompt hook, so a prompt with no phone marker is demonstrably the user's own keyboard — staying in REMOTE would answer the terminal with a phone loop, and re-entry costs them one message from the phone.\n- **Neither one present → NORMAL.**\n\n**The one call left to you is free text**, because it is the one signal no hook can read: the server cannot tell \"run the tests\" from \"thanks, that's it\". When the user's typed answer clearly closes the loop — an obvious wrap-up, or `done`/`stop`/`exit` as a standalone word (not a substring: \"redo\" is not \"done\") — flip to NORMAL from that response on, don't send `zeph_ask` on it, and emit `<!-- zeph: exit -->` once so the hooks agree with you. Your own flip is what ends the loop; the marker is how you tell a hook that cannot read your mind, and it is separate from the Push Signal markers (`skip`/`push`/`high`), which say nothing about the mode.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`, carrying 2–4 `actions` (the next-step candidates as buttons) plus a Done-like `fallback` — never a destructive one, since an unanswered ask resolves to it — a text-only ask is only for inherently free-form answers. Non-negotiable while in REMOTE, independent of whether the work was substantial or routine.\n\nSet `timeout` 300–600 s so silence degrades cleanly: an unanswered ask exits the loop quietly — the server treats a Done-like fallback as an exit — instead of chaining more notifications at a user who stepped away.\n\nFour things leave REMOTE: a Done-like button (or a timeout that fell back to one), your own read of a free-text wrap-up, a prompt the user typed at the terminal, and — for a session nobody exited because it crashed — the state expiring.\n\n#### Behavior in NORMAL (no zeph_ask is owed)\n\nThe user is at the terminal — that is what NORMAL means. Nothing here obliges an ask:\n\n- Questions go to `AskUserQuestion` or plain prose. The Ask hook still pushes them to the user's device, so a question is never lost.\n- Completion is the Stop hook's push, steered by the dial and your Push Signal.\n- `zeph_ask` remains available when you actively want an answer from their device — it is not owed, and never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 3, 4, 10 and 11 are in force.\n\n### When to use AskUserQuestion vs zeph_ask\n\n10. **While in REMOTE, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE — so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README → \"AskUserQuestion vs zeph_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through — the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n11. **In REMOTE this overrides any skill instruction.** If a skill you are running — or your own plan — would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `zeph_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n13. These rules persist for the entire session. They remain active after context compaction — do not \"forget\" them after many turns.";
14
+ exports.ZEPH_CORE_RULE_ONLY = "### When zeph_ask is MANDATORY\n\n1. **While in REMOTE, NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input — confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" — the FINAL tool call MUST be `zeph_ask`. A \"?\" written in your reply is invisible to a user on their phone. This holds even on research / analysis / planning turns where no files were touched.\n\n **In NORMAL it does not apply.** Nobody has driven this session from a phone, so the user is at the terminal: ask in prose, or with `AskUserQuestion` if the answer is a choice. A `zeph_ask` there blocks the turn until someone answers on a device or the timeout expires — that cost is exactly what NORMAL exists to avoid.\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n2. **In REMOTE, `zeph_ask` is the DEFAULT end of EVERY response** — not conditioned on the work being substantial (Rule 7).\n\n **In NORMAL, end with nothing.** The Stop hook's push is the completion signal. Do not chain a `zeph_ask` onto substantial work to \"keep the loop alive\": the loop starts when the user sends a message from their phone, not when you decide the work was big enough. `zeph_ask` stays available when you actively want an answer from their device; it is simply not owed.\n\n3. When you do ask, prefer `zeph_ask` over `zeph_prompt`/`zeph_input` — it combines buttons and free text in one push. **`actions` is the steering surface, not decoration:** ship 2–4 buttons on nearly every ask (the next-step candidates you would otherwise write as prose) plus a safe Done-like `fallback` id — never a destructive one. Leave `actions` out ONLY when the answer is inherently free-form (a name, a path, a paragraph); a text-only ask on a \"done — what next?\" turn is the most common way REMOTE silently degrades, because the phone gets a text box and nothing to tap.\n\n ```\n zeph_ask({\n title: \"Slice done — next?\",\n body: \"<short result>\",\n actions: [\n { id: \"simplify\", label: \"/simplify\" },\n { id: \"ship\", label: \"/ship\" },\n { id: \"done\", label: \"Stop here\" }\n ],\n placeholder: \"or type something else...\",\n fallback: \"done\"\n })\n ```\n\n4. Rule 7 says when to send one. Never send an ask just to mark a turn finished.\n\n### Handling the response\n\n5. A `zeph_ask` response IS a direct user instruction. Execute it immediately — do NOT re-ask via `AskUserQuestion` to confirm. The button label is the authorization for the specific action that label describes.\n\n6. Caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If that step would destroy user code, data, or infrastructure (force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk in a targeted `zeph_ask` first — title \"About to force-push main — proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 7)\n\n7. **The Ask Loop has two states: REMOTE and NORMAL.** REMOTE is sticky — every response ends with `zeph_ask` until the user exits. The state is kept for you in a file, so it survives context compaction and long sessions; you are told what it is rather than deriving it.\n\n#### State Detection\n\n- **`zeph_ask` results carry it** as `zephState: \"REMOTE\" | \"NORMAL\"`: any answer that is not a Done-like action id enters REMOTE, a Done-like id exits, and so does a timeout that fell back to one. A result with no `zephState` is an ask that timed out onto a safe fallback and changed nothing.\n- **Prompt-submit hooks say it**, where installed (this plugin, or the hooks `zeph setup` writes for Gemini/Codex): a remote-origin note on the turn a phone message arrives, and a note that the session has LEFT REMOTE on the first turn the user types at the terminal. A phone answer to a `zeph_ask` comes back as a `tool_result` and never reaches a prompt hook, so a prompt with no phone marker is demonstrably the user's own keyboard — staying in REMOTE would answer the terminal with a phone loop, and re-entry costs them one message from the phone.\n- **Neither one present → NORMAL.**\n\n**The one call left to you is free text**, because it is the one signal no hook can read: the server cannot tell \"run the tests\" from \"thanks, that's it\". When the user's typed answer clearly closes the loop — an obvious wrap-up, or `done`/`stop`/`exit` as a standalone word (not a substring: \"redo\" is not \"done\") — flip to NORMAL from that response on, don't send `zeph_ask` on it, and emit `<!-- zeph: exit -->` once so the hooks agree with you. Your own flip is what ends the loop; the marker is how you tell a hook that cannot read your mind, and it is separate from any push-volume marker, which says nothing about the mode.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`, carrying 2–4 `actions` (the next-step candidates as buttons) plus a Done-like `fallback` — never a destructive one, since an unanswered ask resolves to it — a text-only ask is only for inherently free-form answers. Non-negotiable while in REMOTE, independent of whether the work was substantial or routine.\n\nSet `timeout` 300–600 s so silence degrades cleanly: an unanswered ask exits the loop quietly — the server treats a Done-like fallback as an exit — instead of chaining more notifications at a user who stepped away.\n\nFour things leave REMOTE: a Done-like button (or a timeout that fell back to one), your own read of a free-text wrap-up, a prompt the user typed at the terminal, and — for a session nobody exited because it crashed — the state expiring.\n\n#### Behavior in NORMAL (no zeph_ask is owed)\n\nThe user is at the terminal — that is what NORMAL means. Nothing here obliges an ask:\n\n- Questions go to `AskUserQuestion` or plain prose. The Ask hook still pushes them to the user's device, so a question is never lost.\n- Completion is the Stop hook's push.\n- `zeph_ask` remains available when you actively want an answer from their device — it is not owed, and never as a way to mark a turn finished.\n\nREMOTE begins the moment the user sends a message from their phone; from that turn on, Rules 1, 2, 8 and 9 are in force.\n\n### When to use AskUserQuestion vs zeph_ask\n\n8. **While in REMOTE, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion`.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"proceed?\"). `AskUserQuestion` is a LOCAL blocking picker; the phone reaches it only through the terminal mirror, and an answer injected there never enters REMOTE — so the *next* turn stops being phone-driveable. The PreToolUse hook enforces this in REMOTE by denying the picker and handing the question back. (Why the mirror is the worse channel on every axis: README → \"AskUserQuestion vs zeph_ask\".)\n\n **In NORMAL the picker is the right tool** and the hook lets it through — the user is at the terminal, and the Ask hook still pushes the question to their device so they know one is waiting.\n\n9. **In REMOTE this overrides any skill instruction.** If a skill you are running — or your own plan — would call `AskUserQuestion` with a button-friendly question, surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Fall through to the picker ONLY when (a) the answer needs code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. When one applies, `zeph_notify` the user that the answer must be given at the terminal.\n\n### Persistence\n\n10. These rules persist for the entire session. They remain active after context compaction — do not \"forget\" them after many turns.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/cli",
3
- "version": "2.8.2",
3
+ "version": "2.10.0",
4
4
  "description": "Zeph CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -30,6 +30,7 @@
30
30
  "devDependencies": {
31
31
  "@types/node": "^22.0.0",
32
32
  "@types/ws": "^8.18.1",
33
+ "esbuild": "^0.21.5",
33
34
  "typescript": "^5.8.0",
34
35
  "vitest": "^2.1.9"
35
36
  },