handmux 0.24.0 → 0.25.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 (121) hide show
  1. package/README.md +29 -6
  2. package/README.zh-CN.md +29 -6
  3. package/bin/handmux.js +15 -3
  4. package/bin/node-version.js +20 -0
  5. package/dist/bin/handmux.js +200 -35
  6. package/dist/connectors/bridgeClient.js +385 -0
  7. package/dist/connectors/claude/index.js +476 -0
  8. package/dist/connectors/pi/bridgeClient.js +8 -0
  9. package/dist/connectors/pi/index.js +851 -0
  10. package/dist/hooks/handmux-notify.sh +45 -3
  11. package/dist/hooks/handmux-write.cjs +175 -10
  12. package/dist/package.json +10 -9
  13. package/dist/public/assets/index-CTrqTqtT.css +32 -0
  14. package/dist/public/assets/index-DmjeMSQA.js +346 -0
  15. package/dist/public/index.html +2 -2
  16. package/dist/src/agent-runtime/adapter.js +204 -0
  17. package/dist/src/agent-runtime/bridge.js +1006 -0
  18. package/dist/src/agent-runtime/bridgeStore.js +25 -0
  19. package/dist/src/agent-runtime/bridgeTransport.js +619 -0
  20. package/dist/src/agent-runtime/bridgeTypes.js +13 -0
  21. package/dist/src/agent-runtime/builtinRuntime.js +193 -0
  22. package/dist/src/agent-runtime/conversation.js +2179 -0
  23. package/dist/src/agent-runtime/conversationActivation.js +75 -0
  24. package/dist/src/agent-runtime/conversationActivity.js +69 -0
  25. package/dist/src/agent-runtime/conversationControls.js +206 -0
  26. package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
  27. package/dist/src/agent-runtime/conversationStore.js +352 -0
  28. package/dist/src/agent-runtime/conversationTypes.js +1 -0
  29. package/dist/src/agent-runtime/conversationValidation.js +339 -0
  30. package/dist/src/agent-runtime/inbox.js +973 -0
  31. package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
  32. package/dist/src/agent-runtime/inboxStore.js +25 -0
  33. package/dist/src/agent-runtime/inboxTypes.js +1 -0
  34. package/dist/src/agent-runtime/interaction.js +829 -0
  35. package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
  36. package/dist/src/agent-runtime/interactionStore.js +18 -0
  37. package/dist/src/agent-runtime/interactionTypes.js +1 -0
  38. package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
  39. package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
  40. package/dist/src/agent-runtime/resourceTypes.js +1 -0
  41. package/dist/src/agent-runtime/resources.js +245 -0
  42. package/dist/src/agent-runtime/run.js +328 -0
  43. package/dist/src/agent-runtime/runtime.js +793 -0
  44. package/dist/src/agent-runtime/sessionControl.js +164 -0
  45. package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
  46. package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
  47. package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
  48. package/dist/src/agents/claude.js +22 -0
  49. package/dist/src/agents/claudeConversation.js +362 -0
  50. package/dist/src/agents/claudeInteraction.js +134 -0
  51. package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
  52. package/dist/src/agents/codex.js +52 -7
  53. package/dist/src/agents/codexConversation.js +1103 -0
  54. package/dist/src/agents/codexConversationActivation.js +121 -0
  55. package/dist/src/agents/codexConversationControls.js +256 -0
  56. package/dist/src/agents/codexInteraction.js +306 -0
  57. package/dist/src/agents/codexSessionControl.js +118 -0
  58. package/dist/src/agents/codexSubscriptionUsage.js +313 -0
  59. package/dist/src/agents/codexTerminalControl.js +23 -0
  60. package/dist/src/agents/conversationProjectionSafety.js +416 -0
  61. package/dist/src/agents/index.js +27 -9
  62. package/dist/src/agents/nativeInbox.js +264 -0
  63. package/dist/src/agents/nativeInboxSources.js +115 -0
  64. package/dist/src/agents/pi.js +28 -0
  65. package/dist/src/agents/piConversation.js +523 -0
  66. package/dist/src/agents/piConversationHistory.js +502 -0
  67. package/dist/src/agents/piInboxBridge.js +368 -0
  68. package/dist/src/agents/piSessionControl.js +32 -0
  69. package/dist/src/agents/processIdentity.js +17 -3
  70. package/dist/src/apiAccountProviders.js +194 -0
  71. package/dist/src/apiAccounts.js +592 -0
  72. package/dist/src/apiErrors.js +11 -2
  73. package/dist/src/claudeEvents.js +35 -9
  74. package/dist/src/cli/agentIntegration.js +131 -0
  75. package/dist/src/cli/agentLauncher.js +26 -0
  76. package/dist/src/cli/claudeHooks.js +49 -3
  77. package/dist/src/cli/codexManaged.js +6 -3
  78. package/dist/src/cli/hookScaffold.js +4 -1
  79. package/dist/src/cli/i18n/en.js +30 -4
  80. package/dist/src/cli/i18n/zh.js +30 -4
  81. package/dist/src/cli/piExtension.js +155 -0
  82. package/dist/src/cli/service.js +19 -9
  83. package/dist/src/cli/state.js +3 -0
  84. package/dist/src/cli/statusLine.js +19 -11
  85. package/dist/src/codexAppServer.js +1296 -159
  86. package/dist/src/codexQueueProtocol.js +41 -0
  87. package/dist/src/codexTranscriptParse.js +256 -145
  88. package/dist/src/codexUsageSnapshot.js +29 -6
  89. package/dist/src/git.js +13 -1
  90. package/dist/src/httpApi.js +35 -7
  91. package/dist/src/orphans.js +13 -3
  92. package/dist/src/paneInput.js +42 -0
  93. package/dist/src/privateStateStore.js +35 -0
  94. package/dist/src/projectTask/backup.js +224 -0
  95. package/dist/src/projectTask/lock.js +108 -0
  96. package/dist/src/projectTask/migrations.js +90 -0
  97. package/dist/src/projectTask/routes.js +155 -0
  98. package/dist/src/projectTask/runtime.js +182 -0
  99. package/dist/src/projectTask/schema.js +56 -0
  100. package/dist/src/projectTask/store.js +457 -0
  101. package/dist/src/routes/agents.js +631 -0
  102. package/dist/src/routes/apiAccounts.js +82 -0
  103. package/dist/src/routes/git.js +11 -0
  104. package/dist/src/routes/sessions.js +16 -4
  105. package/dist/src/routes/system.js +69 -5
  106. package/dist/src/routes/terminal.js +22 -16
  107. package/dist/src/server.js +109 -6
  108. package/dist/src/tmux/commands.js +29 -1
  109. package/dist/src/transcriptParse.js +22 -1
  110. package/dist/src/transcriptReader.js +100 -4
  111. package/dist/src/uploadTypes.js +2 -0
  112. package/dist/src/usagePaths.js +15 -0
  113. package/dist/src/workspace/agentRunner.js +103 -6
  114. package/dist/src/workspace/restore.js +51 -2
  115. package/dist/src/workspace/tmuxAdapter.js +10 -0
  116. package/package.json +6 -5
  117. package/dist/public/assets/index-CicdY9E9.css +0 -32
  118. package/dist/public/assets/index-D4VP5t8h.js +0 -337
  119. package/dist/src/routes/codex.js +0 -606
  120. package/dist/src/routes/transcript.js +0 -197
  121. package/dist/src/usage.js +0 -260
