@hwip/cli 0.1.3 → 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.
@@ -11,6 +11,13 @@
11
11
  */
12
12
  /** What the agents run. `hwip` resolves out of the same global bin dir as `npx`. */
13
13
  export declare const MCP_LAUNCH_ARGV: readonly ["hwip", "mcp"];
14
+ /**
15
+ * Turns on the MCP server's `hwip_session_title` (mcp-server config `autoName`),
16
+ * so an agent with no title of its own names its session after its task. Set
17
+ * for Codex, Cursor, Windsurf and Gemini only: Claude Code and opencode title
18
+ * their sessions themselves, and the instruction would cost every turn.
19
+ */
20
+ export declare const AUTONAME_ENV = "HWIP_AUTONAME";
14
21
  /**
15
22
  * `mcpServers.hwip` — what the installer writes for Cursor and Windsurf.
16
23
  * The env block is deliberate: both spawn the MCP server from a graphical
@@ -18,13 +25,15 @@ export declare const MCP_LAUNCH_ARGV: readonly ["hwip", "mcp"];
18
25
  * explicitly rather than left to inheritance.
19
26
  *
20
27
  * `hwipit/plugin`'s `.mcp.json` holds the same shape but is a HAND-KEPT COPY —
21
- * it lives in another repo and cannot import this. Change one, change both.
28
+ * it lives in another repo and cannot import this. Change one, change both —
29
+ * except AUTONAME_ENV, which the plugin (Claude Code) leaves out on purpose.
22
30
  */
23
31
  export declare const MCP_SERVERS_ENTRY: {
24
32
  command: "hwip";
25
33
  args: "mcp"[];
26
34
  env: {
27
35
  HWIP_API_KEY: string;
36
+ HWIP_AUTONAME: string;
28
37
  };
29
38
  };
30
39
  /**
@@ -42,4 +51,7 @@ export declare const OPENCODE_MCP_ENTRY: {
42
51
  export declare const GEMINI_MCP_ADD: string;
43
52
  /** Older gemini CLIs only accept `mcp add <name> -- <command…>`, with no scope flag. */
44
53
  export declare const GEMINI_MCP_ADD_LEGACY: string;
54
+ /** The legacy form without `-e`, last: should a gemini that old refuse `-e`, the tools still load,
55
+ * and `hwip verify` says auto session names are off. */
56
+ export declare const GEMINI_MCP_ADD_BARE: string;
45
57
  //# sourceMappingURL=mcp-command.d.ts.map
@@ -11,10 +11,17 @@
11
11
  * itself. Everything below is derived from MCP_LAUNCH_ARGV.
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.GEMINI_MCP_ADD_LEGACY = exports.GEMINI_MCP_ADD = exports.OPENCODE_MCP_ENTRY = exports.MCP_SERVERS_ENTRY = exports.MCP_LAUNCH_ARGV = void 0;
14
+ exports.GEMINI_MCP_ADD_BARE = exports.GEMINI_MCP_ADD_LEGACY = exports.GEMINI_MCP_ADD = exports.OPENCODE_MCP_ENTRY = exports.MCP_SERVERS_ENTRY = exports.AUTONAME_ENV = exports.MCP_LAUNCH_ARGV = void 0;
15
15
  /** What the agents run. `hwip` resolves out of the same global bin dir as `npx`. */
16
16
  exports.MCP_LAUNCH_ARGV = ['hwip', 'mcp'];
17
17
  const [MCP_BIN, ...MCP_SUBCOMMAND] = exports.MCP_LAUNCH_ARGV;
