@hwip/cli 0.1.2 → 0.1.4

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.
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from 'child_process';
2
2
  import { type TranscriptProjector, type TranscriptReader } from './transcript-tail.js';
3
+ import { type ProviderName } from './session-title.js';
3
4
  export interface RemoteAgent {
4
5
  /** Wire value for AgentSession.agentKind (server/phone contract). */
5
6
  kind: string;
@@ -57,8 +58,11 @@ export interface RemoteAgent {
57
58
  * carries this only once that agent's name store is confirmed. Cursor and
58
59
  * Gemini never will — their state files hold ids, cwds and timestamps and no
59
60
  * name at all, so there is nothing to read and the computed label stands.
61
+ *
62
+ * The answer says who chose the name (`source`) when the store can tell:
63
+ * the user setting that hides agent-made names hides `'auto'` only.
60
64
  */
61
- resolveSessionName?: (paneCwd: string, panePid?: number) => string | null;
65
+ resolveSessionName?: (paneCwd: string, panePid?: number) => ProviderName | null;
62
66
  /**
63
67
  * Resolve the file this agent is writing its session transcript to, for the
64
68
  * live chat timeline (`turn-watch`).
@@ -117,6 +121,11 @@ export interface PidSessionRecord {
117
121
  * without it, and a record without a name is still a valid id match.
118
122
  */
119
123
  name?: string;
124
+ /**
125
+ * Who chose `name`: `"derived"` (from the cwd — CC's placeholder) or
126
+ * `"user"` (`/rename`). Absent from an older CC, which cannot say.
127
+ */
128
+ nameSource?: string;
120
129
  }
121
130
  export interface ProcTable {
122
131
  /** ppid → its direct children, for walking a pane's process tree. */
@@ -208,12 +217,21 @@ export declare const pidSessionRecordsExist: () => boolean;
208
217
  export declare const detectClaudeSessionByPid: (panePid: number, paneCwd: string | null, deps?: PidResolveDeps) => PidSessionRecord | null;
209
218
  export declare const detectClaudeSessionIdByPid: (panePid: number, paneCwd: string | null, deps?: PidResolveDeps) => string | null;
210
219
  /**
211
- * The name of the session running in this pane. No mtime fallback exists here:
212
- * the transcript filenames the heuristic walks carry a UUID and nothing else,
213
- * so an older CC without `~/.claude/sessions` yields no name and the phone
214
- * keeps the computed label.
220
+ * What the phone should call the Claude Code session in this pane, and who
221
+ * chose it.
222
+ *
223
+ * A `/rename` (`nameSource: "user"`) wins. Otherwise the newest `ai-title` CC
224
+ * wrote into the transcript — its own summary of the work, which is what tells
225
+ * parallel sessions apart. A derived name (`hybrid-shell-8f`, from the cwd)
226
+ * is never sent: it says no more than the tmux name the phone falls back to.
227
+ * A record from an older CC carries a name but no `nameSource`; that one is
228
+ * sent as before, with no source, ahead of any ai-title, since it may be the
229
+ * user's rename. No mtime
230
+ * fallback: only the pid match says which session this pane is.
215
231
  */
216
- export declare const detectClaudeSessionNameByPid: (panePid: number, paneCwd: string | null, deps?: PidResolveDeps) => string | null;
232
+ export declare const detectClaudeProviderName: (panePid: number, paneCwd: string | null, deps?: PidResolveDeps & {
233
+ transcriptOf?: (cwd: string, sessionId: string) => string | null;
234
+ }) => ProviderName | null;
217
235
  /**
218
236
  * The transcript file for the Claude Code session running in a tmux pane.
219
237
  *
@@ -241,6 +259,15 @@ export declare const claudeTranscriptPath: (paneCwd: string | null, panePid?: nu
241
259
  * until the TTL ran out.
242
260
  */
243
261
  export declare const piTranscriptPath: (paneCwd: string | null, panePid?: number) => string | null;
262
+ /**
263
+ * The name a pi pane's session goes by, and who chose it: the newest
264
+ * `session_info` name, which is the agent's (`'auto'`) when the hwip
265
+ * extension's tool set it — the tool leaves a marker entry with the same name
266
+ * (`PI_TITLE_MARKER`) — and the user's (`/name`) otherwise. Read from the
267
+ * transcript tail, so a name written before the last 256 KB of a session the
268
+ * listener first meets late is not found until the next one is written.
269
+ */
270
+ export declare const detectPiSessionName: (paneCwd: string, panePid: number | undefined, transcriptOf?: (paneCwd: string, panePid?: number) => string | null) => ProviderName | null;
244
271
  /**
245
272
  * The row whose timestamp is closest to when one of the pane's processes started,
246
273
  * within {@link PROC_MATCH_TOLERANCE_MS}. Null when nothing qualifies — including
@@ -248,6 +275,18 @@ export declare const piTranscriptPath: (paneCwd: string | null, panePid?: number
248
275
  * are collected before any row is compared rather than defaulted to zero.
249
276
  */
250
277
  export declare const pickRowByProcStart: <T>(rows: readonly T[], tsMsOf: (row: T) => number | null, startTimes: Map<number, number>, pids: Set<number>) => T | null;
278
+ /**
279
+ * From when a conversation written to is this pane's: its earliest process start, less {@link PANE_WRITE_MARGIN_MS}. A
280
+ * resume writes to the conversation it picked back up, so that one is written to after the start; a fresh start
281
+ * has not written yet, so the folder's older conversations are all before it. Null when no start is known.
282
+ */
283
+ export declare const paneSince: (panePid: number, deps?: StoreResolveDeps) => number | null;
284
+ /**
285
+ * The conversation a transcript file holds — its name without `.jsonl` — when it was written to since `since`.
286
+ * For the agents whose resume appends to the file it picks back up (pi `-c`, Codex `resume`, Gemini `--resume`,
287
+ * measured 2026-10-20): the file is the conversation.
288
+ */
289
+ export declare const transcriptIdSince: (path: string | null, since: number | null) => string | null;
251
290
  /**
252
291
  * Read rows from a SQLite file as JSON. Null on every failure — no `sqlite3` on
253
292
  * PATH, a locked or corrupt database, a schema that moved — because a missing
@@ -267,7 +306,12 @@ export interface StoreResolveDeps {
267
306
  startTimes?: Map<number, number>;
268
307
  descendants?: Set<number>;
269
308
  }
270
- export declare const detectHermesSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => string | null;
309
+ export declare const detectHermesSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => ProviderName | null;
310
+ /**
311
+ * The Hermes session this pane carries on: in its directory, with a message since the pane started — the newest.
312
+ * A `-c` picks back up a session started long before the pane, which the start-time match above cannot see.
313
+ */
314
+ export declare const detectHermesResumeId: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => string | null;
271
315
  /**
272
316
  * The highest-numbered match, numerically. Codex versions its state file
273
317
  * (`state_5.sqlite` today) and moves to the next number on a schema migration,
@@ -286,7 +330,7 @@ export declare const newestVersionedDb: (entries: readonly string[], pattern: Re
286
330
  * AND a creation time within the tolerance of a process in this pane. Add the
287
331
  * filter once the values can be observed.
288
332
  */
289
- export declare const detectCodexSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => string | null;
333
+ export declare const detectCodexSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => ProviderName | null;
290
334
  export declare const codexTranscriptPath: (paneCwd: string | null, panePid?: number) => string | null;
291
335
  /**
292
336
  * The transcript a Gemini pane is writing, or null when there is none to follow.
@@ -322,7 +366,7 @@ declare const REMOTE_AGENT_TABLE: readonly [{
322
366
  readonly subcommands: readonly ["cc", "claude"];
323
367
  readonly quitCommand: "/exit";
324
368
  readonly resolveSessionId: (paneCwd: string, panePid: number | undefined) => string | null;
325
- readonly resolveSessionName: (paneCwd: string, panePid: number | undefined) => string | null;
369
+ readonly resolveSessionName: (paneCwd: string, panePid: number | undefined) => ProviderName | null;
326
370
  readonly resolveTranscript: (paneCwd: string, panePid: number | undefined) => string | null;
327
371
  readonly projectTranscript: TranscriptProjector;
328
372
  readonly resolveResumeId: (paneCwd: string, panePid: number | undefined) => string | null;
@@ -332,9 +376,10 @@ declare const REMOTE_AGENT_TABLE: readonly [{
332
376
  readonly displayName: "Codex CLI";
333
377
  readonly binary: "codex";
334
378
  readonly subcommands: readonly ["codex"];
335
- readonly resolveSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => string | null;
379
+ readonly resolveSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => ProviderName | null;
336
380
  readonly resolveTranscript: (paneCwd: string, panePid: number | undefined) => string | null;
337
381
  readonly projectTranscript: TranscriptProjector;
382
+ readonly resolveResumeId: (paneCwd: string, panePid: number | undefined) => string | null;
338
383
  }, {
339
384
  readonly kind: "cursor";
340
385
  readonly displayName: "Cursor CLI";
@@ -347,12 +392,14 @@ declare const REMOTE_AGENT_TABLE: readonly [{
347
392
  readonly subcommands: readonly ["gemini"];
348
393
  readonly resolveTranscript: (paneCwd: string, panePid: number | undefined) => string | null;
349
394
  readonly projectTranscript: TranscriptProjector;
395
+ readonly resolveResumeId: (paneCwd: string, panePid: number | undefined) => string | null;
350
396
  }, {
351
397
  readonly kind: "hermes";
352
398
  readonly displayName: "Hermes";
353
399
  readonly binary: "hermes";
354
400
  readonly subcommands: readonly ["hermes"];
355
- readonly resolveSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => string | null;
401
+ readonly resolveSessionName: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => ProviderName | null;
402
+ readonly resolveResumeId: (paneCwd: string, panePid?: number, deps?: StoreResolveDeps) => string | null;
356
403
  }, {
357
404
  readonly kind: "pi";
358
405
  readonly displayName: "Pi";
@@ -360,6 +407,8 @@ declare const REMOTE_AGENT_TABLE: readonly [{
360
407
  readonly subcommands: readonly ["pi"];
361
408
  readonly resolveTranscript: (paneCwd: string, panePid: number | undefined) => string | null;
362
409
  readonly projectTranscript: TranscriptProjector;
410
+ readonly resolveResumeId: (paneCwd: string, panePid: number | undefined) => string | null;
411
+ readonly resolveSessionName: (paneCwd: string, panePid: number | undefined) => ProviderName | null;
363
412
  }, {
364
413
  readonly kind: "opencode";
365
414
  readonly displayName: "OpenCode";
@@ -367,7 +416,10 @@ declare const REMOTE_AGENT_TABLE: readonly [{
367
416
  readonly subcommands: readonly ["opencode"];
368
417
  readonly quitCommand: "/exit";
369
418
  readonly resolveSessionId: (paneCwd: string, panePid: number | undefined) => string | null;
370
- readonly resolveSessionName: (paneCwd: string, panePid: number | undefined) => string | null;
419
+ readonly resolveSessionName: (paneCwd: string, panePid: number | undefined) => {
420
+ name: string;
421
+ source: "auto";
422
+ } | null;
371
423
  readonly resolveTranscript: (paneCwd: string, panePid: number | undefined) => string | null;
372
424
  readonly projectTranscript: TranscriptProjector;
373
425
  readonly readTranscript: (path: string, prev: import("./transcript-tail.js").TailState | null) => import("./transcript-tail.js").TailRead | null;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
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;
3
+ exports.matchAgentByPaneCommand = exports.findAgentBySubcommand = exports.REMOTE_AGENTS = exports.opencodeTranscriptPath = exports.detectOpencodeSession = exports.geminiTranscriptPath = exports.codexTranscriptPath = exports.detectCodexSessionName = exports.newestVersionedDb = exports.detectHermesResumeId = exports.detectHermesSessionName = exports.sqliteJson = exports.transcriptIdSince = exports.paneSince = exports.pickRowByProcStart = exports.detectPiSessionName = exports.piTranscriptPath = exports.claudeTranscriptPath = exports.detectClaudeProviderName = exports.detectClaudeSessionIdByPid = exports.detectClaudeSessionByPid = exports.pidSessionRecordsExist = exports.collectDescendantPids = exports.psStartTimes = exports.foregroundAgentFor = exports.readProcTable = exports.parseProcTable = exports.detectClaudeSessionId = exports.claudeProjectDirName = void 0;
4
4
  /**
5
5
  * Remote-control agent registry — the single table behind every
6
6
  * `hwip <agent>` subcommand, the listener's pane matching, and the
@@ -22,6 +22,7 @@ const path_1 = require("path");
22
22
  const child_process_1 = require("child_process");
23
23
  const transcript_tail_js_1 = require("./transcript-tail.js");
24
24
  const pi_transcript_js_1 = require("./pi-transcript.js");
25
+ const session_title_js_1 = require("./session-title.js");
25
26
  const codex_transcript_js_1 = require("./codex-transcript.js");
26
27
  const gemini_transcript_js_1 = require("./gemini-transcript.js");
27
28
  const opencode_transcript_js_1 = require("./opencode-transcript.js");
@@ -158,6 +159,7 @@ const readPidSessionRecords = () => {
158
159
  sessionId: raw.sessionId,
159
160
  cwd: raw.cwd,
160
161
  ...(typeof raw.name === 'string' ? { name: raw.name } : {}),
162
+ ...(typeof raw.nameSource === 'string' ? { nameSource: raw.nameSource } : {}),
161
163
  });
162
164
  }
163
165
  }
@@ -395,14 +397,35 @@ const detectClaudeSessionByPid = (panePid, paneCwd, deps = {}) => {
395
397
  exports.detectClaudeSessionByPid = detectClaudeSessionByPid;
396
398
  const detectClaudeSessionIdByPid = (panePid, paneCwd, deps = {}) => (0, exports.detectClaudeSessionByPid)(panePid, paneCwd, deps)?.sessionId ?? null;
397
399
  exports.detectClaudeSessionIdByPid = detectClaudeSessionIdByPid;
400
+ /** Claude transcripts whose latest `ai-title` the listener keeps, read incrementally. */
401
+ const claudeTitles = (0, session_title_js_1.createTailWatch)(session_title_js_1.latestClaudeAiTitle);
398
402
  /**
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
+ * What the phone should call the Claude Code session in this pane, and who
404
+ * chose it.
405
+ *
406
+ * A `/rename` (`nameSource: "user"`) wins. Otherwise the newest `ai-title` CC
407
+ * wrote into the transcript — its own summary of the work, which is what tells
408
+ * parallel sessions apart. A derived name (`hybrid-shell-8f`, from the cwd)
409
+ * is never sent: it says no more than the tmux name the phone falls back to.
410
+ * A record from an older CC carries a name but no `nameSource`; that one is
411
+ * sent as before, with no source, ahead of any ai-title, since it may be the
412
+ * user's rename. No mtime
413
+ * fallback: only the pid match says which session this pane is.
403
414
  */
404
- const detectClaudeSessionNameByPid = (panePid, paneCwd, deps = {}) => nonBlank((0, exports.detectClaudeSessionByPid)(panePid, paneCwd, deps)?.name);
405
- exports.detectClaudeSessionNameByPid = detectClaudeSessionNameByPid;
415
+ const detectClaudeProviderName = (panePid, paneCwd, deps = {}) => {
416
+ const record = (0, exports.detectClaudeSessionByPid)(panePid, paneCwd, deps);
417
+ if (!record)
418
+ return null;
419
+ const recordName = nonBlank(record.name);
420
+ if (recordName && record.nameSource === 'user')
421
+ return { name: recordName, source: 'user' };
422
+ if (recordName && record.nameSource === undefined)
423
+ return { name: recordName };
424
+ const transcript = (deps.transcriptOf ?? claudeConversationFile)(record.cwd, record.sessionId);
425
+ const title = transcript ? claudeTitles.read(transcript) : undefined;
426
+ return title ? { name: title, source: 'auto' } : null;
427
+ };
428
+ exports.detectClaudeProviderName = detectClaudeProviderName;
406
429
  /**
407
430
  * The transcript file for the Claude Code session running in a tmux pane.
408
431
  *
@@ -559,6 +582,23 @@ const piTranscriptPath = (paneCwd, panePid) => {
559
582
  return piSessionCache.set(key, doDetectPiTranscript(paneCwd));
560
583
  };
561
584
  exports.piTranscriptPath = piTranscriptPath;
585
+ const piNames = (0, session_title_js_1.createTailWatch)(session_title_js_1.piNamesStep);
586
+ /**
587
+ * The name a pi pane's session goes by, and who chose it: the newest
588
+ * `session_info` name, which is the agent's (`'auto'`) when the hwip
589
+ * extension's tool set it — the tool leaves a marker entry with the same name
590
+ * (`PI_TITLE_MARKER`) — and the user's (`/name`) otherwise. Read from the
591
+ * transcript tail, so a name written before the last 256 KB of a session the
592
+ * listener first meets late is not found until the next one is written.
593
+ */
594
+ const detectPiSessionName = (paneCwd, panePid, transcriptOf = exports.piTranscriptPath) => {
595
+ const file = transcriptOf(paneCwd, panePid);
596
+ const names = file ? piNames.read(file) : undefined;
597
+ if (!names?.name)
598
+ return null;
599
+ return { name: names.name, source: names.marked === names.name ? 'auto' : 'user' };
600
+ };
601
+ exports.detectPiSessionName = detectPiSessionName;
562
602
  // ── Agents whose session store keeps no pid ──────────────────────
563
603
  //
564
604
  // Hermes and Codex both record a session per cwd with a creation timestamp and
@@ -596,6 +636,43 @@ const pickRowByProcStart = (rows, tsMsOf, startTimes, pids) => {
596
636
  return best?.row ?? null;
597
637
  };
598
638
  exports.pickRowByProcStart = pickRowByProcStart;
639
+ /**
640
+ * How far before the pane's start a write may land and still be the pane's: `ps` `lstart` is whole seconds. Not
641
+ * {@link PROC_MATCH_TOLERANCE_MS} — the last sitting's final write lands seconds before an `exit` and a fresh
642
+ * start, and a margin that wide would read the fresh pane as carrying it on.
643
+ */
644
+ const PANE_WRITE_MARGIN_MS = 2_000;
645
+ /**
646
+ * From when a conversation written to is this pane's: its earliest process start, less {@link PANE_WRITE_MARGIN_MS}. A
647
+ * resume writes to the conversation it picked back up, so that one is written to after the start; a fresh start
648
+ * has not written yet, so the folder's older conversations are all before it. Null when no start is known.
649
+ */
650
+ const paneSince = (panePid, deps = {}) => {
651
+ const startTimes = deps.startTimes ?? (0, exports.psStartTimes)();
652
+ const starts = [...(deps.descendants ?? (0, exports.collectDescendantPids)(panePid))]
653
+ .map((pid) => startTimes.get(pid))
654
+ .filter((t) => t !== undefined && Number.isFinite(t));
655
+ return starts.length === 0 ? null : Math.min(...starts) - PANE_WRITE_MARGIN_MS;
656
+ };
657
+ exports.paneSince = paneSince;
658
+ /**
659
+ * The conversation a transcript file holds — its name without `.jsonl` — when it was written to since `since`.
660
+ * For the agents whose resume appends to the file it picks back up (pi `-c`, Codex `resume`, Gemini `--resume`,
661
+ * measured 2026-10-20): the file is the conversation.
662
+ */
663
+ const transcriptIdSince = (path, since) => {
664
+ if (!path || since === null)
665
+ return null;
666
+ try {
667
+ return (0, fs_1.statSync)(path).mtimeMs >= since ? (0, path_1.basename)(path, '.jsonl') : null;
668
+ }
669
+ catch {
670
+ return null;
671
+ }
672
+ };
673
+ exports.transcriptIdSince = transcriptIdSince;
674
+ /** {@link transcriptIdSince} for a pane: what `resolveResumeId` answers for an agent whose transcript is a file. */
675
+ const paneTranscriptId = (path, panePid) => panePid === undefined ? null : (0, exports.transcriptIdSince)(path, (0, exports.paneSince)(panePid));
599
676
  /**
600
677
  * Read rows from a SQLite file as JSON. Null on every failure — no `sqlite3` on
601
678
  * PATH, a locked or corrupt database, a schema that moved — because a missing
@@ -652,10 +729,44 @@ const detectHermesSessionName = (paneCwd, panePid, deps = {}) => {
652
729
  return null;
653
730
  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));
654
731
  // The auto-title is written asynchronously after the first response, so an
655
- // untitled row is a young session, not a broken one.
656
- return nonBlank(row?.title ?? undefined) ?? nonBlank(row?.display_name ?? undefined);
732
+ // untitled row is a young session, not a broken one. `title` is Hermes'
733
+ // own; `display_name` comes from outside its titler (a gateway fills it
734
+ // from the chat it serves), so it is never one to hide.
735
+ const title = nonBlank(row?.title ?? undefined);
736
+ if (title)
737
+ return { name: title, source: 'auto' };
738
+ const display = nonBlank(row?.display_name ?? undefined);
739
+ return display ? { name: display, source: 'user' } : null;
657
740
  };
658
741
  exports.detectHermesSessionName = detectHermesSessionName;
742
+ const readHermesActive = ttlMemo(SNAPSHOT_TTL_MS, () => (0, exports.sqliteJson)(HERMES_STATE_DB, `SELECT s.id AS id, s.cwd AS cwd, (SELECT max(m.timestamp) FROM messages m WHERE m.session_id = s.id) AS active
743
+ FROM sessions s WHERE s.source = 'cli' ORDER BY s.started_at DESC LIMIT ${STORE_ROW_LIMIT}`));
744
+ /**
745
+ * The Hermes session this pane carries on: in its directory, with a message since the pane started — the newest.
746
+ * A `-c` picks back up a session started long before the pane, which the start-time match above cannot see.
747
+ */
748
+ const detectHermesResumeId = (paneCwd, panePid, deps = {}) => {
749
+ if (panePid === undefined)
750
+ return null;
751
+ const raw = deps.rows !== undefined ? deps.rows : readHermesActive();
752
+ if (raw === null)
753
+ return null;
754
+ const since = (0, exports.paneSince)(panePid, deps);
755
+ if (since === null)
756
+ return null;
757
+ let best = null;
758
+ // The query's own columns; each is checked with typeof below before it is used.
759
+ for (const row of raw) {
760
+ if (row.cwd !== paneCwd || typeof row.id !== 'string' || !row.id)
761
+ continue;
762
+ if (typeof row.active !== 'number' || row.active * 1_000 < since)
763
+ continue;
764
+ if (!best || row.active > (best.active ?? 0))
765
+ best = row;
766
+ }
767
+ return best?.id ?? null;
768
+ };
769
+ exports.detectHermesResumeId = detectHermesResumeId;
659
770
  // ── Codex ────────────────────────────────────────────────────────
660
771
  const CODEX_DIR = (0, path_1.join)((0, os_1.homedir)(), '.codex');
661
772
  const CODEX_STATE_DB = /^state_(\d+)\.sqlite$/;
@@ -715,7 +826,13 @@ const detectCodexSessionName = (paneCwd, panePid, deps = {}) => {
715
826
  if (rows.length === 0)
716
827
  return null;
717
828
  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));
718
- return nonBlank(row?.name ?? undefined) ?? nonBlank(row?.title ?? undefined);
829
+ // `name` is what the user typed (`/rename`); `title` is Codex's own —
830
+ // the first prompt, verbatim.
831
+ const name = nonBlank(row?.name ?? undefined);
832
+ if (name)
833
+ return { name, source: 'user' };
834
+ const title = nonBlank(row?.title ?? undefined);
835
+ return title ? { name: title, source: 'auto' } : null;
719
836
  };
