handmux 0.27.2 → 0.29.1

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.
Files changed (83) hide show
  1. package/bin/handmux.js +2 -2
  2. package/dist/bin/handmux.js +51 -21
  3. package/dist/connectors/bridgeClient.js +30 -14
  4. package/dist/connectors/claude/index.js +122 -25
  5. package/dist/hooks/handmux-notify.sh +14 -2
  6. package/dist/hooks/handmux-write.cjs +15 -1
  7. package/dist/package.json +19 -19
  8. package/dist/public/assets/index-BjnnFTLC.css +32 -0
  9. package/dist/public/assets/index-aBgNeovq.js +356 -0
  10. package/dist/public/index.html +2 -2
  11. package/dist/src/agent-runtime/bridge.js +63 -1
  12. package/dist/src/agent-runtime/bridgeTransport.js +31 -4
  13. package/dist/src/agent-runtime/bridgeTypes.js +2 -0
  14. package/dist/src/agent-runtime/builtinRuntime.js +10 -2
  15. package/dist/src/agent-runtime/conversation.js +3 -3
  16. package/dist/src/agent-runtime/conversationStore.js +2 -2
  17. package/dist/src/agent-runtime/inbox.js +20 -2
  18. package/dist/src/agent-runtime/run.js +5 -1
  19. package/dist/src/agent-runtime/runtime.js +1 -0
  20. package/dist/src/agent-runtime/tmuxRuntime.js +27 -3
  21. package/dist/src/agents/claudeConversation.js +69 -6
  22. package/dist/src/agents/claudeLocalCommand.js +54 -0
  23. package/dist/src/agents/claudeNativeTail.js +287 -0
  24. package/dist/src/agents/claudePaneInput.js +47 -0
  25. package/dist/src/agents/codexConversation.js +46 -0
  26. package/dist/src/agents/piInboxBridge.js +64 -5
  27. package/dist/src/agents/processIdentity.js +23 -7
  28. package/dist/src/apiAccountProviders.js +5 -4
  29. package/dist/src/browser/bootstrap.js +5 -0
  30. package/dist/src/browser/credentials.js +68 -0
  31. package/dist/src/browser/manager.js +40 -5
  32. package/dist/src/browser/publicProxy.js +15 -5
  33. package/dist/src/browser/routes.js +7 -0
  34. package/dist/src/browser/workerClient.js +174 -9
  35. package/dist/src/browser/workerServer.js +13 -1
  36. package/dist/src/claudeEvents.js +94 -38
  37. package/dist/src/cli/authCmd.js +202 -0
  38. package/dist/src/cli/authDefaults.js +18 -0
  39. package/dist/src/cli/claudeHooks.js +3 -3
  40. package/dist/src/cli/hookScaffold.js +5 -1
  41. package/dist/src/cli/i18n/en.js +66 -11
  42. package/dist/src/cli/i18n/zh.js +66 -11
  43. package/dist/src/cli/options.js +7 -13
  44. package/dist/src/cli/pushCmd.js +24 -11
  45. package/dist/src/cli/setupModel.js +28 -6
  46. package/dist/src/cli/setupWizard.js +47 -25
  47. package/dist/src/cli/shortcutEditor.js +20 -2
  48. package/dist/src/cli/supervisor.js +5 -7
  49. package/dist/src/codexAppServer.js +25 -15
  50. package/dist/src/codexTranscriptParse.js +40 -1
  51. package/dist/src/codexUsageSnapshot.js +1 -1
  52. package/dist/src/deviceAccess.js +134 -0
  53. package/dist/src/deviceAuth/control.js +154 -0
  54. package/dist/src/deviceAuth/http.js +327 -0
  55. package/dist/src/deviceAuth/service.js +626 -0
  56. package/dist/src/docs.js +3 -0
  57. package/dist/src/httpApi.js +8 -3
  58. package/dist/src/paneInput.js +16 -2
  59. package/dist/src/previewServer.js +26 -2
  60. package/dist/src/previews.js +37 -13
  61. package/dist/src/projectTask/backup.js +28 -0
  62. package/dist/src/projectTask/lock.js +1 -1
  63. package/dist/src/projectTask/migrations.js +30 -1
  64. package/dist/src/projectTask/runtime.js +7 -1
  65. package/dist/src/projectTask/schema.js +2 -2
  66. package/dist/src/push.js +48 -13
  67. package/dist/src/requestAuthority.js +8 -0
  68. package/dist/src/requestOrigin.js +62 -0
  69. package/dist/src/routes/agents.js +3 -1
  70. package/dist/src/routes/files.js +2 -0
  71. package/dist/src/routes/previews.js +9 -3
  72. package/dist/src/routes/push.js +55 -58
  73. package/dist/src/routes/system.js +4 -4
  74. package/dist/src/routes/terminal.js +9 -1
  75. package/dist/src/server.js +88 -12
  76. package/dist/src/terminalStream.js +90 -5
  77. package/dist/src/tmux/commands.js +2 -0
  78. package/dist/src/transcriptParse.js +4 -1
  79. package/dist/src/workspace/environment.js +14 -5
  80. package/dist/src/workspace/runtime.js +16 -1
  81. package/package.json +5 -5
  82. package/dist/public/assets/index-B4pKf3IW.js +0 -356
  83. package/dist/public/assets/index-Cdrh2CUp.css +0 -32
package/bin/handmux.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  // Stable JavaScript launcher. Keep this file free of product imports until the runtime version is known:
3
3
  // Node <22.16 lacks the native sqlite backup API required by the Project Task store, so importing dist first
