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.
- 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 +122 -25
- package/dist/hooks/handmux-notify.sh +14 -2
- package/dist/hooks/handmux-write.cjs +15 -1
- package/dist/package.json +19 -19
- package/dist/public/assets/index-BjnnFTLC.css +32 -0
- package/dist/public/assets/index-aBgNeovq.js +356 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/bridge.js +63 -1
- package/dist/src/agent-runtime/bridgeTransport.js +31 -4
- package/dist/src/agent-runtime/bridgeTypes.js +2 -0
- 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/agent-runtime/runtime.js +1 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +27 -3
- package/dist/src/agents/claudeConversation.js +69 -6
- package/dist/src/agents/claudeLocalCommand.js +54 -0
- package/dist/src/agents/claudeNativeTail.js +287 -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 +23 -7
- 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 +327 -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 +48 -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 +5 -5
- 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,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
|
-
|
|
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',
|
|
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
|
-
|
|
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)) {
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
#
|
|
57
|
-
|
|
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
|
-
|
|
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)
|