handmux 0.27.2 → 0.29.0

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 (78) 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 +78 -23
  5. package/dist/hooks/handmux-write.cjs +11 -0
  6. package/dist/package.json +13 -13
  7. package/dist/public/assets/index-BjnnFTLC.css +32 -0
  8. package/dist/public/assets/index-D0tDAqO-.js +356 -0
  9. package/dist/public/index.html +2 -2
  10. package/dist/src/agent-runtime/bridgeTransport.js +31 -4
  11. package/dist/src/agent-runtime/builtinRuntime.js +10 -2
  12. package/dist/src/agent-runtime/conversation.js +3 -3
  13. package/dist/src/agent-runtime/conversationStore.js +2 -2
  14. package/dist/src/agent-runtime/inbox.js +20 -2
  15. package/dist/src/agent-runtime/run.js +5 -1
  16. package/dist/src/agents/claudeConversation.js +69 -6
  17. package/dist/src/agents/claudeLocalCommand.js +54 -0
  18. package/dist/src/agents/claudeNativeTail.js +271 -0
  19. package/dist/src/agents/claudePaneInput.js +47 -0
  20. package/dist/src/agents/codexConversation.js +46 -0
  21. package/dist/src/agents/piInboxBridge.js +64 -5
  22. package/dist/src/agents/processIdentity.js +13 -6
  23. package/dist/src/apiAccountProviders.js +5 -4
  24. package/dist/src/browser/bootstrap.js +5 -0
  25. package/dist/src/browser/credentials.js +68 -0
  26. package/dist/src/browser/manager.js +40 -5
  27. package/dist/src/browser/publicProxy.js +15 -5
  28. package/dist/src/browser/routes.js +7 -0
  29. package/dist/src/browser/workerClient.js +174 -9
  30. package/dist/src/browser/workerServer.js +13 -1
  31. package/dist/src/claudeEvents.js +94 -38
  32. package/dist/src/cli/authCmd.js +202 -0
  33. package/dist/src/cli/authDefaults.js +18 -0
  34. package/dist/src/cli/claudeHooks.js +3 -3
  35. package/dist/src/cli/hookScaffold.js +5 -1
  36. package/dist/src/cli/i18n/en.js +66 -11
  37. package/dist/src/cli/i18n/zh.js +66 -11
  38. package/dist/src/cli/options.js +7 -13
  39. package/dist/src/cli/pushCmd.js +24 -11
  40. package/dist/src/cli/setupModel.js +28 -6
  41. package/dist/src/cli/setupWizard.js +47 -25
  42. package/dist/src/cli/shortcutEditor.js +20 -2
  43. package/dist/src/cli/supervisor.js +5 -7
  44. package/dist/src/codexAppServer.js +25 -15
  45. package/dist/src/codexTranscriptParse.js +40 -1
  46. package/dist/src/codexUsageSnapshot.js +1 -1
  47. package/dist/src/deviceAccess.js +134 -0
  48. package/dist/src/deviceAuth/control.js +154 -0
  49. package/dist/src/deviceAuth/http.js +321 -0
  50. package/dist/src/deviceAuth/service.js +626 -0
  51. package/dist/src/docs.js +3 -0
  52. package/dist/src/httpApi.js +8 -3
  53. package/dist/src/paneInput.js +16 -2
  54. package/dist/src/previewServer.js +26 -2
  55. package/dist/src/previews.js +37 -13
  56. package/dist/src/projectTask/backup.js +28 -0
  57. package/dist/src/projectTask/lock.js +1 -1
  58. package/dist/src/projectTask/migrations.js +30 -1
  59. package/dist/src/projectTask/runtime.js +7 -1
  60. package/dist/src/projectTask/schema.js +2 -2
  61. package/dist/src/push.js +44 -13
  62. package/dist/src/requestAuthority.js +8 -0
  63. package/dist/src/requestOrigin.js +62 -0
  64. package/dist/src/routes/agents.js +3 -1
  65. package/dist/src/routes/files.js +2 -0
  66. package/dist/src/routes/previews.js +9 -3
  67. package/dist/src/routes/push.js +55 -58
  68. package/dist/src/routes/system.js +4 -4
  69. package/dist/src/routes/terminal.js +9 -1
  70. package/dist/src/server.js +88 -12
  71. package/dist/src/terminalStream.js +90 -5
  72. package/dist/src/tmux/commands.js +2 -0
  73. package/dist/src/transcriptParse.js +4 -1
  74. package/dist/src/workspace/environment.js +14 -5
  75. package/dist/src/workspace/runtime.js +16 -1
  76. package/package.json +4 -4
  77. package/dist/public/assets/index-B4pKf3IW.js +0 -356
  78. 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,6 +2,7 @@ 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';
