@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.
- package/README.md +33 -11
- package/dist/agents.d.ts +10 -0
- package/dist/agents.js +12 -1
- package/dist/codex-config.d.ts +8 -5
- package/dist/codex-config.js +121 -12
- package/dist/command-scan.d.ts +7 -3
- package/dist/command-scan.js +59 -15
- package/dist/installer.js +7 -1
- package/dist/listener.d.ts +9 -0
- package/dist/listener.js +46 -11
- package/dist/mcp-command.d.ts +13 -1
- package/dist/mcp-command.js +18 -5
- package/dist/pane-fit.d.ts +39 -11
- package/dist/pane-fit.js +162 -29
- package/dist/remote-agents.d.ts +64 -12
- package/dist/remote-agents.js +141 -18
- package/dist/session-registry.js +6 -2
- package/dist/session-title.d.ts +46 -0
- package/dist/session-title.js +103 -0
- package/dist/templates.d.ts +1 -0
- package/dist/templates.js +41 -1
- package/dist/verify.d.ts +3 -0
- package/dist/verify.js +33 -8
- package/package.json +1 -1
package/dist/mcp-command.d.ts
CHANGED
|
@@ -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
|
package/dist/mcp-command.js
CHANGED
|
@@ -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
|
-
|
|
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(' ')}`;
|
package/dist/pane-fit.d.ts
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
1
2
|
/**
|
|
2
|
-
* Fit a
|
|
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.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* attaches
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/remote-agents.d.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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;
|