@zeph-to/cli 1.13.2 → 1.15.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/dist/listener.js CHANGED
@@ -25,7 +25,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
25
25
  return (mod && mod.__esModule) ? mod : { "default": mod };
26
26
  };
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
- exports.handleListener = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.setAttachmentContext = exports.collectSessions = exports.collectSessionsVerbose = exports.detectClaudeSessionId = exports.parseSessionName = exports.invalidateTmuxSocketCache = exports.paneCurrentCommand = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = void 0;
28
+ exports.handleListener = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.setAttachmentContext = exports.collectSessions = exports.collectSessionsVerbose = exports.handleScreenRequest = exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = exports.deriveSessionState = exports.resetSessionStates = exports.detectRemoteAgent = exports.parseSessionName = exports.invalidateTmuxSocketCache = exports.paneCurrentCommand = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = void 0;
29
29
  const child_process_1 = require("child_process");
30
30
  const crypto_1 = require("crypto");
31
31
  const fs_1 = require("fs");
@@ -33,6 +33,9 @@ const os_1 = require("os");
33
33
  const path_1 = require("path");
34
34
  const ws_1 = __importDefault(require("ws"));
35
35
  const config_js_1 = require("./config.js");
36
+ const remote_agents_js_1 = require("./remote-agents.js");
37
+ const agent_state_js_1 = require("./agent-state.js");
38
+ const agent_rules_fetch_js_1 = require("./agent-rules-fetch.js");
36
39
  const PING_INTERVAL_MS = 25_000;
37
40
  const PONG_TIMEOUT_MS = 10_000;
38
41
  const RECONNECT_BASE_MS = 1_000;
@@ -54,7 +57,6 @@ const WS_STALL_TIMEOUT_MS = 90_000;
54
57
  // to reflect new `zeph cc` sessions within a few seconds, not half a
55
58
  // minute.
56
59
  const SESSION_REPORT_INTERVAL_MS = 5_000;
57
- const AGENT_KINDS = ['claude', 'codex', 'gemini'];
58
60
  // Per-session token bucket — caps a runaway/compromised sender. 30/min
59
61
  // is generous for human-driven phone use, tight enough to block flooding.
60
62
  const RATE_LIMIT_TOKENS = 30;
@@ -400,70 +402,6 @@ const parseSessionName = (name) => {
400
402
  return { project: rest, label: null };
401
403
  };
402
404
  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
405
  // U+241F "Symbol for Unit Separator" — a *printable* Unicode glyph
468
406
  // (3-byte UTF-8) that visually represents the C0 Unit Separator but is
469
407
  // itself a normal character. Critical detail: tmux 3.5a's `-F` format
@@ -509,28 +447,19 @@ const firstTokenBasename = (cmd) => {
509
447
  return (0, path_1.basename)(stripped.split(/\s+/)[0] || '');
510
448
  };
