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
@@ -0,0 +1,414 @@
1
+ // The CodeBuddy Conversation adapter: the binding between a CodeBuddy session's JSONL log and the shared
2
+ // conversation model. Everything generic is reused — the append-aware reader (transcriptReader), the item
3
+ // model and its safety clipping (conversationProjectionSafety), the pump/Core in agent-runtime/conversation
4
+ // — so this module answers only what is CodeBuddy's: where its logs live, how they parse, and what a
5
+ // delivery or an interrupt means for its pane.
6
+ //
7
+ // The shape mirrors Claude's adapter on purpose: same page/cursor semantics, same live-poll reconciliation
8
+ // (a CodeBuddy session writes its JSONL itself, so there is no push channel to subscribe to), same receipt
9
+ // vocabulary for a send. Two deliberate differences:
10
+ // - No diff is read from a structured patch: CodeBuddy logs no patch. Its Edit result DOES carry the
11
+ // provider's own `renderer: {type:'diff'}` plus the call's old/new strings, so a diff item is derived
12
+ // from those — a file RENDERED as a diff by the provider, never a guess about what changed.
13
+ // - No history reordering: CodeBuddy appends in display order (Claude needs an adjacency fix for its
14
+ // compaction scaffold), so the session id alone is the view epoch.
15
+ import { createHash } from 'node:crypto';
16
+ import { promises as fsp } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { createCodeBuddyTranscriptParser } from './codebuddyTranscriptParse.js';
19
+ import { transcriptReader } from '../transcriptReader.js';
20
+ import { codebuddyProjectsDir } from './codebuddy.js';
21
+ import { isSessionUuid } from './scanUtils.js';
22
+ import { clipConversationText as clipped, safeProviderDiffPath, sanitizeToolInputWithMetadata, sanitizeToolResultText, } from './conversationProjectionSafety.js';
23
+ function isRun(target) {
24
+ return 'runId' in target;
25
+ }
26
+ function inside(root, candidate) {
27
+ const relative = path.relative(root, candidate);
28
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..'
29
+ && !path.isAbsolute(relative));
30
+ }
31
+ async function safeRoot(root) {
32
+ try {
33
+ const resolved = await fsp.realpath(root);
34
+ return (await fsp.stat(resolved)).isDirectory() ? resolved : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ async function safeBoundFile(root, sessionId, candidate) {
41
+ if (!path.isAbsolute(candidate) || path.basename(candidate) !== `${sessionId}.jsonl`)
42
+ return null;
43
+ const resolvedRoot = await safeRoot(root);
44
+ if (!resolvedRoot)
45
+ return null;
46
+ try {
47
+ const parent = await fsp.realpath(path.dirname(candidate));
48
+ if (!inside(resolvedRoot, parent))
49
+ return null;
50
+ try {
51
+ const resolvedFile = await fsp.realpath(candidate);
52
+ const stat = await fsp.stat(resolvedFile);
53
+ return stat.isFile() && inside(resolvedRoot, resolvedFile) ? resolvedFile : null;
54
+ }
55
+ catch {
56
+ // The session can be bound just before CodeBuddy creates its JSONL: the verified parent and the exact
57
+ // UUID filename still make this a safe empty history, and a later read picks the file up.
58
+ return path.join(parent, path.basename(candidate));
59
+ }
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ // CodeBuddy stores one session per encoded-cwd project directory, exactly like Claude. Discovery scans for
66
+ // the exact UUID filename and rejects ambiguity rather than guessing by cwd or mtime.
67
+ export async function findCodeBuddySessionFile(root, sessionId) {
68
+ if (!isSessionUuid(sessionId))
69
+ return null;
70
+ const resolvedRoot = await safeRoot(root);
71
+ if (!resolvedRoot)
72
+ return null;
73
+ let projects;
74
+ try {
75
+ projects = await fsp.readdir(resolvedRoot, { withFileTypes: true });
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ const matches = [];
81
+ for (const project of projects) {
82
+ if (!project.isDirectory() || project.isSymbolicLink())
83
+ continue;
84
+ const candidate = path.join(resolvedRoot, project.name, `${sessionId}.jsonl`);
85
+ try {
86
+ const resolved = await fsp.realpath(candidate);
87
+ if (inside(resolvedRoot, resolved) && (await fsp.stat(resolved)).isFile())
88
+ matches.push(resolved);
89
+ }
90
+ catch { /* not this project */ }
91
+ if (matches.length > 1)
92
+ return null;
93
+ }
94
+ return matches[0] ?? null;
95
+ }
96
+ function sourceViewId(sessionId) {
97
+ return `codebuddy-session:${sessionId}`;
98
+ }
99
+ function sourceTime(value) {
100
+ if (typeof value !== 'string')
101
+ return undefined;
102
+ const parsed = Date.parse(value);
103
+ return Number.isFinite(parsed) ? parsed : undefined;
104
+ }
105
+ function itemBase(message, sessionId, id) {
106
+ const createdAt = sourceTime(message.ts);
107
+ return {
108
+ id,
109
+ sessionId,
110
+ status: 'complete',
111
+ ...(createdAt === undefined ? {} : { sourceCreatedAt: createdAt }),
112
+ };
113
+ }
114
+ function textItem(message, sessionId, id, role, value) {
115
+ const output = clipped(value);
116
+ return {
117
+ ...itemBase(message, sessionId, id),
118
+ kind: 'message',
119
+ role,
120
+ content: [{ type: 'text', text: output.text }],
121
+ ...(output.truncated ? {
122
+ status: 'truncated',
123
+ truncation: { reason: 'size_limit', originalBytes: output.originalBytes },
124
+ } : {}),
125
+ };
126
+ }
127
+ // The call's own old/new strings, in the same +/- line shape Claude's structured patch produces, so the
128
+ // shared diff chip renders it without a provider-specific branch.
129
+ function derivedPatch(oldText, newText) {
130
+ const lines = [];
131
+ for (const line of oldText.split('\n'))
132
+ if (line)
133
+ lines.push(`-${line}`);
134
+ for (const line of newText.split('\n'))
135
+ if (line)
136
+ lines.push(`+${line}`);
137
+ return lines.join('\n');
138
+ }
139
+ function editedStrings(input) {
140
+ if (input === null || typeof input !== 'object' || Array.isArray(input))
141
+ return null;
142
+ const fields = input;
143
+ const oldText = typeof fields.old_string === 'string' ? fields.old_string : null;
144
+ const newText = typeof fields.new_string === 'string' ? fields.new_string : null;
145
+ // Only a replacement carries both halves; a Write/create has content only, and inventing the "before"
146
+ // side of a new file would be a guess, so it stays a plain tool call.
147
+ if (oldText === null || newText === null)
148
+ return null;
149
+ return { oldText, newText };
150
+ }
151
+ function toolItems(message, sessionId, id, tool) {
152
+ const callId = `${id}:call`;
153
+ const name = clipped(tool.name || 'tool', 512).text || 'tool';
154
+ const inputProjection = sanitizeToolInputWithMetadata(tool.input);
155
+ const input = inputProjection.value;
156
+ const items = [{
157
+ ...itemBase(message, sessionId, callId),
158
+ kind: 'tool_call',
159
+ callId,
160
+ name,
161
+ ...(input === undefined ? {} : { input }),
162
+ ...(inputProjection.truncated ? {
163
+ status: 'truncated',
164
+ truncation: { reason: 'size_limit', originalBytes: inputProjection.originalBytes },
165
+ } : {}),
166
+ }];
167
+ if (tool.result !== null && tool.result.length > 0) {
168
+ const output = sanitizeToolResultText(tool.result);
169
+ items.push({
170
+ ...itemBase(message, sessionId, `${id}:result`),
171
+ kind: 'tool_result',
172
+ callId,
173
+ content: [{ type: 'text', text: output.text }],
174
+ ...(tool.isError ? { isError: true } : {}),
175
+ ...(output.truncated ? {
176
+ status: 'truncated',
177
+ truncation: { reason: 'size_limit', originalBytes: output.originalBytes },
178
+ } : {}),
179
+ });
180
+ }
181
+ const edited = editedStrings(tool.input);
182
+ if (edited && !tool.isError) {
183
+ const rawPath = tool.input.file_path;
184
+ const providerPath = safeProviderDiffPath(rawPath);
185
+ const patch = sanitizeToolResultText(derivedPatch(edited.oldText, edited.newText));
186
+ const removed = edited.oldText.split('\n').filter((line) => line).length;
187
+ const added = edited.newText.split('\n').filter((line) => line).length;
188
+ items.push({
189
+ ...itemBase(message, sessionId, `${id}:diff`),
190
+ kind: 'diff',
191
+ ...(providerPath === undefined ? {} : { path: providerPath }),
192
+ ...(patch.text ? { patch: patch.text } : {}),
193
+ summary: `+${added} -${removed}`,
194
+ ...(patch.truncated ? {
195
+ status: 'truncated',
196
+ truncation: { reason: 'size_limit', originalBytes: patch.originalBytes },
197
+ } : {}),
198
+ });
199
+ }
200
+ return items;
201
+ }
202
+ function projectMessage(message, sessionId, ordinal) {
203
+ const id = `codebuddy:${message.i}:${ordinal}:${message.type}`;
204
+ // The parser keeps a public reasoning projection, but the shared model has no place to show it (Claude's
205
+ // adapter drops thinking for the same reason), so it never becomes an item.
206
+ if (message.type === 'thinking')
207
+ return [];
208
+ if (message.type === 'text' && message.role && typeof message.text === 'string' && message.text) {
209
+ return [textItem(message, sessionId, id, message.role, message.text)];
210
+ }
211
+ if (message.type === 'tool' && message.tool)
212
+ return toolItems(message, sessionId, id, message.tool);
213
+ if (message.type === 'compact') {
214
+ const summary = typeof message.summary === 'string' && message.summary
215
+ ? clipped(message.summary) : null;
216
+ const truncated = message.summaryTruncated === true || summary?.truncated === true;
217
+ const originalBytes = typeof message.summaryOriginalBytes === 'number'
218
+ ? message.summaryOriginalBytes : summary?.originalBytes;
219
+ return [{
220
+ ...itemBase(message, sessionId, id), kind: 'compaction',
221
+ ...(summary ? { summary: summary.text } : {}),
222
+ ...(truncated ? {
223
+ status: 'truncated',
224
+ truncation: { reason: 'size_limit', originalBytes: originalBytes ?? 0 },
225
+ } : {}),
226
+ }];
227
+ }
228
+ if (message.type === 'slash') {
229
+ const command = `${message.name ?? '/command'}${message.args ? ` ${message.args}` : ''}`;
230
+ // /compact begins as the user's own message: keeping that role lets the Core replace the optimistic
231
+ // local bubble with the canonical occurrence, exactly as Claude's adapter does.
232
+ if (message.name?.toLowerCase() === '/compact') {
233
+ return [textItem(message, sessionId, id, 'user', command)];
234
+ }
235
+ return [{
236
+ ...itemBase(message, sessionId, id), kind: 'notice', level: 'info',
237
+ code: 'slash_command', message: clipped(command, 4096).text,
238
+ }];
239
+ }
240
+ return [];
241
+ }
242
+ export function createCodeBuddyConversationAdapter({ projectsRoot = codebuddyProjectsDir(), sessions = null, reader = transcriptReader, findSessionFile = findCodeBuddySessionFile, control, livePollMs = 750, } = {}) {
243
+ if (!path.isAbsolute(projectsRoot)) {
244
+ throw new TypeError('CodeBuddy Conversation adapter requires an absolute projects root');
245
+ }
246
+ if (!Number.isFinite(livePollMs) || livePollMs <= 0) {
247
+ throw new TypeError('CodeBuddy Conversation adapter requires a positive live poll interval');
248
+ }
249
+ const boundFiles = new Map();
250
+ async function fileForSession(sessionId) {
251
+ const bound = boundFiles.get(sessionId);
252
+ if (bound)
253
+ return bound;
254
+ return findSessionFile(projectsRoot, sessionId);
255
+ }
256
+ async function discoverFile(target) {
257
+ if (!isSessionUuid(target.sessionId))
258
+ return null;
259
+ if (!isRun(target))
260
+ return fileForSession(target.sessionId);
261
+ const binding = sessions?.paneSession(target.paneId);
262
+ if (!binding || (binding.agent !== undefined && binding.agent !== null && binding.agent !== 'codebuddy')) {
263
+ return null;
264
+ }
265
+ const bindingId = binding.sessionId
266
+ ?? (binding.transcriptPath ? path.basename(binding.transcriptPath, '.jsonl') : null);
267
+ if (bindingId !== target.sessionId)
268
+ return null;
269
+ if (!binding.transcriptPath)
270
+ return fileForSession(target.sessionId);
271
+ const file = await safeBoundFile(projectsRoot, target.sessionId, binding.transcriptPath);
272
+ if (file)
273
+ boundFiles.set(target.sessionId, file);
274
+ return file;
275
+ }
276
+ function project(messages, sessionId) {
277
+ return messages.flatMap((message, ordinal) => projectMessage(message, sessionId, ordinal));
278
+ }
279
+ async function readPage(session, request) {
280
+ const file = await fileForSession(session.sessionId);
281
+ const parsed = file ? await reader.read(file, createCodeBuddyTranscriptParser) : [];
282
+ const all = project(parsed, session.sessionId);
283
+ let end = all.length;
284
+ if (request.beforeSourceCursor !== undefined) {
285
+ if (!/^\d+$/.test(request.beforeSourceCursor))
286
+ throw new Error('Invalid CodeBuddy source cursor');
287
+ const cursor = Number(request.beforeSourceCursor);
288
+ if (!Number.isSafeInteger(cursor))
289
+ throw new Error('Invalid CodeBuddy source cursor');
290
+ end = Math.min(cursor, all.length);
291
+ }
292
+ const start = Math.max(0, end - request.limit);
293
+ return {
294
+ sessionId: session.sessionId,
295
+ sourceViewId: sourceViewId(session.sessionId),
296
+ sourceHistoryToken: createHash('sha256').update(JSON.stringify(all)).digest('hex'),
297
+ items: all.slice(start, end),
298
+ ...(start > 0 ? { previousSourceCursor: String(start) } : {}),
299
+ hasMore: start > 0,
300
+ };
301
+ }
302
+ return {
303
+ apiVersion: 1,
304
+ async discoverNative(target) {
305
+ if (target.agentId !== 'codebuddy' || !target.sessionId)
306
+ return null;
307
+ const file = await discoverFile(target);
308
+ if (!file)
309
+ return null;
310
+ boundFiles.set(target.sessionId, file);
311
+ return {
312
+ session: { agentId: 'codebuddy', sessionId: target.sessionId },
313
+ ...(isRun(target) ? { run: target } : {}),
314
+ sourceViewId: sourceViewId(target.sessionId),
315
+ capabilities: isRun(target) && control ? {
316
+ history: true, live: 'settled', sendable: true, send: ['prompt'], interrupt: true,
317
+ // Verified live on 2.156.0: a prompt typed while a tool is running is accepted by the editor and
318
+ // the TUI reports "Messages to be submitted after next tool call", then submits it into the same
319
+ // turn. So an ordinary send does not have to wait for idle.
320
+ promptWhileActive: true,
321
+ } : { history: true, live: 'poll' },
322
+ };
323
+ },
324
+ readNativePage: readPage,
325
+ ...(control ? {
326
+ async observeNative(run, sink) {
327
+ const sessionId = run.ref.sessionId;
328
+ if (!sessionId)
329
+ throw new Error('CodeBuddy Conversation run has no session');
330
+ const session = { agentId: 'codebuddy', sessionId };
331
+ const baseline = await readPage(session, { limit: 1 });
332
+ let sourceSequence = 0;
333
+ let historyToken = baseline.sourceHistoryToken;
334
+ let closed = false;
335
+ let timer;
336
+ let tail = Promise.resolve();
337
+ const close = () => {
338
+ closed = true;
339
+ if (timer)
340
+ clearTimeout(timer);
341
+ timer = undefined;
342
+ };
343
+ const schedule = () => {
344
+ if (closed || run.signal.aborted)
345
+ return;
346
+ timer = setTimeout(() => {
347
+ tail = tail.then(async () => {
348
+ if (closed || run.signal.aborted)
349
+ return;
350
+ const next = await readPage(session, { limit: 1 });
351
+ if (next.sourceViewId !== baseline.sourceViewId) {
352
+ close();
353
+ await sink({
354
+ type: 'stream.gap', sourceSequence: ++sourceSequence,
355
+ afterSourceSequence: sourceSequence - 1,
356
+ });
357
+ return;
358
+ }
359
+ if (next.sourceHistoryToken !== historyToken) {
360
+ historyToken = next.sourceHistoryToken;
361
+ await sink({
362
+ type: 'history.committed',
363
+ sourceSequence: ++sourceSequence,
364
+ sourceViewId: next.sourceViewId,
365
+ sourceHistoryToken: next.sourceHistoryToken,
366
+ });
367
+ }
368
+ }).catch(async () => {
369
+ if (closed || run.signal.aborted)
370
+ return;
371
+ await Promise.resolve(sink({
372
+ type: 'stream.gap', sourceSequence: ++sourceSequence,
373
+ afterSourceSequence: Math.max(0, sourceSequence - 1),
374
+ })).catch(close);
375
+ }).finally(schedule);
376
+ }, livePollMs);
377
+ timer.unref?.();
378
+ };
379
+ const onAbort = () => close();
380
+ run.signal.addEventListener('abort', onAbort, { once: true });
381
+ schedule();
382
+ return {
383
+ checkpoint: { sourceViewId: baseline.sourceViewId, sourceSequence },
384
+ close() {
385
+ run.signal.removeEventListener('abort', onAbort);
386
+ close();
387
+ },
388
+ };
389
+ },
390
+ async dispatchPrompt(run, request, guard) {
391
+ try {
392
+ const result = await (guard
393
+ ? control.sendPrompt(run.ref.paneId, request.text, guard)
394
+ : control.sendPrompt(run.ref.paneId, request.text));
395
+ if (result && typeof result === 'object'
396
+ && result.nativeMutation === false) {
397
+ if (result.reason === 'terminal_draft_conflict') {
398
+ return { outcome: 'rejected', nativeMutation: false, reason: 'terminal_draft_conflict' };
399
+ }
400
+ return { outcome: 'busy', nativeMutation: false };
401
+ }
402
+ return { outcome: 'accepted' };
403
+ }
404
+ catch {
405
+ return { outcome: 'unknown', nativeMutation: 'unknown', reason: 'delivery_unconfirmed' };
406
+ }
407
+ },
408
+ async dispatchInterrupt(run) {
409
+ await control.interrupt(run.ref.paneId);
410
+ return { status: 'accepted' };
411
+ },
412
+ } : {}),
413
+ };
414
+ }
@@ -0,0 +1,4 @@
1
+ import { createHookInteractionAdapter } from './hookInteraction.js';
2
+ export function createCodeBuddyInteractionAdapter(control, pollMs = 750, reportHealth = () => { }) {
3
+ return createHookInteractionAdapter(control, { id: 'codebuddy', label: 'CodeBuddy' }, pollMs, reportHealth);
4
+ }
@@ -0,0 +1,206 @@
1
+ // The CodeBuddy out-of-band reconciliation. CodeBuddy fires NO Hook when the user answers a
2
+ // PermissionRequest, so an answered pane keeps its 需要你 gate lit until whatever Hook comes next — for a
3
+ // tool outside the PostToolUse matcher, until the turn ends. Claude closes the same gap from its own session
4
+ // registry (`status: idle|busy|waiting`, which flips back to busy the moment the user answers). CodeBuddy
5
+ // keeps no such status anywhere readable: its registry file carries only a heartbeat, its per-session local
6
+ // HTTP endpoint exposes no session state, and its live event channel is a Centrifugo WebSocket. The one
7
+ // artifact that does move is the session transcript, where the granted tool lands its result record.
8
+ //
9
+ // The verdict is deliberately one-sided: a result record strictly newer than the gate proves the pane's tool
10
+ // ran after the user answered, so the gate is over. Everything else — no transcript, an unparsable file, a
11
+ // result older than the gate, a row that is not a gate, or a tail that happens to hold no result at all —
12
+ // says nothing, leaving the Hook's own state exactly as it was.
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { codebuddyProjectsDir, codebuddySessionsDir } from './codebuddy.js';
17
+ const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null);
18
+ // A transcript is append-only and a result is written when it happens, so the newest one is always in the
19
+ // last chunk. A chunk with no result at all (a very large tool output ahead of it) closes nothing.
20
+ const MAX_TAIL_BYTES = 262_144;
21
+ const MAX_CACHED_TRANSCRIPTS = 64;
22
+ // The rows that can latch a gate. Only they are worth reading a transcript for, which keeps this reader at
23
+ // zero cost on a pane that is simply working.
24
+ function isGateRow(src, payload) {
25
+ return src === 'permreq'
26
+ || (src === 'notify' && payload.notification_type === 'permission_prompt');
27
+ }
28
+ // The transcript for a gate row. Three AUTHORITATIVE names, in order — never a guess: a path the Hook
29
+ // carried, the session id the Hook carried, then the pid's own registry row. The registry one matters
30
+ // because a Notification payload carries neither a session id nor a transcript path (measured) and it is
31
+ // the row a gate is often latched from; the session-id one matters because a registry row can be stale
32
+ // (observed: a resumed session keeps the pid file's older id), which would otherwise leave the gate unclosed
33
+ // on a pane whose Hook did name its session.
34
+ function transcriptFor(payload, pid, home, locate) {
35
+ const explicit = payload.transcript_path;
36
+ if (typeof explicit === 'string' && path.isAbsolute(explicit)
37
+ && path.basename(explicit).endsWith('.jsonl'))
38
+ return explicit;
39
+ const projects = codebuddyProjectsDir(home);
40
+ const payloadSession = payload.session_id;
41
+ if (typeof payloadSession === 'string' && payloadSession) {
42
+ const found = locate(projects, payloadSession);
43
+ if (found)
44
+ return found;
45
+ }
46
+ if (!Number.isSafeInteger(pid) || Number(pid) <= 0)
47
+ return null;
48
+ let registry = null;
49
+ try {
50
+ registry = record(JSON.parse(fs.readFileSync(path.join(codebuddySessionsDir(home), `${Number(pid)}.json`), 'utf8')));
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ const sessionId = registry?.sessionId;
56
+ const cwd = registry?.cwd;
57
+ if (typeof sessionId !== 'string' || !sessionId)
58
+ return null;
59
+ // CodeBuddy names a project directory after the cwd with the separating slashes replaced by dashes
60
+ // (verified: /home/user/x → Users-admin-x, /private/tmp/cb-sim → private-tmp-cb-sim). It is only a
61
+ // shortcut past the scan — the session UUID is what actually names the file, so a miss costs nothing.
62
+ if (typeof cwd === 'string' && cwd) {
63
+ const direct = path.join(projects, cwd.replace(/^\//, '').replace(/\//g, '-'), `${sessionId}.jsonl`);
64
+ try {
65
+ if (fs.statSync(direct).isFile())
66
+ return direct;
67
+ }
68
+ catch { /* scan below */ }
69
+ }
70
+ return locate(projects, sessionId);
71
+ }
72
+ // The newest tool result timestamp in the transcript's tail, or null when the tail holds none.
73
+ function newestResultAt(file) {
74
+ let descriptor = null;
75
+ try {
76
+ const stat = fs.statSync(file);
77
+ if (!stat.isFile() || stat.size === 0)
78
+ return null;
79
+ const length = Math.min(stat.size, MAX_TAIL_BYTES);
80
+ descriptor = fs.openSync(file, 'r');
81
+ const buffer = Buffer.alloc(length);
82
+ fs.readSync(descriptor, buffer, 0, length, stat.size - length);
83
+ const lines = buffer.toString('utf8').split('\n');
84
+ // A read that started mid-file begins with a fragment of a line: it cannot parse, but drop it anyway so
85
+ // a truncated record can never be mistaken for one.
86
+ if (stat.size > length)
87
+ lines.shift();
88
+ let newest = null;
89
+ for (const line of lines) {
90
+ const trimmed = line.trim();
91
+ if (!trimmed)
92
+ continue;
93
+ let row = null;
94
+ try {
95
+ row = record(JSON.parse(trimmed));
96
+ }
97
+ catch {
98
+ continue;
99
+ } // a half-written last line
100
+ if (!row || row.type !== 'function_call_result')
101
+ continue;
102
+ const timestamp = row.timestamp;
103
+ if (typeof timestamp !== 'number' || !Number.isFinite(timestamp))
104
+ continue;
105
+ if (newest === null || timestamp > newest)
106
+ newest = timestamp;
107
+ }
108
+ return newest;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ finally {
114
+ if (descriptor !== null) {
115
+ try {
116
+ fs.closeSync(descriptor);
117
+ }
118
+ catch { /* already closed */ }
119
+ }
120
+ }
121
+ }
122
+ export class CodeBuddyNativeTailReader {
123
+ #home;
124
+ // Keyed by transcript file and bounded: an unchanged file costs one `stat`, and the gate rows that get
125
+ // here are few. `retain` is deliberately a no-op — the cache is not keyed by session, and the rows that
126
+ // need it (a gate latched from a Notification) carry no session id to retain it by.
127
+ #tails = new Map();
128
+ // Memoized `<sessionId>.jsonl` lookups: a session's path never moves, and the walk only ever runs for a
129
+ // session a gate actually named.
130
+ #paths = new Map();
131
+ constructor({ home = os.homedir() } = {}) {
132
+ this.#home = home;
133
+ }
134
+ read(payload, after, now, process, src) {
135
+ const body = record(payload) ?? {};
136
+ if (!isGateRow(String(src ?? ''), body))
137
+ return {};
138
+ const file = transcriptFor(body, process?.pid, this.#home, (projects, sessionId) => this.#locate(projects, sessionId));
139
+ if (!file)
140
+ return {};
141
+ const at = this.#resultAt(file);
142
+ // Strictly after the gate, and never in the future: a result from before the request cannot close it,
143
+ // and a clock that ran away must not either.
144
+ if (at === null || at <= after || at > now)
145
+ return {};
146
+ const sessionId = typeof body.session_id === 'string' ? body.session_id : '';
147
+ return {
148
+ status: 'busy',
149
+ statusEventId: `codebuddy-native:${sessionId}:${process?.pid ?? 0}:${at}`,
150
+ };
151
+ }
152
+ retain() { }
153
+ clear() {
154
+ this.#tails.clear();
155
+ this.#paths.clear();
156
+ }
157
+ #locate(projects, sessionId) {
158
+ const key = `${projects}\0${sessionId}`;
159
+ const cached = this.#paths.get(key);
160
+ if (cached !== undefined)
161
+ return cached;
162
+ let found = null;
163
+ try {
164
+ for (const entry of fs.readdirSync(projects)) {
165
+ const candidate = path.join(projects, entry, `${sessionId}.jsonl`);
166
+ try {
167
+ if (fs.statSync(candidate).isFile()) {
168
+ found = candidate;
169
+ break;
170
+ }
171
+ }
172
+ catch { /* next entry */ }
173
+ }
174
+ }
175
+ catch { /* no projects directory */ }
176
+ this.#paths.delete(key);
177
+ this.#paths.set(key, found);
178
+ if (this.#paths.size > MAX_CACHED_TRANSCRIPTS) {
179
+ this.#paths.delete(this.#paths.keys().next().value);
180
+ }
181
+ return found;
182
+ }
183
+ #resultAt(file) {
184
+ let fingerprint;
185
+ try {
186
+ const stat = fs.statSync(file);
187
+ if (!stat.isFile())
188
+ return null;
189
+ fingerprint = [stat.dev, stat.ino, stat.size, stat.mtimeMs].join(':');
190
+ }
191
+ catch {
192
+ this.#tails.delete(file);
193
+ return null;
194
+ }
195
+ const cached = this.#tails.get(file);
196
+ if (cached?.fingerprint === fingerprint)
197
+ return cached.at;
198
+ const at = newestResultAt(file);
199
+ this.#tails.delete(file);
200
+ this.#tails.set(file, { fingerprint, at });
201
+ if (this.#tails.size > MAX_CACHED_TRANSCRIPTS) {
202
+ this.#tails.delete(this.#tails.keys().next().value);
203
+ }
204
+ return at;
205
+ }
206
+ }