18
+ /**
19
+ * Turns on the MCP server's `hwip_session_title` (mcp-server config `autoName`),
20
+ * so an agent with no title of its own names its session after its task. Set
21
+ * for Codex, Cursor, Windsurf and Gemini only: Claude Code and opencode title
22
+ * their sessions themselves, and the instruction would cost every turn.
23
+ */
24
+ exports.AUTONAME_ENV = 'HWIP_AUTONAME';
18
25
  /**
19
26
  * `mcpServers.hwip` — what the installer writes for Cursor and Windsurf.
20
27
  * The env block is deliberate: both spawn the MCP server from a graphical
@@ -22,12 +29,13 @@ const [MCP_BIN, ...MCP_SUBCOMMAND] = exports.MCP_LAUNCH_ARGV;
22
29
  * explicitly rather than left to inheritance.
23
30
  *
24
31
  * `hwipit/plugin`'s `.mcp.json` holds the same shape but is a HAND-KEPT COPY —
25
- * it lives in another repo and cannot import this. Change one, change both.
32
+ * it lives in another repo and cannot import this. Change one, change both —
33
+ * except AUTONAME_ENV, which the plugin (Claude Code) leaves out on purpose.
26
34
  */
27
35
  exports.MCP_SERVERS_ENTRY = {
28
36
  command: MCP_BIN,
29
37
  args: [...MCP_SUBCOMMAND],
30
- env: { HWIP_API_KEY: '${HWIP_API_KEY}' },
38
+ env: { HWIP_API_KEY: '${HWIP_API_KEY}', [exports.AUTONAME_ENV]: '1' },
31
39
  };
32
40
  /**
33
41
  * opencode.json's own MCP schema — top-level `mcp` key (not `mcpServers`),
@@ -45,6 +53,11 @@ exports.OPENCODE_MCP_ENTRY = {
45
53
  // server NAME and the BINARY are both "hwip" here, so the doubled word below
46
54
  // is correct, not a copy-paste slip. Default scope is "project" (cwd-local
47
55
  // .gemini/), so user scope is pinned for a machine-wide install.
48
- exports.GEMINI_MCP_ADD = `gemini mcp add -s user hwip ${MCP_BIN} -- ${MCP_SUBCOMMAND.join(' ')}`;
56
+ // `-e` lands in the entry's env. That a reinstall replaces an existing hwip entry whole, and so
57
+ // adds the flag to it, is unverified (gemini CLI behaviour); `hwip verify` reports it if not.
58
+ exports.GEMINI_MCP_ADD = `gemini mcp add -s user -e ${exports.AUTONAME_ENV}=1 hwip ${MCP_BIN} -- ${MCP_SUBCOMMAND.join(' ')}`;
49
59
  /** Older gemini CLIs only accept `mcp add <name> -- <command…>`, with no scope flag. */
50
- exports.GEMINI_MCP_ADD_LEGACY = `gemini mcp add hwip -- ${exports.MCP_LAUNCH_ARGV.join(' ')}`;
60
+ exports.GEMINI_MCP_ADD_LEGACY = `gemini mcp add -e ${exports.AUTONAME_ENV}=1 hwip -- ${exports.MCP_LAUNCH_ARGV.join(' ')}`;
61
+ /** The legacy form without `-e`, last: should a gemini that old refuse `-e`, the tools still load,
62
+ * and `hwip verify` says auto session names are off. */
63
+ exports.GEMINI_MCP_ADD_BARE = `gemini mcp add hwip -- ${exports.MCP_LAUNCH_ARGV.join(' ')}`;
@@ -1,18 +1,30 @@
1
+ import { spawnSync } from 'child_process';
1
2
  /**
2
- * Fit a detached tmux window to the phone that is watching it.
3
+ * Fit a tmux window to the phone that is watching it.
3
4
  *
4
5
  * A pane is as wide as the terminal that last drew it, usually a desktop's
5
6
  * 120-odd columns. The phone mirror shows about 45, so every full-width row a
6
7
  * prompt or TUI draws (a rule, a right-aligned clock, a status line) folds into
7
- * two. Nobody else sees a DETACHED window, so while a phone watches one it is
8
- * resized to the phone's width and the program redraws to fit; when the phone
9
- * leaves, the old width comes back.
8
+ * two. So while a phone watches a window it is resized to the phone's width
9
+ * and the program redraws to fit; when the phone leaves, the old width comes
10
+ * back.
10
11
  *
11
- * An attached window is left alone: shrinking it would shrink the desktop
12
- * terminal of whoever is attached. A window split into panes is left alone
13
- * too, since the window width is not any one pane's width. And when a client
14
- * attaches while the phone is watching, the next check hands the window back
15
- * to tmux, which sizes it to that client.
12
+ * A window with a desktop client attached is shared, and whoever used it last
13
+ * decides its width — the rule tmux's own `window-size latest` follows. The
14
+ * phone uses a window when it types into it ({@link claim}); a client uses it
15
+ * when it attaches or a key is pressed in it, which is what
16
+ * `#{client_activity}` records. tmux also moves that time on a mouse move
17
+ * over the window and on a focus change (herdr sends one to a pane whenever
18
+ * another pane is clicked), which are not using it. Where the time of the
19
+ * desktop's last key press is known ({@link macosLastKeyAt}), a client counts
20
+ * as used no later than that key, or than when it attached. Opening the mirror ({@link open}) is a weaker
21
+ * claim: it also covers an automatic resubscribe nobody asked for, so it only
22
+ * takes a window whose desktop has been idle for {@link DESKTOP_IDLE_SECONDS}.
23
+ * While the phone is the later of the two, the
24
+ * window is the phone's width (the desktop terminal shows it narrower); once a
25
+ * client is newer, the next check hands the window back to tmux, which sizes
26
+ * it to that client. A window split into panes is left alone, since the
27
+ * window width is not any one pane's width.
16
28
  *
17
29
  * A fitted window carries its old width in `@hwip_fit_width`. A listener that
18
30
  * died without releasing (a crash, a SIGKILL) leaves that mark behind, and
@@ -24,6 +36,8 @@ export type TmuxRun = (args: string[]) => {
24
36
  stdout: string;
25
37
  };
26
38
  export declare const PANE_FIT_MIN_COLS = 20;
39
+ /** How long a desktop client must sit idle before opening the mirror takes its window. */
40
+ export declare const DESKTOP_IDLE_SECONDS = 60;
27
41
  export declare const PANE_FIT_MAX_COLS = 400;