511
449
  /**
512
- * Identify the agent type from the tmux pane. Prefer `pane_start_command`
450
+ * Identify the agent from the tmux pane. Prefer `pane_start_command`
513
451
  * because the foreground process is usually `node`/`python3` (the
514
452
  * interpreter), which doesn't tell us *what* was launched. Fall back to
515
453
  * `pane_current_command` when start_command is empty — tmux clears
516
454
  * start_command in some re-attach cases, especially when a pre-existing
517
455
  * 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.
456
+ * That fallback is safe because only the literal binaries registered in
457
+ * remote-agents.ts are accepted as a match.
520
458
  */
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
- };
459
+ const detectRemoteAgent = (info) => (0, remote_agents_js_1.matchAgentByPaneCommand)(firstTokenBasename(info.startCommand))
460
+ ?? (0, remote_agents_js_1.matchAgentByPaneCommand)(firstTokenBasename(info.currentCommand))
461
+ ?? null;
462
+ exports.detectRemoteAgent = detectRemoteAgent;
534
463
  const epochToIso = (epoch) => {
535
464
  if (!epoch)
536
465
  return undefined;
@@ -539,6 +468,166 @@ const epochToIso = (epoch) => {
539
468
  return undefined;
540
469
  return new Date(n * 1000).toISOString();
541
470
  };
471
+ // ─── Agent state detection (SPEC-AGENT-AWARENESS §S1) ──────────────
472
+ // How many pane lines the state rules see. The live status area every
473
+ // agent renders sits in the last handful of lines; 40 leaves margin for
474
+ // tall dialogs without hauling whole scrollbacks through regex.
475
+ const STATE_CAPTURE_LINES = 40;
476
+ const sessionStates = new Map();
477
+ /** Test hook. */
478
+ const resetSessionStates = () => {
479
+ sessionStates.clear();
480
+ };
481
+ exports.resetSessionStates = resetSessionStates;
482
+ const capturePaneText = (session) => {
483
+ const r = (0, child_process_1.spawnSync)('tmux', tmuxArgs(['capture-pane', '-p', '-t', session, '-S', `-${STATE_CAPTURE_LINES}`]), {
484
+ encoding: 'utf-8',
485
+ stdio: ['ignore', 'pipe', 'pipe'],
486
+ });
487
+ if (r.status !== 0)
488
+ return null;
489
+ return r.stdout ?? '';
490
+ };
491
+ /**
492
+ * Fold one pane capture into the per-session state machine and return
493
+ * the wire fields for this report cycle. Exported (with `paneText`
494
+ * injected) so tests can drive it without tmux.
495
+ *
496
+ * Hash short-circuit: an unchanged screen skips rule evaluation but
497
+ * still replays the previous observation through the debounce — cheap
498
+ * cycles stay cheap without wedging candidate promotion.
499
+ */
500
+ const deriveSessionState = (name, agentKind, paneText, now = Date.now()) => {
501
+ if (paneText === null) {
502
+ // Pane unreadable this cycle (session racing shutdown, tmux
503
+ // hiccup) — report nothing rather than a stale confident state.
504
+ sessionStates.delete(name);
505
+ return {};
506
+ }
507
+ const contentHash = (0, crypto_1.createHash)('sha1').update(paneText).digest('hex');
508
+ const entry = sessionStates.get(name);
509
+ const observed = entry && entry.contentHash === contentHash
510
+ ? entry.lastObserved
511
+ : (0, agent_state_js_1.evaluateState)(paneText, agentKind, (0, agent_rules_fetch_js_1.getActiveManifest)(), entry?.tracker.confirmed ?? 'unknown');
512
+ const tracker = (0, agent_state_js_1.advanceState)(entry?.tracker, observed, now);
513
+ sessionStates.set(name, { tracker, lastObserved: observed, contentHash });
514
+ return {
515
+ state: tracker.confirmed,
516
+ stateChangedAt: new Date(tracker.confirmedAt).toISOString(),
517
+ stateRuleId: tracker.ruleId,
518
+ };
519
+ };
520
+ exports.deriveSessionState = deriveSessionState;
521
+ /** Drop trackers for sessions gone from the inventory. */
522
+ const pruneSessionStates = (liveNames) => {
523
+ for (const name of sessionStates.keys()) {
524
+ if (!liveNames.has(name))
525
+ sessionStates.delete(name);
526
+ }
527
+ };
528
+ let patternWatches = [];
529
+ // One-shot: after a hit we stop re-evaluating that watch locally. The
530
+ // server deletes the record on hit, and the next ack prunes it here —
531
+ // the fired-set only bridges the gap between hit and ack.
532
+ const firedWatchKeys = new Set();
533
+ const patternWatchKey = (w) => `${w.sessionName}${w.pattern}`;
534
+ /** Ack payload → active watch list. Prunes fired-markers for dead watches. */
535
+ const setPatternWatches = (raw) => {
536
+ patternWatches = Array.isArray(raw)
537
+ ? raw.filter((w) => typeof w === 'object' && w !== null
538
+ && typeof w.sessionName === 'string'
539
+ && typeof w.pattern === 'string')
540
+ : [];
541
+ const live = new Set(patternWatches.map(patternWatchKey));
542
+ for (const key of firedWatchKeys) {
543
+ if (!live.has(key))
544
+ firedWatchKeys.delete(key);
545
+ }
546
+ };
547
+ exports.setPatternWatches = setPatternWatches;
548
+ /** Test hook. */
549
+ const resetPatternWatches = () => {
550
+ patternWatches = [];
551
+ firedWatchKeys.clear();
552
+ };
553
+ exports.resetPatternWatches = resetPatternWatches;
554
+ /**
555
+ * Probe every un-fired watch whose session is live. `capture` is
556
+ * injectable for tests; production passes capturePaneText.
557
+ */
558
+ const collectWatchHits = (liveNames, capture = capturePaneText) => {
559
+ const hits = [];
560
+ const textCache = new Map();
561
+ for (const w of patternWatches) {
562
+ if (!liveNames.has(w.sessionName))
563
+ continue;
564
+ const key = patternWatchKey(w);
565
+ if (firedWatchKeys.has(key))
566
+ continue;
567
+ if (!textCache.has(w.sessionName))
568
+ textCache.set(w.sessionName, capture(w.sessionName));
569
+ const text = textCache.get(w.sessionName);
570
+ if (text === null || text === undefined)
571
+ continue;
572
+ const match = (0, agent_state_js_1.findPatternMatch)(w.pattern, text);
573
+ if (!match)
574
+ continue;
575
+ firedWatchKeys.add(key);
576
+ hits.push({ sessionName: w.sessionName, pattern: w.pattern, matchedLine: match.line });
577
+ }
578
+ return hits;
579
+ };
580
+ exports.collectWatchHits = collectWatchHits;
581
+ // ─── Screen peek (SPEC-AGENT-AWARENESS §S4) ────────────────────────
582
+ // Pane lines returned to the phone. Taller than the state-detection
583
+ // capture — the user wants to READ this, not regex it.
584
+ const SCREEN_PEEK_LINES = 60;
585
+ // Ephemeral frames ride API Gateway WS (32KB frame limit); stay well
586
+ // under it after JSON envelope overhead.
587
+ const SCREEN_PEEK_MAX_BYTES = 24 * 1024;
588
+ /**
589
+ * Answer a phone's screen-peek request for one of OUR sessions.
590
+ * Returns null when the message isn't addressed to this machine (other
591
+ * ephemeral traffic — clipboard, mirrors — flows through constantly).
592
+ *
593
+ * Security posture: only sessions the inventory already exposes are
594
+ * readable — the phone can see exactly the panes it can already send
595
+ * commands into, nothing else. Rate-limited with the same per-session
596
+ * token bucket as command injection.
597
+ */
598
+ const handleScreenRequest = (req) => {
599
+ if (req.subtype !== 'agent.screen.request')
600
+ return null;
601
+ if (!req.requestId || !req.sessionName)
602
+ return null;
603
+ if (req.targetDeviceId !== (0, exports.computeListenerDeviceId)())
604
+ return null;
605
+ const capturedAt = new Date().toISOString();
606
+ const base = { subtype: 'agent.screen.snapshot', requestId: req.requestId, sessionName: req.sessionName, capturedAt };
607
+ if (!(0, exports.checkRateLimit)(`screen:${req.sessionName}`)) {
608
+ return { ...base, error: 'rate_limited' };
609
+ }
610
+ if (!(0, exports.collectSessions)().some((s) => s.name === req.sessionName)) {
611
+ return { ...base, error: 'unknown_session' };
612
+ }
613
+ const r = (0, child_process_1.spawnSync)('tmux', tmuxArgs(['capture-pane', '-p', '-t', req.sessionName, '-S', `-${SCREEN_PEEK_LINES}`]), {
614
+ encoding: 'utf-8',
615
+ stdio: ['ignore', 'pipe', 'pipe'],
616
+ });
617
+ if (r.status !== 0) {
618
+ return { ...base, error: 'capture_failed' };
619
+ }
620
+ let content = r.stdout ?? '';
621
+ let truncated = false;
622
+ while (Buffer.byteLength(content, 'utf-8') > SCREEN_PEEK_MAX_BYTES) {
623
+ // Cut from the top — the bottom of the pane is what the user needs.
624
+ const cut = content.indexOf('\n', Math.floor(content.length / 8));
625
+ content = cut > 0 ? content.slice(cut + 1) : content.slice(-SCREEN_PEEK_MAX_BYTES);
626
+ truncated = true;
627
+ }
628
+ return { ...base, content, truncated };
629
+ };
630
+ exports.handleScreenRequest = handleScreenRequest;
542
631
  /**
543
632
  * Inventory pass that also records *why* each `zeph-*` session was
544
633
  * skipped. The verbose log uses the rejection notes to explain empty
@@ -583,38 +672,41 @@ const collectSessionsVerbose = () => {
583
672
  continue;
584
673
  }
585
674
  const info = readPaneInfo(name);
586
- const agentKind = detectAgentKind(info);
587
- if (!agentKind) {
675
+ const agent = (0, exports.detectRemoteAgent)(info);
676
+ if (!agent) {
588
677
  rejected.push({
589
678
  name,
590
679
  reason: `no agent in pane (start=${info.startCommand ?? 'null'}, current=${info.currentCommand ?? 'null'})`,
591
680
  });
592
681
  continue;
593
682
  }
594
- const agentSessionId = agentKind === 'claude' && info.currentPath
595
- ? (0, exports.detectClaudeSessionId)(info.currentPath)
683
+ const agentSessionId = info.currentPath
684
+ ? (agent.resolveSessionId?.(info.currentPath) ?? null)
596
685
  : null;
597
686
  sessions.push({
598
687
  name,
599
688
  attached: attached === '1',
600
- agentKind,
689
+ agentKind: agent.kind,
601
690
  agentSessionId,
602
691
  project: parsed.project,
603
692
  label: parsed.label,
604
693
  createdAt: epochToIso(created),
605
694
  lastActivityAt: epochToIso(activity),
695
+ ...(0, exports.deriveSessionState)(name, agent.kind, capturePaneText(name)),
606
696
  });
607
697
  }
698
+ pruneSessionStates(new Set(sessions.map((s) => s.name)));
608
699
  return { sessions, rejected };
609
700
  };
610
701
  exports.collectSessionsVerbose = collectSessionsVerbose;
611
702
  /**
612
703
  * 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.
704
+ * with the running agent kind, the agent's own session id (when the
705
+ * registry has a resolver currently Claude Code only), project, and
706
+ * tmux activity timestamps. Returns [] when tmux is unreachable or no
707
+ * agent sessions exist. Sessions whose pane is at a shell or running
708
+ * something not registered in remote-agents.ts are filtered out — the
709
+ * phone can't usefully address them.
618
710
  */
619
711
  const collectSessions = () => (0, exports.collectSessionsVerbose)().sessions;
620
712
  exports.collectSessions = collectSessions;
@@ -894,11 +986,20 @@ const streamSession = (wsUrl, apiKey) => {
894
986
  return;
895
987
  const { sessions, rejected } = (0, exports.collectSessionsVerbose)();
896
988
  sock.send(JSON.stringify({ type: 'listener.sessions', data: { sessions } }));
989
+ // Output-match watches (§S5 v2): probe watched panes and
990
+ // report hits. One-shot per watch; the server consumes the
991
+ // record and the next ack prunes it locally.
992
+ for (const hit of (0, exports.collectWatchHits)(new Set(sessions.map((s) => s.name)))) {
993
+ sock.send(JSON.stringify({ type: 'listener.watch.hit', data: hit }));
994
+ log(`🔔 watch hit: ${hit.sessionName} ~ /${hit.pattern}/`);
995
+ }
897
996
  // One line per cycle gives the user immediate feedback on
898
997
  // what the phone picker will see — particularly important
899
998
  // during setup, when an empty picker has no other observable
900
999
  // cause.
901
- const names = sessions.map((s) => s.name).join(', ') || '∅';
1000
+ const names = sessions
1001
+ .map((s) => (s.state ? `${s.name}[${s.state}]` : s.name))
1002
+ .join(', ') || '∅';
902
1003
  log(`reported ${sessions.length} session(s): ${names}`);
903
1004
  // Explain skipped zeph-* sessions so the most common
904
1005
  // confusion (pane lost its claude start_command after a
@@ -988,6 +1089,15 @@ const streamSession = (wsUrl, apiKey) => {
988
1089
  // logged, never thrown into the socket handler.
989
1090
  void (0, exports.handlePush)(m.data).catch((err) => log(`! handlePush: ${err.message}`));
990
1091
  }
1092
+ if (m.type === 'ephemeral' && m.data) {
1093
+ // Screen peek (§S4): the phone asks for this machine's live
1094
+ // pane content over the ephemeral relay; the reply rides the
1095
+ // same channel. Nothing is persisted server-side.
1096
+ const reply = (0, exports.handleScreenRequest)(m.data);
1097
+ if (reply && sock.readyState === ws_1.default.OPEN) {
1098
+ sock.send(JSON.stringify({ type: 'ephemeral', data: reply }));
1099
+ }
1100
+ }
991
1101
  // Surface server-side errors from listener.sessions reports.
992
1102
  // Without this the daemon happily logs "reported N session(s)"
993
1103
  // even when the server is silently dropping every message —
@@ -998,6 +1108,8 @@ const streamSession = (wsUrl, apiKey) => {
998
1108
  if (m.type === 'listener.sessions.ack') {
999
1109
  const d = m.data;
1000
1110
  log(`✓ server persisted ${d?.count ?? '?'} session(s)`);
1111
+ // Pattern watches ride the ack (§S5 v2) — refresh ours.
1112
+ (0, exports.setPatternWatches)(d?.watches);
1001
1113
  }
1002
1114
  // `push.sync` (offline batch on $connect) and other types ignored.
1003
1115
  });
@@ -1122,6 +1234,17 @@ const handleListener = async (args) => {
1122
1234
  sweepAttachments();
1123
1235
  const gcTimer = setInterval(sweepAttachments, 60 * 60 * 1000);
1124
1236
  gcTimer.unref();
1237
+ // Agent detection rules: disk cache immediately (offline-safe),
1238
+ // then a background OTA refresh now and every 6 h (§S7).
1239
+ (0, agent_rules_fetch_js_1.loadManifestFromCache)();
1240
+ const refreshRules = () => {
1241
+ void (0, agent_rules_fetch_js_1.refreshManifest)().then((r) => {
1242
+ log(`rules: source=${r.source} outcome=${r.outcome}${r.version ? ` version=${r.version}` : ''}`);
1243
+ });
1244
+ };
1245
+ refreshRules();
1246
+ const rulesTimer = setInterval(refreshRules, agent_rules_fetch_js_1.RULES_REFRESH_INTERVAL_MS);
1247
+ rulesTimer.unref();
1125
1248
  let shuttingDown = false;
1126
1249
  let activeHandle = null;
1127
1250
  const stop = (sig) => {
@@ -0,0 +1,55 @@
1
+ export interface RemoteAgent {
2
+ /** Wire value for AgentSession.agentKind (server/phone contract). */
3
+ kind: string;
4
+ /** Human name for --help text. */
5
+ displayName: string;
6
+ /** Binary launched in the tmux pane; also the primary pane-match token. */
7
+ binary: string;
8
+ /** `zeph <subcommand>` aliases that launch this agent. */
9
+ subcommands: readonly string[];
10
+ /** Extra pane_command basenames accepted as this agent (beyond binary). */
11
+ paneMatchAliases?: readonly string[];
12
+ /**
13
+ * Resolve the agent's own session id from the pane's cwd.
14
+ * EXTENSION POINT: omitted for codex/gemini until their session-file
15
+ * formats are confirmed — the listener then reports agentSessionId: null.
16
+ */
17
+ resolveSessionId?: (paneCwd: string) => string | null;
18
+ }
19
+ /**
20
+ * Locate the most recent Claude Code session UUID for the working
21
+ * directory of a tmux pane. Mirrors `mcp-server/config.ts`'s
22
+ * detectClaudeSessionId: CC writes per-session jsonl files at
23
+ * `~/.claude/projects/<projectHash>/<UUID>.jsonl` where the hash is
24
+ * the cwd with `/` replaced by `-`. Cached for 60s — see
25
+ * claudeSessionCache.
26
+ */
27
+ export declare const detectClaudeSessionId: (cwd: string) => string | null;
28
+ declare const REMOTE_AGENT_TABLE: readonly [{
29
+ readonly kind: "claude";
30
+ readonly displayName: "Claude Code";
31
+ readonly binary: "claude";
32
+ readonly subcommands: readonly ["cc", "claude"];
33
+ readonly resolveSessionId: (cwd: string) => string | null;
34
+ }, {
35
+ readonly kind: "codex";
36
+ readonly displayName: "Codex CLI";
37
+ readonly binary: "codex";
38
+ readonly subcommands: readonly ["codex"];
39
+ }, {
40
+ readonly kind: "gemini";
41
+ readonly displayName: "Gemini CLI";
42
+ readonly binary: "gemini";
43
+ readonly subcommands: readonly ["gemini"];
44
+ }];
45
+ /** Closed union of remote-controllable agent kinds ('claude' | 'codex' | 'gemini'). */
46
+ export type AgentKind = (typeof REMOTE_AGENT_TABLE)[number]['kind'];
47
+ /** A registry row: the uniform RemoteAgent shape with `kind` narrowed to the closed union. */
48
+ export type RegisteredRemoteAgent = RemoteAgent & {
49
+ kind: AgentKind;
50
+ };
51
+ export declare const REMOTE_AGENTS: readonly RegisteredRemoteAgent[];
52
+ export declare const findAgentBySubcommand: (cmd: string) => RegisteredRemoteAgent | undefined;
53
+ export declare const matchAgentByPaneCommand: (base: string) => RegisteredRemoteAgent | undefined;
54
+ export {};
55
+ //# sourceMappingURL=remote-agents.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-agents.d.ts","sourceRoot":"","sources":["../src/remote-agents.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,WAAW;IACxB,qEAAqE;IACrE,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IAC/B,2EAA2E;IAC3E,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CACzD;AAyCD;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAiB5D,CAAC;AAIF,QAAA,MAAM,kBAAkB;;;;;qCArBmB,MAAM,KAAG,MAAM,GAAG,IAAI;;;;;;;;;;;EAyCtB,CAAC;AAE5C,uFAAuF;AACvF,MAAM,MAAM,SAAS,GAAG,CAAC,OAAO,kBAAkB,CAAC,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC;AAEpE,8FAA8F;AAC9F,MAAM,MAAM,qBAAqB,GAAG,WAAW,GAAG;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAEtE,eAAO,MAAM,aAAa,EAAE,SAAS,qBAAqB,EAAuB,CAAC;AAElF,eAAO,MAAM,qBAAqB,GAAI,KAAK,MAAM,KAAG,qBAAqB,GAAG,SAClB,CAAC;AAE3D,eAAO,MAAM,uBAAuB,GAAI,MAAM,MAAM,KAAG,qBAAqB,GAAG,SAG9E,CAAC"}
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.matchAgentByPaneCommand = exports.findAgentBySubcommand = exports.REMOTE_AGENTS = exports.detectClaudeSessionId = void 0;
4
+ /**
5
+ * Remote-control agent registry — the single table behind `zeph cc` /
6
+ * `zeph codex` / `zeph gemini`, the listener's pane matching, and the
7
+ * per-agent session-id enrichment. Adding a remote-controllable agent is
8
+ * one row here (plus, for a genuinely new kind, backend/phone support:
9
+ * `kind` is a wire contract — AgentSession.agentKind flows to the server
10
+ * and the phone picker, which may validate the enum).
11
+ *
12
+ * This is deliberately NOT merged into `agents.ts`: that table drives
13
+ * install/uninstall/verify detection (8 agents, incl. Cursor/Windsurf
14
+ * which can never be driven via tmux), and the two tables carry different
15
+ * name axes — install id vs subcommand alias vs pane binary.
16
+ */
17
+ const fs_1 = require("fs");
18
+ const os_1 = require("os");
19
+ const path_1 = require("path");
20
+ // ── Claude Code session resolver ─────────────────────────────────
21
+ const CLAUDE_PROJECTS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'projects');
22
+ /**
23
+ * Cache for detectClaudeSessionId. The function walks every jsonl file
24
+ * in `~/.claude/projects/<hash>/` on each call — after weeks of CC use
25
+ * that directory holds hundreds of session files, and we were calling
26
+ * this per tmux session per 5-second report cycle. Heavy disk I/O
27
+ * compounded with multiple sessions caused the report cycle to spike
28
+ * CPU and starve the host shell.
29
+ *
30
+ * The current-session UUID only changes when a new CC session starts
31
+ * in that directory (rare, on the order of hours), so a 60-second TTL
32
+ * is safe and cuts the per-cycle stat count by ~12×.
33
+ */
34
+ const claudeSessionCache = new Map();
35
+ const CLAUDE_SESSION_CACHE_TTL_MS = 60_000;
36
+ const doDetectClaudeSessionId = (cwd) => {
37
+ try {
38
+ const projectHash = cwd.replace(/\//g, '-');
39
+ const sessionsDir = (0, path_1.join)(CLAUDE_PROJECTS_DIR, projectHash);
40
+ let latest;
41
+ for (const entry of (0, fs_1.readdirSync)(sessionsDir)) {
42
+ 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$/);
43
+ if (!m)
44
+ continue;
45
+ const stat = (0, fs_1.statSync)((0, path_1.join)(sessionsDir, entry));
46
+ if (!stat.isFile())
47
+ continue;
48
+ if (!latest || stat.mtimeMs > latest.mtime) {
49
+ latest = { name: m[1], mtime: stat.mtimeMs };
50
+ }
51
+ }
52
+ return latest?.name ?? null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ };
58
+ /**
59
+ * Locate the most recent Claude Code session UUID for the working
60
+ * directory of a tmux pane. Mirrors `mcp-server/config.ts`'s
61
+ * detectClaudeSessionId: CC writes per-session jsonl files at
62
+ * `~/.claude/projects/<projectHash>/<UUID>.jsonl` where the hash is
63
+ * the cwd with `/` replaced by `-`. Cached for 60s — see
64
+ * claudeSessionCache.
65
+ */
66
+ const detectClaudeSessionId = (cwd) => {
67
+ const now = Date.now();
68
+ const cached = claudeSessionCache.get(cwd);
69
+ if (cached && cached.expiresAt > now)
70
+ return cached.sessionId;
71
+ // Cap cache size so a long-lived listener that's seen many cwds
72
+ // doesn't grow unbounded. 64 is plenty for any realistic setup.
73
+ if (claudeSessionCache.size >= 64) {
74
+ // Evict the oldest-expiring entry — Map iteration order is
75
+ // insertion order, so the first key we hit is the oldest.
76
+ const firstKey = claudeSessionCache.keys().next().value;
77
+ if (firstKey !== undefined)
78
+ claudeSessionCache.delete(firstKey);
79
+ }
80
+ const sessionId = doDetectClaudeSessionId(cwd);
81
+ claudeSessionCache.set(cwd, { sessionId, expiresAt: now + CLAUDE_SESSION_CACHE_TTL_MS });
82
+ return sessionId;
83
+ };
84
+ exports.detectClaudeSessionId = detectClaudeSessionId;
85
+ // ── The registry ─────────────────────────────────────────────────
86
+ const REMOTE_AGENT_TABLE = [
87
+ {
88
+ kind: 'claude',
89
+ displayName: 'Claude Code',
90
+ binary: 'claude',
91
+ subcommands: ['cc', 'claude'],
92
+ resolveSessionId: exports.detectClaudeSessionId,
93
+ },
94
+ {
95
+ kind: 'codex',
96
+ displayName: 'Codex CLI',
97
+ binary: 'codex',
98
+ subcommands: ['codex'],
99
+ },
100
+ {
101
+ kind: 'gemini',
102
+ displayName: 'Gemini CLI',
103
+ binary: 'gemini',
104
+ subcommands: ['gemini'],
105
+ },
106
+ ];
107
+ exports.REMOTE_AGENTS = REMOTE_AGENT_TABLE;
108
+ const findAgentBySubcommand = (cmd) => exports.REMOTE_AGENTS.find((a) => a.subcommands.includes(cmd));
109
+ exports.findAgentBySubcommand = findAgentBySubcommand;
110
+ const matchAgentByPaneCommand = (base) => {
111
+ if (!base)
112
+ return undefined;
113
+ return exports.REMOTE_AGENTS.find((a) => a.binary === base || (a.paneMatchAliases ?? []).includes(base));
114
+ };
115
+ exports.matchAgentByPaneCommand = matchAgentByPaneCommand;
@@ -1 +1 @@
1
- {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AAgNA,6EAA6E;AAC7E,eAAO,MAAM,WAAW,QAGtB,CAAC;AAEH,6EAA6E;AAC7E,eAAO,MAAM,aAAa,QAA4C,CAAC;AAEvE,sDAAsD;AACtD,eAAO,MAAM,WAAW,QAA4C,CAAC;AAErE,oDAAoD;AACpD,eAAO,MAAM,UAAU,QAA4C,CAAC;AAEpE,oFAAoF;AACpF,eAAO,MAAM,YAAY,QAA4C,CAAC;AAEtE,gEAAgE;AAChE,eAAO,MAAM,UAAU,QAAuC,CAAC;AAE/D,4FAA4F;AAC5F,eAAO,MAAM,UAAU,QAAuC,CAAC;AAI/D,eAAO,MAAM,YAAY,QAKd,CAAC;AAEZ,eAAO,MAAM,cAAc,QAOhB,CAAC;AAEZ,eAAO,MAAM,YAAY;;;;;;;;;;;;;;CAYxB,CAAC;AAEF,eAAO,MAAM,WAAW,QAQb,CAAC;AAEZ,eAAO,MAAM,aAAa,QASf,CAAC;AASZ,eAAO,MAAM,eAAe,oFAA+E,CAAC;AAC5G,eAAO,MAAM,aAAa,sBAAsB,CAAC;AAQjD;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,EAAE,MAAM,MAAM,KAAG,MAWnE,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,KAAG,MAOrD,CAAC"}
1
+ {"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"AAoFA,6EAA6E;AAC7E,eAAO,MAAM,WAAW,QAItB,CAAC;AAEH,6EAA6E;AAC7E,eAAO,MAAM,aAAa,QAAyE,CAAC;AAEpG,sDAAsD;AACtD,eAAO,MAAM,WAAW,QAAyE,CAAC;AAElG,oDAAoD;AACpD,eAAO,MAAM,UAAU,QAAyE,CAAC;AAEjG,oFAAoF;AACpF,eAAO,MAAM,YAAY,QAAyE,CAAC;AAEnG,gEAAgE;AAChE,eAAO,MAAM,UAAU,QAAkE,CAAC;AAE1F,4FAA4F;AAC5F,eAAO,MAAM,UAAU,QAAkE,CAAC;AAI1F,eAAO,MAAM,YAAY,QAKd,CAAC;AAEZ,eAAO,MAAM,cAAc,QAOhB,CAAC;AAEZ,eAAO,MAAM,YAAY;;;;;;;;;;;;;;CAYxB,CAAC;AAEF,eAAO,MAAM,WAAW,QAQb,CAAC;AAEZ,eAAO,MAAM,aAAa,QASf,CAAC;AASZ,eAAO,MAAM,eAAe,oFAA+E,CAAC;AAC5G,eAAO,MAAM,aAAa,sBAAsB,CAAC;AAQjD;;;;GAIG;AACH,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,EAAE,MAAM,MAAM,KAAG,MAWnE,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,kBAAkB,GAAI,UAAU,MAAM,KAAG,MAOrD,CAAC"}