handmux 0.30.0 → 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 (108) 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 +7 -7
  8. package/dist/public/assets/{abnfDiagram-O67JEVCF-CmVVGJqt.js → abnfDiagram-O67JEVCF-BsRDIQEB.js} +1 -1
  9. package/dist/public/assets/{arc-aYtCI03A.js → arc-DLtLfMnX.js} +1 -1
  10. package/dist/public/assets/{architectureDiagram-NJMV4G6O-CyZZDTaL.js → architectureDiagram-NJMV4G6O-DoIXhWqZ.js} +1 -1
  11. package/dist/public/assets/{blockDiagram-BEXU5L5S-D_ODtFS0.js → blockDiagram-BEXU5L5S-W0HiOcVA.js} +1 -1
  12. package/dist/public/assets/{c4Diagram-YGBWAQC7-C8qZFFmC.js → c4Diagram-YGBWAQC7-72uAUMvX.js} +1 -1
  13. package/dist/public/assets/channel-CzALSnb0.js +1 -0
  14. package/dist/public/assets/{chunk-2Q5K7J3B-Bo85fSxI.js → chunk-2Q5K7J3B-B9wSRFQ0.js} +1 -1
  15. package/dist/public/assets/{chunk-3FUC2YCW-BnET3fKr.js → chunk-3FUC2YCW-ryphp4s0.js} +2 -2
  16. package/dist/public/assets/{chunk-5VM5RSS4-DDw_KNv1.js → chunk-5VM5RSS4-Cm4KeKby.js} +1 -1
  17. package/dist/public/assets/{chunk-GTNCS2PH-B_K2164R.js → chunk-GTNCS2PH-Du4_i7r3.js} +1 -1
  18. package/dist/public/assets/{chunk-GWA4HPMP-1GA2Su8A.js → chunk-GWA4HPMP-CL1biVIA.js} +1 -1
  19. package/dist/public/assets/{chunk-JWPE2WC7-Cfc-8kSE.js → chunk-JWPE2WC7-B8eAVfuB.js} +1 -1
  20. package/dist/public/assets/{chunk-NETBCI7D-C7zyDFCq.js → chunk-NETBCI7D-D273GzYg.js} +1 -1
  21. package/dist/public/assets/{chunk-WEXAMYUT-BI2Zijn_.js → chunk-WEXAMYUT-DipTfRbc.js} +1 -1
  22. package/dist/public/assets/{chunk-XXDRQBXY-Bl50fDL9.js → chunk-XXDRQBXY-D4SqXrQ8.js} +1 -1
  23. package/dist/public/assets/{classDiagram-v2-NBCMYWYE-BMWMChZ4.js → classDiagram-v2-NBCMYWYE-PrpnIXcA.js} +1 -1
  24. package/dist/public/assets/{cose-bilkent-JH36ORCC-B_HUrBN4.js → cose-bilkent-JH36ORCC-BfcYMYTa.js} +1 -1
  25. package/dist/public/assets/{cynefin-OW5HDTMX-2jTRe-JA.js → cynefin-OW5HDTMX-Du659bmz.js} +1 -1
  26. package/dist/public/assets/{cynefinDiagram-VND7K2PF-ClDtZNOK.js → cynefinDiagram-VND7K2PF-DR_2Copy.js} +1 -1
  27. package/dist/public/assets/{dagre-6A5THRUB-CJS3bbcm.js → dagre-6A5THRUB-COsChaV5.js} +1 -1
  28. package/dist/public/assets/{diagram-22UHCM2B-BpRgaKhU.js → diagram-22UHCM2B-Cx2T1HvL.js} +1 -1
  29. package/dist/public/assets/{diagram-3UASUU5V-BNwvXb9K.js → diagram-3UASUU5V-Cp04LOka.js} +1 -1
  30. package/dist/public/assets/{diagram-ATOU4E4O-DyphKhOl.js → diagram-ATOU4E4O-DlqFSshY.js} +1 -1
  31. package/dist/public/assets/{diagram-CDSNMT55-BRazAxS2.js → diagram-CDSNMT55-goq40ctA.js} +1 -1
  32. package/dist/public/assets/{diagram-MLGK6HIB-NN_P6wZp.js → diagram-MLGK6HIB-Bdzjsy4r.js} +1 -1
  33. package/dist/public/assets/{diagram-MPIPVDR6-DHBe5DA4.js → diagram-MPIPVDR6-DtU7cM6P.js} +1 -1
  34. package/dist/public/assets/{ebnfDiagram-ZINNZB2B-BCTHXp7J.js → ebnfDiagram-ZINNZB2B-XyVCfmED.js} +1 -1
  35. package/dist/public/assets/{elk-276RUBZZ-yXNwrgXQ.js → elk-276RUBZZ-aHVIw-2x.js} +1 -1
  36. package/dist/public/assets/{erDiagram-OPXOYQCR-YsAJcCqg.js → erDiagram-OPXOYQCR-Bq_PhTIL.js} +1 -1
  37. package/dist/public/assets/{flowDiagram-KWPJA3E3-CyX8qF-3.js → flowDiagram-KWPJA3E3-WuqnDSiF.js} +1 -1
  38. package/dist/public/assets/{ganttDiagram-FUAMR5RP-BMFOvdDK.js → ganttDiagram-FUAMR5RP-DZ4lXzkC.js} +1 -1
  39. package/dist/public/assets/{gitGraphDiagram-X574FWY7-D1sZiiWD.js → gitGraphDiagram-X574FWY7-t6l07b9g.js} +1 -1
  40. package/dist/public/assets/index-DiYff8oR.css +32 -0
  41. package/dist/public/assets/index-YZNK723o.js +203 -0
  42. package/dist/public/assets/{infoDiagram-VRGFBTTK-CiayIr0f.js → infoDiagram-VRGFBTTK-iUGqoOFp.js} +1 -1
  43. package/dist/public/assets/{ishikawaDiagram-OU5B5YK6-Bf4mzvKk.js → ishikawaDiagram-OU5B5YK6-B0TmoUOK.js} +1 -1
  44. package/dist/public/assets/{journeyDiagram-ZHPQQLJL-mvwzOMZ7.js → journeyDiagram-ZHPQQLJL-CQE61MCu.js} +1 -1
  45. package/dist/public/assets/{kanban-definition-PNTS6WVX-CMFYAtvx.js → kanban-definition-PNTS6WVX-D2MWCUyS.js} +1 -1
  46. package/dist/public/assets/{linear-BcK1QK3e.js → linear-WKGxVNP1.js} +1 -1
  47. package/dist/public/assets/{mermaid.core-BmCR7le1.js → mermaid.core-DPEwUsaj.js} +4 -4
  48. package/dist/public/assets/{mindmap-definition-NLK3R4M7-hvUFWkg7.js → mindmap-definition-NLK3R4M7-DsYEsBsI.js} +1 -1
  49. package/dist/public/assets/{pegDiagram-GJSIUBJH-B8FGnth6.js → pegDiagram-GJSIUBJH-BS52Fbxt.js} +1 -1
  50. package/dist/public/assets/{pieDiagram-5QR66LMP-BdhppGvz.js → pieDiagram-5QR66LMP-D4o8g7nW.js} +1 -1
  51. package/dist/public/assets/{quadrantDiagram-O4NWA36T-i6XjZJ8W.js → quadrantDiagram-O4NWA36T-DnYUxWll.js} +1 -1
  52. package/dist/public/assets/{railroadDiagram-XR7U4H2S-C7XoA5kk.js → railroadDiagram-XR7U4H2S-BKy_7ULV.js} +1 -1
  53. package/dist/public/assets/{requirementDiagram-PLB6GJNP-tq6onssc.js → requirementDiagram-PLB6GJNP-Ce5F4u8K.js} +1 -1
  54. package/dist/public/assets/{sankeyDiagram-IPEJSGJF-DRW13BH8.js → sankeyDiagram-IPEJSGJF-CCJDrut_.js} +1 -1
  55. package/dist/public/assets/{sequenceDiagram-PO4LG4MO-BJqhIpnw.js → sequenceDiagram-PO4LG4MO-D5E8ISM1.js} +1 -1
  56. package/dist/public/assets/{sizeCapture-INFHLROL-BCpyNpYK.js → sizeCapture-INFHLROL-BW3KmxDN.js} +1 -1
  57. package/dist/public/assets/{stateDiagram-v2-GCMORJYK-Da3aSdeG.js → stateDiagram-v2-GCMORJYK-Bw2ZzQOE.js} +1 -1
  58. package/dist/public/assets/{swimlanes-2SLR337P-oT62nN2e.js → swimlanes-2SLR337P-CLJ-6Ex_.js} +1 -1
  59. package/dist/public/assets/swimlanesDiagram-TC7HE7FX-BD8rQfV7.js +8 -0
  60. package/dist/public/assets/{timeline-definition-EJHVYXUP-CQ9Q15bt.js → timeline-definition-EJHVYXUP-C_lFJ-4W.js} +1 -1
  61. package/dist/public/assets/{usecaseDiagram-POWQR4AR-De1gJb8v.js → usecaseDiagram-POWQR4AR-DWXawL2A.js} +1 -1
  62. package/dist/public/assets/{vennDiagram-UO4OBE2U-BusPyTiq.js → vennDiagram-UO4OBE2U-D4O5oZal.js} +1 -1
  63. package/dist/public/assets/{wardleyDiagram-VNRHLVJA-D-vaN_gJ.js → wardleyDiagram-VNRHLVJA-B6HElk__.js} +1 -1
  64. package/dist/public/assets/{xychartDiagram-PMCCYNJV-BcOmOj-j.js → xychartDiagram-PMCCYNJV-C5OAXnJy.js} +1 -1
  65. package/dist/public/index.html +2 -2
  66. package/dist/src/agent-runtime/adapter.js +54 -10
  67. package/dist/src/agent-runtime/builtinRuntime.js +67 -6
  68. package/dist/src/agent-runtime/conversation.js +49 -9
  69. package/dist/src/agent-runtime/interaction.js +26 -12
  70. package/dist/src/agent-runtime/interactionLiveHub.js +4 -0
  71. package/dist/src/agent-runtime/runtime.js +83 -27
  72. package/dist/src/agent-runtime/tmuxRuntime.js +36 -6
  73. package/dist/src/agents/claude.js +2 -19
  74. package/dist/src/agents/claudeInteraction.js +2 -132
  75. package/dist/src/agents/claudePaneInput.js +22 -22
  76. package/dist/src/agents/codebuddy.js +100 -0
  77. package/dist/src/agents/codebuddyConversation.js +414 -0
  78. package/dist/src/agents/codebuddyInteraction.js +4 -0
  79. package/dist/src/agents/codebuddyNativeTail.js +206 -0
  80. package/dist/src/agents/codebuddyPaneInput.js +76 -0
  81. package/dist/src/agents/codebuddyTranscriptParse.js +178 -0
  82. package/dist/src/agents/hookEvents.js +143 -0
  83. package/dist/src/agents/hookInteraction.js +178 -0
  84. package/dist/src/agents/index.js +2 -1
  85. package/dist/src/claudeEvents.js +4 -5
  86. package/dist/src/cli/agentIntegration.js +31 -2
  87. package/dist/src/cli/codebuddyHooks.js +286 -0
  88. package/dist/src/cli/hookScaffold.js +25 -12
  89. package/dist/src/cli/i18n/en.js +1 -1
  90. package/dist/src/cli/i18n/zh.js +1 -1
  91. package/dist/src/cli/state.js +4 -0
  92. package/dist/src/cli/supervisor.js +2 -1
  93. package/dist/src/codebuddyEvents.js +151 -0
  94. package/dist/src/docs.js +17 -6
  95. package/dist/src/logDetail.js +27 -0
  96. package/dist/src/paneInput.js +107 -10
  97. package/dist/src/pendingPrompt.js +21 -5
  98. package/dist/src/routes/system.js +12 -2
  99. package/dist/src/server.js +69 -8
  100. package/dist/src/workspace/checkpointer.js +48 -4
  101. package/dist/src/workspace/lock.js +54 -10
  102. package/dist/src/workspace/runtime.js +23 -8
  103. package/dist/src/workspaceProtocol.js +1 -0
  104. package/package.json +3 -3
  105. package/dist/public/assets/channel-kkiOw5AC.js +0 -1
  106. package/dist/public/assets/index-DqCUBYR8.css +0 -32
  107. package/dist/public/assets/index-OW7snGiD.js +0 -362
  108. package/dist/public/assets/swimlanesDiagram-TC7HE7FX-YHG5AQi4.js +0 -8
