handmux 0.29.2 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/connectors/claude/index.js +33 -562
  2. package/dist/connectors/codebuddy/index.js +49 -0
  3. package/dist/connectors/hookBridge.js +560 -0
  4. package/dist/hooks/handmux-codebuddy-notify.sh +116 -0
  5. package/dist/hooks/handmux-notify.sh +2 -0
  6. package/dist/hooks/handmux-write.cjs +72 -5
  7. package/dist/package.json +13 -13
  8. package/dist/public/assets/_basePickBy-BzC0P9Wg.js +1 -0
  9. package/dist/public/assets/_baseUniq-DNOfPYVY.js +1 -0
  10. package/dist/public/assets/abnfDiagram-O67JEVCF-BsRDIQEB.js +1 -0
  11. package/dist/public/assets/arc-DLtLfMnX.js +1 -0
  12. package/dist/public/assets/architectureDiagram-NJMV4G6O-DoIXhWqZ.js +36 -0
  13. package/dist/public/assets/blockDiagram-BEXU5L5S-W0HiOcVA.js +140 -0
  14. package/dist/public/assets/c4Diagram-YGBWAQC7-72uAUMvX.js +38 -0
  15. package/dist/public/assets/channel-CzALSnb0.js +1 -0
  16. package/dist/public/assets/chunk-2Q5K7J3B-B9wSRFQ0.js +1 -0
  17. package/dist/public/assets/chunk-3FUC2YCW-ryphp4s0.js +2 -0
  18. package/dist/public/assets/chunk-5VM5RSS4-Cm4KeKby.js +15 -0
  19. package/dist/public/assets/chunk-GTNCS2PH-Du4_i7r3.js +88 -0
  20. package/dist/public/assets/chunk-GWA4HPMP-CL1biVIA.js +1 -0
  21. package/dist/public/assets/chunk-JWPE2WC7-B8eAVfuB.js +1 -0
  22. package/dist/public/assets/chunk-NETBCI7D-D273GzYg.js +1 -0
  23. package/dist/public/assets/chunk-WEXAMYUT-DipTfRbc.js +1 -0
  24. package/dist/public/assets/chunk-XXDRQBXY-D4SqXrQ8.js +1 -0
  25. package/dist/public/assets/classDiagram-v2-NBCMYWYE-PrpnIXcA.js +217 -0
  26. package/dist/public/assets/cose-bilkent-JH36ORCC-BfcYMYTa.js +1 -0
  27. package/dist/public/assets/cynefin-OW5HDTMX-Du659bmz.js +178 -0
  28. package/dist/public/assets/cynefinDiagram-VND7K2PF-DR_2Copy.js +62 -0
  29. package/dist/public/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  30. package/dist/public/assets/dagre-6A5THRUB-COsChaV5.js +4 -0
  31. package/dist/public/assets/defaultLocale-CrowFXzY.js +1 -0
  32. package/dist/public/assets/diagram-22UHCM2B-Cx2T1HvL.js +200 -0
  33. package/dist/public/assets/diagram-3UASUU5V-Cp04LOka.js +24 -0
  34. package/dist/public/assets/diagram-ATOU4E4O-DlqFSshY.js +3 -0
  35. package/dist/public/assets/diagram-CDSNMT55-goq40ctA.js +30 -0
  36. package/dist/public/assets/diagram-MLGK6HIB-Bdzjsy4r.js +24 -0
  37. package/dist/public/assets/diagram-MPIPVDR6-DtU7cM6P.js +41 -0
  38. package/dist/public/assets/ebnfDiagram-ZINNZB2B-XyVCfmED.js +1 -0
  39. package/dist/public/assets/elk-276RUBZZ-aHVIw-2x.js +27 -0
  40. package/dist/public/assets/erDiagram-OPXOYQCR-Bq_PhTIL.js +99 -0
  41. package/dist/public/assets/flowDiagram-KWPJA3E3-WuqnDSiF.js +213 -0
  42. package/dist/public/assets/ganttDiagram-FUAMR5RP-DZ4lXzkC.js +292 -0
  43. package/dist/public/assets/gitGraphDiagram-X574FWY7-t6l07b9g.js +106 -0
  44. package/dist/public/assets/graph-jWsD5Ro-.js +1 -0
  45. package/dist/public/assets/index-DiYff8oR.css +32 -0
  46. package/dist/public/assets/index-YZNK723o.js +203 -0
  47. package/dist/public/assets/infoDiagram-VRGFBTTK-iUGqoOFp.js +2 -0
  48. package/dist/public/assets/init-Gi6I4Gst.js +1 -0
  49. package/dist/public/assets/ishikawaDiagram-OU5B5YK6-B0TmoUOK.js +70 -0
  50. package/dist/public/assets/journeyDiagram-ZHPQQLJL-CQE61MCu.js +139 -0
  51. package/dist/public/assets/kanban-definition-PNTS6WVX-D2MWCUyS.js +89 -0
  52. package/dist/public/assets/katex-C5jXJg4s.js +257 -0
  53. package/dist/public/assets/linear-WKGxVNP1.js +1 -0
  54. package/dist/public/assets/mermaid.core-DPEwUsaj.js +311 -0
  55. package/dist/public/assets/mindmap-definition-NLK3R4M7-DsYEsBsI.js +96 -0
  56. package/dist/public/assets/ordinal-Cboi1Yqb.js +1 -0
  57. package/dist/public/assets/pegDiagram-GJSIUBJH-BS52Fbxt.js +1 -0
  58. package/dist/public/assets/pieDiagram-5QR66LMP-D4o8g7nW.js +39 -0
  59. package/dist/public/assets/quadrantDiagram-O4NWA36T-DnYUxWll.js +7 -0
  60. package/dist/public/assets/railroadDiagram-XR7U4H2S-BKy_7ULV.js +1 -0
  61. package/dist/public/assets/requirementDiagram-PLB6GJNP-Ce5F4u8K.js +84 -0
  62. package/dist/public/assets/sankeyDiagram-IPEJSGJF-CCJDrut_.js +40 -0
  63. package/dist/public/assets/sequenceDiagram-PO4LG4MO-D5E8ISM1.js +169 -0
  64. package/dist/public/assets/sizeCapture-INFHLROL-BW3KmxDN.js +1 -0
  65. package/dist/public/assets/stateDiagram-v2-GCMORJYK-Bw2ZzQOE.js +291 -0
  66. package/dist/public/assets/swimlanes-2SLR337P-CLJ-6Ex_.js +1 -0
  67. package/dist/public/assets/swimlanesDiagram-TC7HE7FX-BD8rQfV7.js +8 -0
  68. package/dist/public/assets/timeline-definition-EJHVYXUP-C_lFJ-4W.js +120 -0
  69. package/dist/public/assets/usecaseDiagram-POWQR4AR-DWXawL2A.js +370 -0
  70. package/dist/public/assets/vennDiagram-UO4OBE2U-D4O5oZal.js +34 -0
  71. package/dist/public/assets/wardleyDiagram-VNRHLVJA-B6HElk__.js +78 -0
  72. package/dist/public/assets/xychartDiagram-PMCCYNJV-C5OAXnJy.js +7 -0
  73. package/dist/public/index.html +2 -2
  74. package/dist/src/agent-runtime/adapter.js +54 -10
  75. package/dist/src/agent-runtime/builtinRuntime.js +67 -6
  76. package/dist/src/agent-runtime/conversation.js +49 -9
  77. package/dist/src/agent-runtime/interaction.js +26 -12
  78. package/dist/src/agent-runtime/interactionLiveHub.js +4 -0
  79. package/dist/src/agent-runtime/runtime.js +83 -27
  80. package/dist/src/agent-runtime/tmuxRuntime.js +36 -6
  81. package/dist/src/agents/claude.js +2 -19
  82. package/dist/src/agents/claudeInteraction.js +2 -132
  83. package/dist/src/agents/claudePaneInput.js +22 -22
  84. package/dist/src/agents/codebuddy.js +100 -0
  85. package/dist/src/agents/codebuddyConversation.js +414 -0
  86. package/dist/src/agents/codebuddyInteraction.js +4 -0
  87. package/dist/src/agents/codebuddyNativeTail.js +206 -0
  88. package/dist/src/agents/codebuddyPaneInput.js +76 -0
  89. package/dist/src/agents/codebuddyTranscriptParse.js +178 -0
  90. package/dist/src/agents/hookEvents.js +143 -0
  91. package/dist/src/agents/hookInteraction.js +178 -0
  92. package/dist/src/agents/index.js +2 -1
  93. package/dist/src/claudeEvents.js +4 -5
  94. package/dist/src/cli/agentIntegration.js +31 -2
  95. package/dist/src/cli/codebuddyHooks.js +286 -0
  96. package/dist/src/cli/hookScaffold.js +25 -12
  97. package/dist/src/cli/i18n/en.js +1 -1
  98. package/dist/src/cli/i18n/zh.js +1 -1
  99. package/dist/src/cli/state.js +4 -0
  100. package/dist/src/cli/supervisor.js +2 -1
  101. package/dist/src/codebuddyEvents.js +151 -0
  102. package/dist/src/docs.js +21 -7
  103. package/dist/src/logDetail.js +27 -0
  104. package/dist/src/paneInput.js +107 -10
  105. package/dist/src/pendingPrompt.js +21 -5
  106. package/dist/src/routes/files.js +4 -1
  107. package/dist/src/routes/system.js +12 -2
  108. package/dist/src/server.js +69 -8
  109. package/dist/src/trimCapture.js +1 -1
  110. package/dist/src/workspace/checkpointer.js +48 -4
  111. package/dist/src/workspace/lock.js +54 -10
  112. package/dist/src/workspace/runtime.js +23 -8
  113. package/dist/src/workspaceProtocol.js +1 -0
  114. package/package.json +4 -4
  115. package/dist/public/assets/index-2ghkmGbT.js +0 -356
  116. package/dist/public/assets/index-BjnnFTLC.css +0 -32
