handmux 0.29.2 → 0.31.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 (116) hide show
  1. package/dist/connectors/claude/index.js +33 -562
  2. package/dist/connectors/codebuddy/index.js +49 -0
  3. package/dist/connectors/hookBridge.js +560 -0
  4. package/dist/hooks/handmux-codebuddy-notify.sh +116 -0
  5. package/dist/hooks/handmux-notify.sh +2 -0
  6. package/dist/hooks/handmux-write.cjs +72 -5
  7. package/dist/package.json +13 -13
  8. package/dist/public/assets/_basePickBy-BzC0P9Wg.js +1 -0
  9. package/dist/public/assets/_baseUniq-DNOfPYVY.js +1 -0
  10. package/dist/public/assets/abnfDiagram-O67JEVCF-BsRDIQEB.js +1 -0
  11. package/dist/public/assets/arc-DLtLfMnX.js +1 -0
  12. package/dist/public/assets/architectureDiagram-NJMV4G6O-DoIXhWqZ.js +36 -0
  13. package/dist/public/assets/blockDiagram-BEXU5L5S-W0HiOcVA.js +140 -0
  14. package/dist/public/assets/c4Diagram-YGBWAQC7-72uAUMvX.js +38 -0
  15. package/dist/public/assets/channel-CzALSnb0.js +1 -0
  16. package/dist/public/assets/chunk-2Q5K7J3B-B9wSRFQ0.js +1 -0
  17. package/dist/public/assets/chunk-3FUC2YCW-ryphp4s0.js +2 -0
  18. package/dist/public/assets/chunk-5VM5RSS4-Cm4KeKby.js +15 -0
  19. package/dist/public/assets/chunk-GTNCS2PH-Du4_i7r3.js +88 -0
  20. package/dist/public/assets/chunk-GWA4HPMP-CL1biVIA.js +1 -0
  21. package/dist/public/assets/chunk-JWPE2WC7-B8eAVfuB.js +1 -0
  22. package/dist/public/assets/chunk-NETBCI7D-D273GzYg.js +1 -0
  23. package/dist/public/assets/chunk-WEXAMYUT-DipTfRbc.js +1 -0
  24. package/dist/public/assets/chunk-XXDRQBXY-D4SqXrQ8.js +1 -0
  25. package/dist/public/assets/classDiagram-v2-NBCMYWYE-PrpnIXcA.js +217 -0
  26. package/dist/public/assets/cose-bilkent-JH36ORCC-BfcYMYTa.js +1 -0
  27. package/dist/public/assets/cynefin-OW5HDTMX-Du659bmz.js +178 -0
  28. package/dist/public/assets/cynefinDiagram-VND7K2PF-DR_2Copy.js +62 -0
  29. package/dist/public/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  30. package/dist/public/assets/dagre-6A5THRUB-COsChaV5.js +4 -0
  31. package/dist/public/assets/defaultLocale-CrowFXzY.js +1 -0
  32. package/dist/public/assets/diagram-22UHCM2B-Cx2T1HvL.js +200 -0
  33. package/dist/public/assets/diagram-3UASUU5V-Cp04LOka.js +24 -0
  34. package/dist/public/assets/diagram-ATOU4E4O-DlqFSshY.js +3 -0
  35. package/dist/public/assets/diagram-CDSNMT55-goq40ctA.js +30 -0
  36. package/dist/public/assets/diagram-MLGK6HIB-Bdzjsy4r.js +24 -0
  37. package/dist/public/assets/diagram-MPIPVDR6-DtU7cM6P.js +41 -0
  38. package/dist/public/assets/ebnfDiagram-ZINNZB2B-XyVCfmED.js +1 -0
  39. package/dist/public/assets/elk-276RUBZZ-aHVIw-2x.js +27 -0
  40. package/dist/public/assets/erDiagram-OPXOYQCR-Bq_PhTIL.js +99 -0
  41. package/dist/public/assets/flowDiagram-KWPJA3E3-WuqnDSiF.js +213 -0
  42. package/dist/public/assets/ganttDiagram-FUAMR5RP-DZ4lXzkC.js +292 -0
  43. package/dist/public/assets/gitGraphDiagram-X574FWY7-t6l07b9g.js +106 -0
  44. package/dist/public/assets/graph-jWsD5Ro-.js +1 -0
  45. package/dist/public/assets/index-DiYff8oR.css +32 -0
  46. package/dist/public/assets/index-YZNK723o.js +203 -0
  47. package/dist/public/assets/infoDiagram-VRGFBTTK-iUGqoOFp.js +2 -0
  48. package/dist/public/assets/init-Gi6I4Gst.js +1 -0
  49. package/dist/public/assets/ishikawaDiagram-OU5B5YK6-B0TmoUOK.js +70 -0
  50. package/dist/public/assets/journeyDiagram-ZHPQQLJL-CQE61MCu.js +139 -0
  51. package/dist/public/assets/kanban-definition-PNTS6WVX-D2MWCUyS.js +89 -0
  52. package/dist/public/assets/katex-C5jXJg4s.js +257 -0
  53. package/dist/public/assets/linear-WKGxVNP1.js +1 -0
  54. package/dist/public/assets/mermaid.core-DPEwUsaj.js +311 -0
  55. package/dist/public/assets/mindmap-definition-NLK3R4M7-DsYEsBsI.js +96 -0
  56. package/dist/public/assets/ordinal-Cboi1Yqb.js +1 -0
  57. package/dist/public/assets/pegDiagram-GJSIUBJH-BS52Fbxt.js +1 -0
  58. package/dist/public/assets/pieDiagram-5QR66LMP-D4o8g7nW.js +39 -0
  59. package/dist/public/assets/quadrantDiagram-O4NWA36T-DnYUxWll.js +7 -0
  60. package/dist/public/assets/railroadDiagram-XR7U4H2S-BKy_7ULV.js +1 -0
  61. package/dist/public/assets/requirementDiagram-PLB6GJNP-Ce5F4u8K.js +84 -0
  62. package/dist/public/assets/sankeyDiagram-IPEJSGJF-CCJDrut_.js +40 -0
  63. package/dist/public/assets/sequenceDiagram-PO4LG4MO-D5E8ISM1.js +169 -0
  64. package/dist/public/assets/sizeCapture-INFHLROL-BW3KmxDN.js +1 -0
  65. package/dist/public/assets/stateDiagram-v2-GCMORJYK-Bw2ZzQOE.js +291 -0
  66. package/dist/public/assets/swimlanes-2SLR337P-CLJ-6Ex_.js +1 -0
  67. package/dist/public/assets/swimlanesDiagram-TC7HE7FX-BD8rQfV7.js +8 -0
  68. package/dist/public/assets/timeline-definition-EJHVYXUP-C_lFJ-4W.js +120 -0
  69. package/dist/public/assets/usecaseDiagram-POWQR4AR-DWXawL2A.js +370 -0
  70. package/dist/public/assets/vennDiagram-UO4OBE2U-D4O5oZal.js +34 -0
  71. package/dist/public/assets/wardleyDiagram-VNRHLVJA-B6HElk__.js +78 -0
  72. package/dist/public/assets/xychartDiagram-PMCCYNJV-C5OAXnJy.js +7 -0
  73. package/dist/public/index.html +2 -2
  74. package/dist/src/agent-runtime/adapter.js +54 -10
  75. package/dist/src/agent-runtime/builtinRuntime.js +67 -6
  76. package/dist/src/agent-runtime/conversation.js +49 -9
  77. package/dist/src/agent-runtime/interaction.js +26 -12
  78. package/dist/src/agent-runtime/interactionLiveHub.js +4 -0
  79. package/dist/src/agent-runtime/runtime.js +83 -27
  80. package/dist/src/agent-runtime/tmuxRuntime.js +36 -6
  81. package/dist/src/agents/claude.js +2 -19
  82. package/dist/src/agents/claudeInteraction.js +2 -132
  83. package/dist/src/agents/claudePaneInput.js +22 -22
  84. package/dist/src/agents/codebuddy.js +100 -0
  85. package/dist/src/agents/codebuddyConversation.js +414 -0
  86. package/dist/src/agents/codebuddyInteraction.js +4 -0
  87. package/dist/src/agents/codebuddyNativeTail.js +206 -0
  88. package/dist/src/agents/codebuddyPaneInput.js +76 -0
  89. package/dist/src/agents/codebuddyTranscriptParse.js +178 -0
  90. package/dist/src/agents/hookEvents.js +143 -0
  91. package/dist/src/agents/hookInteraction.js +178 -0
  92. package/dist/src/agents/index.js +2 -1
  93. package/dist/src/claudeEvents.js +4 -5
  94. package/dist/src/cli/agentIntegration.js +31 -2
  95. package/dist/src/cli/codebuddyHooks.js +286 -0
  96. package/dist/src/cli/hookScaffold.js +25 -12
  97. package/dist/src/cli/i18n/en.js +1 -1
  98. package/dist/src/cli/i18n/zh.js +1 -1
  99. package/dist/src/cli/state.js +4 -0
  100. package/dist/src/cli/supervisor.js +2 -1
  101. package/dist/src/codebuddyEvents.js +151 -0
  102. package/dist/src/docs.js +21 -7
  103. package/dist/src/logDetail.js +27 -0
  104. package/dist/src/paneInput.js +107 -10
  105. package/dist/src/pendingPrompt.js +21 -5
  106. package/dist/src/routes/files.js +4 -1
  107. package/dist/src/routes/system.js +12 -2
  108. package/dist/src/server.js +69 -8
  109. package/dist/src/trimCapture.js +1 -1
  110. package/dist/src/workspace/checkpointer.js +48 -4
  111. package/dist/src/workspace/lock.js +54 -10
  112. package/dist/src/workspace/runtime.js +23 -8
  113. package/dist/src/workspaceProtocol.js +1 -0
  114. package/package.json +4 -4
  115. package/dist/public/assets/index-2ghkmGbT.js +0 -356
  116. package/dist/public/assets/index-BjnnFTLC.css +0 -32
