@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.
Files changed (245) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +11 -0
  3. package/README.md +1983 -0
  4. package/dist/agent-list.d.ts +76 -0
  5. package/dist/agent-list.js +102 -0
  6. package/dist/agent-read.d.ts +105 -0
  7. package/dist/agent-read.js +309 -0
  8. package/dist/agent-rules-fetch.d.ts +51 -0
  9. package/dist/agent-rules-fetch.js +275 -0
  10. package/dist/agent-rules.default.d.ts +30 -0
  11. package/dist/agent-rules.default.js +305 -0
  12. package/dist/agent-session.d.ts +23 -0
  13. package/dist/agent-session.js +44 -0
  14. package/dist/agent-state.d.ts +84 -0
  15. package/dist/agent-state.js +188 -0
  16. package/dist/agent-target.d.ts +60 -0
  17. package/dist/agent-target.js +81 -0
  18. package/dist/agents.d.ts +31 -0
  19. package/dist/agents.js +75 -0
  20. package/dist/api.d.ts +28 -0
  21. package/dist/api.js +76 -0
  22. package/dist/args.d.ts +74 -0
  23. package/dist/args.js +254 -0
  24. package/dist/ask-context-attach.d.ts +73 -0
  25. package/dist/ask-context-attach.js +151 -0
  26. package/dist/ask-context-screen.d.ts +21 -0
  27. package/dist/ask-context-screen.js +49 -0
  28. package/dist/ask-context-switch.d.ts +31 -0
  29. package/dist/ask-context-switch.js +74 -0
  30. package/dist/ask-context.d.ts +84 -0
  31. package/dist/ask-context.js +172 -0
  32. package/dist/ask-pending.d.ts +47 -0
  33. package/dist/ask-pending.js +221 -0
  34. package/dist/ask-seal.d.ts +120 -0
  35. package/dist/ask-seal.js +179 -0
  36. package/dist/ask.d.ts +107 -0
  37. package/dist/ask.js +551 -0
  38. package/dist/blocked-prompt-seal.d.ts +39 -0
  39. package/dist/blocked-prompt-seal.js +92 -0
  40. package/dist/blocked-prompt.d.ts +62 -0
  41. package/dist/blocked-prompt.js +518 -0
  42. package/dist/channel.d.ts +67 -0
  43. package/dist/channel.js +136 -0
  44. package/dist/check-update.d.ts +28 -0
  45. package/dist/check-update.js +153 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.js +813 -0
  48. package/dist/codex-config.d.ts +24 -0
  49. package/dist/codex-config.js +169 -0
  50. package/dist/codex-transcript.d.ts +59 -0
  51. package/dist/codex-transcript.js +373 -0
  52. package/dist/command-refusal.d.ts +56 -0
  53. package/dist/command-refusal.js +127 -0
  54. package/dist/command-scan.d.ts +46 -0
  55. package/dist/command-scan.js +154 -0
  56. package/dist/command-seal.d.ts +57 -0
  57. package/dist/command-seal.js +88 -0
  58. package/dist/command-seq.d.ts +30 -0
  59. package/dist/command-seq.js +150 -0
  60. package/dist/config.d.ts +156 -0
  61. package/dist/config.js +392 -0
  62. package/dist/core-sections.d.ts +18 -0
  63. package/dist/core-sections.js +28 -0
  64. package/dist/crypto.d.ts +278 -0
  65. package/dist/crypto.js +698 -0
  66. package/dist/desktop-notify.d.ts +33 -0
  67. package/dist/desktop-notify.js +49 -0
  68. package/dist/device-directory.d.ts +36 -0
  69. package/dist/device-directory.js +97 -0
  70. package/dist/device-presence.d.ts +17 -0
  71. package/dist/device-presence.js +62 -0
  72. package/dist/downloads.d.ts +29 -0
  73. package/dist/downloads.js +106 -0
  74. package/dist/errors.d.ts +12 -0
  75. package/dist/errors.js +28 -0
  76. package/dist/file-crypto.d.ts +94 -0
  77. package/dist/file-crypto.js +278 -0
  78. package/dist/file-view-budget.d.ts +15 -0
  79. package/dist/file-view-budget.js +34 -0
  80. package/dist/file-view-switch.d.ts +28 -0
  81. package/dist/file-view-switch.js +122 -0
  82. package/dist/file-view.d.ts +108 -0
  83. package/dist/file-view.js +327 -0
  84. package/dist/gate.d.ts +141 -0
  85. package/dist/gate.js +336 -0
  86. package/dist/gemini-transcript.d.ts +32 -0
  87. package/dist/gemini-transcript.js +189 -0
  88. package/dist/http-failure.d.ts +98 -0
  89. package/dist/http-failure.js +349 -0
  90. package/dist/hwip-core.generated.d.ts +7 -0
  91. package/dist/hwip-core.generated.js +14 -0
  92. package/dist/hwip-hook.d.ts +77 -0
  93. package/dist/hwip-hook.js +340 -0
  94. package/dist/index.d.ts +7 -0
  95. package/dist/index.js +14 -0
  96. package/dist/input-sequencer.d.ts +65 -0
  97. package/dist/input-sequencer.js +142 -0
  98. package/dist/insecure-url.d.ts +16 -0
  99. package/dist/insecure-url.js +76 -0
  100. package/dist/installer.d.ts +96 -0
  101. package/dist/installer.js +850 -0
  102. package/dist/inventory-offload.d.ts +49 -0
  103. package/dist/inventory-offload.js +209 -0
  104. package/dist/inventory-worker.d.ts +2 -0
  105. package/dist/inventory-worker.js +27 -0
  106. package/dist/jsonc-doc.d.ts +106 -0
  107. package/dist/jsonc-doc.js +354 -0
  108. package/dist/keep-awake.d.ts +32 -0
  109. package/dist/keep-awake.js +46 -0
  110. package/dist/key-changes.d.ts +48 -0
  111. package/dist/key-changes.js +140 -0
  112. package/dist/lan-auth.d.ts +140 -0
  113. package/dist/lan-auth.js +217 -0
  114. package/dist/lan-endpoint.d.ts +41 -0
  115. package/dist/lan-endpoint.js +115 -0
  116. package/dist/lan-receiver.d.ts +122 -0
  117. package/dist/lan-receiver.js +510 -0
  118. package/dist/lan-sender.d.ts +76 -0
  119. package/dist/lan-sender.js +184 -0
  120. package/dist/lan-transfer.d.ts +36 -0
  121. package/dist/lan-transfer.js +54 -0
  122. package/dist/listener-device-id.d.ts +20 -0
  123. package/dist/listener-device-id.js +133 -0
  124. package/dist/listener-log.d.ts +4 -0
  125. package/dist/listener-log.js +23 -0
  126. package/dist/listener-process.d.ts +200 -0
  127. package/dist/listener-process.js +470 -0
  128. package/dist/listener-service.d.ts +225 -0
  129. package/dist/listener-service.js +682 -0
  130. package/dist/listener-worktree-cleanup.d.ts +27 -0
  131. package/dist/listener-worktree-cleanup.js +62 -0
  132. package/dist/listener.d.ts +1325 -0
  133. package/dist/listener.js +5681 -0
  134. package/dist/login-address.d.ts +25 -0
  135. package/dist/login-address.js +97 -0
  136. package/dist/login-page.d.ts +29 -0
  137. package/dist/login-page.js +93 -0
  138. package/dist/login.d.ts +98 -0
  139. package/dist/login.js +388 -0
  140. package/dist/mcp-command.d.ts +45 -0
  141. package/dist/mcp-command.js +50 -0
  142. package/dist/mcp.d.ts +19 -0
  143. package/dist/mcp.js +29 -0
  144. package/dist/node-support.d.ts +19 -0
  145. package/dist/node-support.js +38 -0
  146. package/dist/notify-failure.d.ts +57 -0
  147. package/dist/notify-failure.js +150 -0
  148. package/dist/opencode-transcript.d.ts +139 -0
  149. package/dist/opencode-transcript.js +390 -0
  150. package/dist/optional-features.d.ts +6 -0
  151. package/dist/optional-features.js +19 -0
  152. package/dist/pane-fit.d.ts +40 -0
  153. package/dist/pane-fit.js +119 -0
  154. package/dist/peer-key.d.ts +33 -0
  155. package/dist/peer-key.js +34 -0
  156. package/dist/pi-transcript.d.ts +26 -0
  157. package/dist/pi-transcript.js +255 -0
  158. package/dist/presence.d.ts +14 -0
  159. package/dist/presence.js +124 -0
  160. package/dist/private-files.d.ts +51 -0
  161. package/dist/private-files.js +218 -0
  162. package/dist/project-switch.d.ts +19 -0
  163. package/dist/project-switch.js +60 -0
  164. package/dist/redact.d.ts +10 -0
  165. package/dist/redact.js +21 -0
  166. package/dist/remote-agents.d.ts +410 -0
  167. package/dist/remote-agents.js +1218 -0
  168. package/dist/remote-hook.d.ts +12 -0
  169. package/dist/remote-hook.js +196 -0
  170. package/dist/replace-file.d.ts +50 -0
  171. package/dist/replace-file.js +328 -0
  172. package/dist/schedule-command.d.ts +35 -0
  173. package/dist/schedule-command.js +260 -0
  174. package/dist/schedule-launch.d.ts +15 -0
  175. package/dist/schedule-launch.js +24 -0
  176. package/dist/schedule-ready.d.ts +44 -0
  177. package/dist/schedule-ready.js +71 -0
  178. package/dist/schedule-remote.d.ts +77 -0
  179. package/dist/schedule-remote.js +167 -0
  180. package/dist/schedule-runner.d.ts +89 -0
  181. package/dist/schedule-runner.js +452 -0
  182. package/dist/schedule.d.ts +149 -0
  183. package/dist/schedule.js +428 -0
  184. package/dist/secret-mask.d.ts +29 -0
  185. package/dist/secret-mask.js +245 -0
  186. package/dist/self-register.d.ts +45 -0
  187. package/dist/self-register.js +67 -0
  188. package/dist/send.d.ts +56 -0
  189. package/dist/send.js +104 -0
  190. package/dist/session-diff.d.ts +161 -0
  191. package/dist/session-diff.js +445 -0
  192. package/dist/session-registry.d.ts +143 -0
  193. package/dist/session-registry.js +216 -0
  194. package/dist/sessions-report.d.ts +59 -0
  195. package/dist/sessions-report.js +99 -0
  196. package/dist/shell-audit.d.ts +34 -0
  197. package/dist/shell-audit.js +47 -0
  198. package/dist/subagent-transcripts.d.ts +167 -0
  199. package/dist/subagent-transcripts.js +437 -0
  200. package/dist/subscriber-trust.d.ts +128 -0
  201. package/dist/subscriber-trust.js +397 -0
  202. package/dist/templates.d.ts +105 -0
  203. package/dist/templates.js +871 -0
  204. package/dist/tmux-install.d.ts +44 -0
  205. package/dist/tmux-install.js +65 -0
  206. package/dist/tmux-liveness.d.ts +45 -0
  207. package/dist/tmux-liveness.js +71 -0
  208. package/dist/tmux-pane.d.ts +55 -0
  209. package/dist/tmux-pane.js +126 -0
  210. package/dist/tmux-socket.d.ts +20 -0
  211. package/dist/tmux-socket.js +292 -0
  212. package/dist/todo-fold.d.ts +31 -0
  213. package/dist/todo-fold.js +102 -0
  214. package/dist/transcript-tail.d.ts +312 -0
  215. package/dist/transcript-tail.js +718 -0
  216. package/dist/trust-command.d.ts +49 -0
  217. package/dist/trust-command.js +200 -0
  218. package/dist/trust-lock.d.ts +26 -0
  219. package/dist/trust-lock.js +94 -0
  220. package/dist/trust-pins.d.ts +37 -0
  221. package/dist/trust-pins.js +82 -0
  222. package/dist/turn-recorder-wire.d.ts +70 -0
  223. package/dist/turn-recorder-wire.js +165 -0
  224. package/dist/turn-recorder.d.ts +190 -0
  225. package/dist/turn-recorder.js +695 -0
  226. package/dist/turn-ring.d.ts +93 -0
  227. package/dist/turn-ring.js +418 -0
  228. package/dist/turn-watch.d.ts +177 -0
  229. package/dist/turn-watch.js +630 -0
  230. package/dist/types.d.ts +63 -0
  231. package/dist/types.js +2 -0
  232. package/dist/uninstall.d.ts +55 -0
  233. package/dist/uninstall.js +507 -0
  234. package/dist/usage.d.ts +17 -0
  235. package/dist/usage.js +421 -0
  236. package/dist/verify.d.ts +47 -0
  237. package/dist/verify.js +396 -0
  238. package/dist/worktree.d.ts +144 -0
  239. package/dist/worktree.js +310 -0
  240. package/dist/wrapper.d.ts +254 -0
  241. package/dist/wrapper.js +704 -0
  242. package/dist/ws-url.d.ts +109 -0
  243. package/dist/ws-url.js +202 -0
  244. package/package.json +78 -10
  245. 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;