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
|
@@ -3,8 +3,10 @@ import path from 'node:path';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { installPiExtension, PiExtensionInstallError, piExtensionStatus, uninstallPiExtension, } from './piExtension.js';
|
|
5
5
|
import { hooksHealthStatus, hooksStatus, installHooks, uninstallHooks } from './claudeHooks.js';
|
|
6
|
+
import { codebuddyStatePath } from './state.js';
|
|
7
|
+
import { hooksHealthStatus as codebuddyHooksHealthStatus, hooksStatus as codebuddyHooksStatus, installHooks as installCodebuddyHooks, uninstallHooks as uninstallCodebuddyHooks, } from './codebuddyHooks.js';
|
|
6
8
|
import { statusLineStatus, uninstallStatusLine } from './statusLine.js';
|
|
7
|
-
export const AGENT_NAMES = ['codex', 'pi', 'claude'];
|
|
9
|
+
export const AGENT_NAMES = ['codex', 'pi', 'claude', 'codebuddy'];
|
|
8
10
|
const AGENT_NAME_SET = new Set(AGENT_NAMES);
|
|
9
11
|
export function agentName(value) {
|
|
10
12
|
return typeof value === 'string' && AGENT_NAME_SET.has(value) ? value : null;
|
|
@@ -37,12 +39,13 @@ export function executableOnPath(command, env = process.env, platform = process.
|
|
|
37
39
|
}
|
|
38
40
|
return false;
|
|
39
41
|
}
|
|
40
|
-
export function defaultAgentIntegrationContext({ home = homedir(), piEntryFile, hooksSrcDir, claudeStateFile, }) {
|
|
42
|
+
export function defaultAgentIntegrationContext({ home = homedir(), piEntryFile, hooksSrcDir, claudeStateFile, codebuddyStateFile, }) {
|
|
41
43
|
return {
|
|
42
44
|
home,
|
|
43
45
|
piEntryFile,
|
|
44
46
|
hooksSrcDir,
|
|
45
47
|
claudeStateFile,
|
|
48
|
+
codebuddyStateFile: codebuddyStateFile ?? codebuddyStatePath(home),
|
|
46
49
|
executableAvailable: (command) => executableOnPath(command),
|
|
47
50
|
};
|
|
48
51
|
}
|
|
@@ -61,6 +64,16 @@ export function agentIntegrationStatus(name, context) {
|
|
|
61
64
|
return 'needs-repair';
|
|
62
65
|
return status === 'installed' ? 'ready' : 'not-enabled';
|
|
63
66
|
}
|
|
67
|
+
if (name === 'codebuddy') {
|
|
68
|
+
// Same rule as Claude: PATH decides whether CodeBuddy exists, but its first run creates ~/.codebuddy.
|
|
69
|
+
// Until that directory exists the integration is not enableable, and installHooks must never create it.
|
|
70
|
+
const status = codebuddyHooksHealthStatus(context.home);
|
|
71
|
+
if (status === 'no-codebuddy')
|
|
72
|
+
return 'not-enabled';
|
|
73
|
+
if (status === 'stale')
|
|
74
|
+
return 'needs-repair';
|
|
75
|
+
return status === 'installed' ? 'ready' : 'not-enabled';
|
|
76
|
+
}
|
|
64
77
|
let status;
|
|
65
78
|
try {
|
|
66
79
|
status = piExtensionStatus(context.home, { entryFile: context.piEntryFile });
|
|
@@ -97,6 +110,15 @@ export function enableAgentIntegration(name, context) {
|
|
|
97
110
|
const status = agentIntegrationStatus(name, context);
|
|
98
111
|
return { name, status, changed: installed.status === 'installed' && before !== 'ready' };
|
|
99
112
|
}
|
|
113
|
+
if (name === 'codebuddy') {
|
|
114
|
+
// The same script bundle and the same state-file contract as Claude, deployed into ~/.codebuddy/hooks.
|
|
115
|
+
const installed = installCodebuddyHooks(context.home, {
|
|
116
|
+
srcDir: context.hooksSrcDir,
|
|
117
|
+
stateFile: context.codebuddyStateFile ?? codebuddyStatePath(context.home),
|
|
118
|
+
});
|
|
119
|
+
const status = agentIntegrationStatus(name, context);
|
|
120
|
+
return { name, status, changed: installed.status === 'installed' && before !== 'ready' };
|
|
121
|
+
}
|
|
100
122
|
try {
|
|
101
123
|
const result = installPiExtension(context.home, { entryFile: context.piEntryFile });
|
|
102
124
|
return { name, status: 'ready', changed: result.changed };
|
|
@@ -118,6 +140,13 @@ export function disableAgentIntegration(name, context) {
|
|
|
118
140
|
uninstallStatusLine(context.home);
|
|
119
141
|
return { name, status: 'not-enabled', changed };
|
|
120
142
|
}
|
|
143
|
+
if (name === 'codebuddy') {
|
|
144
|
+
// CodeBuddy has no statusLine equivalent, so only its own hooks are removed — never another product's
|
|
145
|
+
// config, and never ~/.codebuddy itself.
|
|
146
|
+
const changed = codebuddyHooksStatus(context.home) === 'installed';
|
|
147
|
+
uninstallCodebuddyHooks(context.home);
|
|
148
|
+
return { name, status: 'not-enabled', changed };
|
|
149
|
+
}
|
|
121
150
|
try {
|
|
122
151
|
const result = uninstallPiExtension(context.home);
|
|
123
152
|
return { name, status: 'not-enabled', changed: result.changed };
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Install/uninstall the CodeBuddy Code lifecycle hooks that feed the handmux inbox. Structurally this is
|
|
2
|
+
// claudeHooks.ts again — CodeBuddy's hooks follow the Claude Code settings schema — but it is a separate
|
|
3
|
+
// module on purpose: it writes another tool's config in another tool's directory, so it owns its own marker,
|
|
4
|
+
// its own settings file and its own script set. One installer must never edit two products' configs.
|
|
5
|
+
//
|
|
6
|
+
// Iron rule: only ever touch ~/.handmux/ and — after explicit opt-in — ~/.codebuddy/. If ~/.codebuddy is
|
|
7
|
+
// absent (no CodeBuddy), skip and report 'no-codebuddy'; never create it.
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { CODEBUDDY_HOOK_SET, deployHookScripts, removeHookScripts, shellWord, writeJsonAtomic, } from './hookScaffold.js';
|
|
13
|
+
const NOTIFY_SCRIPT = CODEBUDDY_HOOK_SET.notify;
|
|
14
|
+
const ENV_FILE = `${NOTIFY_SCRIPT.replace(/\.sh$/, '')}.env`;
|
|
15
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
16
|
+
const settingsOf = (value) => isRecord(value) ? { ...value } : {};
|
|
17
|
+
const readSettings = (home) => {
|
|
18
|
+
try {
|
|
19
|
+
return settingsOf(JSON.parse(fs.readFileSync(settingsPath(home), 'utf8')));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
// The five events the inbox reads on CodeBuddy (verified against 2.155.0). src is the argument passed to
|
|
26
|
+
// handmux-codebuddy-notify.sh. No matcher: none of these are tool-scoped, so no per-tool-call hot path is
|
|
27
|
+
// added — the hook only ever fires at a session/turn boundary.
|
|
28
|
+
// SessionStart binds the pane→session mapping the instant a session begins. It is also the only payload
|
|
29
|
+
// carrying CodeBuddy's transcript_path, so without it the conversation page has no session to open.
|
|
30
|
+
// Keep this table in sync with ../../hooks/handmux-codebuddy-notify.sh.
|
|
31
|
+
export const HOOK_EVENTS = [
|
|
32
|
+
{ event: 'Stop', src: 'stop' },
|
|
33
|
+
{ event: 'Notification', src: 'notify' },
|
|
34
|
+
{ event: 'UserPromptSubmit', src: 'prompt' },
|
|
35
|
+
{ event: 'SessionStart', src: 'start' },
|
|
36
|
+
{ event: 'SessionEnd', src: 'end' },
|
|
37
|
+
// The pair that owns "需要你": PermissionRequest lights it the moment the dialog appears, and PostToolUse on
|
|
38
|
+
// the two interaction tools clears it the moment the user answers — the same events Claude installs. Without
|
|
39
|
+
// them the state stays lit after the user replies, until the turn happens to end.
|
|
40
|
+
{ event: 'PostToolUse', src: 'resume', matcher: 'AskUserQuestion|ExitPlanMode' },
|
|
41
|
+
{ event: 'PermissionRequest', src: 'permreq' },
|
|
42
|
+
];
|
|
43
|
+
// Version-gated events, mirroring Claude's: written only for a CLI new enough to emit them, pruned otherwise
|
|
44
|
+
// (fail-closed when the version cannot be read). Each closes a state we would otherwise light and never turn
|
|
45
|
+
// off:
|
|
46
|
+
// PreCompact → 压缩中 while compaction runs.
|
|
47
|
+
// PostCompact → clears it the instant compaction finishes.
|
|
48
|
+
// StopFailure → a turn that died on an API error (rate limit / overload / …) fires NO Stop, so without this
|
|
49
|
+
// the pane sticks at 进行中 forever; maps to an 'error' state.
|
|
50
|
+
// `pairWith` welds the invariant "never light a state you cannot turn off": PreCompact installs only together
|
|
51
|
+
// with its clearer PostCompact. minVersion is the version these were verified to exist in (2.155.0); lower it
|
|
52
|
+
// only after test-firing an older build.
|
|
53
|
+
const COMPACT_MIN = '2.155.0';
|
|
54
|
+
export const HOOK_EVENTS_EXT = [
|
|
55
|
+
{ event: 'PostCompact', src: 'compact', minVersion: COMPACT_MIN },
|
|
56
|
+
{ event: 'PreCompact', src: 'compacting', minVersion: COMPACT_MIN, pairWith: 'PostCompact' },
|
|
57
|
+
{ event: 'StopFailure', src: 'stopfail', minVersion: COMPACT_MIN },
|
|
58
|
+
];
|
|
59
|
+
const defaultVersionExec = (command, args, options) => {
|
|
60
|
+
const r = spawnSync(command, [...args], options);
|
|
61
|
+
return { status: r.status, stdout: r.stdout };
|
|
62
|
+
};
|
|
63
|
+
// Parse `codebuddy --version` output ("2.155.0") → { major, minor, patch } | null.
|
|
64
|
+
export function parseCodeBuddyVersion(out) {
|
|
65
|
+
const m = /(\d+)\.(\d+)\.(\d+)/.exec(String(out || ''));
|
|
66
|
+
return m ? { major: Number(m[1] ?? 0), minor: Number(m[2] ?? 0), patch: Number(m[3] ?? 0) } : null;
|
|
67
|
+
}
|
|
68
|
+
// v >= min ("X.Y.Z"); an unknown version is always below → the gate fails closed.
|
|
69
|
+
export function codeBuddyVersionAtLeast(v, minStr) {
|
|
70
|
+
if (!v)
|
|
71
|
+
return false;
|
|
72
|
+
const [a = 0, b = 0, c = 0] = String(minStr).split('.').map(Number);
|
|
73
|
+
if (v.major !== a)
|
|
74
|
+
return v.major > a;
|
|
75
|
+
if (v.minor !== b)
|
|
76
|
+
return v.minor > b;
|
|
77
|
+
return v.patch >= c;
|
|
78
|
+
}
|
|
79
|
+
// Detect the installed CodeBuddy version, or null when it cannot be run/parsed (→ ext events are skipped).
|
|
80
|
+
export function detectCodeBuddyVersion(exec = defaultVersionExec) {
|
|
81
|
+
try {
|
|
82
|
+
for (const command of ['codebuddy', 'cbc']) {
|
|
83
|
+
const r = exec(command, ['--version'], { encoding: 'utf8', timeout: 4000 });
|
|
84
|
+
if (r && r.status === 0 && r.stdout) {
|
|
85
|
+
const parsed = parseCodeBuddyVersion(r.stdout);
|
|
86
|
+
if (parsed)
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Identifies OUR hooks among the user's own. Distinct from Claude's marker, so neither installer can mistake
|
|
97
|
+
// the other's entries for its own even if a config directory is ever shared.
|
|
98
|
+
const HOOK_MARK = NOTIFY_SCRIPT;
|
|
99
|
+
const isOurHook = (value) => {
|
|
100
|
+
const hook = isRecord(value) ? value : null;
|
|
101
|
+
return typeof hook?.command === 'string' && hook.command.includes(HOOK_MARK);
|
|
102
|
+
};
|
|
103
|
+
const cleanGroup = (value) => {
|
|
104
|
+
if (!isRecord(value) || !Array.isArray(value.hooks))
|
|
105
|
+
return value;
|
|
106
|
+
return { ...value, hooks: value.hooks.filter((hook) => !isOurHook(hook)) };
|
|
107
|
+
};
|
|
108
|
+
const nonEmptyGroup = (value) => (!isRecord(value) || !Array.isArray(value.hooks) || value.hooks.length > 0);
|
|
109
|
+
function dropOurHook(hooks, event) {
|
|
110
|
+
if (!Array.isArray(hooks[event]))
|
|
111
|
+
return;
|
|
112
|
+
const kept = hooks[event].map(cleanGroup).filter(nonEmptyGroup);
|
|
113
|
+
if (kept.length)
|
|
114
|
+
hooks[event] = kept;
|
|
115
|
+
else
|
|
116
|
+
delete hooks[event];
|
|
117
|
+
}
|
|
118
|
+
function alreadyHas(hooks, event) {
|
|
119
|
+
const groups = hooks[event];
|
|
120
|
+
return Array.isArray(groups) && groups.some((group) => (isRecord(group) && Array.isArray(group.hooks) && group.hooks.some(isOurHook)));
|
|
121
|
+
}
|
|
122
|
+
function ownedHookCount(hooks, event) {
|
|
123
|
+
const groups = hooks[event];
|
|
124
|
+
if (!Array.isArray(groups))
|
|
125
|
+
return 0;
|
|
126
|
+
return groups.reduce((count, group) => (count + (isRecord(group) && Array.isArray(group.hooks)
|
|
127
|
+
? group.hooks.filter(isOurHook).length : 0)), 0);
|
|
128
|
+
}
|
|
129
|
+
function hasExpectedHook(hooks, event, dest) {
|
|
130
|
+
const groups = hooks[event.event];
|
|
131
|
+
const command = `${shellWord(dest)} ${event.src}`;
|
|
132
|
+
return Array.isArray(groups) && groups.some((group) => (isRecord(group)
|
|
133
|
+
&& group.matcher === (event.matcher ?? '')
|
|
134
|
+
&& Array.isArray(group.hooks)
|
|
135
|
+
&& group.hooks.some((hook) => (isRecord(hook)
|
|
136
|
+
&& hook.type === 'command'
|
|
137
|
+
&& hook.command === command
|
|
138
|
+
&& hook.async === true
|
|
139
|
+
&& hook.timeout === 5))));
|
|
140
|
+
}
|
|
141
|
+
// Merge one event's hook group into `hooks` (mutates), idempotently: first remove Handmux-owned entries for
|
|
142
|
+
// this event, then append the canonical destination. This repairs wrappers left by moved package installs
|
|
143
|
+
// without touching any user/third-party hook that does not carry our marker.
|
|
144
|
+
function addHook(hooks, e, dest) {
|
|
145
|
+
if (ownedHookCount(hooks, e.event) === 1 && hasExpectedHook(hooks, e, dest))
|
|
146
|
+
return;
|
|
147
|
+
dropOurHook(hooks, e.event);
|
|
148
|
+
const existing = hooks[e.event];
|
|
149
|
+
const groups = hooks[e.event] = Array.isArray(existing) ? [...existing] : [];
|
|
150
|
+
groups.push({
|
|
151
|
+
matcher: e.matcher ?? '',
|
|
152
|
+
hooks: [{ type: 'command', command: `${shellWord(dest)} ${e.src}`, async: true, timeout: 5 }],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
// Pure: return a NEW settings object with our hooks merged into settings.hooks, idempotently, leaving
|
|
156
|
+
// the user's own hooks and other keys untouched. `dest` is the absolute path to the copied notify script.
|
|
157
|
+
export function mergeHooks(settings, dest, codebuddyVersion = null) {
|
|
158
|
+
const s = settingsOf(settings);
|
|
159
|
+
const hooks = isRecord(s.hooks) ? { ...s.hooks } : {};
|
|
160
|
+
for (const e of HOOK_EVENTS)
|
|
161
|
+
addHook(hooks, e, dest);
|
|
162
|
+
// Extension events: only for a CLI that emits them, and only with their clearer installed too.
|
|
163
|
+
const enabled = new Set();
|
|
164
|
+
for (const e of HOOK_EVENTS_EXT) {
|
|
165
|
+
const ok = !!e.minVersion
|
|
166
|
+
&& codeBuddyVersionAtLeast(codebuddyVersion, e.minVersion)
|
|
167
|
+
&& (!e.pairWith || enabled.has(e.pairWith));
|
|
168
|
+
if (ok) {
|
|
169
|
+
enabled.add(e.event);
|
|
170
|
+
addHook(hooks, e, dest);
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
dropOurHook(hooks, e.event);
|
|
174
|
+
}
|
|
175
|
+
s.hooks = hooks;
|
|
176
|
+
return s;
|
|
177
|
+
}
|
|
178
|
+
// Pure: return a NEW settings object with all of OUR hooks removed (uninstall). An event group that ends up
|
|
179
|
+
// empty is dropped; the user's own hooks and other keys are untouched.
|
|
180
|
+
export function stripHooks(settings) {
|
|
181
|
+
const s = settingsOf(settings);
|
|
182
|
+
if (!isRecord(s.hooks))
|
|
183
|
+
return s;
|
|
184
|
+
const hooks = {};
|
|
185
|
+
for (const [event, groups] of Object.entries(s.hooks)) {
|
|
186
|
+
if (!Array.isArray(groups)) {
|
|
187
|
+
hooks[event] = groups;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const kept = groups.map(cleanGroup).filter(nonEmptyGroup);
|
|
191
|
+
if (kept.length)
|
|
192
|
+
hooks[event] = kept;
|
|
193
|
+
}
|
|
194
|
+
s.hooks = hooks;
|
|
195
|
+
return s;
|
|
196
|
+
}
|
|
197
|
+
function codebuddyDir(home = homedir()) { return path.join(home, '.codebuddy'); }
|
|
198
|
+
function settingsPath(home = homedir()) {
|
|
199
|
+
return path.join(codebuddyDir(home), 'settings.json');
|
|
200
|
+
}
|
|
201
|
+
function notifyDest(home = homedir()) {
|
|
202
|
+
return path.join(codebuddyDir(home), 'hooks', NOTIFY_SCRIPT);
|
|
203
|
+
}
|
|
204
|
+
// 'no-codebuddy' → ~/.codebuddy absent (don't prompt to enable). 'installed' → our hooks present. 'absent' →
|
|
205
|
+
// CodeBuddy is here but our hooks aren't (offer to enable).
|
|
206
|
+
export function hooksStatus(home = homedir()) {
|
|
207
|
+
if (!fs.existsSync(codebuddyDir(home)))
|
|
208
|
+
return 'no-codebuddy';
|
|
209
|
+
const settings = readSettings(home);
|
|
210
|
+
const hooks = isRecord(settings.hooks) ? settings.hooks : {};
|
|
211
|
+
return Object.keys(hooks).some((ev) => alreadyHas(hooks, ev)) ? 'installed' : 'absent';
|
|
212
|
+
}
|
|
213
|
+
function regularFile(file) {
|
|
214
|
+
try {
|
|
215
|
+
return fs.statSync(file).isFile();
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Stronger diagnostic used by `handmux agent`: any partial/old registration is repairable, not ready. Every
|
|
222
|
+
// registered event and every deployed runtime file is required.
|
|
223
|
+
export function hooksHealthStatus(home = homedir()) {
|
|
224
|
+
const status = hooksStatus(home);
|
|
225
|
+
if (status !== 'installed')
|
|
226
|
+
return status;
|
|
227
|
+
const hooksDir = path.join(codebuddyDir(home), 'hooks');
|
|
228
|
+
const dest = notifyDest(home);
|
|
229
|
+
const settings = readSettings(home);
|
|
230
|
+
const hooks = isRecord(settings.hooks) ? settings.hooks : {};
|
|
231
|
+
if (!HOOK_EVENTS.every((event) => (ownedHookCount(hooks, event.event) === 1 && hasExpectedHook(hooks, event, dest))))
|
|
232
|
+
return 'stale';
|
|
233
|
+
if (![dest, path.join(hooksDir, 'handmux-write.cjs'), path.join(hooksDir, ENV_FILE)].every(regularFile)) {
|
|
234
|
+
return 'stale';
|
|
235
|
+
}
|
|
236
|
+
return 'installed';
|
|
237
|
+
}
|
|
238
|
+
// Install (opt-in): copy the bundled hook scripts to ~/.codebuddy/hooks/, write the env pointing at the state
|
|
239
|
+
// file, and merge our hooks into settings.json. NEVER creates ~/.codebuddy — if it's absent the user
|
|
240
|
+
// doesn't run CodeBuddy, so we report 'no-codebuddy' and do nothing.
|
|
241
|
+
// srcDir = the bundled hooks dir (server/hooks)
|
|
242
|
+
// stateFile = the unified ~/.handmux/codebuddy-state.json path the hook writes and the server reads
|
|
243
|
+
export function installHooks(home = homedir(), { srcDir, stateFile, codebuddyVersion } = {}) {
|
|
244
|
+
if (!fs.existsSync(codebuddyDir(home)))
|
|
245
|
+
return { status: 'no-codebuddy' };
|
|
246
|
+
if (!srcDir || !stateFile)
|
|
247
|
+
throw new Error('hook srcDir and stateFile are required');
|
|
248
|
+
deployHookScripts(path.join(codebuddyDir(home), 'hooks'), srcDir, stateFile, CODEBUDDY_HOOK_SET);
|
|
249
|
+
const settings = readSettings(home);
|
|
250
|
+
const version = codebuddyVersion !== undefined ? codebuddyVersion : detectCodeBuddyVersion();
|
|
251
|
+
writeJsonAtomic(settingsPath(home), mergeHooks(settings, notifyDest(home), version));
|
|
252
|
+
return { status: 'installed' };
|
|
253
|
+
}
|
|
254
|
+
// Keep an ALREADY-installed user's hooks in step with this handmux version on every server start, so a plain
|
|
255
|
+
// `./deploy.sh` (restart) rolls out newly-added lifecycle events and refreshed hook scripts — no phone
|
|
256
|
+
// re-enable needed. Strictly opt-in-preserving: a NO-OP unless our hooks are already present ('installed').
|
|
257
|
+
// It never enables hooks for a user who hasn't opted in ('absent') and never creates ~/.codebuddy
|
|
258
|
+
// ('no-codebuddy'). settings.json is rewritten only when the merge actually changes it, so a steady state
|
|
259
|
+
// writes nothing.
|
|
260
|
+
export function syncHooks(home = homedir(), { srcDir, stateFile } = {}) {
|
|
261
|
+
const status = hooksStatus(home);
|
|
262
|
+
if (status !== 'installed')
|
|
263
|
+
return { status, changed: false };
|
|
264
|
+
if (!srcDir || !stateFile)
|
|
265
|
+
throw new Error('hook srcDir and stateFile are required');
|
|
266
|
+
deployHookScripts(path.join(codebuddyDir(home), 'hooks'), srcDir, stateFile, CODEBUDDY_HOOK_SET);
|
|
267
|
+
const dest = notifyDest(home);
|
|
268
|
+
const settings = readSettings(home);
|
|
269
|
+
const hooks = isRecord(settings.hooks) ? { ...settings.hooks } : {};
|
|
270
|
+
const before = JSON.stringify(hooks);
|
|
271
|
+
for (const e of HOOK_EVENTS)
|
|
272
|
+
addHook(hooks, e, dest); // add only MISSING events
|
|
273
|
+
const changed = JSON.stringify(hooks) !== before;
|
|
274
|
+
if (changed)
|
|
275
|
+
writeJsonAtomic(settingsPath(home), { ...settings, hooks });
|
|
276
|
+
return { status: 'installed', changed };
|
|
277
|
+
}
|
|
278
|
+
// Uninstall: strip our hooks from settings.json and remove the copied scripts/env. Best-effort on the file
|
|
279
|
+
// deletes (a missing file is fine). Leaves ~/.codebuddy and the user's own hooks intact.
|
|
280
|
+
export function uninstallHooks(home = homedir()) {
|
|
281
|
+
const settings = readSettings(home);
|
|
282
|
+
if (fs.existsSync(settingsPath(home)))
|
|
283
|
+
writeJsonAtomic(settingsPath(home), stripHooks(settings));
|
|
284
|
+
removeHookScripts(path.join(codebuddyDir(home), 'hooks'), CODEBUDDY_HOOK_SET);
|
|
285
|
+
return { status: 'absent' };
|
|
286
|
+
}
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
// Shared IO scaffolding for the
|
|
1
|
+
// Shared IO scaffolding for the Agent hook installers and statusLine capturer.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
const
|
|
4
|
+
// The writer is shared by every Agent: the spool/state protocol is the Agent-independent half.
|
|
5
|
+
const WRITER_SCRIPT = 'handmux-write.cjs';
|
|
6
|
+
export const CLAUDE_HOOK_SET = {
|
|
7
|
+
notify: 'handmux-notify.sh',
|
|
8
|
+
eventsVar: 'HANDMUX_CLAUDE_EVENTS',
|
|
9
|
+
legacy: ['handmux-codex-usage.cjs'],
|
|
10
|
+
};
|
|
11
|
+
export const CODEBUDDY_HOOK_SET = {
|
|
12
|
+
notify: 'handmux-codebuddy-notify.sh',
|
|
13
|
+
eventsVar: 'HANDMUX_CODEBUDDY_EVENTS',
|
|
14
|
+
};
|
|
15
|
+
// `handmux-notify.sh` → `handmux-notify.env`, alongside the script so `<dir>/<name>.env` is what sh sources.
|
|
16
|
+
const hookEnvName = (hookSet) => `${hookSet.notify.replace(/\.sh$/, '')}.env`;
|
|
6
17
|
// Env files are sourced by /bin/sh; settings commands use the same shell word syntax.
|
|
7
18
|
export const shellWord = (value) => /^[a-zA-Z0-9_./-]+$/.test(value)
|
|
8
19
|
? value : `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
@@ -14,27 +25,29 @@ export function writeFileAtomic(file, text) {
|
|
|
14
25
|
fs.renameSync(tmp, file);
|
|
15
26
|
}
|
|
16
27
|
export const writeJsonAtomic = (file, value) => (writeFileAtomic(file, JSON.stringify(value, null, 2)));
|
|
17
|
-
// Deploy
|
|
18
|
-
export function deployHookScripts(hooksDir, srcDir, stateFile) {
|
|
28
|
+
// Deploy an Agent's notify/write scripts into `hooksDir` and point their env at the shared state file.
|
|
29
|
+
export function deployHookScripts(hooksDir, srcDir, stateFile, hookSet = CLAUDE_HOOK_SET) {
|
|
19
30
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
20
|
-
for (const f of
|
|
31
|
+
for (const f of [hookSet.notify, WRITER_SCRIPT]) {
|
|
21
32
|
fs.copyFileSync(path.join(srcDir, f), path.join(hooksDir, f));
|
|
22
|
-
|
|
33
|
+
}
|
|
34
|
+
for (const f of hookSet.legacy ?? []) {
|
|
23
35
|
try {
|
|
24
36
|
fs.unlinkSync(path.join(hooksDir, f));
|
|
25
37
|
}
|
|
26
38
|
catch { /* absent */ }
|
|
27
39
|
}
|
|
28
|
-
fs.chmodSync(path.join(hooksDir,
|
|
29
|
-
fs.writeFileSync(path.join(hooksDir,
|
|
40
|
+
fs.chmodSync(path.join(hooksDir, hookSet.notify), 0o755);
|
|
41
|
+
fs.writeFileSync(path.join(hooksDir, hookEnvName(hookSet)), [
|
|
30
42
|
`HANDMUX_STATE=${shellWord(stateFile)}`,
|
|
31
|
-
|
|
43
|
+
`${hookSet.eventsVar}=${shellWord(`${stateFile}.events`)}`,
|
|
32
44
|
'',
|
|
33
45
|
].join('\n'), { mode: 0o600 });
|
|
34
46
|
}
|
|
35
47
|
// Remove the deployed scripts + env (uninstall). Best-effort: a missing file is fine.
|
|
36
|
-
export function removeHookScripts(hooksDir) {
|
|
37
|
-
|
|
48
|
+
export function removeHookScripts(hooksDir, hookSet = CLAUDE_HOOK_SET) {
|
|
49
|
+
const files = [hookSet.notify, WRITER_SCRIPT, hookEnvName(hookSet), ...(hookSet.legacy ?? [])];
|
|
50
|
+
for (const f of files) {
|
|
38
51
|
try {
|
|
39
52
|
fs.unlinkSync(path.join(hooksDir, f));
|
|
40
53
|
}
|
package/dist/src/cli/i18n/en.js
CHANGED
|
@@ -180,7 +180,7 @@ export default {
|
|
|
180
180
|
'agent.codexBuiltIn': '= Codex integration is built in and already ready.',
|
|
181
181
|
'agent.codexDisable': 'Codex integration is built in and cannot be disabled. Run native `codex` directly to bypass Handmux.',
|
|
182
182
|
'agent.unknown': 'unknown Agent: {name}',
|
|
183
|
-
'agent.usage': 'usage: handmux agent [list] | handmux agent status|enable|disable <codex|pi|claude>',
|
|
183
|
+
'agent.usage': 'usage: handmux agent [list] | handmux agent status|enable|disable <codex|pi|claude|codebuddy>',
|
|
184
184
|
// Pi Extension
|
|
185
185
|
'piExtension.confirmEnable': 'Enable the Pi integration with Handmux?',
|
|
186
186
|
'piExtension.installed': '✓ Pi integration enabled.',
|
package/dist/src/cli/i18n/zh.js
CHANGED
|
@@ -179,7 +179,7 @@ export default {
|
|
|
179
179
|
'agent.codexBuiltIn': '= Codex 接入已内置,当前已经 ready。',
|
|
180
180
|
'agent.codexDisable': 'Codex 接入已内置,不能停用。若要绕过 Handmux,请直接运行原生 `codex`。',
|
|
181
181
|
'agent.unknown': '未知 Agent:{name}',
|
|
182
|
-
'agent.usage': '用法:handmux agent [list] | handmux agent status|enable|disable <codex|pi|claude>',
|
|
182
|
+
'agent.usage': '用法:handmux agent [list] | handmux agent status|enable|disable <codex|pi|claude|codebuddy>',
|
|
183
183
|
// Pi Extension
|
|
184
184
|
'piExtension.confirmEnable': '启用 Pi 与 Handmux 的接入能力?',
|
|
185
185
|
'piExtension.installed': '✓ 已启用 Pi 接入。',
|
package/dist/src/cli/state.js
CHANGED
|
@@ -25,6 +25,10 @@ export function agentRuntimeDirectoryPath(home) {
|
|
|
25
25
|
// the package-internal server/data default). The CLI sets this as CLAUDE_STATE_FILE for the server child
|
|
26
26
|
// and writes the same path into the hook's env, so both ends read/write one file.
|
|
27
27
|
export function claudeStatePath(home) { return path.join(pocketHome(home), 'claude-state.json'); }
|
|
28
|
+
// The same contract for CodeBuddy, which drives an identical Hook (state file + bounded spool) through the
|
|
29
|
+
// same writer. Its own file, on the same stable per-user path: the CLI sets CODEBUDDY_STATE_FILE for the
|
|
30
|
+
// server child and writes the same path into the CodeBuddy hook env, so both ends agree.
|
|
31
|
+
export function codebuddyStatePath(home) { return path.join(pocketHome(home), 'codebuddy-state.json'); }
|
|
28
32
|
// Push subscriptions and the dynamic-preview registry are mutable runtime data too, so they belong on the
|
|
29
33
|
// same stable per-user path — NOT the package-internal server/data default, which a `npm i -g handmux`
|
|
30
34
|
// reinstall replaces (silently dropping every saved subscription). The CLI injects these as PUSH_STORE /
|
|
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
10
|
import { providerMode, voiceProviderRegistry } from '../asr/providerRegistry.js';
|
|
11
11
|
import { getDriver } from './drivers.js';
|
|
12
|
-
import { writeState, clearState, claudeStatePath, pushStorePath, previewStorePath, notificationsDirPath } from './state.js';
|
|
12
|
+
import { writeState, clearState, claudeStatePath, codebuddyStatePath, pushStorePath, previewStorePath, notificationsDirPath } from './state.js';
|
|
13
13
|
import { initialSupervisorComponentState, reduceSupervisorComponent, } from './supervisorState.js';
|
|
14
14
|
import { probeServerReadiness } from './supervisorHealth.js';
|
|
15
15
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -121,6 +121,7 @@ export function supervise(cfg, { home, log = console, spawnChild = defaultSpawnC
|
|
|
121
121
|
HANDMUX_HOST: cfg.host,
|
|
122
122
|
HANDMUX_TOKEN: cfg.token,
|
|
123
123
|
CLAUDE_STATE_FILE: claudeStatePath(home),
|
|
124
|
+
CODEBUDDY_STATE_FILE: codebuddyStatePath(home),
|
|
124
125
|
PUSH_STORE: pushStorePath(home),
|
|
125
126
|
PREVIEW_STORE: previewStorePath(home),
|
|
126
127
|
NOTIF_DIR: notificationsDirPath(home),
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// The CodeBuddy Code hook-event layer: what Handmux knows that is SPECIFIC to CodeBuddy's hook events.
|
|
2
|
+
//
|
|
3
|
+
// CodeBuddy's hook protocol is Claude Code's (same lifecycle event names, same raw payload on stdin, same
|
|
4
|
+
// shared writer + state/spool format), so this file deliberately does NOT re-implement the generic parts:
|
|
5
|
+
// - the lifecycle vocabulary and the on-disk record format come from agents/hookEvents.ts;
|
|
6
|
+
// - the state row / spool event reading is shared by the generic Connector (connectors/hookBridge.ts);
|
|
7
|
+
// - the Inbox operation/snapshot projection is the shared projectHookInbox, parameterized below.
|
|
8
|
+
// What stays here is the mapping from CodeBuddy's `src` names and payload fields to the shared kind
|
|
9
|
+
// language — the one thing no other provider can answer for CodeBuddy.
|
|
10
|
+
//
|
|
11
|
+
// There is also no legacy reader service here (unlike src/claudeEvents.ts): Claude needed one because it
|
|
12
|
+
// predates Inbox Core, and a fresh Agent must NOT create a second state store — the Connector + ordered
|
|
13
|
+
// projector are the only authority for CodeBuddy from day one. A `createCodebuddyEvents` service that
|
|
14
|
+
// serves the conversation lens (pane → session binding, activity) is added by that slice, not this one.
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { hookErrorMessage, readHookStateRows } from './agents/hookEvents.js';
|
|
18
|
+
import { CodeBuddyNativeTailReader } from './agents/codebuddyNativeTail.js';
|
|
19
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
// The hook-maintained state file: ONE JSON object keyed by tmux pane id, each value the pane's latest
|
|
21
|
+
// event { ts, src, host, payload } — the same format Claude uses, in CodeBuddy's own file. The hook writes
|
|
22
|
+
// it (the shared handmux-write.cjs); the server only reads it. Default lives under server/data (gitignored
|
|
23
|
+
// runtime data); override with CODEBUDDY_STATE_FILE — the CLI sets that for the server child and writes the
|
|
24
|
+
// same path into the hook's env, exactly like CLAUDE_STATE_FILE.
|
|
25
|
+
export const DEFAULT_STATE_FILE = process.env.CODEBUDDY_STATE_FILE
|
|
26
|
+
|| path.resolve(here, '../data/codebuddy-state.json');
|
|
27
|
+
const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
28
|
+
? value
|
|
29
|
+
: null);
|
|
30
|
+
const text = (value) => typeof value === 'string' ? value : '';
|
|
31
|
+
// Build the 需要你 one-liner from the tool the gate is blocking on. CodeBuddy's PermissionRequest payload is
|
|
32
|
+
// Claude-shaped, but its interactive tool set is NOT verified, so this reads only the generic tool name
|
|
33
|
+
// instead of borrowing Claude's AskUserQuestion/ExitPlanMode wording — a wrong assumption there would put a
|
|
34
|
+
// confident, wrong label in front of the user.
|
|
35
|
+
function permissionMessage(body) {
|
|
36
|
+
const tool = text(body.tool_name);
|
|
37
|
+
return tool ? `需要你授权:${tool}` : '需要你';
|
|
38
|
+
}
|
|
39
|
+
// Map a CodeBuddy hook event (src + raw payload) to a notification kind. Pure — no I/O, easy to test.
|
|
40
|
+
// stop → done (Stop: the turn finished; carries the last assistant message)
|
|
41
|
+
// prompt → working (UserPromptSubmit: carries the submitted prompt)
|
|
42
|
+
// end → end (SessionEnd: the pane's CodeBuddy session is over → clear its projection)
|
|
43
|
+
// start → null (SessionStart: neutral by design — it (re)binds pane→session, including the
|
|
44
|
+
// NEW transcript_path after /clear. A fresh/just-cleared session reads as
|
|
45
|
+
// present, not 进行中, until its first prompt)
|
|
46
|
+
// permreq → permission (PermissionRequest: a real gate just appeared, named by its tool)
|
|
47
|
+
// permdenied → working (PermissionDenied: the gate is CLOSED. Like Claude's deny→tool_result→resume
|
|
48
|
+
// path, the agent continues the turn with the denial; if the turn then ends,
|
|
49
|
+
// Stop overrides this with done. Unverified against a live payload — the
|
|
50
|
+
// alternative (neutral) would silently drop a running turn's card)
|
|
51
|
+
// compacting → compacting (PreCompact: a slow operation, shown as 压缩中)
|
|
52
|
+
// compact → null (PostCompact: compaction finished → clear 压缩中. It must not fabricate done;
|
|
53
|
+
// Claude's equivalent clears here too, and the next real edge re-states)
|
|
54
|
+
// stopfail → error (StopFailure: the turn ended on an API error, so no Stop fires — this is the
|
|
55
|
+
// only signal that closes 进行中 with a failure)
|
|
56
|
+
// notify → permission / idle by notification_type; anything else is null (ignored noise:
|
|
57
|
+
// auth_success, elicitation_*, …) — it must never create or keep an Inbox card
|
|
58
|
+
export function classifyCodeBuddy(src, rawBody = {}) {
|
|
59
|
+
const body = record(rawBody) || {};
|
|
60
|
+
if (src === 'stop')
|
|
61
|
+
return { kind: 'done', msg: text(body.last_assistant_message) };
|
|
62
|
+
if (src === 'prompt')
|
|
63
|
+
return { kind: 'working', msg: text(body.prompt) };
|
|
64
|
+
if (src === 'permreq')
|
|
65
|
+
return { kind: 'permission', msg: permissionMessage(body) };
|
|
66
|
+
// Answered or approved: back to working, carrying what the user picked so the card stops asking.
|
|
67
|
+
if (src === 'resume')
|
|
68
|
+
return { kind: 'working', msg: text(body.tool_response) };
|
|
69
|
+
if (src === 'permdenied')
|
|
70
|
+
return { kind: 'working', msg: '' };
|
|
71
|
+
if (src === 'compacting')
|
|
72
|
+
return { kind: 'compacting', msg: '' };
|
|
73
|
+
if (src === 'compact')
|
|
74
|
+
return null;
|
|
75
|
+
if (src === 'stopfail')
|
|
76
|
+
return { kind: 'error', msg: hookErrorMessage(body) };
|
|
77
|
+
if (src === 'end')
|
|
78
|
+
return { kind: 'end' };
|
|
79
|
+
if (src === 'start')
|
|
80
|
+
return null;
|
|
81
|
+
if (src === 'notify') {
|
|
82
|
+
if (body.notification_type === 'permission_prompt')
|
|
83
|
+
return { kind: 'permission', msg: text(body.message) };
|
|
84
|
+
if (body.notification_type === 'idle_prompt')
|
|
85
|
+
return { kind: 'idle', msg: text(body.message) };
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// Which record markings are CodeBuddy's self same pipeline may read. The shared writer stamps the Agent's
|
|
90
|
+
// own id on every spool file and state row, so a row carrying another provider's mark must fail closed
|
|
91
|
+
// rather than be projected as CodeBuddy state. Exported here, next to the event vocabulary, because both
|
|
92
|
+
// the Inbox Connector and the conversation lens's pane binding must answer it the same way.
|
|
93
|
+
export function acceptsCodeBuddyAgent(agent) {
|
|
94
|
+
return agent === 'codebuddy';
|
|
95
|
+
}
|
|
96
|
+
// The conversation lens's pane → session bind. The hook state file records THIS pane's own session id (and
|
|
97
|
+
// its transcript path, when the payload carried one), which is authoritative over any cwd→newest-file
|
|
98
|
+
// guess. Returns null when hooks are off, the pane is not CodeBuddy's, or the row carries no session to
|
|
99
|
+
// bind — so a lens can never open onto a session the pane was not actually running.
|
|
100
|
+
export function createCodebuddyEvents({ stateFile = DEFAULT_STATE_FILE, nativeTail = new CodeBuddyNativeTailReader(), } = {}) {
|
|
101
|
+
const read = () => readHookStateRows(stateFile, acceptsCodeBuddyAgent);
|
|
102
|
+
return {
|
|
103
|
+
paneSession(paneId) {
|
|
104
|
+
const row = read().get(paneId);
|
|
105
|
+
const payload = row?.payload;
|
|
106
|
+
if (!payload)
|
|
107
|
+
return null;
|
|
108
|
+
const sessionId = typeof payload.session_id === 'string' ? payload.session_id : null;
|
|
109
|
+
const transcriptPath = typeof payload.transcript_path === 'string' ? payload.transcript_path : null;
|
|
110
|
+
if (!sessionId && !transcriptPath)
|
|
111
|
+
return null;
|
|
112
|
+
return {
|
|
113
|
+
sessionId,
|
|
114
|
+
transcriptPath,
|
|
115
|
+
cwd: typeof payload.cwd === 'string' ? payload.cwd : null,
|
|
116
|
+
agent: row?.agent ?? null,
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
// The activity the 对话 lens shows while a turn runs: the SAME classification the Inbox projects, so the
|
|
120
|
+
// typing wave and the roster row can never disagree about what the pane is doing. A caller holding a
|
|
121
|
+
// process generation gets the row only when it is still that generation's.
|
|
122
|
+
//
|
|
123
|
+
// This MUST consult the transcript reconciler, not just the row. CodeBuddy fires no Hook when the user
|
|
124
|
+
// answers a gate, so a `permreq` row keeps answering 需要你 long after the pane moved on — and the
|
|
125
|
+
// Interaction adapter's fallback turns that stale answer into a card reading 这个操作需要在终端中完成 on a
|
|
126
|
+
// pane that is simply working (measured on %5, 2026-09-21: gate answered, Bash results landing at
|
|
127
|
+
// 00:17:38/42/50/54, a card raised at each of those same seconds with the idle editor as its body).
|
|
128
|
+
// Claude cannot drift this way because its own paneKind reads the native status first.
|
|
129
|
+
paneKind(paneId, process) {
|
|
130
|
+
const row = read().get(paneId);
|
|
131
|
+
if (!row)
|
|
132
|
+
return null;
|
|
133
|
+
if (process && row.process
|
|
134
|
+
&& (process.pid !== row.process.pid || process.startedAt !== row.process.startedAt))
|
|
135
|
+
return null;
|
|
136
|
+
if (nativeTail.read(row.payload, row.ts, Date.now(), row.process, row.src).status === 'busy') {
|
|
137
|
+
return 'working';
|
|
138
|
+
}
|
|
139
|
+
return classifyCodeBuddy(row.src, row.payload)?.kind ?? null;
|
|
140
|
+
},
|
|
141
|
+
// The prompt this pane last submitted, used only to recognize OUR OWN leftover in the native editor:
|
|
142
|
+
// the composer may replace that, never a draft a human typed.
|
|
143
|
+
paneSubmittedPrompt(paneId) {
|
|
144
|
+
const row = read().get(paneId);
|
|
145
|
+
if (!row || row.src !== 'prompt')
|
|
146
|
+
return null;
|
|
147
|
+
const prompt = row.payload.prompt;
|
|
148
|
+
return typeof prompt === 'string' && prompt ? prompt : null;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|