@@ -1,175 +1,23 @@
1
- import crypto from 'node:crypto';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
1
+ // The Claude Code Inbox Connector. All pipeline mechanics live in the generic Hook Bridge Connector
2
+ // (connectors/hookBridge.ts); this module is the Claude PROFILE — the product facts only Claude can answer:
3
+ // which records carry its marking, how a pane proves it is still running Claude, how a `src`+payload maps
4
+ // to an Inbox state, and the out-of-band native Tail reconciliation Claude has and CodeBuddy does not.
4
5
  import { classifyClaude } from '../../src/agents/claude.js';
5
6
  import { ClaudeNativeTailReader } from '../../src/agents/claudeNativeTail.js';
6
- import { LocalConnectorBridgeClient } from '../bridgeClient.js';
7
- const DEFAULT_SPOOL_MAX_FILES = 2_000;
8
- const DEFAULT_SPOOL_RETENTION_MS = 24 * 60 * 60_000;
9
- const EVENT_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$/;
10
- function isRecord(value) {
11
- return value !== null && typeof value === 'object' && !Array.isArray(value);
12
- }
13
- function bounded(value, max) {
14
- return typeof value === 'string' && value.length > 0 && value.length <= max;
15
- }
16
- function parseProcessFingerprint(value) {
17
- if (!isRecord(value) || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0
18
- || typeof value.startedAt !== 'number' || !Number.isFinite(value.startedAt)
19
- || value.startedAt < 0 || !bounded(value.tty, 1024))
20
- return null;
21
- return {
22
- pid: Number(value.pid),
23
- startedAt: value.startedAt,
24
- tty: value.tty,
25
- };
26
- }
27
- function normalizedTty(value) {
28
- if (!value)
29
- return undefined;
30
- return value.startsWith('/dev/') ? value : `/dev/${value}`;
31
- }
32
- function matchesSourceProcess(source, current) {
33
- if (!source)
34
- return true; // additive field: accept records produced by an older installed Hook
35
- return source.pid === current.pid
36
- && source.startedAt === current.startedAt
37
- && normalizedTty(source.tty) === normalizedTty(current.tty);
38
- }
39
- function optionalSession(payload, explicit) {
40
- if (bounded(explicit, 1024))
41
- return explicit;
42
- return bounded(payload.session_id, 1024) ? payload.session_id : undefined;
43
- }
44
- function message(value, max = 4096) {
45
- if (typeof value !== 'string')
46
- return undefined;
47
- const normalized = value.trim();
48
- if (!normalized)
49
- return undefined;
50
- return normalized.length <= max ? normalized : `${normalized.slice(0, max - 1)}…`;
51
- }
52
- function canonicalState(kind) {
53
- if (kind === 'working' || kind === 'compacting')
54
- return 'working';
55
- if (kind === 'permission')
56
- return 'waiting';
57
- if (kind === 'done')
58
- return 'done';
59
- if (kind === 'error')
60
- return 'error';
61
- return null;
62
- }
63
- export function projectClaudeHookInbox({ eventId, src, sourceOccurredAt, payload, }) {
64
- const classified = classifyClaude(src, payload);
65
- const state = classified ? canonicalState(classified.kind) : null;
66
- const safeEventId = eventId && EVENT_ID_RE.test(eventId) ? eventId : undefined;
67
- const occurred = typeof sourceOccurredAt === 'number' && Number.isFinite(sourceOccurredAt)
68
- ? sourceOccurredAt : undefined;
69
- const base = {
70
- ...(safeEventId === undefined ? {} : { eventId: safeEventId }),
71
- ...(occurred === undefined ? {} : { sourceOccurredAt: occurred }),
72
- };
73
- if (!state) {
74
- return {
75
- operation: { kind: 'clear', ...base },
76
- snapshot: { availability: 'ready' },
77
- };
78
- }
79
- const text = message(classified?.msg);
80
- const item = {
81
- state,
82
- ...(text === undefined ? {} : { message: text }),
83
- ...((state === 'waiting' || state === 'done' || state === 'error') && safeEventId
84
- ? { eventId: safeEventId } : {}),
85
- ...(occurred === undefined ? {} : { sourceOccurredAt: occurred }),
86
- };
87
- return {
88
- operation: {
89
- kind: 'set',
90
- ...item,
91
- ...(state === 'working' && safeEventId ? { eventId: safeEventId } : {}),
92
- },
93
- snapshot: { availability: 'ready', current: item },
94
- };
95
- }
96
- function parseEvent(value) {
97
- if (!isRecord(value) || value.version !== 1 || (value.type !== 'event' && value.type !== 'gap')
98
- || (value.agent !== undefined && value.agent !== 'claude')
99
- || !bounded(value.eventId, 256) || !EVENT_ID_RE.test(value.eventId)
100
- || !bounded(value.paneId, 256)
101
- || (value.sequence !== undefined && (!Number.isSafeInteger(value.sequence) || Number(value.sequence) <= 0))
102
- || (value.src !== undefined && !bounded(value.src, 64))
103
- || (value.sessionId !== undefined && !bounded(value.sessionId, 1024))
104
- || (value.sourceOccurredAt !== undefined
105
- && (typeof value.sourceOccurredAt !== 'number' || !Number.isFinite(value.sourceOccurredAt)))
106
- || (value.process !== undefined && parseProcessFingerprint(value.process) === null)
107
- || (value.payload !== undefined && !isRecord(value.payload)))
108
- return null;
109
- return {
110
- ...value,
111
- ...(value.process === undefined ? {} : { process: parseProcessFingerprint(value.process) }),
112
- };
113
- }
114
- function readHookState(file) {
115
- try {
116
- const value = JSON.parse(fs.readFileSync(file, 'utf8'));
117
- if (!isRecord(value))
118
- return new Map();
119
- return new Map(Object.entries(value).flatMap(([paneId, raw]) => {
120
- const row = isRecord(raw) ? raw : null;
121
- const payload = isRecord(row?.payload) ? row.payload : null;
122
- if (!bounded(paneId, 256) || !row || !payload || !bounded(row.src, 64)
123
- || (row.agent !== undefined && row.agent !== 'claude'))
124
- return [];
125
- const ts = typeof row.ts === 'number' && Number.isFinite(row.ts) ? row.ts : 0;
126
- const sequence = Number.isSafeInteger(row.sequence) && Number(row.sequence) > 0
127
- ? Number(row.sequence) : undefined;
128
- const process = row.process === undefined ? undefined : parseProcessFingerprint(row.process);
129
- if (process === null)
130
- return [];
131
- return [[paneId, {
132
- ts,
133
- src: row.src,
134
- ...(row.agent === 'claude' ? { agent: 'claude' } : {}),
135
- payload,
136
- ...(sequence === undefined ? {} : { sequence }),
137
- ...(process === undefined ? {} : { process }),
138
- }]];
139
- }));
140
- }
141
- catch {
142
- return new Map();
143
- }
144
- }
145
- function signature(paneId, sessionId, process) {
146
- return JSON.stringify({ paneId, sessionId, pid: process.pid, startedAt: process.startedAt, tty: process.tty });
147
- }
148
- function digest(value) {
149
- return crypto.createHash('sha256').update(value).digest('hex');
150
- }
151
- function gapKey(file, event) {
152
- return JSON.stringify({
153
- marker: path.basename(file),
154
- paneId: event.paneId,
155
- sessionId: event.sessionId ?? null,
156
- process: event.process ?? null,
157
- eventId: event.eventId,
158
- });
159
- }
160
- function candidate(paneId, sessionId, process) {
161
- const processRef = {
162
- pid: process.pid,
163
- ...(process.startedAt === undefined ? {} : { startedAt: process.startedAt }),
164
- ...(process.tty === undefined ? {} : { tty: process.tty }),
165
- };
166
- return {
167
- paneId,
168
- attachmentId: `claude-hook:${digest(JSON.stringify({ paneId, process: processRef }))}`,
169
- ...(sessionId === undefined ? {} : { sessionId }),
170
- process: processRef,
171
- };
172
- }
7
+ import { HookBridgeConnector, projectHookInbox } from '../hookBridge.js';
8
+ // Claude's records are explicitly marked `agent: "claude"`; an ABSENT marking is the legacy Claude row from
9
+ // before the multi-Agent format (the reader admits it). Anything else fails closed at the Connector entrance.
10
+ function acceptsClaudeAgent(agent) {
11
+ return agent === 'claude';
12
+ }
13
+ // Project one Claude Hook edge. Kept as a named export because it is Claude's documented projection and is
14
+ // the only place the Claude classifier meets the shared Inbox operation shape.
15
+ export function projectClaudeHookInbox(input) {
16
+ return projectHookInbox({ classify: classifyClaude, ...input });
17
+ }
18
+ // tmux reports the native installer's version-named binary (e.g. "2_1_196"), and the value is normalized
19
+ // to 'claude' at ingest by resolveVersionedComms — so an exact single-name match is right here. A
20
+ // version-shaped command is corroborated through the process's REAL executable path.
173
21
  function looksLikeClaude(pane, foreground) {
174
22
  if (pane.currentCommand === 'claude')
175
23
  return true;
@@ -177,397 +25,20 @@ function looksLikeClaude(pane, foreground) {
177
25
  return false;
178
26
  return typeof foreground.executable === 'string' && /claude/.test(foreground.executable);
179
27
  }
180
- // Bridges the existing async Claude Hooks without ever blocking Claude itself. Hook subprocesses only
181
- // commit files; this coordinator owns socket lifetime, per-session replacement and durable acknowledgements.
182
- export class ClaudeHookBridgeConnector {
183
- #nativeTail;
184
- #socketPath;
185
- #credentialFile;
186
- #stateDirectory;
187
- #hookStateFile;
188
- #eventDirectory;
189
- #panes;
190
- #process;
191
- #pollMs;
192
- #spoolMaxFiles;
193
- #spoolRetentionMs;
194
- #retryDelayMs;
195
- #maxRetryDelayMs;
196
- #logger;
197
- #createClient;
198
- #clients = new Map();
199
- #confirmedGaps = new Set();
200
- #pendingAcks = new Map();
201
- #timer;
202
- #tail = Promise.resolve();
203
- #started = false;
204
- #closed = false;
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, }) {
206
- if (![socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory].every(path.isAbsolute)
207
- || !panes || !process || !Number.isSafeInteger(pollMs) || pollMs < 50
208
- || !Number.isSafeInteger(retryDelayMs) || retryDelayMs <= 0
209
- || !Number.isSafeInteger(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs
210
- || !Number.isSafeInteger(spoolMaxFiles) || spoolMaxFiles <= 0
211
- || !Number.isSafeInteger(spoolRetentionMs) || spoolRetentionMs <= 0) {
212
- throw new TypeError('Claude Hook Bridge Connector requires private paths and Runtime identity sources');
213
- }
214
- this.#socketPath = socketPath;
215
- this.#nativeTail = nativeTail;
216
- this.#credentialFile = credentialFile;
217
- this.#stateDirectory = stateDirectory;
218
- this.#hookStateFile = hookStateFile;
219
- this.#eventDirectory = eventDirectory;
220
- this.#panes = panes;
221
- this.#process = process;
222
- this.#pollMs = pollMs;
223
- this.#retryDelayMs = retryDelayMs;
224
- this.#maxRetryDelayMs = maxRetryDelayMs;
225
- this.#spoolMaxFiles = spoolMaxFiles;
226
- this.#spoolRetentionMs = spoolRetentionMs;
227
- this.#logger = logger;
228
- this.#createClient = createClient;
229
- }
230
- start() {
231
- if (this.#closed)
232
- throw new Error('Claude Hook Bridge Connector is closed');
233
- if (this.#started)
234
- return;
235
- this.#started = true;
236
- this.#poll();
237
- }
238
- reconcile() {
239
- const pending = this.#tail.then(() => this.#reconcile());
240
- this.#tail = pending.catch((error) => {
241
- this.#logger('Claude Hook Bridge reconciliation failed', error);
242
- });
243
- return pending;
244
- }
245
- async close() {
246
- if (this.#closed)
247
- return;
248
- this.#closed = true;
249
- this.#nativeTail.clear();
250
- if (this.#timer)
251
- clearTimeout(this.#timer);
252
- this.#timer = undefined;
253
- for (const tracked of this.#clients.values())
254
- tracked.client.close();
255
- this.#clients.clear();
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));
261
- }
262
- #poll() {
263
- if (this.#closed)
264
- return;
265
- void this.reconcile().catch(() => { }).finally(() => {
266
- if (this.#closed)
267
- return;
268
- this.#timer = setTimeout(() => this.#poll(), this.#pollMs);
269
- this.#timer.unref?.();
270
- });
271
- }
272
- #eventFiles() {
273
- try {
274
- return fs.readdirSync(this.#eventDirectory)
275
- .filter((name) => /^(event-\d+-\d+|gap-[a-f0-9]+)\.json$/.test(name))
276
- .sort((first, second) => {
277
- const firstGap = first.startsWith('gap-');
278
- const secondGap = second.startsWith('gap-');
279
- return firstGap === secondGap ? first.localeCompare(second) : firstGap ? -1 : 1;
280
- })
281
- .map((name) => path.join(this.#eventDirectory, name));
282
- }
283
- catch {
284
- return [];
285
- }
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
- }
321
- async #reconcile() {
322
- if (this.#closed)
323
- return;
324
- let panes;
325
- try {
326
- panes = await this.#panes.list();
327
- }
328
- catch {
329
- return;
330
- }
331
- const paneMap = new Map(panes.map((pane) => [pane.paneId, pane]));
332
- const identities = new Map();
333
- const identity = (pane) => {
334
- let pending = identities.get(pane.paneId);
335
- if (!pending) {
336
- pending = this.#process.inspectForeground(pane);
337
- identities.set(pane.paneId, pending);
338
- }
339
- return pending;
340
- };
341
- const events = [];
342
- for (const eventFile of this.#pruneSpool(this.#eventFiles())) {
343
- let event = null;
344
- try {
345
- const stat = fs.lstatSync(eventFile);
346
- if (stat.isFile())
347
- event = parseEvent(JSON.parse(fs.readFileSync(eventFile, 'utf8')));
348
- }
349
- catch { /* partial/removed file */ }
350
- if (event)
351
- events.push({ file: eventFile, event });
352
- }
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
- }
368
- const gaps = new Map();
369
- const activeGapKeys = new Set(events.flatMap(({ file, event }) => (event.type === 'gap' ? [gapKey(file, event)] : [])));
370
- for (const key of this.#confirmedGaps) {
371
- if (!activeGapKeys.has(key))
372
- this.#confirmedGaps.delete(key);
373
- }
374
- for (const { file: eventFile, event } of events) {
375
- if (event.type !== 'gap')
376
- continue;
377
- const pane = paneMap.get(event.paneId);
378
- const foreground = pane ? await identity(pane) : null;
379
- if (!pane || !foreground)
380
- continue;
381
- if (!event.process && !looksLikeClaude(pane, foreground))
382
- continue;
383
- if (!matchesSourceProcess(event.process, foreground)) {
384
- this.#logger('Discarding stale Claude Hook event after pane process replacement', {
385
- eventId: event.eventId,
386
- paneId: event.paneId,
387
- });
388
- try {
389
- fs.unlinkSync(eventFile);
390
- }
391
- catch { /* concurrently removed */ }
392
- continue;
393
- }
394
- const latest = state.get(event.paneId);
395
- const latestSession = latest ? optionalSession(latest.payload) : undefined;
396
- const gapSession = optionalSession(event.payload ?? {}, event.sessionId);
397
- if (latestSession && gapSession && latestSession !== gapSession) {
398
- try {
399
- fs.unlinkSync(eventFile);
400
- }
401
- catch { /* replaced session already retired the gap */ }
402
- continue;
403
- }
404
- gaps.set(event.paneId, { event, key: gapKey(eventFile, event) });
405
- }
406
- const currentStatePanes = new Set();
407
- const currentNativeSessions = new Set();
408
- const persistedSnapshots = new Map();
409
- for (const [paneId, row] of state) {
410
- const pane = paneMap.get(paneId);
411
- const foreground = pane ? await identity(pane) : null;
412
- if (!pane || !foreground)
413
- continue;
414
- if (!row.process && !looksLikeClaude(pane, foreground))
415
- continue;
416
- if (!matchesSourceProcess(row.process, foreground))
417
- continue;
418
- currentStatePanes.add(paneId);
419
- // A cancelled old-session ACK settles asynchronously; switch clients once it has released the pane.
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;
426
- const eventId = row.sequence === undefined ? undefined : `claude-hook-${row.sequence}`;
427
- const projection = projectClaudeHookInbox({
428
- ...(eventId === undefined ? {} : { eventId }),
429
- src: row.src,
430
- sourceOccurredAt: row.ts,
431
- payload: row.payload,
432
- });
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' ? {
447
- ...projection.snapshot,
448
- availability: 'degraded',
449
- message: 'Claude Hook event history is incomplete',
450
- } : projection.snapshot;
451
- persistedSnapshots.set(paneId, this.#client(paneId, sessionId, foreground).setSnapshot('inbox', {
452
- ...snapshot,
453
- ...(row.process && row.sequence !== undefined ? { sourceSequence: row.sequence } : {}),
454
- }));
455
- }
456
- this.#nativeTail.retain(currentNativeSessions);
457
- const liveClaudePanes = new Set(currentStatePanes);
458
- for (const pane of panes) {
459
- if (currentStatePanes.has(pane.paneId))
460
- continue;
461
- const foreground = await identity(pane);
462
- if (!foreground || !looksLikeClaude(pane, foreground))
463
- continue;
464
- liveClaudePanes.add(pane.paneId);
465
- if (this.#pendingAcks.has(pane.paneId))
466
- continue;
467
- const gap = gaps.get(pane.paneId)?.event;
468
- const sessionId = gap ? optionalSession(gap.payload ?? {}, gap.sessionId) : undefined;
469
- persistedSnapshots.set(pane.paneId, this.#client(pane.paneId, sessionId, foreground).setSnapshot('inbox', gap ? {
470
- availability: 'degraded',
471
- message: 'Claude Hook event history is incomplete',
472
- } : { availability: 'ready' }));
473
- }
474
- for (const { file: eventFile, event } of events) {
475
- const gap = event.type === 'gap' ? gaps.get(event.paneId) : undefined;
476
- if (event.type === 'gap' && gap?.event !== event)
477
- continue;
478
- const pane = paneMap.get(event.paneId);
479
- const foreground = pane ? await identity(pane) : null;
480
- if (!pane || !foreground)
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;
488
- if (!matchesSourceProcess(event.process, foreground)) {
489
- this.#logger('Discarding stale Claude Hook event after pane process replacement', {
490
- eventId: event.eventId,
491
- paneId: event.paneId,
492
- });
493
- try {
494
- fs.unlinkSync(eventFile);
495
- }
496
- catch { /* concurrently removed */ }
497
- continue;
498
- }
499
- const payload = event.payload ?? {};
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;
508
- const client = this.#client(event.paneId, sessionId, foreground);
509
- if (event.type === 'gap' && persistedSnapshots.get(event.paneId) !== true)
510
- continue;
511
- if (event.type === 'gap' && gap && this.#confirmedGaps.has(gap.key))
512
- continue;
513
- const projection = event.type === 'gap' ? {
514
- operation: { kind: 'gap', eventId: event.eventId },
515
- } : projectClaudeHookInbox({
516
- eventId: event.eventId,
517
- src: event.src ?? '',
518
- ...(event.sourceOccurredAt === undefined ? {} : {
519
- sourceOccurredAt: event.sourceOccurredAt,
520
- }),
521
- payload,
522
- });
523
- const operation = projection.operation;
524
- const restoredAck = client.resumePersistedDurable('inbox', event.eventId);
525
- if (!restoredAck && !client.publishDurable('inbox', event.eventId, operation)) {
526
- this.#logger('Claude Hook event remains queued because Connector durable state could not be persisted', {
527
- eventId: event.eventId,
528
- paneId: event.paneId,
529
- });
530
- continue;
531
- }
532
- const pending = (restoredAck ?? client.waitForDurableAck('inbox', event.eventId)).then(() => {
533
- if (event.type === 'gap' && gap)
534
- this.#confirmedGaps.add(gap.key);
535
- else
536
- fs.unlinkSync(eventFile);
537
- }).catch((error) => {
538
- if (!this.#closed)
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 });
542
- }
543
- for (const [paneId, tracked] of [...this.#clients]) {
544
- if (liveClaudePanes.has(paneId))
545
- continue;
546
- tracked.client.close();
547
- this.#clients.delete(paneId);
548
- }
549
- }
550
- #client(paneId, sessionId, foreground) {
551
- const nextSignature = signature(paneId, sessionId, foreground);
552
- const current = this.#clients.get(paneId);
553
- if (current?.signature === nextSignature)
554
- return current.client;
555
- current?.client.close();
556
- const stateFile = path.join(this.#stateDirectory, `${digest(nextSignature)}.json`);
557
- const client = this.#createClient({
558
- adapterId: 'claude',
559
- snapshotBeforeDurable: true,
560
- socketPath: this.#socketPath,
561
- credentialFile: this.#credentialFile,
562
- stateFile,
563
- candidate: candidate(paneId, sessionId, foreground),
564
- generation: { id: `claude-session:${digest(sessionId ?? nextSignature)}`, replace: true },
565
- retryDelayMs: this.#retryDelayMs,
566
- maxRetryDelayMs: this.#maxRetryDelayMs,
567
- logger: this.#logger,
568
- });
569
- this.#clients.set(paneId, { signature: nextSignature, client });
570
- client.start();
571
- return client;
28
+ function claudeProfile(nativeTail) {
29
+ return {
30
+ agentId: 'claude',
31
+ label: 'Claude',
32
+ attachmentPrefix: 'claude-hook',
33
+ eventPrefix: 'claude-hook',
34
+ acceptsAgent: acceptsClaudeAgent,
35
+ matchesAgentPane: looksLikeClaude,
36
+ project: projectClaudeHookInbox,
37
+ nativeTail,
38
+ };
39
+ }
40
+ export class ClaudeHookBridgeConnector extends HookBridgeConnector {
41
+ constructor({ nativeTail = new ClaudeNativeTailReader(), ...options }) {
42
+ super({ ...options, profile: claudeProfile(nativeTail) });
572
43
  }
573
44
  }
