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