@hwip/cli 0.1.3 → 0.1.5

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.
@@ -24,6 +24,7 @@ import { type HwipConfig } from './config.js';
24
24
  import { type ShellAuditEntry } from './shell-audit.js';
25
25
  import { type KnownSession } from './session-registry.js';
26
26
  import { type AgentKind } from './remote-agents.js';
27
+ import type { ProviderNameSource } from './session-title.js';
27
28
  import { type TranscriptSource } from './transcript-tail.js';
28
29
  import { type AgentState } from './agent-state.js';
29
30
  import { type SealedFileMeta, type SealedV2 } from './crypto.js';
@@ -121,6 +122,12 @@ interface AgentSession {
121
122
  * word for both would put the ambiguity in the type.
122
123
  */
123
124
  providerSessionName?: string | null;
125
+ /**
126
+ * Who chose `providerSessionName`: a person in the agent (`'user'`) or the
127
+ * agent itself (`'auto'`). Omitted when the agent's store cannot say. The
128
+ * account setting that hides agent-made names hides `'auto'` only.
129
+ */
130
+ providerSessionNameSource?: ProviderNameSource;
124
131
  createdAt?: string;
125
132
  /**
126
133
  * Which run of the name this is (hwip docs/session-runs.md): tmux
@@ -222,6 +229,9 @@ export interface ReportedKnownSession {
222
229
  lastSeq?: number;
223
230
  /** The later tmux sessions the run went on in (Q35): the server ends another run's push window at their start. */
224
231
  tmuxRunIds?: string[];
232
+ /** The last name the agent gave the run, and who gave it (hwip docs/session-runs.md Q39): what the phone calls it once ended. */
233
+ providerSessionName?: string;
234
+ providerSessionNameSource?: ProviderNameSource;
225
235
  lastSeenAt: string;
226
236
  }
227
237
  /** Each live run's first seq, for the report (Q24). A run that has recorded nothing yet has none. */
@@ -274,6 +284,8 @@ export declare const resolveKeys: (keys: string[]) => string[] | null;
274
284
  export { invalidateTmuxSocketCache, silenceListenerLog, tmuxArgs };
275
285
  export { liveSessionNames, sessionExists, sessionLiveness };
276
286
  export { detectRemoteAgent, isSubagentSessionName, recordSubagentTranscripts, recordTargets, subagentTranscriptFor, tmuxTargetFor };