@@ -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 Claude Code hook installer and statusLine capturer.
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
- export const HOOK_SCRIPTS = ['handmux-notify.sh', 'handmux-write.cjs'];
5
- const LEGACY_HOOK_SCRIPTS = ['handmux-codex-usage.cjs'];
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 the Claude notify/write scripts into `hooksDir` and point their env at the shared state file.
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 HOOK_SCRIPTS)
31
+ for (const f of [hookSet.notify, WRITER_SCRIPT]) {
21
32
  fs.copyFileSync(path.join(srcDir, f), path.join(hooksDir, f));
22
- for (const f of LEGACY_HOOK_SCRIPTS) {
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, 'handmux-notify.sh'), 0o755);
29
- fs.writeFileSync(path.join(hooksDir, 'handmux-notify.env'), [
40
+ fs.chmodSync(path.join(hooksDir, hookSet.notify), 0o755);
41
+ fs.writeFileSync(path.join(hooksDir, hookEnvName(hookSet)), [
30
42
  `HANDMUX_STATE=${shellWord(stateFile)}`,
31
- `HANDMUX_CLAUDE_EVENTS=${shellWord(`${stateFile}.events`)}`,
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
- for (const f of [...HOOK_SCRIPTS, ...LEGACY_HOOK_SCRIPTS, 'handmux-notify.env']) {
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
  }
@@ -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.',
@@ -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 接入。',
@@ -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
+ }