4
- // would fail with an opaque module error before HandMux can tell the user what to do.
4
+ // would fail with an opaque module error before handmux can tell the user what to do.
5
5
  import { nodeVersionSupport } from './node-version.js';
6
6
 
7
7
  const support = nodeVersionSupport(process.versions.node);
8
8
  if (!support.ok) {
9
9
  console.error(
10
10
  `[handmux] Node.js ${support.actual} is not supported. `
11
- + `Install Node.js ${support.minimum} or newer, then run HandMux again: https://nodejs.org/`,
11
+ + `Install Node.js ${support.minimum} or newer, then run handmux again: https://nodejs.org/`,
12
12
  );
13
13
  process.exitCode = 1;
14
14
  } else {
@@ -2,9 +2,8 @@
2
2
  // handmux CLI implementation — compiled before development, tests, packing and publishing.
3
3
  //
4
4
  // The whole config story is two doors:
5
- // handmux start — just run it. No config needed: defaults to `none` (LAN-only), auto-generates a
6
- // token, prints the (token-free) URL + a QR of it, and the token on its own line. Flags
7
- // let you try variations for one run (e.g. --tunnel cloudflare).
5
+ // handmux start — just run it. New users default to `none` (LAN-only) with Token + trusted-device
6
+ // authentication. Prints the URL and QR. Flags let you try variations for one run.
8
7
  // handmux setup — the one place to configure persistently. Interactive; writes ~/.handmux/config.json
9
8
  // (name, tunnel, push, voice). Re-run it to change anything.
10
9
  // `start` reads that file; with no file it uses defaults. Precedence: flag > file > env (HANDMUX_*) >
@@ -15,6 +14,7 @@
15
14
  // `cloudflare-named` (stable URL on your own Cloudflare domain) · `ssh` (reverse-forward to your own
16
15
  // server via `tunlite run`). `handmux setup` wires any of these up interactively.
17
16
  import fs from 'node:fs';
17
+ import { connectAuthControl } from '../src/deviceAuth/control.js';
18
18
  import path from 'node:path';
19
19
  import { spawn, spawnSync } from 'node:child_process';
20
20
  import { createInterface } from 'node:readline';
@@ -22,14 +22,15 @@ import { fileURLToPath } from 'node:url';
22
22
  import { homedir } from 'node:os';
23
23
  import { createRequire } from 'node:module';
24
24
  import { parseArgs, resolveConfig, explainConfig } from '../src/cli/options.js';
25
+ import { installationAuthDefaults, tokenWarning } from '../src/cli/authDefaults.js';
25
26
  import { renderCompactQr } from '../src/cli/qr.js';
26
- import { supervise, bareUrl, publicUrlWithToken } from '../src/cli/supervisor.js';
27
+ import { supervise, bareUrl } from '../src/cli/supervisor.js';
27
28
  import { resolveCloudflared } from '../src/cli/cloudflared.js';
28
29
  import { resolveTunlite, checkSshAuth } from '../src/cli/tunlite.js';
29
30
  import { resolveNatapp, resolveCpolar } from '../src/cli/tunnelClients.js';
30
31
  import { installService, uninstallService, isServiceInstalled, stopService } from '../src/cli/service.js';
31
32
  import { checkTmux, MIN_TMUX, tmuxInstallHint } from '../src/cli/tmuxVersion.js';
32
- import { readState, clearState, isAlive, acquireLifecycleLock, logPath, configPath, claudeStatePath, } from '../src/cli/state.js';
33
+ import { readState, clearState, writeSupervisorConfig, isAlive, acquireLifecycleLock, logPath, configPath, claudeStatePath, } from '../src/cli/state.js';
33
34
  import { parseSupervisorConfig, readSupervisorLaunchConfig, supervisorLaunchArgs, } from '../src/cli/supervisorLaunch.js';
34
35
  import { scanSupervisorPids, terminateSupervisorPids } from '../src/cli/supervisorProcesses.js';
35
36
  import { runSetup } from '../src/cli/setupWizard.js';
@@ -42,6 +43,7 @@ import { probe } from '../src/cli/probe.js';
42
43
  import { notifyUpdate, runUpdateCheck, isBrewInstall, PKG_NAME } from '../src/cli/updateCheck.js';
43
44
  import { t, initLocale } from '../src/cli/i18n/index.js';
44
45
  import { runPush } from '../src/cli/pushCmd.js';
46
+ import { runAuthCommand } from '../src/cli/authCmd.js';
45
47
  import { runWorkspaceCommand } from '../src/cli/workspaceCmd.js';
46
48
  import { runManagedCodexProcess } from '../src/cli/codexManaged.js';
47
49
  import { launchErrorCode, launchRawAgent, rawAgentInvocation, } from '../src/cli/agentLauncher.js';
@@ -147,10 +149,7 @@ function resolveFileConfig() {
147
149
  function describeConfig(p) {
148
150
  return p || t('config.none');
149
151
  }
150
- // Which user-visible settings THIS run would use differ from what's already running (from state.json)?
151
- // Kept to the two people actually re-run `start` to change — the tunnel and the port; each row is ready to
152
- // drop straight into the `start.running.changedRow` message ({key, from, to}). Only compares fields the
153
- // running state actually recorded, so an older state.json can't manufacture phantom diffs.
152
+ // Which user-visible connection settings THIS run would use differ from what's already running (from state.json)?
154
153
  function configChanges(cfg, st) {
155
154
  const out = [];
156
155
  for (const key of ['tunnel', 'port']) {
@@ -205,11 +204,16 @@ async function preflightNgrok(cfg) {
205
204
  }
206
205
  async function main() {
207
206
  switch (command) {
208
- case 'start': return withLifecycleLock(start);
207
+ case 'start': return withLifecycleLock(() => start());
209
208
  case 'open': return openCmd();
210
209
  case 'stop': return withLifecycleLock(stopAndWait);
211
- case 'restart': return withLifecycleLock(async () => { if (!await stopAndWait())
212
- return; return start(); });
210
+ case 'restart': return withLifecycleLock(async () => {
211
+ const { path: cfgPath } = resolveFileConfig();
212
+ const authDefaults = installationAuthDefaults(HOME, cfgPath ?? configPath(HOME), flags);
213
+ if (!await stopAndWait())
214
+ return;
215
+ return start(authDefaults);
216
+ });
213
217
  case 'status':
214
218
  await status();
215
219
  process.exit(process.exitCode || 0);
@@ -217,6 +221,9 @@ async function main() {
217
221
  case 'push':
218
222
  process.exitCode = await pushCmd();
219
223
  return;
224
+ case 'auth':
225
+ process.exitCode = await runAuthCommand({ argv: process.argv.slice(3), home: HOME });
226
+ return;
220
227
  case 'restore':
221
228
  process.exitCode = await runWorkspaceCommand({ flags, positionals, unknownShortFlags, home: HOME });
222
229
  return;
@@ -225,7 +232,9 @@ async function main() {
225
232
  case 'shortcuts': return shortcutsCmd();
226
233
  case 'hooks': return hooksCmd();
227
234
  case 'agent': return agentCmd();
228
- case 'service': return withLifecycleLock(serviceCmd);
235
+ case 'service': return withLifecycleLock(async () => {
236
+ return serviceCmd();
237
+ });
229
238
  case 'update':
230
239
  case 'upgrade': return updateCmd();
231
240
  case '__supervise': return runSupervise();
@@ -291,12 +300,12 @@ function updateCmd() {
291
300
  function maybeNotifyUpdate() {
292
301
  notifyUpdate(HOME, { version: currentVersion(), selfPath: SELF_REAL });
293
302
  }
294
- async function start() {
303
+ async function start(authDefaults) {
295
304
  const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
296
305
  console.log(t('config.loaded', { path: describeConfig(cfgPath) }));
297
306
  let cfg;
298
307
  try {
299
- cfg = resolveConfig(flags, fileCfg);
308
+ cfg = resolveConfig(flags, fileCfg, process.env, undefined, authDefaults ?? installationAuthDefaults(HOME, cfgPath ?? configPath(HOME), flags));
300
309
  }
301
310
  catch (error) {
302
311
  console.error(t('err.generic', { msg: errorMessage(error) }));
@@ -349,7 +358,7 @@ async function start() {
349
358
  if (process.stdin.isTTY && await confirm(t('start.running.switchQ'))) {
350
359
  if (!await stopAndWait())
351
360
  return;
352
- return start();
361
+ return start({ token: cfg.token });
353
362
  }
354
363
  console.log(t('start.running.hint'));
355
364
  await printAccess(existing);
@@ -403,6 +412,8 @@ async function start() {
403
412
  }
404
413
  }
405
414
  if (cfg.foreground) {
415
+ // Match background launches: preserve the mode and token even when stop removes state.json.
416
+ writeSupervisorConfig(cfg, HOME);
406
417
  supervise(cfg, { home: HOME });
407
418
  console.log(t('start.foreground', { tunnel: cfg.tunnel, port: cfg.port }));
408
419
  await waitAndPrint(false);
@@ -568,7 +579,7 @@ async function serviceInstall() {
568
579
  console.log(t('config.loaded', { path: describeConfig(cfgPath) }));
569
580
  let cfg;
570
581
  try {
571
- cfg = resolveConfig(flags, fileCfg);
582
+ cfg = resolveConfig(flags, fileCfg, process.env, undefined, installationAuthDefaults(HOME, cfgPath ?? configPath(HOME), flags));
572
583
  }
573
584
  catch (error) {
574
585
  console.error(t('err.generic', { msg: errorMessage(error) }));
@@ -894,7 +905,7 @@ function configCmd() {
894
905
  const { path: cfgPath, cfg: fileCfg } = resolveFileConfig();
895
906
  let rows;
896
907
  try {
897
- rows = explainConfig(flags, fileCfg, cfgPath);
908
+ rows = explainConfig(flags, fileCfg, cfgPath, process.env, installationAuthDefaults(HOME, cfgPath ?? configPath(HOME), flags));
898
909
  }
899
910
  catch (error) {
900
911
  console.error(t('err.generic', { msg: errorMessage(error) }));
@@ -939,6 +950,20 @@ async function printAccess(st) {
939
950
  }
940
951
  const publicUrl = st.publicUrl ?? null;
941
952
  const localUrl = st.localUrl ?? null;
953
+ let devices = [];
954
+ let deviceProtection = null;
955
+ try {
956
+ const control = await connectAuthControl(HOME);
957
+ try {
958
+ const status = await control.request({ op: 'device-status' });
959
+ devices = status.devices;
960
+ deviceProtection = status.enabled === true;
961
+ }
962
+ finally {
963
+ control.close();
964
+ }
965
+ }
966
+ catch { /* The server may still be starting; the token is printed from the resolved state below. */ }
942
967
  const token = st.token ?? '';
943
968
  const scan = bareUrl(publicUrl);
944
969
  console.log('');
@@ -947,10 +972,15 @@ async function printAccess(st) {
947
972
  if (st.tunnel === 'none' && st.lanUrl)
948
973
  console.log(t('access.lan', { url: bareUrl(st.lanUrl) }));
949
974
  console.log(t('access.local', { url: bareUrl(localUrl) }));
975
+ if (deviceProtection === true) {
976
+ console.log(t('auth.access'));
977
+ if (!devices.some(d => d.status === 'active'))
978
+ console.log(t('auth.noDevices'));
979
+ }
950
980
  console.log(t('access.token', { token }));
951
- // The QR carries the token so a phone scan signs in one-tap; the PRINTED links above stay token-free
952
- // (safe to screenshot/share — paste the token shown above to sign in there).
953
- await maybeQr(publicUrl && token ? publicUrlWithToken(publicUrl, token) : scan, st);
981
+ if (deviceProtection === false)
982
+ console.log(tokenWarning(t('auth.warning')));
983
+ await maybeQr(scan || bareUrl(st.lanUrl ?? localUrl), st);
954
984
  if (publicUrl && st.tunnel !== 'none') {
955
985
  const ok = await probe(publicUrl);
956
986
  if (ok)
@@ -69,9 +69,11 @@ export class LocalConnectorBridgeClient {
69
69
  #retryTimer;
70
70
  #retryAttempt = 0;
71
71
  #flushTail = Promise.resolve();
72
+ #snapshotBeforeDurable;
72
73
  #started = false;
74
+ #restoredDurable = new Set();
73
75
  #closed = false;
74
- constructor({ adapterId, socketPath, credentialFile, stateFile, candidate, generation, connect = connectBridgeTransport, retryDelayMs = 250, maxRetryDelayMs = 5_000, maxEphemeralPerChannel = 256, logger = () => { }, }) {
76
+ constructor({ adapterId, socketPath, credentialFile, stateFile, candidate, generation, connect = connectBridgeTransport, retryDelayMs = 250, maxRetryDelayMs = 5_000, maxEphemeralPerChannel = 256, snapshotBeforeDurable = false, logger = () => { }, }) {
75
77
  if (!NAME_RE.test(adapterId) || !path.isAbsolute(socketPath)
76
78
  || !path.isAbsolute(credentialFile) || !path.isAbsolute(stateFile)
77
79
  || !candidate || !Number.isSafeInteger(retryDelayMs) || retryDelayMs <= 0
@@ -89,8 +91,11 @@ export class LocalConnectorBridgeClient {
89
91
  this.#maxRetryDelayMs = maxRetryDelayMs;
90
92
  this.#maxEphemeralPerChannel = maxEphemeralPerChannel;
91
93
  this.#logger = logger;
94
+ this.#snapshotBeforeDurable = snapshotBeforeDurable;
92
95
  this.#store = new PrivateStateStore(stateFile);
93
96
  this.#state = parseState(this.#store.read());
97
+ for (const item of this.#state.durable)
98
+ this.#restoredDurable.add(durableKey(item.channel, item.eventId));
94
99
  for (const [channel, value] of Object.entries(this.#state.snapshots)) {
95
100
  if (!NAME_RE.test(channel))
96
101
  continue;
@@ -170,6 +175,12 @@ export class LocalConnectorBridgeClient {
170
175
  waiters.add({ resolve, reject });
171
176
  });
172
177
  }
178
+ // Resume the exact payload loaded at startup, including one already ACKed during connection setup.
179
+ // Source adapters must not reinterpret an old durable ID into different wire bytes on upgrade.
180
+ resumePersistedDurable(channel, eventId) {
181
+ return this.#restoredDurable.has(durableKey(channel, eventId))
182
+ ? this.waitForDurableAck(channel, eventId) : null;
183
+ }
173
184
  waitForDurableDrain() {
174
185
  if (this.#closed)
175
186
  return Promise.reject(new Error('Connector Bridge client is closed'));
@@ -339,6 +350,8 @@ export class LocalConnectorBridgeClient {
339
350
  if (!connection || connection.signal.aborted || this.#closed)
340
351
  return;
341
352
  while (this.#state.durable.length) {
353
+ if (this.#snapshotBeforeDurable)
354
+ await this.#flushSnapshots(connection);
342
355
  const item = this.#state.durable[0];
343
356
  const receipt = await connection.channel(item.channel).publish({
344
357
  eventId: item.eventId, payload: item.payload,
@@ -353,19 +366,7 @@ export class LocalConnectorBridgeClient {
353
366
  }
354
367
  this.#resolveDurable(item.channel, item.eventId);
355
368
  }
356
- for (const channel of [...this.#dirtySnapshots]) {
357
- const entry = this.#snapshots.get(channel);
358
- if (!entry) {
359
- this.#dirtySnapshots.delete(channel);
360
- continue;
361
- }
362
- const revision = entry.revision;
363
- const receipt = await connection.channel(channel).setSnapshot(entry.value);
364
- if (!receipt.accepted)
365
- throw new Error(`Bridge snapshot rejected: ${receipt.reason ?? 'unknown'}`);
366
- if (this.#snapshots.get(channel)?.revision === revision)
367
- this.#dirtySnapshots.delete(channel);
368
- }
369
+ await this.#flushSnapshots(connection);
369
370
  for (const channel of [...this.#dropped]) {
370
371
  const receipt = await connection.channel(channel).publish({ payload: { type: 'stream.gap' } });
371
372
  if (!receipt.accepted)
@@ -382,4 +383,19 @@ export class LocalConnectorBridgeClient {
382
383
  }
383
384
  }
384
385
  }
386
+ async #flushSnapshots(connection) {
387
+ for (const channel of [...this.#dirtySnapshots]) {
388
+ const entry = this.#snapshots.get(channel);
389
+ if (!entry) {
390
+ this.#dirtySnapshots.delete(channel);
391
+ continue;
392
+ }
393
+ const revision = entry.revision;
394
+ const receipt = await connection.channel(channel).setSnapshot(entry.value);
395
+ if (!receipt.accepted)
396
+ throw new Error(`Bridge snapshot rejected: ${receipt.reason ?? 'unknown'}`);
397
+ if (this.#snapshots.get(channel)?.revision === revision)
398
+ this.#dirtySnapshots.delete(channel);
399
+ }
400
+ }
385
401
  }
@@ -2,7 +2,10 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { classifyClaude } from '../../src/agents/claude.js';
5
+ import { ClaudeNativeTailReader } from '../../src/agents/claudeNativeTail.js';
5
6
  import { LocalConnectorBridgeClient } from '../bridgeClient.js';
7
+ const DEFAULT_SPOOL_MAX_FILES = 2_000;
8
+ const DEFAULT_SPOOL_RETENTION_MS = 24 * 60 * 60_000;
6
9
  const EVENT_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$/;
7
10
  function isRecord(value) {
8
11
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -177,6 +180,7 @@ function looksLikeClaude(pane, foreground) {
177
180
  // Bridges the existing async Claude Hooks without ever blocking Claude itself. Hook subprocesses only
178
181
  // commit files; this coordinator owns socket lifetime, per-session replacement and durable acknowledgements.
179
182
  export class ClaudeHookBridgeConnector {
183
+ #nativeTail;
180
184
  #socketPath;
181
185
  #credentialFile;
182
186
  #stateDirectory;
@@ -185,24 +189,30 @@ export class ClaudeHookBridgeConnector {
185
189
  #panes;
186
190
  #process;
187
191
  #pollMs;
192
+ #spoolMaxFiles;
193
+ #spoolRetentionMs;
188
194
  #retryDelayMs;
189
195
  #maxRetryDelayMs;
190
196
  #logger;
191
197
  #createClient;
192
198
  #clients = new Map();
193
199
  #confirmedGaps = new Set();
200
+ #pendingAcks = new Map();
194
201
  #timer;
195
202
  #tail = Promise.resolve();
196
203
  #started = false;
197
204
  #closed = false;
198
- constructor({ socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory, panes, process, pollMs = 250, retryDelayMs = 100, maxRetryDelayMs = 5_000, logger = () => { }, createClient = (options) => new LocalConnectorBridgeClient(options), }) {
205
+ constructor({ nativeTail = new ClaudeNativeTailReader(), socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory, panes, process, pollMs = 250, retryDelayMs = 100, maxRetryDelayMs = 5_000, logger = () => { }, createClient = (options) => new LocalConnectorBridgeClient(options), spoolMaxFiles = DEFAULT_SPOOL_MAX_FILES, spoolRetentionMs = DEFAULT_SPOOL_RETENTION_MS, }) {
199
206
  if (![socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory].every(path.isAbsolute)
200
207
  || !panes || !process || !Number.isSafeInteger(pollMs) || pollMs < 50
201
208
  || !Number.isSafeInteger(retryDelayMs) || retryDelayMs <= 0
202
- || !Number.isSafeInteger(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs) {
209
+ || !Number.isSafeInteger(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs
210
+ || !Number.isSafeInteger(spoolMaxFiles) || spoolMaxFiles <= 0
211
+ || !Number.isSafeInteger(spoolRetentionMs) || spoolRetentionMs <= 0) {
203
212
  throw new TypeError('Claude Hook Bridge Connector requires private paths and Runtime identity sources');
204
213
  }
205
214
  this.#socketPath = socketPath;
215
+ this.#nativeTail = nativeTail;
206
216
  this.#credentialFile = credentialFile;
207
217
  this.#stateDirectory = stateDirectory;
208
218
  this.#hookStateFile = hookStateFile;
@@ -212,6 +222,8 @@ export class ClaudeHookBridgeConnector {
212
222
  this.#pollMs = pollMs;
213
223
  this.#retryDelayMs = retryDelayMs;
214
224
  this.#maxRetryDelayMs = maxRetryDelayMs;
225
+ this.#spoolMaxFiles = spoolMaxFiles;
226
+ this.#spoolRetentionMs = spoolRetentionMs;
215
227
  this.#logger = logger;
216
228
  this.#createClient = createClient;
217
229
  }
@@ -234,6 +246,7 @@ export class ClaudeHookBridgeConnector {
234
246
  if (this.#closed)
235
247
  return;
236
248
  this.#closed = true;
249
+ this.#nativeTail.clear();
237
250
  if (this.#timer)
238
251
  clearTimeout(this.#timer);
239
252
  this.#timer = undefined;
@@ -241,6 +254,10 @@ export class ClaudeHookBridgeConnector {
241
254
  tracked.client.close();
242
255
  this.#clients.clear();
243
256
  await this.#tail.catch(() => { });
257
+ for (const tracked of this.#clients.values())
258
+ tracked.client.close();
259
+ this.#clients.clear();
260
+ await Promise.allSettled([...this.#pendingAcks.values()].map((pending) => pending.promise));
244
261
  }
245
262
  #poll() {
246
263
  if (this.#closed)
@@ -267,6 +284,40 @@ export class ClaudeHookBridgeConnector {
267
284
  return [];
268
285
  }
269
286
  }
287
+ // Retention for Hook source files that never reach an acknowledgement: a pane that is gone, a client
288
+ // that never drains, or a durable publish that keeps failing leaves its file behind, and every poll
289
+ // re-reads it — an observed 170k files over nine days. Files are listed oldest-first, so a cap drops
290
+ // the stuck ones and an age window only ever expires files no one is waiting on. `gap-*` markers are
291
+ // not touched: they are bounded by pane/session and carry a degradation signal until confirmed.
292
+ #pruneSpool(files) {
293
+ const events = files.filter((file) => path.basename(file).startsWith('event-'));
294
+ if (events.length === 0)
295
+ return files;
296
+ const now = Date.now();
297
+ const dropped = new Set();
298
+ for (const [index, file] of events.entries()) {
299
+ const beyondCap = this.#spoolMaxFiles > 0 && index < events.length - this.#spoolMaxFiles;
300
+ let expired = false;
301
+ try {
302
+ expired = now - fs.statSync(file).mtimeMs > this.#spoolRetentionMs;
303
+ }
304
+ catch { /* removed */ }
305
+ if (beyondCap || expired)
306
+ dropped.add(file);
307
+ }
308
+ if (dropped.size === 0)
309
+ return files;
310
+ for (const file of dropped) {
311
+ try {
312
+ fs.unlinkSync(file);
313
+ }
314
+ catch { /* concurrently removed */ }
315
+ }
316
+ this.#logger?.('Dropped Claude Hook source files that could not be delivered', {
317
+ dropped: dropped.size, remaining: events.length - dropped.size,
318
+ });
319
+ return files.filter((file) => !dropped.has(file));
320
+ }
270
321
  async #reconcile() {
271
322
  if (this.#closed)
272
323
  return;
@@ -288,7 +339,7 @@ export class ClaudeHookBridgeConnector {
288
339
  return pending;
289
340
  };
290
341
  const events = [];
291
- for (const eventFile of this.#eventFiles()) {
342
+ for (const eventFile of this.#pruneSpool(this.#eventFiles())) {
292
343
  let event = null;
293
344
  try {
294
345
  const stat = fs.lstatSync(eventFile);
@@ -300,6 +351,20 @@ export class ClaudeHookBridgeConnector {
300
351
  events.push({ file: eventFile, event });
301
352
  }
302
353
  const state = readHookState(this.#hookStateFile);
354
+ for (const [paneId, pending] of this.#pendingAcks) {
355
+ const pane = paneMap.get(paneId);
356
+ const foreground = pane ? await identity(pane) : null;
357
+ const latest = state.get(paneId);
358
+ const latestSession = latest ? optionalSession(latest.payload) : undefined;
359
+ const replacedSession = foreground && latest?.process && matchesSourceProcess(latest.process, foreground)
360
+ && latestSession && pending.sessionId && latestSession !== pending.sessionId;
361
+ if (replacedSession || !pane || (foreground && (foreground.pid !== pending.process.pid
362
+ || (foreground.startedAt !== undefined && pending.process.startedAt !== undefined
363
+ && foreground.startedAt !== pending.process.startedAt)
364
+ || (foreground.tty !== undefined && pending.process.tty !== undefined
365
+ && foreground.tty !== pending.process.tty))))
366
+ pending.client.close();
367
+ }
303
368
  const gaps = new Map();
304
369
  const activeGapKeys = new Set(events.flatMap(({ file, event }) => (event.type === 'gap' ? [gapKey(file, event)] : [])));
305
370
  for (const key of this.#confirmedGaps) {
@@ -313,6 +378,8 @@ export class ClaudeHookBridgeConnector {
313
378
  const foreground = pane ? await identity(pane) : null;
314
379
  if (!pane || !foreground)
315
380
  continue;
381
+ if (!event.process && !looksLikeClaude(pane, foreground))
382
+ continue;
316
383
  if (!matchesSourceProcess(event.process, foreground)) {
317
384
  this.#logger('Discarding stale Claude Hook event after pane process replacement', {
318
385
  eventId: event.eventId,
@@ -337,16 +404,25 @@ export class ClaudeHookBridgeConnector {
337
404
  gaps.set(event.paneId, { event, key: gapKey(eventFile, event) });
338
405
  }
339
406
  const currentStatePanes = new Set();
407
+ const currentNativeSessions = new Set();
340
408
  const persistedSnapshots = new Map();
341
409
  for (const [paneId, row] of state) {
342
410
  const pane = paneMap.get(paneId);
343
411
  const foreground = pane ? await identity(pane) : null;
344
412
  if (!pane || !foreground)
345
413
  continue;
414
+ if (!row.process && !looksLikeClaude(pane, foreground))
415
+ continue;
346
416
  if (!matchesSourceProcess(row.process, foreground))
347
417
  continue;
348
418
  currentStatePanes.add(paneId);
419
+ // A cancelled old-session ACK settles asynchronously; switch clients once it has released the pane.
349
420
  const sessionId = optionalSession(row.payload);
421
+ if (sessionId && row.src !== 'end')
422
+ currentNativeSessions.add(sessionId);
423
+ const pending = this.#pendingAcks.get(paneId);
424
+ if (pending && pending.sessionId !== sessionId)
425
+ continue;
350
426
  const eventId = row.sequence === undefined ? undefined : `claude-hook-${row.sequence}`;
351
427
  const projection = projectClaudeHookInbox({
352
428
  ...(eventId === undefined ? {} : { eventId }),
@@ -354,13 +430,30 @@ export class ClaudeHookBridgeConnector {
354
430
  sourceOccurredAt: row.ts,
355
431
  payload: row.payload,
356
432
  });
357
- const snapshot = gaps.has(paneId) ? {
433
+ if (row.src !== 'end') {
434
+ const native = this.#nativeTail.read(row.payload, row.ts, Date.now(), row.process, row.src);
435
+ if (native.settled)
436
+ projection.snapshot = { availability: 'ready' };
437
+ else if (native.status === 'busy' || native.status === 'waiting') {
438
+ projection.snapshot = { availability: 'ready', current: {
439
+ state: native.status === 'busy' ? 'working' : 'waiting',
440
+ eventId: native.statusEventId,
441
+ } };
442
+ }
443
+ else if (native.status === 'unknown')
444
+ projection.snapshot = { availability: 'unavailable' };
445
+ }
446
+ const snapshot = gaps.has(paneId) && projection.snapshot.availability !== 'unavailable' ? {
358
447
  ...projection.snapshot,
359
448
  availability: 'degraded',
360
449
  message: 'Claude Hook event history is incomplete',
361
450
  } : projection.snapshot;
362
- persistedSnapshots.set(paneId, this.#client(paneId, sessionId, foreground).setSnapshot('inbox', snapshot));
451
+ persistedSnapshots.set(paneId, this.#client(paneId, sessionId, foreground).setSnapshot('inbox', {
452
+ ...snapshot,
453
+ ...(row.process && row.sequence !== undefined ? { sourceSequence: row.sequence } : {}),
454
+ }));
363
455
  }
456
+ this.#nativeTail.retain(currentNativeSessions);
364
457
  const liveClaudePanes = new Set(currentStatePanes);
365
458
  for (const pane of panes) {
366
459
  if (currentStatePanes.has(pane.paneId))
@@ -369,6 +462,8 @@ export class ClaudeHookBridgeConnector {
369
462
  if (!foreground || !looksLikeClaude(pane, foreground))
370
463
  continue;
371
464
  liveClaudePanes.add(pane.paneId);
465
+ if (this.#pendingAcks.has(pane.paneId))
466
+ continue;
372
467
  const gap = gaps.get(pane.paneId)?.event;
373
468
  const sessionId = gap ? optionalSession(gap.payload ?? {}, gap.sessionId) : undefined;
374
469
  persistedSnapshots.set(pane.paneId, this.#client(pane.paneId, sessionId, foreground).setSnapshot('inbox', gap ? {
@@ -384,6 +479,12 @@ export class ClaudeHookBridgeConnector {
384
479
  const foreground = pane ? await identity(pane) : null;
385
480
  if (!pane || !foreground)
386
481
  continue;
482
+ if (!event.process && !looksLikeClaude(pane, foreground))
483
+ continue;
484
+ // A disconnected/rejected pane must not suspend the shared poller or another pane's events.
485
+ // Keep one acknowledgement in flight per pane to retain its lifecycle ordering.
486
+ if (this.#pendingAcks.has(event.paneId))
487
+ continue;
387
488
  if (!matchesSourceProcess(event.process, foreground)) {
388
489
  this.#logger('Discarding stale Claude Hook event after pane process replacement', {
389
490
  eventId: event.eventId,
@@ -397,6 +498,13 @@ export class ClaudeHookBridgeConnector {
397
498
  }
398
499
  const payload = event.payload ?? {};
399
500
  const sessionId = optionalSession(payload, event.sessionId);
501
+ const latest = state.get(event.paneId);
502
+ const latestSession = latest ? optionalSession(latest.payload) : undefined;
503
+ // A current pane session must never be replaced just to replay another session's old queue.
504
+ // Retain that session's source and original durable payload for a legitimate future resume.
505
+ if (latest?.process && matchesSourceProcess(latest.process, foreground)
506
+ && latestSession && sessionId && latestSession !== sessionId)
507
+ continue;
400
508
  const client = this.#client(event.paneId, sessionId, foreground);
401
509
  if (event.type === 'gap' && persistedSnapshots.get(event.paneId) !== true)
402
510
  continue;
@@ -412,37 +520,25 @@ export class ClaudeHookBridgeConnector {
412
520
  }),
413
521
  payload,
414
522
  });
415
- let operation = projection.operation;
416
- const latest = state.get(event.paneId);
417
- const latestSession = latest ? optionalSession(latest.payload) : undefined;
418
- if (event.type === 'event' && operation.kind === 'set' && operation.state === 'waiting'
419
- && event.sequence !== undefined && latest?.sequence !== undefined
420
- && latest.sequence > event.sequence && latestSession === sessionId) {
421
- operation = {
422
- kind: 'superseded',
423
- eventId: event.eventId,
424
- ...(event.sourceOccurredAt === undefined ? {} : { sourceOccurredAt: event.sourceOccurredAt }),
425
- };
426
- }
427
- if (!client.publishDurable('inbox', event.eventId, operation)) {
523
+ const operation = projection.operation;
524
+ const restoredAck = client.resumePersistedDurable('inbox', event.eventId);
525
+ if (!restoredAck && !client.publishDurable('inbox', event.eventId, operation)) {
428
526
  this.#logger('Claude Hook event remains queued because Connector durable state could not be persisted', {
429
527
  eventId: event.eventId,
430
528
  paneId: event.paneId,
431
529
  });
432
530
  continue;
433
531
  }
434
- try {
435
- await client.waitForDurableAck('inbox', event.eventId);
532
+ const pending = (restoredAck ?? client.waitForDurableAck('inbox', event.eventId)).then(() => {
436
533
  if (event.type === 'gap' && gap)
437
534
  this.#confirmedGaps.add(gap.key);
438
535
  else
439
536
  fs.unlinkSync(eventFile);
440
- }
441
- catch (error) {
537
+ }).catch((error) => {
442
538
  if (!this.#closed)
443
- throw error;
444
- return;
445
- }
539
+ this.#logger('Claude Hook acknowledgement interrupted; source event remains queued', error);
540
+ }).finally(() => { this.#pendingAcks.delete(event.paneId); });
541
+ this.#pendingAcks.set(event.paneId, { promise: pending, client, process: foreground, sessionId });
446
542
  }
447
543
  for (const [paneId, tracked] of [...this.#clients]) {
448
544
  if (liveClaudePanes.has(paneId))
@@ -460,6 +556,7 @@ export class ClaudeHookBridgeConnector {
460
556
  const stateFile = path.join(this.#stateDirectory, `${digest(nextSignature)}.json`);
461
557
  const client = this.#createClient({
462
558
  adapterId: 'claude',
559
+ snapshotBeforeDurable: true,
463
560
  socketPath: this.#socketPath,
464
561
  credentialFile: this.#credentialFile,
465
562
  stateFile,
@@ -53,8 +53,20 @@ if [ -z "$CLAUDE_PID" ]; then
53
53
  done
54
54
  fi
55
55
  if [ -n "$CLAUDE_PID" ]; then
56
- # `lstart` follows the process locale on macOS; force C so standalone Node can parse it consistently.
57
- CLAUDE_STARTED_AT=$(LC_ALL=C ps -p "$CLAUDE_PID" -o lstart= 2>/dev/null || printf '')
56
+ # Start time in the same form the server reads (processStartedAt in tmuxRuntime.ts): on Linux the raw
57
+ # procfs start tick, on macOS the `lstart` string. A wall-clock `lstart` moves whenever the host steps
58
+ # the guest clock (WSL2), which makes one live process look like several.
59
+ case "$(uname -s)" in
60
+ Linux)
61
+ # comm may contain spaces/parentheses: drop "pid (comm) " first, then starttime is field 20.
62
+ CLAUDE_STARTED_AT=$(sed 's/.*) //' "/proc/$CLAUDE_PID/stat" 2>/dev/null \
63
+ | awk '{ print $20 }' 2>/dev/null || printf '')
64
+ ;;
65
+ *)
66
+ # `lstart` follows the process locale on macOS; force C so standalone Node can parse it consistently.
67
+ CLAUDE_STARTED_AT=$(LC_ALL=C ps -p "$CLAUDE_PID" -o lstart= 2>/dev/null || printf '')
68
+ ;;
69
+ esac
58
70
  CLAUDE_TTY=$(ps -p "$CLAUDE_PID" -o tty= 2>/dev/null || printf '')
59
71
  fi
60
72
  # payload 经 stdin 原样流给 node(不在 shell 里转义,避免坏数据);pane 含 '%' 直接进 JSON 字段,
@@ -27,7 +27,10 @@ if (!file || !pane || !src) process.exit(0);
27
27
 
28
28
  function processFingerprint() {
29
29
  const pid = Number(String(claudePid).trim());
30
- const startedAt = Date.parse(String(claudeStartedAt).trim());
30
+ // The Hook reports the platform's start value (see handmux-notify.sh): a raw procfs tick on Linux, an
31
+ // lstart string on macOS. A digit-only value stays numeric — Date.parse would make it NaN.
32
+ const rawStartedAt = String(claudeStartedAt).trim();
33
+ const startedAt = /^\d+$/.test(rawStartedAt) ? Number(rawStartedAt) : Date.parse(rawStartedAt);
31
34
  const rawTty = String(claudeTty).trim();
32
35
  if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isFinite(startedAt)
33
36
  || !rawTty || rawTty === '?' || rawTty === '??') return undefined;
@@ -213,6 +216,17 @@ function update() {
213
216
  // stay latched at 进行中 forever; treat it as a soft end and clear the pane.
214
217
  if (prevSrc === 'prompt' || prevSrc === 'resume') { delete obj[pane]; }
215
218
  else applied = false; // resting → drop without writing
219
+ } else if (src === 'start' && payload?.source === 'compact' && prevSrc === 'compact'
220
+ && typeof payload.prompt_id === 'string' && payload.prompt_id.length > 0
221
+ && payload.prompt_id === previous?.payload?.prompt_id
222
+ && typeof payload.session_id === 'string' && payload.session_id.length > 0
223
+ && payload.session_id === previous?.payload?.session_id
224
+ && sameSourceProcess(previous)
225
+ && Number(ts) > 0 && Number(ts) <= previous.ts) {
226
+ // Native Claude invokes SessionStart(compact) before PostCompact, but async Hook processes can
227
+ // finish out of order. Do not replace the completed state with an earlier-started Hook from that
228
+ // same prompt. Timestamp + prompt/session scope keeps later independent compactions intact.
229
+ applied = false;
216
230
  } else if (src === 'notify' && payload?.notification_type === 'permission_prompt'
217
231
  && prevSrc === 'permreq'
218
232
  && sameSourceProcess(previous)