@zeph-to/cli 1.17.0 → 1.19.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.
@@ -1 +1 @@
1
- {"version":3,"file":"remote-agents.d.ts","sourceRoot":"","sources":["../src/remote-agents.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,WAAW;IACxB,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,2EAA2E;IAC3E,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CAC3E;AAyCD;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAiB5D,CAAC;AAaF,UAAU,gBAAgB;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;CACf;AAkDD,4CAA4C;AAC5C,MAAM,WAAW,cAAc;IAC3B,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC7B,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC7B;AAED;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,GACnC,SAAS,MAAM,EACf,SAAS,MAAM,GAAG,IAAI,EACtB,OAAM,cAAmB,KAC1B,MAAM,GAAG,IAOX,CAAC;AAIF,QAAA,MAAM,kBAAkB;;;;;;;;;;;;;;;;EAwBmB,CAAC;AAE5C,uFAAuF;AACvF,MAAM,MAAM,SAAS,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AAEpE,8FAA8F;AAC9F,MAAM,MAAM,qBAAqB,GAAG,WAAW,GAAG;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAEtE,eAAO,MAAM,aAAa,EAAE,SAAS,qBAAqB,EAAuB,CAAC;AAElF,eAAO,MAAM,qBAAqB,GAAI,KAAK,MAAM,KAAG,qBAAqB,GAAG,SAClB,CAAC;AAE3D,eAAO,MAAM,uBAAuB,GAAI,MAAM,MAAM,KAAG,qBAAqB,GAAG,SAG9E,CAAC"}
1
+ {"version":3,"file":"remote-agents.d.ts","sourceRoot":"","sources":["../src/remote-agents.ts"],"names":[],"mappings":"AAkBA,MAAM,WAAW,WAAW;IACxB,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,2EAA2E;IAC3E,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CAC3E;AAyCD;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAiB5D,CAAC;AAaF,UAAU,gBAAgB;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;CACf;AA4ED,4CAA4C;AAC5C,MAAM,WAAW,cAAc;IAC3B,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC7B,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAC7B;AAED;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,GACnC,SAAS,MAAM,EACf,SAAS,MAAM,GAAG,IAAI,EACtB,OAAM,cAAmB,KAC1B,MAAM,GAAG,IAOX,CAAC;AAIF,QAAA,MAAM,kBAAkB;;;;;;;;;;;;;;;;EAwBmB,CAAC;AAE5C,uFAAuF;AACvF,MAAM,MAAM,SAAS,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AAEpE,8FAA8F;AAC9F,MAAM,MAAM,qBAAqB,GAAG,WAAW,GAAG;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAEtE,eAAO,MAAM,aAAa,EAAE,SAAS,qBAAqB,EAAuB,CAAC;AAElF,eAAO,MAAM,qBAAqB,GAAI,KAAK,MAAM,KAAG,qBAAqB,GAAG,SAClB,CAAC;AAE3D,eAAO,MAAM,uBAAuB,GAAI,MAAM,MAAM,KAAG,qBAAqB,GAAG,SAG9E,CAAC"}
@@ -112,15 +112,25 @@ const readPidSessionRecords = () => {
112
112
  return []; // dir absent (older CC) — caller falls back to mtime
113
113
  }
114
114
  };