287
+ /** Test hook. */
288
+ export declare const resetPaneFitClaims: () => void;
277
289
  /**
278
290
  * Parse a `hwip-*` tmux session name into `{project, label}`. The wrapper
279
291
  * emits `hwip-<project>` and, with `--label <x>`, `hwip-<project>-<x>`; the
@@ -631,6 +643,27 @@ export interface ScreenHistorySnapshot {
631
643
  error?: string;
632
644
  capturedAt: string;
633
645
  }
646
+ /**
647
+ * What a sealed history page's envelope holds (as JSON): the page and every
648
+ * number a viewer pages by, bound to the request it answers and to the
649
+ * subscription it was sealed for. On a sealed stream the snapshot carries
650
+ * only `requestId` (for routing) beside this — a relay that hands an earlier
651
+ * page back under a new request, or rewrites where it sits, is caught on open.
652
+ */
653
+ export type SealedHistoryPage = {
654
+ sessionName: string;
655
+ requestId: string;
656
+ nonce?: string;
657
+ epoch: number;
658
+ before: number;
659
+ lines: number;
660
+ hasMore: boolean;
661
+ truncated: boolean;
662
+ /** The pane's history size when this page was captured, so the viewer can
663
+ * tell how much the history grew between its request and the capture. */
664
+ historySize: number;
665
+ content: string;
666
+ };
634
667
  /**
635
668
  * Answer a phone's request for one page of scrollback above the live window.
636
669
  * Returns true when the message was ours to answer, so the caller stops routing
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.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.continueRuns = 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.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 = exports.fileViewBytes = 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 = 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");
@@ -319,16 +319,18 @@ const knownSessionsToReport = (live, now = Date.now(), seqs = (0, run_seqs_js_1.
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
321
  ...(e.tmuxRunIds?.length ? { tmuxRunIds: e.tmuxRunIds } : {}),
322
+ ...(e.providerSessionName ? { providerSessionName: e.providerSessionName } : {}),
323
+ ...(e.providerSessionName && e.providerSessionNameSource ? { providerSessionNameSource: e.providerSessionNameSource } : {}),
322
324
  lastSeenAt: e.lastSeenAt,
323
325
  }));
324
326
  };
325
327
  exports.knownSessionsToReport = knownSessionsToReport;
326
- const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''}`).sort().join('|');
328
+ const knownSessionsFingerprint = (known) => known.map((k) => `${k.name}${k.agentKind}${k.runId ?? ''}${k.lastSeenAt}${k.firstSeq ?? ''}${k.lastSeq ?? ''}${k.tmuxRunIds?.join(',') ?? ''} ${k.providerSessionNameSource ?? ''} ${k.providerSessionName ?? ''}`).sort().join('|');
327
329
  exports.knownSessionsFingerprint = knownSessionsFingerprint;
328
330
  const sessionsFingerprint = (sessions) => sessions
329
331
  .map((s) => JSON.stringify([
330
332
  s.name, s.attached, s.agentKind, s.agentSessionId ?? null,
331
- s.project, s.label ?? null, s.providerSessionName ?? null, s.createdAt ?? null, s.runId ?? null, s.firstSeq ?? null,
333
+ s.project, s.label ?? null, s.providerSessionName ?? null, s.providerSessionNameSource ?? null, s.createdAt ?? null, s.runId ?? null, s.firstSeq ?? null,
332
334
  s.lastActivityAt ?? null, s.state ?? null,
333
335
  s.stateChangedAt ?? null, s.stateRuleId ?? null,
334
336
  s.blockedPrompt?.id ?? null, s.blockedPrompt?.at ?? null,
@@ -588,8 +590,13 @@ const worktreeCleanup = (0, listener_worktree_cleanup_js_1.createWorktreeCleanup
588
590
  notify: desktop_notify_js_1.notifyDesktop,
589
591
  tmux: (args, timeoutMs) => (0, worktree_js_1.tmuxRun)((0, tmux_socket_js_1.tmuxArgs)(args), timeoutMs),
590
592
  });
591
- /** Sizes a watched detached window to its viewer (pane-fit.ts). */
592
- const paneFitter = (0, pane_fit_js_1.createPaneFitter)(runTmux);
593
+ /** Sizes a watched window to its viewer while the phone is its latest user (pane-fit.ts). */
594
+ const paneFitter = (0, pane_fit_js_1.createPaneFitter)(runTmux, undefined, (0, pane_fit_js_1.macosLastKeyAt)());
595
+ /** Test hook. */
596
+ const resetPaneFitClaims = () => paneFitter.forgetClaims();
597
+ exports.resetPaneFitClaims = resetPaneFitClaims;
598
+ /** How often fitted windows are checked for a desktop that took them back; idle windows cost nothing. */
599
+ const PANE_FIT_RECHECK_MS = 1_000;
593
600
  // ─── Session inventory ──────────────────────────────────────────────
594
601
  /**
595
602
  * Parse a `hwip-*` tmux session name into `{project, label}`. The wrapper
@@ -1745,11 +1752,11 @@ const handleScreenHistoryRequest = (req, send) => {
1745
1752
  return reply({ error: 'capture_failed' });
1746
1753
  // Everything below this line the caller already has.
1747
1754
  const held = before;
1748
- // Scrolled past the oldest line. An empty page rather than an error — the
1749
- // view stops, and stopping is not a failure. Nothing to seal either.
1750
- if (depth <= held)
1751
- return reply({ before, content: '', lines: 0, hasMore: false });
1752
- const captured = capturePaneRange(req.sessionName, -(held + lines), -(held + 1));
1755
+ // Scrolled past the oldest line: an empty page rather than an error — the
1756
+ // view stops, and stopping is not a failure.
1757
+ const captured = depth <= held
1758
+ ? { content: '', truncated: false }
1759
+ : capturePaneRange(req.sessionName, -(held + lines), -(held + 1));
1753
1760
  if (!captured)
1754
1761
  return reply({ error: 'capture_failed' });
1755
1762
  const returned = countLines(captured.content);
@@ -1761,11 +1768,21 @@ const handleScreenHistoryRequest = (req, send) => {
1761
1768
  };
1762
1769
  if (!stream.subscriberPublicKey)
1763
1770
  return reply({ ...page, content: captured.content });
1764
- // Encrypted stream: the page rides ONLY inside the envelope, and a failed
1765
- // seal is an error rather than a plaintext page — the same rule the frame
1766
- // path follows when its encrypt fails.
1767
- (0, crypto_js_1.sealV2)('screen.history', captured.content, stream.subscriberPublicKey, req.sessionName)
1768
- .then((encrypted) => send({ ...base, ...page, encrypted }))
1771
+ // Encrypted stream: the page and its numbers ride ONLY inside the envelope
1772
+ // — the empty last page too, or a relay could end the paging with one — and
1773
+ // a failed seal is an error rather than a plaintext page, the same rule the
1774
+ // frame path follows when its encrypt fails.
1775
+ const sealed = {
1776
+ sessionName: req.sessionName,
1777
+ requestId: req.requestId,
1778
+ ...(stream.nonce ? { nonce: stream.nonce } : {}),
1779
+ epoch: stream.epoch,
1780
+ ...page,
1781
+ historySize: depth,
1782
+ content: captured.content,
1783
+ };
1784
+ (0, crypto_js_1.sealV2)('screen.history', JSON.stringify(sealed), stream.subscriberPublicKey, req.sessionName)
1785
+ .then((encrypted) => send({ ...base, encrypted }))
1769
1786
  .catch((err) => {
1770
1787
  (0, listener_log_js_1.log)(`⧉ history ${req.sessionName}: seal failed (${err instanceof Error ? err.message : err})`);
1771
1788
  send({ ...base, error: 'encrypt_failed' });
@@ -2076,9 +2093,9 @@ const leaseFor = (renewing) => (renewing ? exports.STREAM_LEASE_MS : STREAM_MAX_
2076
2093
  * Live mirrors, one per (session, viewing device): every viewer keeps the whole of the machinery a
2077
2094
  * single viewer had — its own capture loop state, lease, nonce, epoch and seq, sealed frames — so
2078
2095
  * two devices watching one pane do not take each other's stream over. A viewer with no device id
2079
- * (a web tab) shares the empty slot, one at a time, as before. The tmux pane is shared: its width
2080
- * is whatever the last viewer to send input or `cols` asked for (pane-fit.ts), and every viewer
2081
- * receives the pane at that width.
2096
+ * (a web tab) shares the empty slot, one at a time, as before. The tmux pane is shared: while the
2097
+ * phone is its latest user its width is whatever the last viewer to send input or `cols` asked for,
2098
+ * and a desktop client used since takes it back (pane-fit.ts); every viewer receives the pane at that width.
2082
2099
  */