720
837
  exports.detectCodexSessionName = detectCodexSessionName;
721
838
  /**
@@ -1031,13 +1148,9 @@ const detectOpencodeSession = (paneCwd, panePid, deps = {}) => {
1031
1148
  const raw = deps.rows !== undefined ? deps.rows : readOpencodeSessions();
1032
1149
  if (raw === null)
1033
1150
  return null;
1034
- const startTimes = deps.startTimes ?? (0, exports.psStartTimes)();
1035
- const starts = [...(deps.descendants ?? (0, exports.collectDescendantPids)(panePid))]
1036
- .map((pid) => startTimes.get(pid))
1037
- .filter((t) => t !== undefined && Number.isFinite(t));
1038
- if (starts.length === 0)
1151
+ const since = (0, exports.paneSince)(panePid, deps);
1152
+ if (since === null)
1039
1153
  return null;
1040
- const since = Math.min(...starts) - PROC_MATCH_TOLERANCE_MS;
1041
1154
  const dir = realDir(paneCwd);
1042
1155
  let best = null;
1043
1156
  for (const row of raw) {
@@ -1079,7 +1192,7 @@ const REMOTE_AGENT_TABLE = [
1079
1192
  // (older CC without ~/.claude/sessions, or ps failure).
1080
1193
  resolveSessionId: (paneCwd, panePid) => (panePid !== undefined ? (0, exports.detectClaudeSessionIdByPid)(panePid, paneCwd) : null)
1081
1194
  ?? (0, exports.detectClaudeSessionId)(paneCwd),
1082
- resolveSessionName: (paneCwd, panePid) => panePid !== undefined ? (0, exports.detectClaudeSessionNameByPid)(panePid, paneCwd) : null,
1195
+ resolveSessionName: (paneCwd, panePid) => panePid !== undefined ? (0, exports.detectClaudeProviderName)(panePid, paneCwd) : null,
1083
1196
  resolveTranscript: (paneCwd, panePid) => (0, exports.claudeTranscriptPath)(paneCwd, panePid),
1084
1197
  projectTranscript: transcript_tail_js_1.projectTranscriptEntries,
1085
1198
  // The pid match alone: the mtime fallback above can name another
@@ -1098,6 +1211,8 @@ const REMOTE_AGENT_TABLE = [
1098
1211
  resolveSessionName: exports.detectCodexSessionName,
1099
1212
  resolveTranscript: (paneCwd, panePid) => (0, exports.codexTranscriptPath)(paneCwd, panePid),
1100
1213
  projectTranscript: codex_transcript_js_1.projectCodexEntries,
1214
+ // Read only to tell a resumed conversation's run (no `resumeArgs`): the rollout a resume appends to.
1215
+ resolveResumeId: (paneCwd, panePid) => paneTranscriptId((0, exports.codexTranscriptPath)(paneCwd, panePid), panePid),
1101
1216
  },
1102
1217
  {
1103
1218
  // The pane binary is `cursor-agent`, Cursor's terminal TUI — NOT the
@@ -1118,6 +1233,7 @@ const REMOTE_AGENT_TABLE = [
1118
1233
  // limit pi has (newest file wins).
1119
1234
  resolveTranscript: (paneCwd, panePid) => (0, exports.geminiTranscriptPath)(paneCwd, panePid),
1120
1235
  projectTranscript: gemini_transcript_js_1.projectGeminiEntries,
1236
+ resolveResumeId: (paneCwd, panePid) => paneTranscriptId((0, exports.geminiTranscriptPath)(paneCwd, panePid), panePid),
1121
1237
  },
1122
1238
  {
1123
1239
  // `hermes` on PATH is a bash script that execs a venv Python, so the
@@ -1133,6 +1249,7 @@ const REMOTE_AGENT_TABLE = [
1133
1249
  // No `resolveSessionId` counterpart: the store's own id is a
1134
1250
  // `<timestamp>_<hash>` string, and nothing on the wire consumes it yet.
1135
1251
  resolveSessionName: exports.detectHermesSessionName,
1252
+ resolveResumeId: exports.detectHermesResumeId,
1136
1253
  },
1137
1254
  {
1138
1255
  kind: 'pi',
@@ -1141,6 +1258,8 @@ const REMOTE_AGENT_TABLE = [
1141
1258
  subcommands: ['pi'],
1142
1259
  resolveTranscript: (paneCwd, panePid) => (0, exports.piTranscriptPath)(paneCwd, panePid),
1143
1260
  projectTranscript: pi_transcript_js_1.projectPiEntries,
1261
+ resolveResumeId: (paneCwd, panePid) => paneTranscriptId((0, exports.piTranscriptPath)(paneCwd, panePid), panePid),
1262
+ resolveSessionName: (paneCwd, panePid) => (0, exports.detectPiSessionName)(paneCwd, panePid),
1144
1263
  },
1145
1264
  {
1146
1265
  kind: 'opencode',
@@ -1150,7 +1269,11 @@ const REMOTE_AGENT_TABLE = [
1150
1269
  // Typed into a 2.0.24 TUI pane: `/exit` + Enter closed it.
1151
1270
  quitCommand: '/exit',
1152
1271
  resolveSessionId: (paneCwd, panePid) => (0, exports.detectOpencodeSession)(paneCwd, panePid)?.id ?? null,
1153
- resolveSessionName: (paneCwd, panePid) => (0, exports.detectOpencodeSession)(paneCwd, panePid)?.title ?? null,
1272
+ // opencode titles a session itself after the first exchange.
1273
+ resolveSessionName: (paneCwd, panePid) => {
1274
+ const title = (0, exports.detectOpencodeSession)(paneCwd, panePid)?.title;
1275
+ return title ? { name: title, source: 'auto' } : null;
1276
+ },
1154
1277
  resolveTranscript: (paneCwd, panePid) => (0, exports.opencodeTranscriptPath)(paneCwd, panePid),
1155
1278
  projectTranscript: opencode_transcript_js_1.projectOpencodeEntries,
1156
1279
  readTranscript: (path, prev) => (0, opencode_transcript_js_1.readOpencodeTranscript)(path, prev, { query: queryOpencodeTranscript }),
@@ -37,6 +37,12 @@ export interface KnownSession {
37
37
  * agent whose id this machine cannot read, and in files written before it.
38
38
  */
