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.
- package/bin/handmux.js +2 -2
- package/dist/bin/handmux.js +51 -21
- package/dist/connectors/bridgeClient.js +30 -14
- package/dist/connectors/claude/index.js +78 -23
- package/dist/hooks/handmux-write.cjs +11 -0
- package/dist/package.json +13 -13
- package/dist/public/assets/index-BjnnFTLC.css +32 -0
- package/dist/public/assets/index-D0tDAqO-.js +356 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/bridgeTransport.js +31 -4
- package/dist/src/agent-runtime/builtinRuntime.js +10 -2
- package/dist/src/agent-runtime/conversation.js +3 -3
- package/dist/src/agent-runtime/conversationStore.js +2 -2
- package/dist/src/agent-runtime/inbox.js +20 -2
- package/dist/src/agent-runtime/run.js +5 -1
- package/dist/src/agents/claudeConversation.js +69 -6
- package/dist/src/agents/claudeLocalCommand.js +54 -0
- package/dist/src/agents/claudeNativeTail.js +271 -0
- package/dist/src/agents/claudePaneInput.js +47 -0
- package/dist/src/agents/codexConversation.js +46 -0
- package/dist/src/agents/piInboxBridge.js +64 -5
- package/dist/src/agents/processIdentity.js +13 -6
- package/dist/src/apiAccountProviders.js +5 -4
- package/dist/src/browser/bootstrap.js +5 -0
- package/dist/src/browser/credentials.js +68 -0
- package/dist/src/browser/manager.js +40 -5
- package/dist/src/browser/publicProxy.js +15 -5
- package/dist/src/browser/routes.js +7 -0
- package/dist/src/browser/workerClient.js +174 -9
- package/dist/src/browser/workerServer.js +13 -1
- package/dist/src/claudeEvents.js +94 -38
- package/dist/src/cli/authCmd.js +202 -0
- package/dist/src/cli/authDefaults.js +18 -0
- package/dist/src/cli/claudeHooks.js +3 -3
- package/dist/src/cli/hookScaffold.js +5 -1
- package/dist/src/cli/i18n/en.js +66 -11
- package/dist/src/cli/i18n/zh.js +66 -11
- package/dist/src/cli/options.js +7 -13
- package/dist/src/cli/pushCmd.js +24 -11
- package/dist/src/cli/setupModel.js +28 -6
- package/dist/src/cli/setupWizard.js +47 -25
- package/dist/src/cli/shortcutEditor.js +20 -2
- package/dist/src/cli/supervisor.js +5 -7
- package/dist/src/codexAppServer.js +25 -15
- package/dist/src/codexTranscriptParse.js +40 -1
- package/dist/src/codexUsageSnapshot.js +1 -1
- package/dist/src/deviceAccess.js +134 -0
- package/dist/src/deviceAuth/control.js +154 -0
- package/dist/src/deviceAuth/http.js +321 -0
- package/dist/src/deviceAuth/service.js +626 -0
- package/dist/src/docs.js +3 -0
- package/dist/src/httpApi.js +8 -3
- package/dist/src/paneInput.js +16 -2
- package/dist/src/previewServer.js +26 -2
- package/dist/src/previews.js +37 -13
- package/dist/src/projectTask/backup.js +28 -0
- package/dist/src/projectTask/lock.js +1 -1
- package/dist/src/projectTask/migrations.js +30 -1
- package/dist/src/projectTask/runtime.js +7 -1
- package/dist/src/projectTask/schema.js +2 -2
- package/dist/src/push.js +44 -13
- package/dist/src/requestAuthority.js +8 -0
- package/dist/src/requestOrigin.js +62 -0
- package/dist/src/routes/agents.js +3 -1
- package/dist/src/routes/files.js +2 -0
- package/dist/src/routes/previews.js +9 -3
- package/dist/src/routes/push.js +55 -58
- package/dist/src/routes/system.js +4 -4
- package/dist/src/routes/terminal.js +9 -1
- package/dist/src/server.js +88 -12
- package/dist/src/terminalStream.js +90 -5
- package/dist/src/tmux/commands.js +2 -0
- package/dist/src/transcriptParse.js +4 -1
- package/dist/src/workspace/environment.js +14 -5
- package/dist/src/workspace/runtime.js +16 -1
- package/package.json +4 -4
- package/dist/public/assets/index-B4pKf3IW.js +0 -356
- 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
|
|
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
|
|
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 {
|
package/dist/bin/handmux.js
CHANGED
|
@@ -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.
|
|
6
|
-
//
|
|
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
|
|
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 () => {
|
|
212
|
-
|
|
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(
|
|
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
|
-
|
|
952
|
-
|
|
953
|
-
await maybeQr(
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
416
|
-
const
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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.
|
|
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
|
}
|