@zeph-to/cli 2.16.0 → 2.17.1

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
@@ -154,7 +154,10 @@ other way in: a `zeph_ask` answer that is not a Done-like button. Their rule
154
154
  files therefore keep the after-real-work `zeph_ask` — the "Entering REMOTE
155
155
  without a prompt hook" preamble in `src/templates.ts` — so the phone always
156
156
  has a button to tap that starts the loop. Agents with the hook drop that
157
- obligation while at the terminal.
157
+ obligation while at the terminal — and their rule file carries only the
158
+ NORMAL branch of the core (about a quarter of it), since it is read on every
159
+ turn; the hook injects the REMOTE sections in full on the turn a phone
160
+ message enters REMOTE, the way the Claude Code plugin's hooks do.
158
161
 
159
162
  `zeph cursor` has no remote-origin hook, so it never enters sticky REMOTE
160
163
  mode by itself. **Just ask for it** — one line, once per session:
@@ -664,6 +667,28 @@ others cannot: a turn with no tool calls, a turn with exactly one (a
664
667
  lone edit included — the gate wants two), and a turn whose calls were
665
668
  all reads. `/zeph-loud` still pushes on all of them.
666
669
 
670
+ The Pi extension also pushes when pi is **waiting on you**: any blocking
671
+ extension dialog — a bash guard's Run/Abort, an ask-user tool, a
672
+ `ctx.ui.confirm` — that is still open 10 seconds after it appeared sends a
673
+ `high` push ("pi asks: <project>", body = the dialog title or the guarded
674
+ bash command). `high` gets through `quiet`; only `/zeph-mute` stops it.
675
+ Answering inside the 10 seconds sends nothing. It is the pi twin of the
676
+ Claude Code plugin's AskUserQuestion push, driven by pi's
677
+ `ui_prompt_start` / `ui_prompt_end` events. Only dialogs that open while a
678
+ turn is running count: a settings picker you open yourself between turns
679
+ (`/caveman config`) never pushes.
680
+
681
+ Two more end-of-turn cases differ from Claude Code:
682
+
683
+ - **Push Signal markers** — pi's rules teach the same `<!-- zeph: skip|push|high -->`
684
+ markers, and the extension reads them from the final assistant message
685
+ (then strips them, since pi's terminal would otherwise print the comment).
686
+ `high` is how pi gets a "the turn ended with a question for you" push
687
+ through `quiet`.
688
+ - **Errors and aborts** — a turn that ended on a provider error sends a
689
+ `high` "pi stopped: <project>" push with the error text instead of
690
+ "Task done"; a turn you aborted with Esc sends nothing.
691
+
667
692
  #### Quiet while away
668
693
 
669
694
  `quiet` silences routine pushes because you are watching the pane. When
@@ -789,7 +814,7 @@ try {
789
814
  | Copilot CLI | Session end hook + rules |
790
815
  | Cline | Rules file (`~/.cline/rules/zeph.md`) |
791
816
  | Aider | Conventions file + `read:` directive in `~/.aider.conf.yml` |
792
- | Pi | Extension (settle hook + remote detection) + rules — no MCP (pi has none; rules map zeph tools to the CLI) |
817
+ | Pi | Extension (settle hook + waiting-on-you push + remote detection) + rules — no MCP (pi has none; rules map zeph tools to the CLI) |
793
818
  | OpenCode | MCP server + idle-notify plugin + rules |
794
819
 
795
820
  For remote-control via `zeph listener` the per-agent setup is the same
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Slice one `### ` section out of a generated rule core (zeph-core.generated.ts).
3
+ *
4
+ * The core is one string per audience, assembled upstream from
5
+ * plugin/docs/CORE_RULES.md. Two consumers need single sections of it:
6
+ * templates.ts, which builds the NORMAL-only rule file for agents whose
7
+ * prompt-submit hook can announce REMOTE, and remote-hook.ts, which injects
8
+ * the REMOTE sections on the turn that enters it — the twin of the plugin's
9
+ * zeph-remote.sh reading CORE_RULES.md on entry.
10
+ *
11
+ * Headings are matched by title prefix (`Sticky REMOTE mode`), not by the
12
+ * `(Rule N)` suffix: the extractor renumbers per audience, so the number is
13
+ * not stable across cores. A heading the core does not carry throws — every
14
+ * caller passes a literal, so the throw surfaces at import time in the tests
15
+ * rather than as a silently empty rule file.
16
+ */
17
+ export declare const coreSection: (core: string, heading: string) => string;
18
+ //# sourceMappingURL=core-sections.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"core-sections.d.ts","sourceRoot":"","sources":["../src/core-sections.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,EAAE,SAAS,MAAM,KAAG,MAO3D,CAAC"}
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.coreSection = void 0;
4
+ /**
5
+ * Slice one `### ` section out of a generated rule core (zeph-core.generated.ts).
6
+ *
7
+ * The core is one string per audience, assembled upstream from
8
+ * plugin/docs/CORE_RULES.md. Two consumers need single sections of it:
9
+ * templates.ts, which builds the NORMAL-only rule file for agents whose
10
+ * prompt-submit hook can announce REMOTE, and remote-hook.ts, which injects
11
+ * the REMOTE sections on the turn that enters it — the twin of the plugin's
12
+ * zeph-remote.sh reading CORE_RULES.md on entry.
13
+ *
14
+ * Headings are matched by title prefix (`Sticky REMOTE mode`), not by the
15
+ * `(Rule N)` suffix: the extractor renumbers per audience, so the number is
16
+ * not stable across cores. A heading the core does not carry throws — every
17
+ * caller passes a literal, so the throw surfaces at import time in the tests
18
+ * rather than as a silently empty rule file.
19
+ */
20
+ const coreSection = (core, heading) => {
21
+ const sections = core.split(/^(?=### )/m).map((s) => s.trim());
22
+ const found = sections.filter((s) => s.startsWith(`### ${heading}`));
23
+ if (found.length !== 1) {
24
+ throw new Error(`core section "${heading}" ${found.length ? 'is ambiguous' : 'not found'} in the generated core`);
25
+ }
26
+ return found[0];
27
+ };
28
+ exports.coreSection = coreSection;
@@ -2,6 +2,7 @@
2
2
  * NOT here — its hook ships with the plugin (hooks/zeph-remote.sh). */
3
3
  export declare const REMOTE_HOOK_AGENTS: readonly ["gemini", "codex", "pi"];
4
4
  export type RemoteHookAgent = (typeof REMOTE_HOOK_AGENTS)[number];
5
+ export declare const remoteEntrySections: () => string;
5
6
  export declare const isRemoteHookAgent: (raw: string) => raw is RemoteHookAgent;
6
7
  /**
7
8
  * Core of the hook: match the stdin payload against the marker and return
@@ -1 +1 @@
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"}
1
+ {"version":3,"file":"remote-hook.d.ts","sourceRoot":"","sources":["../src/remote-hook.ts"],"names":[],"mappings":"AA4CA;wEACwE;AACxE,eAAO,MAAM,kBAAkB,oCAAqC,CAAC;AACrE,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AAkClE,eAAO,MAAM,mBAAmB,QAAO,MAGtB,CAAC;AAgBlB,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,IA2CX,CAAC"}
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runRemoteHook = exports.isRemoteHookAgent = exports.REMOTE_HOOK_AGENTS = void 0;
3
+ exports.runRemoteHook = exports.isRemoteHookAgent = exports.remoteEntrySections = exports.REMOTE_HOOK_AGENTS = void 0;
4
4
  /**
5
5
  * `zeph remote-hook <agent>` — prompt-submit hook handler for agents whose
6
6
  * hooks the cli installs directly: Gemini CLI (BeforeAgent), Codex CLI
@@ -33,6 +33,8 @@ exports.runRemoteHook = exports.isRemoteHookAgent = exports.REMOTE_HOOK_AGENTS =
33
33
  */
34
34
  const fs_1 = require("fs");
35
35
  const config_js_1 = require("./config.js");
36
+ const core_sections_js_1 = require("./core-sections.js");
37
+ const zeph_core_generated_js_1 = require("./zeph-core.generated.js");
36
38
  const gate_js_1 = require("./gate.js");
37
39
  /** Agents whose prompt-submit hooks this command serves. Claude Code is
38
40
  * NOT here — its hook ships with the plugin (hooks/zeph-remote.sh). */
@@ -55,6 +57,21 @@ const FRESH_WINDOW_SEC = 900;
55
57
  const TWO_WAY_CONTEXT = `# System note (Zeph remote-origin detect)
56
58
 
57
59
  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.`;
60
+ // The static rule file of these agents carries the NORMAL branch only
61
+ // (templates.ts PROMPT_HOOK_CORE), so the turn that enters REMOTE is where the
62
+ // contract has to arrive in full — the twin of plugin/hooks/zeph-remote.sh
63
+ // reading CORE_RULES.md on entry. Read from the generated core rather than
64
+ // restated here: a copy would be one more place for the rule to drift. The
65
+ // four sections together cite only rules they carry (1–4, 7–9), so no
66
+ // cross-reference dangles; remote-hook.test.ts checks that. Sent once per
67
+ // entry: a later phone prompt on a session already in REMOTE gets the note
68
+ // alone, since the contract is already in its context. Sliced on that turn
69
+ // only — this command runs on every prompt submit, and the common path is a
70
+ // silent no-op. Exported for the tests.
71
+ const remoteEntrySections = () => ['When zeph_ask is MANDATORY', 'When zeph_ask is the DEFAULT', 'Sticky REMOTE mode', 'When to use AskUserQuestion vs zeph_ask']
72
+ .map((heading) => (0, core_sections_js_1.coreSection)(zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN, heading))
73
+ .join('\n\n');
74
+ exports.remoteEntrySections = remoteEntrySections;
58
75
  const ONE_WAY_CONTEXT = `# System note (Zeph remote-origin detect)
59
76
 
60
77
  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).`;
@@ -105,8 +122,9 @@ const runRemoteHook = (agent, stdin, env = process.env, now = Date.now) => {
105
122
  return emit(ONE_WAY_CONTEXT);
106
123
  // Only a two-way session has a mode to stay in — without zeph_ask there is
107
124
  // nothing for a later turn to be reminded of, so no state is recorded.
125
+ const entering = !(0, gate_js_1.isRemoteActive)(cwd, now);
108
126
  (0, gate_js_1.touchRemoteActive)(cwd, now);
109
- return emit(TWO_WAY_CONTEXT);
127
+ return emit(entering ? `${TWO_WAY_CONTEXT}\n\n${(0, exports.remoteEntrySections)()}` : TWO_WAY_CONTEXT);
110
128
  }
111
129
  if (origin === 'keyboard' && (0, gate_js_1.isRemoteActive)(cwd, now) && (0, config_js_1.resolveHookId)(env)) {
112
130
  (0, gate_js_1.clearRemoteActive)(cwd);
@@ -1,10 +1,12 @@
1
+ /** Grace before the pi extension turns an unanswered prompt into a push. Exported for the tests. */
2
+ export declare const PI_PROMPT_GRACE_MS = 10000;
1
3
  /** Cursor — written to ~/.cursor/rules/zeph.mdc (needs .mdc frontmatter). No prompt hook. */
2
4
  export declare const CURSOR_RULE: string;
3
5
  /** Windsurf — appended into ~/.codeium/windsurf/memories/global_rules.md. No prompt hook. */
4
6
  export declare const WINDSURF_RULE: string;
5
- /** Gemini CLI — appended into ~/.gemini/GEMINI.md. Has the prompt hook (GEMINI_HOOKS). */
7
+ /** Gemini CLI — appended into ~/.gemini/GEMINI.md. Has the prompt hook (GEMINI_HOOKS), so NORMAL-only core. */
6
8
  export declare const GEMINI_RULE: string;
7
- /** Codex CLI — appended into ~/.codex/AGENTS.md. Has the prompt hook (CODEX_HOOKS). */
9
+ /** Codex CLI — appended into ~/.codex/AGENTS.md. Has the prompt hook (CODEX_HOOKS), so NORMAL-only core. */
8
10
  export declare const CODEX_RULE: string;
9
11
  /** GitHub Copilot CLI — written to ~/.copilot/instructions/zeph.instructions.md. No prompt hook. */
10
12
  export declare const COPILOT_RULE: string;
@@ -12,7 +14,7 @@ export declare const COPILOT_RULE: string;
12
14
  export declare const CLINE_RULE: string;
13
15
  /** Aider — standalone conventions file via .aider.conf.yml `read:` (no Stop hook, no prompt hook). */
14
16
  export declare const AIDER_RULE: string;
15
- /** Pi — managed block in ~/.pi/agent/AGENTS.md. Extension = Stop-equivalent + prompt hook (PI_EXTENSION). */
17
+ /** Pi — managed block in ~/.pi/agent/AGENTS.md. Extension = Stop-equivalent + prompt hook + waiting-on-you push (PI_EXTENSION), so NORMAL-only core. */
16
18
  export declare const PI_RULE: string;
17
19
  /** OpenCode — managed block in ~/.config/opencode/AGENTS.md. Stop hook via plugin, no prompt hook (v1). */
18
20
  export declare const OPENCODE_RULE: string;
@@ -1 +1 @@
1
- {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AA6KA,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"}
1
+ {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AAwGA,oGAAoG;AACpG,eAAO,MAAM,kBAAkB,QAAS,CAAC;AA8IzC,6FAA6F;AAC7F,eAAO,MAAM,WAAW,QAKtB,CAAC;AAEH,6FAA6F;AAC7F,eAAO,MAAM,aAAa,QAIxB,CAAC;AAEH,+GAA+G;AAC/G,eAAO,MAAM,WAAW,QAAoE,CAAC;AAE7F,4GAA4G;AAC5G,eAAO,MAAM,UAAU,QAAoE,CAAC;AAE5F,oGAAoG;AACpG,eAAO,MAAM,YAAY,QAIvB,CAAC;AAEH,gFAAgF;AAChF,eAAO,MAAM,UAAU,QAIrB,CAAC;AAEH,sGAAsG;AACtG,eAAO,MAAM,UAAU,QAIrB,CAAC;AAEH,wJAAwJ;AACxJ,eAAO,MAAM,OAAO,QAKlB,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,QAgJxB,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
@@ -23,11 +23,20 @@
23
23
  // ask itself — so for them the NORMAL branch must still send one after real
24
24
  // work, or the phone loop can never begin. That is REMOTE_ENTRY_NO_HOOK.
25
25
  //
26
+ // The same axis decides how much of the core the static rule file carries.
27
+ // That file is read on every turn, and a session is NORMAL nearly all of the
28
+ // time, so for the three hook agents it carries the NORMAL branch only
29
+ // (PROMPT_HOOK_CORE) — the REMOTE sections arrive from the hook on the turn
30
+ // that enters REMOTE (remote-hook.ts remoteEntrySections), the way the
31
+ // plugin's SessionStart / UserPromptSubmit pair does it for Claude Code. The
32
+ // hook-less agents have no such turn, so their file keeps the whole core.
33
+ //
26
34
  // Keeping this in one place means a rule change lands everywhere at once
27
35
  // and the agents can't drift apart.
28
36
  Object.defineProperty(exports, "__esModule", { value: true });
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;
37
+ 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 = exports.PI_PROMPT_GRACE_MS = void 0;
30
38
  const gate_js_1 = require("./gate.js");
39
+ const core_sections_js_1 = require("./core-sections.js");
31
40
  const zeph_core_generated_js_1 = require("./zeph-core.generated.js");
32
41
  // Graceful resolution: prefer the installed `zeph` CLI, but fall back to
33
42
  // `npx -y @zeph-to/cli` so the hook still fires when the user
@@ -73,6 +82,19 @@ const TURN_FACT_FLAGS = ` --${gate_js_1.TOOL_COUNT_FLAG} \${tools} --${gate_js_1
73
82
  // stderr is discarded; `|| true` keeps a broken install from ever blocking
74
83
  // a prompt.
75
84
  const remoteHookCmd = (agent) => `$(command -v zeph || echo "npx -y @zeph-to/cli") remote-hook ${agent} 2>/dev/null || true`;
85
+ // One-off `high` push from the pi extension — a prompt left waiting (the twin
86
+ // of the plugin's zeph-ask.sh) or a turn that died on an error. `high` gets
87
+ // through the quiet dial, and plain `notify` (no --auto) means only a mute
88
+ // stops it. Title and body arrive as env vars, never spliced into the string:
89
+ // the body can carry the model's own bash command or a provider error.
90
+ const highNotifyCmd = '$(command -v zeph || echo "npx -y @zeph-to/cli") notify'
91
+ + ' --title "$ZEPH_PUSH_TITLE" --body "$ZEPH_PUSH_BODY" --priority high 2>/dev/null || true';
92
+ // The pi turn's Push Signal marker, as a JS template placeholder (single quotes:
93
+ // `${marker}` must reach the artifact unexpanded). The artifact only ever
94
+ // assigns it a word the marker regex captured, or "none".
95
+ const PUSH_SIGNAL_FLAG = ' --marker ${marker}';
96
+ /** Grace before the pi extension turns an unanswered prompt into a push. Exported for the tests. */
97
+ exports.PI_PROMPT_GRACE_MS = 10_000;
76
98
  // ── Shared behavioral core ───────────────────────────────────────
77
99
  //
78
100
  // GENERATED from plugin/docs/CORE_RULES.md — see src/zeph-core.generated.ts
@@ -137,10 +159,55 @@ tool instead — same semantics:
137
159
  unreachable) is a Done-like outcome — treat it as NORMAL.
138
160
  - zeph_notify → \`zeph notify --title "…" --body "…" [--priority high]\`
139
161
  - AskUserQuestion → pi's own terminal prompt.`;
162
+ // Push Signal preamble — pi only. The shared core has no Push Signal section:
163
+ // Claude Code gets it from the plugin's SessionStart hook, per push dial. The
164
+ // pi extension reads the same markers (PI_EXTENSION, message_end), so pi's
165
+ // rules carry a dial-independent version here.
166
+ const PI_PUSH_SIGNAL = `## Push Signal — steer the end-of-turn push
167
+
168
+ When a turn ends, the zeph extension decides whether to push by tool volume
169
+ and the user's push dial. Put ONE marker anywhere in your final response to
170
+ override that for this turn. The extension removes it before the message is
171
+ shown or kept:
172
+
173
+ - \`<!-- zeph: high -->\` — push at high priority. The only marker that gets
174
+ through the quiet dial, so use it whenever the user must act: you ended the
175
+ turn with a question for them, or you are blocked on a decision only they
176
+ can make. Never on routine completions.
177
+ - \`<!-- zeph: push -->\` — push a turn the heuristic would skip.
178
+ - \`<!-- zeph: skip -->\` — no push for a turn not worth a ping.
179
+
180
+ No marker → the heuristic: fewer than 2 tool calls, or only read-only ones,
181
+ stays silent. Markers are lowercase and exact.`;
182
+ // NORMAL-only core — agents WITH a prompt-submit hook (Gemini, Codex, Pi).
183
+ // Mirrors plugin/hooks/zeph-setup.js `normal()`: the NORMAL branch owes no
184
+ // `zeph_ask`, so it carries none of the REMOTE-scoped sections (MANDATORY /
185
+ // DEFAULT / Sticky REMOTE / AskUserQuestion-vs) — about three quarters of the
186
+ // core, paid on every turn of a session that is almost always NORMAL. What it
187
+ // needs is the trigger: what flips the session, and what that turns on, stated
188
+ // here rather than pointed at because a `zeph_ask` answer reporting
189
+ // `zephState: "REMOTE"` flips it mid-turn with no hook to say so. The turn a
190
+ // phone message arrives, remote-hook.ts injects the REMOTE sections in full.
191
+ // Sections keep the numbers the core gave them (a render concern the extractor
192
+ // settled per audience; the plugin's NORMAL branch has the same gaps).
193
+ const REMOTE_STUB = `### What starts REMOTE
194
+
195
+ The user sending a message from their phone starts sticky REMOTE — the prompt-submit hook says so on that turn and injects the contract in full. A \`zeph_ask\` result reporting \`zephState: "REMOTE"\` starts it mid-turn.
196
+
197
+ From that response on: end EVERY response with \`zeph_ask\` (2–4 \`actions\` plus a Done-like \`fallback\`, \`timeout\` 300–600s), route button-friendly questions through it instead of \`AskUserQuestion\`, and never end on a plain-text question — until the user exits with a Done-like button, a free-text wrap-up you read as one (emit \`<!-- zeph: exit -->\` once), or a prompt they type at the terminal.`;
198
+ const PROMPT_HOOK_CORE = [
199
+ `## NORMAL — the user is at the terminal
200
+
201
+ Zeph can hand this session to the user's phone, but nobody has done that yet. **You owe no \`zeph_ask\`**: ask questions at the terminal or in prose, and let the end-of-turn push be the completion signal. A \`zeph_ask\` here blocks the turn until someone answers on a device or it times out.`,
202
+ (0, core_sections_js_1.coreSection)(zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN, 'Handling the response'),
203
+ REMOTE_STUB,
204
+ (0, core_sections_js_1.coreSection)(zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN, 'Persistence'),
205
+ ].join('\n\n');
140
206
  /** Assemble a full rule document from optional frontmatter + preambles + core. */
141
207
  const buildRule = (opts) => {
142
208
  const fm = opts.frontmatter ? `${opts.frontmatter}\n\n` : '';
143
209
  const tools = opts.toolAccess ? `${opts.toolAccess}\n\n` : '';
210
+ const signal = opts.pushSignal ? `${opts.pushSignal}\n\n` : '';
144
211
  const entry = opts.remoteEntry ? `${opts.remoteEntry}\n\n` : '';
145
212
  return `${fm}# Zeph — Remote-Control Rules
146
213
 
@@ -150,7 +217,7 @@ the user.
150
217
 
151
218
  ${opts.notify}
152
219
 
153
- ${tools}${entry}${opts.core}
220
+ ${tools}${signal}${entry}${opts.core}
154
221
  `;
155
222
  };
156
223
  // ── Per-agent rule documents ─────────────────────────────────────
@@ -171,10 +238,10 @@ exports.WINDSURF_RULE = buildRule({
171
238
  remoteEntry: REMOTE_ENTRY_NO_HOOK,
172
239
  core: zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN,
173
240
  });
174
- /** Gemini CLI — appended into ~/.gemini/GEMINI.md. Has the prompt hook (GEMINI_HOOKS). */
175
- exports.GEMINI_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY, core: zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN });
176
- /** Codex CLI — appended into ~/.codex/AGENTS.md. Has the prompt hook (CODEX_HOOKS). */
177
- exports.CODEX_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY, core: zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN });
241
+ /** Gemini CLI — appended into ~/.gemini/GEMINI.md. Has the prompt hook (GEMINI_HOOKS), so NORMAL-only core. */
242
+ exports.GEMINI_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY, core: PROMPT_HOOK_CORE });
243
+ /** Codex CLI — appended into ~/.codex/AGENTS.md. Has the prompt hook (CODEX_HOOKS), so NORMAL-only core. */
244
+ exports.CODEX_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY, core: PROMPT_HOOK_CORE });
178
245
  /** GitHub Copilot CLI — written to ~/.copilot/instructions/zeph.instructions.md. No prompt hook. */