39
39
  agentSessionId?: string;
40
+ /**
41
+ * The later tmux sessions this run went on in (`session_created`, as
42
+ * `runId`): the same conversation carried on after an `exit` is the same
43
+ * run (`continuedRunId`). Absent for a run that has had one tmux session.
44
+ */
45
+ tmuxRunIds?: string[];
40
46
  /** Last time this machine saw the session running. */
41
47
  lastSeenAt: string;
42
48
  }
@@ -86,6 +92,20 @@ export declare const recallSession: (name: string, now?: number) => KnownSession
86
92
  * decides whether to fall back to the newest run or refuse.
87
93
  */
88
94
  export declare const recallRun: (name: string, agentKind: string, now?: number) => KnownSession | null;
95
+ /** tmux sessions remembered per run: an `exit` and `-c` every few minutes all day stays a short list. */
96
+ export declare const MAX_TMUX_RUNS_PER_RUN = 10;
97
+ /**
98
+ * Which run the tmux session `tmuxRunId` of `name` is (hwip docs/session-runs.md §3).
99
+ *
100
+ * Its own, unless it carries on an earlier run: it was already joined to one
101
+ * (a sweep or a listener restart since), or the agent's conversation
102
+ * `conversationId` is the one an earlier run of the same name and agent last
103
+ * had. A tmux session that has had a conversation of its own stays its own run
104
+ * — a fresh start `/resume`d into an old conversation keeps what it wrote first.
105
+ */
106
+ export declare const continuedRunId: (entries: readonly KnownSession[], name: string, agentKind: string, tmuxRunId: string, conversationId: string | null | undefined) => string;
107
+ /** The run going on in tmux session `tmuxRunId` of `name`: the one it was joined to, else its own. */
108
+ export declare const runOfTmuxSession: (name: string, tmuxRunId: string, now?: number) => string;
89
109
  /**
90
110
  * Make this process a reader of the registry: `rememberSessions` writes nothing from here on. For the
91
111
  * CLI commands that look at the sweep (`hwip agent list|read`) — the file is the listener's resume
@@ -110,6 +130,8 @@ export declare const rememberSessions: (live: Array<{
110
130
  label?: string | null;
111
131
  runId?: string;
112
132
  startedAt?: string;
133
+ /** The tmux session it runs in; one other than `runId` means the run goes on in it (`continuedRunId`). */
134
+ tmuxRunId?: string;
113
135
  /**
114
136
  * The conversation a resume may pick up. Absent: not readable this
115
137
  * sweep, so the run keeps the one it had. Null: read, but not this
@@ -16,7 +16,7 @@
16
16
  * another program.
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.sessionDirectoryExists = exports.knownSessionsPath = exports.isKnownSession = exports.forgetEnded = exports.forgetSession = exports.rememberSessions = exports.keepRegistryUntouched = exports.recallRun = exports.recallSession = exports.knownSessions = exports.runKey = exports.KNOWN_SESSION_TTL_MS = exports.MAX_RUNS_PER_NAME = exports.MAX_KNOWN_SESSIONS = void 0;
19
+ exports.sessionDirectoryExists = exports.knownSessionsPath = exports.isKnownSession = exports.forgetEnded = exports.forgetSession = exports.rememberSessions = exports.keepRegistryUntouched = exports.runOfTmuxSession = exports.continuedRunId = exports.MAX_TMUX_RUNS_PER_RUN = exports.recallRun = exports.recallSession = exports.knownSessions = exports.runKey = exports.KNOWN_SESSION_TTL_MS = exports.MAX_RUNS_PER_NAME = exports.MAX_KNOWN_SESSIONS = void 0;
20
20
  const fs_1 = require("fs");
21
21
  const path_1 = require("path");
22
22
  const turn_ring_js_1 = require("./turn-ring.js");
@@ -35,6 +35,7 @@ exports.MAX_RUNS_PER_NAME = 10;
35
35
  * likely to have moved than to be what the user meant. */