2083
2100
  const activeStreams = new Map();
2084
2101
  const streamKey = (sessionName, deviceId) => `${sessionName}\u0000${typeof deviceId === 'string' ? deviceId : ''}`;
@@ -2607,6 +2624,9 @@ const handleStreamControl = (req, send) => {
2607
2624
  const renewTarget = (0, tmux_pane_js_1.tmuxTargetFor)(req.sessionName);
2608
2625
  if (renewTarget !== null && (colsChanged || streamsOf(req.sessionName).length === 1))
2609
2626
  paneFitter.fit(req.sessionName, renewTarget, req.cols);
2627
+ // Otherwise keep the width the pane has, but still notice a desktop client used since (pane-fit.ts).
2628
+ else
2629
+ paneFitter.recheck(req.sessionName);
2610
2630
  return true;
2611
2631
  }
2612
2632
  // Addressed to us but we hold no such stream: it was reaped (lost
@@ -2648,6 +2668,8 @@ const handleStreamControl = (req, send) => {
2648
2668
  return true;
2649
2669
  }
2650
2670
  const fitTarget = (0, tmux_pane_js_1.tmuxTargetFor)(sessionName);
2671
+ // Opening the mirror (or resubscribing) takes the width only from an idle desktop (pane-fit.ts).
2672
+ paneFitter.open(sessionName);
2651
2673
  if (fitTarget !== null)
2652
2674
  paneFitter.fit(sessionName, fitTarget, req.cols);
2653
2675
  let lastContent = null;
@@ -3047,6 +3069,23 @@ const deliverInput = (input) => {
3047
3069
  // Only a reason that does not depend on the text: this input may be sealed.
3048
3070
  if (!injected)
3049
3071
  input.send(streamErrorFrame(input.sessionName, 'input_rejected', input, reason && (0, command_refusal_js_1.isSessionStateRefusal)(reason) ? reason : undefined));
3072
+ else
3073
+ takeWidthBack(input.sessionName, input.deviceId);
3074
+ };
3075
+ /**
3076
+ * Typing from the phone makes it the session's latest user again (pane-fit.ts): a window a desktop
3077
+ * client took back is sized to the sender's width once more.
3078
+ */
3079
+ const takeWidthBack = (sessionName, deviceId) => {
3080
+ paneFitter.claim(sessionName);
3081
+ const viewer = activeStreams.get(streamKey(sessionName, deviceId)) ?? streamsOf(sessionName)[0];
3082
+ // Already at the sender's width: the claim is enough, and a keystroke costs no tmux round trip (renew rechecks).
3083
+ if (!viewer || paneFitter.fitted(sessionName, viewer.cols))
3084
+ return;
3085
+ const target = (0, tmux_pane_js_1.tmuxTargetFor)(sessionName);
3086
+ if (target === null)
3087
+ return;
3088
+ paneFitter.fit(sessionName, target, viewer.cols);
3050
3089
  };
