@zeph-to/hook-sdk 1.9.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.
@@ -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;