36
36
  exports.KNOWN_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
37
37
  const registryPath = () => (0, path_1.join)((0, gate_js_1.stateDir)(), 'known-sessions.json');
38
+ const isTmuxRunIds = (v) => v === undefined || (Array.isArray(v) && v.every((id) => typeof id === 'string'));
38
39
  const readAll = () => {
39
40
  try {
40
41
  const raw = (0, fs_1.readFileSync)(registryPath(), 'utf-8');
@@ -49,7 +50,8 @@ const readAll = () => {
49
50
  typeof e.cwd === 'string' &&
50
51
  typeof e.agentKind === 'string' &&
51
52
  typeof e.lastSeenAt === 'string' &&
52
- (e.runId === undefined || typeof e.runId === 'string'));
53
+ (e.runId === undefined || typeof e.runId === 'string') &&
54
+ isTmuxRunIds(e.tmuxRunIds));
53
55
  }
54
56
  catch {
55
57
  return [];
@@ -109,6 +111,41 @@ exports.recallSession = recallSession;
109
111
  */
110
112
  const recallRun = (name, agentKind, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name && e.agentKind === agentKind) ?? null;
111
113
  exports.recallRun = recallRun;
114
+ /** tmux sessions remembered per run: an `exit` and `-c` every few minutes all day stays a short list. */
115
+ exports.MAX_TMUX_RUNS_PER_RUN = 10;
116
+ /**
117
+ * Agents whose own resume is measured to keep the conversation id: Claude Code 2.1.300's `-c`, opencode 2.0.29's
118
+ * `-c`, pi 1.2.0's `-c`, Codex 0.163.0's `resume --last`, Gemini 0.46.0's `--resume latest` and Hermes 0.20.0's
119
+ * `-c` (2026-10-20). Cursor's was not measured (signed out) and the others are not installed here.
120
+ */
121
+ const CONTINUING_KINDS = new Set(['claude', 'opencode', 'pi', 'codex', 'gemini', 'hermes']);
122
+ /**
123
+ * Which run the tmux session `tmuxRunId` of `name` is (hwip docs/session-runs.md §3).
124
+ *
125
+ * Its own, unless it carries on an earlier run: it was already joined to one
126
+ * (a sweep or a listener restart since), or the agent's conversation
127
+ * `conversationId` is the one an earlier run of the same name and agent last
128
+ * had. A tmux session that has had a conversation of its own stays its own run
129
+ * — a fresh start `/resume`d into an old conversation keeps what it wrote first.
130
+ */
131
+ const continuedRunId = (entries, name, agentKind, tmuxRunId, conversationId) => {
132
+ const ofName = entries.filter((e) => e.name === name && e.agentKind === agentKind && e.runId);
133
+ const joined = ofName.find((e) => e.runId !== tmuxRunId && e.tmuxRunIds?.includes(tmuxRunId));
134
+ if (joined?.runId)
135
+ return joined.runId;
136
+ if (!conversationId || !CONTINUING_KINDS.has(agentKind))
137
+ return tmuxRunId;
138
+ if (ofName.some((e) => e.runId === tmuxRunId && e.agentSessionId))
139
+ return tmuxRunId;
140
+ const earlier = ofName
141
+ .filter((e) => e.agentSessionId === conversationId && Number(e.runId) < Number(tmuxRunId))
142
+ .sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt))[0];
143
+ return earlier?.runId ?? tmuxRunId;
144
+ };
145
+ exports.continuedRunId = continuedRunId;
146
+ /** The run going on in tmux session `tmuxRunId` of `name`: the one it was joined to, else its own. */
147
+ const runOfTmuxSession = (name, tmuxRunId, now = Date.now()) => (0, exports.knownSessions)(now).find((e) => e.name === name && e.tmuxRunIds?.includes(tmuxRunId))?.runId ?? tmuxRunId;
148
+ exports.runOfTmuxSession = runOfTmuxSession;
112
149
  let readOnly = false;