3051
3090
  /** Ceiling on concurrent sender lanes. The lane key includes a relay-stamped
3052
3091
  * deviceId, but a relay older than the unconditional stamp lets a sender vary
@@ -3448,7 +3487,7 @@ const collectSessionsVerbose = () => {
3448
3487
  const agentSessionId = info.currentPath
3449
3488
  ? (main.agent.resolveSessionId?.(info.currentPath, info.panePid ?? undefined) ?? null)
3450
3489
  : null;
3451
- const providerSessionName = info.currentPath
3490
+ const providerName = info.currentPath
3452
3491
  ? (main.agent.resolveSessionName?.(info.currentPath, info.panePid ?? undefined) ?? null)
3453
3492
  : null;
3454
3493
  if (info.currentPath)
@@ -3466,7 +3505,8 @@ const collectSessionsVerbose = () => {
3466
3505
  agentSessionId,
3467
3506
  project: parsed.project,
3468
3507
  label: parsed.label,
3469
- providerSessionName,
3508
+ providerSessionName: providerName?.name ?? null,
3509
+ ...(providerName?.source ? { providerSessionNameSource: providerName.source } : {}),
3470
3510
  createdAt: epochToIso(group.created),
3471
3511
  runId: (0, exports.runIdOfEpoch)(group.created),
3472
3512
  lastActivityAt: epochToIso(group.activity),
@@ -3611,6 +3651,8 @@ const collectSessionsVerbose = () => {
3611
3651
  startedAt: s.createdAt,
3612
3652
  tmuxRunId: runIdOfIso(s.createdAt),
3613
3653
  agentSessionId: ownResumeId(s.name),
3654
+ providerSessionName: s.providerSessionName,
3655
+ providerSessionNameSource: s.providerSessionNameSource,
3614
3656
  })));
3615
3657
  return { sessions, rejected, targets, subagentTranscripts, recordable };
3616
3658
  };
@@ -4589,7 +4631,7 @@ const openSealedCommand = async (push, deps) => {
4589
4631
  return { text: opened.body ?? '', sender: senderKey, files: named, keys, ifPrompt, schedule };
4590
4632
  };
4591
4633
  /**
4592
- * A phone's request to make or remove a schedule (F8). The decisions are `applyPhoneSchedule`'s; this spends
4634
+ * A phone's request to make, change or remove a schedule (F8). The decisions are `applyPhoneSchedule`'s; this spends
4593
4635
  * the session's rate budget the way a typed command does and reports a refusal the way every command does.
4594
4636
  * Never types anything. A push no one addressed to this machine is not acted on: it changes files, and a
4595
4637
  * push.new reaches every listener of the account.
@@ -4935,6 +4977,10 @@ const streamSession = (wsUrl, apiKey) => {
4935
4977
  clearInterval(commandsTimer);
4936
4978
  commandsTimer = null;
4937
4979
  }
4980
+ if (paneFitTimer) {
4981
+ clearInterval(paneFitTimer);
4982
+ paneFitTimer = null;
4983
+ }
4938
4984
  if (connectTimer) {
4939
4985
  clearTimeout(connectTimer);
4940
4986
  connectTimer = null;
@@ -4981,6 +5027,7 @@ const streamSession = (wsUrl, apiKey) => {
4981
5027
  // rescan changes it. The rescan has its own slow timer — it reads the
4982
5028
  // filesystem, so it must never ride the 5 s session loop.
4983
5029
  let commandsTimer = null;
5030
+ let paneFitTimer = null;
4984
5031
  // Per connection, like the inventory gate above: `sock.send` returning
4985
5032
  // is not delivery, and a fingerprint that outlived the socket would let
4986
5033
  // one lost frame leave the server's catalog stale until the user next
@@ -5157,6 +5204,8 @@ const streamSession = (wsUrl, apiKey) => {
5157
5204
  sessionsTimer = setInterval(() => void reportSessions(), exports.SESSION_REPORT_INTERVAL_MS);
5158
5205
  void reportCommands();
5159
5206
  commandsTimer = setInterval(reportCommands, exports.COMMAND_SCAN_INTERVAL_MS);
5207
+ // A desktop key takes a fitted window back within a second, not at the phone's next renew (5 s).
5208
+ paneFitTimer = setInterval(() => paneFitter.recheckAll(), PANE_FIT_RECHECK_MS);
5160
5209
  loopSampleAt = Date.now();
5161
5210
  loopMeterTimer = setInterval(() => {
5162
5211
  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
  /**
@@ -36,10 +45,13 @@ export declare const MCP_SERVERS_ENTRY: {
36
45
  */
37
46
  export declare const OPENCODE_MCP_ENTRY: {
38
47
  type: string;
39
- command: ("mcp" | "hwip")[];
48
+ command: ("hwip" | "mcp")[];
40
49
  enabled: boolean;
41
50
  };
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