@@ -0,0 +1,476 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { classifyClaude } from '../../src/agents/claude.js';
5
+ import { LocalConnectorBridgeClient } from '../bridgeClient.js';
6
+ const EVENT_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$/;
7
+ function isRecord(value) {
8
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ }
10
+ function bounded(value, max) {
11
+ return typeof value === 'string' && value.length > 0 && value.length <= max;
12
+ }
13
+ function parseProcessFingerprint(value) {
14
+ if (!isRecord(value) || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0
15
+ || typeof value.startedAt !== 'number' || !Number.isFinite(value.startedAt)
16
+ || value.startedAt < 0 || !bounded(value.tty, 1024))
17
+ return null;
18
+ return {
19
+ pid: Number(value.pid),
20
+ startedAt: value.startedAt,
21
+ tty: value.tty,
22
+ };
23
+ }
24
+ function normalizedTty(value) {
25
+ if (!value)
26
+ return undefined;
27
+ return value.startsWith('/dev/') ? value : `/dev/${value}`;
28
+ }
29
+ function matchesSourceProcess(source, current) {
30
+ if (!source)
31
+ return true; // additive field: accept records produced by an older installed Hook
32
+ return source.pid === current.pid
33
+ && source.startedAt === current.startedAt
34
+ && normalizedTty(source.tty) === normalizedTty(current.tty);
35
+ }
36
+ function optionalSession(payload, explicit) {
37
+ if (bounded(explicit, 1024))
38
+ return explicit;
39
+ return bounded(payload.session_id, 1024) ? payload.session_id : undefined;
40
+ }
41
+ function message(value, max = 4096) {
42
+ if (typeof value !== 'string')
43
+ return undefined;
44
+ const normalized = value.trim();
45
+ if (!normalized)
46
+ return undefined;
47
+ return normalized.length <= max ? normalized : `${normalized.slice(0, max - 1)}…`;
48
+ }
49
+ function canonicalState(kind) {
50
+ if (kind === 'working' || kind === 'compacting')
51
+ return 'working';
52
+ if (kind === 'permission')
53
+ return 'waiting';
54
+ if (kind === 'done')
55
+ return 'done';
56
+ if (kind === 'error')
57
+ return 'error';
58
+ return null;
59
+ }
60
+ export function projectClaudeHookInbox({ eventId, src, sourceOccurredAt, payload, }) {
61
+ const classified = classifyClaude(src, payload);
62
+ const state = classified ? canonicalState(classified.kind) : null;
63
+ const safeEventId = eventId && EVENT_ID_RE.test(eventId) ? eventId : undefined;
64
+ const occurred = typeof sourceOccurredAt === 'number' && Number.isFinite(sourceOccurredAt)
65
+ ? sourceOccurredAt : undefined;
66
+ const base = {
67
+ ...(safeEventId === undefined ? {} : { eventId: safeEventId }),
68
+ ...(occurred === undefined ? {} : { sourceOccurredAt: occurred }),
69
+ };
70
+ if (!state) {
71
+ return {
72
+ operation: { kind: 'clear', ...base },
73
+ snapshot: { availability: 'ready' },
74
+ };
75
+ }
76
+ const text = message(classified?.msg);
77
+ const item = {
78
+ state,
79
+ ...(text === undefined ? {} : { message: text }),
80
+ ...((state === 'waiting' || state === 'done' || state === 'error') && safeEventId
81
+ ? { eventId: safeEventId } : {}),
82
+ ...(occurred === undefined ? {} : { sourceOccurredAt: occurred }),
83
+ };
84
+ return {
85
+ operation: {
86
+ kind: 'set',
87
+ ...item,
88
+ ...(state === 'working' && safeEventId ? { eventId: safeEventId } : {}),
89
+ },
90
+ snapshot: { availability: 'ready', current: item },
91
+ };
92
+ }
93
+ function parseEvent(value) {
94
+ if (!isRecord(value) || value.version !== 1 || (value.type !== 'event' && value.type !== 'gap')
95
+ || (value.agent !== undefined && value.agent !== 'claude')
96
+ || !bounded(value.eventId, 256) || !EVENT_ID_RE.test(value.eventId)
97
+ || !bounded(value.paneId, 256)
98
+ || (value.sequence !== undefined && (!Number.isSafeInteger(value.sequence) || Number(value.sequence) <= 0))
99
+ || (value.src !== undefined && !bounded(value.src, 64))
100
+ || (value.sessionId !== undefined && !bounded(value.sessionId, 1024))
101
+ || (value.sourceOccurredAt !== undefined
102
+ && (typeof value.sourceOccurredAt !== 'number' || !Number.isFinite(value.sourceOccurredAt)))
103
+ || (value.process !== undefined && parseProcessFingerprint(value.process) === null)
104
+ || (value.payload !== undefined && !isRecord(value.payload)))
105
+ return null;
106
+ return {
107
+ ...value,
108
+ ...(value.process === undefined ? {} : { process: parseProcessFingerprint(value.process) }),
109
+ };
110
+ }
111
+ function readHookState(file) {
112
+ try {
113
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
114
+ if (!isRecord(value))
115
+ return new Map();
116
+ return new Map(Object.entries(value).flatMap(([paneId, raw]) => {
117
+ const row = isRecord(raw) ? raw : null;
118
+ const payload = isRecord(row?.payload) ? row.payload : null;
119
+ if (!bounded(paneId, 256) || !row || !payload || !bounded(row.src, 64)
120
+ || (row.agent !== undefined && row.agent !== 'claude'))
121
+ return [];
122
+ const ts = typeof row.ts === 'number' && Number.isFinite(row.ts) ? row.ts : 0;
123
+ const sequence = Number.isSafeInteger(row.sequence) && Number(row.sequence) > 0
124
+ ? Number(row.sequence) : undefined;
125
+ const process = row.process === undefined ? undefined : parseProcessFingerprint(row.process);
126
+ if (process === null)
127
+ return [];
128
+ return [[paneId, {
129
+ ts,
130
+ src: row.src,
131
+ ...(row.agent === 'claude' ? { agent: 'claude' } : {}),
132
+ payload,
133
+ ...(sequence === undefined ? {} : { sequence }),
134
+ ...(process === undefined ? {} : { process }),
135
+ }]];
136
+ }));
137
+ }
138
+ catch {
139
+ return new Map();
140
+ }
141
+ }
142
+ function signature(paneId, sessionId, process) {
143
+ return JSON.stringify({ paneId, sessionId, pid: process.pid, startedAt: process.startedAt, tty: process.tty });
144
+ }
145
+ function digest(value) {
146
+ return crypto.createHash('sha256').update(value).digest('hex');
147
+ }
148
+ function gapKey(file, event) {
149
+ return JSON.stringify({
150
+ marker: path.basename(file),
151
+ paneId: event.paneId,
152
+ sessionId: event.sessionId ?? null,
153
+ process: event.process ?? null,
154
+ eventId: event.eventId,
155
+ });
156
+ }
157
+ function candidate(paneId, sessionId, process) {
158
+ const processRef = {
159
+ pid: process.pid,
160
+ ...(process.startedAt === undefined ? {} : { startedAt: process.startedAt }),
161
+ ...(process.tty === undefined ? {} : { tty: process.tty }),
162
+ };
163
+ return {
164
+ paneId,
165
+ attachmentId: `claude-hook:${digest(JSON.stringify({ paneId, process: processRef }))}`,
166
+ ...(sessionId === undefined ? {} : { sessionId }),
167
+ process: processRef,
168
+ };
169
+ }
170
+ function looksLikeClaude(pane, foreground) {
171
+ if (pane.currentCommand === 'claude')
172
+ return true;
173
+ if (!/^\d+[._]\d+[._]\d+$/.test(pane.currentCommand))
174
+ return false;
175
+ return typeof foreground.executable === 'string' && /claude/.test(foreground.executable);
176
+ }
177
+ // Bridges the existing async Claude Hooks without ever blocking Claude itself. Hook subprocesses only
178
+ // commit files; this coordinator owns socket lifetime, per-session replacement and durable acknowledgements.
179
+ export class ClaudeHookBridgeConnector {
180
+ #socketPath;
181
+ #credentialFile;
182
+ #stateDirectory;
183
+ #hookStateFile;
184
+ #eventDirectory;
185
+ #panes;
186
+ #process;
187
+ #pollMs;
188
+ #retryDelayMs;
189
+ #maxRetryDelayMs;
190
+ #logger;
191
+ #createClient;
192
+ #clients = new Map();
193
+ #confirmedGaps = new Set();
194
+ #timer;
195
+ #tail = Promise.resolve();
196
+ #started = false;
197
+ #closed = false;
198
+ constructor({ socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory, panes, process, pollMs = 250, retryDelayMs = 100, maxRetryDelayMs = 5_000, logger = () => { }, createClient = (options) => new LocalConnectorBridgeClient(options), }) {
199
+ if (![socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory].every(path.isAbsolute)
200
+ || !panes || !process || !Number.isSafeInteger(pollMs) || pollMs < 50
201
+ || !Number.isSafeInteger(retryDelayMs) || retryDelayMs <= 0
202
+ || !Number.isSafeInteger(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs) {
203
+ throw new TypeError('Claude Hook Bridge Connector requires private paths and Runtime identity sources');
204
+ }
205
+ this.#socketPath = socketPath;
206
+ this.#credentialFile = credentialFile;
207
+ this.#stateDirectory = stateDirectory;
208
+ this.#hookStateFile = hookStateFile;
209
+ this.#eventDirectory = eventDirectory;
210
+ this.#panes = panes;
211
+ this.#process = process;
212
+ this.#pollMs = pollMs;
213
+ this.#retryDelayMs = retryDelayMs;
214
+ this.#maxRetryDelayMs = maxRetryDelayMs;
215
+ this.#logger = logger;
216
+ this.#createClient = createClient;
217
+ }
218
+ start() {
219
+ if (this.#closed)
220
+ throw new Error('Claude Hook Bridge Connector is closed');
221
+ if (this.#started)
222
+ return;
223
+ this.#started = true;
224
+ this.#poll();
225
+ }
226
+ reconcile() {
227
+ const pending = this.#tail.then(() => this.#reconcile());
228
+ this.#tail = pending.catch((error) => {
229
+ this.#logger('Claude Hook Bridge reconciliation failed', error);
230
+ });
231
+ return pending;
232
+ }
233
+ async close() {
234
+ if (this.#closed)
235
+ return;
236
+ this.#closed = true;
237
+ if (this.#timer)
238
+ clearTimeout(this.#timer);
239
+ this.#timer = undefined;
240
+ for (const tracked of this.#clients.values())
241
+ tracked.client.close();
242
+ this.#clients.clear();
243
+ await this.#tail.catch(() => { });
244
+ }
245
+ #poll() {
246
+ if (this.#closed)
247
+ return;
248
+ void this.reconcile().catch(() => { }).finally(() => {
249
+ if (this.#closed)
250
+ return;
251
+ this.#timer = setTimeout(() => this.#poll(), this.#pollMs);
252
+ this.#timer.unref?.();
253
+ });
254
+ }
255
+ #eventFiles() {
256
+ try {
257
+ return fs.readdirSync(this.#eventDirectory)
258
+ .filter((name) => /^(event-\d+-\d+|gap-[a-f0-9]+)\.json$/.test(name))
259
+ .sort((first, second) => {
260
+ const firstGap = first.startsWith('gap-');
261
+ const secondGap = second.startsWith('gap-');
262
+ return firstGap === secondGap ? first.localeCompare(second) : firstGap ? -1 : 1;
263
+ })
264
+ .map((name) => path.join(this.#eventDirectory, name));
265
+ }
266
+ catch {
267
+ return [];
268
+ }
269
+ }
270
+ async #reconcile() {
271
+ if (this.#closed)
272
+ return;
273
+ let panes;
274
+ try {
275
+ panes = await this.#panes.list();
276
+ }
277
+ catch {
278
+ return;
279
+ }
280
+ const paneMap = new Map(panes.map((pane) => [pane.paneId, pane]));
281
+ const identities = new Map();
282
+ const identity = (pane) => {
283
+ let pending = identities.get(pane.paneId);
284
+ if (!pending) {
285
+ pending = this.#process.inspectForeground(pane);
286
+ identities.set(pane.paneId, pending);
287
+ }
288
+ return pending;
289
+ };
290
+ const events = [];
291
+ for (const eventFile of this.#eventFiles()) {
292
+ let event = null;
293
+ try {
294
+ const stat = fs.lstatSync(eventFile);
295
+ if (stat.isFile())
296
+ event = parseEvent(JSON.parse(fs.readFileSync(eventFile, 'utf8')));
297
+ }
298
+ catch { /* partial/removed file */ }
299
+ if (event)
300
+ events.push({ file: eventFile, event });
301
+ }
302
+ const state = readHookState(this.#hookStateFile);
303
+ const gaps = new Map();
304
+ const activeGapKeys = new Set(events.flatMap(({ file, event }) => (event.type === 'gap' ? [gapKey(file, event)] : [])));
305
+ for (const key of this.#confirmedGaps) {
306
+ if (!activeGapKeys.has(key))
307
+ this.#confirmedGaps.delete(key);
308
+ }
309
+ for (const { file: eventFile, event } of events) {
310
+ if (event.type !== 'gap')
311
+ continue;
312
+ const pane = paneMap.get(event.paneId);
313
+ const foreground = pane ? await identity(pane) : null;
314
+ if (!pane || !foreground)
315
+ continue;
316
+ if (!matchesSourceProcess(event.process, foreground)) {
317
+ this.#logger('Discarding stale Claude Hook event after pane process replacement', {
318
+ eventId: event.eventId,
319
+ paneId: event.paneId,
320
+ });
321
+ try {
322
+ fs.unlinkSync(eventFile);
323
+ }
324
+ catch { /* concurrently removed */ }
325
+ continue;
326
+ }
327
+ const latest = state.get(event.paneId);
328
+ const latestSession = latest ? optionalSession(latest.payload) : undefined;
329
+ const gapSession = optionalSession(event.payload ?? {}, event.sessionId);
330
+ if (latestSession && gapSession && latestSession !== gapSession) {
331
+ try {
332
+ fs.unlinkSync(eventFile);
333
+ }
334
+ catch { /* replaced session already retired the gap */ }
335
+ continue;
336
+ }
337
+ gaps.set(event.paneId, { event, key: gapKey(eventFile, event) });
338
+ }
339
+ const currentStatePanes = new Set();
340
+ const persistedSnapshots = new Map();
341
+ for (const [paneId, row] of state) {
342
+ const pane = paneMap.get(paneId);
343
+ const foreground = pane ? await identity(pane) : null;
344
+ if (!pane || !foreground)
345
+ continue;
346
+ if (!matchesSourceProcess(row.process, foreground))
347
+ continue;
348
+ currentStatePanes.add(paneId);
349
+ const sessionId = optionalSession(row.payload);
350
+ const eventId = row.sequence === undefined ? undefined : `claude-hook-${row.sequence}`;
351
+ const projection = projectClaudeHookInbox({
352
+ ...(eventId === undefined ? {} : { eventId }),
353
+ src: row.src,
354
+ sourceOccurredAt: row.ts,
355
+ payload: row.payload,
356
+ });
357
+ const snapshot = gaps.has(paneId) ? {
358
+ ...projection.snapshot,
359
+ availability: 'degraded',
360
+ message: 'Claude Hook event history is incomplete',
361
+ } : projection.snapshot;
362
+ persistedSnapshots.set(paneId, this.#client(paneId, sessionId, foreground).setSnapshot('inbox', snapshot));
363
+ }
364
+ const liveClaudePanes = new Set(currentStatePanes);
365
+ for (const pane of panes) {
366
+ if (currentStatePanes.has(pane.paneId))
367
+ continue;
368
+ const foreground = await identity(pane);
369
+ if (!foreground || !looksLikeClaude(pane, foreground))
370
+ continue;
371
+ liveClaudePanes.add(pane.paneId);
372
+ const gap = gaps.get(pane.paneId)?.event;
373
+ const sessionId = gap ? optionalSession(gap.payload ?? {}, gap.sessionId) : undefined;
374
+ persistedSnapshots.set(pane.paneId, this.#client(pane.paneId, sessionId, foreground).setSnapshot('inbox', gap ? {
375
+ availability: 'degraded',
376
+ message: 'Claude Hook event history is incomplete',
377
+ } : { availability: 'ready' }));
378
+ }
379
+ for (const { file: eventFile, event } of events) {
380
+ const gap = event.type === 'gap' ? gaps.get(event.paneId) : undefined;
381
+ if (event.type === 'gap' && gap?.event !== event)
382
+ continue;
383
+ const pane = paneMap.get(event.paneId);
384
+ const foreground = pane ? await identity(pane) : null;
385
+ if (!pane || !foreground)
386
+ continue;
387
+ if (!matchesSourceProcess(event.process, foreground)) {
388
+ this.#logger('Discarding stale Claude Hook event after pane process replacement', {
389
+ eventId: event.eventId,
390
+ paneId: event.paneId,
391
+ });
392
+ try {
393
+ fs.unlinkSync(eventFile);
394
+ }
395
+ catch { /* concurrently removed */ }
396
+ continue;
397
+ }
398
+ const payload = event.payload ?? {};
399
+ const sessionId = optionalSession(payload, event.sessionId);
400
+ const client = this.#client(event.paneId, sessionId, foreground);
401
+ if (event.type === 'gap' && persistedSnapshots.get(event.paneId) !== true)
402
+ continue;
403
+ if (event.type === 'gap' && gap && this.#confirmedGaps.has(gap.key))
404
+ continue;
405
+ const projection = event.type === 'gap' ? {
406
+ operation: { kind: 'gap', eventId: event.eventId },
407
+ } : projectClaudeHookInbox({
408
+ eventId: event.eventId,
409
+ src: event.src ?? '',
410
+ ...(event.sourceOccurredAt === undefined ? {} : {
411
+ sourceOccurredAt: event.sourceOccurredAt,
412
+ }),
413
+ payload,
414
+ });
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)) {
428
+ this.#logger('Claude Hook event remains queued because Connector durable state could not be persisted', {
429
+ eventId: event.eventId,
430
+ paneId: event.paneId,
431
+ });
432
+ continue;
433
+ }
434
+ try {
435
+ await client.waitForDurableAck('inbox', event.eventId);
436
+ if (event.type === 'gap' && gap)
437
+ this.#confirmedGaps.add(gap.key);
438
+ else
439
+ fs.unlinkSync(eventFile);
440
+ }
441
+ catch (error) {
442
+ if (!this.#closed)
443
+ throw error;
444
+ return;
445
+ }
446
+ }
447
+ for (const [paneId, tracked] of [...this.#clients]) {
448
+ if (liveClaudePanes.has(paneId))
449
+ continue;
450
+ tracked.client.close();
451
+ this.#clients.delete(paneId);
452
+ }
453
+ }
454
+ #client(paneId, sessionId, foreground) {
455
+ const nextSignature = signature(paneId, sessionId, foreground);
456
+ const current = this.#clients.get(paneId);
457
+ if (current?.signature === nextSignature)
458
+ return current.client;
459
+ current?.client.close();
460
+ const stateFile = path.join(this.#stateDirectory, `${digest(nextSignature)}.json`);
461
+ const client = this.#createClient({
462
+ adapterId: 'claude',
463
+ socketPath: this.#socketPath,
464
+ credentialFile: this.#credentialFile,
465
+ stateFile,
466
+ candidate: candidate(paneId, sessionId, foreground),
467
+ generation: { id: `claude-session:${digest(sessionId ?? nextSignature)}`, replace: true },
468
+ retryDelayMs: this.#retryDelayMs,
469
+ maxRetryDelayMs: this.#maxRetryDelayMs,
470
+ logger: this.#logger,
471
+ });
472
+ this.#clients.set(paneId, { signature: nextSignature, client });
473
+ client.start();
474
+ return client;
475
+ }
476
+ }
@@ -0,0 +1,8 @@
1
+ import { LocalConnectorBridgeClient } from '../bridgeClient.js';
2
+ // Compatibility name for the Pi Extension. Connection, retry, durable queue and request behavior are
3
+ // implemented once by the shared Handmux-owned Connector client.
4
+ export class PiBridgeClient extends LocalConnectorBridgeClient {
5
+ constructor(options) {
6
+ super({ ...options, adapterId: 'pi' });
7
+ }
8
+ }