@zeph-to/hook-sdk 1.8.0 → 1.10.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 +283 -14
- package/dist/cli.js +33 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/installer.d.ts +7 -0
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +84 -27
- package/dist/listener.d.ts +116 -0
- package/dist/listener.d.ts.map +1 -0
- package/dist/listener.js +878 -0
- package/dist/wrapper.d.ts +26 -0
- package/dist/wrapper.d.ts.map +1 -0
- package/dist/wrapper.js +210 -0
- package/package.json +8 -3
package/dist/listener.js
ADDED
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `zeph listener` — resident daemon that watches the user's Zeph feed
|
|
4
|
+
* over a persistent WebSocket and injects matching messages into a
|
|
5
|
+
* named tmux session via `tmux send-keys`.
|
|
6
|
+
*
|
|
7
|
+
* Solves the MCP polling-window problem: an `zeph_ask` polling cycle
|
|
8
|
+
* times out (120–600 s) and the CC/Codex session becomes unaddressable
|
|
9
|
+
* from the phone. The listener stays subscribed indefinitely and can
|
|
10
|
+
* deliver to any named tmux session at any time.
|
|
11
|
+
*
|
|
12
|
+
* Wire format: pushes with `type='agent.command'` carry the tmux
|
|
13
|
+
* session name in `agentSessionName` and the message in `body`. The
|
|
14
|
+
* "AI Agent에게 명령" sheet on the phone builds these structured
|
|
15
|
+
* pushes from the listener-reported session inventory. Other push
|
|
16
|
+
* types (Stop-hook auto-pushes, zeph_ask responses, channel
|
|
17
|
+
* broadcasts) are ignored.
|
|
18
|
+
*
|
|
19
|
+
* Transport: WebSocket against the Zeph $connect endpoint with
|
|
20
|
+
* `?apiKey=<key>`. The server fan-out pushes `{ type: 'push.new', data }`
|
|
21
|
+
* messages as new pushes are created. Reconnects with exponential
|
|
22
|
+
* backoff on transient failures; gives up on auth failures (4001/4002/4003).
|
|
23
|
+
*/
|
|
24
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
25
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
26
|
+
};
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.handleListener = exports.computeListenerDeviceId = exports.handlePush = exports.collectSessions = exports.collectSessionsVerbose = exports.detectClaudeSessionId = exports.parseSessionName = exports.paneCurrentCommand = exports.checkRateLimit = void 0;
|
|
29
|
+
const child_process_1 = require("child_process");
|
|
30
|
+
const crypto_1 = require("crypto");
|
|
31
|
+
const fs_1 = require("fs");
|
|
32
|
+
const os_1 = require("os");
|
|
33
|
+
const path_1 = require("path");
|
|
34
|
+
const ws_1 = __importDefault(require("ws"));
|
|
35
|
+
const config_js_1 = require("./config.js");
|
|
36
|
+
const PING_INTERVAL_MS = 25_000;
|
|
37
|
+
const PONG_TIMEOUT_MS = 10_000;
|
|
38
|
+
const RECONNECT_BASE_MS = 1_000;
|
|
39
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
40
|
+
const RECONNECT_JITTER_RATIO = 0.15;
|
|
41
|
+
// How often the listener reports its tmux session inventory to the
|
|
42
|
+
// backend (in addition to immediately on $connect). Cheap — tmux runs
|
|
43
|
+
// locally, the payload is small, and the user expects the phone picker
|
|
44
|
+
// to reflect new `zeph cc` sessions within a few seconds, not half a
|
|
45
|
+
// minute.
|
|
46
|
+
const SESSION_REPORT_INTERVAL_MS = 5_000;
|
|
47
|
+
const AGENT_KINDS = ['claude', 'codex', 'gemini'];
|
|
48
|
+
// Per-session token bucket — caps a runaway/compromised sender. 30/min
|
|
49
|
+
// is generous for human-driven phone use, tight enough to block flooding.
|
|
50
|
+
const RATE_LIMIT_TOKENS = 30;
|
|
51
|
+
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
52
|
+
// Shells are refused: a shell prompt + send-keys = arbitrary command exec.
|
|
53
|
+
const SHELL_COMMANDS = new Set(['bash', 'zsh', 'fish', 'sh', 'dash', 'ksh', 'tcsh', 'csh', 'pwsh']);
|
|
54
|
+
// Auth-failure close codes: retrying with the same bad credentials hammers
|
|
55
|
+
// the server forever, so the listener exits instead.
|
|
56
|
+
const AUTH_FAILURE_CODES = new Set([4001, 4002, 4003]);
|
|
57
|
+
const buckets = new Map();
|
|
58
|
+
// Evict idle buckets older than this so the Map can't grow without bound
|
|
59
|
+
// under attack. Two refill windows past full refill = bucket is at cap
|
|
60
|
+
// anyway and recreating it on next hit is free.
|
|
61
|
+
const BUCKET_IDLE_TTL_MS = RATE_LIMIT_WINDOW_MS * 2;
|
|
62
|
+
const pruneStaleBuckets = (now) => {
|
|
63
|
+
for (const [key, b] of buckets) {
|
|
64
|
+
if (now - b.lastRefillAt > BUCKET_IDLE_TTL_MS)
|
|
65
|
+
buckets.delete(key);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const checkRateLimit = (session, now = Date.now()) => {
|
|
69
|
+
pruneStaleBuckets(now);
|
|
70
|
+
const b = buckets.get(session) ?? { tokens: RATE_LIMIT_TOKENS, lastRefillAt: now };
|
|
71
|
+
const elapsed = Math.max(0, now - b.lastRefillAt);
|
|
72
|
+
// Fractional refill is intentional: smooths the boundary so a session
|
|
73
|
+
// hitting the cap doesn't have to wait a full window for the next slot.
|
|
74
|
+
const refilled = Math.min(RATE_LIMIT_TOKENS, b.tokens + (elapsed / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_TOKENS);
|
|
75
|
+
if (refilled < 1) {
|
|
76
|
+
buckets.set(session, { tokens: refilled, lastRefillAt: now });
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
buckets.set(session, { tokens: refilled - 1, lastRefillAt: now });
|
|
80
|
+
return true;
|
|
81
|
+
};
|
|
82
|
+
exports.checkRateLimit = checkRateLimit;
|
|
83
|
+
/** Read the foreground command in the named tmux session's active pane. */
|
|
84
|
+
const paneCurrentCommand = (session) => {
|
|
85
|
+
const result = (0, child_process_1.spawnSync)('tmux', tmuxArgs(['display-message', '-p', '-t', session, '#{pane_current_command}']), {
|
|
86
|
+
encoding: 'utf-8',
|
|
87
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
88
|
+
});
|
|
89
|
+
if (result.status !== 0)
|
|
90
|
+
return null;
|
|
91
|
+
return (result.stdout ?? '').trim() || null;
|
|
92
|
+
};
|
|
93
|
+
exports.paneCurrentCommand = paneCurrentCommand;
|
|
94
|
+
const isShellPane = (command) => {
|
|
95
|
+
if (!command)
|
|
96
|
+
return false;
|
|
97
|
+
return SHELL_COMMANDS.has(command);
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Inject text into a tmux session: literal text via `-l`, then a
|
|
101
|
+
* separate `Enter`. `-l` takes the text as data, so tmux escape
|
|
102
|
+
* sequences inside the message can't drive other tmux commands.
|
|
103
|
+
*/
|
|
104
|
+
const injectKeys = (session, text) => {
|
|
105
|
+
const a = (0, child_process_1.spawnSync)('tmux', tmuxArgs(['send-keys', '-l', '-t', session, text]), { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
106
|
+
if (a.status !== 0)
|
|
107
|
+
return false;
|
|
108
|
+
const b = (0, child_process_1.spawnSync)('tmux', tmuxArgs(['send-keys', '-t', session, 'Enter']), { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
109
|
+
return b.status === 0;
|
|
110
|
+
};
|
|
111
|
+
const stamp = () => new Date().toISOString().slice(11, 19);
|
|
112
|
+
const log = (msg) => console.log(`[${stamp()}] ${msg}`);
|
|
113
|
+
// ─── tmux socket discovery ──────────────────────────────────────────
|
|
114
|
+
/**
|
|
115
|
+
* macOS sets a per-user `TMPDIR` like `/var/folders/xz/.../T/`, and tmux
|
|
116
|
+
* (started from a regular shell there) lays its socket at
|
|
117
|
+
* `<TMPDIR>/tmux-<uid>/default`. When the listener is spawned from a
|
|
118
|
+
* shell with a different TMPDIR — or no TMPDIR at all (cron, launchd,
|
|
119
|
+
* IDE-managed terminals) — tmux defaults to `/tmp/tmux-<uid>/default`
|
|
120
|
+
* and the user's real server is invisible. We probe a small list of
|
|
121
|
+
* common locations and use `-S <path>` for every subsequent tmux call
|
|
122
|
+
* once a live server is found.
|
|
123
|
+
*
|
|
124
|
+
* Caching is one-way: a successful discovery sticks for the process
|
|
125
|
+
* lifetime, but failure does NOT — we re-probe every cycle so the
|
|
126
|
+
* listener picks up a tmux server that gets started AFTER the listener
|
|
127
|
+
* itself (very common: the user opens `zeph cc` after starting the
|
|
128
|
+
* daemon). If tmux dies and respawns under a different path the user
|
|
129
|
+
* has to restart the listener (rare).
|
|
130
|
+
*/
|
|
131
|
+
let cachedSocketPath = null;
|
|
132
|
+
/** True once we've confirmed a working socket. `cachedSocketPath` of
|
|
133
|
+
* `null` is ambiguous on its own — it can mean either "use default
|
|
134
|
+
* (we verified it works)" or "we haven't checked yet". This flag
|
|
135
|
+
* removes the ambiguity so we don't re-probe every collectSessions
|
|
136
|
+
* cycle (which was spamming the log with "tmux: default socket OK"). */
|
|
137
|
+
let cacheValid = false;
|
|
138
|
+
const probeTmuxSocketDetail = (socketPath) => {
|
|
139
|
+
const args = socketPath ? ['-S', socketPath, 'list-sessions'] : ['list-sessions'];
|
|
140
|
+
const r = (0, child_process_1.spawnSync)('tmux', args, {
|
|
141
|
+
encoding: 'utf-8',
|
|
142
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
143
|
+
});
|
|
144
|
+
if (r.status === 0)
|
|
145
|
+
return { ok: true };
|
|
146
|
+
const err = (r.stderr ?? '').trim();
|
|
147
|
+
return { ok: false, stderr: err || undefined };
|
|
148
|
+
};
|
|
149
|
+
const probeTmuxSocket = (socketPath) => probeTmuxSocketDetail(socketPath).ok;
|
|
150
|
+
/**
|
|
151
|
+
* List every socket file inside a `tmux-<uid>/` directory. tmux's
|
|
152
|
+
* default socket name is `default`, but users can change it with
|
|
153
|
+
* `tmux -L <name>` or via .tmux.conf — so we probe every file we find,
|
|
154
|
+
* not just `default`. Returns absolute socket paths.
|
|
155
|
+
*/
|
|
156
|
+
const listSocketsIn = (dir) => {
|
|
157
|
+
if (!(0, fs_1.existsSync)(dir))
|
|
158
|
+
return [];
|
|
159
|
+
try {
|
|
160
|
+
return (0, fs_1.readdirSync)(dir).map((name) => `${dir}/${name}`);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* Final-fallback socket discovery: find tmux server processes via `ps`
|
|
168
|
+
* and ask `lsof` what unix socket each is bound to. Handles the cases
|
|
169
|
+
* filesystem walking can't:
|
|
170
|
+
* - macOS auto-cleanup deleted the socket file while the server kept
|
|
171
|
+
* running (the most likely cause of "no server running" errors when
|
|
172
|
+
* a tmux session is clearly alive in another iTerm/Warp pane)
|
|
173
|
+
* - The user runs tmux with `-L <name>` or `-S <unusual-path>` that we
|
|
174
|
+
* never thought to enumerate
|
|
175
|
+
*
|
|
176
|
+
* `lsof` on macOS may report sockets as `(deleted)`. Even then, if the
|
|
177
|
+
* server still has the inode open we can still tmux-attach by recreating
|
|
178
|
+
* the path — but for now we only return paths that still exist on disk
|
|
179
|
+
* so tmux's connect logic isn't confused. If the path is gone, the user
|
|
180
|
+
* has to `tmux kill-server` + restart anyway.
|
|
181
|
+
*/
|
|
182
|
+
const findTmuxViaProcess = () => {
|
|
183
|
+
const username = (0, os_1.userInfo)().username;
|
|
184
|
+
const ps = (0, child_process_1.spawnSync)('ps', ['-A', '-o', 'pid=,user=,command='], {
|
|
185
|
+
encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
186
|
+
});
|
|
187
|
+
if (ps.status !== 0)
|
|
188
|
+
return [];
|
|
189
|
+
const tmuxPids = [];
|
|
190
|
+
for (const line of (ps.stdout ?? '').split('\n')) {
|
|
191
|
+
const m = line.match(/^\s*(\d+)\s+(\S+)\s+(.+)$/);
|
|
192
|
+
if (!m)
|
|
193
|
+
continue;
|
|
194
|
+
const [, pid, user, cmd] = m;
|
|
195
|
+
if (user !== username)
|
|
196
|
+
continue;
|
|
197
|
+
// Server processes show up as `tmux: server` (with the colon) on
|
|
198
|
+
// some versions; client/wrapper invocations show up as `tmux new`
|
|
199
|
+
// / `tmux attach` etc. lsof works on either.
|
|
200
|
+
if (!/(^|[^\w-])tmux($|[:\s])/.test(cmd))
|
|
201
|
+
continue;
|
|
202
|
+
tmuxPids.push(pid);
|
|
203
|
+
}
|
|
204
|
+
if (tmuxPids.length === 0)
|
|
205
|
+
return [];
|
|
206
|
+
const found = new Set();
|
|
207
|
+
for (const pid of tmuxPids) {
|
|
208
|
+
const lsof = (0, child_process_1.spawnSync)('lsof', ['-p', pid, '-Fn'], {
|
|
209
|
+
encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
210
|
+
});
|
|
211
|
+
if (lsof.status !== 0)
|
|
212
|
+
continue;
|
|
213
|
+
// `-Fn` prints names prefixed with `n`; one per line. Filter for
|
|
214
|
+
// tmux-shaped socket paths.
|
|
215
|
+
for (const lline of (lsof.stdout ?? '').split('\n')) {
|
|
216
|
+
if (!lline.startsWith('n'))
|
|
217
|
+
continue;
|
|
218
|
+
const path = lline.slice(1);
|
|
219
|
+
if (!/\/tmux-\d+\//.test(path))
|
|
220
|
+
continue;
|
|
221
|
+
if (path.endsWith(' (deleted)') || path.includes('(deleted)'))
|
|
222
|
+
continue;
|
|
223
|
+
if ((0, fs_1.existsSync)(path))
|
|
224
|
+
found.add(path);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return [...found];
|
|
228
|
+
};
|
|
229
|
+
/** Walk `/var/folders` for user-owned `tmux-<uid>/*` socket files. Each
|
|
230
|
+
* subdir is wrapped in its own try/catch — entries that belong to other
|
|
231
|
+
* users (or that we otherwise can't read) must skip cleanly, not abort
|
|
232
|
+
* the whole walk. */
|
|
233
|
+
const walkVarFolders = (uid) => {
|
|
234
|
+
const found = [];
|
|
235
|
+
const root = '/var/folders';
|
|
236
|
+
if (!(0, fs_1.existsSync)(root))
|
|
237
|
+
return found;
|
|
238
|
+
let topEntries;
|
|
239
|
+
try {
|
|
240
|
+
topEntries = (0, fs_1.readdirSync)(root);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return found;
|
|
244
|
+
}
|
|
245
|
+
for (const a of topEntries) {
|
|
246
|
+
const aPath = `${root}/${a}`;
|
|
247
|
+
let subEntries;
|
|
248
|
+
try {
|
|
249
|
+
subEntries = (0, fs_1.readdirSync)(aPath);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
for (const b of subEntries) {
|
|
255
|
+
found.push(...listSocketsIn(`${aPath}/${b}/T/tmux-${uid}`));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return found;
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* Track whether the "no server anywhere" diagnostic was already logged
|
|
262
|
+
* this run. We want the user to see the path list *once* on first
|
|
263
|
+
* failure, then go quiet until we either find a server or notice a new
|
|
264
|
+
* candidate file appearing — otherwise every 30-s cycle would spam the
|
|
265
|
+
* full probe report.
|
|
266
|
+
*/
|
|
267
|
+
let warnedNoServer = false;
|
|
268
|
+
const findTmuxSocket = () => {
|
|
269
|
+
// Successful discovery sticks. Failure does NOT — we want to pick
|
|
270
|
+
// up a tmux server that the user launches *after* `zeph listener`.
|
|
271
|
+
if (cacheValid)
|
|
272
|
+
return cachedSocketPath;
|
|
273
|
+
// Explicit override — for users with `tmux -L <name>` setups or
|
|
274
|
+
// unusual socket locations. Skip discovery entirely if set and
|
|
275
|
+
// probeable.
|
|
276
|
+
const override = process.env.ZEPH_TMUX_SOCKET;
|
|
277
|
+
if (override) {
|
|
278
|
+
if (probeTmuxSocket(override)) {
|
|
279
|
+
cachedSocketPath = override;
|
|
280
|
+
cacheValid = true;
|
|
281
|
+
log(`tmux socket → ${override} (from ZEPH_TMUX_SOCKET)`);
|
|
282
|
+
warnedNoServer = false;
|
|
283
|
+
return override;
|
|
284
|
+
}
|
|
285
|
+
// Fall through to standard discovery if override fails — better
|
|
286
|
+
// than failing silently. We re-log this every cycle (no
|
|
287
|
+
// `warnedNoServer`) because it's a user-supplied setting we want
|
|
288
|
+
// to keep nagging about.
|
|
289
|
+
log(`tmux: ZEPH_TMUX_SOCKET=${override} probe failed, falling back to auto-discovery`);
|
|
290
|
+
}
|
|
291
|
+
const uid = (0, os_1.userInfo)().uid;
|
|
292
|
+
const candidates = [];
|
|
293
|
+
// Process-based discovery first — it's the only path that handles
|
|
294
|
+
// stale-socket-file cases (macOS /tmp cleanup) and unusual socket
|
|
295
|
+
// locations the heuristic walks would miss.
|
|
296
|
+
candidates.push(...findTmuxViaProcess());
|
|
297
|
+
// Include every socket file we find in any `tmux-<uid>/` dir — the
|
|
298
|
+
// user might have `-L <name>` configured rather than the default
|
|
299
|
+
// socket name.
|
|
300
|
+
const envDir = process.env.TMUX_TMPDIR || process.env.TMPDIR;
|
|
301
|
+
if (envDir)
|
|
302
|
+
candidates.push(...listSocketsIn(`${envDir.replace(/\/+$/, '')}/tmux-${uid}`));
|
|
303
|
+
candidates.push(...walkVarFolders(uid));
|
|
304
|
+
candidates.push(...listSocketsIn(`/tmp/tmux-${uid}`));
|
|
305
|
+
candidates.push(...listSocketsIn(`/private/tmp/tmux-${uid}`));
|
|
306
|
+
const seen = new Set();
|
|
307
|
+
const unique = candidates.filter((p) => (seen.has(p) ? false : (seen.add(p), true)));
|
|
308
|
+
// Default first — succeeds when the shell that launched us shares
|
|
309
|
+
// tmux's view. We deliberately don't cache this success; on the
|
|
310
|
+
// first call though it's enough.
|
|
311
|
+
if (probeTmuxSocket(null)) {
|
|
312
|
+
cachedSocketPath = null; // null means "use default"
|
|
313
|
+
cacheValid = true;
|
|
314
|
+
if (!warnedNoServer)
|
|
315
|
+
log('tmux: default socket OK');
|
|
316
|
+
warnedNoServer = false;
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
for (const path of unique) {
|
|
320
|
+
if (!(0, fs_1.existsSync)(path))
|
|
321
|
+
continue;
|
|
322
|
+
if (probeTmuxSocket(path)) {
|
|
323
|
+
cachedSocketPath = path;
|
|
324
|
+
cacheValid = true;
|
|
325
|
+
log(`tmux socket → ${path}`);
|
|
326
|
+
warnedNoServer = false;
|
|
327
|
+
return path;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// No live tmux yet. Log the full probe report once, then stay quiet
|
|
331
|
+
// until something works — otherwise the user gets a 4-line dump
|
|
332
|
+
// every 30 seconds while they're still bringing tmux up. Include the
|
|
333
|
+
// tmux binary identification + stderr for failed probes so the user
|
|
334
|
+
// can spot version mismatches (homebrew on /usr/local vs /opt/homebrew)
|
|
335
|
+
// or stale socket files.
|
|
336
|
+
if (!warnedNoServer) {
|
|
337
|
+
const which = (0, child_process_1.spawnSync)('which', ['tmux'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
338
|
+
const tmuxPath = (which.stdout ?? '').trim() || '(not on PATH)';
|
|
339
|
+
const ver = (0, child_process_1.spawnSync)('tmux', ['-V'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
340
|
+
const tmuxVer = (ver.stdout ?? '').trim() || '?';
|
|
341
|
+
log(`tmux: no live server yet — using ${tmuxPath} (${tmuxVer})`);
|
|
342
|
+
log(`tmux: probed ${unique.length} candidate(s):`);
|
|
343
|
+
for (const path of unique) {
|
|
344
|
+
if (!(0, fs_1.existsSync)(path)) {
|
|
345
|
+
log(` - ${path} (no socket file)`);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const detail = probeTmuxSocketDetail(path);
|
|
349
|
+
log(` ✗ ${path} (${detail.stderr ?? 'probe failed without stderr'})`);
|
|
350
|
+
}
|
|
351
|
+
log(`tmux: will retry each cycle. If your tmux uses a custom socket,`);
|
|
352
|
+
log(` run \`tmux info | head -1\` in the same shell as 'zeph cc'`);
|
|
353
|
+
log(` and pass it via: ZEPH_TMUX_SOCKET=<path> zeph listener`);
|
|
354
|
+
warnedNoServer = true;
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
};
|
|
358
|
+
/** Prepend `-S <socket>` when we've discovered a non-default tmux server. */
|
|
359
|
+
const tmuxArgs = (args) => {
|
|
360
|
+
const sock = findTmuxSocket();
|
|
361
|
+
return sock ? ['-S', sock, ...args] : args;
|
|
362
|
+
};
|
|
363
|
+
// ─── Session inventory ──────────────────────────────────────────────
|
|
364
|
+
/**
|
|
365
|
+
* Parse a `zeph-*` tmux session name into `{project, label}`. For
|
|
366
|
+
* Phase 1 the wrapper only emits `zeph-<project>` (no labels), so the
|
|
367
|
+
* whole tail becomes the project. When labels land in Phase 2 the
|
|
368
|
+
* wrapper will sidecar `{project, label}` so the listener doesn't need
|
|
369
|
+
* to guess from a name that allows dashes in project names.
|
|
370
|
+
*/
|
|
371
|
+
const parseSessionName = (name) => {
|
|
372
|
+
if (!name.startsWith('zeph-'))
|
|
373
|
+
return null;
|
|
374
|
+
const rest = name.slice('zeph-'.length);
|
|
375
|
+
if (!rest)
|
|
376
|
+
return null;
|
|
377
|
+
return { project: rest, label: null };
|
|
378
|
+
};
|
|
379
|
+
exports.parseSessionName = parseSessionName;
|
|
380
|
+
const CLAUDE_PROJECTS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'projects');
|
|
381
|
+
/**
|
|
382
|
+
* Locate the most recent Claude Code session UUID for the working
|
|
383
|
+
* directory of a tmux pane. Mirrors `mcp-server/config.ts`'s
|
|
384
|
+
* detectClaudeSessionId: CC writes per-session jsonl files at
|
|
385
|
+
* `~/.claude/projects/<projectHash>/<UUID>.jsonl` where the hash is
|
|
386
|
+
* the cwd with `/` replaced by `-`.
|
|
387
|
+
*/
|
|
388
|
+
const detectClaudeSessionId = (cwd) => {
|
|
389
|
+
try {
|
|
390
|
+
const projectHash = cwd.replace(/\//g, '-');
|
|
391
|
+
const sessionsDir = (0, path_1.join)(CLAUDE_PROJECTS_DIR, projectHash);
|
|
392
|
+
let latest;
|
|
393
|
+
for (const entry of (0, fs_1.readdirSync)(sessionsDir)) {
|
|
394
|
+
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$/);
|
|
395
|
+
if (!m)
|
|
396
|
+
continue;
|
|
397
|
+
const stat = (0, fs_1.statSync)((0, path_1.join)(sessionsDir, entry));
|
|
398
|
+
if (!stat.isFile())
|
|
399
|
+
continue;
|
|
400
|
+
if (!latest || stat.mtimeMs > latest.mtime) {
|
|
401
|
+
latest = { name: m[1], mtime: stat.mtimeMs };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return latest?.name ?? null;
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
exports.detectClaudeSessionId = detectClaudeSessionId;
|
|
411
|
+
// U+241F "Symbol for Unit Separator" — a *printable* Unicode glyph
|
|
412
|
+
// (3-byte UTF-8) that visually represents the C0 Unit Separator but is
|
|
413
|
+
// itself a normal character. Critical detail: tmux 3.5a's `-F` format
|
|
414
|
+
// escapes raw control bytes (0x00-0x1F) like `\037` for terminal safety,
|
|
415
|
+
// which broke an earlier `'\x1f'` separator — the byte we passed never
|
|
416
|
+
// arrived at the consumer end. A printable Unicode char passes through
|
|
417
|
+
// verbatim and won't appear in any real session name or filesystem path.
|
|
418
|
+
const FIELD_SEP = '␟';
|
|
419
|
+
const readPaneInfo = (session) => {
|
|
420
|
+
const r = (0, child_process_1.spawnSync)('tmux', tmuxArgs(['display-message', '-p', '-t', session,
|
|
421
|
+
`#{pane_current_command}${FIELD_SEP}#{pane_start_command}${FIELD_SEP}#{pane_current_path}`]), {
|
|
422
|
+
encoding: 'utf-8',
|
|
423
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
424
|
+
});
|
|
425
|
+
if (r.status !== 0)
|
|
426
|
+
return { currentCommand: null, startCommand: null, currentPath: null };
|
|
427
|
+
const parts = (r.stdout ?? '').trim().split(FIELD_SEP);
|
|
428
|
+
if (parts.length !== 3)
|
|
429
|
+
return { currentCommand: null, startCommand: null, currentPath: null };
|
|
430
|
+
const [current, start, path] = parts;
|
|
431
|
+
return {
|
|
432
|
+
currentCommand: current || null,
|
|
433
|
+
startCommand: start || null,
|
|
434
|
+
currentPath: path || null,
|
|
435
|
+
};
|
|
436
|
+
};
|
|
437
|
+
const firstTokenBasename = (cmd) => {
|
|
438
|
+
if (!cmd)
|
|
439
|
+
return '';
|
|
440
|
+
return (0, path_1.basename)(cmd.split(/\s+/)[0] || '');
|
|
441
|
+
};
|
|
442
|
+
/**
|
|
443
|
+
* Identify the agent type from the tmux pane. Prefer `pane_start_command`
|
|
444
|
+
* because the foreground process is usually `node`/`python3` (the
|
|
445
|
+
* interpreter), which doesn't tell us *what* was launched. Fall back to
|
|
446
|
+
* `pane_current_command` when start_command is empty — tmux clears
|
|
447
|
+
* start_command in some re-attach cases, especially when a pre-existing
|
|
448
|
+
* session was joined via `tmux new -A` instead of being created fresh.
|
|
449
|
+
* That fallback is safe because we only accept literal `claude` /
|
|
450
|
+
* `codex` / `gemini` as a match.
|
|
451
|
+
*/
|
|
452
|
+
const detectAgentKind = (info) => {
|
|
453
|
+
const startBase = firstTokenBasename(info.startCommand);
|
|
454
|
+
for (const k of AGENT_KINDS) {
|
|
455
|
+
if (startBase === k)
|
|
456
|
+
return k;
|
|
457
|
+
}
|
|
458
|
+
const currentBase = firstTokenBasename(info.currentCommand);
|
|
459
|
+
for (const k of AGENT_KINDS) {
|
|
460
|
+
if (currentBase === k)
|
|
461
|
+
return k;
|
|
462
|
+
}
|
|
463
|
+
return null;
|
|
464
|
+
};
|
|
465
|
+
const epochToIso = (epoch) => {
|
|
466
|
+
if (!epoch)
|
|
467
|
+
return undefined;
|
|
468
|
+
const n = Number(epoch);
|
|
469
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
470
|
+
return undefined;
|
|
471
|
+
return new Date(n * 1000).toISOString();
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* Inventory pass that also records *why* each `zeph-*` session was
|
|
475
|
+
* skipped. The verbose log uses the rejection notes to explain empty
|
|
476
|
+
* pickers (most common cause: tmux pane lost its start_command after a
|
|
477
|
+
* re-attach, and the current command is `node` rather than `claude`).
|
|
478
|
+
*/
|
|
479
|
+
const collectSessionsVerbose = () => {
|
|
480
|
+
const list = (0, child_process_1.spawnSync)('tmux', tmuxArgs(['list-sessions', '-F',
|
|
481
|
+
`#{session_name}${FIELD_SEP}#{session_attached}${FIELD_SEP}#{session_created}${FIELD_SEP}#{session_activity}`]), {
|
|
482
|
+
encoding: 'utf-8',
|
|
483
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
484
|
+
});
|
|
485
|
+
if (list.status !== 0) {
|
|
486
|
+
const stderr = (list.stderr ?? '').toString().trim();
|
|
487
|
+
log(` tmux list-sessions failed: status=${list.status}${stderr ? ', stderr=' + stderr : ''}`);
|
|
488
|
+
return { sessions: [], rejected: [] };
|
|
489
|
+
}
|
|
490
|
+
const rawLines = (list.stdout ?? '').split('\n').filter(Boolean);
|
|
491
|
+
// Sanity-check that the format separator actually survived. tmux is
|
|
492
|
+
// supposed to pass non-format bytes through unchanged, but if any
|
|
493
|
+
// shim (login shell, security tool, terminal wrapper) mangles the
|
|
494
|
+
// 0x1f byte we'd parse the line as a single un-split field and drop
|
|
495
|
+
// it as "not zeph-*". Detect that explicitly so the user isn't left
|
|
496
|
+
// guessing.
|
|
497
|
+
if (rawLines.length > 0 && !rawLines[0].includes(FIELD_SEP)) {
|
|
498
|
+
log(` tmux output missing FIELD_SEP — likely encoding issue. Raw line: ${JSON.stringify(rawLines[0])}`);
|
|
499
|
+
}
|
|
500
|
+
const sessions = [];
|
|
501
|
+
const rejected = [];
|
|
502
|
+
for (const line of rawLines) {
|
|
503
|
+
const [name, attached, created, activity] = line.split(FIELD_SEP);
|
|
504
|
+
const parsed = (0, exports.parseSessionName)(name);
|
|
505
|
+
if (!parsed) {
|
|
506
|
+
// Not noisy enough to log every plain tmux session here —
|
|
507
|
+
// would clutter the verbose output on machines with many
|
|
508
|
+
// non-zeph sessions.
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const info = readPaneInfo(name);
|
|
512
|
+
const agentKind = detectAgentKind(info);
|
|
513
|
+
if (!agentKind) {
|
|
514
|
+
rejected.push({
|
|
515
|
+
name,
|
|
516
|
+
reason: `no agent in pane (start=${info.startCommand ?? 'null'}, current=${info.currentCommand ?? 'null'})`,
|
|
517
|
+
});
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
const agentSessionId = agentKind === 'claude' && info.currentPath
|
|
521
|
+
? (0, exports.detectClaudeSessionId)(info.currentPath)
|
|
522
|
+
: null;
|
|
523
|
+
sessions.push({
|
|
524
|
+
name,
|
|
525
|
+
attached: attached === '1',
|
|
526
|
+
agentKind,
|
|
527
|
+
agentSessionId,
|
|
528
|
+
project: parsed.project,
|
|
529
|
+
label: parsed.label,
|
|
530
|
+
createdAt: epochToIso(created),
|
|
531
|
+
lastActivityAt: epochToIso(activity),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
return { sessions, rejected };
|
|
535
|
+
};
|
|
536
|
+
exports.collectSessionsVerbose = collectSessionsVerbose;
|
|
537
|
+
/**
|
|
538
|
+
* Snapshot the live `zeph-*` tmux sessions on this machine, enriched
|
|
539
|
+
* with the running agent kind, CC session UUID (claude only), project,
|
|
540
|
+
* and tmux activity timestamps. Returns [] when tmux is unreachable
|
|
541
|
+
* or no agent sessions exist. Sessions whose pane is at a shell or
|
|
542
|
+
* running something other than claude/codex/gemini are filtered out
|
|
543
|
+
* — the phone can't usefully address them.
|
|
544
|
+
*/
|
|
545
|
+
const collectSessions = () => (0, exports.collectSessionsVerbose)().sessions;
|
|
546
|
+
exports.collectSessions = collectSessions;
|
|
547
|
+
/**
|
|
548
|
+
* Shared inject path: pane guard → rate limit → tmux send-keys. Both
|
|
549
|
+
* the structured `agent.command` push type and the legacy `@<session>`
|
|
550
|
+
* prefix path route through here so the defense layers can't diverge.
|
|
551
|
+
*/
|
|
552
|
+
const tryInject = (session, text, deps) => {
|
|
553
|
+
if (!text) {
|
|
554
|
+
log(`! ${session}: empty text — drop`);
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
const cmd = (deps.paneCommand ?? exports.paneCurrentCommand)(session);
|
|
558
|
+
if (cmd === null) {
|
|
559
|
+
log(`! ${session}: no such tmux session — drop`);
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
if (isShellPane(cmd)) {
|
|
563
|
+
log(`! ${session}: pane is at shell (${cmd}) — refusing (would be RCE)`);
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
const allowed = (deps.rateLimit ?? exports.checkRateLimit)(session);
|
|
567
|
+
if (!allowed) {
|
|
568
|
+
log(`! ${session}: rate-limited — drop`);
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
const ok = (deps.inject ?? injectKeys)(session, text);
|
|
572
|
+
const preview = text.length > 60 ? text.slice(0, 60) + '…' : text;
|
|
573
|
+
log(`${ok ? '→' : '✗'} ${session}: ${preview}`);
|
|
574
|
+
return ok;
|
|
575
|
+
};
|
|
576
|
+
/**
|
|
577
|
+
* Process one push. Returns true when an injection actually fired.
|
|
578
|
+
* Exported for unit testing with mocked deps.
|
|
579
|
+
*
|
|
580
|
+
* Only acts on `type='agent.command'` pushes carrying both an
|
|
581
|
+
* `agentSessionName` (tmux session to inject into) and a non-empty
|
|
582
|
+
* `body`. Everything else (Stop-hook auto-pushes, zeph_ask responses,
|
|
583
|
+
* encrypted pushes, normal text/link/file notifications) is ignored.
|
|
584
|
+
*/
|
|
585
|
+
const handlePush = (push, deps = {}) => {
|
|
586
|
+
if (push.isEncrypted) {
|
|
587
|
+
// Per-device keys aren't wired yet; encrypted pushes are opaque
|
|
588
|
+
// to the listener.
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
if (push.type !== 'agent.command' || !push.agentSessionName)
|
|
592
|
+
return false;
|
|
593
|
+
return tryInject(push.agentSessionName, push.body ?? '', deps);
|
|
594
|
+
};
|
|
595
|
+
exports.handlePush = handlePush;
|
|
596
|
+
// ─── WS connect loop ─────────────────────────────────────────────────
|
|
597
|
+
const verifyTmux = () => {
|
|
598
|
+
const r = (0, child_process_1.spawnSync)('tmux', ['-V'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
599
|
+
if (r.status !== 0) {
|
|
600
|
+
console.error('zeph listener: tmux not found on PATH. Install tmux first.');
|
|
601
|
+
process.exit(127);
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
605
|
+
const computeBackoff = (attempt) => {
|
|
606
|
+
const base = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt), RECONNECT_MAX_MS);
|
|
607
|
+
const jitter = base * RECONNECT_JITTER_RATIO * (Math.random() * 2 - 1);
|
|
608
|
+
return Math.max(0, base + jitter);
|
|
609
|
+
};
|
|
610
|
+
/**
|
|
611
|
+
* Stable per-host device id for the listener. We hash the OS hostname so
|
|
612
|
+
* the same machine reuses the same DeviceRecord across listener restarts
|
|
613
|
+
* (otherwise the phone's session inventory grows a new ghost device every
|
|
614
|
+
* time `zeph listener` rebinds). `dev_listener_<sha8(hostname)>` keeps it
|
|
615
|
+
* human-recognisable in dev logs without leaking the raw hostname.
|
|
616
|
+
*/
|
|
617
|
+
const computeListenerDeviceId = (host = (0, os_1.hostname)()) => {
|
|
618
|
+
const h = (0, crypto_1.createHash)('sha256').update(host).digest('hex').slice(0, 8);
|
|
619
|
+
return `dev_listener_${h}`;
|
|
620
|
+
};
|
|
621
|
+
exports.computeListenerDeviceId = computeListenerDeviceId;
|
|
622
|
+
/**
|
|
623
|
+
* Open one WebSocket and stream messages until it closes. `done` resolves
|
|
624
|
+
* when the connection is gone; the outer loop decides whether to reconnect.
|
|
625
|
+
* `terminate` lets a signal handler force-close from outside (otherwise
|
|
626
|
+
* SIGINT during an open WS would hang the loop until the server closed).
|
|
627
|
+
*/
|
|
628
|
+
const streamSession = (wsUrl, apiKey) => {
|
|
629
|
+
let ws = null;
|
|
630
|
+
const done = new Promise((resolve) => {
|
|
631
|
+
// deviceId + listenerNickname let the backend attach the connection
|
|
632
|
+
// to a DeviceRecord (auto-created on first connect for apiKey auth).
|
|
633
|
+
// Without these the `listener.sessions` reports are silently dropped
|
|
634
|
+
// server-side and the phone's picker stays empty.
|
|
635
|
+
const deviceId = (0, exports.computeListenerDeviceId)();
|
|
636
|
+
const nickname = (0, os_1.hostname)() || 'listener';
|
|
637
|
+
const params = new URLSearchParams({
|
|
638
|
+
apiKey,
|
|
639
|
+
deviceId,
|
|
640
|
+
listenerNickname: nickname,
|
|
641
|
+
});
|
|
642
|
+
const url = `${wsUrl}?${params.toString()}`;
|
|
643
|
+
ws = new ws_1.default(url);
|
|
644
|
+
const sock = ws;
|
|
645
|
+
let pingTimer = null;
|
|
646
|
+
let pongTimer = null;
|
|
647
|
+
let sessionsTimer = null;
|
|
648
|
+
const cleanup = () => {
|
|
649
|
+
if (pingTimer) {
|
|
650
|
+
clearInterval(pingTimer);
|
|
651
|
+
pingTimer = null;
|
|
652
|
+
}
|
|
653
|
+
if (pongTimer) {
|
|
654
|
+
clearTimeout(pongTimer);
|
|
655
|
+
pongTimer = null;
|
|
656
|
+
}
|
|
657
|
+
if (sessionsTimer) {
|
|
658
|
+
clearInterval(sessionsTimer);
|
|
659
|
+
sessionsTimer = null;
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
const reportSessions = () => {
|
|
663
|
+
if (sock.readyState !== ws_1.default.OPEN)
|
|
664
|
+
return;
|
|
665
|
+
const { sessions, rejected } = (0, exports.collectSessionsVerbose)();
|
|
666
|
+
sock.send(JSON.stringify({ type: 'listener.sessions', data: { sessions } }));
|
|
667
|
+
// One line per cycle gives the user immediate feedback on
|
|
668
|
+
// what the phone picker will see — particularly important
|
|
669
|
+
// during setup, when an empty picker has no other observable
|
|
670
|
+
// cause.
|
|
671
|
+
const names = sessions.map((s) => s.name).join(', ') || '∅';
|
|
672
|
+
log(`reported ${sessions.length} session(s): ${names}`);
|
|
673
|
+
// Explain skipped zeph-* sessions so the most common
|
|
674
|
+
// confusion (pane lost its claude start_command after a
|
|
675
|
+
// re-attach) shows up directly in the log.
|
|
676
|
+
for (const r of rejected)
|
|
677
|
+
log(` skip ${r.name}: ${r.reason}`);
|
|
678
|
+
// When the parsed result is empty AND nothing was rejected,
|
|
679
|
+
// we likely have a tmux-visibility issue (different socket,
|
|
680
|
+
// tmux server not running, etc.). Dump what tmux sees from
|
|
681
|
+
// *this process's* perspective so the user can compare with
|
|
682
|
+
// their interactive shell.
|
|
683
|
+
if (sessions.length === 0 && rejected.length === 0) {
|
|
684
|
+
const raw = (0, child_process_1.spawnSync)('tmux', tmuxArgs(['list-sessions', '-F', '#{session_name}']), {
|
|
685
|
+
encoding: 'utf-8',
|
|
686
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
687
|
+
});
|
|
688
|
+
if (raw.status !== 0) {
|
|
689
|
+
const err = (raw.stderr ?? '').toString().trim() || 'no stderr';
|
|
690
|
+
log(` diag: tmux list-sessions exit=${raw.status}, ${err}`);
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
const all = (raw.stdout ?? '').trim().split('\n').filter(Boolean);
|
|
694
|
+
log(` diag: tmux sees ${all.length} session(s) total: ${all.join(', ') || '∅'}`);
|
|
695
|
+
if (all.length > 0) {
|
|
696
|
+
log(` diag: none start with "zeph-" — check wrapper output or run 'zeph cc' to verify naming`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
sock.on('open', () => {
|
|
702
|
+
log('connected');
|
|
703
|
+
// Initial inventory so the phone's picker has something to
|
|
704
|
+
// show as soon as the listener comes online.
|
|
705
|
+
reportSessions();
|
|
706
|
+
sessionsTimer = setInterval(reportSessions, SESSION_REPORT_INTERVAL_MS);
|
|
707
|
+
pingTimer = setInterval(() => {
|
|
708
|
+
if (sock.readyState !== ws_1.default.OPEN)
|
|
709
|
+
return;
|
|
710
|
+
sock.send(JSON.stringify({ type: 'ping' }));
|
|
711
|
+
pongTimer = setTimeout(() => {
|
|
712
|
+
log('! pong timeout — forcing reconnect');
|
|
713
|
+
sock.terminate();
|
|
714
|
+
}, PONG_TIMEOUT_MS);
|
|
715
|
+
}, PING_INTERVAL_MS);
|
|
716
|
+
});
|
|
717
|
+
sock.on('message', (raw) => {
|
|
718
|
+
if (pongTimer) {
|
|
719
|
+
clearTimeout(pongTimer);
|
|
720
|
+
pongTimer = null;
|
|
721
|
+
}
|
|
722
|
+
let msg;
|
|
723
|
+
try {
|
|
724
|
+
msg = JSON.parse(raw.toString('utf-8'));
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
return; // malformed — ignore
|
|
728
|
+
}
|
|
729
|
+
if (!msg || typeof msg !== 'object')
|
|
730
|
+
return;
|
|
731
|
+
const m = msg;
|
|
732
|
+
if (m.type === 'pong')
|
|
733
|
+
return;
|
|
734
|
+
if (m.type === 'push.new' && m.data)
|
|
735
|
+
(0, exports.handlePush)(m.data);
|
|
736
|
+
// Surface server-side errors from listener.sessions reports.
|
|
737
|
+
// Without this the daemon happily logs "reported N session(s)"
|
|
738
|
+
// even when the server is silently dropping every message —
|
|
739
|
+
// exactly how the picker-empty bug stayed hidden for weeks.
|
|
740
|
+
if (m.type === 'listener.sessions.error') {
|
|
741
|
+
log(`! server rejected listener.sessions: ${m.message ?? '(no detail)'}`);
|
|
742
|
+
}
|
|
743
|
+
if (m.type === 'listener.sessions.ack') {
|
|
744
|
+
const d = m.data;
|
|
745
|
+
log(`✓ server persisted ${d?.count ?? '?'} session(s)`);
|
|
746
|
+
}
|
|
747
|
+
// `push.sync` (offline batch on $connect) and other types ignored.
|
|
748
|
+
});
|
|
749
|
+
sock.on('error', (err) => {
|
|
750
|
+
log(`! ws error: ${err.message}`);
|
|
751
|
+
});
|
|
752
|
+
sock.on('close', (code, reasonBuf) => {
|
|
753
|
+
cleanup();
|
|
754
|
+
resolve({ closeCode: code, reason: reasonBuf?.toString('utf-8') ?? '' });
|
|
755
|
+
});
|
|
756
|
+
});
|
|
757
|
+
return {
|
|
758
|
+
done,
|
|
759
|
+
terminate: () => { ws?.terminate(); },
|
|
760
|
+
};
|
|
761
|
+
};
|
|
762
|
+
const resolveWsUrl = (args, config) => {
|
|
763
|
+
const fromArg = typeof args['ws-url'] === 'string' ? args['ws-url'] : null;
|
|
764
|
+
return fromArg || (0, config_js_1.resolvedEnv)('ZEPH_WS_URL') || config.wsUrl || null;
|
|
765
|
+
};
|
|
766
|
+
// ── Singleton guard (PID file) ──────────────────────────────────────
|
|
767
|
+
const ZEPH_DIR = (0, path_1.join)((0, os_1.homedir)(), '.zeph');
|
|
768
|
+
const LISTENER_PID_FILE = (0, path_1.join)(ZEPH_DIR, 'listener.pid');
|
|
769
|
+
/**
|
|
770
|
+
* Whether another `zeph listener` is already running on this machine.
|
|
771
|
+
* The wrapper's autostart and a user typing `zeph listener` by hand can
|
|
772
|
+
* race — both check this guard so we don't spawn duplicates that
|
|
773
|
+
* compete for the same `agent.command` pushes.
|
|
774
|
+
*
|
|
775
|
+
* Stale PID files (process gone) are treated as "no listener" so the
|
|
776
|
+
* wrapper can recover from crashes without manual cleanup.
|
|
777
|
+
*/
|
|
778
|
+
const otherListenerAlive = () => {
|
|
779
|
+
try {
|
|
780
|
+
const pid = Number((0, fs_1.readFileSync)(LISTENER_PID_FILE, 'utf-8').trim());
|
|
781
|
+
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid)
|
|
782
|
+
return null;
|
|
783
|
+
process.kill(pid, 0); // existence check, throws if dead
|
|
784
|
+
return pid;
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
const writeListenerPid = () => {
|
|
791
|
+
try {
|
|
792
|
+
(0, fs_1.mkdirSync)(ZEPH_DIR, { recursive: true });
|
|
793
|
+
(0, fs_1.writeFileSync)(LISTENER_PID_FILE, String(process.pid));
|
|
794
|
+
}
|
|
795
|
+
catch (err) {
|
|
796
|
+
log(`! could not write ${LISTENER_PID_FILE}: ${err.message}`);
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
const removeListenerPid = () => {
|
|
800
|
+
try {
|
|
801
|
+
if (!(0, fs_1.existsSync)(LISTENER_PID_FILE))
|
|
802
|
+
return;
|
|
803
|
+
// Only remove our own pid file — don't trample a successor's.
|
|
804
|
+
const pid = Number((0, fs_1.readFileSync)(LISTENER_PID_FILE, 'utf-8').trim());
|
|
805
|
+
if (pid === process.pid)
|
|
806
|
+
(0, fs_1.unlinkSync)(LISTENER_PID_FILE);
|
|
807
|
+
}
|
|
808
|
+
catch { /* best-effort */ }
|
|
809
|
+
};
|
|
810
|
+
const handleListener = async (args) => {
|
|
811
|
+
verifyTmux();
|
|
812
|
+
// Refuse to start when another listener is already running. The
|
|
813
|
+
// wrapper's autostart calls us blindly on every `zeph cc`; the user
|
|
814
|
+
// running `zeph listener` directly does too. Bail with exit 0 (not
|
|
815
|
+
// an error — there *is* a listener, just not us).
|
|
816
|
+
const otherPid = otherListenerAlive();
|
|
817
|
+
if (otherPid) {
|
|
818
|
+
if (process.env.ZEPH_LISTENER_AUTOSTART === '1') {
|
|
819
|
+
// Autostart from the wrapper — stay quiet on the happy path.
|
|
820
|
+
return 0;
|
|
821
|
+
}
|
|
822
|
+
console.error(`zeph listener: another listener is already running (pid ${otherPid}). ` +
|
|
823
|
+
`Tail \`~/.zeph/listener.log\` to follow it, or kill ${otherPid} first.`);
|
|
824
|
+
return 0;
|
|
825
|
+
}
|
|
826
|
+
const config = (0, config_js_1.loadConfig)();
|
|
827
|
+
const apiKey = args.key || (0, config_js_1.resolvedEnv)('ZEPH_API_KEY') || config.apiKey;
|
|
828
|
+
if (!apiKey) {
|
|
829
|
+
console.error('zeph listener: API key required. Run `zeph install` or set ZEPH_API_KEY.');
|
|
830
|
+
return 3;
|
|
831
|
+
}
|
|
832
|
+
const wsUrl = resolveWsUrl(args, config);
|
|
833
|
+
if (!wsUrl) {
|
|
834
|
+
console.error('zeph listener: WebSocket URL not set. Either:\n' +
|
|
835
|
+
' • add "wsUrl": "wss://..." to ~/.zeph/config.json\n' +
|
|
836
|
+
' • export ZEPH_WS_URL=wss://...\n' +
|
|
837
|
+
' • pass --ws-url wss://...');
|
|
838
|
+
return 1;
|
|
839
|
+
}
|
|
840
|
+
writeListenerPid();
|
|
841
|
+
process.on('exit', removeListenerPid);
|
|
842
|
+
log(`zeph listener starting — ${wsUrl}`);
|
|
843
|
+
log(`device=${(0, exports.computeListenerDeviceId)()} host=${(0, os_1.hostname)()} pid=${process.pid}`);
|
|
844
|
+
log("Waiting for 'agent.command' pushes from the phone picker. Ctrl-C to stop.");
|
|
845
|
+
let shuttingDown = false;
|
|
846
|
+
let activeHandle = null;
|
|
847
|
+
const stop = (sig) => {
|
|
848
|
+
if (shuttingDown)
|
|
849
|
+
return;
|
|
850
|
+
shuttingDown = true;
|
|
851
|
+
log(`received ${sig}, stopping`);
|
|
852
|
+
// Force-close any open WS so the streamSession promise resolves
|
|
853
|
+
// immediately instead of waiting for the server to drop us.
|
|
854
|
+
activeHandle?.terminate();
|
|
855
|
+
};
|
|
856
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
857
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
858
|
+
let attempt = 0;
|
|
859
|
+
while (!shuttingDown) {
|
|
860
|
+
activeHandle = streamSession(wsUrl, apiKey);
|
|
861
|
+
const result = await activeHandle.done;
|
|
862
|
+
activeHandle = null;
|
|
863
|
+
if (AUTH_FAILURE_CODES.has(result.closeCode ?? -1)) {
|
|
864
|
+
console.error(`zeph listener: auth failure (${result.closeCode} ${result.reason}). Check API key.`);
|
|
865
|
+
removeListenerPid();
|
|
866
|
+
return 3;
|
|
867
|
+
}
|
|
868
|
+
if (shuttingDown)
|
|
869
|
+
break;
|
|
870
|
+
const delay = computeBackoff(attempt);
|
|
871
|
+
log(`disconnected (code=${result.closeCode}) — reconnect in ${Math.round(delay / 1000)}s`);
|
|
872
|
+
await sleep(delay);
|
|
873
|
+
attempt = Math.min(attempt + 1, 10);
|
|
874
|
+
}
|
|
875
|
+
removeListenerPid();
|
|
876
|
+
return 0;
|
|
877
|
+
};
|
|
878
|
+
exports.handleListener = handleListener;
|