@hwip/cli 0.0.1 → 0.1.1
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/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1983 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +305 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +39 -0
- package/dist/blocked-prompt-seal.js +92 -0
- package/dist/blocked-prompt.d.ts +62 -0
- package/dist/blocked-prompt.js +518 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +278 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5681 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,1218 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.matchAgentByPaneCommand = exports.findAgentBySubcommand = exports.REMOTE_AGENTS = exports.opencodeTranscriptPath = exports.detectOpencodeSession = exports.geminiTranscriptPath = exports.codexTranscriptPath = exports.detectCodexSessionName = exports.newestVersionedDb = exports.detectHermesSessionName = exports.sqliteJson = exports.pickRowByProcStart = exports.piTranscriptPath = exports.claudeTranscriptPath = exports.detectClaudeSessionNameByPid = exports.detectClaudeSessionIdByPid = exports.detectClaudeSessionByPid = exports.pidSessionRecordsExist = exports.collectDescendantPids = exports.psStartTimes = exports.foregroundAgentFor = exports.readProcTable = exports.parseProcTable = exports.detectClaudeSessionId = exports.claudeProjectDirName = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Remote-control agent registry — the single table behind every
|
|
6
|
+
* `hwip <agent>` subcommand, 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. Windsurf, which can
|
|
14
|
+
* never be driven via tmux), and the two tables carry different name axes
|
|
15
|
+
* — install id vs subcommand alias vs pane binary. Cursor is the clearest
|
|
16
|
+
* case for keeping them apart: the `cursor` install id there means the
|
|
17
|
+
* IDE, while the drivable binary here is `cursor-agent`, its terminal TUI.
|
|
18
|
+
*/
|
|
19
|
+
const fs_1 = require("fs");
|
|
20
|
+
const os_1 = require("os");
|
|
21
|
+
const path_1 = require("path");
|
|
22
|
+
const child_process_1 = require("child_process");
|
|
23
|
+
const transcript_tail_js_1 = require("./transcript-tail.js");
|
|
24
|
+
const pi_transcript_js_1 = require("./pi-transcript.js");
|
|
25
|
+
const codex_transcript_js_1 = require("./codex-transcript.js");
|
|
26
|
+
const gemini_transcript_js_1 = require("./gemini-transcript.js");
|
|
27
|
+
const opencode_transcript_js_1 = require("./opencode-transcript.js");
|
|
28
|
+
/**
|
|
29
|
+
* A per-key TTL cache, bounded so a long-lived listener that has seen many keys
|
|
30
|
+
* cannot grow without limit. `get` returns a wrapper rather than the value so a
|
|
31
|
+
* cached `null` is distinguishable from a miss, and `set` returns what it stored
|
|
32
|
+
* so callers read as one expression.
|
|
33
|
+
*
|
|
34
|
+
* Eviction drops the first key in insertion order, which is the oldest entry a
|
|
35
|
+
* key was FIRST written under — refreshing a key does not move it. With a bound
|
|
36
|
+
* of 64 keys against a handful of live panes that never matters; a workload that
|
|
37
|
+
* genuinely cycles more keys than the bound would want a real LRU.
|
|
38
|
+
*/
|
|
39
|
+
const keyedTtlCache = (ttlMs, max = 64) => {
|
|
40
|
+
const entries = new Map();
|
|
41
|
+
return {
|
|
42
|
+
get: (key) => {
|
|
43
|
+
const hit = entries.get(key);
|
|
44
|
+
return hit && hit.expiresAt > Date.now() ? hit : undefined;
|
|
45
|
+
},
|
|
46
|
+
set: (key, value) => {
|
|
47
|
+
if (entries.size >= max) {
|
|
48
|
+
const oldest = entries.keys().next().value;
|
|
49
|
+
if (oldest !== undefined)
|
|
50
|
+
entries.delete(oldest);
|
|
51
|
+
}
|
|
52
|
+
entries.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
53
|
+
return value;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* A name is only worth carrying if it has visible characters. Renderers treat
|
|
59
|
+
* `''` as falsy and fall back on their own, but the change fingerprints on both
|
|
60
|
+
* sides of the wire collapse `undefined`/`null` and would ship a blank string as
|
|
61
|
+
* a real change — a report per cycle that says nothing.
|
|
62
|
+
*/
|
|
63
|
+
const nonBlank = (value) => {
|
|
64
|
+
const trimmed = value?.trim();
|
|
65
|
+
return trimmed ? trimmed : null;
|
|
66
|
+
};
|
|
67
|
+
// ── Claude Code session resolver ─────────────────────────────────
|
|
68
|
+
const CLAUDE_PROJECTS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'projects');
|
|
69
|
+
const CLAUDE_PROJECT_DIR_MAX = 200;
|
|
70
|
+
/**
|
|
71
|
+
* The directory name Claude Code gives a cwd under `~/.claude/projects/`.
|
|
72
|
+
* Copied from the Claude Code 2.1.287 binary (`k` / `Jx`): every character
|
|
73
|
+
* outside `[a-zA-Z0-9]` becomes `-`, so `.`, `_`, spaces, `@` and Hangul all
|
|
74
|
+
* collapse, one `-` per UTF-16 unit. Names past 200 characters are cut and
|
|
75
|
+
* suffixed with `-` plus the base-36 of a Java-style string hash of the cwd.
|
|
76
|
+
* mcp-server/src/config.ts carries the same rule and test vectors — keep the
|
|
77
|
+
* two in step.
|
|
78
|
+
*/
|
|
79
|
+
const claudeProjectDirName = (cwd) => {
|
|
80
|
+
const name = cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
81
|
+
if (name.length <= CLAUDE_PROJECT_DIR_MAX)
|
|
82
|
+
return name;
|
|
83
|
+
let hash = 0;
|
|
84
|
+
for (let i = 0; i < cwd.length; i++)
|
|
85
|
+
hash = ((hash << 5) - hash + cwd.charCodeAt(i)) | 0;
|
|
86
|
+
return `${name.slice(0, CLAUDE_PROJECT_DIR_MAX)}-${Math.abs(hash).toString(36)}`;
|
|
87
|
+
};
|
|
88
|
+
exports.claudeProjectDirName = claudeProjectDirName;
|
|
89
|
+
/**
|
|
90
|
+
* Cache for detectClaudeSessionId. The function walks every jsonl file
|
|
91
|
+
* in `~/.claude/projects/<hash>/` on each call — after weeks of CC use
|
|
92
|
+
* that directory holds hundreds of session files, and we were calling
|
|
93
|
+
* this per tmux session per 5-second report cycle. Heavy disk I/O
|
|
94
|
+
* compounded with multiple sessions caused the report cycle to spike
|
|
95
|
+
* CPU and starve the host shell.
|
|
96
|
+
*
|
|
97
|
+
* The current-session UUID only changes when a new CC session starts
|
|
98
|
+
* in that directory (rare, on the order of hours), so a 60-second TTL
|
|
99
|
+
* is safe and cuts the per-cycle stat count by ~12×.
|
|
100
|
+
*/
|
|
101
|
+
const claudeSessionCache = keyedTtlCache(60_000);
|
|
102
|
+
const doDetectClaudeSessionId = (cwd) => {
|
|
103
|
+
try {
|
|
104
|
+
const sessionsDir = (0, path_1.join)(CLAUDE_PROJECTS_DIR, (0, exports.claudeProjectDirName)(cwd));
|
|
105
|
+
let latest;
|
|
106
|
+
for (const entry of (0, fs_1.readdirSync)(sessionsDir)) {
|
|
107
|
+
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$/);
|
|
108
|
+
if (!m)
|
|
109
|
+
continue;
|
|
110
|
+
const stat = (0, fs_1.statSync)((0, path_1.join)(sessionsDir, entry));
|
|
111
|
+
if (!stat.isFile())
|
|
112
|
+
continue;
|
|
113
|
+
if (!latest || stat.mtimeMs > latest.mtime) {
|
|
114
|
+
latest = { name: m[1], mtime: stat.mtimeMs };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return latest?.name ?? null;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Locate the most recent Claude Code session UUID for the working
|
|
125
|
+
* directory of a tmux pane. Mirrors `mcp-server/config.ts`'s
|
|
126
|
+
* detectClaudeSessionId: CC writes per-session jsonl files at
|
|
127
|
+
* `~/.claude/projects/<claudeProjectDirName(cwd)>/<UUID>.jsonl`.
|
|
128
|
+
* Cached for 60s — see
|
|
129
|
+
* claudeSessionCache.
|
|
130
|
+
*/
|
|
131
|
+
const detectClaudeSessionId = (cwd) => {
|
|
132
|
+
const cached = claudeSessionCache.get(cwd);
|
|
133
|
+
if (cached)
|
|
134
|
+
return cached.value;
|
|
135
|
+
return claudeSessionCache.set(cwd, doDetectClaudeSessionId(cwd));
|
|
136
|
+
};
|
|
137
|
+
exports.detectClaudeSessionId = detectClaudeSessionId;
|
|
138
|
+
// ── Claude Code pid → session resolver ───────────────────────────
|
|
139
|
+
//
|
|
140
|
+
// Claude Code maintains ~/.claude/sessions/<pid>.json with the exact
|
|
141
|
+
// {pid, sessionId, cwd} of every live session. Matching the tmux pane's
|
|
142
|
+
// process tree against these records identifies THE session running in
|
|
143
|
+
// that pane — immune to the identity theft the mtime heuristic suffers
|
|
144
|
+
// when a second `claude` opens in the same cwd (its transcript becomes
|
|
145
|
+
// the newest file and steals the tmux session's id).
|
|
146
|
+
const CLAUDE_SESSIONS_DIR = (0, path_1.join)((0, os_1.homedir)(), '.claude', 'sessions');
|
|
147
|
+
const readPidSessionRecords = () => {
|
|
148
|
+
try {
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const entry of (0, fs_1.readdirSync)(CLAUDE_SESSIONS_DIR)) {
|
|
151
|
+
if (!/^\d+\.json$/.test(entry))
|
|
152
|
+
continue;
|
|
153
|
+
try {
|
|
154
|
+
const raw = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(CLAUDE_SESSIONS_DIR, entry), 'utf-8'));
|
|
155
|
+
if (typeof raw.pid === 'number' && typeof raw.sessionId === 'string' && typeof raw.cwd === 'string') {
|
|
156
|
+
out.push({
|
|
157
|
+
pid: raw.pid,
|
|
158
|
+
sessionId: raw.sessionId,
|
|
159
|
+
cwd: raw.cwd,
|
|
160
|
+
...(typeof raw.name === 'string' ? { name: raw.name } : {}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch { /* partial write / corrupt — skip */ }
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return []; // dir absent (older CC) — caller falls back to mtime
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
// One `ps` spawn and one ~/.claude/sessions scan per report cycle, not per
|
|
173
|
+
// tmux session: the resolver runs for every session every 5s, and each call
|
|
174
|
+
// paid a full process-table spawn plus a directory scan on the daemon's hot
|
|
175
|
+
// path. A TTL just under the report interval lets all sessions in one cycle
|
|
176
|
+
// share a single snapshot while still refreshing every cycle. The parsed
|
|
177
|
+
// child-adjacency map is what's cached (not the raw table) so N sessions
|
|
178
|
+
// share one parse and differ only in the per-root BFS.
|
|
179
|
+
const SNAPSHOT_TTL_MS = 4_000;
|
|
180
|
+
const ttlMemo = (ttlMs, compute) => {
|
|
181
|
+
let snap = null;
|
|
182
|
+
return () => {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
if (snap && snap.expiresAt > now)
|
|
185
|
+
return snap.value;
|
|
186
|
+
snap = { value: compute(), expiresAt: now + ttlMs };
|
|
187
|
+
return snap.value;
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* Parse `ps -axo pid=,ppid=,lstart=`. A row is `<pid> <ppid> [<lstart>]`, e.g.
|
|
192
|
+
* ` 1 0 Fri Aug 7 11:12:59 2026 ` — note the two spaces before a
|
|
193
|
+
* single-digit day and the trailing padding.
|
|
194
|
+
*
|
|
195
|
+
* The third field is OPTIONAL in the pattern, and that is load-bearing rather
|
|
196
|
+
* than defensive. Requiring it would mean that on any machine whose `ps` prints
|
|
197
|
+
* a different `lstart` shape, EVERY row fails to match, `children` comes back
|
|
198
|
+
* empty, `collectDescendantPids` answers `{rootPid}`, and
|
|
199
|
+
* `detectClaudeSessionByPid` finds nothing — dropping Claude Code back to the
|
|
200
|
+
* mtime heuristic and its identity theft (see CLAUDE_SESSIONS_DIR above). The
|
|
201
|
+
* parent links must survive an unreadable timestamp; only the start time is
|
|
202
|
+
* allowed to go missing, and every consumer of `startTimes` treats an absent
|
|
203
|
+
* entry as "no match" rather than guessing.
|
|
204
|
+
*/
|
|
205
|
+
/**
|
|
206
|
+
* Parse `ps -axo pid=,ppid=,pgid=,tpgid=,lstart=,comm=`. A row is
|
|
207
|
+
* `<pid> <ppid> <pgid> <tpgid> [<lstart>] [<comm>]`, e.g.
|
|
208
|
+
* ` 902 1 902 902 Fri Aug 7 11:12:59 2026 zsh` — note the two spaces
|
|
209
|
+
* before a single-digit day and the trailing padding.
|
|
210
|
+
*
|
|
211
|
+
* Token positions, not one regex: `lstart` is a fixed 5 tokens when present
|
|
212
|
+
* and `comm` is LAST because it may contain spaces. A row with fewer tokens
|
|
213
|
+
* simply contributes fewer maps — the shape is load-bearing rather than
|
|
214
|
+
* defensive: parent links must survive an unreadable timestamp (the reason
|
|
215
|
+
* `lstart` is optional), and the pgid/tpgid/comm columns are absent only when
|
|
216
|
+
* a caller fed the old narrower argv, which nothing in production does.
|
|
217
|
+
*/
|
|
218
|
+
const parseProcTable = (table) => {
|
|
219
|
+
const children = new Map();
|
|
220
|
+
const startTimes = new Map();
|
|
221
|
+
const pgidOf = new Map();
|
|
222
|
+
const tpgidOf = new Map();
|
|
223
|
+
const commOf = new Map();
|
|
224
|
+
for (const line of table.split('\n')) {
|
|
225
|
+
const tok = line.trim().split(/\s+/);
|
|
226
|
+
const pid = Number(tok[0]);
|
|
227
|
+
const ppid = Number(tok[1]);
|
|
228
|
+
if (!Number.isInteger(pid) || !Number.isInteger(ppid))
|
|
229
|
+
continue;
|
|
230
|
+
const list = children.get(ppid) ?? [];
|
|
231
|
+
list.push(pid);
|
|
232
|
+
children.set(ppid, list);
|
|
233
|
+
let rest = tok.slice(2);
|
|
234
|
+
if (rest.length >= 2 && Number.isInteger(Number(rest[0]))) {
|
|
235
|
+
pgidOf.set(pid, Number(rest[0]));
|
|
236
|
+
tpgidOf.set(pid, Number(rest[1]));
|
|
237
|
+
rest = rest.slice(2);
|
|
238
|
+
}
|
|
239
|
+
if (rest.length >= 5) {
|
|
240
|
+
const started = Date.parse(rest.slice(0, 5).join(' '));
|
|
241
|
+
if (Number.isFinite(started))
|
|
242
|
+
startTimes.set(pid, started);
|
|
243
|
+
rest = rest.slice(5);
|
|
244
|
+
}
|
|
245
|
+
if (rest.length > 0)
|
|
246
|
+
commOf.set(pid, rest.join(' '));
|
|
247
|
+
}
|
|
248
|
+
return { children, startTimes, pgidOf, tpgidOf, commOf };
|
|
249
|
+
};
|
|
250
|
+
exports.parseProcTable = parseProcTable;
|
|
251
|
+
/**
|
|
252
|
+
* Bounds for the per-cycle reader spawns. The default 1 MiB buffer is what a
|
|
253
|
+
* busy machine's `ps` table or a store full of long titles outgrows, and past
|
|
254
|
+
* it the read fails as "no names, no subagents" with nothing said; an
|
|
255
|
+
* unbounded `ps` that hangs would hold the whole report cycle.
|
|
256
|
+
*/
|
|
257
|
+
const READER_MAX_BUFFER = 16 * 1024 * 1024;
|
|
258
|
+
const PS_TIMEOUT_MS = 2_000;
|
|
259
|
+
/** Rows read from an agent's session store per cycle, newest first. */
|
|
260
|
+
const STORE_ROW_LIMIT = 1000;
|
|
261
|
+
/** Say once per reader why it came back empty, so a silent null is traceable. */
|
|
262
|
+
const warnedReaders = new Set();
|
|
263
|
+
const warnReaderOnce = (reader, why, effect = 'session names and subagent detection may be missing') => {
|
|
264
|
+
if (warnedReaders.has(reader))
|
|
265
|
+
return;
|
|
266
|
+
warnedReaders.add(reader);
|
|
267
|
+
console.error(`! ${reader} read failed (${why}) — ${effect}`);
|
|
268
|
+
};
|
|
269
|
+
/** Why a reader spawn produced nothing usable. */
|
|
270
|
+
const spawnFailure = (r) => r.error ? r.error.message : r.signal ? `killed by ${r.signal}` : `exit ${r.status}`;
|
|
271
|
+
/**
|
|
272
|
+
* The process table, or null when `ps` failed. Exported with its spawn as a
|
|
273
|
+
* seam so the bounds are testable without a real `ps`.
|
|
274
|
+
*/
|
|
275
|
+
const readProcTable = (run = child_process_1.spawnSync) => {
|
|
276
|
+
// pgid/tpgid/comm ride along for the subagent detection (foreground
|
|
277
|
+
// process group), so the extra panes cost zero new spawns — one `ps`
|
|
278
|
+
// per report cycle is still the whole table (KB precedent).
|
|
279
|
+
// LC_ALL=C pins lstart to the 5-token English form parseProcTable cuts on:
|
|
280
|
+
// under ko_KR it came out as 4 or 7 tokens, the comm was misread, and no pi
|
|
281
|
+
// subagent pane was ever reported.
|
|
282
|
+
const r = run('ps', ['-axo', 'pid=,ppid=,pgid=,tpgid=,lstart=,comm='], {
|
|
283
|
+
encoding: 'utf-8',
|
|
284
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
285
|
+
env: { ...process.env, LC_ALL: 'C' },
|
|
286
|
+
timeout: PS_TIMEOUT_MS,
|
|
287
|
+
maxBuffer: READER_MAX_BUFFER,
|
|
288
|
+
});
|
|
289
|
+
if (r.status === 0)
|
|
290
|
+
return (0, exports.parseProcTable)(String(r.stdout ?? ''));
|
|
291
|
+
warnReaderOnce('ps', spawnFailure(r));
|
|
292
|
+
return null;
|
|
293
|
+
};
|
|
294
|
+
exports.readProcTable = readProcTable;
|
|
295
|
+
/**
|
|
296
|
+
* One process-table snapshot per report cycle, shared by every pane. `lstart`
|
|
297
|
+
* rides along because the agents without a pid registry (Hermes, Codex) are
|
|
298
|
+
* matched to their session row by when their process started, and paying for a
|
|
299
|
+
* second `ps` spawn to learn that would undo what this memo is for.
|
|
300
|
+
*/
|
|
301
|
+
const readPsSnapshot = ttlMemo(SNAPSHOT_TTL_MS, () => (0, exports.readProcTable)());
|
|
302
|
+
/**
|
|
303
|
+
* The agent running in the FOREGROUND process group of the tty owned by a
|
|
304
|
+
* pane shell, or null. This is what separates a subagent pane from a plain
|
|
305
|
+
* prompt: the subagent pane reports `pane_current_command=bash` (a script
|
|
306
|
+
* execs pi), so the pane-level command identifies nothing — but the tty's
|
|
307
|
+
* foreground group is pi's, and `comm` names it. A pane sitting at its login
|
|
308
|
+
* shell has the shell's own group in the foreground (tpgid === pgid) and is
|
|
309
|
+
* not a subagent, no matter what the registry would match.
|
|
310
|
+
*/
|
|
311
|
+
const foregroundAgentFor = (panePid, table = readPsSnapshot()) => {
|
|
312
|
+
if (!Number.isInteger(panePid) || panePid <= 0 || !table)
|
|
313
|
+
return null;
|
|
314
|
+
const pgid = table.pgidOf.get(panePid);
|
|
315
|
+
const tpgid = table.tpgidOf.get(panePid);
|
|
316
|
+
if (pgid === undefined || tpgid === undefined || tpgid === pgid)
|
|
317
|
+
return null;
|
|
318
|
+
for (const [pid, pgrp] of table.pgidOf) {
|
|
319
|
+
if (pgrp !== tpgid)
|
|
320
|
+
continue;
|
|
321
|
+
const agent = (0, exports.matchAgentByPaneCommand)((0, path_1.basename)(table.commOf.get(pid) ?? ''));
|
|
322
|
+
if (agent)
|
|
323
|
+
return agent;
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
};
|
|
327
|
+
exports.foregroundAgentFor = foregroundAgentFor;
|
|
328
|
+
/** Start times for this cycle's snapshot. Empty when `ps` is unavailable. */
|
|
329
|
+
const psStartTimes = () => readPsSnapshot()?.startTimes ?? new Map();
|
|
330
|
+
exports.psStartTimes = psStartTimes;
|
|
331
|
+
const cachedPidSessionRecords = ttlMemo(SNAPSHOT_TTL_MS, readPidSessionRecords);
|
|
332
|
+
const walkDescendants = (rootPid, children) => {
|
|
333
|
+
const found = new Set([rootPid]);
|
|
334
|
+
const queue = [rootPid];
|
|
335
|
+
while (queue.length > 0) {
|
|
336
|
+
for (const child of children.get(queue.shift()) ?? []) {
|
|
337
|
+
if (!found.has(child)) {
|
|
338
|
+
found.add(child);
|
|
339
|
+
queue.push(child);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return found;
|
|
344
|
+
};
|
|
345
|
+
/**
|
|
346
|
+
* Per-pane process tree, memoized for the same window as the `ps` snapshot it
|
|
347
|
+
* walks. The snapshot itself is already shared across the cycle, but the walk was
|
|
348
|
+
* not: each resolver on a row ran its own BFS, so asking a pane for both its
|
|
349
|
+
* session id and its session name doubled the work on the path whose CPU cost is
|
|
350
|
+
* the reason these caches exist (see claudeSessionCache).
|
|
351
|
+
*
|
|
352
|
+
* Callers must treat the returned Set as read-only — it is shared.
|
|
353
|
+
*/
|
|
354
|
+
const descendantCache = keyedTtlCache(SNAPSHOT_TTL_MS);
|
|
355
|
+
/** pid set of `rootPid` + all descendants, from one `ps` snapshot. */
|
|
356
|
+
const collectDescendantPids = (rootPid) => {
|
|
357
|
+
const cached = descendantCache.get(rootPid);
|
|
358
|
+
if (cached)
|
|
359
|
+
return cached.value;
|
|
360
|
+
const children = readPsSnapshot()?.children;
|
|
361
|
+
// A failed `ps` is not cached: the next cycle should retry rather than
|
|
362
|
+
// serve "this pane has no children" for the whole TTL.
|
|
363
|
+
if (children === undefined)
|
|
364
|
+
return new Set([rootPid]);
|
|
365
|
+
return descendantCache.set(rootPid, walkDescendants(rootPid, children));
|
|
366
|
+
};
|
|
367
|
+
exports.collectDescendantPids = collectDescendantPids;
|
|
368
|
+
/**
|
|
369
|
+
* Whether this machine's Claude Code writes `~/.claude/sessions` records at all.
|
|
370
|
+
*
|
|
371
|
+
* The difference between "no records anywhere" (an older CC — the mtime
|
|
372
|
+
* heuristic is the only option) and "records exist, none match this pane" (the
|
|
373
|
+
* pane is not a Claude session) is the difference between a useful fallback and
|
|
374
|
+
* a wrong answer, so callers that care ask it explicitly.
|
|
375
|
+
*/
|
|
376
|
+
const pidSessionRecordsExist = () => cachedPidSessionRecords().length > 0;
|
|
377
|
+
exports.pidSessionRecordsExist = pidSessionRecordsExist;
|
|
378
|
+
/**
|
|
379
|
+
* Exact resolution: the session record whose pid lives in the pane's
|
|
380
|
+
* process tree AND whose cwd matches (guards against OS pid reuse
|
|
381
|
+
* leaving a stale record pointing elsewhere). Null → caller falls back
|
|
382
|
+
* to the mtime heuristic.
|
|
383
|
+
*
|
|
384
|
+
* Returns the whole record so the id and the name come from one lookup — they
|
|
385
|
+
* describe the same session, and reading the directory twice per cycle to get
|
|
386
|
+
* them separately is what the cache above exists to avoid.
|
|
387
|
+
*/
|
|
388
|
+
const detectClaudeSessionByPid = (panePid, paneCwd, deps = {}) => {
|
|
389
|
+
const records = deps.records ?? cachedPidSessionRecords();
|
|
390
|
+
if (records.length === 0)
|
|
391
|
+
return null;
|
|
392
|
+
const descendants = deps.descendants ?? (0, exports.collectDescendantPids)(panePid);
|
|
393
|
+
return records.find((r) => descendants.has(r.pid) && (paneCwd === null || r.cwd === paneCwd)) ?? null;
|
|
394
|
+
};
|
|
395
|
+
exports.detectClaudeSessionByPid = detectClaudeSessionByPid;
|
|
396
|
+
const detectClaudeSessionIdByPid = (panePid, paneCwd, deps = {}) => (0, exports.detectClaudeSessionByPid)(panePid, paneCwd, deps)?.sessionId ?? null;
|
|
397
|
+
exports.detectClaudeSessionIdByPid = detectClaudeSessionIdByPid;
|
|
398
|
+
/**
|
|
399
|
+
* The name of the session running in this pane. No mtime fallback exists here:
|
|
400
|
+
* the transcript filenames the heuristic walks carry a UUID and nothing else,
|
|
401
|
+
* so an older CC without `~/.claude/sessions` yields no name and the phone
|
|
402
|
+
* keeps the computed label.
|
|
403
|
+
*/
|
|
404
|
+
const detectClaudeSessionNameByPid = (panePid, paneCwd, deps = {}) => nonBlank((0, exports.detectClaudeSessionByPid)(panePid, paneCwd, deps)?.name);
|
|
405
|
+
exports.detectClaudeSessionNameByPid = detectClaudeSessionNameByPid;
|
|
406
|
+
/**
|
|
407
|
+
* The transcript file for the Claude Code session running in a tmux pane.
|
|
408
|
+
*
|
|
409
|
+
* Pid first, cwd only as a fallback, and the order is the whole point: the cwd
|
|
410
|
+
* heuristic picks the newest `*.jsonl` in the directory, so a second `claude`
|
|
411
|
+
* started in the same repo steals the first one's transcript (the comment above
|
|
412
|
+
* `detectClaudeSessionIdByPid` records that failure). Live-timeline viewers are
|
|
413
|
+
* per tmux session, so two sessions in one directory must resolve to two files
|
|
414
|
+
* or each phone reads the other's work.
|
|
415
|
+
*
|
|
416
|
+
* Returns null when this pane is not running Claude Code at all — a Codex or
|
|
417
|
+
* Gemini pane has no transcript here, and that is an answer, not a failure.
|
|
418
|
+
*/
|
|
419
|
+
const claudeTranscriptPath = (paneCwd, panePid) => {
|
|
420
|
+
if (!paneCwd)
|
|
421
|
+
return null;
|
|
422
|
+
let sessionId = null;
|
|
423
|
+
if (panePid) {
|
|
424
|
+
sessionId = (0, exports.detectClaudeSessionIdByPid)(panePid, paneCwd);
|
|
425
|
+
// Records exist but this pane matched none: the pane is not running
|
|
426
|
+
// Claude Code, or its record has not landed yet. Falling through to the
|
|
427
|
+
// mtime heuristic here is what lets a *second* `claude` in the same
|
|
428
|
+
// directory hand this pane the other session's transcript — and a
|
|
429
|
+
// viewer reading another session's work is worse than one reading none.
|
|
430
|
+
// Only an installation that writes no records at all earns the fallback.
|
|
431
|
+
if (!sessionId && (0, exports.pidSessionRecordsExist)())
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
sessionId ??= (0, exports.detectClaudeSessionId)(paneCwd);
|
|
435
|
+
if (!sessionId)
|
|
436
|
+
return null;
|
|
437
|
+
// A mis-encoded directory yields a path that never exists, and the watcher
|
|
438
|
+
// would then poll a missing file forever without ever saying so.
|
|
439
|
+
const projectDir = (0, path_1.join)(CLAUDE_PROJECTS_DIR, (0, exports.claudeProjectDirName)(paneCwd));
|
|
440
|
+
const path = (0, path_1.join)(projectDir, `${sessionId}.jsonl`);
|
|
441
|
+
// Prove it before handing it out. The pid path never touches the filesystem,
|
|
442
|
+
// so without this a mis-encoded directory reads as a healthy silent session.
|
|
443
|
+
try {
|
|
444
|
+
return (0, fs_1.statSync)(path).isFile() ? path : null;
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
exports.claudeTranscriptPath = claudeTranscriptPath;
|
|
451
|
+
// ── pi ───────────────────────────────────────────────────────────
|
|
452
|
+
/**
|
|
453
|
+
* Where pi keeps its sessions, honouring the override pi itself honours.
|
|
454
|
+
*
|
|
455
|
+
* `getAgentDir()` (`…/pi-coding-agent/dist/config.js:421-426`) reads
|
|
456
|
+
* `PI_CODING_AGENT_DIR` — named `${APP_NAME.toUpperCase()}_CODING_AGENT_DIR` at
|
|
457
|
+
* `config.js:406` — and expands a leading `~` before falling back to
|
|
458
|
+
* `~/.pi/agent`. Hardcoding the fallback would answer `null` for every session
|
|
459
|
+
* belonging to anyone who sets it, and answer it silently.
|
|
460
|
+
*
|
|
461
|
+
* Read per call rather than at module load: the listener is long-lived, and a
|
|
462
|
+
* constant captured at import time is a constant captured before the service
|
|
463
|
+
* environment is in place.
|
|
464
|
+
*/
|
|
465
|
+
const piSessionsDir = () => {
|
|
466
|
+
const override = process.env.PI_CODING_AGENT_DIR;
|
|
467
|
+
const agentDir = override
|
|
468
|
+
? override.replace(/^~(?=\/|$)/, (0, os_1.homedir)())
|
|
469
|
+
: (0, path_1.join)((0, os_1.homedir)(), '.pi', 'agent');
|
|
470
|
+
return (0, path_1.join)(agentDir, 'sessions');
|
|
471
|
+
};
|
|
472
|
+
/**
|
|
473
|
+
* pi's own encoding of a cwd into one directory name, copied from the writer
|
|
474
|
+
* rather than inferred from the directories it produced.
|
|
475
|
+
*
|
|
476
|
+
* Read 2026-09-14 out of `@earendil-works/pi-coding-agent`,
|
|
477
|
+
* `dist/core/session-manager.js:245` (`getDefaultSessionDirPath`), which
|
|
478
|
+
* `dist/migrations.js:102` repeats verbatim:
|
|
479
|
+
*
|
|
480
|
+
* `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`
|
|
481
|
+
*
|
|
482
|
+
* Worth copying exactly rather than eyeballing, because it differs from Claude's
|
|
483
|
+
* on the case that matters: Claude maps `.` to `-` as well, pi does not. A
|
|
484
|
+
* directory with a dot in it resolves under one rule and not the other, and the
|
|
485
|
+
* failure is silent — a watcher polling a path that never existed.
|
|
486
|
+
*
|
|
487
|
+
* The `resolvePath(cwd)` on the line above that regex is part of the rule, not
|
|
488
|
+
* preamble: `dist/utils/paths.js:82-86` expands a leading `~` and then runs
|
|
489
|
+
* `path.resolve`. Encoding the raw string instead turns a trailing slash into a
|
|
490
|
+
* doubled separator (`--a-b---`) and leaves `.`/`..` segments in the directory
|
|
491
|
+
* name, both of which resolve to a path pi has never written.
|
|
492
|
+
*/
|
|
493
|
+
const piSessionDirName = (cwd) => {
|
|
494
|
+
const expanded = cwd.replace(/^~(?=\/|$)/, (0, os_1.homedir)());
|
|
495
|
+
const resolved = (0, path_1.resolve)(expanded);
|
|
496
|
+
return `--${resolved.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
|
|
497
|
+
};
|
|
498
|
+
/**
|
|
499
|
+
* Newest session file pi wrote for this directory.
|
|
500
|
+
*
|
|
501
|
+
* Same 60s TTL and the same reason as `claudeSessionCache`: `turn-watch`
|
|
502
|
+
* re-resolves every 10s per watcher, and a directory a person has run pi in for
|
|
503
|
+
* months holds one file per run.
|
|
504
|
+
*
|
|
505
|
+
* Keyed on the pane's pid as well as its cwd, so a pi that was restarted in the
|
|
506
|
+
* same directory is not followed on the previous run's file for the rest of the
|
|
507
|
+
* TTL. Within one pid the answer genuinely cannot change: pi writes one
|
|
508
|
+
* transcript per process, and `/clear` inside it keeps writing to that file.
|
|
509
|
+
*/
|
|
510
|
+
const piSessionCache = keyedTtlCache(60_000);
|
|
511
|
+
const doDetectPiTranscript = (cwd) => {
|
|
512
|
+
try {
|
|
513
|
+
const sessionDir = (0, path_1.join)(piSessionsDir(), piSessionDirName(cwd));
|
|
514
|
+
let latest;
|
|
515
|
+
for (const entry of (0, fs_1.readdirSync)(sessionDir)) {
|
|
516
|
+
// `<ISO timestamp>_<uuid>.jsonl`, beside a `.jsonl.loadout.json`
|
|
517
|
+
// sidecar per session that is not a transcript.
|
|
518
|
+
if (!entry.endsWith('.jsonl'))
|
|
519
|
+
continue;
|
|
520
|
+
const path = (0, path_1.join)(sessionDir, entry);
|
|
521
|
+
const stat = (0, fs_1.statSync)(path);
|
|
522
|
+
if (!stat.isFile())
|
|
523
|
+
continue;
|
|
524
|
+
if (!latest || stat.mtimeMs > latest.mtime)
|
|
525
|
+
latest = { path, mtime: stat.mtimeMs };
|
|
526
|
+
}
|
|
527
|
+
return latest?.path ?? null;
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
/**
|
|
534
|
+
* The transcript a pi pane is writing, or null when there is none to follow.
|
|
535
|
+
*
|
|
536
|
+
* Newest-by-mtime within the directory. pi keeps no pid→session record the way
|
|
537
|
+
* Claude Code does, so `panePid` cannot pick between two pi panes sharing a
|
|
538
|
+
* directory — both resolve to the newer session's file. That is the known limit
|
|
539
|
+
* of this resolver, and it is the same shape as Claude's mtime fallback.
|
|
540
|
+
*
|
|
541
|
+
* `panePid` still matters, as the cache key: a restart in the same directory
|
|
542
|
+
* writes a new file, and a cwd-only key would keep answering with the old one
|
|
543
|
+
* until the TTL ran out.
|
|
544
|
+
*/
|
|
545
|
+
const piTranscriptPath = (paneCwd, panePid) => {
|
|
546
|
+
if (!paneCwd)
|
|
547
|
+
return null;
|
|
548
|
+
// `|` is safe as the separator because the left half is always numeric.
|
|
549
|
+
const key = `${panePid ?? 0}|${paneCwd}`;
|
|
550
|
+
const cached = piSessionCache.get(key);
|
|
551
|
+
if (cached)
|
|
552
|
+
return cached.value;
|
|
553
|
+
return piSessionCache.set(key, doDetectPiTranscript(paneCwd));
|
|
554
|
+
};
|
|
555
|
+
exports.piTranscriptPath = piTranscriptPath;
|
|
556
|
+
// ── Agents whose session store keeps no pid ──────────────────────
|
|
557
|
+
//
|
|
558
|
+
// Hermes and Codex both record a session per cwd with a creation timestamp and
|
|
559
|
+
// no pid, so a pane cannot be joined to its row the exact way Claude Code's
|
|
560
|
+
// `<pid>.json` allows. What is left is time: both stores stamp the row when the
|
|
561
|
+
// session starts, so the row belonging to this pane is the one stamped when a
|
|
562
|
+
// process in this pane started. The window has to be narrow — two agents opened
|
|
563
|
+
// in the same directory within it are indistinguishable — and a miss must stay a
|
|
564
|
+
// miss, since a wrong name shown confidently is worse than the computed label.
|
|
565
|
+
/** How far a row's creation time may sit from a process start time. */
|
|
566
|
+
const PROC_MATCH_TOLERANCE_MS = 10_000;
|
|
567
|
+
/**
|
|
568
|
+
* The row whose timestamp is closest to when one of the pane's processes started,
|
|
569
|
+
* within {@link PROC_MATCH_TOLERANCE_MS}. Null when nothing qualifies — including
|
|
570
|
+
* when no pid in the tree has a known start time, which is why the start times
|
|
571
|
+
* are collected before any row is compared rather than defaulted to zero.
|
|
572
|
+
*/
|
|
573
|
+
const pickRowByProcStart = (rows, tsMsOf, startTimes, pids) => {
|
|
574
|
+
const procStarts = [...pids]
|
|
575
|
+
.map((pid) => startTimes.get(pid))
|
|
576
|
+
.filter((t) => t !== undefined && Number.isFinite(t));
|
|
577
|
+
if (procStarts.length === 0)
|
|
578
|
+
return null;
|
|
579
|
+
let best = null;
|
|
580
|
+
for (const row of rows) {
|
|
581
|
+
const at = tsMsOf(row);
|
|
582
|
+
if (at === null || !Number.isFinite(at))
|
|
583
|
+
continue;
|
|
584
|
+
const distance = Math.min(...procStarts.map((start) => Math.abs(at - start)));
|
|
585
|
+
if (distance > PROC_MATCH_TOLERANCE_MS)
|
|
586
|
+
continue;
|
|
587
|
+
if (best === null || distance < best.distance)
|
|
588
|
+
best = { row, distance };
|
|
589
|
+
}
|
|
590
|
+
return best?.row ?? null;
|
|
591
|
+
};
|
|
592
|
+
exports.pickRowByProcStart = pickRowByProcStart;
|
|
593
|
+
/**
|
|
594
|
+
* Read rows from a SQLite file as JSON. Null on every failure — no `sqlite3` on
|
|
595
|
+
* PATH, a locked or corrupt database, a schema that moved — because a missing
|
|
596
|
+
* name costs the computed label and nothing else.
|
|
597
|
+
*
|
|
598
|
+
* The query carries no parameters BY DESIGN: SQL passed to the `sqlite3` binary
|
|
599
|
+
* as an argv string has nowhere to bind `?` to, so interpolating a cwd would
|
|
600
|
+
* make this file the place that has to know SQLite quoting rules. Callers select
|
|
601
|
+
* the columns they need and filter in JS instead; the stores hold tens of rows,
|
|
602
|
+
* not millions. The `timeout` keeps a locked database from holding up a report
|
|
603
|
+
* cycle that runs every five seconds.
|
|
604
|
+
*/
|
|
605
|
+
const sqliteJson = (dbPath, sql, run = child_process_1.spawnSync) => {
|
|
606
|
+
const r = run('sqlite3', ['-readonly', '-json', dbPath, sql], {
|
|
607
|
+
encoding: 'utf-8',
|
|
608
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
609
|
+
timeout: 1_000,
|
|
610
|
+
maxBuffer: READER_MAX_BUFFER,
|
|
611
|
+
});
|
|
612
|
+
if (r.status !== 0) {
|
|
613
|
+
warnReaderOnce(`sqlite3 ${(0, path_1.basename)(dbPath)}`, spawnFailure(r));
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
const out = String(r.stdout ?? '').trim();
|
|
617
|
+
if (out === '')
|
|
618
|
+
return []; // no rows — sqlite3 prints nothing, not `[]`
|
|
619
|
+
try {
|
|
620
|
+
const parsed = JSON.parse(out);
|
|
621
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
exports.sqliteJson = sqliteJson;
|
|
628
|
+
// ── Hermes ───────────────────────────────────────────────────────
|
|
629
|
+
const HERMES_STATE_DB = (0, path_1.join)((0, os_1.homedir)(), '.hermes', 'state.db');
|
|
630
|
+
const readHermesSessions = ttlMemo(SNAPSHOT_TTL_MS, () => (0, exports.sqliteJson)(HERMES_STATE_DB,
|
|
631
|
+
// Newest first and capped: only a session started alongside a live
|
|
632
|
+
// process can match, and an old store holds thousands of rows. The cap
|
|
633
|
+
// is wide enough that a session still running is not crowded out by the
|
|
634
|
+
// ones started after it.
|
|
635
|
+
`SELECT cwd, title, display_name, started_at FROM sessions WHERE source = 'cli' ORDER BY started_at DESC LIMIT ${STORE_ROW_LIMIT}`));
|
|
636
|
+
const detectHermesSessionName = (paneCwd, panePid, deps = {}) => {
|
|
637
|
+
// Without the pane's pid there is no process tree to match against, and this
|
|
638
|
+
// store offers no other key — no name is the only honest answer.
|
|
639
|
+
if (panePid === undefined)
|
|
640
|
+
return null;
|
|
641
|
+
const raw = deps.rows !== undefined ? deps.rows : readHermesSessions();
|
|
642
|
+
if (raw === null)
|
|
643
|
+
return null;
|
|
644
|
+
const rows = raw.filter((r) => r.cwd === paneCwd);
|
|
645
|
+
if (rows.length === 0)
|
|
646
|
+
return null;
|
|
647
|
+
const row = (0, exports.pickRowByProcStart)(rows, (r) => (typeof r.started_at === 'number' ? r.started_at * 1_000 : null), deps.startTimes ?? (0, exports.psStartTimes)(), deps.descendants ?? (0, exports.collectDescendantPids)(panePid));
|
|
648
|
+
// The auto-title is written asynchronously after the first response, so an
|
|
649
|
+
// untitled row is a young session, not a broken one.
|
|
650
|
+
return nonBlank(row?.title ?? undefined) ?? nonBlank(row?.display_name ?? undefined);
|
|
651
|
+
};
|
|
652
|
+
exports.detectHermesSessionName = detectHermesSessionName;
|
|
653
|
+
// ── Codex ────────────────────────────────────────────────────────
|
|
654
|
+
const CODEX_DIR = (0, path_1.join)((0, os_1.homedir)(), '.codex');
|
|
655
|
+
const CODEX_STATE_DB = /^state_(\d+)\.sqlite$/;
|
|
656
|
+
/**
|
|
657
|
+
* The highest-numbered match, numerically. Codex versions its state file
|
|
658
|
+
* (`state_5.sqlite` today) and moves to the next number on a schema migration,
|
|
659
|
+
* so hard-coding a name would silently stop reading after an upgrade. Sorting
|
|
660
|
+
* the strings would break at `state_10` vs `state_9`.
|
|
661
|
+
*/
|
|
662
|
+
const newestVersionedDb = (entries, pattern) => {
|
|
663
|
+
let best = null;
|
|
664
|
+
for (const entry of entries) {
|
|
665
|
+
const m = entry.match(pattern);
|
|
666
|
+
if (!m)
|
|
667
|
+
continue;
|
|
668
|
+
const version = Number(m[1]);
|
|
669
|
+
if (best === null || version > best.version)
|
|
670
|
+
best = { entry, version };
|
|
671
|
+
}
|
|
672
|
+
return best?.entry ?? null;
|
|
673
|
+
};
|
|
674
|
+
exports.newestVersionedDb = newestVersionedDb;
|
|
675
|
+
const readCodexThreads = ttlMemo(SNAPSHOT_TTL_MS, () => {
|
|
676
|
+
let entries;
|
|
677
|
+
try {
|
|
678
|
+
entries = (0, fs_1.readdirSync)(CODEX_DIR);
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
return null; // codex never installed here
|
|
682
|
+
}
|
|
683
|
+
const db = (0, exports.newestVersionedDb)(entries, CODEX_STATE_DB);
|
|
684
|
+
if (db === null)
|
|
685
|
+
return null;
|
|
686
|
+
return (0, exports.sqliteJson)((0, path_1.join)(CODEX_DIR, db),
|
|
687
|
+
// Capped like the Hermes read, after archived threads are dropped so
|
|
688
|
+
// they do not use up the cap (a NULL reads as not archived).
|
|
689
|
+
`SELECT cwd, name, title, archived, created_at, created_at_ms FROM threads WHERE archived IS NULL OR archived = 0 ORDER BY COALESCE(created_at_ms, created_at * 1000) DESC LIMIT ${STORE_ROW_LIMIT}`);
|
|
690
|
+
});
|
|
691
|
+
/**
|
|
692
|
+
* No `source` filter, unlike the Hermes reader's `source = 'cli'`. The column
|
|
693
|
+
* exists here too (alongside `thread_source`), but Codex is unauthenticated on
|
|
694
|
+
* every machine measured so far and its `threads` table is empty, so which
|
|
695
|
+
* values mark a terminal session is unknown — and a wrong guess filters out
|
|
696
|
+
* every row, which fails as "no name ever" rather than loudly. A thread from a
|
|
697
|
+
* non-terminal frontend is therefore eligible to match, and it lands in the same
|
|
698
|
+
* accepted misjoin as two terminals in the same directory: it needs the same cwd
|
|
699
|
+
* AND a creation time within the tolerance of a process in this pane. Add the
|
|
700
|
+
* filter once the values can be observed.
|
|
701
|
+
*/
|
|
702
|
+
const detectCodexSessionName = (paneCwd, panePid, deps = {}) => {
|
|
703
|
+
if (panePid === undefined)
|
|
704
|
+
return null;
|
|
705
|
+
const raw = deps.rows !== undefined ? deps.rows : readCodexThreads();
|
|
706
|
+
if (raw === null)
|
|
707
|
+
return null;
|
|
708
|
+
const rows = raw.filter((r) => r.cwd === paneCwd && !r.archived);
|
|
709
|
+
if (rows.length === 0)
|
|
710
|
+
return null;
|
|
711
|
+
const row = (0, exports.pickRowByProcStart)(rows, (r) => r.created_at_ms ?? (typeof r.created_at === 'number' ? r.created_at * 1_000 : null), deps.startTimes ?? (0, exports.psStartTimes)(), deps.descendants ?? (0, exports.collectDescendantPids)(panePid));
|
|
712
|
+
return nonBlank(row?.name ?? undefined) ?? nonBlank(row?.title ?? undefined);
|
|
713
|
+
};
|
|
714
|
+
exports.detectCodexSessionName = detectCodexSessionName;
|
|
715
|
+
/**
|
|
716
|
+
* Where Codex keeps its rollouts.
|
|
717
|
+
*
|
|
718
|
+
* `CODEX_HOME` overrides `~/.codex`, and the writer resolves it per run — the
|
|
719
|
+
* binary carries both `CODEX_HOME: ` (its `doctor` report) and
|
|
720
|
+
* `failed to resolve CODEX_HOME`. Read per call for the same reason
|
|
721
|
+
* `piSessionsDir` is: a constant captured at import time is captured before the
|
|
722
|
+
* service environment is in place.
|
|
723
|
+
*/
|
|
724
|
+
const codexSessionsDir = () => {
|
|
725
|
+
const override = process.env.CODEX_HOME;
|
|
726
|
+
const home = override ? override.replace(/^~(?=\/|$)/, (0, os_1.homedir)()) : CODEX_DIR;
|
|
727
|
+
return (0, path_1.join)(home, 'sessions');
|
|
728
|
+
};
|
|
729
|
+
/**
|
|
730
|
+
* The two newest `sessions/<YYYY>/<MM>/<DD>` shards.
|
|
731
|
+
*
|
|
732
|
+
* Codex shards by date and puts no cwd anywhere in the path, so finding a
|
|
733
|
+
* session means opening files. Two shards is the bound: the newest holds every
|
|
734
|
+
* session started today, and the one before it covers a session started before
|
|
735
|
+
* midnight and still running. Sorting the names rather than computing today's
|
|
736
|
+
* date is deliberate — the shard is named in the writer's timezone, and a
|
|
737
|
+
* machine whose UTC date has already turned over would look in a directory that
|
|
738
|
+
* does not exist yet on a date-arithmetic rule.
|
|
739
|
+
*/
|
|
740
|
+
const newestCodexShards = (root, limit = 2) => {
|
|
741
|
+
const descend = (dir) => {
|
|
742
|
+
try {
|
|
743
|
+
return (0, fs_1.readdirSync)(dir).filter((e) => /^\d+$/.test(e)).sort().reverse();
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return [];
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
const shards = [];
|
|
750
|
+
for (const year of descend(root)) {
|
|
751
|
+
for (const month of descend((0, path_1.join)(root, year))) {
|
|
752
|
+
for (const day of descend((0, path_1.join)(root, year, month))) {
|
|
753
|
+
shards.push((0, path_1.join)(root, year, month, day));
|
|
754
|
+
if (shards.length >= limit)
|
|
755
|
+
return shards;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return shards;
|
|
760
|
+
};
|
|
761
|
+
/**
|
|
762
|
+
* How much of a rollout is read at a time to find its `session_meta` header, and
|
|
763
|
+
* how far that read will go before giving up.
|
|
764
|
+
*
|
|
765
|
+
* The first line carries `base_instructions` — measured at 21551 bytes of a
|
|
766
|
+
* 22049-byte header in the newest session here — and that field grows with the
|
|
767
|
+
* user's AGENTS.md and skill set, so a single fixed read is a bound someone
|
|
768
|
+
* eventually crosses. Crossing it would answer null forever and show as "this
|
|
769
|
+
* agent has no live timeline", with nothing to say why; the chunked read below
|
|
770
|
+
* costs one extra `readSync` per oversized header instead.
|
|
771
|
+
*
|
|
772
|
+
* The outer cap exists because this runs against whatever files are in the
|
|
773
|
+
* directory, and a file with no newline in it at all must not be read whole.
|
|
774
|
+
*/
|
|
775
|
+
const CODEX_HEADER_CHUNK = 64 * 1024;
|
|
776
|
+
const CODEX_HEADER_MAX = 256 * 1024;
|
|
777
|
+
/** The first line of a file, or null when it holds none within {@link CODEX_HEADER_MAX}. */
|
|
778
|
+
const firstLineOf = (path) => {
|
|
779
|
+
let fd;
|
|
780
|
+
try {
|
|
781
|
+
fd = (0, fs_1.openSync)(path, 'r');
|
|
782
|
+
const buffer = Buffer.allocUnsafe(CODEX_HEADER_CHUNK);
|
|
783
|
+
// Accumulated as bytes, not as a string: a chunk boundary can fall in
|
|
784
|
+
// the middle of a multi-byte character, and decoding each chunk on its
|
|
785
|
+
// own would both corrupt that character and desynchronise the read
|
|
786
|
+
// offset from the byte position it has to be.
|
|
787
|
+
const chunks = [];
|
|
788
|
+
let size = 0;
|
|
789
|
+
while (size < CODEX_HEADER_MAX) {
|
|
790
|
+
const read = (0, fs_1.readSync)(fd, buffer, 0, CODEX_HEADER_CHUNK, size);
|
|
791
|
+
if (read === 0)
|
|
792
|
+
return null;
|
|
793
|
+
const filled = buffer.subarray(0, read);
|
|
794
|
+
const end = filled.indexOf(0x0a);
|
|
795
|
+
if (end !== -1) {
|
|
796
|
+
chunks.push(Buffer.from(filled.subarray(0, end)));
|
|
797
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
798
|
+
}
|
|
799
|
+
chunks.push(Buffer.from(filled));
|
|
800
|
+
size += read;
|
|
801
|
+
}
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
catch {
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
finally {
|
|
808
|
+
if (fd !== undefined)
|
|
809
|
+
(0, fs_1.closeSync)(fd);
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
/** The `session_meta` header of a rollout, when it names this pane's directory. */
|
|
813
|
+
const codexRolloutFor = (path, cwd, mtime) => {
|
|
814
|
+
const line = firstLineOf(path);
|
|
815
|
+
if (!line)
|
|
816
|
+
return null;
|
|
817
|
+
try {
|
|
818
|
+
const entry = JSON.parse(line);
|
|
819
|
+
if (entry.type !== 'session_meta' || !entry.payload)
|
|
820
|
+
return null;
|
|
821
|
+
if (entry.payload.cwd !== cwd)
|
|
822
|
+
return null;
|
|
823
|
+
const at = typeof entry.payload.timestamp === 'string' ? Date.parse(entry.payload.timestamp) : NaN;
|
|
824
|
+
return { path, mtime, startedAt: Number.isFinite(at) ? at : null };
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
const doDetectCodexTranscript = (cwd, panePid) => {
|
|
831
|
+
const resolved = (0, path_1.resolve)(cwd.replace(/^~(?=\/|$)/, (0, os_1.homedir)()));
|
|
832
|
+
const candidates = [];
|
|
833
|
+
for (const shard of newestCodexShards(codexSessionsDir())) {
|
|
834
|
+
let entries;
|
|
835
|
+
try {
|
|
836
|
+
entries = (0, fs_1.readdirSync)(shard);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
continue;
|
|
840
|
+
}
|
|
841
|
+
for (const entry of entries) {
|
|
842
|
+
if (!entry.startsWith('rollout-') || !entry.endsWith('.jsonl'))
|
|
843
|
+
continue;
|
|
844
|
+
const path = (0, path_1.join)(shard, entry);
|
|
845
|
+
try {
|
|
846
|
+
const stat = (0, fs_1.statSync)(path);
|
|
847
|
+
if (!stat.isFile())
|
|
848
|
+
continue;
|
|
849
|
+
const rollout = codexRolloutFor(path, resolved, stat.mtimeMs);
|
|
850
|
+
if (rollout)
|
|
851
|
+
candidates.push(rollout);
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (candidates.length === 0)
|
|
859
|
+
return null;
|
|
860
|
+
// Two Codex panes in one directory are told apart the way Hermes and Codex
|
|
861
|
+
// session names already are: the rollout stamped when a process in this pane
|
|
862
|
+
// started is this pane's. A miss falls back to the newest file rather than
|
|
863
|
+
// answering nothing, because one pane in a directory is the normal case and
|
|
864
|
+
// it has no process-start match to make when the pane's pid is unknown.
|
|
865
|
+
const matched = panePid === undefined
|
|
866
|
+
? null
|
|
867
|
+
: (0, exports.pickRowByProcStart)(candidates, (c) => c.startedAt, (0, exports.psStartTimes)(), (0, exports.collectDescendantPids)(panePid));
|
|
868
|
+
if (matched)
|
|
869
|
+
return matched.path;
|
|
870
|
+
return candidates.reduce((best, c) => (c.mtime > best.mtime ? c : best)).path;
|
|
871
|
+
};
|
|
872
|
+
/**
|
|
873
|
+
* Newest session file Codex wrote for this directory.
|
|
874
|
+
*
|
|
875
|
+
* Cached harder than pi's, and for a reason pi's does not have: this resolver
|
|
876
|
+
* opens and parses a 22KB header out of every rollout in two date shards just to
|
|
877
|
+
* read one `cwd` field, and `turn-watch` re-resolves every 10s per watcher on a
|
|
878
|
+
* path documented as uncached and blocking.
|
|
879
|
+
*/
|
|
880
|
+
const codexSessionCache = keyedTtlCache(60_000);
|
|
881
|
+
const codexTranscriptPath = (paneCwd, panePid) => {
|
|
882
|
+
if (!paneCwd)
|
|
883
|
+
return null;
|
|
884
|
+
// `|` is safe as the separator because the left half is always numeric.
|
|
885
|
+
const key = `${panePid ?? 0}|${paneCwd}`;
|
|
886
|
+
const cached = codexSessionCache.get(key);
|
|
887
|
+
if (cached)
|
|
888
|
+
return cached.value;
|
|
889
|
+
return codexSessionCache.set(key, doDetectCodexTranscript(paneCwd, panePid));
|
|
890
|
+
};
|
|
891
|
+
exports.codexTranscriptPath = codexTranscriptPath;
|
|
892
|
+
// ── gemini ───────────────────────────────────────────────────────
|
|
893
|
+
/**
|
|
894
|
+
* Where Gemini CLI keeps its state, honouring the override Gemini itself honours.
|
|
895
|
+
*
|
|
896
|
+
* Read 2026-10-08 out of `@google/gemini-cli` 0.46.0
|
|
897
|
+
* (`bundle/chunk-6ZHP2EJW.js:248868-248874`): `GEMINI_CLI_HOME` when set, else
|
|
898
|
+
* `os.homedir()`, with `.gemini` joined on after. Read per call for the same
|
|
899
|
+
* reason as `piSessionsDir`: the listener is long-lived and a constant captured
|
|
900
|
+
* at import time is captured before the service environment is in place.
|
|
901
|
+
*/
|
|
902
|
+
const geminiDir = () => (0, path_1.join)(process.env.GEMINI_CLI_HOME || (0, os_1.homedir)(), '.gemini');
|
|
903
|
+
/**
|
|
904
|
+
* The directory name Gemini filed this cwd under, from its project registry.
|
|
905
|
+
*
|
|
906
|
+
* It is a registry lookup, not a rule: `projects.json` maps an absolute path to
|
|
907
|
+
* a slug (`bundle:249630-249636`, `250073-250078`; lowercased on win32). Every
|
|
908
|
+
* slug measured so far equals `basename(cwd)`, but a collision gets a suffix, so
|
|
909
|
+
* deriving it would be right until the day it silently is not.
|
|
910
|
+
*
|
|
911
|
+
* The slug comes out of a file and goes into a path, so only a single plain path
|
|
912
|
+
* segment is accepted.
|
|
913
|
+
*/
|
|
914
|
+
const geminiProjectSlug = (cwd) => {
|
|
915
|
+
const registry = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(geminiDir(), 'projects.json'), 'utf-8'));
|
|
916
|
+
if (!registry || typeof registry !== 'object')
|
|
917
|
+
return null;
|
|
918
|
+
const projects = registry.projects;
|
|
919
|
+
if (!projects || typeof projects !== 'object')
|
|
920
|
+
return null;
|
|
921
|
+
const resolved = (0, path_1.resolve)(cwd);
|
|
922
|
+
const slug = projects[process.platform === 'win32' ? resolved.toLowerCase() : resolved];
|
|
923
|
+
if (typeof slug !== 'string' || !slug || slug === '.' || slug === '..' || (0, path_1.basename)(slug) !== slug)
|
|
924
|
+
return null;
|
|
925
|
+
return slug;
|
|
926
|
+
};
|
|
927
|
+
const geminiSessionCache = keyedTtlCache(60_000);
|
|
928
|
+
const doDetectGeminiTranscript = (cwd) => {
|
|
929
|
+
try {
|
|
930
|
+
const slug = geminiProjectSlug(cwd);
|
|
931
|
+
if (!slug)
|
|
932
|
+
return null;
|
|
933
|
+
const chatsDir = (0, path_1.join)(geminiDir(), 'tmp', slug, 'chats');
|
|
934
|
+
let latest;
|
|
935
|
+
for (const entry of (0, fs_1.readdirSync)(chatsDir)) {
|
|
936
|
+
// `session-<ts>-<id>.jsonl`. The legacy `.json` form is rewritten
|
|
937
|
+
// whole rather than appended to, so it cannot be tailed; and a
|
|
938
|
+
// sub-agent's transcript sits one directory down, where this
|
|
939
|
+
// listing does not look.
|
|
940
|
+
if (!entry.startsWith('session-') || !entry.endsWith('.jsonl'))
|
|
941
|
+
continue;
|
|
942
|
+
const path = (0, path_1.join)(chatsDir, entry);
|
|
943
|
+
const stat = (0, fs_1.statSync)(path);
|
|
944
|
+
if (!stat.isFile())
|
|
945
|
+
continue;
|
|
946
|
+
if (!latest || stat.mtimeMs > latest.mtime)
|
|
947
|
+
latest = { path, mtime: stat.mtimeMs };
|
|
948
|
+
}
|
|
949
|
+
return latest?.path ?? null;
|
|
950
|
+
}
|
|
951
|
+
catch {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
/**
|
|
956
|
+
* The transcript a Gemini pane is writing, or null when there is none to follow.
|
|
957
|
+
*
|
|
958
|
+
* Newest-by-mtime within the project's `chats` directory, which also follows a
|
|
959
|
+
* resumed session: Gemini keeps appending to the original file. Like pi's,
|
|
960
|
+
* Gemini keeps no pid → session record, so two Gemini panes in one directory
|
|
961
|
+
* both resolve to the newer file — the known limit — and `panePid` is only the
|
|
962
|
+
* cache key that lets a restart be noticed.
|
|
963
|
+
*
|
|
964
|
+
* The cwd is resolved as given; if a pane's cwd and the path Gemini registered
|
|
965
|
+
* ever differ by a symlink (`/tmp` ↔ `/private/tmp`) this answers null, and
|
|
966
|
+
* realpath is the fix to reach for then.
|
|
967
|
+
*/
|
|
968
|
+
const geminiTranscriptPath = (paneCwd, panePid) => {
|
|
969
|
+
if (!paneCwd)
|
|
970
|
+
return null;
|
|
971
|
+
// `|` is safe as the separator because the left half is always numeric.
|
|
972
|
+
const key = `${panePid ?? 0}|${paneCwd}`;
|
|
973
|
+
const cached = geminiSessionCache.get(key);
|
|
974
|
+
if (cached)
|
|
975
|
+
return cached.value;
|
|
976
|
+
return geminiSessionCache.set(key, doDetectGeminiTranscript(paneCwd));
|
|
977
|
+
};
|
|
978
|
+
exports.geminiTranscriptPath = geminiTranscriptPath;
|
|
979
|
+
const readOpencodeSessions = ttlMemo(SNAPSHOT_TTL_MS, () => {
|
|
980
|
+
const db = (0, opencode_transcript_js_1.opencodeDbPath)();
|
|
981
|
+
try {
|
|
982
|
+
(0, fs_1.statSync)(db);
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
// opencode 2.x never ran here — or the listener sees another HOME,
|
|
986
|
+
// XDG_DATA_HOME or OPENCODE_DB than the pane does.
|
|
987
|
+
warnReaderOnce('opencode store', `no ${db}`, 'opencode sessions get no name or transcript');
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
return (0, exports.sqliteJson)(db, `SELECT s.id AS id, s.title AS title, s.directory AS directory,
|
|
991
|
+
(SELECT max(m.time_updated) FROM session_message m WHERE m.session_id = s.id) AS active
|
|
992
|
+
FROM session_v2 s WHERE s.parent_id IS NULL AND s.time_archived IS NULL
|
|
993
|
+
ORDER BY active DESC LIMIT ${STORE_ROW_LIMIT}`);
|
|
994
|
+
});
|
|
995
|
+
/**
|
|
996
|
+
* The title opencode gives a session before it names one (`New session -
|
|
997
|
+
* <ISO time>`, seen on sessions this machine's opencode left untitled) says
|
|
998
|
+
* nothing the computed label does not, so it is not a name.
|
|
999
|
+
*/
|
|
1000
|
+
const OPENCODE_PLACEHOLDER_TITLE = /^New session - \d{4}-\d{2}-\d{2}T/;
|
|
1001
|
+
const realTitle = (title) => {
|
|
1002
|
+
const name = nonBlank(title ?? undefined);
|
|
1003
|
+
return name && !OPENCODE_PLACEHOLDER_TITLE.test(name) ? name : null;
|
|
1004
|
+
};
|
|
1005
|
+
/** opencode records the real path (`/private/tmp`, not `/tmp`), and a pane may report either. */
|
|
1006
|
+
const realDir = (dir) => {
|
|
1007
|
+
try {
|
|
1008
|
+
return (0, fs_1.realpathSync)(dir);
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
return dir;
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
/**
|
|
1015
|
+
* The opencode session running in this pane: the one in the pane's directory
|
|
1016
|
+
* written to most recently since the pane's process started. Started-since is
|
|
1017
|
+
* what keeps a pane that has not sent its first prompt — no session row yet —
|
|
1018
|
+
* from being handed yesterday's session in the same directory. Two opencode
|
|
1019
|
+
* panes in one directory both answer the busier one: the store says nothing
|
|
1020
|
+
* about which process wrote what, the limit pi and Gemini already have.
|
|
1021
|
+
*/
|
|
1022
|
+
const detectOpencodeSession = (paneCwd, panePid, deps = {}) => {
|
|
1023
|
+
if (panePid === undefined)
|
|
1024
|
+
return null;
|
|
1025
|
+
const raw = deps.rows !== undefined ? deps.rows : readOpencodeSessions();
|
|
1026
|
+
if (raw === null)
|
|
1027
|
+
return null;
|
|
1028
|
+
const startTimes = deps.startTimes ?? (0, exports.psStartTimes)();
|
|
1029
|
+
const starts = [...(deps.descendants ?? (0, exports.collectDescendantPids)(panePid))]
|
|
1030
|
+
.map((pid) => startTimes.get(pid))
|
|
1031
|
+
.filter((t) => t !== undefined && Number.isFinite(t));
|
|
1032
|
+
if (starts.length === 0)
|
|
1033
|
+
return null;
|
|
1034
|
+
const since = Math.min(...starts) - PROC_MATCH_TOLERANCE_MS;
|
|
1035
|
+
const dir = realDir(paneCwd);
|
|
1036
|
+
let best = null;
|
|
1037
|
+
for (const row of raw) {
|
|
1038
|
+
if (row.directory !== dir && row.directory !== paneCwd)
|
|
1039
|
+
continue;
|
|
1040
|
+
if (typeof row.id !== 'string' || !opencode_transcript_js_1.OPENCODE_SESSION_ID.test(row.id))
|
|
1041
|
+
continue;
|
|
1042
|
+
if (typeof row.active !== 'number' || row.active < since)
|
|
1043
|
+
continue;
|
|
1044
|
+
if (!best || row.active > (best.active ?? 0))
|
|
1045
|
+
best = row;
|
|
1046
|
+
}
|
|
1047
|
+
return best ? { id: best.id, title: realTitle(best.title) } : null;
|
|
1048
|
+
};
|
|
1049
|
+
exports.detectOpencodeSession = detectOpencodeSession;
|
|
1050
|
+
/** `sqliteJson` with its own warning: a failed transcript read costs the timeline, not the session list. */
|
|
1051
|
+
const queryOpencodeTranscript = (db, sql) => {
|
|
1052
|
+
const rows = (0, exports.sqliteJson)(db, sql);
|
|
1053
|
+
if (rows === null)
|
|
1054
|
+
warnReaderOnce('opencode transcript', `sqlite3 ${(0, path_1.basename)(db)}`, 'opencode panes show no live timeline');
|
|
1055
|
+
return rows;
|
|
1056
|
+
};
|
|
1057
|
+
const opencodeTranscriptPath = (paneCwd, panePid) => {
|
|
1058
|
+
if (!paneCwd)
|
|
1059
|
+
return null;
|
|
1060
|
+
const session = (0, exports.detectOpencodeSession)(paneCwd, panePid);
|
|
1061
|
+
return session ? (0, opencode_transcript_js_1.opencodeSourcePath)((0, opencode_transcript_js_1.opencodeDbPath)(), session.id) : null;
|
|
1062
|
+
};
|
|
1063
|
+
exports.opencodeTranscriptPath = opencodeTranscriptPath;
|
|
1064
|
+
// ── The registry ─────────────────────────────────────────────────
|
|
1065
|
+
const REMOTE_AGENT_TABLE = [
|
|
1066
|
+
{
|
|
1067
|
+
kind: 'claude',
|
|
1068
|
+
displayName: 'Claude Code',
|
|
1069
|
+
binary: 'claude',
|
|
1070
|
+
subcommands: ['cc', 'claude'],
|
|
1071
|
+
quitCommand: '/exit',
|
|
1072
|
+
// Exact pid-tree match first; mtime heuristic only as fallback
|
|
1073
|
+
// (older CC without ~/.claude/sessions, or ps failure).
|
|
1074
|
+
resolveSessionId: (paneCwd, panePid) => (panePid !== undefined ? (0, exports.detectClaudeSessionIdByPid)(panePid, paneCwd) : null)
|
|
1075
|
+
?? (0, exports.detectClaudeSessionId)(paneCwd),
|
|
1076
|
+
resolveSessionName: (paneCwd, panePid) => panePid !== undefined ? (0, exports.detectClaudeSessionNameByPid)(panePid, paneCwd) : null,
|
|
1077
|
+
resolveTranscript: (paneCwd, panePid) => (0, exports.claudeTranscriptPath)(paneCwd, panePid),
|
|
1078
|
+
projectTranscript: transcript_tail_js_1.projectTranscriptEntries,
|
|
1079
|
+
},
|
|
1080
|
+
{
|
|
1081
|
+
kind: 'codex',
|
|
1082
|
+
displayName: 'Codex CLI',
|
|
1083
|
+
binary: 'codex',
|
|
1084
|
+
subcommands: ['codex'],
|
|
1085
|
+
// Name only, like the hermes row: the store's thread id is a UUID this
|
|
1086
|
+
// resolver could return, but nothing on the wire consumes it yet.
|
|
1087
|
+
resolveSessionName: exports.detectCodexSessionName,
|
|
1088
|
+
resolveTranscript: (paneCwd, panePid) => (0, exports.codexTranscriptPath)(paneCwd, panePid),
|
|
1089
|
+
projectTranscript: codex_transcript_js_1.projectCodexEntries,
|
|
1090
|
+
},
|
|
1091
|
+
{
|
|
1092
|
+
// The pane binary is `cursor-agent`, Cursor's terminal TUI — NOT the
|
|
1093
|
+
// bare `cursor` on PATH, which is the IDE launcher script: it opens
|
|
1094
|
+
// the app and exits, so a pane started with it dies before the
|
|
1095
|
+
// listener can address it.
|
|
1096
|
+
kind: 'cursor',
|
|
1097
|
+
displayName: 'Cursor CLI',
|
|
1098
|
+
binary: 'cursor-agent',
|
|
1099
|
+
subcommands: ['cursor', 'cursor-agent'],
|
|
1100
|
+
},
|
|
1101
|
+
{
|
|
1102
|
+
kind: 'gemini',
|
|
1103
|
+
displayName: 'Gemini CLI',
|
|
1104
|
+
binary: 'gemini',
|
|
1105
|
+
subcommands: ['gemini'],
|
|
1106
|
+
// Two Gemini sessions in one folder cannot be told apart — the same
|
|
1107
|
+
// limit pi has (newest file wins).
|
|
1108
|
+
resolveTranscript: (paneCwd, panePid) => (0, exports.geminiTranscriptPath)(paneCwd, panePid),
|
|
1109
|
+
projectTranscript: gemini_transcript_js_1.projectGeminiEntries,
|
|
1110
|
+
},
|
|
1111
|
+
{
|
|
1112
|
+
// `hermes` on PATH is a bash script that execs a venv Python, so the
|
|
1113
|
+
// pane's *current* command reads the interpreter (`python3.11` as
|
|
1114
|
+
// measured). Only `pane_start_command` identifies it, which is the
|
|
1115
|
+
// same bargain `claude` (node) already makes. Deliberately no
|
|
1116
|
+
// paneMatchAliases: matching interpreter names would adopt every
|
|
1117
|
+
// Python REPL on the machine as a Hermes session.
|
|
1118
|
+
kind: 'hermes',
|
|
1119
|
+
displayName: 'Hermes',
|
|
1120
|
+
binary: 'hermes',
|
|
1121
|
+
subcommands: ['hermes'],
|
|
1122
|
+
// No `resolveSessionId` counterpart: the store's own id is a
|
|
1123
|
+
// `<timestamp>_<hash>` string, and nothing on the wire consumes it yet.
|
|
1124
|
+
resolveSessionName: exports.detectHermesSessionName,
|
|
1125
|
+
},
|
|
1126
|
+
{
|
|
1127
|
+
kind: 'pi',
|
|
1128
|
+
displayName: 'Pi',
|
|
1129
|
+
binary: 'pi',
|
|
1130
|
+
subcommands: ['pi'],
|
|
1131
|
+
resolveTranscript: (paneCwd, panePid) => (0, exports.piTranscriptPath)(paneCwd, panePid),
|
|
1132
|
+
projectTranscript: pi_transcript_js_1.projectPiEntries,
|
|
1133
|
+
},
|
|
1134
|
+
{
|
|
1135
|
+
kind: 'opencode',
|
|
1136
|
+
displayName: 'OpenCode',
|
|
1137
|
+
binary: 'opencode',
|
|
1138
|
+
subcommands: ['opencode'],
|
|
1139
|
+
// Typed into a 2.0.24 TUI pane: `/exit` + Enter closed it.
|
|
1140
|
+
quitCommand: '/exit',
|
|
1141
|
+
resolveSessionId: (paneCwd, panePid) => (0, exports.detectOpencodeSession)(paneCwd, panePid)?.id ?? null,
|
|
1142
|
+
resolveSessionName: (paneCwd, panePid) => (0, exports.detectOpencodeSession)(paneCwd, panePid)?.title ?? null,
|
|
1143
|
+
resolveTranscript: (paneCwd, panePid) => (0, exports.opencodeTranscriptPath)(paneCwd, panePid),
|
|
1144
|
+
projectTranscript: opencode_transcript_js_1.projectOpencodeEntries,
|
|
1145
|
+
readTranscript: (path, prev) => (0, opencode_transcript_js_1.readOpencodeTranscript)(path, prev, { query: queryOpencodeTranscript }),
|
|
1146
|
+
},
|
|
1147
|
+
// The rows below are the minimum: launch in a tmux pane and be addressable.
|
|
1148
|
+
// Binary names come from Happier's provider table; only `copilot` and
|
|
1149
|
+
// `kiro-cli` were run against a real install (`copilot` is a node script, so
|
|
1150
|
+
// its pane reads `node` — the pane is found by session, and the native child
|
|
1151
|
+
// `copilot` is what `foregroundAgentFor` matches).
|
|
1152
|
+
// No session name, transcript, or turn-state resolver — each stays omitted
|
|
1153
|
+
// until that agent's store is confirmed on a real install (EXTENSION POINT
|
|
1154
|
+
// rule above).
|
|
1155
|
+
{
|
|
1156
|
+
kind: 'kimi',
|
|
1157
|
+
displayName: 'Kimi Code',
|
|
1158
|
+
binary: 'kimi',
|
|
1159
|
+
subcommands: ['kimi'],
|
|
1160
|
+
},
|
|
1161
|
+
{
|
|
1162
|
+
kind: 'grok',
|
|
1163
|
+
displayName: 'Grok Build',
|
|
1164
|
+
binary: 'grok',
|
|
1165
|
+
subcommands: ['grok'],
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
kind: 'antigravity',
|
|
1169
|
+
displayName: 'Antigravity',
|
|
1170
|
+
binary: 'agy',
|
|
1171
|
+
subcommands: ['agy', 'antigravity'],
|
|
1172
|
+
},
|
|
1173
|
+
{
|
|
1174
|
+
kind: 'qwen',
|
|
1175
|
+
displayName: 'Qwen Code',
|
|
1176
|
+
binary: 'qwen',
|
|
1177
|
+
subcommands: ['qwen'],
|
|
1178
|
+
},
|
|
1179
|
+
{
|
|
1180
|
+
kind: 'auggie',
|
|
1181
|
+
displayName: 'Auggie',
|
|
1182
|
+
binary: 'auggie',
|
|
1183
|
+
subcommands: ['auggie'],
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
kind: 'kilo',
|
|
1187
|
+
displayName: 'Kilo',
|
|
1188
|
+
binary: 'kilo',
|
|
1189
|
+
subcommands: ['kilo'],
|
|
1190
|
+
},
|
|
1191
|
+
{
|
|
1192
|
+
kind: 'copilot',
|
|
1193
|
+
displayName: 'Copilot CLI',
|
|
1194
|
+
binary: 'copilot',
|
|
1195
|
+
subcommands: ['copilot'],
|
|
1196
|
+
},
|
|
1197
|
+
{
|
|
1198
|
+
kind: 'kiro',
|
|
1199
|
+
displayName: 'Kiro',
|
|
1200
|
+
binary: 'kiro-cli',
|
|
1201
|
+
subcommands: ['kiro-cli', 'kiro'],
|
|
1202
|
+
},
|
|
1203
|
+
{
|
|
1204
|
+
kind: 'droid',
|
|
1205
|
+
displayName: 'Factory Droid',
|
|
1206
|
+
binary: 'droid',
|
|
1207
|
+
subcommands: ['droid'],
|
|
1208
|
+
},
|
|
1209
|
+
];
|
|
1210
|
+
exports.REMOTE_AGENTS = REMOTE_AGENT_TABLE;
|
|
1211
|
+
const findAgentBySubcommand = (cmd) => exports.REMOTE_AGENTS.find((a) => a.subcommands.includes(cmd));
|
|
1212
|
+
exports.findAgentBySubcommand = findAgentBySubcommand;
|
|
1213
|
+
const matchAgentByPaneCommand = (base) => {
|
|
1214
|
+
if (!base)
|
|
1215
|
+
return undefined;
|
|
1216
|
+
return exports.REMOTE_AGENTS.find((a) => a.binary === base || (a.paneMatchAliases ?? []).includes(base));
|
|
1217
|
+
};
|
|
1218
|
+
exports.matchAgentByPaneCommand = matchAgentByPaneCommand;
|