@zeph-to/cli 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -16
- package/dist/ask.d.ts +52 -0
- package/dist/ask.d.ts.map +1 -0
- package/dist/ask.js +165 -0
- package/dist/cli.js +7 -0
- package/dist/gate.d.ts +21 -0
- package/dist/gate.d.ts.map +1 -1
- package/dist/gate.js +75 -1
- package/dist/listener.d.ts +235 -5
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js +0 -0
- package/dist/remote-agents.d.ts +12 -0
- package/dist/remote-agents.d.ts.map +1 -1
- package/dist/remote-agents.js +1 -0
- package/dist/remote-hook.d.ts.map +1 -1
- package/dist/remote-hook.js +61 -21
- package/dist/session-registry.d.ts +63 -0
- package/dist/session-registry.d.ts.map +1 -0
- package/dist/session-registry.js +110 -0
- package/dist/test-setup.d.ts +22 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/dist/test-setup.js +29 -0
- package/dist/zeph-core.generated.d.ts +3 -3
- package/dist/zeph-core.generated.d.ts.map +1 -1
- package/dist/zeph-core.generated.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@zeph-to/cli)
|
|
5
5
|
[](https://nodejs.org)
|
|
6
6
|
[](./LICENSE)
|
|
7
|
+
[](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
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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.
|
|
640
|
-
|
|
641
|
-
|
|
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,27 @@ 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 exists at all
|
|
51
|
+
* because nothing owns "this session ended" — there is no SessionEnd hook — so
|
|
52
|
+
* a crash or Ctrl-C would otherwise latch REMOTE forever.
|
|
53
|
+
*/
|
|
54
|
+
export declare const REMOTE_TTL_SEC = 14400;
|
|
55
|
+
/** State path for a project hash: `<stateDir>/remote-active-<cksum(dir)>`. */
|
|
56
|
+
export declare const remoteStatePath: (hash: string) => string;
|
|
57
|
+
/**
|
|
58
|
+
* True while REMOTE is live for this project. State that is expired or
|
|
59
|
+
* unparseable is deleted on sight — the same housekeeping a stale entry
|
|
60
|
+
* marker gets, since state that can never flag again is dead weight.
|
|
61
|
+
*/
|
|
62
|
+
export declare const isRemoteActive: (dir: string, now?: () => number) => boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Enter REMOTE, or push its expiry back. Best-effort by contract: the prompt
|
|
65
|
+
* hooks that call this must never fail a prompt over state IO.
|
|
66
|
+
*/
|
|
67
|
+
export declare const touchRemoteActive: (dir: string, now?: () => number) => void;
|
|
47
68
|
/** True when the user ran /zeph-mute for this project. */
|
|
48
69
|
export declare const isMuted: (dir: string) => boolean;
|
|
49
70
|
/**
|
package/dist/gate.d.ts.map
CHANGED
|
@@ -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;
|
|
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;;;;;;GAMG;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,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.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,80 @@ 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 exists at all
|
|
153
|
+
* because nothing owns "this session ended" — there is no SessionEnd hook — so
|
|
154
|
+
* a crash or Ctrl-C would otherwise latch REMOTE forever.
|
|
155
|
+
*/
|
|
156
|
+
exports.REMOTE_TTL_SEC = 14400;
|
|
157
|
+
/** State path for a project hash: `<stateDir>/remote-active-<cksum(dir)>`. */
|
|
158
|
+
const remoteStatePath = (hash) => (0, path_1.join)((0, exports.stateDir)(), `remote-active-${hash}`);
|
|
159
|
+
exports.remoteStatePath = remoteStatePath;
|
|
160
|
+
/**
|
|
161
|
+
* True while REMOTE is live for this project. State that is expired or
|
|
162
|
+
* unparseable is deleted on sight — the same housekeeping a stale entry
|
|
163
|
+
* marker gets, since state that can never flag again is dead weight.
|
|
164
|
+
*/
|
|
165
|
+
const isRemoteActive = (dir, now = Date.now) => {
|
|
166
|
+
const hash = (0, exports.projectHash)(dir);
|
|
167
|
+
if (!hash)
|
|
168
|
+
return false;
|
|
169
|
+
const file = (0, exports.remoteStatePath)(hash);
|
|
170
|
+
let raw;
|
|
171
|
+
try {
|
|
172
|
+
raw = (0, fs_1.readFileSync)(file, 'utf-8').trim();
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
// Digits only, matching the bash twin's `case "$ts" in *[!0-9]*)`. Number()
|
|
178
|
+
// alone is looser than that test — it reads '1e10' as ten billion, so a file
|
|
179
|
+
// bash would sweep would live on here as a far-future timestamp.
|
|
180
|
+
const fresh = /^\d+$/.test(raw) && Math.floor(now() / 1000) - Number(raw) <= exports.REMOTE_TTL_SEC;
|
|
181
|
+
if (!fresh) {
|
|
182
|
+
try {
|
|
183
|
+
(0, fs_1.unlinkSync)(file);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* best-effort housekeeping */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return fresh;
|
|
190
|
+
};
|
|
191
|
+
exports.isRemoteActive = isRemoteActive;
|
|
192
|
+
/**
|
|
193
|
+
* Enter REMOTE, or push its expiry back. Best-effort by contract: the prompt
|
|
194
|
+
* hooks that call this must never fail a prompt over state IO.
|
|
195
|
+
*/
|
|
196
|
+
const touchRemoteActive = (dir, now = Date.now) => {
|
|
197
|
+
const hash = (0, exports.projectHash)(dir);
|
|
198
|
+
if (!hash)
|
|
199
|
+
return;
|
|
200
|
+
try {
|
|
201
|
+
(0, fs_1.mkdirSync)((0, exports.stateDir)(), { recursive: true });
|
|
202
|
+
(0, fs_1.writeFileSync)((0, exports.remoteStatePath)(hash), `${Math.floor(now() / 1000)}\n`);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* a hook must never fail on state IO */
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
exports.touchRemoteActive = touchRemoteActive;
|
|
135
209
|
/** True when the user ran /zeph-mute for this project. */
|
|
136
210
|
const isMuted = (dir) => {
|
|
137
211
|
const hash = (0, exports.projectHash)(dir);
|