6
7
  const EVENT_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$/;
7
8
  function isRecord(value) {
@@ -177,6 +178,7 @@ function looksLikeClaude(pane, foreground) {
177
178
  // Bridges the existing async Claude Hooks without ever blocking Claude itself. Hook subprocesses only
178
179
  // commit files; this coordinator owns socket lifetime, per-session replacement and durable acknowledgements.
179
180
  export class ClaudeHookBridgeConnector {
181
+ #nativeTail;
180
182
  #socketPath;
181
183
  #credentialFile;
182
184
  #stateDirectory;
@@ -191,11 +193,12 @@ export class ClaudeHookBridgeConnector {
191
193
  #createClient;
192
194
  #clients = new Map();
193
195
  #confirmedGaps = new Set();
196
+ #pendingAcks = new Map();
194
197
  #timer;
195
198
  #tail = Promise.resolve();
196
199
  #started = false;
197
200
  #closed = false;
198
- constructor({ socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory, panes, process, pollMs = 250, retryDelayMs = 100, maxRetryDelayMs = 5_000, logger = () => { }, createClient = (options) => new LocalConnectorBridgeClient(options), }) {
201
+ constructor({ nativeTail = new ClaudeNativeTailReader(), socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory, panes, process, pollMs = 250, retryDelayMs = 100, maxRetryDelayMs = 5_000, logger = () => { }, createClient = (options) => new LocalConnectorBridgeClient(options), }) {
199
202
  if (![socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory].every(path.isAbsolute)
200
203
  || !panes || !process || !Number.isSafeInteger(pollMs) || pollMs < 50
201
204
  || !Number.isSafeInteger(retryDelayMs) || retryDelayMs <= 0
@@ -203,6 +206,7 @@ export class ClaudeHookBridgeConnector {
203
206
  throw new TypeError('Claude Hook Bridge Connector requires private paths and Runtime identity sources');
204
207
  }
205
208
  this.#socketPath = socketPath;
209
+ this.#nativeTail = nativeTail;
206
210
  this.#credentialFile = credentialFile;
207
211
  this.#stateDirectory = stateDirectory;
208
212
  this.#hookStateFile = hookStateFile;
@@ -234,6 +238,7 @@ export class ClaudeHookBridgeConnector {
234
238
  if (this.#closed)
235
239
  return;
236
240
  this.#closed = true;
241
+ this.#nativeTail.clear();
237
242
  if (this.#timer)
238
243
  clearTimeout(this.#timer);
239
244
  this.#timer = undefined;
@@ -241,6 +246,10 @@ export class ClaudeHookBridgeConnector {
241
246
  tracked.client.close();
242
247
  this.#clients.clear();
243
248
  await this.#tail.catch(() => { });
249
+ for (const tracked of this.#clients.values())
250
+ tracked.client.close();
251
+ this.#clients.clear();
252
+ await Promise.allSettled([...this.#pendingAcks.values()].map((pending) => pending.promise));
244
253
  }
245
254
  #poll() {
246
255
  if (this.#closed)
@@ -300,6 +309,20 @@ export class ClaudeHookBridgeConnector {
300
309
  events.push({ file: eventFile, event });
301
310
  }
302
311
  const state = readHookState(this.#hookStateFile);
312
+ for (const [paneId, pending] of this.#pendingAcks) {
313
+ const pane = paneMap.get(paneId);
314
+ const foreground = pane ? await identity(pane) : null;
315
+ const latest = state.get(paneId);
316
+ const latestSession = latest ? optionalSession(latest.payload) : undefined;
317
+ const replacedSession = foreground && latest?.process && matchesSourceProcess(latest.process, foreground)
318
+ && latestSession && pending.sessionId && latestSession !== pending.sessionId;
319
+ if (replacedSession || !pane || (foreground && (foreground.pid !== pending.process.pid
320
+ || (foreground.startedAt !== undefined && pending.process.startedAt !== undefined
321
+ && foreground.startedAt !== pending.process.startedAt)
322
+ || (foreground.tty !== undefined && pending.process.tty !== undefined
323
+ && foreground.tty !== pending.process.tty))))
324
+ pending.client.close();
325
+ }
303
326
  const gaps = new Map();
304
327
  const activeGapKeys = new Set(events.flatMap(({ file, event }) => (event.type === 'gap' ? [gapKey(file, event)] : [])));
305
328
  for (const key of this.#confirmedGaps) {
@@ -313,6 +336,8 @@ export class ClaudeHookBridgeConnector {
313
336
  const foreground = pane ? await identity(pane) : null;
314
337
  if (!pane || !foreground)
315
338
  continue;
339
+ if (!event.process && !looksLikeClaude(pane, foreground))
340
+ continue;
316
341
  if (!matchesSourceProcess(event.process, foreground)) {
317
342
  this.#logger('Discarding stale Claude Hook event after pane process replacement', {
318
343
  eventId: event.eventId,
@@ -337,16 +362,25 @@ export class ClaudeHookBridgeConnector {
337
362
  gaps.set(event.paneId, { event, key: gapKey(eventFile, event) });
338
363
  }
339
364
  const currentStatePanes = new Set();
365
+ const currentNativeSessions = new Set();
340
366
  const persistedSnapshots = new Map();
341
367
  for (const [paneId, row] of state) {
342
368
  const pane = paneMap.get(paneId);
343
369
  const foreground = pane ? await identity(pane) : null;
344
370
  if (!pane || !foreground)
345
371
  continue;
372
+ if (!row.process && !looksLikeClaude(pane, foreground))
373
+ continue;
346
374
  if (!matchesSourceProcess(row.process, foreground))
347
375
  continue;
348
376
  currentStatePanes.add(paneId);
377
+ // A cancelled old-session ACK settles asynchronously; switch clients once it has released the pane.
349
378
  const sessionId = optionalSession(row.payload);
379
+ if (sessionId && row.src !== 'end')
380
+ currentNativeSessions.add(sessionId);
381
+ const pending = this.#pendingAcks.get(paneId);
382
+ if (pending && pending.sessionId !== sessionId)
383
+ continue;
350
384
  const eventId = row.sequence === undefined ? undefined : `claude-hook-${row.sequence}`;
351
385
  const projection = projectClaudeHookInbox({
352
386
  ...(eventId === undefined ? {} : { eventId }),
@@ -354,13 +388,30 @@ export class ClaudeHookBridgeConnector {
354
388
  sourceOccurredAt: row.ts,
355
389
  payload: row.payload,
356
390
  });
357
- const snapshot = gaps.has(paneId) ? {
391
+ if (row.src !== 'end') {
392
+ const native = this.#nativeTail.read(row.payload, row.ts, Date.now(), row.process, row.src);
393
+ if (native.settled)
394
+ projection.snapshot = { availability: 'ready' };
395
+ else if (native.status === 'busy' || native.status === 'waiting') {
396
+ projection.snapshot = { availability: 'ready', current: {
397
+ state: native.status === 'busy' ? 'working' : 'waiting',
398
+ eventId: native.statusEventId,
399
+ } };
400
+ }
401
+ else if (native.status === 'unknown')
402
+ projection.snapshot = { availability: 'unavailable' };
403
+ }
404
+ const snapshot = gaps.has(paneId) && projection.snapshot.availability !== 'unavailable' ? {
358
405
  ...projection.snapshot,
359
406
  availability: 'degraded',
360
407
  message: 'Claude Hook event history is incomplete',
361
408
  } : projection.snapshot;
362
- persistedSnapshots.set(paneId, this.#client(paneId, sessionId, foreground).setSnapshot('inbox', snapshot));
409
+ persistedSnapshots.set(paneId, this.#client(paneId, sessionId, foreground).setSnapshot('inbox', {
410
+ ...snapshot,
411
+ ...(row.process && row.sequence !== undefined ? { sourceSequence: row.sequence } : {}),
412
+ }));
363
413
  }
414
+ this.#nativeTail.retain(currentNativeSessions);
364
415
  const liveClaudePanes = new Set(currentStatePanes);
365
416
  for (const pane of panes) {
366
417
  if (currentStatePanes.has(pane.paneId))
@@ -369,6 +420,8 @@ export class ClaudeHookBridgeConnector {
369
420
  if (!foreground || !looksLikeClaude(pane, foreground))
370
421
  continue;
371
422
  liveClaudePanes.add(pane.paneId);
423
+ if (this.#pendingAcks.has(pane.paneId))
424
+ continue;
372
425
  const gap = gaps.get(pane.paneId)?.event;
373
426
  const sessionId = gap ? optionalSession(gap.payload ?? {}, gap.sessionId) : undefined;
374
427
  persistedSnapshots.set(pane.paneId, this.#client(pane.paneId, sessionId, foreground).setSnapshot('inbox', gap ? {
@@ -384,6 +437,12 @@ export class ClaudeHookBridgeConnector {
384
437
  const foreground = pane ? await identity(pane) : null;
385
438
  if (!pane || !foreground)
386
439
  continue;
440
+ if (!event.process && !looksLikeClaude(pane, foreground))
441
+ continue;
442
+ // A disconnected/rejected pane must not suspend the shared poller or another pane's events.
443
+ // Keep one acknowledgement in flight per pane to retain its lifecycle ordering.
444
+ if (this.#pendingAcks.has(event.paneId))
445
+ continue;
387
446
  if (!matchesSourceProcess(event.process, foreground)) {
388
447
  this.#logger('Discarding stale Claude Hook event after pane process replacement', {
389
448
  eventId: event.eventId,
@@ -397,6 +456,13 @@ export class ClaudeHookBridgeConnector {
397
456
  }
398
457
  const payload = event.payload ?? {};
399
458
  const sessionId = optionalSession(payload, event.sessionId);
459
+ const latest = state.get(event.paneId);
460
+ const latestSession = latest ? optionalSession(latest.payload) : undefined;
461
+ // A current pane session must never be replaced just to replay another session's old queue.
462
+ // Retain that session's source and original durable payload for a legitimate future resume.
463
+ if (latest?.process && matchesSourceProcess(latest.process, foreground)
464
+ && latestSession && sessionId && latestSession !== sessionId)
465
+ continue;
400
466
  const client = this.#client(event.paneId, sessionId, foreground);
401
467
  if (event.type === 'gap' && persistedSnapshots.get(event.paneId) !== true)
402
468
  continue;
@@ -412,37 +478,25 @@ export class ClaudeHookBridgeConnector {
412
478
  }),
413
479
  payload,
414
480
  });
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)) {
481
+ const operation = projection.operation;
482
+ const restoredAck = client.resumePersistedDurable('inbox', event.eventId);
483
+ if (!restoredAck && !client.publishDurable('inbox', event.eventId, operation)) {
428
484
  this.#logger('Claude Hook event remains queued because Connector durable state could not be persisted', {
429
485
  eventId: event.eventId,
430
486
  paneId: event.paneId,
431
487
  });
432
488
  continue;
433
489
  }
434
- try {
435
- await client.waitForDurableAck('inbox', event.eventId);
490
+ const pending = (restoredAck ?? client.waitForDurableAck('inbox', event.eventId)).then(() => {
436
491
  if (event.type === 'gap' && gap)
437
492
  this.#confirmedGaps.add(gap.key);
438
493
  else
439
494
  fs.unlinkSync(eventFile);
440
- }
441
- catch (error) {
495
+ }).catch((error) => {
442
496
  if (!this.#closed)
443
- throw error;
444
- return;
445
- }
497
+ this.#logger('Claude Hook acknowledgement interrupted; source event remains queued', error);
498
+ }).finally(() => { this.#pendingAcks.delete(event.paneId); });
499
+ this.#pendingAcks.set(event.paneId, { promise: pending, client, process: foreground, sessionId });
446
500
  }
447
501
  for (const [paneId, tracked] of [...this.#clients]) {
448
502
  if (liveClaudePanes.has(paneId))
@@ -460,6 +514,7 @@ export class ClaudeHookBridgeConnector {
460
514
  const stateFile = path.join(this.#stateDirectory, `${digest(nextSignature)}.json`);
461
515
  const client = this.#createClient({
462
516
  adapterId: 'claude',
517
+ snapshotBeforeDurable: true,
463
518
  socketPath: this.#socketPath,
464
519
  credentialFile: this.#credentialFile,
465
520
  stateFile,
@@ -213,6 +213,17 @@ function update() {
213
213
  // stay latched at 进行中 forever; treat it as a soft end and clear the pane.
214
214
  if (prevSrc === 'prompt' || prevSrc === 'resume') { delete obj[pane]; }
215
215
  else applied = false; // resting → drop without writing
216
+ } else if (src === 'start' && payload?.source === 'compact' && prevSrc === 'compact'
217
+ && typeof payload.prompt_id === 'string' && payload.prompt_id.length > 0
218
+ && payload.prompt_id === previous?.payload?.prompt_id
219
+ && typeof payload.session_id === 'string' && payload.session_id.length > 0
220
+ && payload.session_id === previous?.payload?.session_id
221
+ && sameSourceProcess(previous)
222
+ && Number(ts) > 0 && Number(ts) <= previous.ts) {
223
+ // Native Claude invokes SessionStart(compact) before PostCompact, but async Hook processes can
224
+ // finish out of order. Do not replace the completed state with an earlier-started Hook from that
225
+ // same prompt. Timestamp + prompt/session scope keeps later independent compactions intact.
226
+ applied = false;
216
227
  } else if (src === 'notify' && payload?.notification_type === 'permission_prompt'
217
228
  && prevSrc === 'permreq'
218
229
  && sameSourceProcess(previous)
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handmux",
3
- "version": "0.27.2",
3
+ "version": "0.29.0",
4
4
  "description": "A mobile vibe-coding cockpit — built on tmux: drive your live session, Claude Code / Codex — anything a terminal can run — from your phone.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -75,6 +75,18 @@
75
75
  "vitest": "^2.0.0"
76
76
  },
77
77
  "whatsNew": [
78
+ {
79
+ "version": "0.29.0",
80
+ "date": "2026-09-16",
81
+ "zh": "可信设备与可信地址保护",
82
+ "en": "Trusted device and address protection"
83
+ },
84
+ {
85
+ "version": "0.28.0",
86
+ "date": "2026-09-11",
87
+ "zh": "Claude 对话更可靠 · 工具图片预览",
88
+ "en": "More reliable Claude chat · tool image previews"
89
+ },
78
90
  {
79
91
  "version": "0.27.2",
80
92
  "date": "2026-09-08",
@@ -110,18 +122,6 @@
110
122
  "date": "2026-09-02",
111
123
  "zh": "修复 Codex 幽灵任务与对话空白",
112
124
  "en": "Fixed Codex ghost tasks and blank chats"
113
- },
114
- {
115
- "version": "0.25.0",
116
- "date": "2026-09-01",
117
- "zh": "Pi 原生接入 · 多 Agent 对话更可靠",
118
- "en": "Native Pi support · more reliable agent chat"
119
- },
120
- {
121
- "version": "0.24.0",
122
- "date": "2026-08-11",
123
- "zh": "Codex 任务进度与 Goal 原生上线",
124
- "en": "Native Codex task progress and Goals"
125
125
  }
126
126
  ]
127
127
  }