@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.
package/dist/listener.js CHANGED
@@ -25,9 +25,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
25
25
  return (mod && mod.__esModule) ? mod : { "default": mod };
26
26
  };
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
- exports.fileViewBytes = exports.handleScreenRequest = exports.setSubscriberTrust = exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = exports.withSettledPrompts = exports.PROMPT_SEAL_WAIT_MS = exports.withBlockedPrompts = exports.setPromptSealer = exports.deriveSessionState = exports.resetSessionStates = exports.runIdOfEpoch = exports.__checkoutCacheSize = exports.parseSessionName = exports.tmuxTargetFor = exports.subagentTranscriptFor = exports.recordTargets = exports.recordSubagentTranscripts = exports.isSubagentSessionName = exports.detectRemoteAgent = exports.sessionLiveness = exports.sessionExists = exports.liveSessionNames = exports.tmuxArgs = exports.silenceListenerLog = exports.invalidateTmuxSocketCache = exports.resolveKeys = exports.probePane = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = exports.commandsReportDue = exports.commandsFingerprint = exports.COMMAND_SCAN_INTERVAL_MS = exports.sessionsReportDue = exports.sessionsFingerprint = exports.knownSessionsFingerprint = exports.knownSessionsToReport = exports.withRunSeqs = exports.KNOWN_SESSIONS_REPORTED = exports.staleConnectionAction = exports.askedToReconnect = exports.MAX_STALE_CONNECTION_DROPS = exports.createReportAckWatch = exports.REPORT_ACK_TIMEOUT_MS = exports.SESSION_REPORT_HEARTBEAT_MS = exports.SESSION_REPORT_INTERVAL_MS = exports.heartbeatVerdict = exports.pingDue = exports.PING_IDLE_MS = void 0;
29
- exports.writeRemoteMarker = exports.isInventoried = exports.syncTmuxAgentNames = exports.recordInventory = exports.collectSessionsVerbose = exports.handleCommandInput = exports.pendingInputDecrypts = exports.MAX_PENDING_DECRYPTS = exports.MAX_INPUT_LANES = exports.validateInputMessage = exports.MAX_INPUT_BODY_CHARS = exports.MAX_INPUT_KEYS = exports.handleStreamControl = exports.buildStreamFrame = exports.isStreamCapReached = exports.fitSealedFrame = exports.SEALED_FRAME_MAX_BYTES = exports.stopAllStreams = exports.stopStream = exports.stopStreamOf = exports.handleTurnWatch = exports.sealTurnBatch = exports.resolveWatchTranscript = exports.MAX_CONCURRENT_STREAMS = exports.STREAM_RENEW_FRESH_MS = exports.STREAM_LEASE_MS = exports.claimFrameSend = exports.hasFrameBudget = exports.streamCadence = exports.MAX_FRAMES_PER_SEC = exports.BURST_WINDOW_MS = exports.BURST_INTERVAL_MS = exports.STREAM_INTERVAL_MS = exports.STREAM_CAPTURE_LINES = exports.scheduleInjectSnapshots = exports.historyLinesIn = exports.cursorLineFor = exports.parsePaneGeometry = exports.STREAM_GEOMETRY_FORMAT = exports.handleScreenHistoryRequest = exports.SCREEN_HISTORY_MAX_LINES = exports.handleSessionForgetRequest = exports.handleSessionExitRequest = exports.SESSION_EXIT_STEP_MS = exports.handleSessionResumeRequest = exports.handleScheduleListRequest = exports.handleFileBlobRequest = exports.handleFileTreeRequest = exports.handleDiffFileRequest = exports.handleDiffFilesRequest = void 0;
30
- exports.handleListener = exports.liveScheduleRunnerDeps = exports.listenerSettings = exports.resolveWsUrl = exports.streamSession = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.displayFolder = exports.claimLanFile = exports.saveFileFromS3 = exports.downloadSealedEach = exports.answerFileBound = exports.downloadAttachments = exports.setAttachmentContext = exports.shellTextRefusal = void 0;
28
+ exports.setSubscriberTrust = exports.collectWatchHits = exports.resetPatternWatches = exports.setPatternWatches = exports.withSettledPrompts = exports.PROMPT_SEAL_WAIT_MS = exports.withBlockedPrompts = exports.setPromptSealer = exports.deriveSessionState = exports.resetSessionStates = exports.continueRuns = exports.runIdOfEpoch = exports.__checkoutCacheSize = exports.parseSessionName = exports.resetPaneFitClaims = exports.tmuxTargetFor = exports.subagentTranscriptFor = exports.recordTargets = exports.recordSubagentTranscripts = exports.isSubagentSessionName = exports.detectRemoteAgent = exports.sessionLiveness = exports.sessionExists = exports.liveSessionNames = exports.tmuxArgs = exports.silenceListenerLog = exports.invalidateTmuxSocketCache = exports.resolveKeys = exports.probePane = exports.checkRateLimit = exports.AUTH_FAILURE_CODES = exports.commandsReportDue = exports.commandsFingerprint = exports.COMMAND_SCAN_INTERVAL_MS = exports.sessionsReportDue = exports.sessionsFingerprint = exports.knownSessionsFingerprint = exports.knownSessionsToReport = exports.withRunSeqs = exports.KNOWN_SESSIONS_REPORTED = exports.staleConnectionAction = exports.askedToReconnect = exports.MAX_STALE_CONNECTION_DROPS = exports.createReportAckWatch = exports.REPORT_ACK_TIMEOUT_MS = exports.SESSION_REPORT_HEARTBEAT_MS = exports.SESSION_REPORT_INTERVAL_MS = exports.heartbeatVerdict = exports.pingDue = exports.PING_IDLE_MS = void 0;
29
+ exports.syncTmuxAgentNames = exports.recordInventory = exports.collectSessionsVerbose = exports.handleCommandInput = exports.pendingInputDecrypts = exports.MAX_PENDING_DECRYPTS = exports.MAX_INPUT_LANES = exports.validateInputMessage = exports.MAX_INPUT_BODY_CHARS = exports.MAX_INPUT_KEYS = exports.handleStreamControl = exports.buildStreamFrame = exports.isStreamCapReached = exports.fitSealedFrame = exports.SEALED_FRAME_MAX_BYTES = exports.stopAllStreams = exports.stopStream = exports.stopStreamOf = exports.handleTurnWatch = exports.sealTurnBatch = exports.resolveWatchTranscript = exports.MAX_CONCURRENT_STREAMS = exports.STREAM_RENEW_FRESH_MS = exports.STREAM_LEASE_MS = exports.claimFrameSend = exports.hasFrameBudget = exports.streamCadence = exports.MAX_FRAMES_PER_SEC = exports.BURST_WINDOW_MS = exports.BURST_INTERVAL_MS = exports.STREAM_INTERVAL_MS = exports.STREAM_CAPTURE_LINES = exports.scheduleInjectSnapshots = exports.historyLinesIn = exports.cursorLineFor = exports.parsePaneGeometry = exports.STREAM_GEOMETRY_FORMAT = exports.handleScreenHistoryRequest = exports.SCREEN_HISTORY_MAX_LINES = exports.handleSessionForgetRequest = exports.handleSessionExitRequest = exports.SESSION_EXIT_STEP_MS = exports.handleSessionResumeRequest = exports.handleScheduleListRequest = exports.handleFileBlobRequest = exports.handleFileTreeRequest = exports.handleDiffFileRequest = exports.handleDiffFilesRequest = exports.fileViewBytes = exports.handleScreenRequest = void 0;
30
+ exports.handleListener = exports.liveScheduleRunnerDeps = exports.listenerSettings = exports.resolveWsUrl = exports.streamSession = exports.computeListenerDeviceId = exports.computeBackoff = exports.handlePush = exports.gcAttachments = exports.displayFolder = exports.claimLanFile = exports.saveFileFromS3 = exports.downloadSealedEach = exports.answerFileBound = exports.downloadAttachments = exports.setAttachmentContext = exports.shellTextRefusal = exports.writeRemoteMarker = exports.isInventoried = void 0;
31
31
  const child_process_1 = require("child_process");