28
42
  /** A viewer's column count, or null when it did not send a usable one. */
29
43
  export declare const clampViewerCols: (cols: unknown) => number | null;
@@ -32,9 +46,23 @@ export declare const clampViewerCols: (cols: unknown) => number | null;
32
46
  * restore its width when detached, then drop the manual size and the mark.
33
47
  */
34
48
  export declare const releaseStaleFits: (run: TmuxRun) => number;
35
- export declare const createPaneFitter: (run: TmuxRun) => {
49
+ /** When the desktop behind the tmux client `clientPid` last pressed a key, in seconds since the epoch, or null when it cannot tell. */
50
+ export type LastKeyAt = (clientPid: number) => number | null;
51
+ /**
52
+ * The Mac's last key press, from the HID system's own clock (no Input Monitoring permission needed), read
53
+ * through `osascript` at most once a second and not again for half a minute after a failed read. A client
54
+ * that came in over SSH types on another machine, so the Mac's clock says nothing about it: null, as off macOS.
55
+ */
56
+ export declare const macosLastKeyAt: (now?: () => number, spawn?: typeof spawnSync, platform?: NodeJS.Platform) => LastKeyAt;
57
+ /** `now` is in ms, as `Date.now`; tmux reports client use in whole seconds. */
58
+ export declare const createPaneFitter: (run: TmuxRun, now?: () => number, lastKeyAt?: LastKeyAt) => {
36
59
  fit: (session: string, target: string, cols: unknown) => void;
60
+ recheck: (session: string) => void;
61
+ recheckAll: () => void;
62
+ claim: (session: string) => void;
63
+ open: (session: string) => void;
37
64
  release: (session: string) => void;
38
- fitted: (session: string) => boolean;
65
+ fitted: (session: string, cols?: unknown) => boolean;
66
+ forgetClaims: () => void;
39
67
  };
40
68
  //# sourceMappingURL=pane-fit.d.ts.map
package/dist/pane-fit.js CHANGED
@@ -1,27 +1,10 @@
1
1
  "use strict";
2
- /**
3
- * Fit a detached tmux window to the phone that is watching it.
4
- *
5
- * A pane is as wide as the terminal that last drew it, usually a desktop's
6
- * 120-odd columns. The phone mirror shows about 45, so every full-width row a
7
- * prompt or TUI draws (a rule, a right-aligned clock, a status line) folds into
8
- * two. Nobody else sees a DETACHED window, so while a phone watches one it is
9
- * resized to the phone's width and the program redraws to fit; when the phone
10
- * leaves, the old width comes back.
11
- *
12
- * An attached window is left alone: shrinking it would shrink the desktop
13
- * terminal of whoever is attached. A window split into panes is left alone
14
- * too, since the window width is not any one pane's width. And when a client
15
- * attaches while the phone is watching, the next check hands the window back
16
- * to tmux, which sizes it to that client.
17
- *
18
- * A fitted window carries its old width in `@hwip_fit_width`. A listener that
19
- * died without releasing (a crash, a SIGKILL) leaves that mark behind, and
20
- * the next one to start gives those windows back ({@link releaseStaleFits}).
21
- */
22
2
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.createPaneFitter = exports.releaseStaleFits = exports.clampViewerCols = exports.PANE_FIT_MAX_COLS = exports.PANE_FIT_MIN_COLS = void 0;
3
+ exports.createPaneFitter = exports.macosLastKeyAt = exports.releaseStaleFits = exports.clampViewerCols = exports.PANE_FIT_MAX_COLS = exports.DESKTOP_IDLE_SECONDS = exports.PANE_FIT_MIN_COLS = void 0;
4
+ const child_process_1 = require("child_process");
24
5
  exports.PANE_FIT_MIN_COLS = 20;
6
+ /** How long a desktop client must sit idle before opening the mirror takes its window. */
7
+ exports.DESKTOP_IDLE_SECONDS = 60;
25
8
  exports.PANE_FIT_MAX_COLS = 400;
26
9
  /** A viewer's column count, or null when it did not send a usable one. */
27
10
  const clampViewerCols = (cols) => {
@@ -55,6 +38,74 @@ const releaseStaleFits = (run) => {
55
38
  return released;
56
39
  };
57
40
  exports.releaseStaleFits = releaseStaleFits;
41
+ /**
42
+ * The Mac's last key press, from the HID system's own clock (no Input Monitoring permission needed), read
43
+ * through `osascript` at most once a second and not again for half a minute after a failed read. A client
44
+ * that came in over SSH types on another machine, so the Mac's clock says nothing about it: null, as off macOS.
45
+ */
46
+ const macosLastKeyAt = (now = () => Date.now(), spawn = child_process_1.spawnSync, platform = process.platform) => {
47
+ let cached;
48
+ const remote = new Map();
49
+ const overSsh = (clientPid) => {
50
+ const known = remote.get(clientPid);
51
+ if (known !== undefined)
52
+ return known;
53
+ let found = false;
54
+ for (let pid = clientPid, depth = 0; pid > 1 && depth < 32; depth++) {
55
+ const r = spawn('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 });
56
+ const m = r.status === 0 ? /^\s*(\d+)\s+(.*)$/.exec(String(r.stdout).trim()) : null;
57
+ if (!m)
58
+ break;
59
+ if (/(^|\/)sshd\b/.test(m[2])) {
60
+ found = true;
61
+ break;
62
+ }
63
+ pid = Number(m[1]);
64
+ }
65
+ remote.set(clientPid, found);
66
+ return found;
67
+ };
68
+ const keyAt = () => {
69
+ const readAt = now();
70
+ if (cached && readAt < cached.until)
71
+ return cached.keyAt;
72
+ // kCGEventSourceStateHIDSystemState = 1, kCGEventKeyDown = 10.
73
+ const r = spawn('osascript', ['-l', 'JavaScript', '-e',
74
+ "ObjC.import('CoreGraphics'); $.CGEventSourceSecondsSinceLastEventType(1, 10)"], { encoding: 'utf8', timeout: 2000 });
75
+ const out = String(r.stdout ?? '').trim();
76
+ const idle = Number(out);
77
+ const read = r.status === 0 && out !== '' && Number.isFinite(idle) ? Math.floor(readAt / 1000 - idle) : null;
78
+ // A hung or failing osascript blocks the listener for its timeout: do not ask again soon.
79
+ cached = { until: now() + (read === null ? 30_000 : 1000), keyAt: read };
80
+ return read;
81
+ };
82
+ return (clientPid) => {
83
+ if (platform !== 'darwin')
84
+ return null;
85
+ if (Number.isFinite(clientPid) && overSsh(clientPid))
86
+ return null;
87
+ return keyAt();
88
+ };
89
+ };
90
+ exports.macosLastKeyAt = macosLastKeyAt;
91
+ /** The clients on `target`'s session, or null when tmux cannot tell. */
92
+ const readClients = (run, target) => {
93
+ const r = run(['list-clients', '-t', target, '-F', `#{client_activity}${SEP}#{client_created}${SEP}#{client_pid}`]);
94
+ if (r.status !== 0)
95
+ return null;
96
+ return r.stdout.split('\n').filter(Boolean).flatMap((line) => {
97
+ const [activity, created, pid] = line.split(SEP).map(Number);
98
+ return Number.isFinite(activity) ? [{ activity, created: Number.isFinite(created) ? created : 0, pid }] : [];
99
+ });
100
+ };
101
+ /**
102
+ * When a client was last used (seconds). With the desktop's key clock, its activity counts only up to the last
103
+ * key press, or when it attached: a mouse move or focus change after the last key is not use.
104
+ */
105
+ const clientUse = (client, lastKeyAt) => {
106
+ const keyAt = lastKeyAt(client.pid);
107
+ return keyAt === null ? client.activity : Math.max(Math.min(client.activity, keyAt), client.created);
108
+ };
58
109
  const readWindow = (run, target) => {
59
110
  const r = run(['display-message', '-p', '-t', target,
60
111
  `#{session_attached}${SEP}#{window_panes}${SEP}#{window_width}`]);
@@ -65,15 +116,30 @@ const readWindow = (run, target) => {
65
116
  return null;
66
117
  return { attached, panes, width };
67
118
  };
68
- const createPaneFitter = (run) => {
119
+ /** `now` is in ms, as `Date.now`; tmux reports client use in whole seconds. */
120
+ const createPaneFitter = (run, now = () => Date.now(), lastKeyAt = () => null) => {
69
121
  const fits = new Map();
122
+ /** When the phone last typed into and last opened each session, in tmux's seconds. */
123
+ const claims = new Map();
124
+ const seconds = () => Math.floor(now() / 1000);
125
+ /** The phone typed into `session` just now: it is the latest user until a client is used after it. */
126
+ const claim = (session) => {
127
+ claims.set(session, { ...claims.get(session), typedAt: seconds() });
128
+ };
129
+ /**
130
+ * The phone opened the mirror of `session` just now: it takes the window once the desktop is idle. The time
131
+ * the phone last typed is kept: a reconnect is not a reason to hand an untouched desktop its width back.
132
+ */
133
+ const open = (session) => {
134
+ claims.set(session, { ...claims.get(session), openedAt: seconds() });
135
+ };
70
136
  /**
71
137
  * Give the window back: restore its width if nobody is attached, then drop
72
138
  * the manual size `resize-window` set, so the next client to attach sizes
73
139
  * it again. With a client attached, dropping the option alone resizes it to
74
140
  * that client.
75
141
  */
76
- const release = (session) => {
142
+ const unfit = (session) => {
77
143
  const fit = fits.get(session);
78
144
  if (!fit)
79
145
  return;
@@ -85,9 +151,50 @@ const createPaneFitter = (run) => {
85
151
  run(['set-option', '-w', '-u', '-t', fit.target, FIT_MARK]);
86
152
  };
87
153
  /**
88
- * Size `session`'s window to `cols` if it is detached and unsplit. Safe to
89
- * call on every renew: an unchanged width costs one display-message, and a
90
- * window that gained a client since is released.
154
+ * The window is not the phone's to size: it is split, or an attached client was used at or after the
155
+ * phone's claim, or — when the phone only opened the mirror — within the last {@link DESKTOP_IDLE_SECONDS}.
156
+ * A tie goes to the desktop: tmux counts in whole seconds, and shrinking a terminal in use is the worse mistake.
157
+ */
158
+ const notOurs = (session, target, win) => {
159
+ if (win.panes !== 1)
160
+ return true;
161
+ if (win.attached === 0)
162
+ return false;
163
+ const clients = readClients(run, target);
164
+ if (!clients || clients.length === 0)
165
+ return true;
166
+ const claimed = claims.get(session);
167
+ const desktops = (use) => {
168
+ const typedSince = claimed?.typedAt !== undefined && use < claimed.typedAt;
169
+ const openedOnIdle = claimed?.openedAt !== undefined && use < claimed.openedAt
170
+ && seconds() - use >= exports.DESKTOP_IDLE_SECONDS;
171
+ return !typedSince && !openedOnIdle;
172
+ };
173
+ // A use is never later than the raw activity: when that already leaves the window to the phone, the key
174
+ // clock (an osascript on macOS) is not read.
175
+ if (!desktops(Math.max(...clients.map((c) => c.activity))))
176
+ return false;
177
+ return desktops(Math.max(...clients.map((c) => clientUse(c, lastKeyAt))));
178
+ };
179
+ /**
180
+ * The phone left: give the window back. When it typed, that is kept: the phone comes and goes (a screen
181
+ * locks, the app is left a moment, the mirror resubscribes), and on its return it is still the latest user
182
+ * unless the desktop was used since — the same test a renew applies.
183
+ */
184
+ const release = (session) => {
185
+ const typedAt = claims.get(session)?.typedAt;
186
+ if (typedAt === undefined)
187
+ claims.delete(session);
188
+ else
189
+ claims.set(session, { typedAt });
190
+ unfit(session);
191
+ };
192
+ /**
193
+ * Size `session`'s window to `cols` if it is unsplit and the phone used it
194
+ * after every attached client. Safe to call on every renew: an unchanged
195
+ * width costs one display-message (and one list-clients when attached), and
196
+ * a window a client used since is handed back. The first call for a session
197
+ * counts as opening the mirror when nothing claimed it yet.
91
198
  */
92
199
  const fit = (session, target, cols) => {
93
200
  const want = (0, exports.clampViewerCols)(cols);
@@ -97,9 +204,12 @@ const createPaneFitter = (run) => {
97
204
  if (!win)
98
205
  return;
99
206
  const held = fits.get(session);
100
- if (win.attached > 0 || win.panes !== 1) {
207
+ if (!claims.has(session))
208
+ open(session);
209
+ if (notOurs(session, target, win)) {
210
+ // Only the fit goes: the claim stays, so the desktop keeps the window until the phone uses it again.
101
211
  if (held)
102
- release(session);
212
+ unfit(session);
103
213
  return;
104
214
  }
105
215
  if (held && held.cols === want && win.width === want)
@@ -114,6 +224,29 @@ const createPaneFitter = (run) => {
114
224
  }
115
225
  fits.set(session, { target, width, cols: want });
116
226
  };
117
- return { fit, release, fitted: (session) => fits.has(session) };
227
+ /** Hand a held window back if a client was used since or it was split, without touching its width otherwise. */
228
+ const recheck = (session) => {
229
+ const held = fits.get(session);
230
+ if (!held)
231
+ return;
232
+ const win = readWindow(run, held.target);
233
+ if (win && notOurs(session, held.target, win))
234
+ unfit(session);
235
+ };
236
+ /** {@link recheck} every fitted window: a desktop key is answered within a tick, not a renew. */
237
+ const recheckAll = () => {
238
+ for (const session of [...fits.keys()])
239
+ recheck(session);
240
+ };
241
+ /** Whether `session`'s window is fitted — at `cols`, when given. */
242
+ const fitted = (session, cols) => {
243
+ const held = fits.get(session);
244
+ if (!held)
245
+ return false;
246
+ return cols === undefined || held.cols === (0, exports.clampViewerCols)(cols);
247
+ };
248
+ /** Test hook: forget every claim, as a new listener starts with none. */
249
+ const forgetClaims = () => claims.clear();
250
+ return { fit, recheck, recheckAll, claim, open, release, fitted, forgetClaims };
118
251
  };
119
252
  exports.createPaneFitter = createPaneFitter;
@@ -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;