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.
- package/dist/connectors/claude/index.js +33 -562
- package/dist/connectors/codebuddy/index.js +49 -0
- package/dist/connectors/hookBridge.js +560 -0
- package/dist/hooks/handmux-codebuddy-notify.sh +116 -0
- package/dist/hooks/handmux-notify.sh +2 -0
- package/dist/hooks/handmux-write.cjs +72 -5
- package/dist/package.json +13 -13
- package/dist/public/assets/_basePickBy-BzC0P9Wg.js +1 -0
- package/dist/public/assets/_baseUniq-DNOfPYVY.js +1 -0
- package/dist/public/assets/abnfDiagram-O67JEVCF-BsRDIQEB.js +1 -0
- package/dist/public/assets/arc-DLtLfMnX.js +1 -0
- package/dist/public/assets/architectureDiagram-NJMV4G6O-DoIXhWqZ.js +36 -0
- package/dist/public/assets/blockDiagram-BEXU5L5S-W0HiOcVA.js +140 -0
- package/dist/public/assets/c4Diagram-YGBWAQC7-72uAUMvX.js +38 -0
- package/dist/public/assets/channel-CzALSnb0.js +1 -0
- package/dist/public/assets/chunk-2Q5K7J3B-B9wSRFQ0.js +1 -0
- package/dist/public/assets/chunk-3FUC2YCW-ryphp4s0.js +2 -0
- package/dist/public/assets/chunk-5VM5RSS4-Cm4KeKby.js +15 -0
- package/dist/public/assets/chunk-GTNCS2PH-Du4_i7r3.js +88 -0
- package/dist/public/assets/chunk-GWA4HPMP-CL1biVIA.js +1 -0
- package/dist/public/assets/chunk-JWPE2WC7-B8eAVfuB.js +1 -0
- package/dist/public/assets/chunk-NETBCI7D-D273GzYg.js +1 -0
- package/dist/public/assets/chunk-WEXAMYUT-DipTfRbc.js +1 -0
- package/dist/public/assets/chunk-XXDRQBXY-D4SqXrQ8.js +1 -0
- package/dist/public/assets/classDiagram-v2-NBCMYWYE-PrpnIXcA.js +217 -0
- package/dist/public/assets/cose-bilkent-JH36ORCC-BfcYMYTa.js +1 -0
- package/dist/public/assets/cynefin-OW5HDTMX-Du659bmz.js +178 -0
- package/dist/public/assets/cynefinDiagram-VND7K2PF-DR_2Copy.js +62 -0
- package/dist/public/assets/cytoscape.esm-C5JX3QLV.js +331 -0
- package/dist/public/assets/dagre-6A5THRUB-COsChaV5.js +4 -0
- package/dist/public/assets/defaultLocale-CrowFXzY.js +1 -0
- package/dist/public/assets/diagram-22UHCM2B-Cx2T1HvL.js +200 -0
- package/dist/public/assets/diagram-3UASUU5V-Cp04LOka.js +24 -0
- package/dist/public/assets/diagram-ATOU4E4O-DlqFSshY.js +3 -0
- package/dist/public/assets/diagram-CDSNMT55-goq40ctA.js +30 -0
- package/dist/public/assets/diagram-MLGK6HIB-Bdzjsy4r.js +24 -0
- package/dist/public/assets/diagram-MPIPVDR6-DtU7cM6P.js +41 -0
- package/dist/public/assets/ebnfDiagram-ZINNZB2B-XyVCfmED.js +1 -0
- package/dist/public/assets/elk-276RUBZZ-aHVIw-2x.js +27 -0
- package/dist/public/assets/erDiagram-OPXOYQCR-Bq_PhTIL.js +99 -0
- package/dist/public/assets/flowDiagram-KWPJA3E3-WuqnDSiF.js +213 -0
- package/dist/public/assets/ganttDiagram-FUAMR5RP-DZ4lXzkC.js +292 -0
- package/dist/public/assets/gitGraphDiagram-X574FWY7-t6l07b9g.js +106 -0
- package/dist/public/assets/graph-jWsD5Ro-.js +1 -0
- package/dist/public/assets/index-DiYff8oR.css +32 -0
- package/dist/public/assets/index-YZNK723o.js +203 -0
- package/dist/public/assets/infoDiagram-VRGFBTTK-iUGqoOFp.js +2 -0
- package/dist/public/assets/init-Gi6I4Gst.js +1 -0
- package/dist/public/assets/ishikawaDiagram-OU5B5YK6-B0TmoUOK.js +70 -0
- package/dist/public/assets/journeyDiagram-ZHPQQLJL-CQE61MCu.js +139 -0
- package/dist/public/assets/kanban-definition-PNTS6WVX-D2MWCUyS.js +89 -0
- package/dist/public/assets/katex-C5jXJg4s.js +257 -0
- package/dist/public/assets/linear-WKGxVNP1.js +1 -0
- package/dist/public/assets/mermaid.core-DPEwUsaj.js +311 -0
- package/dist/public/assets/mindmap-definition-NLK3R4M7-DsYEsBsI.js +96 -0
- package/dist/public/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/public/assets/pegDiagram-GJSIUBJH-BS52Fbxt.js +1 -0
- package/dist/public/assets/pieDiagram-5QR66LMP-D4o8g7nW.js +39 -0
- package/dist/public/assets/quadrantDiagram-O4NWA36T-DnYUxWll.js +7 -0
- package/dist/public/assets/railroadDiagram-XR7U4H2S-BKy_7ULV.js +1 -0
- package/dist/public/assets/requirementDiagram-PLB6GJNP-Ce5F4u8K.js +84 -0
- package/dist/public/assets/sankeyDiagram-IPEJSGJF-CCJDrut_.js +40 -0
- package/dist/public/assets/sequenceDiagram-PO4LG4MO-D5E8ISM1.js +169 -0
- package/dist/public/assets/sizeCapture-INFHLROL-BW3KmxDN.js +1 -0
- package/dist/public/assets/stateDiagram-v2-GCMORJYK-Bw2ZzQOE.js +291 -0
- package/dist/public/assets/swimlanes-2SLR337P-CLJ-6Ex_.js +1 -0
- package/dist/public/assets/swimlanesDiagram-TC7HE7FX-BD8rQfV7.js +8 -0
- package/dist/public/assets/timeline-definition-EJHVYXUP-C_lFJ-4W.js +120 -0
- package/dist/public/assets/usecaseDiagram-POWQR4AR-DWXawL2A.js +370 -0
- package/dist/public/assets/vennDiagram-UO4OBE2U-D4O5oZal.js +34 -0
- package/dist/public/assets/wardleyDiagram-VNRHLVJA-B6HElk__.js +78 -0
- package/dist/public/assets/xychartDiagram-PMCCYNJV-C5OAXnJy.js +7 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/adapter.js +54 -10
- package/dist/src/agent-runtime/builtinRuntime.js +67 -6
- package/dist/src/agent-runtime/conversation.js +49 -9
- package/dist/src/agent-runtime/interaction.js +26 -12
- package/dist/src/agent-runtime/interactionLiveHub.js +4 -0
- package/dist/src/agent-runtime/runtime.js +83 -27
- package/dist/src/agent-runtime/tmuxRuntime.js +36 -6
- package/dist/src/agents/claude.js +2 -19
- package/dist/src/agents/claudeInteraction.js +2 -132
- package/dist/src/agents/claudePaneInput.js +22 -22
- package/dist/src/agents/codebuddy.js +100 -0
- package/dist/src/agents/codebuddyConversation.js +414 -0
- package/dist/src/agents/codebuddyInteraction.js +4 -0
- package/dist/src/agents/codebuddyNativeTail.js +206 -0
- package/dist/src/agents/codebuddyPaneInput.js +76 -0
- package/dist/src/agents/codebuddyTranscriptParse.js +178 -0
- package/dist/src/agents/hookEvents.js +143 -0
- package/dist/src/agents/hookInteraction.js +178 -0
- package/dist/src/agents/index.js +2 -1
- package/dist/src/claudeEvents.js +4 -5
- package/dist/src/cli/agentIntegration.js +31 -2
- package/dist/src/cli/codebuddyHooks.js +286 -0
- package/dist/src/cli/hookScaffold.js +25 -12
- package/dist/src/cli/i18n/en.js +1 -1
- package/dist/src/cli/i18n/zh.js +1 -1
- package/dist/src/cli/state.js +4 -0
- package/dist/src/cli/supervisor.js +2 -1
- package/dist/src/codebuddyEvents.js +151 -0
- package/dist/src/docs.js +21 -7
- package/dist/src/logDetail.js +27 -0
- package/dist/src/paneInput.js +107 -10
- package/dist/src/pendingPrompt.js +21 -5
- package/dist/src/routes/files.js +4 -1
- package/dist/src/routes/system.js +12 -2
- package/dist/src/server.js +69 -8
- package/dist/src/trimCapture.js +1 -1
- package/dist/src/workspace/checkpointer.js +48 -4
- package/dist/src/workspace/lock.js +54 -10
- package/dist/src/workspace/runtime.js +23 -8
- package/dist/src/workspaceProtocol.js +1 -0
- package/package.json +4 -4
- package/dist/public/assets/index-2ghkmGbT.js +0 -356
- package/dist/public/assets/index-BjnnFTLC.css +0 -32
|
@@ -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
|