@zeph-to/cli 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
  [![downloads](https://img.shields.io/npm/dm/@zeph-to/cli.svg)](https://www.npmjs.com/package/@zeph-to/cli)
5
5
  [![node](https://img.shields.io/node/v/@zeph-to/cli.svg)](https://nodejs.org)
6
6
  [![license](https://img.shields.io/npm/l/@zeph-to/cli.svg)](./LICENSE)
7
+ [![docs](https://img.shields.io/badge/docs-docs.zeph.to-1f6feb)](https://docs.zeph.to)
7
8
 
8
9
  **Your agent works, hits a decision, and asks your phone. You tap a button (or type a reply), and the answer lands back in the live session — so the agent keeps going.**
9
10
 
@@ -19,6 +20,8 @@
19
20
 
20
21
  Part of the Zeph toolchain: [`@zeph-to/mcp-server`](https://github.com/zeph-to/mcp-server) (the MCP tools your agent calls, e.g. `zeph_ask`) · [`zeph-to/plugin`](https://github.com/zeph-to/plugin) (Claude Code plugin bundling hooks + MCP + rules) · the [Zeph app](https://zeph.to) on your phone.
21
22
 
23
+ > **New here?** [docs.zeph.to](https://docs.zeph.to) walks the whole setup — one command on this machine, the app on your phone, and a restart. The reference below assumes that is already done.
24
+
22
25
  ## Quick Start
23
26
 
24
27
  Install the CLI globally, then run setup:
@@ -354,10 +357,26 @@ into a shell-adjacent pane). The defense is layered:
354
357
  prompt is still in front of every destructive tool call. The phone
355
358
  can *talk* but can't approve `rm -rf` for you.
356
359
 
357
- The transport (WS) is currently authenticated by API key + `push:read`
358
- scope and is **not** end-to-end encrypted in v1 your Zeph backend
359
- sees the message plaintext. If you self-host or trust your backend,
360
- that's fine. If you don't, hold off until per-device E2E ships.
360
+ The transport (WS) is authenticated by API key + `push:read` scope.
361
+ Whether your backend also reads what crosses it depends on encryption
362
+ being on:
363
+
364
+ - **Encryption off — the default.** The phone has no device keypair to
365
+ hand the listener, so pane frames and the messages you type both
366
+ cross the relay in plaintext.
367
+ - **Encryption on.** The phone sends its device public key when it
368
+ subscribes, every pane frame comes back inside an ECDH P-256 +
369
+ AES-256-GCM envelope, and your keystrokes are sealed for this
370
+ listener with their `seq`/`epoch` stamps embedded in the ciphertext,
371
+ so the relay can't replay one. A frame that fails to encrypt is
372
+ dropped, never downgraded.
373
+
374
+ Two gaps either way. A message sent while no live stream is open falls
375
+ back to REST, which is plaintext to the server — that includes every
376
+ `@<session>` command from the agent chat. And the sealed channel buys
377
+ confidentiality against a *passive* relay only: each side learns the
378
+ other's key from the wire, so a backend that mints its own keypair can
379
+ pose as the listener.
361
380
 
362
381
  ## CLI Usage
363
382
 
@@ -619,15 +638,20 @@ session.
619
638
 
620
639
  ## Encryption
621
640
 
622
- Push bodies and long-body attachments are encrypted with AES-256-GCM.
623
- This host holds its own ECDH P-256 keypair in `~/.zeph/device-keys.json`
624
- generated on first use, and the private half never leaves the machine.
625
- Each push is encrypted once, and its AES key is wrapped separately for
626
- every device on your account using ECDH against that device's public
627
- key.
628
-
629
- Toggle encryption in the Zeph app (Settings Encryption); when it is
630
- off, the CLI sends plaintext. No configuration needed.
641
+ End-to-end encryption is **off by default** and turning it on needs
642
+ Zeph Pro. The switch is in the app under Settings → E2E Encryption;
643
+ until you flip it, every push leaves this host in plaintext. If the
644
+ account later loses Pro the server answers `PRO_REQUIRED` and the CLI
645
+ resends the same push in the clear. No configuration either way.
646
+
647
+ With it on, push bodies and long-body attachments are encrypted with
648
+ AES-256-GCM. This host holds its own ECDH P-256 keypair in
649
+ `~/.zeph/device-keys.json` generated on first use, and the private
650
+ half never leaves the machine; the backend stores public keys only and
651
+ rejects a private-key upload. Each push is encrypted once, and its AES
652
+ key is wrapped separately for every device on your account using ECDH
653
+ against that device's public key. The title and URL move inside the
654
+ ciphertext with the body.
631
655
 
632
656
  **Threat model:** against a passive backend — a leaked snapshot, an
633
657
  operator reading the table — the stored ciphertext and wrapped keys are
@@ -636,9 +660,13 @@ useless, so push contents stay private. Three limits worth knowing:
636
660
  - **No protection from an active malicious operator.** Recipient public
637
661
  keys come from `GET /devices` on that same server, unsigned and
638
662
  unpinned. A backend that injects a device record carrying its own key
639
- gets the message key wrapped for it, and reads everything. Closing
640
- this needs out-of-band device verification (ADR-0007 Phase 4, not
641
- built).
663
+ gets the message key wrapped for it, and reads everything. The Zeph
664
+ app ships the counter-measure compare device fingerprints, mark a
665
+ device verified, and strict mode then wraps only for verified
666
+ devices — but it defaults off, its verified list is per browser
667
+ profile, and this CLI does not consult it: `selectRecipients` asks
668
+ only whether a device has a public key, and whether that key is the
669
+ legacy account-wide one (ADR-0007 Phase 4).
642
670
  - **No forward secrecy.** The ECDH secret for a given sender/device pair
643
671
  is static, so compromising either private key opens every past push
644
672
  wrapped for that pair.
package/dist/ask.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ export interface AskAction {
2
+ id: string;
3
+ label: string;
4
+ }
5
+ export interface AskOptions {
6
+ apiKey: string;
7
+ baseUrl: string;
8
+ hookId: string;
9
+ title: string;
10
+ body?: string;
11
+ actions?: AskAction[];
12
+ timeoutSeconds: number;
13
+ }
14
+ /** Injected so the poll loop is testable without a clock or a network. */
15
+ export interface AskDeps {
16
+ fetchFn: typeof fetch;
17
+ now: () => number;
18
+ sleep: (ms: number) => Promise<void>;
19
+ }
20
+ export type AskOutcome = {
21
+ readonly answered: true;
22
+ readonly actionId: string;
23
+ } | {
24
+ readonly answered: true;
25
+ readonly value: string;
26
+ } | {
27
+ readonly answered: false;
28
+ readonly error?: string;
29
+ };
30
+ /**
31
+ * `id:Label` pairs, comma separated. The label may contain colons — only the
32
+ * first one separates, so `go:Deploy: prod` keeps its punctuation. A bare
33
+ * segment becomes its own label, which is what someone typing `--actions ok`
34
+ * means.
35
+ */
36
+ export declare const parseActions: (spec?: string) => AskAction[];
37
+ /**
38
+ * Ask, then wait. Resolves when the user answers or the deadline passes —
39
+ * never rejects.
40
+ */
41
+ export declare const requestApproval: (opts: AskOptions, deps: AskDeps) => Promise<AskOutcome>;
42
+ /** Wall-clock deps for real use. */
43
+ export declare const liveDeps: () => AskDeps;
44
+ /**
45
+ * `zeph ask --title … [--body …] [--actions id:Label,…] [--timeout 60]`
46
+ *
47
+ * Prints one JSON object and nothing else, so a hook can pipe it straight into
48
+ * `jq`. Exit code 0 means answered, 1 means not — a shell caller that only
49
+ * wants approve/deny can read `$?` and skip the JSON entirely.
50
+ */
51
+ export declare const handleAsk: (args: Record<string, string | boolean>) => Promise<number>;
52
+ //# sourceMappingURL=ask.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ask.d.ts","sourceRoot":"","sources":["../src/ask.ts"],"names":[],"mappings":"AA+BA,MAAM,WAAW,SAAS;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,UAAU;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,SAAS,EAAE,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;CAC1B;AAED,0EAA0E;AAC1E,MAAM,WAAW,OAAO;IACpB,OAAO,EAAE,OAAO,KAAK,CAAC;IACtB,GAAG,EAAE,MAAM,MAAM,CAAC;IAClB,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC;AAED,MAAM,MAAM,UAAU,GAChB;IAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACtD;IAAE,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACnD;IAAE,QAAQ,CAAC,QAAQ,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE5D;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAI,OAAO,MAAM,KAAG,SAAS,EAcrD,CAAC;AAoCF;;;GAGG;AACH,eAAO,MAAM,eAAe,GAAU,MAAM,UAAU,EAAE,MAAM,OAAO,KAAG,OAAO,CAAC,UAAU,CAmCzF,CAAC;AAEF,oCAAoC;AACpC,eAAO,MAAM,QAAQ,QAAO,OAI1B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,SAAS,GAAU,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,KAAG,OAAO,CAAC,MAAM,CAkCtF,CAAC"}
package/dist/ask.js ADDED
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleAsk = exports.liveDeps = exports.requestApproval = exports.parseActions = void 0;
4
+ /**
5
+ * `zeph ask` — put a question on the user's phone and block until they answer.
6
+ *
7
+ * Why this lives in the CLI at all: hooks are shell scripts, and a shell script
8
+ * cannot call an MCP tool. `zeph_ask` (mcp-server) already does this for the
9
+ * model; a hook that needs to wait for a person — an approval gate in front of
10
+ * a dangerous command — has no way to reach it. This is that path.
11
+ *
12
+ * It returns ONE shape for every outcome, including failure. A hook standing in
13
+ * front of `rm -rf` cannot handle an exception, and an approval gate that
14
+ * throws has stopped being a gate: unreachable server, bad key, and silence all
15
+ * come back as `answered: false` so the caller makes the same decision it would
16
+ * have made on a refusal.
17
+ *
18
+ * No WebSocket fast path, unlike mcp-server's poll. A hook run is seconds long
19
+ * and dies with the tool call, so a socket would cost a handshake it cannot
20
+ * amortise; plain polling is the whole protocol here.
21
+ */
22
+ const config_js_1 = require("./config.js");
23
+ /** Server-side hook trigger + event read. Kept narrow on purpose — this
24
+ * module needs two routes, not an API client. */
25
+ const TRIGGER_PATH = (hookId) => `/hooks/${hookId}/trigger`;
26
+ const EVENT_PATH = (hookId, eventId) => `/hooks/${hookId}/events/${eventId}`;
27
+ /** Poll cadence. Fast enough that a tap feels immediate, slow enough that a
28
+ * 10-minute wait is not thousands of requests. */
29
+ const POLL_INTERVAL_MS = 1_000;
30
+ /** Per-request bound, well under any sane overall deadline. */
31
+ const REQUEST_TIMEOUT_MS = 10_000;
32
+ /**
33
+ * `id:Label` pairs, comma separated. The label may contain colons — only the
34
+ * first one separates, so `go:Deploy: prod` keeps its punctuation. A bare
35
+ * segment becomes its own label, which is what someone typing `--actions ok`
36
+ * means.
37
+ */
38
+ const parseActions = (spec) => {
39
+ if (!spec)
40
+ return [];
41
+ return spec
42
+ .split(',')
43
+ .map((seg) => seg.trim())
44
+ .filter((seg) => seg.length > 0)
45
+ .map((seg) => {
46
+ const idx = seg.indexOf(':');
47
+ if (idx === -1)
48
+ return { id: seg, label: seg };
49
+ const id = seg.slice(0, idx).trim();
50
+ const label = seg.slice(idx + 1).trim();
51
+ return { id, label: label || id };
52
+ })
53
+ .filter((a) => a.id.length > 0);
54
+ };
55
+ exports.parseActions = parseActions;
56
+ const errorMessage = (err) => err instanceof Error ? err.message : String(err);
57
+ const callApi = async (deps, opts, method, path, body) => {
58
+ const controller = new AbortController();
59
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
60
+ try {
61
+ const headers = { 'X-API-Key': opts.apiKey };
62
+ if (body)
63
+ headers['Content-Type'] = 'application/json';
64
+ const res = await deps.fetchFn(`${opts.baseUrl}${path}`, {
65
+ method,
66
+ headers,
67
+ body: body ? JSON.stringify(body) : undefined,
68
+ signal: controller.signal,
69
+ });
70
+ const json = (await res.json());
71
+ if (!res.ok)
72
+ throw new Error(json.error?.message ?? `HTTP ${res.status}`);
73
+ return json;
74
+ }
75
+ finally {
76
+ clearTimeout(timer);
77
+ }
78
+ };
79
+ /**
80
+ * Ask, then wait. Resolves when the user answers or the deadline passes —
81
+ * never rejects.
82
+ */
83
+ const requestApproval = async (opts, deps) => {
84
+ const deadline = deps.now() + opts.timeoutSeconds * 1000;
85
+ let eventId;
86
+ try {
87
+ const trigger = await callApi(deps, opts, 'POST', TRIGGER_PATH(opts.hookId), {
88
+ title: opts.title,
89
+ body: opts.body,
90
+ actions: opts.actions,
91
+ timeout: opts.timeoutSeconds,
92
+ hookType: 'combo',
93
+ });
94
+ const id = trigger.data?.eventId;
95
+ if (!id)
96
+ return { answered: false, error: 'no eventId in trigger response' };
97
+ eventId = id;
98
+ }
99
+ catch (err) {
100
+ // The question never reached the phone; there is nothing to wait for.
101
+ return { answered: false, error: errorMessage(err) };
102
+ }
103
+ while (deps.now() < deadline) {
104
+ try {
105
+ const event = await callApi(deps, opts, 'GET', EVENT_PATH(opts.hookId, eventId));
106
+ const response = event.data?.response;
107
+ if (response?.actionId)
108
+ return { answered: true, actionId: response.actionId };
109
+ if (response?.value)
110
+ return { answered: true, value: response.value };
111
+ }
112
+ catch {
113
+ // One failed poll is not an answer and not a refusal — the user may
114
+ // still be reaching for their phone. Keep waiting; the deadline is
115
+ // what ends this loop.
116
+ }
117
+ await deps.sleep(POLL_INTERVAL_MS);
118
+ }
119
+ return { answered: false };
120
+ };
121
+ exports.requestApproval = requestApproval;
122
+ /** Wall-clock deps for real use. */
123
+ const liveDeps = () => ({
124
+ fetchFn: fetch,
125
+ now: () => Date.now(),
126
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
127
+ });
128
+ exports.liveDeps = liveDeps;
129
+ /**
130
+ * `zeph ask --title … [--body …] [--actions id:Label,…] [--timeout 60]`
131
+ *
132
+ * Prints one JSON object and nothing else, so a hook can pipe it straight into
133
+ * `jq`. Exit code 0 means answered, 1 means not — a shell caller that only
134
+ * wants approve/deny can read `$?` and skip the JSON entirely.
135
+ */
136
+ const handleAsk = async (args) => {
137
+ const config = (0, config_js_1.loadConfig)();
138
+ // `resolvedEnv`, not `process.env`: a hook environment can hand us a literal
139
+ // unexpanded `${ZEPH_API_KEY}`, which is truthy and would beat the config
140
+ // file. Auth then fails, this returns `answered: false`, and the approval
141
+ // hook maps that to DENY — a command blocked for a reason that has nothing
142
+ // to do with the user. Every other command in cli.ts resolves env this way.
143
+ const apiKey = args['api-key'] || (0, config_js_1.resolvedEnv)('ZEPH_API_KEY') || config.apiKey;
144
+ const hookId = args.hook || (0, config_js_1.resolvedEnv)('ZEPH_HOOK_ID') || config.hookId;
145
+ const title = args.title || '';
146
+ if (!apiKey || !hookId || !title) {
147
+ const missing = [!title && '--title', !apiKey && 'an API key', !hookId && 'a hook id']
148
+ .filter(Boolean).join(', ');
149
+ process.stdout.write(JSON.stringify({ answered: false, error: `missing ${missing}` }) + '\n');
150
+ return 1;
151
+ }
152
+ const timeoutSeconds = Number(args.timeout ?? 60);
153
+ const outcome = await (0, exports.requestApproval)({
154
+ apiKey,
155
+ baseUrl: (config.baseUrl ?? 'https://api.zeph.to/v1').replace(/\/$/, ''),
156
+ hookId,
157
+ title,
158
+ body: args.body,
159
+ actions: (0, exports.parseActions)(args.actions),
160
+ timeoutSeconds: Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds : 60,
161
+ }, (0, exports.liveDeps)());
162
+ process.stdout.write(JSON.stringify(outcome) + '\n');
163
+ return outcome.answered ? 0 : 1;
164
+ };
165
+ exports.handleAsk = handleAsk;
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ const login_js_1 = require("./login.js");
9
9
  const uninstall_js_1 = require("./uninstall.js");
10
10
  const verify_js_1 = require("./verify.js");
11
11
  const check_update_js_1 = require("./check-update.js");
12
+ const ask_js_1 = require("./ask.js");
12
13
  const wrapper_js_1 = require("./wrapper.js");
13
14
  const listener_js_1 = require("./listener.js");
14
15
  const config_js_1 = require("./config.js");
@@ -68,6 +69,10 @@ Commands:
68
69
  verify Check installation health across detected agents
69
70
  check-update Check whether a newer Zeph version is available
70
71
  notify Send a push notification
72
+ ask Ask the phone a question and WAIT for the answer
73
+ (--title, --body, --actions id:Label,…, --timeout secs)
74
+ Prints one JSON line; exit 0 answered, 1 not. Built for
75
+ hooks, which cannot call the MCP zeph_ask tool.
71
76
  list List recent push notifications
72
77
  dismiss <id> Dismiss a push notification (or --all)
73
78
  rename <name> Set this agent session's display name in the app
@@ -462,6 +467,8 @@ const main = async () => {
462
467
  return (0, check_update_js_1.handleCheckUpdate)(args);
463
468
  case 'notify':
464
469
  return handleNotify(args);
470
+ case 'ask':
471
+ return (0, ask_js_1.handleAsk)(args);
465
472
  case 'list':
466
473
  return handleList(args);
467
474
  case 'dismiss':
package/dist/gate.d.ts CHANGED
@@ -44,6 +44,35 @@ export declare const remoteMarkerPath: (hash: string) => string;
44
44
  * the bash reader keeps, and both sides must hash identical bytes.
45
45
  */
46
46
  export declare const remoteDigest: (text: string) => string;
47
+ /**
48
+ * How long REMOTE stays live without a refresh. Generous on purpose: the state
49
+ * is refreshed on every phone prompt and every answered `zeph_ask`, so it only
50
+ * has to outlive a working session, never an idle user. It is the backstop and
51
+ * not the usual exit — a terminal-typed prompt, a Done-like answer and the
52
+ * model's `<!-- zeph: exit -->` all delete the file outright. What none of them
53
+ * covers is a session that simply died: there is no SessionEnd hook, so without
54
+ * a TTL a crash or Ctrl-C would latch REMOTE forever.
55
+ */
56
+ export declare const REMOTE_TTL_SEC = 14400;
57
+ /** State path for a project hash: `<stateDir>/remote-active-<cksum(dir)>`. */
58
+ export declare const remoteStatePath: (hash: string) => string;
59
+ /**
60
+ * True while REMOTE is live for this project. State that is expired or
61
+ * unparseable is deleted on sight — the same housekeeping a stale entry
62
+ * marker gets, since state that can never flag again is dead weight.
63
+ */
64
+ export declare const isRemoteActive: (dir: string, now?: () => number) => boolean;
65
+ /**
66
+ * Enter REMOTE, or push its expiry back. Best-effort by contract: the prompt
67
+ * hooks that call this must never fail a prompt over state IO.
68
+ */
69
+ export declare const touchRemoteActive: (dir: string, now?: () => number) => void;
70
+ /**
71
+ * Leave REMOTE. Best-effort like its twin: an absent file is success, and the
72
+ * caller is a hook with its own job to finish. Bash twin: gate.sh
73
+ * zeph_remote_clear.
74
+ */
75
+ export declare const clearRemoteActive: (dir: string) => void;
47
76
  /** True when the user ran /zeph-mute for this project. */
48
77
  export declare const isMuted: (dir: string) => boolean;
49
78
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAyBA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAC3D,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,gBAAgB,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,YAAY,EAAE,OAAO,CAAC;IACtB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,YAAY,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;CAC7B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa;;;;CAIhB,CAAC;AAEX,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,UACS,CAAC;AAEpE,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,YACR,CAAC;AAErD,eAAO,MAAM,UAAU,GAAI,OAAO,SAAS,KAAG,WAW7C,CAAC;AAeF,eAAO,MAAM,QAAQ,QAAO,MACoD,CAAC;AA2BjF,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAOlD,CAAC;AAWF,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,KAAG,MAA4C,CAAC;AAE7F;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,MAG1B,CAAC;AAEnB,0DAA0D;AAC1D,eAAO,MAAM,OAAO,GAAI,KAAK,MAAM,KAAG,OAGrC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,EAAE,YAAsB,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,qBAAqB,CAAC;AAExD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,YAAY,GACvB,KAAK,MAAM,EACX,WAAU,YAA+B,KACxC,YAUF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,GAAG,OAAO,GAAG,SAAS,KAAG,YACW,CAAC"}
1
+ {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../src/gate.ts"],"names":[],"mappings":"AAyBA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAC3D,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAEvD,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,gBAAgB,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,YAAY,EAAE,OAAO,CAAC;IACtB,MAAM,EAAE,UAAU,CAAC;IACnB,QAAQ,EAAE,YAAY,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,OAAO,CAAC;IACd,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAAC;CAC7B;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa;;;;CAIhB,CAAC;AAEX,eAAO,MAAM,eAAe,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,UACS,CAAC;AAEpE,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,GAAG,SAAS,KAAG,YACR,CAAC;AAErD,eAAO,MAAM,UAAU,GAAI,OAAO,SAAS,KAAG,WAW7C,CAAC;AAeF,eAAO,MAAM,QAAQ,QAAO,MACoD,CAAC;AA2BjF,eAAO,MAAM,WAAW,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAOlD,CAAC;AAWF,+EAA+E;AAC/E,eAAO,MAAM,gBAAgB,GAAI,MAAM,MAAM,KAAG,MAA4C,CAAC;AAE7F;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAI,MAAM,MAAM,KAAG,MAG1B,CAAC;AAiBnB;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,QAAQ,CAAC;AAEpC,8EAA8E;AAC9E,eAAO,MAAM,eAAe,GAAI,MAAM,MAAM,KAAG,MACJ,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,EAAE,MAAK,MAAM,MAAiB,KAAG,OAsB1E,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,EAAE,MAAK,MAAM,MAAiB,KAAG,IAS7E,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,KAAK,MAAM,KAAG,IAQ/C,CAAC;AAEF,0DAA0D;AAC1D,eAAO,MAAM,OAAO,GAAI,KAAK,MAAM,KAAG,OAGrC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,EAAE,YAAsB,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,qBAAqB,CAAC;AAExD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,YAAY,GACvB,KAAK,MAAM,EACX,WAAU,YAA+B,KACxC,YAUF,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,EAAE,MAAM,MAAM,GAAG,OAAO,GAAG,SAAS,KAAG,YACW,CAAC"}
package/dist/gate.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.autoPushMode = exports.readPushMode = exports.PUSHMODE_DEFAULT_FLAG = exports.PUSHMODE_DEFAULT = exports.isMuted = exports.remoteDigest = exports.remoteMarkerPath = exports.projectHash = exports.stateDir = exports.decidePush = exports.normalizePushMode = exports.normalizeMarker = exports.GATE_DEFAULTS = void 0;
3
+ exports.autoPushMode = exports.readPushMode = exports.PUSHMODE_DEFAULT_FLAG = exports.PUSHMODE_DEFAULT = exports.isMuted = exports.clearRemoteActive = exports.touchRemoteActive = exports.isRemoteActive = exports.remoteStatePath = exports.REMOTE_TTL_SEC = exports.remoteDigest = exports.remoteMarkerPath = exports.projectHash = exports.stateDir = exports.decidePush = exports.normalizePushMode = exports.normalizeMarker = exports.GATE_DEFAULTS = void 0;
4
4
  /**
5
5
  * Push-gate decision — the portable half of the Zeph Stop-hook logic.
6
6
  *
@@ -132,6 +132,99 @@ const remoteDigest = (text) => (0, crypto_1.createHash)('sha256')
132
132
  .update(text.replace(/^[ \t\r\n\f\v]+|[ \t\r\n\f\v]+$/g, ''))
133
133
  .digest('hex');
134
134
  exports.remoteDigest = remoteDigest;
135
+ // ── Sticky REMOTE state ──────────────────────────────────────────
136
+ //
137
+ // The marker above is a ONE-SHOT entry signal, consumed the moment a prompt
138
+ // matches it. REMOTE outlives that turn — the user can answer from the phone
139
+ // and then type at the terminal — so the mode itself lives in
140
+ // `remote-active-<hash>`, holding the epoch second it was last confirmed.
141
+ // Keeping it in a file is also what lets it survive context compaction.
142
+ // Bash twin: plugin/hooks/gate.sh zeph_remote_active / zeph_remote_touch.
143
+ //
144
+ // Deliberately not routed through findStateFile. That helper also honors a
145
+ // legacy /tmp copy so files written by older versions keep working; this kind
146
+ // has never had a /tmp writer, so the branch could only ever match something
147
+ // stale — and since the refresh always writes the XDG path, such a file would
148
+ // never expire.
149
+ /**
150
+ * How long REMOTE stays live without a refresh. Generous on purpose: the state
151
+ * is refreshed on every phone prompt and every answered `zeph_ask`, so it only
152
+ * has to outlive a working session, never an idle user. It is the backstop and
153
+ * not the usual exit — a terminal-typed prompt, a Done-like answer and the
154
+ * model's `<!-- zeph: exit -->` all delete the file outright. What none of them
155
+ * covers is a session that simply died: there is no SessionEnd hook, so without
156
+ * a TTL a crash or Ctrl-C would latch REMOTE forever.
157
+ */
158
+ exports.REMOTE_TTL_SEC = 14400;
159
+ /** State path for a project hash: `<stateDir>/remote-active-<cksum(dir)>`. */
160
+ const remoteStatePath = (hash) => (0, path_1.join)((0, exports.stateDir)(), `remote-active-${hash}`);
161
+ exports.remoteStatePath = remoteStatePath;
162
+ /**
163
+ * True while REMOTE is live for this project. State that is expired or
164
+ * unparseable is deleted on sight — the same housekeeping a stale entry
165
+ * marker gets, since state that can never flag again is dead weight.
166
+ */
167
+ const isRemoteActive = (dir, now = Date.now) => {
168
+ const hash = (0, exports.projectHash)(dir);
169
+ if (!hash)
170
+ return false;
171
+ const file = (0, exports.remoteStatePath)(hash);
172
+ let raw;
173
+ try {
174
+ raw = (0, fs_1.readFileSync)(file, 'utf-8').trim();
175
+ }
176
+ catch {
177
+ return false;
178
+ }
179
+ // Digits only, matching the bash twin's `case "$ts" in *[!0-9]*)`. Number()
180
+ // alone is looser than that test — it reads '1e10' as ten billion, so a file
181
+ // bash would sweep would live on here as a far-future timestamp.
182
+ const fresh = /^\d+$/.test(raw) && Math.floor(now() / 1000) - Number(raw) <= exports.REMOTE_TTL_SEC;
183
+ if (!fresh) {
184
+ try {
185
+ (0, fs_1.unlinkSync)(file);
186
+ }
187
+ catch {
188
+ /* best-effort housekeeping */
189
+ }
190
+ }
191
+ return fresh;
192
+ };
193
+ exports.isRemoteActive = isRemoteActive;
194
+ /**
195
+ * Enter REMOTE, or push its expiry back. Best-effort by contract: the prompt
196
+ * hooks that call this must never fail a prompt over state IO.
197
+ */
198
+ const touchRemoteActive = (dir, now = Date.now) => {
199
+ const hash = (0, exports.projectHash)(dir);
200
+ if (!hash)
201
+ return;
202
+ try {
203
+ (0, fs_1.mkdirSync)((0, exports.stateDir)(), { recursive: true });
204
+ (0, fs_1.writeFileSync)((0, exports.remoteStatePath)(hash), `${Math.floor(now() / 1000)}\n`);
205
+ }
206
+ catch {
207
+ /* a hook must never fail on state IO */
208
+ }
209
+ };
210
+ exports.touchRemoteActive = touchRemoteActive;
211
+ /**
212
+ * Leave REMOTE. Best-effort like its twin: an absent file is success, and the
213
+ * caller is a hook with its own job to finish. Bash twin: gate.sh
214
+ * zeph_remote_clear.
215
+ */
216
+ const clearRemoteActive = (dir) => {
217
+ const hash = (0, exports.projectHash)(dir);
218
+ if (!hash)
219
+ return;
220
+ try {
221
+ (0, fs_1.unlinkSync)((0, exports.remoteStatePath)(hash));
222
+ }
223
+ catch {
224
+ /* already gone, or unwritable — the prompt still goes through */
225
+ }
226
+ };
227
+ exports.clearRemoteActive = clearRemoteActive;
135
228
  /** True when the user ran /zeph-mute for this project. */
136
229
  const isMuted = (dir) => {
137
230
  const hash = (0, exports.projectHash)(dir);