113
150
  /**
114
151
  * Make this process a reader of the registry: `rememberSessions` writes nothing from here on. For the
@@ -146,6 +183,10 @@ const rememberSessions = (live, now = Date.now()) => {
146
183
  if (before && before.lastSeenAt >= s.startedAt)
147
184
  byRun.delete((0, exports.runKey)(s.name, s.agentKind));
148
185
  }
186
+ // Carried on in a new tmux session: what an earlier sweep wrote under that session is this run.
187
+ const continued = !!s.runId && !!s.tmuxRunId && s.tmuxRunId !== s.runId;
188
+ if (continued)
189
+ byRun.delete((0, exports.runKey)(s.name, s.agentKind, s.tmuxRunId));
149
190
  const key = (0, exports.runKey)(s.name, s.agentKind, s.runId);
150
191
  // A sweep that could not read the id — the agent has not written its
151
192
  // record yet — keeps the one this run already had. Keyed by run and
@@ -154,6 +195,11 @@ const rememberSessions = (live, now = Date.now()) => {
154
195
  const prev = byRun.get(key);
155
196
  const keepPrev = s.agentSessionId === undefined && prev?.cwd === s.cwd;
156
197
  const agentSessionId = s.agentSessionId || (keepPrev ? prev?.agentSessionId : undefined);
198
+ // A run keeps its first start whichever tmux session it goes on in.
199
+ const startedAt = continued ? prev?.startedAt ?? s.startedAt : s.startedAt;
200
+ const tmuxRunIds = continued && s.tmuxRunId
201
+ ? [...(prev?.tmuxRunIds ?? []).filter((id) => id !== s.tmuxRunId), s.tmuxRunId].slice(-exports.MAX_TMUX_RUNS_PER_RUN)
202
+ : prev?.tmuxRunIds;
157
203
  byRun.set(key, {
158
204
  name: s.name,
159
205
  cwd: s.cwd,
@@ -161,8 +207,9 @@ const rememberSessions = (live, now = Date.now()) => {
161
207
  ...(s.project ? { project: s.project } : {}),
162
208
  ...(s.label ? { label: s.label } : {}),
163
209
  ...(s.runId ? { runId: s.runId } : {}),
164
- ...(s.startedAt ? { startedAt: s.startedAt } : {}),
210
+ ...(startedAt ? { startedAt } : {}),
165
211
  ...(agentSessionId ? { agentSessionId } : {}),
212
+ ...(tmuxRunIds?.length ? { tmuxRunIds } : {}),
166
213
  lastSeenAt: seenAt,
167
214
  });
168
215
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The latest value a session transcript states for something — Claude Code's
3
+ * `ai-title`, pi's `session_info` name — kept current without re-reading the
4
+ * file every report cycle.
5
+ *
6
+ * The listener resolves every session's name every ~5 s, and a transcript runs
7
+ * to tens of megabytes. So each file is read the way the chat tail reads it:
8
+ * the last `TRANSCRIPT_BACKFILL_BYTES` once, then only what was appended, and
9
+ * nothing at all while the file is unchanged. `step` folds each new line into
10
+ * the value, so a chunk with no line it cares about leaves the value as it was.
11
+ */
12
+ import { type TailRead, type TailState } from './transcript-tail.js';
13
+ /** Who named a session: someone in the agent (`/rename`, `/name`), or the agent itself. */
14
+ export type ProviderNameSource = 'user' | 'auto';
15
+ /** A session name as an agent reports it. No `source` when the agent's store does not say. */
16
+ export interface ProviderName {
17
+ readonly name: string;
18
+ readonly source?: ProviderNameSource;
19
+ }
20
+ export declare const createTailWatch: <T>(step: (value: T | undefined, line: string) => T | undefined, read?: (path: string, prev: TailState | null) => TailRead | null, capacity?: number) => {
21
+ read: (path: string) => T | undefined;
22
+ size: () => number;
23
+ has: (path: string) => boolean;
24
+ };
25
+ /**
26
+ * The title out of a Claude Code `{"type":"ai-title","aiTitle":"…"}` line.
27
+ * The substring test runs first so the other few thousand lines of a
28
+ * transcript are never handed to `JSON.parse`.
29
+ */
30
+ export declare const claudeAiTitleOf: (line: string) => string | undefined;
31
+ /** The newest ai-title so far: a line without one keeps the last. */
32
+ export declare const latestClaudeAiTitle: (title: string | undefined, line: string) => string | undefined;
33
+ /** The custom entry the hwip pi extension leaves beside a name its tool set (PI_EXTENSION). */
34
+ export declare const PI_TITLE_MARKER = "hwip-session-name";
35
+ /**
36
+ * A pi session's naming state: the newest `session_info` name (`/name`, or the
37
+ * hwip tool through `pi.setSessionName`) and the newest hwip marker's name.
38
+ * pi records no author for a name, so the marker is how the tool's own are
39
+ * told apart: the name is the agent's only while it equals the marker's.
40
+ */
41
+ export interface PiNames {
42
+ readonly name?: string;
43
+ readonly marked?: string;
44
+ }
45
+ export declare const piNamesStep: (names: PiNames | undefined, line: string) => PiNames | undefined;
46
+ //# sourceMappingURL=session-title.d.ts.map