32
32
  const crypto_1 = require("crypto");
33
33
  const fs_1 = require("fs");
@@ -318,16 +318,17 @@ const knownSessionsToReport = (live, now = Date.now(), seqs = (0, run_seqs_js_1.
318
318
  ...(e.runId ? { runId: e.runId } : {}),
319
319
  ...(e.startedAt ? { startedAt: e.startedAt } : {}),
320
320
  ...(e.runId ? seqs.get((0, run_seqs_js_1.runSeqsKey)(e.name, e.runId)) : undefined),
321
+ ...(e.tmuxRunIds?.length ? { tmuxRunIds: e.tmuxRunIds } : {}),
321
322
  lastSeenAt: e.lastSeenAt,
322
323
  }));
323
324
  };
324
325
  exports.knownSessionsToReport = knownSessionsToReport;
325
- const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}`).sort().join('|');
326
+ const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''}`).sort().join('|');
326
327
  exports.knownSessionsFingerprint = knownSessionsFingerprint;
327
328
  const sessionsFingerprint = (sessions) => sessions
328
329
  .map((s) => JSON.stringify([
329
330
  s.name, s.attached, s.agentKind, s.agentSessionId ?? null,
330
- s.project, s.label ?? null, s.providerSessionName ?? null, s.createdAt ?? null, s.runId ?? null, s.firstSeq ?? null,
331
+ s.project, s.label ?? null, s.providerSessionName ?? null, s.providerSessionNameSource ?? null, s.createdAt ?? null, s.runId ?? null, s.firstSeq ?? null,
331
332
  s.lastActivityAt ?? null, s.state ?? null,
332
333
  s.stateChangedAt ?? null, s.stateRuleId ?? null,
333
334
  s.blockedPrompt?.id ?? null, s.blockedPrompt?.at ?? null,
@@ -587,8 +588,13 @@ const worktreeCleanup = (0, listener_worktree_cleanup_js_1.createWorktreeCleanup
587
588
  notify: desktop_notify_js_1.notifyDesktop,
588
589
  tmux: (args, timeoutMs) => (0, worktree_js_1.tmuxRun)((0, tmux_socket_js_1.tmuxArgs)(args), timeoutMs),
589
590
  });
590
- /** Sizes a watched detached window to its viewer (pane-fit.ts). */
591
- const paneFitter = (0, pane_fit_js_1.createPaneFitter)(runTmux);
591
+ /** Sizes a watched window to its viewer while the phone is its latest user (pane-fit.ts). */
592
+ const paneFitter = (0, pane_fit_js_1.createPaneFitter)(runTmux, undefined, (0, pane_fit_js_1.macosLastKeyAt)());
593
+ /** Test hook. */
594
+ const resetPaneFitClaims = () => paneFitter.forgetClaims();
595
+ exports.resetPaneFitClaims = resetPaneFitClaims;
596
+ /** How often fitted windows are checked for a desktop that took them back; idle windows cost nothing. */
597
+ const PANE_FIT_RECHECK_MS = 1_000;
592
598
  // ─── Session inventory ──────────────────────────────────────────────
593
599
  /**
594
600
  * Parse a `hwip-*` tmux session name into `{project, label}`. The wrapper
@@ -638,11 +644,33 @@ const epochToIso = (epoch) => {
638
644
  * from `createdAt` for listeners that do not send one (`runIdOf`), so the two
639
645
  * must agree: `epochToIso` keeps whole seconds and this floors them.
640
646
  */
641
- const runIdOfEpoch = (epoch) => {
642
- const iso = epochToIso(epoch);
643
- return iso ? String(Math.floor(Date.parse(iso) / 1000)) : undefined;
644
- };
647
+ const runIdOfEpoch = (epoch) => runIdOfIso(epochToIso(epoch));
645
648
  exports.runIdOfEpoch = runIdOfEpoch;
649
+ /** The run id of the tmux session that started at `iso` (a session's `createdAt`). */
650
+ const runIdOfIso = (iso) => {
651
+ const ms = iso ? Date.parse(iso) : NaN;
652
+ return Number.isFinite(ms) ? String(Math.floor(ms / 1000)) : undefined;
653
+ };
654
+ /**
655
+ * Each live session's run once the sweep has read the conversations (hwip
656
+ * docs/session-runs.md §3): a session carrying on an earlier run's
657
+ * conversation reports — and its recorder tags — that run instead of its new
658
+ * tmux session. `sessions` and `recordable` are this sweep's, changed in place.
659
+ */
660
+ const continueRuns = (sessions, recordable, conversationOf, known = (0, session_registry_js_1.knownSessions)()) => {
661
+ for (const s of sessions) {
662
+ if (s.parentName || !s.runId)
663
+ continue;
664
+ const runId = (0, session_registry_js_1.continuedRunId)(known, s.name, s.agentKind, s.runId, conversationOf(s.name));
665
+ if (runId === s.runId)
666
+ continue;
667
+ s.runId = runId;
668
+ const source = recordable[s.name];
669
+ if (source)
670
+ recordable[s.name] = { ...source, runId };
671
+ }
672
+ };
673
+ exports.continueRuns = continueRuns;
646
674
  // ─── Agent state detection (SPEC-AGENT-AWARENESS §S1) ──────────────
647
675
  // How many pane lines the state rules see. The live status area every
648
676
  // agent renders sits in the last handful of lines; 40 leaves margin for
@@ -1597,7 +1625,8 @@ const handleSessionForgetRequest = (req, send) => {
1597
1625
  // A name running again is not the run being forgotten: an ended run of it goes on its own (Q29).
1598
1626
  // The running run itself, a request naming no run, or a run tmux could not tell apart stay refused.
1599
1627
  if (liveness === 'running') {
1600
- const live = runId ? (0, tmux_liveness_js_1.liveRunIdOf)(sessionName) : undefined;
1628
+ const tmuxRun = runId ? (0, tmux_liveness_js_1.liveRunIdOf)(sessionName) : undefined;
1629
+ const live = tmuxRun ? (0, session_registry_js_1.runOfTmuxSession)(sessionName, tmuxRun) : undefined;
1601
1630
  if (!runId || !live || live === runId)
1602
1631
  return reply({ error: 'still_running' });
1603
1632
  }
@@ -2052,9 +2081,9 @@ const leaseFor = (renewing) => (renewing ? exports.STREAM_LEASE_MS : STREAM_MAX_
2052
2081
  * Live mirrors, one per (session, viewing device): every viewer keeps the whole of the machinery a
2053
2082
  * single viewer had — its own capture loop state, lease, nonce, epoch and seq, sealed frames — so
2054
2083
  * two devices watching one pane do not take each other's stream over. A viewer with no device id
2055
- * (a web tab) shares the empty slot, one at a time, as before. The tmux pane is shared: its width
2056
- * is whatever the last viewer to send input or `cols` asked for (pane-fit.ts), and every viewer
2057
- * receives the pane at that width.
2084
+ * (a web tab) shares the empty slot, one at a time, as before. The tmux pane is shared: while the
2085
+ * phone is its latest user its width is whatever the last viewer to send input or `cols` asked for,
2086
+ * and a desktop client used since takes it back (pane-fit.ts); every viewer receives the pane at that width.
2058
2087
  */
2059
2088
  const activeStreams = new Map();
2060
2089
  const streamKey = (sessionName, deviceId) => `${sessionName}\u0000${typeof deviceId === 'string' ? deviceId : ''}`;
@@ -2583,6 +2612,9 @@ const handleStreamControl = (req, send) => {
2583
2612
  const renewTarget = (0, tmux_pane_js_1.tmuxTargetFor)(req.sessionName);
2584
2613
  if (renewTarget !== null && (colsChanged || streamsOf(req.sessionName).length === 1))
2585
2614
  paneFitter.fit(req.sessionName, renewTarget, req.cols);
2615
+ // Otherwise keep the width the pane has, but still notice a desktop client used since (pane-fit.ts).
2616
+ else
2617
+ paneFitter.recheck(req.sessionName);
2586
2618
  return true;
2587
2619
  }
2588
2620
  // Addressed to us but we hold no such stream: it was reaped (lost
@@ -2624,6 +2656,8 @@ const handleStreamControl = (req, send) => {
2624
2656
  return true;
2625
2657
  }
2626
2658
  const fitTarget = (0, tmux_pane_js_1.tmuxTargetFor)(sessionName);
2659
+ // Opening the mirror (or resubscribing) takes the width only from an idle desktop (pane-fit.ts).
2660
+ paneFitter.open(sessionName);
2627
2661
  if (fitTarget !== null)
2628
2662
  paneFitter.fit(sessionName, fitTarget, req.cols);
2629
2663
  let lastContent = null;
@@ -3023,6 +3057,23 @@ const deliverInput = (input) => {
3023
3057
  // Only a reason that does not depend on the text: this input may be sealed.
3024
3058
  if (!injected)
3025
3059
  input.send(streamErrorFrame(input.sessionName, 'input_rejected', input, reason && (0, command_refusal_js_1.isSessionStateRefusal)(reason) ? reason : undefined));
3060
+ else
3061
+ takeWidthBack(input.sessionName, input.deviceId);
3062
+ };
3063
+ /**
3064
+ * Typing from the phone makes it the session's latest user again (pane-fit.ts): a window a desktop
3065
+ * client took back is sized to the sender's width once more.
3066
+ */
3067
+ const takeWidthBack = (sessionName, deviceId) => {
3068
+ paneFitter.claim(sessionName);
3069
+ const viewer = activeStreams.get(streamKey(sessionName, deviceId)) ?? streamsOf(sessionName)[0];
3070
+ // Already at the sender's width: the claim is enough, and a keystroke costs no tmux round trip (renew rechecks).
3071
+ if (!viewer || paneFitter.fitted(sessionName, viewer.cols))
3072
+ return;
3073
+ const target = (0, tmux_pane_js_1.tmuxTargetFor)(sessionName);
3074
+ if (target === null)
3075
+ return;
3076
+ paneFitter.fit(sessionName, target, viewer.cols);
3026
3077
  };
3027
3078
  /** Ceiling on concurrent sender lanes. The lane key includes a relay-stamped
3028
3079
  * deviceId, but a relay older than the unconditional stamp lets a sender vary
@@ -3424,7 +3475,7 @@ const collectSessionsVerbose = () => {
3424
3475
  const agentSessionId = info.currentPath
3425
3476
  ? (main.agent.resolveSessionId?.(info.currentPath, info.panePid ?? undefined) ?? null)
3426
3477
  : null;
3427
- const providerSessionName = info.currentPath
3478
+ const providerName = info.currentPath
3428
3479
  ? (main.agent.resolveSessionName?.(info.currentPath, info.panePid ?? undefined) ?? null)
3429
3480
  : null;
3430
3481
  if (info.currentPath)
@@ -3442,7 +3493,8 @@ const collectSessionsVerbose = () => {
3442
3493
  agentSessionId,
3443
3494
  project: parsed.project,
3444
3495
  label: parsed.label,
3445
- providerSessionName,
3496
+ providerSessionName: providerName?.name ?? null,
3497
+ ...(providerName?.source ? { providerSessionNameSource: providerName.source } : {}),
3446
3498
  createdAt: epochToIso(group.created),
3447
3499
  runId: (0, exports.runIdOfEpoch)(group.created),
3448
3500
  lastActivityAt: epochToIso(group.activity),
@@ -3566,6 +3618,7 @@ const collectSessionsVerbose = () => {
3566
3618
  return undefined;
3567
3619
  return resumeIdCount.get(id) === 1 ? id : null;
3568
3620
  };
3621
+ (0, exports.continueRuns)(sessions, recordable, ownResumeId);
3569
3622
  // Write down what each live session IS, while it still exists to be read.
3570
3623
  // tmux forgets a session the moment it ends, which is exactly when the
3571
3624
  // phone wants it back — and a resume must take its directory and its binary
@@ -3584,6 +3637,7 @@ const collectSessionsVerbose = () => {
3584
3637
  label: s.label,
3585
3638
  runId: s.runId,
3586
3639
  startedAt: s.createdAt,
3640
+ tmuxRunId: runIdOfIso(s.createdAt),
3587
3641
  agentSessionId: ownResumeId(s.name),
3588
3642
  })));
3589
3643
  return { sessions, rejected, targets, subagentTranscripts, recordable };
@@ -4909,6 +4963,10 @@ const streamSession = (wsUrl, apiKey) => {
4909
4963
  clearInterval(commandsTimer);
4910
4964
  commandsTimer = null;
4911
4965
  }
4966
+ if (paneFitTimer) {
4967
+ clearInterval(paneFitTimer);
4968
+ paneFitTimer = null;
4969
+ }
4912
4970
  if (connectTimer) {
4913
4971
  clearTimeout(connectTimer);
4914
4972
  connectTimer = null;
@@ -4955,6 +5013,7 @@ const streamSession = (wsUrl, apiKey) => {
4955
5013
  // rescan changes it. The rescan has its own slow timer — it reads the
4956
5014
  // filesystem, so it must never ride the 5 s session loop.
4957
5015
  let commandsTimer = null;
5016
+ let paneFitTimer = null;
4958
5017
  // Per connection, like the inventory gate above: `sock.send` returning
4959
5018
  // is not delivery, and a fingerprint that outlived the socket would let
4960
5019
  // one lost frame leave the server's catalog stale until the user next
@@ -5131,6 +5190,8 @@ const streamSession = (wsUrl, apiKey) => {
5131
5190
  sessionsTimer = setInterval(() => void reportSessions(), exports.SESSION_REPORT_INTERVAL_MS);
5132
5191
  void reportCommands();
5133
5192
  commandsTimer = setInterval(reportCommands, exports.COMMAND_SCAN_INTERVAL_MS);
5193
+ // A desktop key takes a fitted window back within a second, not at the phone's next renew (5 s).
5194
+ paneFitTimer = setInterval(() => paneFitter.recheckAll(), PANE_FIT_RECHECK_MS);
5134
5195
  loopSampleAt = Date.now();
5135
5196
  loopMeterTimer = setInterval(() => {
5136
5197
  const now = Date.now();
@@ -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;