@@ -0,0 +1,49 @@
1
+ // The CodeBuddy Code Inbox Connector. Pipeline mechanics live in the generic Hook Bridge Connector
2
+ // (connectors/hookBridge.ts); this module is the CodeBuddy profile — the product facts only CodeBuddy can
3
+ // answer. It is deliberately thin: CodeBuddy drives the same file Hooks and the same shared writer as
4
+ // Claude, so the only CodeBuddy-specific pieces are the record marking, the pane-process anchor, and the
5
+ // `src`+payload classification (src/codebuddyEvents.ts).
6
+ import { acceptsCodeBuddyAgent, classifyCodeBuddy } from '../../src/codebuddyEvents.js';
7
+ import { isCodeBuddyCommandLine, verifyCodeBuddyProcess } from '../../src/agents/codebuddy.js';
8
+ import { CodeBuddyNativeTailReader } from '../../src/agents/codebuddyNativeTail.js';
9
+ import { HookBridgeConnector, projectHookInbox } from '../hookBridge.js';
10
+ // The marking rule and its reasoning live in src/codebuddyEvents.ts — the conversation lens's pane binding
11
+ // has to answer it identically, so there is exactly one definition rather than two that can drift.
12
+ export { acceptsCodeBuddyAgent };
13
+ export function projectCodeBuddyHookInbox(input) {
14
+ return projectHookInbox({ classify: classifyCodeBuddy, ...input });
15
+ }
16
+ // CodeBuddy is never the pane's foreground LEAF: it runs inside a Node launcher whose group also carries
17
+ // transient tool children (npm/ruby update checks), and the single-leaf view prefers such a child. The Hook
18
+ // shell records the owning CodeBuddy process, and Runtime anchors the lease on that very row — so the
19
+ // Connector must resolve identity exactly like the adapter verifies it. Comparing the recorded fingerprint
20
+ // (or publishing a candidate) against the leaf instead would never match, and the whole chain would drop
21
+ // every event.
22
+ async function resolveCodeBuddyPaneProcess(pane, context) {
23
+ const verified = await verifyCodeBuddyProcess(pane, context);
24
+ return verified && typeof verified === 'object' ? verified : null;
25
+ }
26
+ // Only reached for a record with NO process fingerprint (an older writer). The resolved anchor is already
27
+ // proven CodeBuddy, so this just re-states that in command-line terms.
28
+ function looksLikeCodeBuddy(pane, foreground) {
29
+ return isCodeBuddyCommandLine(foreground.commandLine ?? '')
30
+ || isCodeBuddyCommandLine(pane.currentCommand);
31
+ }
32
+ export class CodeBuddyHookBridgeConnector extends HookBridgeConnector {
33
+ constructor({ nativeTail = new CodeBuddyNativeTailReader(), ...options }) {
34
+ super({
35
+ ...options,
36
+ profile: {
37
+ agentId: 'codebuddy',
38
+ label: 'CodeBuddy',
39
+ attachmentPrefix: 'codebuddy-hook',
40
+ eventPrefix: 'codebuddy-hook',
41
+ acceptsAgent: acceptsCodeBuddyAgent,
42
+ matchesAgentPane: looksLikeCodeBuddy,
43
+ project: projectCodeBuddyHookInbox,
44
+ resolvePaneProcess: resolveCodeBuddyPaneProcess,
45
+ nativeTail,
46
+ },
47
+ });
48
+ }
49
+ }