@zeph-to/cli 1.13.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- const fs_1 = require("fs");
5
4
  const child_process_1 = require("child_process");
6
5
  const zeph_hook_js_1 = require("./zeph-hook.js");
7
6
  const errors_js_1 = require("./errors.js");
@@ -13,21 +12,10 @@ const check_update_js_1 = require("./check-update.js");
13
12
  const wrapper_js_1 = require("./wrapper.js");
14
13
  const listener_js_1 = require("./listener.js");
15
14
  const config_js_1 = require("./config.js");
16
- const PROJECT_DIR_VARS = ['CLAUDE_PROJECT_DIR', 'CURSOR_PROJECT_DIR', 'WINDSURF_PROJECT_DIR'];
17
- const detectProjectDir = () => PROJECT_DIR_VARS.reduce((found, key) => found || process.env[key], undefined) ?? process.cwd();
18
- const isMuted = () => {
19
- try {
20
- const dir = detectProjectDir();
21
- const raw = (0, child_process_1.execFileSync)('cksum', { input: dir, encoding: 'utf-8' });
22
- const hash = raw.split(' ')[0];
23
- return (0, fs_1.existsSync)(`/tmp/zeph-muted-${hash}`);
24
- }
25
- catch {
26
- return false;
27
- }
28
- };
15
+ const gate_js_1 = require("./gate.js");
16
+ const remote_agents_js_1 = require("./remote-agents.js");
29
17
  const detectBranchAndProject = () => {
30
- const dir = detectProjectDir();
18
+ const dir = (0, config_js_1.detectProjectDir)();
31
19
  const project = dir.split('/').filter(Boolean).pop() ?? 'project';
32
20
  let branch;
33
21
  try {
@@ -67,6 +55,8 @@ const parseArgs = (argv) => {
67
55
  return result;
68
56
  };
69
57
  // ── Output ──────────────────────────────────────────────────────
58
+ /** One usage line per registered remote agent — generated so help text can't drift from the table. */
59
+ const usageAgentLines = () => remote_agents_js_1.REMOTE_AGENTS.map((a) => ` ${`${a.subcommands[0]} [args…]`.padEnd(15)} Run '${a.binary}' in a named tmux session ('zeph-<project>')`).join('\n');
70
60
  const printUsage = () => {
71
61
  console.log(`Usage: zeph <command> [options]
72
62
 
@@ -80,9 +70,7 @@ Commands:
80
70
  list List recent push notifications
81
71
  dismiss <id> Dismiss a push notification (or --all)
82
72
  test Send a test notification to verify setup
83
- cc [args…] Run 'claude' in a named tmux session ('zeph-<project>')
84
- codex [args…] Run 'codex' in a named tmux session
85
- gemini [args…] Run 'gemini' in a named tmux session
73
+ ${usageAgentLines()}
86
74
  (auto-suffixed -2/-3/… when another zeph cc is already
87
75
  attached to the default name; any args after the
88
76
  subcommand are forwarded verbatim, e.g.
@@ -99,6 +87,11 @@ Notify options:
99
87
  --priority <p> Priority (low|normal|high|urgent) [default: normal]
100
88
  --device <id> Target device ID
101
89
  --session <id> AI session ID (or set ZEPH_SESSION_ID env)
90
+ --auto Apply the push gate before sending (honors the
91
+ /zeph-quiet | /zeph-loud dial; silent exit when gated)
92
+ --marker <m> Push Signal marker for --auto (skip|push|high)
93
+ --tools <n> Turn tool count for --auto [default: assume real work]
94
+ --nonreadonly <n> Non-read-only tool count for --auto
102
95
 
103
96
  List options:
104
97
  --limit <n> Number of pushes (1-20, default 5)
@@ -166,10 +159,33 @@ const createHook = (args) => {
166
159
  ...(baseUrl && { baseUrl }),
167
160
  });
168
161
  };
162
+ /** Parse a gate count flag; garbage input falls back to the default (never accidentally silences). */
163
+ const gateCount = (raw, fallback) => {
164
+ const n = typeof raw === 'string' ? Number(raw) : NaN;
165
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
166
+ };
169
167
  const handleNotify = async (args) => {
170
168
  const isJson = args.json === true;
171
- if (isMuted())
169
+ const projectDir = (0, config_js_1.detectProjectDir)();
170
+ if ((0, gate_js_1.isMuted)(projectDir))
172
171
  return 0;
172
+ // --auto: apply the shared push-gate before sending. Inputs default to
173
+ // GATE_DEFAULTS ("assume real work") so dumb hooks keep their historical
174
+ // always-push behavior in normal mode, while the /zeph-quiet | /zeph-loud
175
+ // dial now works for every hook-driven agent. Gated-out → silent success.
176
+ if (args.auto === true) {
177
+ const verdict = (0, gate_js_1.decidePush)({
178
+ toolCount: gateCount(args.tools, gate_js_1.GATE_DEFAULTS.toolCount),
179
+ nonReadonlyCount: gateCount(args.nonreadonly, gate_js_1.GATE_DEFAULTS.nonReadonlyCount),
180
+ alreadyAsked: gate_js_1.GATE_DEFAULTS.alreadyAsked,
181
+ marker: (0, gate_js_1.normalizeMarker)(typeof args.marker === 'string' ? args.marker : undefined),
182
+ pushMode: (0, gate_js_1.readPushMode)(projectDir),
183
+ });
184
+ if (!verdict.push)
185
+ return 0;
186
+ if (verdict.priority === 'high' && !args.priority)
187
+ args.priority = 'high';
188
+ }
173
189
  const hook = createHook(args);
174
190
  if (!hook)
175
191
  return 3;
@@ -337,6 +353,12 @@ const main = async () => {
337
353
  printUsage();
338
354
  return 0;
339
355
  }
356
+ // Remote-control subcommands (`zeph cc` / `zeph codex` / …) come from the
357
+ // registry — one table row per agent, no hardcoded cases. Pass the typed
358
+ // command token to collectPassthrough (aliases map to the same agent).
359
+ const remote = (0, remote_agents_js_1.findAgentBySubcommand)(command);
360
+ if (remote)
361
+ return (0, wrapper_js_1.handleAgentSession)(remote, collectPassthrough(process.argv, command));
340
362
  switch (command) {
341
363
  case 'install':
342
364
  case 'setup':
@@ -357,12 +379,6 @@ const main = async () => {
357
379
  return handleDismiss(args);
358
380
  case 'test':
359
381
  return handleTest(args);
360
- case 'cc':
361
- return (0, wrapper_js_1.handleAgentSession)('claude', collectPassthrough(process.argv, 'cc'));
362
- case 'codex':
363
- return (0, wrapper_js_1.handleAgentSession)('codex', collectPassthrough(process.argv, 'codex'));
364
- case 'gemini':
365
- return (0, wrapper_js_1.handleAgentSession)('gemini', collectPassthrough(process.argv, 'gemini'));
366
382
  case 'listener':
367
383
  return (0, listener_js_1.handleListener)(args);
368
384
  default:
package/dist/config.d.ts CHANGED
@@ -8,6 +8,9 @@ export interface ZephConfig {
8
8
  deviceId?: string;
9
9
  }
10
10
  export declare const resolvedEnv: (key: string) => string | undefined;
11
+ export declare const PROJECT_DIR_ENV_VARS: readonly ["CLAUDE_PROJECT_DIR", "CURSOR_PROJECT_DIR", "WINDSURF_PROJECT_DIR"];
12
+ /** First set project-dir env (unresolved `${VAR}` placeholders ignored), else cwd. */
13
+ export declare const detectProjectDir: () => string;
11
14
  export declare const loadConfig: () => ZephConfig;
12
15
  export declare const saveConfig: (config: ZephConfig) => void;
13
16
  export declare const VERSION: string;
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,QAA2B,CAAC;AACnD,eAAO,MAAM,WAAW,QAAkC,CAAC;AAE3D,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,SAGlD,CAAC;AAEF,eAAO,MAAM,UAAU,QAAO,UAM7B,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,QAAQ,UAAU,KAAG,IAG/C,CAAC;AAEF,eAAO,MAAM,OAAO,QAOhB,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,QAA2B,CAAC;AACnD,eAAO,MAAM,WAAW,QAAkC,CAAC;AAE3D,MAAM,WAAW,UAAU;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,SAGlD,CAAC;AAMF,eAAO,MAAM,oBAAoB,+EAAgF,CAAC;AAElH,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,QAAO,MAMnC,CAAC;AAEF,eAAO,MAAM,UAAU,QAAO,UAM7B,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,QAAQ,UAAU,KAAG,IAG/C,CAAC;AAEF,eAAO,MAAM,OAAO,QAOhB,CAAC"}
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.VERSION = exports.saveConfig = exports.loadConfig = exports.resolvedEnv = exports.CONFIG_FILE = exports.CONFIG_DIR = void 0;
3
+ exports.VERSION = exports.saveConfig = exports.loadConfig = exports.detectProjectDir = exports.PROJECT_DIR_ENV_VARS = exports.resolvedEnv = exports.CONFIG_FILE = exports.CONFIG_DIR = void 0;
4
4
  const fs_1 = require("fs");
5
5
  const os_1 = require("os");
6
6
  const path_1 = require("path");
@@ -11,6 +11,21 @@ const resolvedEnv = (key) => {
11
11
  return val && !val.startsWith('${') ? val : undefined;
12
12
  };
13
13
  exports.resolvedEnv = resolvedEnv;
14
+ // Per-agent project-dir env vars, in precedence order. Deliberately NOT part
15
+ // of the remote-agent registry: Cursor/Windsurf carry project-dir envs but
16
+ // are not remote-controllable via tmux — the two tables have different
17
+ // membership.
18
+ exports.PROJECT_DIR_ENV_VARS = ['CLAUDE_PROJECT_DIR', 'CURSOR_PROJECT_DIR', 'WINDSURF_PROJECT_DIR'];
19
+ /** First set project-dir env (unresolved `${VAR}` placeholders ignored), else cwd. */
20
+ const detectProjectDir = () => {
21
+ for (const key of exports.PROJECT_DIR_ENV_VARS) {
22
+ const val = (0, exports.resolvedEnv)(key);
23
+ if (val)
24
+ return val;
25
+ }
26
+ return process.cwd();
27
+ };
28
+ exports.detectProjectDir = detectProjectDir;
14
29
  const loadConfig = () => {
15
30
  try {
16
31
  return JSON.parse((0, fs_1.readFileSync)(exports.CONFIG_FILE, 'utf-8'));
@@ -1 +1 @@
1
- {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AA2JH;;;;;GAKG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAsE5E,CAAC;AAqCF,eAAO,MAAM,UAAU,QAAO,aAAa,GAAG,IAAqB,CAAC;AACpE,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAA+B,CAAC;AAEzE;;;GAGG;AACH,eAAO,MAAM,eAAe,GAC1B,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACtD,uBAAuB,MAAM,KAC5B,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAeA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,sBAAsB,GACjC,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,KACrD,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAaA,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,uBAAuB,GAClC,SAAS,MAAM,EACf,uBAAuB,MAAM,KAC5B,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CASlE,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,kBAAkB,GAC7B,SAAS,MAAM,KACd,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAQlE,CAAC"}
1
+ {"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AA2JH;;;;;GAKG;AACH,eAAO,MAAM,UAAU,GAAI,SAAS,MAAM,EAAE,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAsE5E,CAAC;AA2CF,eAAO,MAAM,UAAU,QAAO,aAAa,GAAG,IAAqB,CAAC;AACpE,eAAO,MAAM,YAAY,QAAO,MAAM,GAAG,IAA+B,CAAC;AAEzE;;;GAGG;AACH,eAAO,MAAM,eAAe,GAC1B,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACtD,uBAAuB,MAAM,KAC5B,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAeA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,sBAAsB,GACjC,OAAO;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,KACrD,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,IAAI,CAAC;CACnB,CAaA,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,uBAAuB,GAClC,SAAS,MAAM,EACf,uBAAuB,MAAM,KAC5B,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CASlE,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,kBAAkB,GAC7B,SAAS,MAAM,KACd,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAA;CAAE,CAQlE,CAAC"}
package/dist/crypto.js CHANGED
@@ -212,13 +212,19 @@ const fetchServerKeys = async (apiKey, baseUrl) => {
212
212
  return null;
213
213
  }
214
214
  };
215
+ // SECURITY: only the PUBLIC key is ever sent to the server. The server
216
+ // rejects private-key uploads outright (per-device E2E — escrow removed),
217
+ // and a private key must never leave this host. Sending the full
218
+ // ExportedKeyPair previously leaked the private key onto the wire on every
219
+ // init and the rejection was swallowed silently. The per-device migration
220
+ // (see ADR-0007) reworks this path; until then, register the public key only.
215
221
  const uploadServerKeys = async (keys, apiKey, baseUrl) => {
216
222
  try {
217
223
  const url = `${(baseUrl ?? 'https://api.zeph.to/v1').replace(/\/$/, '')}/users/me/keys`;
218
224
  await fetch(url, {
219
225
  method: 'PUT',
220
226
  headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
221
- body: JSON.stringify(keys),
227
+ body: JSON.stringify({ publicKey: keys.publicKey }),
222
228
  });
223
229
  }
224
230
  catch { /* non-critical */ }
package/dist/gate.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ export type GateMarker = 'skip' | 'push' | 'high' | 'none';
2
+ export type GatePushMode = 'quiet' | 'loud' | 'normal';
3
+ export interface GateInput {
4
+ /** Total tool_use blocks this turn. */
5
+ toolCount: number;
6
+ /** Tools that are NOT read-only (Read/Grep/Glob). */
7
+ nonReadonlyCount: number;
8
+ /** A zeph_ask/zeph_prompt already notified this turn. */
9
+ alreadyAsked: boolean;
10
+ marker: GateMarker;
11
+ pushMode: GatePushMode;
12
+ }
13
+ export interface GateVerdict {
14
+ push: boolean;
15
+ priority: 'high' | 'normal';
16
+ }
17
+ /**
18
+ * "Assume real work" defaults for hooks that can't supply turn facts
19
+ * (most non-Claude agents pass no counts): in normal mode the push still
20
+ * fires — preserving the historical always-push behavior of the dumb
21
+ * hooks — while quiet/loud now work everywhere.
22
+ */
23
+ export declare const GATE_DEFAULTS: {
24
+ readonly toolCount: 2;
25
+ readonly nonReadonlyCount: 1;
26
+ readonly alreadyAsked: false;
27
+ };
28
+ export declare const normalizeMarker: (raw: string | undefined) => GateMarker;
29
+ export declare const normalizePushMode: (raw: string | undefined) => GatePushMode;
30
+ export declare const decidePush: (input: GateInput) => GateVerdict;
31
+ export declare const projectHash: (dir: string) => string | null;
32
+ /** True when the user ran /zeph-mute for this project. */
33
+ export declare const isMuted: (dir: string) => boolean;
34
+ /** The user's session push-mode dial (/zeph-quiet | /zeph-loud), default normal. */
35
+ export declare const readPushMode: (dir: string) => GatePushMode;
36
+ //# sourceMappingURL=gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAsBA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAC3D,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,gBAAgB,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,YAAY,EAAE,OAAO,CAAC;IACtB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,YAAY,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;CAC7B;AAED;;;;;GAKG;AACH,eAAO,MAAM,aAAa;;;;CAIhB,CAAC;AAEX,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,UACS,CAAC;AAEpE,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,YACR,CAAC;AAErD,eAAO,MAAM,UAAU,GAAI,OAAO,SAAS,KAAG,WAW7C,CAAC;AAQF,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAOlD,CAAC;AAEF,0DAA0D;AAC1D,eAAO,MAAM,OAAO,GAAI,KAAK,MAAM,KAAG,OAGrC,CAAC;AAEF,oFAAoF;AACpF,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,KAAG,YAQ1C,CAAC"}
package/dist/gate.js ADDED
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readPushMode = exports.isMuted = exports.projectHash = exports.decidePush = exports.normalizePushMode = exports.normalizeMarker = exports.GATE_DEFAULTS = void 0;
4
+ /**
5
+ * Push-gate decision — the portable half of the Zeph Stop-hook logic.
6
+ *
7
+ * `decidePush` is the TS twin of `plugin/hooks/gate.sh` (zeph-to/plugin).
8
+ * Both implementations are locked to the same semantics by the shared
9
+ * vector file `src/fixtures/gate-vectors.json` (vendored from the plugin
10
+ * repo's canonical copy via `npm run sync:plugin`): the bash side and this
11
+ * side run the exact same cases in their CIs, so a semantic change to one
12
+ * that isn't mirrored in the other fails a build. Edit them together.
13
+ *
14
+ * Ordering is contractual (encoded as named vectors):
15
+ * 1. alreadyAsked wins over EVERYTHING — even loud (dedup beats the dial).
16
+ * 2. priority is high iff marker === 'high', decided BEFORE the mode
17
+ * switch, so quiet+high and loud+high both push at high priority.
18
+ * 3. quiet → only a high marker pushes; loud → always push; normal →
19
+ * marker overrides the heuristic (skip → silent, push/high → push),
20
+ * no marker → push iff toolCount ≥ 2 AND nonReadonlyCount > 0
21
+ * (the B1 read-only floor).
22
+ */
23
+ const child_process_1 = require("child_process");
24
+ const fs_1 = require("fs");
25
+ /**
26
+ * "Assume real work" defaults for hooks that can't supply turn facts
27
+ * (most non-Claude agents pass no counts): in normal mode the push still
28
+ * fires — preserving the historical always-push behavior of the dumb
29
+ * hooks — while quiet/loud now work everywhere.
30
+ */
31
+ exports.GATE_DEFAULTS = {
32
+ toolCount: 2,
33
+ nonReadonlyCount: 1,
34
+ alreadyAsked: false,
35
+ };
36
+ const normalizeMarker = (raw) => raw === 'skip' || raw === 'push' || raw === 'high' ? raw : 'none';
37
+ exports.normalizeMarker = normalizeMarker;
38
+ const normalizePushMode = (raw) => raw === 'quiet' || raw === 'loud' ? raw : 'normal';
39
+ exports.normalizePushMode = normalizePushMode;
40
+ const decidePush = (input) => {
41
+ if (input.alreadyAsked)
42
+ return { push: false, priority: 'normal' };
43
+ const priority = input.marker === 'high' ? 'high' : 'normal';
44
+ if (input.pushMode === 'quiet')
45
+ return { push: input.marker === 'high', priority };
46
+ if (input.pushMode === 'loud')
47
+ return { push: true, priority };
48
+ if (input.marker === 'skip')
49
+ return { push: false, priority };
50
+ if (input.marker === 'push' || input.marker === 'high')
51
+ return { push: true, priority };
52
+ return { push: input.toolCount >= 2 && input.nonReadonlyCount > 0, priority };
53
+ };
54
+ exports.decidePush = decidePush;
55
+ // ── Per-project gate state (mute + push-mode dial) ───────────────
56
+ //
57
+ // The plugin's bash hooks key these tmp files off `cksum` of the project
58
+ // dir; shelling out to the same `cksum` here (instead of a pure-TS CRC)
59
+ // guarantees hash parity with every already-written file.
60
+ const projectHash = (dir) => {
61
+ try {
62
+ const raw = (0, child_process_1.execFileSync)('cksum', { input: dir, encoding: 'utf-8' });
63
+ return raw.split(' ')[0] || null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ };
69
+ exports.projectHash = projectHash;
70
+ /** True when the user ran /zeph-mute for this project. */
71
+ const isMuted = (dir) => {
72
+ const hash = (0, exports.projectHash)(dir);
73
+ return hash !== null && (0, fs_1.existsSync)(`/tmp/zeph-muted-${hash}`);
74
+ };
75
+ exports.isMuted = isMuted;
76
+ /** The user's session push-mode dial (/zeph-quiet | /zeph-loud), default normal. */
77
+ const readPushMode = (dir) => {
78
+ const hash = (0, exports.projectHash)(dir);
79
+ if (!hash)
80
+ return 'normal';
81
+ try {
82
+ return (0, exports.normalizePushMode)((0, fs_1.readFileSync)(`/tmp/zeph-pushmode-${hash}`, 'utf-8').replace(/\s+/g, ''));
83
+ }
84
+ catch {
85
+ return 'normal';
86
+ }
87
+ };
88
+ exports.readPushMode = readPushMode;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { ZephHook } from './zeph-hook.js';
2
2
  export { ZephError, AuthenticationError, QuotaExceededError } from './errors.js';
3
3
  export type { ZephOptions, NotifyPayload, NotifyResult, ListParams, ListResult, DismissOneResult, DismissAllResult, PushItem } from './types.js';
4
+ export { decidePush, GATE_DEFAULTS, normalizeMarker, normalizePushMode } from './gate.js';
5
+ export type { GateInput, GateVerdict, GateMarker, GatePushMode } from './gate.js';
4
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjF,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjF,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACjJ,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1F,YAAY,EAAE,SAAS,EAAE,WAAW,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC"}
package/dist/index.js CHANGED
@@ -1,9 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.QuotaExceededError = exports.AuthenticationError = exports.ZephError = exports.ZephHook = void 0;
3
+ exports.normalizePushMode = exports.normalizeMarker = exports.GATE_DEFAULTS = exports.decidePush = exports.QuotaExceededError = exports.AuthenticationError = exports.ZephError = exports.ZephHook = void 0;
4
4
  var zeph_hook_js_1 = require("./zeph-hook.js");
5
5
  Object.defineProperty(exports, "ZephHook", { enumerable: true, get: function () { return zeph_hook_js_1.ZephHook; } });
6
6
  var errors_js_1 = require("./errors.js");
7
7
  Object.defineProperty(exports, "ZephError", { enumerable: true, get: function () { return errors_js_1.ZephError; } });
8
8
  Object.defineProperty(exports, "AuthenticationError", { enumerable: true, get: function () { return errors_js_1.AuthenticationError; } });
9
9
  Object.defineProperty(exports, "QuotaExceededError", { enumerable: true, get: function () { return errors_js_1.QuotaExceededError; } });
10
+ var gate_js_1 = require("./gate.js");
11
+ Object.defineProperty(exports, "decidePush", { enumerable: true, get: function () { return gate_js_1.decidePush; } });
12
+ Object.defineProperty(exports, "GATE_DEFAULTS", { enumerable: true, get: function () { return gate_js_1.GATE_DEFAULTS; } });
13
+ Object.defineProperty(exports, "normalizeMarker", { enumerable: true, get: function () { return gate_js_1.normalizeMarker; } });
14
+ Object.defineProperty(exports, "normalizePushMode", { enumerable: true, get: function () { return gate_js_1.normalizePushMode; } });
@@ -20,7 +20,7 @@
20
20
  * messages as new pushes are created. Reconnects with exponential
21
21
  * backoff on transient failures; gives up on auth failures (4001/4002/4003).
22
22
  */
23
- type AgentKind = 'claude' | 'codex' | 'gemini';
23
+ import { type AgentKind, type RegisteredRemoteAgent } from './remote-agents.js';
24
24
  interface AgentSession {
25
25
  name: string;
26
26
  attached: boolean;
@@ -31,6 +31,7 @@ interface AgentSession {
31
31
  createdAt?: string;
32
32
  lastActivityAt?: string;
33
33
  }
34
+ export declare const AUTH_FAILURE_CODES: ReadonlySet<number>;
34
35
  export declare const checkRateLimit: (session: string, now?: number) => boolean;
35
36
  /** Read the foreground command in the named tmux session's active pane. */
36
37
  export declare const paneCurrentCommand: (session: string) => string | null;
@@ -54,15 +55,22 @@ export declare const parseSessionName: (name: string) => {
54
55
  project: string;
55
56
  label: string | null;
56
57
  } | null;
58
+ interface PaneInfo {
59
+ currentCommand: string | null;
60
+ startCommand: string | null;
61
+ currentPath: string | null;
62
+ }
57
63
  /**
58
- * Locate the most recent Claude Code session UUID for the working
59
- * directory of a tmux pane. Mirrors `mcp-server/config.ts`'s
60
- * detectClaudeSessionId: CC writes per-session jsonl files at
61
- * `~/.claude/projects/<projectHash>/<UUID>.jsonl` where the hash is
62
- * the cwd with `/` replaced by `-`. Cached for 60s — see
63
- * claudeSessionCache.
64
+ * Identify the agent from the tmux pane. Prefer `pane_start_command`
65
+ * because the foreground process is usually `node`/`python3` (the
66
+ * interpreter), which doesn't tell us *what* was launched. Fall back to
67
+ * `pane_current_command` when start_command is empty — tmux clears
68
+ * start_command in some re-attach cases, especially when a pre-existing
69
+ * session was joined via `tmux new -A` instead of being created fresh.
70
+ * That fallback is safe because only the literal binaries registered in
71
+ * remote-agents.ts are accepted as a match.
64
72
  */
65
- export declare const detectClaudeSessionId: (cwd: string) => string | null;
73
+ export declare const detectRemoteAgent: (info: PaneInfo) => RegisteredRemoteAgent | null;
66
74
  export interface CollectResult {
67
75
  sessions: AgentSession[];
68
76
  /** Diagnostic notes per rejected session — surfaced under `--verbose`. */
@@ -80,11 +88,12 @@ export interface CollectResult {
80
88
  export declare const collectSessionsVerbose: () => CollectResult;
81
89
  /**
82
90
  * Snapshot the live `zeph-*` tmux sessions on this machine, enriched
83
- * with the running agent kind, CC session UUID (claude only), project,
84
- * and tmux activity timestamps. Returns [] when tmux is unreachable
85
- * or no agent sessions exist. Sessions whose pane is at a shell or
86
- * running something other than claude/codex/gemini are filtered out
87
- * the phone can't usefully address them.
91
+ * with the running agent kind, the agent's own session id (when the
92
+ * registry has a resolver currently Claude Code only), project, and
93
+ * tmux activity timestamps. Returns [] when tmux is unreachable or no
94
+ * agent sessions exist. Sessions whose pane is at a shell or running
95
+ * something not registered in remote-agents.ts are filtered out — the
96
+ * phone can't usefully address them.
88
97
  */
89
98
  export declare const collectSessions: () => AgentSession[];
90
99
  /**
@@ -141,6 +150,7 @@ export declare const gcAttachments: (now?: number, dir?: string, ttl?: number) =
141
150
  * encrypted pushes, normal text/link/file notifications) is ignored.
142
151
  */
143
152
  export declare const handlePush: (push: PushItem, deps?: HandlePushDeps) => Promise<boolean>;
153
+ export declare const computeBackoff: (attempt: number) => number;
144
154
  /**
145
155
  * Stable per-host device id for the listener. We hash the OS hostname so
146
156
  * the same machine reuses the same DeviceRecord across listener restarts
@@ -1 +1 @@
1
- {"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../src/listener.ts"],"names":[],"mappings":"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"}
package/dist/listener.js CHANGED
@@ -25,7 +25,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
25
25
  return (mod && mod.__esModule) ? mod : { "default": mod };
26
26
  };
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
- exports.handleListener = exports.computeListenerDeviceId = exports.handlePush = exports.gcAttachments = exports.setAttachmentContext = exports.collectSessions = exports.collectSessionsVerbose = exports.detectClaudeSessionId = exports.parseSessionName = exports.invalidateTmuxSocketCache = exports.paneCurrentCommand = exports.checkRateLimit = void 0;
28
+ exports.handleListener = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.setAttachmentContext = exports.collectSessions = exports.collectSessionsVerbose = exports.detectRemoteAgent = exports.parseSessionName = exports.invalidateTmuxSocketCache = exports.paneCurrentCommand = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = void 0;
29
29
  const child_process_1 = require("child_process");
30
30
  const crypto_1 = require("crypto");
31
31
  const fs_1 = require("fs");
@@ -33,6 +33,7 @@ const os_1 = require("os");
33
33
  const path_1 = require("path");
34
34
  const ws_1 = __importDefault(require("ws"));
35
35
  const config_js_1 = require("./config.js");
36
+ const remote_agents_js_1 = require("./remote-agents.js");
36
37
  const PING_INTERVAL_MS = 25_000;
37
38
  const PONG_TIMEOUT_MS = 10_000;
38
39
  const RECONNECT_BASE_MS = 1_000;
@@ -54,7 +55,6 @@ const WS_STALL_TIMEOUT_MS = 90_000;
54
55
  // to reflect new `zeph cc` sessions within a few seconds, not half a
55
56
  // minute.
56
57
  const SESSION_REPORT_INTERVAL_MS = 5_000;
57
- const AGENT_KINDS = ['claude', 'codex', 'gemini'];
58
58
  // Per-session token bucket — caps a runaway/compromised sender. 30/min
59
59
  // is generous for human-driven phone use, tight enough to block flooding.
60
60
  const RATE_LIMIT_TOKENS = 30;
@@ -63,7 +63,7 @@ const RATE_LIMIT_WINDOW_MS = 60_000;
63
63
  const SHELL_COMMANDS = new Set(['bash', 'zsh', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh', 'pwsh']);
64
64
  // Auth-failure close codes: retrying with the same bad credentials hammers
65
65
  // the server forever, so the listener exits instead.
66
- const AUTH_FAILURE_CODES = new Set([4001, 4002, 4003]);
66
+ exports.AUTH_FAILURE_CODES = new Set([4001, 4002, 4003]);
67
67
  const buckets = new Map();
68
68
  // Evict idle buckets older than this so the Map can't grow without bound
69
69
  // under attack. Two refill windows past full refill = bucket is at cap
@@ -400,70 +400,6 @@ const parseSessionName = (name) => {
400
400
  return { project: rest, label: null };
401
401
  };
402
402
  exports.parseSessionName = parseSessionName;
403
- const CLAUDE_PROJECTS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'projects');
404
- /**
405
- * Cache for detectClaudeSessionId. The function walks every jsonl file
406
- * in `~/.claude/projects/<hash>/` on each call — after weeks of CC use
407
- * that directory holds hundreds of session files, and we were calling
408
- * this per tmux session per 5-second report cycle. Heavy disk I/O
409
- * compounded with multiple sessions caused the report cycle to spike
410
- * CPU and starve the host shell.
411
- *
412
- * The current-session UUID only changes when a new CC session starts
413
- * in that directory (rare, on the order of hours), so a 60-second TTL
414
- * is safe and cuts the per-cycle stat count by ~12×.
415
- */
416
- const claudeSessionCache = new Map();
417
- const CLAUDE_SESSION_CACHE_TTL_MS = 60_000;
418
- const doDetectClaudeSessionId = (cwd) => {
419
- try {
420
- const projectHash = cwd.replace(/\//g, '-');
421
- const sessionsDir = (0, path_1.join)(CLAUDE_PROJECTS_DIR, projectHash);
422
- let latest;
423
- for (const entry of (0, fs_1.readdirSync)(sessionsDir)) {
424
- const m = entry.match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/);
425
- if (!m)
426
- continue;
427
- const stat = (0, fs_1.statSync)((0, path_1.join)(sessionsDir, entry));
428
- if (!stat.isFile())
429
- continue;
430
- if (!latest || stat.mtimeMs > latest.mtime) {
431
- latest = { name: m[1], mtime: stat.mtimeMs };
432
- }
433
- }
434
- return latest?.name ?? null;
435
- }
436
- catch {
437
- return null;
438
- }
439
- };
440
- /**
441
- * Locate the most recent Claude Code session UUID for the working
442
- * directory of a tmux pane. Mirrors `mcp-server/config.ts`'s
443
- * detectClaudeSessionId: CC writes per-session jsonl files at
444
- * `~/.claude/projects/<projectHash>/<UUID>.jsonl` where the hash is
445
- * the cwd with `/` replaced by `-`. Cached for 60s — see
446
- * claudeSessionCache.
447
- */
448
- const detectClaudeSessionId = (cwd) => {
449
- const now = Date.now();
450
- const cached = claudeSessionCache.get(cwd);
451
- if (cached && cached.expiresAt > now)
452
- return cached.sessionId;
453
- // Cap cache size so a long-lived listener that's seen many cwds
454
- // doesn't grow unbounded. 64 is plenty for any realistic setup.
455
- if (claudeSessionCache.size >= 64) {
456
- // Evict the oldest-expiring entry — Map iteration order is
457
- // insertion order, so the first key we hit is the oldest.
458
- const firstKey = claudeSessionCache.keys().next().value;
459
- if (firstKey !== undefined)
460
- claudeSessionCache.delete(firstKey);
461
- }
462
- const sessionId = doDetectClaudeSessionId(cwd);
463
- claudeSessionCache.set(cwd, { sessionId, expiresAt: now + CLAUDE_SESSION_CACHE_TTL_MS });
464
- return sessionId;
465
- };
466
- exports.detectClaudeSessionId = detectClaudeSessionId;
467
403
  // U+241F "Symbol for Unit Separator" — a *printable* Unicode glyph
468
404
  // (3-byte UTF-8) that visually represents the C0 Unit Separator but is
469
405
  // itself a normal character. Critical detail: tmux 3.5a's `-F` format
@@ -509,28 +445,19 @@ const firstTokenBasename = (cmd) => {
509
445
  return (0, path_1.basename)(stripped.split(/\s+/)[0] || '');
510
446
  };
511
447
  /**
512
- * Identify the agent type from the tmux pane. Prefer `pane_start_command`
448
+ * Identify the agent from the tmux pane. Prefer `pane_start_command`
513
449
  * because the foreground process is usually `node`/`python3` (the
514
450
  * interpreter), which doesn't tell us *what* was launched. Fall back to
515
451
  * `pane_current_command` when start_command is empty — tmux clears
516
452
  * start_command in some re-attach cases, especially when a pre-existing
517
453
  * session was joined via `tmux new -A` instead of being created fresh.
518
- * That fallback is safe because we only accept literal `claude` /
519
- * `codex` / `gemini` as a match.
454
+ * That fallback is safe because only the literal binaries registered in
455
+ * remote-agents.ts are accepted as a match.
520
456
  */
521
- const detectAgentKind = (info) => {
522
- const startBase = firstTokenBasename(info.startCommand);
523
- for (const k of AGENT_KINDS) {
524
- if (startBase === k)
525
- return k;
526
- }
527
- const currentBase = firstTokenBasename(info.currentCommand);
528
- for (const k of AGENT_KINDS) {
529
- if (currentBase === k)
530
- return k;
531
- }
532
- return null;
533
- };
457
+ const detectRemoteAgent = (info) => (0, remote_agents_js_1.matchAgentByPaneCommand)(firstTokenBasename(info.startCommand))
458
+ ?? (0, remote_agents_js_1.matchAgentByPaneCommand)(firstTokenBasename(info.currentCommand))
459
+ ?? null;
460
+ exports.detectRemoteAgent = detectRemoteAgent;
534
461
  const epochToIso = (epoch) => {
535
462
  if (!epoch)
536
463
  return undefined;
@@ -583,21 +510,21 @@ const collectSessionsVerbose = () => {
583
510
  continue;
584
511
  }
585
512
  const info = readPaneInfo(name);
586
- const agentKind = detectAgentKind(info);
587
- if (!agentKind) {
513
+ const agent = (0, exports.detectRemoteAgent)(info);
514
+ if (!agent) {
588
515
  rejected.push({
589
516
  name,
590
517
  reason: `no agent in pane (start=${info.startCommand ?? 'null'}, current=${info.currentCommand ?? 'null'})`,
591
518
  });
592
519
  continue;
593
520
  }
594
- const agentSessionId = agentKind === 'claude' && info.currentPath
595
- ? (0, exports.detectClaudeSessionId)(info.currentPath)
521
+ const agentSessionId = info.currentPath
522
+ ? (agent.resolveSessionId?.(info.currentPath) ?? null)
596
523
  : null;
597
524
  sessions.push({
598
525
  name,
599
526
  attached: attached === '1',
600
- agentKind,
527
+ agentKind: agent.kind,
601
528
  agentSessionId,
602
529
  project: parsed.project,
603
530
  label: parsed.label,
@@ -610,11 +537,12 @@ const collectSessionsVerbose = () => {
610
537
  exports.collectSessionsVerbose = collectSessionsVerbose;
611
538
  /**
612
539
  * Snapshot the live `zeph-*` tmux sessions on this machine, enriched
613
- * with the running agent kind, CC session UUID (claude only), project,
614
- * and tmux activity timestamps. Returns [] when tmux is unreachable
615
- * or no agent sessions exist. Sessions whose pane is at a shell or
616
- * running something other than claude/codex/gemini are filtered out
617
- * the phone can't usefully address them.
540
+ * with the running agent kind, the agent's own session id (when the
541
+ * registry has a resolver currently Claude Code only), project, and
542
+ * tmux activity timestamps. Returns [] when tmux is unreachable or no
543
+ * agent sessions exist. Sessions whose pane is at a shell or running
544
+ * something not registered in remote-agents.ts are filtered out — the
545
+ * phone can't usefully address them.
618
546
  */
619
547
  const collectSessions = () => (0, exports.collectSessionsVerbose)().sessions;
620
548
  exports.collectSessions = collectSessions;
@@ -812,6 +740,7 @@ const computeBackoff = (attempt) => {
812
740
  const jitter = base * RECONNECT_JITTER_RATIO * (Math.random() * 2 - 1);
813
741
  return Math.max(0, base + jitter);
814
742
  };
743
+ exports.computeBackoff = computeBackoff;
815
744
  /**
816
745
  * Stable per-host device id for the listener. We hash the OS hostname so
817
746
  * the same machine reuses the same DeviceRecord across listener restarts
@@ -1139,14 +1068,14 @@ const handleListener = async (args) => {
1139
1068
  activeHandle = streamSession(wsUrl, apiKey);
1140
1069
  const result = await activeHandle.done;
1141
1070
  activeHandle = null;
1142
- if (AUTH_FAILURE_CODES.has(result.closeCode ?? -1)) {
1071
+ if (exports.AUTH_FAILURE_CODES.has(result.closeCode ?? -1)) {
1143
1072
  console.error(`zeph listener: auth failure (${result.closeCode} ${result.reason}). Check API key.`);
1144
1073
  removeListenerPid();
1145
1074
  return 3;
1146
1075
  }
1147
1076
  if (shuttingDown)
1148
1077
  break;
1149
- const delay = computeBackoff(attempt);
1078
+ const delay = (0, exports.computeBackoff)(attempt);
1150
1079
  log(`disconnected (code=${result.closeCode}) — reconnect in ${Math.round(delay / 1000)}s`);
1151
1080
  await sleep(delay);
1152
1081
  attempt = Math.min(attempt + 1, 10);
@@ -0,0 +1,55 @@
1
+ export interface RemoteAgent {
2
+ /** Wire value for AgentSession.agentKind (server/phone contract). */
3
+ kind: string;
4
+ /** Human name for --help text. */
5
+ displayName: string;
6
+ /** Binary launched in the tmux pane; also the primary pane-match token. */
7
+ binary: string;
8
+ /** `zeph <subcommand>` aliases that launch this agent. */
9
+ subcommands: readonly string[];
10
+ /** Extra pane_command basenames accepted as this agent (beyond binary). */
11
+ paneMatchAliases?: readonly string[];
12
+ /**
13
+ * Resolve the agent's own session id from the pane's cwd.
14
+ * EXTENSION POINT: omitted for codex/gemini until their session-file
15
+ * formats are confirmed — the listener then reports agentSessionId: null.
16
+ */
17
+ resolveSessionId?: (paneCwd: string) => string | null;
18
+ }
19
+ /**
20
+ * Locate the most recent Claude Code session UUID for the working
21
+ * directory of a tmux pane. Mirrors `mcp-server/config.ts`'s
22
+ * detectClaudeSessionId: CC writes per-session jsonl files at
23
+ * `~/.claude/projects/<projectHash>/<UUID>.jsonl` where the hash is
24
+ * the cwd with `/` replaced by `-`. Cached for 60s — see
25
+ * claudeSessionCache.
26
+ */
27
+ export declare const detectClaudeSessionId: (cwd: string) => string | null;
28
+ declare const REMOTE_AGENT_TABLE: readonly [{
29
+ readonly kind: "claude";
30
+ readonly displayName: "Claude Code";
31
+ readonly binary: "claude";
32
+ readonly subcommands: readonly ["cc", "claude"];
33
+ readonly resolveSessionId: (cwd: string) => string | null;
34
+ }, {
35
+ readonly kind: "codex";
36
+ readonly displayName: "Codex CLI";
37
+ readonly binary: "codex";
38
+ readonly subcommands: readonly ["codex"];
39
+ }, {
40
+ readonly kind: "gemini";
41
+ readonly displayName: "Gemini CLI";
42
+ readonly binary: "gemini";
43
+ readonly subcommands: readonly ["gemini"];
44
+ }];
45
+ /** Closed union of remote-controllable agent kinds ('claude' | 'codex' | 'gemini'). */
46
+ export type AgentKind = (typeof REMOTE_AGENT_TABLE)[number]['kind'];
47
+ /** A registry row: the uniform RemoteAgent shape with `kind` narrowed to the closed union. */
48
+ export type RegisteredRemoteAgent = RemoteAgent & {
49
+ kind: AgentKind;
50
+ };
51
+ export declare const REMOTE_AGENTS: readonly RegisteredRemoteAgent[];
52
+ export declare const findAgentBySubcommand: (cmd: string) => RegisteredRemoteAgent | undefined;
53
+ export declare const matchAgentByPaneCommand: (base: string) => RegisteredRemoteAgent | undefined;
54
+ export {};
55
+ //# sourceMappingURL=remote-agents.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-agents.d.ts","sourceRoot":"","sources":["../src/remote-agents.ts"],"names":[],"mappings":"AAiBA,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;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CACzD;AAyCD;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAiB5D,CAAC;AAIF,QAAA,MAAM,kBAAkB;;;;;qCArBmB,MAAM,KAAG,MAAM,GAAG,IAAI;;;;;;;;;;;EAyCtB,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"}
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.matchAgentByPaneCommand = exports.findAgentBySubcommand = exports.REMOTE_AGENTS = exports.detectClaudeSessionId = void 0;
4
+ /**
5
+ * Remote-control agent registry — the single table behind `zeph cc` /
6
+ * `zeph codex` / `zeph gemini`, the listener's pane matching, and the
7
+ * per-agent session-id enrichment. Adding a remote-controllable agent is
8
+ * one row here (plus, for a genuinely new kind, backend/phone support:
9
+ * `kind` is a wire contract — AgentSession.agentKind flows to the server
10
+ * and the phone picker, which may validate the enum).
11
+ *
12
+ * This is deliberately NOT merged into `agents.ts`: that table drives
13
+ * install/uninstall/verify detection (8 agents, incl. Cursor/Windsurf
14
+ * which can never be driven via tmux), and the two tables carry different
15
+ * name axes — install id vs subcommand alias vs pane binary.
16
+ */
17
+ const fs_1 = require("fs");
18
+ const os_1 = require("os");
19
+ const path_1 = require("path");
20
+ // ── Claude Code session resolver ─────────────────────────────────
21
+ const CLAUDE_PROJECTS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'projects');
22
+ /**
23
+ * Cache for detectClaudeSessionId. The function walks every jsonl file
24
+ * in `~/.claude/projects/<hash>/` on each call — after weeks of CC use
25
+ * that directory holds hundreds of session files, and we were calling
26
+ * this per tmux session per 5-second report cycle. Heavy disk I/O
27
+ * compounded with multiple sessions caused the report cycle to spike
28
+ * CPU and starve the host shell.
29
+ *
30
+ * The current-session UUID only changes when a new CC session starts
31
+ * in that directory (rare, on the order of hours), so a 60-second TTL
32
+ * is safe and cuts the per-cycle stat count by ~12×.
33
+ */
34
+ const claudeSessionCache = new Map();
35
+ const CLAUDE_SESSION_CACHE_TTL_MS = 60_000;
36
+ const doDetectClaudeSessionId = (cwd) => {
37
+ try {
38
+ const projectHash = cwd.replace(/\//g, '-');
39
+ const sessionsDir = (0, path_1.join)(CLAUDE_PROJECTS_DIR, projectHash);
40
+ let latest;
41
+ for (const entry of (0, fs_1.readdirSync)(sessionsDir)) {
42
+ const m = entry.match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/);
43
+ if (!m)
44
+ continue;
45
+ const stat = (0, fs_1.statSync)((0, path_1.join)(sessionsDir, entry));
46
+ if (!stat.isFile())
47
+ continue;
48
+ if (!latest || stat.mtimeMs > latest.mtime) {
49
+ latest = { name: m[1], mtime: stat.mtimeMs };
50
+ }
51
+ }
52
+ return latest?.name ?? null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ };
58
+ /**
59
+ * Locate the most recent Claude Code session UUID for the working
60
+ * directory of a tmux pane. Mirrors `mcp-server/config.ts`'s
61
+ * detectClaudeSessionId: CC writes per-session jsonl files at
62
+ * `~/.claude/projects/<projectHash>/<UUID>.jsonl` where the hash is
63
+ * the cwd with `/` replaced by `-`. Cached for 60s — see
64
+ * claudeSessionCache.
65
+ */
66
+ const detectClaudeSessionId = (cwd) => {
67
+ const now = Date.now();
68
+ const cached = claudeSessionCache.get(cwd);
69
+ if (cached && cached.expiresAt > now)
70
+ return cached.sessionId;
71
+ // Cap cache size so a long-lived listener that's seen many cwds
72
+ // doesn't grow unbounded. 64 is plenty for any realistic setup.
73
+ if (claudeSessionCache.size >= 64) {
74
+ // Evict the oldest-expiring entry — Map iteration order is
75
+ // insertion order, so the first key we hit is the oldest.
76
+ const firstKey = claudeSessionCache.keys().next().value;
77
+ if (firstKey !== undefined)
78
+ claudeSessionCache.delete(firstKey);
79
+ }
80
+ const sessionId = doDetectClaudeSessionId(cwd);
81
+ claudeSessionCache.set(cwd, { sessionId, expiresAt: now + CLAUDE_SESSION_CACHE_TTL_MS });
82
+ return sessionId;
83
+ };
84
+ exports.detectClaudeSessionId = detectClaudeSessionId;
85
+ // ── The registry ─────────────────────────────────────────────────
86
+ const REMOTE_AGENT_TABLE = [
87
+ {
88
+ kind: 'claude',
89
+ displayName: 'Claude Code',
90
+ binary: 'claude',
91
+ subcommands: ['cc', 'claude'],
92
+ resolveSessionId: exports.detectClaudeSessionId,
93
+ },
94
+ {
95
+ kind: 'codex',
96
+ displayName: 'Codex CLI',
97
+ binary: 'codex',
98
+ subcommands: ['codex'],
99
+ },
100
+ {
101
+ kind: 'gemini',
102
+ displayName: 'Gemini CLI',
103
+ binary: 'gemini',
104
+ subcommands: ['gemini'],
105
+ },
106
+ ];
107
+ exports.REMOTE_AGENTS = REMOTE_AGENT_TABLE;
108
+ const findAgentBySubcommand = (cmd) => exports.REMOTE_AGENTS.find((a) => a.subcommands.includes(cmd));
109
+ exports.findAgentBySubcommand = findAgentBySubcommand;
110
+ const matchAgentByPaneCommand = (base) => {
111
+ if (!base)
112
+ return undefined;
113
+ return exports.REMOTE_AGENTS.find((a) => a.binary === base || (a.paneMatchAliases ?? []).includes(base));
114
+ };
115
+ exports.matchAgentByPaneCommand = matchAgentByPaneCommand;
@@ -1 +1 @@
1
- {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AAgNA,6EAA6E;AAC7E,eAAO,MAAM,WAAW,QAGtB,CAAC;AAEH,6EAA6E;AAC7E,eAAO,MAAM,aAAa,QAA4C,CAAC;AAEvE,sDAAsD;AACtD,eAAO,MAAM,WAAW,QAA4C,CAAC;AAErE,oDAAoD;AACpD,eAAO,MAAM,UAAU,QAA4C,CAAC;AAEpE,oFAAoF;AACpF,eAAO,MAAM,YAAY,QAA4C,CAAC;AAEtE,gEAAgE;AAChE,eAAO,MAAM,UAAU,QAAuC,CAAC;AAE/D,4FAA4F;AAC5F,eAAO,MAAM,UAAU,QAAuC,CAAC;AAI/D,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":"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"}
package/dist/templates.js CHANGED
@@ -3,7 +3,8 @@
3
3
  //
4
4
  // Every supported agent gets the SAME behavioral rules so Zeph behaves
5
5
  // identically everywhere. The rule text is assembled from one shared
6
- // core (ZEPH_CORE) plus a per-agent notification preamble:
6
+ // generated core (src/zeph-core.generated.ts) plus a per-agent
7
+ // notification preamble:
7
8
  //
8
9
  // - Hook-driven agents (Cursor, Windsurf, Gemini, Codex, Copilot) have
9
10
  // a Stop-equivalent hook installed that auto-pushes on completion, so
@@ -12,160 +13,31 @@
12
13
  // zeph_notify for meaningful completions.
13
14
  //
14
15
  // The Ask-Loop / sticky-REMOTE / question-mandate rules are identical for
15
- // all of them — that is the whole point of the shared ZEPH_CORE.
16
+ // all of them — that is the whole point of the shared generated core.
16
17
  //
17
18
  // Keeping this in one place means a rule change lands everywhere at once
18
19
  // and the agents can't drift apart.
19
20
  Object.defineProperty(exports, "__esModule", { value: true });
20
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;
22
+ const zeph_core_generated_js_1 = require("./zeph-core.generated.js");
21
23
  // Graceful resolution: prefer the installed `zeph` CLI, but fall back to
22
24
  // `npx -y @zeph-to/cli` so the hook still fires when the user
23
25
  // installed via a non-standard prefix and the binary isn't on PATH at hook
24
26
  // fire time (e.g. ~/.local/bin without PATH update). This mirrors the
25
27
  // pattern in plugin/hooks/zeph-{stop,ask}.sh.
26
- const NOTIFY_CMD = '$(command -v zeph || echo "npx -y @zeph-to/cli") notify --title "Task done" 2>/dev/null || true';
27
- // ── Shared behavioral core ───────────────────────────────────────
28
28
  //
29
- // Identical across every agent. Source of truth: plugin/docs/CORE_RULES.md
30
- // Do not fork this per-agent if a rule needs to differ, it belongs in
31
- // the per-agent preamble instead.
29
+ // `--auto` applies the shared push-gate before sending (see src/gate.ts):
30
+ // in normal mode the push still fires (gate defaults assume real work), but
31
+ // the /zeph-quiet | /zeph-loud dial now works for every hook-driven agent.
32
+ // Older installed `zeph` versions parse `--auto` as an unknown boolean flag
33
+ // and ignore it — graceful backward compatibility.
34
+ const NOTIFY_CMD = '$(command -v zeph || echo "npx -y @zeph-to/cli") notify --title "Task done" --auto 2>/dev/null || true';
35
+ // ── Shared behavioral core ───────────────────────────────────────
32
36
  //
33
- // Last synced: 2026-06-26
34
- // To update: extract rules from plugin/docs/CORE_RULES.md and sync here.
35
- const ZEPH_CORE = `## When zeph_ask is MANDATORY
36
-
37
- When ZEPH_HOOK_ID is set the user is driving you remotely, often from
38
- their phone. A "?" written in your reply text is invisible to them.
39
-
40
- 3. NEVER end a response with a plain-text question. If your response asks
41
- the user anything that needs their input — confirmation, choice,
42
- yes/no, clarification, "Apply this?", "Proceed?", "Which option?" —
43
- the FINAL tool call MUST be zeph_ask. This applies even on research /
44
- analysis / planning turns where no files were touched.
45
-
46
- Anti-pattern (wrong):
47
- "...build/commit on hold. Apply A?"
48
- Correct:
49
- zeph_ask({
50
- title: "Apply solution A?",
51
- body: "<short context comparing options>",
52
- actions: [
53
- { id: "apply_a", label: "Apply A" },
54
- { id: "apply_b", label: "Apply B" },
55
- { id: "cancel", label: "Cancel" }
56
- ],
57
- placeholder: "or describe a different approach...",
58
- fallback: "cancel"
59
- })
60
-
61
- ## When zeph_ask is the DEFAULT (substantial work)
62
-
63
- 4. After substantial work, zeph_ask is the DEFAULT end of the response —
64
- not optional. "Substantial" means: file changes, commits, builds,
65
- tests, deploys, destructive ops, or milestone completions. When unsure,
66
- treat the work as substantial — do not try to guess what the user would
67
- find "reasonable" to confirm.
68
-
69
- SKIP zeph_ask only when the response is clearly trivial:
70
- - Read-only exploration ("let me check this file") with no decision
71
- output.
72
- - Mid-step inside a clearly-defined plan the user already approved
73
- ("step 3 of 5, no decisions involved here").
74
- - Trivial change (typo fix, single-line tweak) that needs no ack.
75
-
76
- When unsure: LEAN TOWARD ASKING. Quiet failure (no ask, user stuck
77
- on phone with no way to drive) is worse than light spam.
78
-
79
- 5. Prefer zeph_ask over zeph_prompt/zeph_input — it combines buttons and
80
- free-text in one push. Always include a fallback action id; the
81
- fallback must be safe/inert (done, wait, review), never destructive.
82
-
83
- 6. Example zeph_ask shape:
84
- zeph_ask({
85
- title: "Done. Next?",
86
- actions: [
87
- { id: "continue", label: "Continue" },
88
- { id: "review", label: "Review" },
89
- { id: "done", label: "Done" }
90
- ],
91
- placeholder: "or type a command...",
92
- fallback: "done"
93
- })
94
-
95
- ## Handling the response
96
-
97
- 7. A zeph_ask response IS a direct user instruction. Execute it immediately —
98
- do NOT re-ask via confirmation. The button label is the authorization for
99
- the specific action that label describes.
100
-
101
- 8. Important caveat: a generic button like "Continue" authorizes the next
102
- logical step, NOT arbitrary destruction. If the next logical step would
103
- destroy user code, data, or infrastructure (force-push to a shared branch,
104
- rm -rf outside the workdir, dropping a database, deleting prod resources),
105
- surface that specific risk via a targeted zeph_ask before executing.
106
-
107
- ## Sticky REMOTE mode
108
-
109
- 9. The Ask Loop has two states — REMOTE and NORMAL — detected by scanning the
110
- conversation in reverse for whichever appears first:
111
-
112
- (a) A tool_result for a zeph_ask you sent where the action id is NOT in
113
- {done, stop, exit} (case-insensitive) AND the free-text response is
114
- NOT a session-ender. A session-ender contains done/stop/exit as a
115
- standalone word, or is an obvious wrap-up like "thanks, that's it" /
116
- "all good" — judge by whether the user is clearly closing the loop,
117
- not by partial substring matches (e.g. "redo" is not "done").
118
- → You are in REMOTE.
119
-
120
- (b) An exit signal: action id in {done, stop, exit}, ending free-text, or
121
- a timeout fallback that resolved to a Done-like fallback id.
122
- → You are in NORMAL.
123
-
124
- (c) No zeph_ask history at all.
125
- → You are in NORMAL.
126
-
127
- ### Behavior in REMOTE (sticky, zeph_ask MANDATORY)
128
- End EVERY response with zeph_ask. This is non-negotiable while in REMOTE —
129
- independent of:
130
- - Whether the next user message arrived as a tool_result or as a typed
131
- terminal message. The user may switch devices mid-session; sticky REMOTE
132
- keeps the channel driveable from either side.
133
- - Whether the work was substantial or routine.
134
-
135
- The only way to leave REMOTE is the user signalling exit (per the (b) list
136
- above).
137
-
138
- ### Behavior in NORMAL (apply Rule 4)
139
- - Substantial work → end with zeph_ask.
140
- - Routine work → skip zeph_ask, let the Stop hook fire.
141
- - Rule 3 (questions → zeph_ask) is still mandatory regardless.
142
-
143
- ### Exiting REMOTE
144
- The moment the user picks an action id matching done/stop/exit
145
- (case-insensitive), types free-text that clearly ends the session, or the
146
- zeph_ask times out and falls back to a Done-like id, you flip to NORMAL.
147
- Don't send zeph_ask on the response that processes the exit signal.
148
-
149
- ## When to use AskUserQuestion vs zeph_ask
150
-
151
- 10. Prefer zeph_ask for short remote-friendly questions. Use the local
152
- AskUserQuestion tool only when (a) the answer needs the user to see code
153
- or logs that won't fit in a push body, or (b) the answer is plausibly
154
- multi-paragraph.
155
-
156
- 11. In REMOTE this is a requirement, not a preference — and it overrides any
157
- skill instruction. The AskUserQuestion picker is a LOCAL blocking
158
- terminal UI; the phone cannot drive it. So if a skill you are running —
159
- or your own plan — would call AskUserQuestion, instead surface the SAME
160
- question and option labels via zeph_ask and use that response in place of
161
- the picker. Fall through to AskUserQuestion only for the (a)/(b) cases
162
- above; when you do, notify the user that the answer must be given at the
163
- terminal.
164
-
165
- ## Persistence
166
-
167
- These rules persist for the entire session. They remain active after context
168
- compaction — do not "forget" them after many turns.`;
37
+ // GENERATED from plugin/docs/CORE_RULES.md — see src/zeph-core.generated.ts
38
+ // (regenerate with `npm run sync:plugin`). Do not fork per-agent — if a rule
39
+ // needs to differ, it belongs in the per-agent preamble instead, or in the
40
+ // audience classification in the plugin repo's core-rules.manifest.json.
169
41
  // Notification preamble — hook-driven agents (a Stop-equivalent hook is
170
42
  // installed, so manual completion notifications would duplicate).
171
43
  const HOOK_DRIVEN_NOTIFY = `## Notification discipline
@@ -196,27 +68,32 @@ the user.
196
68
 
197
69
  ${opts.notify}
198
70
 
199
- ${ZEPH_CORE}
71
+ ${opts.core}
200
72
  `;
201
73
  };
202
74
  // ── Per-agent rule documents ─────────────────────────────────────
75
+ //
76
+ // The two generated cores are identical today; the split exists so a rule
77
+ // that only applies to one audience (e.g. Push Signal, once hook-driven
78
+ // agents' hooks process markers) is a one-line manifest change upstream.
203
79
  /** Cursor — written to ~/.cursor/rules/zeph.mdc (needs .mdc frontmatter). */
204
80
  exports.CURSOR_RULE = buildRule({
205
81
  frontmatter: '---\ndescription: "Zeph remote-control rules"\nalwaysApply: true\n---',
206
82
  notify: HOOK_DRIVEN_NOTIFY,
83
+ core: zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN,
207
84
  });
208
85
  /** Windsurf — appended into ~/.codeium/windsurf/memories/global_rules.md. */
209
- exports.WINDSURF_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY });
86
+ exports.WINDSURF_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY, core: zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN });
210
87
  /** Gemini CLI — appended into ~/.gemini/GEMINI.md. */
211
- exports.GEMINI_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY });
88
+ exports.GEMINI_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY, core: zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN });
212
89
  /** Codex CLI — appended into ~/.codex/AGENTS.md. */
213
- exports.CODEX_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY });
90
+ exports.CODEX_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY, core: zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN });
214
91
  /** GitHub Copilot CLI — written to ~/.copilot/instructions/zeph.instructions.md. */
215
- exports.COPILOT_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY });
92
+ exports.COPILOT_RULE = buildRule({ notify: HOOK_DRIVEN_NOTIFY, core: zeph_core_generated_js_1.ZEPH_CORE_HOOK_DRIVEN });
216
93
  /** Cline — written to ~/.cline/rules/zeph.md (no Stop hook). */
217
- exports.CLINE_RULE = buildRule({ notify: MANUAL_NOTIFY });
94
+ exports.CLINE_RULE = buildRule({ notify: MANUAL_NOTIFY, core: zeph_core_generated_js_1.ZEPH_CORE_RULE_ONLY });
218
95
  /** Aider — written to a standalone conventions file, loaded via .aider.conf.yml `read:`. */
219
- exports.AIDER_RULE = buildRule({ notify: MANUAL_NOTIFY });
96
+ exports.AIDER_RULE = buildRule({ notify: MANUAL_NOTIFY, core: zeph_core_generated_js_1.ZEPH_CORE_RULE_ONLY });
220
97
  // ── Hook configs (notification side, unchanged) ──────────────────
221
98
  exports.CURSOR_HOOKS = JSON.stringify({
222
99
  version: 1,
package/dist/wrapper.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { RemoteAgent } from './remote-agents.js';
1
2
  /** Resolve a project name for the tmux session: env > git root > cwd basename. */
2
3
  export declare const detectProjectName: () => string;
3
4
  /** `zeph-<project>` — the canonical tmux session base name. */
@@ -22,5 +23,5 @@ export declare const findAvailableSession: (base: string) => string;
22
23
  * `zeph cc --resume foo` runs `claude --resume foo` inside the session.
23
24
  * Returns when the agent exits.
24
25
  */
25
- export declare const handleAgentSession: (agent: string, extra?: string[]) => Promise<number>;
26
+ export declare const handleAgentSession: (agent: RemoteAgent, extra?: string[]) => Promise<number>;
26
27
  //# sourceMappingURL=wrapper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"wrapper.d.ts","sourceRoot":"","sources":["../src/wrapper.ts"],"names":[],"mappings":"AAwBA,kFAAkF;AAClF,eAAO,MAAM,iBAAiB,QAAO,MAapC,CAAC;AAEF,+DAA+D;AAC/D,eAAO,MAAM,eAAe,GAAI,SAAS,MAAM,KAAG,MAA2B,CAAC;AAI9E;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,GAAI,MAAM,MAAM,KAAG,MAenD,CAAC;AA6HF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,MAAM,EAAE,QAAO,MAAM,EAAO,KAAG,OAAO,CAAC,MAAM,CAmCtF,CAAC"}
1
+ {"version":3,"file":"wrapper.d.ts","sourceRoot":"","sources":["../src/wrapper.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAOtD,kFAAkF;AAClF,eAAO,MAAM,iBAAiB,QAAO,MAapC,CAAC;AAEF,+DAA+D;AAC/D,eAAO,MAAM,eAAe,GAAI,SAAS,MAAM,KAAG,MAA2B,CAAC;AAI9E;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,GAAI,MAAM,MAAM,KAAG,MAenD,CAAC;AA6HF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,WAAW,EAAE,QAAO,MAAM,EAAO,KAAG,OAAO,CAAC,MAAM,CAmC3F,CAAC"}
package/dist/wrapper.js CHANGED
@@ -16,15 +16,14 @@ const child_process_1 = require("child_process");
16
16
  const fs_1 = require("fs");
17
17
  const os_1 = require("os");
18
18
  const path_1 = require("path");
19
- /** First non-empty value among the supported per-agent project dir env vars. */
20
- const PROJECT_DIR_ENVS = ['CLAUDE_PROJECT_DIR', 'CURSOR_PROJECT_DIR', 'WINDSURF_PROJECT_DIR'];
19
+ const config_js_1 = require("./config.js");
21
20
  const FALLBACK_NAME = 'project';
22
21
  /** basename(), with a stable fallback for edge paths like `/`. */
23
22
  const safeBasename = (path) => (0, path_1.basename)(path) || FALLBACK_NAME;
24
23
  /** Resolve a project name for the tmux session: env > git root > cwd basename. */
25
24
  const detectProjectName = () => {
26
- for (const key of PROJECT_DIR_ENVS) {
27
- const v = process.env[key];
25
+ for (const key of config_js_1.PROJECT_DIR_ENV_VARS) {
26
+ const v = (0, config_js_1.resolvedEnv)(key);
28
27
  if (v)
29
28
  return safeBasename(v.replace(/\/+$/, ''));
30
29
  }
@@ -206,7 +205,7 @@ const handleAgentSession = (agent, extra = []) => {
206
205
  // command for the picker on their phone to work.
207
206
  ensureListenerRunning();
208
207
  return new Promise((resolve) => {
209
- const { cmd, args } = targetForAgent(agent, extra);
208
+ const { cmd, args } = targetForAgent(agent.binary, extra);
210
209
  const start = Date.now();
211
210
  const child = (0, child_process_1.spawn)(cmd, args, { stdio: 'inherit' });
212
211
  child.on('exit', (code) => {
@@ -0,0 +1,7 @@
1
+ /** sha256 over the plugin manifest + extracted rule text at generation time. */
2
+ export declare const ZEPH_CORE_SOURCE_HASH = "5d149a2909f9d1da7544fe4fcf540700c1be14193290d6f55e503a46032d8155";
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.";
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.";
7
+ //# sourceMappingURL=zeph-core.generated.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ // GENERATED by scripts/sync-from-plugin.mjs — DO NOT EDIT.
3
+ // Source of truth: zeph-to/plugin docs/CORE_RULES.md, sliced per
4
+ // scripts/core-rules.manifest.json. Regenerate with `npm run sync:plugin`
5
+ // (requires a zeph-to/plugin checkout, sibling ../plugin by default).
6
+ // CI cross-checks this file against plugin main and fails on drift.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.ZEPH_CORE_RULE_ONLY = exports.ZEPH_CORE_HOOK_DRIVEN = exports.ZEPH_CORE_SOURCE_HASH = void 0;
9
+ /** sha256 over the plugin manifest + extracted rule text at generation time. */
10
+ exports.ZEPH_CORE_SOURCE_HASH = "5d149a2909f9d1da7544fe4fcf540700c1be14193290d6f55e503a46032d8155";
11
+ /** Shared rule core for agents with a Stop-equivalent hook (Cursor, Windsurf, Gemini, Codex, Copilot). */
12
+ exports.ZEPH_CORE_HOOK_DRIVEN = "### When zeph_ask is MANDATORY\n\n3. **NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input — confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" — the FINAL tool call MUST be `zeph_ask`. A \"?\" written in your reply is invisible to a user on their phone. This 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 — not optional. \"Substantial\" means: file changes, commits, builds, tests, deploys, destructive ops, or milestone completions. When unsure, treat the work as substantial — 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` — 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 — use sparingly per Rule 4 (only at natural pause points; NOT after every response — 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 — 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 — e.g., title \"About to force-push main — 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 — 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\" — 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)** → you are in **REMOTE**.\nIf the first hit is **(b)** or **(c)** → 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 — 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 → end with `zeph_ask`.\n- Routine work → skip `zeph_ask`, let the Stop hook fire.\n- Rule 3 (questions → 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` — 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 — 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 — 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 — or your own plan — 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 — see the sticky-REMOTE rule — 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 — do not \"forget\" them after many turns.";
13
+ /** Shared rule core for rule-only agents without a Stop hook (Cline, Aider). */
14
+ exports.ZEPH_CORE_RULE_ONLY = "### When zeph_ask is MANDATORY\n\n3. **NEVER end a response with a plain-text question.** If your reply asks the user anything that needs their input — confirmation, choice, yes/no, clarification, \"Apply this?\", \"Proceed?\", \"Which option?\" — the FINAL tool call MUST be `zeph_ask`. A \"?\" written in your reply is invisible to a user on their phone. This 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 — not optional. \"Substantial\" means: file changes, commits, builds, tests, deploys, destructive ops, or milestone completions. When unsure, treat the work as substantial — 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` — 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 — use sparingly per Rule 4 (only at natural pause points; NOT after every response — 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 — 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 — e.g., title \"About to force-push main — 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 — 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\" — 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)** → you are in **REMOTE**.\nIf the first hit is **(b)** or **(c)** → 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 — 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 → end with `zeph_ask`.\n- Routine work → skip `zeph_ask`, let the Stop hook fire.\n- Rule 3 (questions → 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` — 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 — 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 — 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 — or your own plan — 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 — see the sticky-REMOTE rule — 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 — do not \"forget\" them after many turns.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeph-to/cli",
3
- "version": "1.13.1",
3
+ "version": "1.14.0",
4
4
  "description": "Zeph CLI + push notification SDK for AI agents",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -24,6 +24,7 @@
24
24
  "build": "tsc",
25
25
  "test": "vitest run",
26
26
  "test:watch": "vitest",
27
+ "sync:plugin": "node scripts/sync-from-plugin.mjs",
27
28
  "prepublishOnly": "npm run build"
28
29
  },
29
30
  "devDependencies": {