@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/agent-rules-fetch.d.ts +33 -0
- package/dist/agent-rules-fetch.d.ts.map +1 -0
- package/dist/agent-rules-fetch.js +190 -0
- package/dist/agent-rules.default.d.ts +25 -0
- package/dist/agent-rules.default.d.ts.map +1 -0
- package/dist/agent-rules.default.js +89 -0
- package/dist/agent-state.d.ts +99 -0
- package/dist/agent-state.d.ts.map +1 -0
- package/dist/agent-state.js +140 -0
- package/dist/cli.js +41 -25
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -1
- package/dist/gate.d.ts +36 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +88 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/listener.d.ts +87 -13
- package/dist/listener.d.ts.map +1 -1
- package/dist/listener.js +216 -93
- package/dist/remote-agents.d.ts +55 -0
- package/dist/remote-agents.d.ts.map +1 -0
- package/dist/remote-agents.js +115 -0
- package/dist/templates.d.ts.map +1 -1
- package/dist/templates.js +27 -150
- package/dist/wrapper.d.ts +2 -1
- package/dist/wrapper.d.ts.map +1 -1
- package/dist/wrapper.js +4 -5
- package/dist/zeph-core.generated.d.ts +7 -0
- package/dist/zeph-core.generated.d.ts.map +1 -0
- package/dist/zeph-core.generated.js +14 -0
- package/package.json +2 -1
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.
|
|
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
|
|
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
|
|
519
|
-
*
|
|
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
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
587
|
-
if (!
|
|
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 =
|
|
595
|
-
? (
|
|
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,
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
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
|
|
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;
|
package/dist/templates.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../src/templates.ts"],"names":[],"mappings":"
|
|
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"}
|