@@ -0,0 +1,560 @@
1
+ // The generic Hook → LocalAgentBridge → Inbox Connector. Every Agent whose native lifecycle is delivered
2
+ // as filesystem Hooks (Claude Code, CodeBuddy) drives the same async pipeline, so the pipeline is written
3
+ // ONCE here and the provider supplies only a profile:
4
+ //
5
+ // - agentId / label / attachmentPrefix identity of the records this connector publishes
6
+ // - eventPrefix the prefix the shared writer stamps on the spool it reads
7
+ // - acceptsAgent(agent) which provider marking a row or spool event may carry
8
+ // - resolvePaneProcess(pane, context) the pane's provider process (the lease anchor)
9
+ // - matchesAgentPane(pane, foreground) the legacy-record fallback when no fingerprint was recorded
10
+ // - project({eventId, src, …}) the provider's src+payload → Inbox projection
11
+ // - nativeTail OPTIONAL out-of-band reconciliation (Claude only today)
12
+ //
13
+ // The Connector never blocks the provider: Hook subprocesses only commit files, while this owns socket
14
+ // lifetime, per-session replacement, and durable acknowledgements.
15
+ import crypto from 'node:crypto';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { LocalConnectorBridgeClient } from './bridgeClient.js';
19
+ import { HOOK_EVENT_ID_RE, canonicalInboxState, matchesHookProcess, normalizedTty, parseHookBridgeEvent, readHookStateRows, } from '../src/agents/hookEvents.js';
20
+ const DEFAULT_SPOOL_MAX_FILES = 2_000;
21
+ const DEFAULT_SPOOL_RETENTION_MS = 24 * 60 * 60_000;
22
+ function isRecord(value) {
23
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
24
+ }
25
+ function message(value, max = 4096) {
26
+ if (typeof value !== 'string')
27
+ return undefined;
28
+ const normalized = value.trim();
29
+ if (!normalized)
30
+ return undefined;
31
+ return normalized.length <= max ? normalized : `${normalized.slice(0, max - 1)}…`;
32
+ }
33
+ // The provider-neutral Inbox projection: classify a Hook edge, then emit the one ordered operation plus the
34
+ // recoverable snapshot that describes the same moment. `null` classification clears the run's Inbox item
35
+ // (the pane stays present through process identity); a state with no event ID can never notify.
36
+ export function projectHookInbox({ classify, eventId, src, sourceOccurredAt, payload, }) {
37
+ const classified = classify(src, payload);
38
+ const state = classified ? canonicalInboxState(classified.kind) : null;
39
+ const safeEventId = eventId && HOOK_EVENT_ID_RE.test(eventId) ? eventId : undefined;
40
+ const occurred = typeof sourceOccurredAt === 'number' && Number.isFinite(sourceOccurredAt)
41
+ ? sourceOccurredAt : undefined;
42
+ const base = {
43
+ ...(safeEventId === undefined ? {} : { eventId: safeEventId }),
44
+ ...(occurred === undefined ? {} : { sourceOccurredAt: occurred }),
45
+ };
46
+ if (!state) {
47
+ return {
48
+ operation: { kind: 'clear', ...base },
49
+ snapshot: { availability: 'ready' },
50
+ };
51
+ }
52
+ const text = message(classified?.msg);
53
+ const item = {
54
+ state,
55
+ ...(text === undefined ? {} : { message: text }),
56
+ ...((state === 'waiting' || state === 'done' || state === 'error') && safeEventId
57
+ ? { eventId: safeEventId } : {}),
58
+ ...(occurred === undefined ? {} : { sourceOccurredAt: occurred }),
59
+ };
60
+ return {
61
+ operation: {
62
+ kind: 'set',
63
+ ...item,
64
+ ...(state === 'working' && safeEventId ? { eventId: safeEventId } : {}),
65
+ },
66
+ snapshot: { availability: 'ready', current: item },
67
+ };
68
+ }
69
+ function optionalSession(payload, explicit) {
70
+ if (typeof explicit === 'string' && explicit.length > 0 && explicit.length <= 1024)
71
+ return explicit;
72
+ const value = payload.session_id;
73
+ return typeof value === 'string' && value.length > 0 && value.length <= 1024 ? value : undefined;
74
+ }
75
+ // The per-connection identity: a new session, or a new process generation on the same pane, is a different
76
+ // bridge client (and a different local state file). The Agent is deliberately absent — each Connector owns
77
+ // its own state directory, so including it would only rename every existing Claude client's file.
78
+ function signature(paneId, sessionId, process) {
79
+ return JSON.stringify({
80
+ paneId, sessionId, pid: process.pid, startedAt: process.startedAt, tty: process.tty,
81
+ });
82
+ }
83
+ function digest(value) {
84
+ return crypto.createHash('sha256').update(value).digest('hex');
85
+ }
86
+ // A gap marker is per pane+session+process generation: confirming it must not swallow the next session's
87
+ // gap on the same reused pane.
88
+ function gapKey(file, event) {
89
+ return JSON.stringify({
90
+ marker: path.basename(file),
91
+ paneId: event.paneId,
92
+ sessionId: event.sessionId ?? null,
93
+ process: event.process ?? null,
94
+ eventId: event.eventId,
95
+ });
96
+ }
97
+ function candidate(profile, paneId, sessionId, process) {
98
+ const processRef = {
99
+ pid: process.pid,
100
+ ...(process.startedAt === undefined ? {} : { startedAt: process.startedAt }),
101
+ ...(process.tty === undefined ? {} : { tty: process.tty }),
102
+ };
103
+ return {
104
+ paneId,
105
+ attachmentId: `${profile.attachmentPrefix}:${digest(JSON.stringify({ paneId, process: processRef }))}`,
106
+ ...(sessionId === undefined ? {} : { sessionId }),
107
+ process: processRef,
108
+ };
109
+ }
110
+ // A record's OWN process fingerprint is the only identity a Hook record carries: the notify script read it off
111
+ // the pane's tty when the record was written. Deciding whether the record still belongs to this pane therefore
112
+ // does not start from the pane's foreground GROUP — while the Agent runs a tool, that group's foreground owner
113
+ // IS the tool, so a scan names a different process (or none) from one attempt to the next. The attachment is
114
+ // keyed by the process, so a changing answer mints a new attachment — and so a new run — on every retry, which
115
+ // is what makes an Agent's row blink in and out of the roster, and what discarded events that carried a real
116
+ // state change.
117
+ //
118
+ // The ladder is deliberately conservative: every rung rejects only on positive evidence, and a rung that
119
+ // cannot answer hands the question to the next one.
120
+ // 1. No fingerprint — a record from an older writer. Accept; the legacy pane check owns it.
121
+ // 2. A tty that positively belongs to a different pane — reject. A pane's tty is its stable identity.
122
+ // 3. A liveness probe (every production host supplies one) confirms the record's own pid: alive with the
123
+ // recorded start time is this generation, and the same pid with a different start time is a recycled pid
124
+ // — reject. The foreground group is not consulted at all, which is what removes the churn.
125
+ // 4. No probe, or a probe that could not confirm the process (gone, or unreadable — the probe reports both
126
+ // the same way): compare against the pane's foreground group, exactly as before. This rung has to stay:
127
+ // without it a record from a dead generation could never retire, and since only one acknowledgement is
128
+ // ever in flight per pane it would starve that pane's live events forever.
129
+ async function recordStillOwnsPane(fingerprint, pane, inspectProcess, foreground) {
130
+ if (!fingerprint)
131
+ return true;
132
+ if (fingerprint.tty && pane.tty
133
+ && normalizedTty(fingerprint.tty) !== normalizedTty(pane.tty))
134
+ return false;
135
+ if (inspectProcess) {
136
+ let alive = null;
137
+ try {
138
+ alive = await inspectProcess(fingerprint.pid);
139
+ }
140
+ catch {
141
+ alive = null;
142
+ }
143
+ if (alive)
144
+ return alive.startedAt === undefined || alive.startedAt === fingerprint.startedAt;
145
+ }
146
+ const current = await foreground();
147
+ return !current || matchesHookProcess(fingerprint, current);
148
+ }
149
+ export class HookBridgeConnector {
150
+ #profile;
151
+ #socketPath;
152
+ #credentialFile;
153
+ #stateDirectory;
154
+ #hookStateFile;
155
+ #eventDirectory;
156
+ #panes;
157
+ #process;
158
+ #pollMs;
159
+ #spoolMaxFiles;
160
+ #spoolRetentionMs;
161
+ #retryDelayMs;
162
+ #maxRetryDelayMs;
163
+ #logger;
164
+ #createClient;
165
+ #clients = new Map();
166
+ #confirmedGaps = new Set();
167
+ #pendingAcks = new Map();
168
+ #timer;
169
+ #tail = Promise.resolve();
170
+ #started = false;
171
+ #closed = false;
172
+ constructor({ profile, 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, }) {
173
+ if (!profile || !profile.agentId || !profile.attachmentPrefix
174
+ || ![socketPath, credentialFile, stateDirectory, hookStateFile, eventDirectory].every(path.isAbsolute)
175
+ || !panes || !process || !Number.isSafeInteger(pollMs) || pollMs < 50
176
+ || !Number.isSafeInteger(retryDelayMs) || retryDelayMs <= 0
177
+ || !Number.isSafeInteger(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs
178
+ || !Number.isSafeInteger(spoolMaxFiles) || spoolMaxFiles <= 0
179
+ || !Number.isSafeInteger(spoolRetentionMs) || spoolRetentionMs <= 0) {
180
+ throw new TypeError(`${profile?.label ?? 'Hook'} Hook Bridge Connector requires private paths and Runtime identity sources`);
181
+ }
182
+ this.#profile = profile;
183
+ this.#socketPath = socketPath;
184
+ this.#credentialFile = credentialFile;
185
+ this.#stateDirectory = stateDirectory;
186
+ this.#hookStateFile = hookStateFile;
187
+ this.#eventDirectory = eventDirectory;
188
+ this.#panes = panes;
189
+ this.#process = process;
190
+ this.#pollMs = pollMs;
191
+ this.#retryDelayMs = retryDelayMs;
192
+ this.#maxRetryDelayMs = maxRetryDelayMs;
193
+ this.#spoolMaxFiles = spoolMaxFiles;
194
+ this.#spoolRetentionMs = spoolRetentionMs;
195
+ this.#logger = logger;
196
+ this.#createClient = createClient;
197
+ }
198
+ start() {
199
+ if (this.#closed)
200
+ throw new Error(`${this.#profile.label} Hook Bridge Connector is closed`);
201
+ if (this.#started)
202
+ return;
203
+ this.#started = true;
204
+ this.#poll();
205
+ }
206
+ reconcile() {
207
+ const pending = this.#tail.then(() => this.#reconcile());
208
+ this.#tail = pending.catch((error) => {
209
+ this.#logger(`${this.#profile.label} Hook Bridge reconciliation failed`, error);
210
+ });
211
+ return pending;
212
+ }
213
+ async close() {
214
+ if (this.#closed)
215
+ return;
216
+ this.#closed = true;
217
+ this.#profile.nativeTail?.clear();
218
+ if (this.#timer)
219
+ clearTimeout(this.#timer);
220
+ this.#timer = undefined;
221
+ for (const tracked of this.#clients.values())
222
+ tracked.client.close();
223
+ this.#clients.clear();
224
+ await this.#tail.catch(() => { });
225
+ for (const tracked of this.#clients.values())
226
+ tracked.client.close();
227
+ this.#clients.clear();
228
+ await Promise.allSettled([...this.#pendingAcks.values()].map((pending) => pending.promise));
229
+ }
230
+ #poll() {
231
+ if (this.#closed)
232
+ return;
233
+ void this.reconcile().catch(() => { }).finally(() => {
234
+ if (this.#closed)
235
+ return;
236
+ this.#timer = setTimeout(() => this.#poll(), this.#pollMs);
237
+ this.#timer.unref?.();
238
+ });
239
+ }
240
+ #eventFiles() {
241
+ try {
242
+ return fs.readdirSync(this.#eventDirectory)
243
+ .filter((name) => /^(event-\d+-\d+|gap-[a-f0-9]+)\.json$/.test(name))
244
+ .sort((first, second) => {
245
+ const firstGap = first.startsWith('gap-');
246
+ const secondGap = second.startsWith('gap-');
247
+ return firstGap === secondGap ? first.localeCompare(second) : firstGap ? -1 : 1;
248
+ })
249
+ .map((name) => path.join(this.#eventDirectory, name));
250
+ }
251
+ catch {
252
+ return [];
253
+ }
254
+ }
255
+ // Retention for Hook source files that never reach an acknowledgement: a pane that is gone, a client
256
+ // that never drains, or a durable publish that keeps failing leaves its file behind, and every poll
257
+ // re-reads it — an observed 170k files over nine days. Files are listed oldest-first, so a cap drops
258
+ // the stuck ones and an age window only ever expires files no one is waiting on. `gap-*` markers are
259
+ // not touched: they are bounded by pane/session and carry a degradation signal until confirmed.
260
+ #pruneSpool(files) {
261
+ const events = files.filter((file) => path.basename(file).startsWith('event-'));
262
+ if (events.length === 0)
263
+ return files;
264
+ const now = Date.now();
265
+ const dropped = new Set();
266
+ for (const [index, file] of events.entries()) {
267
+ const beyondCap = this.#spoolMaxFiles > 0 && index < events.length - this.#spoolMaxFiles;
268
+ let expired = false;
269
+ try {
270
+ expired = now - fs.statSync(file).mtimeMs > this.#spoolRetentionMs;
271
+ }
272
+ catch { /* removed */ }
273
+ if (beyondCap || expired)
274
+ dropped.add(file);
275
+ }
276
+ if (dropped.size === 0)
277
+ return files;
278
+ for (const file of dropped) {
279
+ try {
280
+ fs.unlinkSync(file);
281
+ }
282
+ catch { /* concurrently removed */ }
283
+ }
284
+ this.#logger?.(`Dropped ${this.#profile.label} Hook source files that could not be delivered`, { dropped: dropped.size, remaining: events.length - dropped.size });
285
+ return files.filter((file) => !dropped.has(file));
286
+ }
287
+ // The pane's provider process — the lease anchor the Connector publishes and the identity every recorded
288
+ // Hook edge must belong to. Profiles that prove a non-leaf anchor override it; the rest use the leaf.
289
+ #inspectPane(pane) {
290
+ return this.#profile.resolvePaneProcess
291
+ ? this.#profile.resolvePaneProcess(pane, this.#process)
292
+ : this.#process.inspectForeground(pane);
293
+ }
294
+ async #reconcile() {
295
+ if (this.#closed)
296
+ return;
297
+ const profile = this.#profile;
298
+ let panes;
299
+ try {
300
+ panes = await this.#panes.list();
301
+ }
302
+ catch {
303
+ return;
304
+ }
305
+ const paneMap = new Map(panes.map((pane) => [pane.paneId, pane]));
306
+ const identities = new Map();
307
+ const identity = (pane) => {
308
+ let pending = identities.get(pane.paneId);
309
+ if (!pending) {
310
+ pending = this.#inspectPane(pane);
311
+ identities.set(pane.paneId, pending);
312
+ }
313
+ return pending;
314
+ };
315
+ const events = [];
316
+ for (const eventFile of this.#pruneSpool(this.#eventFiles())) {
317
+ let event = null;
318
+ try {
319
+ const stat = fs.lstatSync(eventFile);
320
+ if (stat.isFile()) {
321
+ event = parseHookBridgeEvent(JSON.parse(fs.readFileSync(eventFile, 'utf8')), (agent) => profile.acceptsAgent(agent));
322
+ }
323
+ }
324
+ catch { /* partial/removed file */ }
325
+ if (event)
326
+ events.push({ file: eventFile, event });
327
+ }
328
+ const state = readHookStateRows(this.#hookStateFile, (agent) => profile.acceptsAgent(agent));
329
+ for (const [paneId, pending] of this.#pendingAcks) {
330
+ const pane = paneMap.get(paneId);
331
+ const latest = state.get(paneId);
332
+ const latestSession = latest ? optionalSession(latest.payload) : undefined;
333
+ const anchor = latest?.process;
334
+ const replacedSession = anchor && latestSession && pending.sessionId && latestSession !== pending.sessionId;
335
+ const generationMoved = !!anchor && !matchesHookProcess(anchor, pending.process);
336
+ if (replacedSession || !pane || generationMoved
337
+ || !await recordStillOwnsPane(anchor, pane, this.#process.inspectProcess, () => identity(pane))) {
338
+ pending.client.close();
339
+ }
340
+ }
341
+ const gaps = new Map();
342
+ const activeGapKeys = new Set(events.flatMap(({ file, event }) => (event.type === 'gap' ? [gapKey(file, event)] : [])));
343
+ for (const key of this.#confirmedGaps) {
344
+ if (!activeGapKeys.has(key))
345
+ this.#confirmedGaps.delete(key);
346
+ }
347
+ for (const { file: eventFile, event } of events) {
348
+ if (event.type !== 'gap')
349
+ continue;
350
+ const pane = paneMap.get(event.paneId);
351
+ if (!pane)
352
+ continue;
353
+ if (!await recordStillOwnsPane(event.process, pane, this.#process.inspectProcess, () => identity(pane))) {
354
+ this.#logger(`Discarding stale ${profile.label} Hook event after pane process replacement`, {
355
+ eventId: event.eventId,
356
+ paneId: event.paneId,
357
+ });
358
+ try {
359
+ fs.unlinkSync(eventFile);
360
+ }
361
+ catch { /* concurrently removed */ }
362
+ continue;
363
+ }
364
+ const foreground = event.process ?? (await identity(pane));
365
+ if (!foreground)
366
+ continue;
367
+ if (!event.process && !profile.matchesAgentPane(pane, foreground))
368
+ continue;
369
+ const latest = state.get(event.paneId);
370
+ const latestSession = latest ? optionalSession(latest.payload) : undefined;
371
+ const gapSession = optionalSession(event.payload ?? {}, event.sessionId);
372
+ if (latestSession && gapSession && latestSession !== gapSession) {
373
+ try {
374
+ fs.unlinkSync(eventFile);
375
+ }
376
+ catch { /* replaced session already retired the gap */ }
377
+ continue;
378
+ }
379
+ gaps.set(event.paneId, { event, key: gapKey(eventFile, event) });
380
+ }
381
+ const currentStatePanes = new Set();
382
+ const currentNativeSessions = new Set();
383
+ const persistedSnapshots = new Map();
384
+ for (const [paneId, row] of state) {
385
+ const pane = paneMap.get(paneId);
386
+ if (!pane)
387
+ continue;
388
+ // Same rule as the published events: the row's own fingerprint decides, and a row is only skipped when
389
+ // that fingerprint is positively a different process generation. Skipping it because a foreground scan
390
+ // disagreed is what made a live pane's state vanish.
391
+ if (!await recordStillOwnsPane(row.process, pane, this.#process.inspectProcess, () => identity(pane)))
392
+ continue;
393
+ const foreground = row.process ?? (await identity(pane));
394
+ if (!foreground)
395
+ continue;
396
+ if (!row.process && !profile.matchesAgentPane(pane, foreground))
397
+ continue;
398
+ currentStatePanes.add(paneId);
399
+ // A cancelled old-session ACK settles asynchronously; switch clients once it has released the pane.
400
+ const sessionId = optionalSession(row.payload);
401
+ if (sessionId && row.src !== 'end')
402
+ currentNativeSessions.add(sessionId);
403
+ const pending = this.#pendingAcks.get(paneId);
404
+ if (pending && pending.sessionId !== sessionId)
405
+ continue;
406
+ const eventId = row.sequence === undefined ? undefined : `${profile.eventPrefix}-${row.sequence}`;
407
+ const projection = profile.project({
408
+ ...(eventId === undefined ? {} : { eventId }),
409
+ src: row.src,
410
+ sourceOccurredAt: row.ts,
411
+ payload: row.payload,
412
+ });
413
+ if (row.src !== 'end') {
414
+ const native = profile.nativeTail?.read(row.payload, row.ts, Date.now(), row.process, row.src);
415
+ if (native?.settled)
416
+ projection.snapshot = { availability: 'ready' };
417
+ else if (native?.status === 'busy' || native?.status === 'waiting') {
418
+ projection.snapshot = { availability: 'ready', current: {
419
+ state: native.status === 'busy' ? 'working' : 'waiting',
420
+ eventId: native.statusEventId,
421
+ } };
422
+ }
423
+ else if (native?.status === 'unknown')
424
+ projection.snapshot = { availability: 'unavailable' };
425
+ }
426
+ const snapshot = gaps.has(paneId) && projection.snapshot.availability !== 'unavailable' ? {
427
+ ...projection.snapshot,
428
+ availability: 'degraded',
429
+ message: `${profile.label} Hook event history is incomplete`,
430
+ } : projection.snapshot;
431
+ persistedSnapshots.set(paneId, this.#client(paneId, sessionId, foreground).setSnapshot('inbox', {
432
+ ...snapshot,
433
+ ...(row.process && row.sequence !== undefined ? { sourceSequence: row.sequence } : {}),
434
+ }));
435
+ }
436
+ profile.nativeTail?.retain(currentNativeSessions);
437
+ const liveAgentPanes = new Set(currentStatePanes);
438
+ for (const pane of panes) {
439
+ if (currentStatePanes.has(pane.paneId))
440
+ continue;
441
+ const foreground = await identity(pane);
442
+ if (!foreground || !profile.matchesAgentPane(pane, foreground))
443
+ continue;
444
+ liveAgentPanes.add(pane.paneId);
445
+ if (this.#pendingAcks.has(pane.paneId))
446
+ continue;
447
+ const gap = gaps.get(pane.paneId)?.event;
448
+ const sessionId = gap ? optionalSession(gap.payload ?? {}, gap.sessionId) : undefined;
449
+ persistedSnapshots.set(pane.paneId, this.#client(pane.paneId, sessionId, foreground).setSnapshot('inbox', gap ? {
450
+ availability: 'degraded',
451
+ message: `${profile.label} Hook event history is incomplete`,
452
+ } : { availability: 'ready' }));
453
+ }
454
+ for (const { file: eventFile, event } of events) {
455
+ const gap = event.type === 'gap' ? gaps.get(event.paneId) : undefined;
456
+ if (event.type === 'gap' && gap?.event !== event)
457
+ continue;
458
+ const pane = paneMap.get(event.paneId);
459
+ if (!pane)
460
+ continue;
461
+ // The event names its own process: the notify script captured it from this pane's tty when the event
462
+ // fired, and the Runtime verifies that exact generation — alive, same start time, same tty — before it
463
+ // authorizes a run.
464
+ const foreground = event.process ?? (await identity(pane));
465
+ if (!foreground)
466
+ continue;
467
+ if (!event.process && !profile.matchesAgentPane(pane, foreground))
468
+ continue;
469
+ // A disconnected/rejected pane must not suspend the shared poller or another pane's events.
470
+ // Keep one acknowledgement in flight per pane to retain its lifecycle ordering.
471
+ if (this.#pendingAcks.has(event.paneId))
472
+ continue;
473
+ if (!await recordStillOwnsPane(event.process, pane, this.#process.inspectProcess, () => identity(pane))) {
474
+ this.#logger(`Discarding stale ${profile.label} Hook event after pane process replacement`, {
475
+ eventId: event.eventId,
476
+ paneId: event.paneId,
477
+ });
478
+ try {
479
+ fs.unlinkSync(eventFile);
480
+ }
481
+ catch { /* concurrently removed */ }
482
+ continue;
483
+ }
484
+ const payload = event.payload ?? {};
485
+ const sessionId = optionalSession(payload, event.sessionId);
486
+ const latest = state.get(event.paneId);
487
+ const latestSession = latest ? optionalSession(latest.payload) : undefined;
488
+ // A current pane session must never be replaced just to replay another session's old queue.
489
+ // Retain that session's source and original durable payload for a legitimate future resume.
490
+ if (latest?.process && matchesHookProcess(latest.process, foreground)
491
+ && latestSession && sessionId && latestSession !== sessionId)
492
+ continue;
493
+ const client = this.#client(event.paneId, sessionId, foreground);
494
+ if (event.type === 'gap' && persistedSnapshots.get(event.paneId) !== true)
495
+ continue;
496
+ if (event.type === 'gap' && gap && this.#confirmedGaps.has(gap.key))
497
+ continue;
498
+ const projection = event.type === 'gap' ? {
499
+ operation: { kind: 'gap', eventId: event.eventId },
500
+ } : profile.project({
501
+ eventId: event.eventId,
502
+ src: event.src ?? '',
503
+ ...(event.sourceOccurredAt === undefined ? {} : {
504
+ sourceOccurredAt: event.sourceOccurredAt,
505
+ }),
506
+ payload,
507
+ });
508
+ const operation = projection.operation;
509
+ const restoredAck = client.resumePersistedDurable('inbox', event.eventId);
510
+ if (!restoredAck && !client.publishDurable('inbox', event.eventId, operation)) {
511
+ this.#logger(`${profile.label} Hook event remains queued because Connector durable state could not be persisted`, {
512
+ eventId: event.eventId,
513
+ paneId: event.paneId,
514
+ });
515
+ continue;
516
+ }
517
+ const pending = (restoredAck ?? client.waitForDurableAck('inbox', event.eventId)).then(() => {
518
+ if (event.type === 'gap' && gap)
519
+ this.#confirmedGaps.add(gap.key);
520
+ else
521
+ fs.unlinkSync(eventFile);
522
+ }).catch((error) => {
523
+ if (!this.#closed) {
524
+ this.#logger(`${profile.label} Hook acknowledgement interrupted; source event remains queued`, error);
525
+ }
526
+ }).finally(() => { this.#pendingAcks.delete(event.paneId); });
527
+ this.#pendingAcks.set(event.paneId, { promise: pending, client, process: foreground, sessionId });
528
+ }
529
+ for (const [paneId, tracked] of [...this.#clients]) {
530
+ if (liveAgentPanes.has(paneId))
531
+ continue;
532
+ tracked.client.close();
533
+ this.#clients.delete(paneId);
534
+ }
535
+ }
536
+ #client(paneId, sessionId, foreground) {
537
+ const profile = this.#profile;
538
+ const nextSignature = signature(paneId, sessionId, foreground);
539
+ const current = this.#clients.get(paneId);
540
+ if (current?.signature === nextSignature)
541
+ return current.client;
542
+ current?.client.close();
543
+ const stateFile = path.join(this.#stateDirectory, `${digest(nextSignature)}.json`);
544
+ const client = this.#createClient({
545
+ adapterId: profile.agentId,
546
+ snapshotBeforeDurable: true,
547
+ socketPath: this.#socketPath,
548
+ credentialFile: this.#credentialFile,
549
+ stateFile,
550
+ candidate: candidate(profile, paneId, sessionId, foreground),
551
+ generation: { id: `${profile.agentId}-session:${digest(sessionId ?? nextSignature)}`, replace: true },
552
+ retryDelayMs: this.#retryDelayMs,
553
+ maxRetryDelayMs: this.#maxRetryDelayMs,
554
+ logger: this.#logger,
555
+ });
556
+ this.#clients.set(paneId, { signature: nextSignature, client });
557
+ client.start();
558
+ return client;
559
+ }
560
+ }
@@ -0,0 +1,116 @@
1
+ #!/bin/sh
2
+ # handmux 上报 hook(CodeBuddy Code). $1 = 事件 src 名:
3
+ # start(SessionStart) | prompt(UserPromptSubmit) | stop(Stop) | end(SessionEnd) | notify(Notification)
4
+ # | resume(PostToolUse on the interaction tools) | permreq(PermissionRequest) | permdenied(PermissionDenied) | compacting(PreCompact)
5
+ # | compact(PostCompact) | stopfail(StopFailure)
6
+ # stdin = CodeBuddy 原始 payload(JSON).
7
+ # 只做本地落盘:更新 JSON latest state,并在启用时追加有界 Bridge event spool。不联网、不依赖服务进程
8
+ # 是否在跑 → 永不阻塞 CodeBuddy(始终 exit 0)。服务端/Connector 稍后消费这些文件。
9
+ # 真正的读-改-写交给同目录的 handmux-write.cjs(node:真 JSON 解析 + 文件锁,多 pane 并发 hook 不丢更新)。
10
+ # 与 Claude 版(handmux-notify.sh)的差别只有三处:配置目录、事件名表、进程身份判定 —— 详见各自注释。
11
+ HANDMUX_AGENT=codebuddy
12
+ export HANDMUX_AGENT
13
+ CFG="$(dirname "$0")/handmux-codebuddy-notify.env"
14
+ [ -f "$CFG" ] && . "$CFG"
15
+ PANE="$TMUX_PANE"
16
+ [ -z "$PANE" ] && exit 0 # 不在 tmux 里(没有 pane 可定位)→ 暂不记录
17
+ # The installer registers the canonical src as the command's argument. Accept the raw hook event name too:
18
+ # a hand-written or project-level settings.json can then point straight at `<script> <EventName>`, and both
19
+ # spellings collapse into the one vocabulary the Connector reads. Anything else is not ours → touch nothing.
20
+ case "$1" in
21
+ start|prompt|stop|end|notify|resume|permreq|permdenied|compacting|compact|stopfail) SRC="$1" ;;
22
+ SessionStart) SRC='start' ;;
23
+ UserPromptSubmit) SRC='prompt' ;;
24
+ Stop) SRC='stop' ;;
25
+ SessionEnd) SRC='end' ;;
26
+ Notification) SRC='notify' ;;
27
+ # The user answered a question or approved a plan: the wait is over and the turn continues. Registered with
28
+ # a matcher, so this only ever fires for those two tools.
29
+ PostToolUse) SRC='resume' ;;
30
+ PermissionRequest) SRC='permreq' ;;
31
+ PermissionDenied) SRC='permdenied' ;;
32
+ PreCompact) SRC='compacting' ;;
33
+ PostCompact) SRC='compact' ;;
34
+ StopFailure) SRC='stopfail' ;;
35
+ *) exit 0 ;;
36
+ esac
37
+ FILE="${HANDMUX_STATE:-$HOME/.codebuddy/handmux-state.json}"
38
+ # 毫秒时间戳:与旧实现的 Date.now() 同单位(客户端已阅水位线兼容)。优先 perl,退化到 秒×1000。
39
+ TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null)
40
+ [ -z "$TS" ] && TS=$(( $(date +%s) * 1000 ))
41
+ HOST=$(hostname 2>/dev/null || printf '')
42
+ # Capture the owning CodeBuddy process rather than this short-lived Hook process. The fingerprint lets the
43
+ # Connector reject an offline event after tmux has reused the pane for a different CodeBuddy generation.
44
+ # Missing/incomplete identity is intentionally tolerated for older CodeBuddy/platform variants: the writer
45
+ # then omits the additive field and the legacy latest-state path keeps working.
46
+ CODEBUDDY_PID=''
47
+ CODEBUDDY_STARTED_AT=''
48
+ CODEBUDDY_TTY=''
49
+ # CodeBuddy's process is a Node CLI whose comm is meaningless for identity (`node`, or a version string), so
50
+ # this hook matches the COMMAND LINE instead. A row is CodeBuddy when its PROGRAM token is one of the
51
+ # published launchers (`codebuddy`, `codebuddy-code`, `cbc` — the native build) or when `node` runs a
52
+ # CodeBuddy entry point (the npm layout: `node …/@tencent-ai/codebuddy-code/bin/codebuddy`).
53
+ # The match is deliberately NOT a substring over the whole line: this hook's own path lives under
54
+ # ~/.codebuddy/, and CodeBuddy runs configured hooks through a wrapper that names that path — a whole-line
55
+ # match would crown that short-lived wrapper as the session owner. Only the program/entry tokens are read.
56
+ # Input rows are `pid command…`; the pid prints only when EXACTLY ONE row matches, so an ambiguous pane
57
+ # stays unresolved and the writer omits the fingerprint instead of pinning a wrong process generation.
58
+ CODEBUDDY_OWNER_AWK='
59
+ function base(value) { sub(/.*\//, "", value); return value }
60
+ function is_codebuddy( i, program, entry) {
61
+ program = $2
62
+ sub(/^-/, "", program)
63
+ program = base(program)
64
+ if (program == "codebuddy" || program == "codebuddy-code" || program == "cbc") return 1
65
+ if (program != "node") return 0
66
+ for (i = 3; i <= NF; i++) {
67
+ if ($i ~ /^-/) continue
68
+ entry = base($i)
69
+ return (entry == "codebuddy" || entry == "codebuddy-code" || entry == "cbc") ? 1 : 0
70
+ }
71
+ return 0
72
+ }
73
+ { if (is_codebuddy()) { count += 1; only = $1 } }
74
+ END { if (count == 1) print only }
75
+ '
76
+ # Walk up from PPID first: it needs no tmux round-trip and, for a synchronous hook, names the Agent exactly.
77
+ # The depth cap keeps a reparented/corrupted chain (PID 1 loops, container init) from spinning forever.
78
+ ANCESTOR_PID=$PPID
79
+ ANCESTOR_DEPTH=0
80
+ while [ -n "$ANCESTOR_PID" ] && [ "$ANCESTOR_PID" -gt 1 ] 2>/dev/null && [ "$ANCESTOR_DEPTH" -lt 32 ]; do
81
+ ANCESTOR_COMMAND=$(ps -p "$ANCESTOR_PID" -o command= 2>/dev/null || printf '')
82
+ if [ -n "$ANCESTOR_COMMAND" ]; then
83
+ CODEBUDDY_PID=$(printf '%s %s\n' "$ANCESTOR_PID" "$ANCESTOR_COMMAND" | awk "$CODEBUDDY_OWNER_AWK")
84
+ fi
85
+ [ -n "$CODEBUDDY_PID" ] && break
86
+ ANCESTOR_PID=$(ps -p "$ANCESTOR_PID" -o ppid= 2>/dev/null | tr -d ' ')
87
+ ANCESTOR_DEPTH=$((ANCESTOR_DEPTH + 1))
88
+ done
89
+ if [ -z "$CODEBUDDY_PID" ]; then
90
+ # CodeBuddy runs configured hooks asynchronously, so the short-lived shell may already have been
91
+ # reparented. The pane's own TTY is still authoritative: scan it and take the single CodeBuddy row.
92
+ PANE_TTY=$(tmux display-message -p -t "$PANE" '#{pane_tty}' 2>/dev/null || printf '')
93
+ if [ -n "$PANE_TTY" ]; then
94
+ CODEBUDDY_PID=$(ps -t "${PANE_TTY#/dev/}" -o pid=,command= 2>/dev/null \
95
+ | awk "$CODEBUDDY_OWNER_AWK")
96
+ fi
97
+ fi
98
+ if [ -n "$CODEBUDDY_PID" ]; then
99
+ # Start time in the same form the server reads (processStartedAt in tmuxRuntime.ts): the raw procfs start
100
+ # tick where procfs exists, otherwise the `lstart` string. A wall-clock `lstart` moves whenever the host
101
+ # steps the guest clock (WSL2), which makes one live process look like several. The proc root is
102
+ # overridable so tests can supply a fixture instead of the host's live process table.
103
+ PROC_ROOT="${HANDMUX_PROC_ROOT:-/proc}"
104
+ # comm may contain spaces/parentheses: drop "pid (comm) " first, then starttime is field 20.
105
+ CODEBUDDY_STARTED_AT=$(sed 's/.*) //' "$PROC_ROOT/$CODEBUDDY_PID/stat" 2>/dev/null \
106
+ | awk '{ print $20 }' 2>/dev/null || printf '')
107
+ if [ -z "$CODEBUDDY_STARTED_AT" ]; then
108
+ # `lstart` follows the process locale on macOS; force C so standalone Node can parse it consistently.
109
+ CODEBUDDY_STARTED_AT=$(LC_ALL=C ps -p "$CODEBUDDY_PID" -o lstart= 2>/dev/null || printf '')
110
+ fi
111
+ CODEBUDDY_TTY=$(ps -p "$CODEBUDDY_PID" -o tty= 2>/dev/null || printf '')
112
+ fi
113
+ # payload 经 stdin 原样流给 node(不在 shell 里转义,避免坏数据);pane 含 '%' 直接进 JSON 字段,不再进 URL。
114
+ node "$(dirname "$0")/handmux-write.cjs" "$FILE" "$PANE" "$SRC" "$TS" "$HOST" \
115
+ "${HANDMUX_CODEBUDDY_EVENTS:-}" "$CODEBUDDY_PID" "$CODEBUDDY_STARTED_AT" "$CODEBUDDY_TTY" 2>/dev/null || true
116
+ exit 0