179
246
  exports.COPILOT_RULE = buildRule({
180
247
  notify: HOOK_DRIVEN_NOTIFY,
@@ -193,11 +260,12 @@ exports.AIDER_RULE = buildRule({
193
260
  remoteEntry: REMOTE_ENTRY_NO_HOOK,
194
261
  core: zeph_core_generated_js_1.ZEPH_CORE_RULE_ONLY,
195
262
  });
196
- /** Pi — managed block in ~/.pi/agent/AGENTS.md. Extension = Stop-equivalent + prompt hook (PI_EXTENSION). */
263
+ /** Pi — managed block in ~/.pi/agent/AGENTS.md. Extension = Stop-equivalent + prompt hook + waiting-on-you push (PI_EXTENSION), so NORMAL-only core. */
197
264
  exports.PI_RULE = buildRule({
198
265
  notify: HOOK_DRIVEN_NOTIFY,
199
266
  toolAccess: PI_TOOL_ACCESS,
200
- core: zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN,
267
+ pushSignal: PI_PUSH_SIGNAL,
268
+ core: PROMPT_HOOK_CORE,
201
269
  });
202
270
  /** OpenCode — managed block in ~/.config/opencode/AGENTS.md. Stop hook via plugin, no prompt hook (v1). */
203
271
  exports.OPENCODE_RULE = buildRule({
@@ -320,20 +388,108 @@ const sh = (cmd: string, cwd: string, stdin?: string): Promise<string> =>
320
388
  // an unknown tool errs toward pushing rather than toward silence.
321
389
  const READ_ONLY = new Set(["read", "grep", "find", "ls"]);
322
390
 
391
+ // How long a blocking prompt may stay open before it becomes a push. An answer
392
+ // inside the window means the user is at the terminal, and a guard extension
393
+ // can raise several prompts a turn — pushing each one at once would be noise.
394
+ const PROMPT_GRACE_MS = ${exports.PI_PROMPT_GRACE_MS};
395
+
396
+ // Push Signal markers — the pattern the Claude Code Stop hook reads
397
+ // (plugin/hooks/zeph-stop.sh MARKER_RE). pi's markdown renders an HTML comment
398
+ // as plain text, so message_end also strips it from what the user sees.
399
+ const MARKER_RE = /<!--[ \\t]*zeph:[ \\t]*(skip|push|high)[ \\t]*-->/g;
400
+
401
+ const projectOf = (cwd: string): string => cwd.split("/").pop() || cwd;
402
+
403
+ // Fire-and-forget: never block pi on the notify network call.
404
+ const pushHigh = (cwd: string, title: string, body: string): void => {
405
+ const env = {
406
+ ...process.env,
407
+ ZEPH_PUSH_TITLE: title,
408
+ ZEPH_PUSH_BODY: body.length > 200 ? body.slice(0, 199) + "…" : body,
409
+ };
410
+ const child = spawn("sh", ["-c", ${JSON.stringify(highNotifyCmd)}], { cwd, env, stdio: "ignore", detached: true });
411
+ child.on("error", () => {});
412
+ child.unref();
413
+ };
414
+
323
415
  export default function (pi: ExtensionAPI) {
324
416
  // Turn facts for the push gate. One agent per process, so plain counters
325
417
  // suffice — no session keying.
326
418
  let tools = 0;
327
419
  let nonReadonly = 0;
420
+ // The tool call in flight. tool_execution_start fires before pi runs the
421
+ // tool_call handlers, so a guard's prompt always finds its own call here.
422
+ let running: { toolName: string; args: any } | undefined;
423
+ let promptTimer: ReturnType<typeof setTimeout> | undefined;
424
+ // A prompt only counts inside a turn. Outside one it is a dialog the user
425
+ // opened at the terminal themselves (/caveman config, a settings picker).
426
+ let agentRunning = false;
427
+ // This turn's Push Signal, and why its last assistant message stopped.
428
+ let marker = "none";
429
+ let stop: { reason: string; error?: string } | undefined;
328
430
 
431
+ pi.on("agent_start", () => {
432
+ agentRunning = true;
433
+ });
434
+ pi.on("message_end", (event) => {
435
+ const message = event.message;
436
+ if (message.role !== "assistant") return;
437
+ stop = { reason: message.stopReason, error: message.errorMessage };
438
+ let found = false;
439
+ const content = message.content.map((part) => {
440
+ if (part.type !== "text") return part;
441
+ const text = part.text.replace(MARKER_RE, (_match: string, word: string) => {
442
+ marker = word;
443
+ found = true;
444
+ return "";
445
+ });
446
+ return text === part.text ? part : { ...part, text: text.trimEnd() };
447
+ });
448
+ if (found) return { message: { ...message, content } };
449
+ });
450
+ pi.on("tool_execution_start", (event) => {
451
+ running = event;
452
+ });
329
453
  pi.on("tool_execution_end", (event) => {
454
+ running = undefined;
330
455
  tools += 1;
331
456
  if (!READ_ONLY.has(event.toolName)) nonReadonly += 1;
332
457
  });
458
+ // Waiting-on-you: pi fires ui_prompt_start for every blocking extension
459
+ // dialog (select/confirm/input/editor/custom — bash guards, ask-user tools).
460
+ // A custom dialog carries no title, so name the call that raised it instead.
461
+ pi.on("ui_prompt_start", (event, ctx) => {
462
+ clearTimeout(promptTimer);
463
+ if (!agentRunning) return;
464
+ const command = running?.toolName === "bash" ? running.args?.command : undefined;
465
+ const body = event.title
466
+ ?? (typeof command === "string" ? "$ " + command : undefined)
467
+ ?? (running ? running.toolName + " is waiting for your answer" : "Waiting for your input");
468
+ promptTimer = setTimeout(() => {
469
+ promptTimer = undefined;
470
+ pushHigh(ctx.cwd, "pi asks: " + projectOf(ctx.cwd), body);
471
+ }, PROMPT_GRACE_MS);
472
+ promptTimer.unref?.();
473
+ });
474
+ pi.on("ui_prompt_end", () => {
475
+ clearTimeout(promptTimer);
476
+ promptTimer = undefined;
477
+ });
333
478
  // Stop-equivalent: agent_settled fires once per user turn, after retries/compaction.
334
479
  // Fire-and-forget: never block pi's turn-end on the notify network call.
335
480
  pi.on("agent_settled", (_event, ctx) => {
336
- const child = spawn("sh", ["-c", ${notifyCmdLiteral(TURN_FACT_FLAGS)}], { cwd: ctx.cwd, stdio: "ignore", detached: true });
481
+ agentRunning = false;
482
+ clearTimeout(promptTimer);
483
+ promptTimer = undefined;
484
+ // Esc (at the terminal, or a key sent from the phone): whoever stopped the
485
+ // turn is already looking at it.
486
+ if (stop?.reason === "aborted") return;
487
+ // A turn that died on a provider error is a blocker, not a completion.
488
+ if (stop?.reason === "error") {
489
+ pushHigh(ctx.cwd, "pi stopped: " + projectOf(ctx.cwd), stop.error || "The model request failed");
490
+ return;
491
+ }
492
+ const child = spawn("sh", ["-c", ${notifyCmdLiteral(TURN_FACT_FLAGS + PUSH_SIGNAL_FLAG)}], { cwd: ctx.cwd, stdio: "ignore", detached: true });
337
493
  child.on("error", () => {});
338
494
  child.unref();
339
495
  });
@@ -343,6 +499,8 @@ export default function (pi: ExtensionAPI) {
343
499
  pi.on("before_agent_start", async (event, ctx) => {
344
500
  tools = 0;
345
501
  nonReadonly = 0;
502
+ marker = "none";
503
+ stop = undefined;
346
504
  const out = await sh(${JSON.stringify(remoteHookCmd('pi'))}, ctx.cwd,
347
505
  JSON.stringify({ prompt: event.prompt, cwd: ctx.cwd }));
348
506
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/cli",
3
- "version": "2.16.0",
3
+ "version": "2.17.1",
4
4
  "description": "Zeph CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",