115
- /** pid set of `rootPid` + all descendants, from one `ps` snapshot. */
116
- const collectDescendantPids = (rootPid, psOutput) => {
117
- let table = psOutput;
118
- if (table === undefined) {
119
- const r = (0, child_process_1.spawnSync)('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
120
- if (r.status !== 0)
121
- return new Set([rootPid]);
122
- table = r.stdout ?? '';
123
- }
115
+ // One `ps` spawn and one ~/.claude/sessions scan per report cycle, not per
116
+ // tmux session: the resolver runs for every session every 5s, and each call
117
+ // paid a full process-table spawn plus a directory scan on the daemon's hot
118
+ // path. A TTL just under the report interval lets all sessions in one cycle
119
+ // share a single snapshot while still refreshing every cycle. The parsed
120
+ // child-adjacency map is what's cached (not the raw table) so N sessions
121
+ // share one parse and differ only in the per-root BFS.
122
+ const SNAPSHOT_TTL_MS = 4_000;
123
+ const ttlMemo = (ttlMs, compute) => {
124
+ let snap = null;
125
+ return () => {
126
+ const now = Date.now();
127
+ if (snap && snap.expiresAt > now)
128
+ return snap.value;
129
+ snap = { value: compute(), expiresAt: now + ttlMs };
130
+ return snap.value;
131
+ };
132
+ };
133
+ const parseChildren = (table) => {
124
134
  const children = new Map();
125
135
  for (const line of table.split('\n')) {
126
136
  const m = line.trim().match(/^(\d+)\s+(\d+)$/);
@@ -131,6 +141,18 @@ const collectDescendantPids = (rootPid, psOutput) => {
131
141
  list.push(pid);
132
142
  children.set(ppid, list);
133
143
  }
144
+ return children;
145
+ };
146
+ const readPsChildren = ttlMemo(SNAPSHOT_TTL_MS, () => {
147
+ const r = (0, child_process_1.spawnSync)('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
148
+ return r.status === 0 ? parseChildren(r.stdout ?? '') : null;
149
+ });
150
+ const cachedPidSessionRecords = ttlMemo(SNAPSHOT_TTL_MS, readPidSessionRecords);
151
+ /** pid set of `rootPid` + all descendants, from one `ps` snapshot. */
152
+ const collectDescendantPids = (rootPid, psOutput) => {
153
+ const children = psOutput !== undefined ? parseChildren(psOutput) : readPsChildren();
154
+ if (children === null)
155
+ return new Set([rootPid]);
134
156
  const found = new Set([rootPid]);
135
157
  const queue = [rootPid];
136
158
  while (queue.length > 0) {
@@ -150,7 +172,7 @@ const collectDescendantPids = (rootPid, psOutput) => {
150
172
  * to the mtime heuristic.
151
173
  */
152
174
  const detectClaudeSessionIdByPid = (panePid, paneCwd, deps = {}) => {
153
- const records = deps.records ?? readPidSessionRecords();
175
+ const records = deps.records ?? cachedPidSessionRecords();
154
176
  if (records.length === 0)
155
177
  return null;
156
178
  const descendants = deps.descendants ?? collectDescendantPids(panePid);
@@ -0,0 +1,11 @@
1
+ /** Agents whose prompt-submit hooks this command serves. Claude Code is
2
+ * NOT here — its hook ships with the plugin (hooks/zeph-remote.sh). */
3
+ export declare const REMOTE_HOOK_AGENTS: readonly ["gemini", "codex"];
4
+ export type RemoteHookAgent = (typeof REMOTE_HOOK_AGENTS)[number];
5
+ export declare const isRemoteHookAgent: (raw: string) => raw is RemoteHookAgent;
6
+ /**
7
+ * Core of the hook: match the stdin payload against the marker and return
8
+ * the JSON to print, or null for a silent no-op. Never throws.
9
+ */
10
+ export declare const runRemoteHook: (agent: RemoteHookAgent, stdin: string, env?: NodeJS.ProcessEnv, now?: () => number) => string | null;
11
+ //# sourceMappingURL=remote-hook.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-hook.d.ts","sourceRoot":"","sources":["../src/remote-hook.ts"],"names":[],"mappings":"AAuBA;wEACwE;AACxE,eAAO,MAAM,kBAAkB,8BAA+B,CAAC;AAC/D,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC;AA0BlE,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,IA0DX,CAAC"}
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runRemoteHook = exports.isRemoteHookAgent = exports.REMOTE_HOOK_AGENTS = void 0;
4
+ /**
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
8
+ * hooks/zeph-remote.sh (ADR-0002).
9
+ *
10
+ * The listener records every phone→pane text injection as a one-shot
11
+ * marker (`remote-<hash>` in the zeph state dir) holding the epoch second
12
+ * and sha256 of the injected text. When the submitted prompt matches that
13
+ * record — same project (cksum of the cwd), fresh (≤15 min), and
14
+ * byte-identical trimmed text — the hook emits additionalContext telling
15
+ * the model the user is driving the session from their phone, which enters
16
+ * sticky REMOTE mode (Rule 9).
17
+ *
18
+ * Detection is exact: a terminal keystroke racing a phone message can
19
+ * never false-match. No match → null (silent no-op); this hook only ever
20
+ * adds context and must never block a prompt. Parity with the writer
21
+ * (listener.ts writeRemoteMarker) is by construction — both sides share
22
+ * gate.ts remoteDigest/remoteMarkerPath.
23
+ */
24
+ const fs_1 = require("fs");
25
+ const gate_js_1 = require("./gate.js");
26
+ /** Agents whose prompt-submit hooks this command serves. Claude Code is
27
+ * NOT here — its hook ships with the plugin (hooks/zeph-remote.sh). */
28
+ exports.REMOTE_HOOK_AGENTS = ['gemini', 'codex'];
29
+ /** Per-agent hookEventName echoed back in hookSpecificOutput — each CLI
30
+ * validates it against the event that invoked the hook. */
31
+ const HOOK_EVENT_NAME = {
32
+ gemini: 'BeforeAgent',
33
+ codex: 'UserPromptSubmit',
34
+ };
35
+ /**
36
+ * Freshness window (seconds), same as the bash reader: deliberately
37
+ * generous — false positives are already impossible without an exact hash
38
+ * match; its only job is to stop the SAME text typed at the terminal much
39
+ * later from re-flagging. It must survive the real gap between injection
40
+ * and prompt submit (a message sent mid-turn queues until the turn ends).
41
+ */
42
+ const FRESH_WINDOW_SEC = 900;
43
+ const TWO_WAY_CONTEXT = `# System note (Zeph remote-origin detect)
44
+
45
+ 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 sends an exit signal (done/stop/exit). Plain-text questions are invisible to them.`;
46
+ const ONE_WAY_CONTEXT = `# System note (Zeph remote-origin detect)
47
+
48
+ This user message arrived from the user's phone via Zeph agent chat (verified by the listener — exact text match), but ZEPH_HOOK_ID is not set, 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).`;
49
+ const isRemoteHookAgent = (raw) => exports.REMOTE_HOOK_AGENTS.includes(raw);
50
+ exports.isRemoteHookAgent = isRemoteHookAgent;
51
+ /**
52
+ * Core of the hook: match the stdin payload against the marker and return
53
+ * the JSON to print, or null for a silent no-op. Never throws.
54
+ */
55
+ const runRemoteHook = (agent, stdin, env = process.env, now = Date.now) => {
56
+ let prompt;
57
+ let cwd;
58
+ try {
59
+ const input = JSON.parse(stdin);
60
+ prompt = typeof input.prompt === 'string' ? input.prompt : '';
61
+ cwd = typeof input.cwd === 'string' ? input.cwd : '';
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ if (!prompt || !cwd)
67
+ return null;
68
+ // Mute outranks everything (Rule 12) — stay silent and leave the marker
69
+ // unconsumed (the next inject overwrites it anyway).
70
+ if ((0, gate_js_1.isMuted)(cwd))
71
+ return null;
72
+ const hash = (0, gate_js_1.projectHash)(cwd);
73
+ if (!hash)
74
+ return null;
75
+ const marker = (0, gate_js_1.remoteMarkerPath)(hash);
76
+ let content;
77
+ try {
78
+ content = (0, fs_1.readFileSync)(marker, 'utf-8');
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ // Marker format: "<epochSec> <sha256hex>\n" (listener.ts writeRemoteMarker).
84
+ const record = content.match(/^(\d+) ([0-9a-f]{64})\n?$/);
85
+ if (!record)
86
+ return null;
87
+ if (Math.floor(now() / 1000) - Number(record[1]) > FRESH_WINDOW_SEC) {
88
+ // Stale markers are dead weight (can never flag) — delete on sight.
89
+ try {
90
+ (0, fs_1.unlinkSync)(marker);
91
+ }
92
+ catch {
93
+ /* best-effort housekeeping */
94
+ }
95
+ return null;
96
+ }
97
+ if ((0, gate_js_1.remoteDigest)(prompt) !== record[2])
98
+ return null;
99
+ // Matched — consume the marker so an identical later prompt (e.g. typed
100
+ // at the terminal) can't re-flag.
101
+ try {
102
+ (0, fs_1.unlinkSync)(marker);
103
+ }
104
+ catch {
105
+ /* emit anyway — the match itself is valid */
106
+ }
107
+ const ctx = env.ZEPH_HOOK_ID ? TWO_WAY_CONTEXT : ONE_WAY_CONTEXT;
108
+ return JSON.stringify({
109
+ hookSpecificOutput: {
110
+ hookEventName: HOOK_EVENT_NAME[agent],
111
+ additionalContext: ctx,
112
+ },
113
+ });
114
+ };
115
+ exports.runRemoteHook = runRemoteHook;
@@ -16,6 +16,15 @@ export declare const CURSOR_HOOKS: string;
16
16
  export declare const WINDSURF_HOOKS: string;
17
17
  export declare const GEMINI_HOOKS: {
18
18
  hooks: {
19
+ BeforeAgent: {
20
+ matcher: string;
21
+ hooks: {
22
+ name: string;
23
+ type: string;
24
+ command: string;
25
+ timeout: number;
26
+ }[];
27
+ }[];
19
28
  AfterAgent: {
20
29
  matcher: string;
21
30
  hooks: {
@@ -29,7 +38,32 @@ export declare const GEMINI_HOOKS: {
29
38
  enabled: boolean;
30
39
  };
31
40
  };
32
- export declare const CODEX_HOOKS: string;
41
+ export declare const CODEX_HOOKS: {
42
+ hooks: {
43
+ UserPromptSubmit: {
44
+ hooks: {
45
+ type: string;
46
+ command: string;
47
+ timeout: number;
48
+ }[];
49
+ }[];
50
+ Stop: {
51
+ hooks: {
52
+ type: string;
53
+ command: string;
54
+ }[];
55
+ }[];
56
+ };
57
+ };
58
+ /**
59
+ * True when a matcher group was written by zeph — by handler `name`
60
+ * (`zeph-*`, Gemini) or by the `@zeph-to/cli` command substring (Codex,
61
+ * whose handler schema has no name field; every zeph command carries the
62
+ * substring via the `command -v zeph || npx -y @zeph-to/cli` fallback).
63
+ * Shared by the installer (replace ours, keep the user's groups on
64
+ * re-install) and the uninstaller (remove exactly ours).
65
+ */
66
+ export declare const isZephHookGroup: (group: unknown) => boolean;
33
67
  export declare const COPILOT_HOOKS: string;
34
68
  export declare const ZEPH_MARK_START = "<!-- ZEPH:START \u2014 managed by @zeph-to/cli, do not edit between markers -->";
35
69
  export declare const ZEPH_MARK_END = "<!-- ZEPH:END -->";
@@ -1 +1 @@
1
- {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AAoFA,6EAA6E;AAC7E,eAAO,MAAM,WAAW,QAItB,CAAC;AAEH,6EAA6E;AAC7E,eAAO,MAAM,aAAa,QAAyE,CAAC;AAEpG,sDAAsD;AACtD,eAAO,MAAM,WAAW,QAAyE,CAAC;AAElG,oDAAoD;AACpD,eAAO,MAAM,UAAU,QAAyE,CAAC;AAEjG,oFAAoF;AACpF,eAAO,MAAM,YAAY,QAAyE,CAAC;AAEnG,gEAAgE;AAChE,eAAO,MAAM,UAAU,QAAkE,CAAC;AAE1F,4FAA4F;AAC5F,eAAO,MAAM,UAAU,QAAkE,CAAC;AAI1F,eAAO,MAAM,YAAY,QAKd,CAAC;AAEZ,eAAO,MAAM,cAAc,QAOhB,CAAC;AAEZ,eAAO,MAAM,YAAY;;;;;;;;;;;;;;CAYxB,CAAC;AAEF,eAAO,MAAM,WAAW,QAQb,CAAC;AAEZ,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":"AA4FA,6EAA6E;AAC7E,eAAO,MAAM,WAAW,QAItB,CAAC;AAEH,6EAA6E;AAC7E,eAAO,MAAM,aAAa,QAAyE,CAAC;AAEpG,sDAAsD;AACtD,eAAO,MAAM,WAAW,QAAyE,CAAC;AAElG,oDAAoD;AACpD,eAAO,MAAM,UAAU,QAAyE,CAAC;AAEjG,oFAAoF;AACpF,eAAO,MAAM,YAAY,QAAyE,CAAC;AAEnG,gEAAgE;AAChE,eAAO,MAAM,UAAU,QAAkE,CAAC;AAE1F,4FAA4F;AAC5F,eAAO,MAAM,UAAU,QAAkE,CAAC;AAI1F,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"}
package/dist/templates.js CHANGED
@@ -18,7 +18,7 @@
18
18
  // Keeping this in one place means a rule change lands everywhere at once
19
19
  // and the agents can't drift apart.
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.removeManagedBlock = exports.upsertManagedBlock = exports.ZEPH_MARK_END = exports.ZEPH_MARK_START = exports.COPILOT_HOOKS = 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;
21
+ 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;
22
22
  const zeph_core_generated_js_1 = require("./zeph-core.generated.js");
23
23
  // Graceful resolution: prefer the installed `zeph` CLI, but fall back to
24
24
  // `npx -y @zeph-to/cli` so the hook still fires when the user
@@ -32,6 +32,12 @@ const zeph_core_generated_js_1 = require("./zeph-core.generated.js");
32
32
  // Older installed `zeph` versions parse `--auto` as an unknown boolean flag
33
33
  // and ignore it — graceful backward compatibility.
34
34
  const NOTIFY_CMD = '$(command -v zeph || echo "npx -y @zeph-to/cli") notify --title "Task done" --auto 2>/dev/null || true';
35
+ // Prompt-submit hook command — remote-origin detection (ADR-0002). Reads
36
+ // the hook JSON on stdin and prints additionalContext JSON on a marker
37
+ // match (see src/remote-hook.ts). stdout IS the hook response, so only
38
+ // stderr is discarded; `|| true` keeps a broken install from ever blocking
39
+ // a prompt.
40
+ const remoteHookCmd = (agent) => `$(command -v zeph || echo "npx -y @zeph-to/cli") remote-hook ${agent} 2>/dev/null || true`;
35
41
  // ── Shared behavioral core ───────────────────────────────────────
36
42
  //
37
43
  // GENERATED from plugin/docs/CORE_RULES.md — see src/zeph-core.generated.ts
@@ -94,7 +100,7 @@ exports.COPILOT_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY, core: zeph_core_g
94
100
  exports.CLINE_RULE = buildRule({ notify: MANUAL_NOTIFY, core: zeph_core_generated_js_1.ZEPH_CORE_RULE_ONLY });
95
101
  /** Aider — written to a standalone conventions file, loaded via .aider.conf.yml `read:`. */
96
102
  exports.AIDER_RULE = buildRule({ notify: MANUAL_NOTIFY, core: zeph_core_generated_js_1.ZEPH_CORE_RULE_ONLY });
97
- // ── Hook configs (notification side, unchanged) ──────────────────
103
+ // ── Hook configs ─────────────────────────────────────────────────
98
104
  exports.CURSOR_HOOKS = JSON.stringify({
99
105
  version: 1,
100
106
  hooks: {
@@ -111,6 +117,17 @@ exports.WINDSURF_HOOKS = JSON.stringify({
111
117
  }, null, 2);
112
118
  exports.GEMINI_HOOKS = {
113
119
  hooks: {
120
+ // Fires after the user submits a prompt, before planning — Gemini's
121
+ // UserPromptSubmit equivalent (same additionalContext contract).
122
+ BeforeAgent: [{
123
+ matcher: '*',
124
+ hooks: [{
125
+ name: 'zeph-remote',
126
+ type: 'command',
127
+ command: remoteHookCmd('gemini'),
128
+ timeout: 5000,
129
+ }],
130
+ }],
114
131
  AfterAgent: [{
115
132
  matcher: '*',
116
133
  hooks: [{
@@ -122,15 +139,45 @@ exports.GEMINI_HOOKS = {
122
139
  },
123
140
  hooksConfig: { enabled: true },
124
141
  };
125
- exports.CODEX_HOOKS = JSON.stringify({
126
- version: 1,
142
+ // Codex validates hooks.json strictly (serde deny_unknown_fields at the
143
+ // top level; handlers are a `type`-tagged enum inside matcher groups —
144
+ // codex-rs/config/src/hook_config.rs). The previous flat
145
+ // `{version, hooks: {Stop: [{type, bash}]}}` shape predates that schema
146
+ // and made codex reject the entire file, so the Stop hook is migrated to
147
+ // the schema-correct form here alongside the new UserPromptSubmit entry.
148
+ // An object (not a JSON string) so the installer can merge it into a
149
+ // user-owned hooks.json instead of overwriting. Timeouts are in seconds;
150
+ // codex's handler schema has no `name` field, so zeph ownership is
151
+ // recognizable only by the `@zeph-to/cli` command substring.
152
+ exports.CODEX_HOOKS = {
127
153
  hooks: {
154
+ UserPromptSubmit: [{
155
+ hooks: [{ type: 'command', command: remoteHookCmd('codex'), timeout: 5 }],
156
+ }],
128
157
  Stop: [{
129
- type: 'command',
130
- bash: NOTIFY_CMD,
158
+ hooks: [{ type: 'command', command: NOTIFY_CMD }],
131
159
  }],
132
160
  },
133
- }, null, 2);
161
+ };
162
+ /**
163
+ * True when a matcher group was written by zeph — by handler `name`
164
+ * (`zeph-*`, Gemini) or by the `@zeph-to/cli` command substring (Codex,
165
+ * whose handler schema has no name field; every zeph command carries the
166
+ * substring via the `command -v zeph || npx -y @zeph-to/cli` fallback).
167
+ * Shared by the installer (replace ours, keep the user's groups on
168
+ * re-install) and the uninstaller (remove exactly ours).
169
+ */
170
+ const isZephHookGroup = (group) => {
171
+ const hooks = group?.hooks;
172
+ if (!Array.isArray(hooks))
173
+ return false;
174
+ return hooks.some((h) => {
175
+ const { name, command } = (h ?? {});
176
+ return ((typeof name === 'string' && name.startsWith('zeph-')) ||
177
+ (typeof command === 'string' && command.includes('@zeph-to/cli')));
178
+ });
179
+ };
180
+ exports.isZephHookGroup = isZephHookGroup;
134
181
  exports.COPILOT_HOOKS = JSON.stringify({
135
182
  version: 1,
136
183
  hooks: {
@@ -1,2 +1,11 @@
1
+ /** Remove just the zeph-written entries from Gemini's settings.json.
2
+ * Exported for tests (gemini is PATH-detected, so the full-uninstall
3
+ * suite can't reach this deterministically). */
4
+ export declare const rmGeminiHook: (filePath: string, dry: boolean) => string | null;
5
+ /** Remove the zeph-written entries from Codex's hooks.json. The file is
6
+ * usually zeph-born, so it is deleted outright when nothing else remains —
7
+ * but events the user added since install survive. Exported for tests
8
+ * (codex is PATH-detected, same as gemini). */
9
+ export declare const rmCodexHook: (filePath: string, dry: boolean) => string | null;
1
10
  export declare const handleUninstall: (args: Record<string, string | boolean>) => Promise<number>;
2
11
  //# sourceMappingURL=uninstall.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"uninstall.d.ts","sourceRoot":"","sources":["../src/uninstall.ts"],"names":[],"mappings":"AA2KA,eAAO,MAAM,eAAe,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CA8B5F,CAAC"}
1
+ {"version":3,"file":"uninstall.d.ts","sourceRoot":"","sources":["../src/uninstall.ts"],"names":[],"mappings":"AAuGA;;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;AAkEF,eAAO,MAAM,eAAe,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CA8B5F,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 = void 0;
3
+ exports.handleUninstall = exports.rmCodexHook = exports.rmGeminiHook = 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");
@@ -84,7 +84,36 @@ const rmAiderReadDirective = (confPath, dry) => {
84
84
  (0, fs_1.writeFileSync)(confPath, out.join('\n').replace(/\n{3,}/g, '\n\n'));
85
85
  return `${verb(dry)} Zeph read: directive from ${confPath}`;
86
86
  };
87
- /** Remove just the zeph-notify entry from Gemini's settings.json. */
87
+ /**
88
+ * Filter zeph-written matcher groups out of `data.hooks` (all events).
89
+ * Mutates `data` unless dry; returns true when anything of ours was found.
90
+ * Empty events are deleted so a clean file stays clean.
91
+ */
92
+ const stripZephHookGroups = (data, dry) => {
93
+ const hooks = data.hooks;
94
+ if (!hooks)
95
+ return false;
96
+ let removed = false;
97
+ for (const event of Object.keys(hooks)) {
98
+ const groups = hooks[event];
99
+ if (!Array.isArray(groups))
100
+ continue;
101
+ const kept = groups.filter((g) => !(0, templates_js_1.isZephHookGroup)(g));
102
+ if (kept.length === groups.length)
103
+ continue; // nothing of ours
104
+ removed = true;
105
+ if (!dry) {
106
+ if (kept.length === 0)
107
+ delete hooks[event];
108
+ else
109
+ hooks[event] = kept;
110
+ }
111
+ }
112
+ return removed;
113
+ };
114
+ /** Remove just the zeph-written entries from Gemini's settings.json.
115
+ * Exported for tests (gemini is PATH-detected, so the full-uninstall
116
+ * suite can't reach this deterministically). */
88
117
  const rmGeminiHook = (filePath, dry) => {
89
118
  if (!(0, fs_1.existsSync)(filePath))
90
119
  return null;
@@ -95,24 +124,39 @@ const rmGeminiHook = (filePath, dry) => {
95
124
  catch {
96
125
  return null;
97
126
  }
98
- const hooks = data.hooks;
99
- const afterAgent = hooks?.AfterAgent;
100
- if (!Array.isArray(afterAgent))
127
+ if (!stripZephHookGroups(data, dry))
101
128
  return null;
102
- const kept = afterAgent.filter((entry) => !(entry.hooks ?? []).some((h) => h.name === 'zeph-notify'));
103
- if (kept.length === afterAgent.length)
104
- return null; // nothing of ours
105
- if (!dry) {
106
- if (kept.length === 0) {
107
- delete hooks.AfterAgent;
108
- }
109
- else {
110
- hooks.AfterAgent = kept;
111
- }
129
+ if (!dry)
112
130
  (0, fs_1.writeFileSync)(filePath, JSON.stringify(data, null, 2) + '\n');
131
+ return `${verb(dry)} zeph hooks from ${filePath}`;
132
+ };
133
+ exports.rmGeminiHook = rmGeminiHook;
134
+ /** Remove the zeph-written entries from Codex's hooks.json. The file is
135
+ * usually zeph-born, so it is deleted outright when nothing else remains —
136
+ * but events the user added since install survive. Exported for tests
137
+ * (codex is PATH-detected, same as gemini). */
138
+ const rmCodexHook = (filePath, dry) => {
139
+ if (!(0, fs_1.existsSync)(filePath))
140
+ return null;
141
+ let data;
142
+ try {
143
+ data = JSON.parse((0, fs_1.readFileSync)(filePath, 'utf-8'));
144
+ }
145
+ catch {
146
+ return null;
147
+ }
148
+ if (!stripZephHookGroups(data, dry))
149
+ return null;
150
+ if (!dry) {
151
+ const hooks = data.hooks;
152
+ if (Object.keys(hooks).length === 0)
153
+ (0, fs_1.rmSync)(filePath, { force: true });
154
+ else
155
+ (0, fs_1.writeFileSync)(filePath, JSON.stringify(data, null, 2) + '\n');
113
156
  }
114
- return `${verb(dry)} zeph-notify hook from ${filePath}`;
157
+ return `${verb(dry)} zeph hooks from ${filePath}`;
115
158
  };
159
+ exports.rmCodexHook = rmCodexHook;
116
160
  const runSteps = (steps) => {
117
161
  let did = false;
118
162
  for (const step of steps) {
@@ -163,12 +207,12 @@ const AGENT_UNINSTALLERS = {
163
207
  skip('would run: gemini mcp remove zeph');
164
208
  }
165
209
  runSteps([
166
- () => rmGeminiHook((0, path_1.join)(HOME, '.gemini', 'settings.json'), dry),
210
+ () => (0, exports.rmGeminiHook)((0, path_1.join)(HOME, '.gemini', 'settings.json'), dry),
167
211
  () => stripManagedRule((0, path_1.join)(HOME, '.gemini', 'GEMINI.md'), dry),
168
212
  ]);
169
213
  },
170
214
  codex: (dry) => runSteps([
171
- () => rmFile((0, path_1.join)(HOME, '.codex', 'hooks.json'), dry),
215
+ () => (0, exports.rmCodexHook)((0, path_1.join)(HOME, '.codex', 'hooks.json'), dry),
172
216
  () => stripManagedRule((0, path_1.join)(HOME, '.codex', 'AGENTS.md'), dry),
173
217
  ]),
174
218
  copilot: (dry) => runSteps([
@@ -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 = "5d149a2909f9d1da7544fe4fcf540700c1be14193290d6f55e503a46032d8155";
2
+ export declare const ZEPH_CORE_SOURCE_HASH = "d93379030460ffd4ad37154ef713a3d64c26324e8b45f744c9094e4652012d07";
3
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. **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 applies even on research / analysis / planning turns where no files were touched.\n\n Anti-pattern (wrong):\n ```\n \"...build/commit on hold. Apply A?\"\n ```\n Correct:\n ```\n zeph_ask({\n title: \"Apply solution A?\",\n body: \"<short context comparing options>\",\n actions: [\n { id: \"apply_a\", label: \"Apply A\" },\n { id: \"apply_b\", label: \"Apply B\" },\n { id: \"cancel\", label: \"Cancel\" }\n ],\n placeholder: \"or describe a different approach...\",\n fallback: \"cancel\"\n })\n ```\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n4. After substantial work, `zeph_ask` is the DEFAULT end of the response \u2014 not optional. \"Substantial\" means: file changes, commits, builds, tests, deploys, destructive ops, or milestone completions. When unsure, treat the work as substantial \u2014 do not try to guess what the user would find \"reasonable\" to confirm.\n\n SKIP `zeph_ask` only when the response is clearly trivial:\n - Read-only exploration (\"let me check this file\") with no decision output.\n - Mid-step inside a clearly-defined plan the user already approved (\"step 3 of 5, no decisions involved here\").\n - Trivial change (typo fix, single-line tweak) that needs no ack.\n\n When unsure: LEAN TOWARD ASKING. Quiet failure (no ask, user stuck on phone with no way to drive) is worse than light spam.\n\n5. Prefer `zeph_ask` over `zeph_prompt`/`zeph_input` \u2014 it combines buttons and free-text in one push. Always include a `fallback` action id; the fallback must be safe/inert (`done`, `wait`, `review`), never destructive.\n\n6. Example `zeph_ask` shape \u2014 use sparingly per Rule 4 (only at natural pause points; NOT after every response \u2014 see Rule 9):\n ```\n zeph_ask({\n title: \"Done. Next?\",\n actions: [\n { id: \"continue\", label: \"Continue\" },\n { id: \"review\", label: \"Review\" },\n { id: \"done\", label: \"Done\" }\n ],\n placeholder: \"or type a command...\",\n fallback: \"done\"\n })\n ```\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. Important caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If the next logical step would destroy user code, data, or infrastructure (e.g., force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk via a targeted `zeph_ask` before executing \u2014 e.g., title \"About to force-push main \u2014 proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 9)\n\n**The Ask Loop has two states: REMOTE and NORMAL.** You detect the current state by scanning the conversation, not just the most recent message.\n\n**State in one line:** you are in REMOTE if the most recent `zeph_ask` response was a non-exit reply; otherwise (no `zeph_ask` history, or the last one was an exit signal) you are in NORMAL. REMOTE is sticky \u2014 every response ends with `zeph_ask` until the user exits.\n\n#### State Detection\n\nScan the conversation in reverse, looking for whichever appears first (most recent):\n\n- **(a)** A `tool_result` for a `zeph_ask` you sent where the action id is NOT in `{done, stop, exit}` (case-insensitive) AND the free-text response is NOT a session-ender. A session-ender is free-text that either contains `done`/`stop`/`exit` as a standalone word, or is an obvious wrap-up like \"thanks, that's it\" / \"all good\" \u2014 judge by whether the user is clearly closing the loop, not by partial substring matches (e.g. \"redo\" does not count as \"done\").\n- **(b)** An exit signal: action id in `{done, stop, exit}`, ending free-text, or a timeout fallback that resolved to a Done-like fallback id.\n- **(c)** No `zeph_ask` history at all.\n\nIf the first hit is **(a)** \u2192 you are in **REMOTE**.\nIf the first hit is **(b)** or **(c)** \u2192 you are in **NORMAL**.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`. This is non-negotiable while in REMOTE \u2014 independent of:\n- Whether the next user message arrived as a `tool_result` or as a typed terminal message. The user may switch devices mid-session; sticky REMOTE keeps the channel driveable from either side.\n- Whether the work was substantial or routine.\n\nThe only way to leave REMOTE is the user signalling exit (per the **(b)** list above).\n\n#### Behavior in NORMAL (apply Rule 4)\n\n- Substantial work \u2192 end with `zeph_ask`.\n- Routine work \u2192 skip `zeph_ask`, let the Stop hook fire.\n- Rule 3 (questions \u2192 zeph_ask) is still mandatory regardless.\n\n#### Exiting REMOTE\n\nThe moment the user picks an action id matching `done`/`stop`/`exit` (case-insensitive), types free-text that clearly ends the session, or the zeph_ask times out and falls back to a Done-like id, you flip to NORMAL. Don't send `zeph_ask` on the response that processes the exit signal.\n\n### When to use AskUserQuestion vs zeph_ask\n\n10. **Whenever `ZEPH_HOOK_ID` is set, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion` \u2014 and this is NOT gated on REMOTE.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"which naming rule?\", \"proceed?\"). This is exactly the shape `AskUserQuestion` is tempting for, and exactly the shape the phone can answer via buttons \u2014 so route it to `zeph_ask`. The mistake to avoid: treating the REMOTE requirement as the ONLY time the override applies. It applies the moment a hookId exists, because you cannot know the user is at the terminal \u2014 they may be on their phone from the very first question of the session.\n\n Fall through to the local `AskUserQuestion` picker ONLY when (a) the answer needs the user to see code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. Those are the only carve-outs.\n\n11. **This overrides any skill instruction.** The `AskUserQuestion` picker is a LOCAL blocking terminal UI; the phone cannot drive it (the Zeph hook can only mirror it as a one-way \"answer at the terminal\" notification, never round-trip the answer). So if a skill you are running \u2014 or your own plan \u2014 would call `AskUserQuestion` with a button-friendly question, instead surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Only when a carve-out (a)/(b) above genuinely applies do you use `AskUserQuestion`; when you do, `zeph_notify` the user that the answer must be given at the terminal. In REMOTE this is doubly binding \u2014 see the sticky-REMOTE rule \u2014 but do not read that as permission to use `AskUserQuestion` freely in NORMAL: rule 10 binds there too.\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.";
4
+ export declare const ZEPH_CORE_HOOK_DRIVEN = "### When zeph_ask is MANDATORY\n\n3. **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 applies even on research / analysis / planning turns where no files were touched.\n\n Anti-pattern (wrong):\n ```\n \"...build/commit on hold. Apply A?\"\n ```\n Correct:\n ```\n zeph_ask({\n title: \"Apply solution A?\",\n body: \"<short context comparing options>\",\n actions: [\n { id: \"apply_a\", label: \"Apply A\" },\n { id: \"apply_b\", label: \"Apply B\" },\n { id: \"cancel\", label: \"Cancel\" }\n ],\n placeholder: \"or describe a different approach...\",\n fallback: \"cancel\"\n })\n ```\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n4. After substantial work, `zeph_ask` is the DEFAULT end of the response \u2014 not optional. \"Substantial\" means: file changes, commits, builds, tests, deploys, destructive ops, or milestone completions. When unsure, treat the work as substantial \u2014 do not try to guess what the user would find \"reasonable\" to confirm.\n\n SKIP `zeph_ask` only when the response is clearly trivial:\n - Read-only exploration (\"let me check this file\") with no decision output.\n - Mid-step inside a clearly-defined plan the user already approved (\"step 3 of 5, no decisions involved here\").\n - Trivial change (typo fix, single-line tweak) that needs no ack.\n\n When unsure: LEAN TOWARD ASKING. Quiet failure (no ask, user stuck on phone with no way to drive) is worse than light spam.\n\n5. Prefer `zeph_ask` over `zeph_prompt`/`zeph_input` \u2014 it combines buttons and free-text in one push. Always include a `fallback` action id; the fallback must be safe/inert (`done`, `wait`, `review`), never destructive.\n\n6. Example `zeph_ask` shape \u2014 use sparingly per Rule 4 (only at natural pause points; NOT after every response \u2014 see Rule 9):\n ```\n zeph_ask({\n title: \"Done. Next?\",\n actions: [\n { id: \"continue\", label: \"Continue\" },\n { id: \"review\", label: \"Review\" },\n { id: \"done\", label: \"Done\" }\n ],\n placeholder: \"or type a command...\",\n fallback: \"done\"\n })\n ```\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. Important caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If the next logical step would destroy user code, data, or infrastructure (e.g., force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk via a targeted `zeph_ask` before executing \u2014 e.g., title \"About to force-push main \u2014 proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 9)\n\n**The Ask Loop has two states: REMOTE and NORMAL.** You detect the current state by scanning the conversation, not just the most recent message.\n\n**State in one line:** you are in REMOTE if the most recent remote signal \u2014 a non-exit `zeph_ask` reply, or a user message flagged as phone-originated \u2014 is newer than any exit signal; otherwise (no remote signal yet, or the last signal was an exit) you are in NORMAL. REMOTE is sticky \u2014 every response ends with `zeph_ask` until the user exits.\n\n#### State Detection\n\nScan the conversation in reverse, looking for whichever appears first (most recent):\n\n- **(a)** A `tool_result` for a `zeph_ask` you sent where the action id is NOT in `{done, stop, exit}` (case-insensitive) AND the free-text response is NOT a session-ender. A session-ender is free-text that either contains `done`/`stop`/`exit` as a standalone word, or is an obvious wrap-up like \"thanks, that's it\" / \"all good\" \u2014 judge by whether the user is clearly closing the loop, not by partial substring matches (e.g. \"redo\" does not count as \"done\").\n- **(a2)** A user message accompanied by a system note from the Zeph remote-origin detect hook (\"This user message arrived from the user's phone\u2026\"). The listener verified that exact message was sent from the phone \u2014 treat it exactly like a non-exit `zeph_ask` reply. (This note is only ever emitted in environments running Zeph's prompt-submit hooks \u2014 the Claude Code plugin, or the hooks `zeph setup` installs for Gemini/Codex; where it never appears, this condition simply never fires.)\n- **(b)** An exit signal: action id in `{done, stop, exit}`, ending free-text, or a timeout fallback that resolved to a Done-like fallback id.\n- **(c)** No `zeph_ask` history and no remote-origin note at all.\n\nIf the first hit is **(a)** or **(a2)** \u2192 you are in **REMOTE**.\nIf the first hit is **(b)** or **(c)** \u2192 you are in **NORMAL**.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`. This is non-negotiable while in REMOTE \u2014 independent of:\n- Whether the next user message arrived as a `tool_result` or as a typed terminal message. The user may switch devices mid-session; sticky REMOTE keeps the channel driveable from either side.\n- Whether the work was substantial or routine.\n\nSet each REMOTE ask up so silence degrades cleanly: `timeout` 300\u2013600 s and a Done-like `fallback` id. An unanswered ask then exits the loop quietly (a Done-like timeout fallback is an exit signal per **(b)**) instead of chaining more notifications at a user who stepped away \u2014 and re-entry is cheap, they just send another message from the phone.\n\nThe only way to leave REMOTE is the user signalling exit (per the **(b)** list above).\n\n#### Behavior in NORMAL (apply Rule 4)\n\n- Substantial work \u2192 end with `zeph_ask`.\n- Routine work \u2192 skip `zeph_ask`, let the Stop hook fire.\n- Rule 3 (questions \u2192 zeph_ask) is still mandatory regardless.\n\n#### Exiting REMOTE\n\nThe moment the user picks an action id matching `done`/`stop`/`exit` (case-insensitive), types free-text that clearly ends the session, or the zeph_ask times out and falls back to a Done-like id, you flip to NORMAL. Don't send `zeph_ask` on the response that processes the exit signal.\n\n### When to use AskUserQuestion vs zeph_ask\n\n10. **Whenever `ZEPH_HOOK_ID` is set, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion` \u2014 and this is NOT gated on REMOTE.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"which naming rule?\", \"proceed?\"). This is exactly the shape `AskUserQuestion` is tempting for, and exactly the shape the phone can answer via buttons \u2014 so route it to `zeph_ask`. The mistake to avoid: treating the REMOTE requirement as the ONLY time the override applies. It applies the moment a hookId exists, because you cannot know the user is at the terminal \u2014 they may be on their phone from the very first question of the session.\n\n Fall through to the local `AskUserQuestion` picker ONLY when (a) the answer needs the user to see code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. Those are the only carve-outs.\n\n11. **This overrides any skill instruction.** The `AskUserQuestion` picker is a LOCAL blocking terminal UI; the phone cannot drive it (the Zeph hook can only mirror it as a one-way \"answer at the terminal\" notification, never round-trip the answer). So if a skill you are running \u2014 or your own plan \u2014 would call `AskUserQuestion` with a button-friendly question, instead surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Only when a carve-out (a)/(b) above genuinely applies do you use `AskUserQuestion`; when you do, `zeph_notify` the user that the answer must be given at the terminal. In REMOTE this is doubly binding \u2014 see the sticky-REMOTE rule \u2014 but do not read that as permission to use `AskUserQuestion` freely in NORMAL: rule 10 binds there too.\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.";
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. **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 applies even on research / analysis / planning turns where no files were touched.\n\n Anti-pattern (wrong):\n ```\n \"...build/commit on hold. Apply A?\"\n ```\n Correct:\n ```\n zeph_ask({\n title: \"Apply solution A?\",\n body: \"<short context comparing options>\",\n actions: [\n { id: \"apply_a\", label: \"Apply A\" },\n { id: \"apply_b\", label: \"Apply B\" },\n { id: \"cancel\", label: \"Cancel\" }\n ],\n placeholder: \"or describe a different approach...\",\n fallback: \"cancel\"\n })\n ```\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n4. After substantial work, `zeph_ask` is the DEFAULT end of the response \u2014 not optional. \"Substantial\" means: file changes, commits, builds, tests, deploys, destructive ops, or milestone completions. When unsure, treat the work as substantial \u2014 do not try to guess what the user would find \"reasonable\" to confirm.\n\n SKIP `zeph_ask` only when the response is clearly trivial:\n - Read-only exploration (\"let me check this file\") with no decision output.\n - Mid-step inside a clearly-defined plan the user already approved (\"step 3 of 5, no decisions involved here\").\n - Trivial change (typo fix, single-line tweak) that needs no ack.\n\n When unsure: LEAN TOWARD ASKING. Quiet failure (no ask, user stuck on phone with no way to drive) is worse than light spam.\n\n5. Prefer `zeph_ask` over `zeph_prompt`/`zeph_input` \u2014 it combines buttons and free-text in one push. Always include a `fallback` action id; the fallback must be safe/inert (`done`, `wait`, `review`), never destructive.\n\n6. Example `zeph_ask` shape \u2014 use sparingly per Rule 4 (only at natural pause points; NOT after every response \u2014 see Rule 9):\n ```\n zeph_ask({\n title: \"Done. Next?\",\n actions: [\n { id: \"continue\", label: \"Continue\" },\n { id: \"review\", label: \"Review\" },\n { id: \"done\", label: \"Done\" }\n ],\n placeholder: \"or type a command...\",\n fallback: \"done\"\n })\n ```\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. Important caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If the next logical step would destroy user code, data, or infrastructure (e.g., force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk via a targeted `zeph_ask` before executing \u2014 e.g., title \"About to force-push main \u2014 proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 9)\n\n**The Ask Loop has two states: REMOTE and NORMAL.** You detect the current state by scanning the conversation, not just the most recent message.\n\n**State in one line:** you are in REMOTE if the most recent `zeph_ask` response was a non-exit reply; otherwise (no `zeph_ask` history, or the last one was an exit signal) you are in NORMAL. REMOTE is sticky \u2014 every response ends with `zeph_ask` until the user exits.\n\n#### State Detection\n\nScan the conversation in reverse, looking for whichever appears first (most recent):\n\n- **(a)** A `tool_result` for a `zeph_ask` you sent where the action id is NOT in `{done, stop, exit}` (case-insensitive) AND the free-text response is NOT a session-ender. A session-ender is free-text that either contains `done`/`stop`/`exit` as a standalone word, or is an obvious wrap-up like \"thanks, that's it\" / \"all good\" \u2014 judge by whether the user is clearly closing the loop, not by partial substring matches (e.g. \"redo\" does not count as \"done\").\n- **(b)** An exit signal: action id in `{done, stop, exit}`, ending free-text, or a timeout fallback that resolved to a Done-like fallback id.\n- **(c)** No `zeph_ask` history at all.\n\nIf the first hit is **(a)** \u2192 you are in **REMOTE**.\nIf the first hit is **(b)** or **(c)** \u2192 you are in **NORMAL**.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`. This is non-negotiable while in REMOTE \u2014 independent of:\n- Whether the next user message arrived as a `tool_result` or as a typed terminal message. The user may switch devices mid-session; sticky REMOTE keeps the channel driveable from either side.\n- Whether the work was substantial or routine.\n\nThe only way to leave REMOTE is the user signalling exit (per the **(b)** list above).\n\n#### Behavior in NORMAL (apply Rule 4)\n\n- Substantial work \u2192 end with `zeph_ask`.\n- Routine work \u2192 skip `zeph_ask`, let the Stop hook fire.\n- Rule 3 (questions \u2192 zeph_ask) is still mandatory regardless.\n\n#### Exiting REMOTE\n\nThe moment the user picks an action id matching `done`/`stop`/`exit` (case-insensitive), types free-text that clearly ends the session, or the zeph_ask times out and falls back to a Done-like id, you flip to NORMAL. Don't send `zeph_ask` on the response that processes the exit signal.\n\n### When to use AskUserQuestion vs zeph_ask\n\n10. **Whenever `ZEPH_HOOK_ID` is set, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion` \u2014 and this is NOT gated on REMOTE.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"which naming rule?\", \"proceed?\"). This is exactly the shape `AskUserQuestion` is tempting for, and exactly the shape the phone can answer via buttons \u2014 so route it to `zeph_ask`. The mistake to avoid: treating the REMOTE requirement as the ONLY time the override applies. It applies the moment a hookId exists, because you cannot know the user is at the terminal \u2014 they may be on their phone from the very first question of the session.\n\n Fall through to the local `AskUserQuestion` picker ONLY when (a) the answer needs the user to see code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. Those are the only carve-outs.\n\n11. **This overrides any skill instruction.** The `AskUserQuestion` picker is a LOCAL blocking terminal UI; the phone cannot drive it (the Zeph hook can only mirror it as a one-way \"answer at the terminal\" notification, never round-trip the answer). So if a skill you are running \u2014 or your own plan \u2014 would call `AskUserQuestion` with a button-friendly question, instead surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Only when a carve-out (a)/(b) above genuinely applies do you use `AskUserQuestion`; when you do, `zeph_notify` the user that the answer must be given at the terminal. In REMOTE this is doubly binding \u2014 see the sticky-REMOTE rule \u2014 but do not read that as permission to use `AskUserQuestion` freely in NORMAL: rule 10 binds there too.\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\n3. **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 applies even on research / analysis / planning turns where no files were touched.\n\n Anti-pattern (wrong):\n ```\n \"...build/commit on hold. Apply A?\"\n ```\n Correct:\n ```\n zeph_ask({\n title: \"Apply solution A?\",\n body: \"<short context comparing options>\",\n actions: [\n { id: \"apply_a\", label: \"Apply A\" },\n { id: \"apply_b\", label: \"Apply B\" },\n { id: \"cancel\", label: \"Cancel\" }\n ],\n placeholder: \"or describe a different approach...\",\n fallback: \"cancel\"\n })\n ```\n\n### When zeph_ask is the DEFAULT (substantial work)\n\n4. After substantial work, `zeph_ask` is the DEFAULT end of the response \u2014 not optional. \"Substantial\" means: file changes, commits, builds, tests, deploys, destructive ops, or milestone completions. When unsure, treat the work as substantial \u2014 do not try to guess what the user would find \"reasonable\" to confirm.\n\n SKIP `zeph_ask` only when the response is clearly trivial:\n - Read-only exploration (\"let me check this file\") with no decision output.\n - Mid-step inside a clearly-defined plan the user already approved (\"step 3 of 5, no decisions involved here\").\n - Trivial change (typo fix, single-line tweak) that needs no ack.\n\n When unsure: LEAN TOWARD ASKING. Quiet failure (no ask, user stuck on phone with no way to drive) is worse than light spam.\n\n5. Prefer `zeph_ask` over `zeph_prompt`/`zeph_input` \u2014 it combines buttons and free-text in one push. Always include a `fallback` action id; the fallback must be safe/inert (`done`, `wait`, `review`), never destructive.\n\n6. Example `zeph_ask` shape \u2014 use sparingly per Rule 4 (only at natural pause points; NOT after every response \u2014 see Rule 9):\n ```\n zeph_ask({\n title: \"Done. Next?\",\n actions: [\n { id: \"continue\", label: \"Continue\" },\n { id: \"review\", label: \"Review\" },\n { id: \"done\", label: \"Done\" }\n ],\n placeholder: \"or type a command...\",\n fallback: \"done\"\n })\n ```\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. Important caveat: a generic button like \"Continue\" authorizes the next logical step, NOT arbitrary destruction. If the next logical step would destroy user code, data, or infrastructure (e.g., force-push to a shared branch, `rm -rf` outside the workdir, dropping a database, deleting prod resources), surface that specific risk via a targeted `zeph_ask` before executing \u2014 e.g., title \"About to force-push main \u2014 proceed?\", actions `[ok, cancel]`, fallback `cancel`.\n\n### Sticky REMOTE mode (Rule 9)\n\n**The Ask Loop has two states: REMOTE and NORMAL.** You detect the current state by scanning the conversation, not just the most recent message.\n\n**State in one line:** you are in REMOTE if the most recent remote signal \u2014 a non-exit `zeph_ask` reply, or a user message flagged as phone-originated \u2014 is newer than any exit signal; otherwise (no remote signal yet, or the last signal was an exit) you are in NORMAL. REMOTE is sticky \u2014 every response ends with `zeph_ask` until the user exits.\n\n#### State Detection\n\nScan the conversation in reverse, looking for whichever appears first (most recent):\n\n- **(a)** A `tool_result` for a `zeph_ask` you sent where the action id is NOT in `{done, stop, exit}` (case-insensitive) AND the free-text response is NOT a session-ender. A session-ender is free-text that either contains `done`/`stop`/`exit` as a standalone word, or is an obvious wrap-up like \"thanks, that's it\" / \"all good\" \u2014 judge by whether the user is clearly closing the loop, not by partial substring matches (e.g. \"redo\" does not count as \"done\").\n- **(a2)** A user message accompanied by a system note from the Zeph remote-origin detect hook (\"This user message arrived from the user's phone\u2026\"). The listener verified that exact message was sent from the phone \u2014 treat it exactly like a non-exit `zeph_ask` reply. (This note is only ever emitted in environments running Zeph's prompt-submit hooks \u2014 the Claude Code plugin, or the hooks `zeph setup` installs for Gemini/Codex; where it never appears, this condition simply never fires.)\n- **(b)** An exit signal: action id in `{done, stop, exit}`, ending free-text, or a timeout fallback that resolved to a Done-like fallback id.\n- **(c)** No `zeph_ask` history and no remote-origin note at all.\n\nIf the first hit is **(a)** or **(a2)** \u2192 you are in **REMOTE**.\nIf the first hit is **(b)** or **(c)** \u2192 you are in **NORMAL**.\n\n#### Behavior in REMOTE (sticky, zeph_ask MANDATORY)\n\nEnd EVERY response with `zeph_ask`. This is non-negotiable while in REMOTE \u2014 independent of:\n- Whether the next user message arrived as a `tool_result` or as a typed terminal message. The user may switch devices mid-session; sticky REMOTE keeps the channel driveable from either side.\n- Whether the work was substantial or routine.\n\nSet each REMOTE ask up so silence degrades cleanly: `timeout` 300\u2013600 s and a Done-like `fallback` id. An unanswered ask then exits the loop quietly (a Done-like timeout fallback is an exit signal per **(b)**) instead of chaining more notifications at a user who stepped away \u2014 and re-entry is cheap, they just send another message from the phone.\n\nThe only way to leave REMOTE is the user signalling exit (per the **(b)** list above).\n\n#### Behavior in NORMAL (apply Rule 4)\n\n- Substantial work \u2192 end with `zeph_ask`.\n- Routine work \u2192 skip `zeph_ask`, let the Stop hook fire.\n- Rule 3 (questions \u2192 zeph_ask) is still mandatory regardless.\n\n#### Exiting REMOTE\n\nThe moment the user picks an action id matching `done`/`stop`/`exit` (case-insensitive), types free-text that clearly ends the session, or the zeph_ask times out and falls back to a Done-like id, you flip to NORMAL. Don't send `zeph_ask` on the response that processes the exit signal.\n\n### When to use AskUserQuestion vs zeph_ask\n\n10. **Whenever `ZEPH_HOOK_ID` is set, a button-friendly question MUST go through `zeph_ask`, not `AskUserQuestion` \u2014 and this is NOT gated on REMOTE.** \"Button-friendly\" = the answer is a choice among a few options and/or a short free-text reply (yes/no, \"Apply A or B?\", \"which naming rule?\", \"proceed?\"). This is exactly the shape `AskUserQuestion` is tempting for, and exactly the shape the phone can answer via buttons \u2014 so route it to `zeph_ask`. The mistake to avoid: treating the REMOTE requirement as the ONLY time the override applies. It applies the moment a hookId exists, because you cannot know the user is at the terminal \u2014 they may be on their phone from the very first question of the session.\n\n Fall through to the local `AskUserQuestion` picker ONLY when (a) the answer needs the user to see code or logs that won't fit in a push body, or (b) the answer is plausibly multi-paragraph. Those are the only carve-outs.\n\n11. **This overrides any skill instruction.** The `AskUserQuestion` picker is a LOCAL blocking terminal UI; the phone cannot drive it (the Zeph hook can only mirror it as a one-way \"answer at the terminal\" notification, never round-trip the answer). So if a skill you are running \u2014 or your own plan \u2014 would call `AskUserQuestion` with a button-friendly question, instead surface the SAME question and option labels via `zeph_ask` and use that response in place of the picker. Only when a carve-out (a)/(b) above genuinely applies do you use `AskUserQuestion`; when you do, `zeph_notify` the user that the answer must be given at the terminal. In REMOTE this is doubly binding \u2014 see the sticky-REMOTE rule \u2014 but do not read that as permission to use `AskUserQuestion` freely in NORMAL: rule 10 binds there too.\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.";
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,y7OAAuzO,CAAC;AAE11O,gFAAgF;AAChF,eAAO,MAAM,mBAAmB,y7OAAuzO,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,0GAA0G;AAC1G,eAAO,MAAM,qBAAqB,05QAAqvQ,CAAC;AAExxQ,gFAAgF;AAChF,eAAO,MAAM,mBAAmB,05QAAqvQ,CAAC"}