handmux 0.29.2 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/connectors/claude/index.js +33 -562
  2. package/dist/connectors/codebuddy/index.js +49 -0
  3. package/dist/connectors/hookBridge.js +560 -0
  4. package/dist/hooks/handmux-codebuddy-notify.sh +116 -0
  5. package/dist/hooks/handmux-notify.sh +2 -0
  6. package/dist/hooks/handmux-write.cjs +72 -5
  7. package/dist/package.json +13 -13
  8. package/dist/public/assets/_basePickBy-BzC0P9Wg.js +1 -0
  9. package/dist/public/assets/_baseUniq-DNOfPYVY.js +1 -0
  10. package/dist/public/assets/abnfDiagram-O67JEVCF-BsRDIQEB.js +1 -0
  11. package/dist/public/assets/arc-DLtLfMnX.js +1 -0
  12. package/dist/public/assets/architectureDiagram-NJMV4G6O-DoIXhWqZ.js +36 -0
  13. package/dist/public/assets/blockDiagram-BEXU5L5S-W0HiOcVA.js +140 -0
  14. package/dist/public/assets/c4Diagram-YGBWAQC7-72uAUMvX.js +38 -0
  15. package/dist/public/assets/channel-CzALSnb0.js +1 -0
  16. package/dist/public/assets/chunk-2Q5K7J3B-B9wSRFQ0.js +1 -0
  17. package/dist/public/assets/chunk-3FUC2YCW-ryphp4s0.js +2 -0
  18. package/dist/public/assets/chunk-5VM5RSS4-Cm4KeKby.js +15 -0
  19. package/dist/public/assets/chunk-GTNCS2PH-Du4_i7r3.js +88 -0
  20. package/dist/public/assets/chunk-GWA4HPMP-CL1biVIA.js +1 -0
  21. package/dist/public/assets/chunk-JWPE2WC7-B8eAVfuB.js +1 -0
  22. package/dist/public/assets/chunk-NETBCI7D-D273GzYg.js +1 -0
  23. package/dist/public/assets/chunk-WEXAMYUT-DipTfRbc.js +1 -0
  24. package/dist/public/assets/chunk-XXDRQBXY-D4SqXrQ8.js +1 -0
  25. package/dist/public/assets/classDiagram-v2-NBCMYWYE-PrpnIXcA.js +217 -0
  26. package/dist/public/assets/cose-bilkent-JH36ORCC-BfcYMYTa.js +1 -0
  27. package/dist/public/assets/cynefin-OW5HDTMX-Du659bmz.js +178 -0
  28. package/dist/public/assets/cynefinDiagram-VND7K2PF-DR_2Copy.js +62 -0
  29. package/dist/public/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  30. package/dist/public/assets/dagre-6A5THRUB-COsChaV5.js +4 -0
  31. package/dist/public/assets/defaultLocale-CrowFXzY.js +1 -0
  32. package/dist/public/assets/diagram-22UHCM2B-Cx2T1HvL.js +200 -0
  33. package/dist/public/assets/diagram-3UASUU5V-Cp04LOka.js +24 -0
  34. package/dist/public/assets/diagram-ATOU4E4O-DlqFSshY.js +3 -0
  35. package/dist/public/assets/diagram-CDSNMT55-goq40ctA.js +30 -0
  36. package/dist/public/assets/diagram-MLGK6HIB-Bdzjsy4r.js +24 -0
  37. package/dist/public/assets/diagram-MPIPVDR6-DtU7cM6P.js +41 -0
  38. package/dist/public/assets/ebnfDiagram-ZINNZB2B-XyVCfmED.js +1 -0
  39. package/dist/public/assets/elk-276RUBZZ-aHVIw-2x.js +27 -0
  40. package/dist/public/assets/erDiagram-OPXOYQCR-Bq_PhTIL.js +99 -0
  41. package/dist/public/assets/flowDiagram-KWPJA3E3-WuqnDSiF.js +213 -0
  42. package/dist/public/assets/ganttDiagram-FUAMR5RP-DZ4lXzkC.js +292 -0
  43. package/dist/public/assets/gitGraphDiagram-X574FWY7-t6l07b9g.js +106 -0
  44. package/dist/public/assets/graph-jWsD5Ro-.js +1 -0
  45. package/dist/public/assets/index-DiYff8oR.css +32 -0
  46. package/dist/public/assets/index-YZNK723o.js +203 -0
  47. package/dist/public/assets/infoDiagram-VRGFBTTK-iUGqoOFp.js +2 -0
  48. package/dist/public/assets/init-Gi6I4Gst.js +1 -0
  49. package/dist/public/assets/ishikawaDiagram-OU5B5YK6-B0TmoUOK.js +70 -0
  50. package/dist/public/assets/journeyDiagram-ZHPQQLJL-CQE61MCu.js +139 -0
  51. package/dist/public/assets/kanban-definition-PNTS6WVX-D2MWCUyS.js +89 -0
  52. package/dist/public/assets/katex-C5jXJg4s.js +257 -0
  53. package/dist/public/assets/linear-WKGxVNP1.js +1 -0
  54. package/dist/public/assets/mermaid.core-DPEwUsaj.js +311 -0
  55. package/dist/public/assets/mindmap-definition-NLK3R4M7-DsYEsBsI.js +96 -0
  56. package/dist/public/assets/ordinal-Cboi1Yqb.js +1 -0
  57. package/dist/public/assets/pegDiagram-GJSIUBJH-BS52Fbxt.js +1 -0
  58. package/dist/public/assets/pieDiagram-5QR66LMP-D4o8g7nW.js +39 -0
  59. package/dist/public/assets/quadrantDiagram-O4NWA36T-DnYUxWll.js +7 -0
  60. package/dist/public/assets/railroadDiagram-XR7U4H2S-BKy_7ULV.js +1 -0
  61. package/dist/public/assets/requirementDiagram-PLB6GJNP-Ce5F4u8K.js +84 -0
  62. package/dist/public/assets/sankeyDiagram-IPEJSGJF-CCJDrut_.js +40 -0
  63. package/dist/public/assets/sequenceDiagram-PO4LG4MO-D5E8ISM1.js +169 -0
  64. package/dist/public/assets/sizeCapture-INFHLROL-BW3KmxDN.js +1 -0
  65. package/dist/public/assets/stateDiagram-v2-GCMORJYK-Bw2ZzQOE.js +291 -0
  66. package/dist/public/assets/swimlanes-2SLR337P-CLJ-6Ex_.js +1 -0
  67. package/dist/public/assets/swimlanesDiagram-TC7HE7FX-BD8rQfV7.js +8 -0
  68. package/dist/public/assets/timeline-definition-EJHVYXUP-C_lFJ-4W.js +120 -0
  69. package/dist/public/assets/usecaseDiagram-POWQR4AR-DWXawL2A.js +370 -0
  70. package/dist/public/assets/vennDiagram-UO4OBE2U-D4O5oZal.js +34 -0
  71. package/dist/public/assets/wardleyDiagram-VNRHLVJA-B6HElk__.js +78 -0
  72. package/dist/public/assets/xychartDiagram-PMCCYNJV-C5OAXnJy.js +7 -0
  73. package/dist/public/index.html +2 -2
  74. package/dist/src/agent-runtime/adapter.js +54 -10
  75. package/dist/src/agent-runtime/builtinRuntime.js +67 -6
  76. package/dist/src/agent-runtime/conversation.js +49 -9
  77. package/dist/src/agent-runtime/interaction.js +26 -12
  78. package/dist/src/agent-runtime/interactionLiveHub.js +4 -0
  79. package/dist/src/agent-runtime/runtime.js +83 -27
  80. package/dist/src/agent-runtime/tmuxRuntime.js +36 -6
  81. package/dist/src/agents/claude.js +2 -19
  82. package/dist/src/agents/claudeInteraction.js +2 -132
  83. package/dist/src/agents/claudePaneInput.js +22 -22
  84. package/dist/src/agents/codebuddy.js +100 -0
  85. package/dist/src/agents/codebuddyConversation.js +414 -0
  86. package/dist/src/agents/codebuddyInteraction.js +4 -0
  87. package/dist/src/agents/codebuddyNativeTail.js +206 -0
  88. package/dist/src/agents/codebuddyPaneInput.js +76 -0
  89. package/dist/src/agents/codebuddyTranscriptParse.js +178 -0
  90. package/dist/src/agents/hookEvents.js +143 -0
  91. package/dist/src/agents/hookInteraction.js +178 -0
  92. package/dist/src/agents/index.js +2 -1
  93. package/dist/src/claudeEvents.js +4 -5
  94. package/dist/src/cli/agentIntegration.js +31 -2
  95. package/dist/src/cli/codebuddyHooks.js +286 -0
  96. package/dist/src/cli/hookScaffold.js +25 -12
  97. package/dist/src/cli/i18n/en.js +1 -1
  98. package/dist/src/cli/i18n/zh.js +1 -1
  99. package/dist/src/cli/state.js +4 -0
  100. package/dist/src/cli/supervisor.js +2 -1
  101. package/dist/src/codebuddyEvents.js +151 -0
  102. package/dist/src/docs.js +21 -7
  103. package/dist/src/logDetail.js +27 -0
  104. package/dist/src/paneInput.js +107 -10
  105. package/dist/src/pendingPrompt.js +21 -5
  106. package/dist/src/routes/files.js +4 -1
  107. package/dist/src/routes/system.js +12 -2
  108. package/dist/src/server.js +69 -8
  109. package/dist/src/trimCapture.js +1 -1
  110. package/dist/src/workspace/checkpointer.js +48 -4
  111. package/dist/src/workspace/lock.js +54 -10
  112. package/dist/src/workspace/runtime.js +23 -8
  113. package/dist/src/workspaceProtocol.js +1 -0
  114. package/package.json +4 -4
  115. package/dist/public/assets/index-2ghkmGbT.js +0 -356
  116. package/dist/public/assets/index-BjnnFTLC.css +0 -32
@@ -0,0 +1,76 @@
1
+ // CodeBuddy's pane input: the delivery and interrupt semantics for its TUI. The mechanics are shared
2
+ // (paneInput.ts owns the per-pane critical section, the text+submit gap and the one-line editor reader);
3
+ // what is CodeBuddy's is the editor's own shape and the keys its keybindings document.
4
+ //
5
+ // Measured on 2.156.0, its editor is the same one-line box Claude draws, with `>` as the prompt and a
6
+ // right-aligned `↵ send` footer painted inside the same line once there is draft text:
7
+ //
8
+ // ────────────────────────────────────────────────
9
+ // > 检查文件是否创建成功 ↵ send
10
+ // ────────────────────────────────────────────────
11
+ // ? for shortcuts ← for agents
12
+ //
13
+ // With no draft text the capture arrives as the bare `>`: the plain space CodeBuddy paints after its prompt
14
+ // is a trailing blank, which `capture-pane` drops (Claude's non-breaking space survives). The reader treats
15
+ // that as the empty editor it is — see `singleLineDraft`. The distinction matters downstream: the same line
16
+ // is what a cleared editor looks like, so reading it as unrecognizable refused both the send itself and the
17
+ // check that our own stale prompt had been cleared.
18
+ //
19
+ // Interrupt is `esc`: the pane's own footer says so (`✹ Buzzing… (esc to interrupt)`) and Escape really does
20
+ // stop it — measured, see `interruptAgentPane` in paneInput.ts. CodeBuddy's published keybindings list
21
+ // `ctrl+c → app:interrupt` (ctrl+d is exit); sending that one mid-tool did nothing at all.
22
+ import { describeEditorArea, serializePaneInput, singleLineDraft } from '../paneInput.js';
23
+ const SUBMIT_GAP_MS = 120;
24
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
25
+ // The footer the editor paints at the right edge of the draft line. Stripped before the draft is judged,
26
+ // so `> draft ↵ send` reads as `draft` while the footer itself can never be mistaken for typed text.
27
+ const EDITOR_HINT_RE = /[\s\u00a0]*↵\s*\S+[\s\u00a0]*$/;
28
+ export function codebuddySingleLineDraft(screen, cursor) {
29
+ return singleLineDraft(screen, cursor, '>', EDITOR_HINT_RE);
30
+ }
31
+ // Send a prompt the way the composer means it: replace only a draft this pane is KNOWN to own (the prompt
32
+ // we restored from the run's own submission) and never a draft a human typed — that is a refusal the caller
33
+ // can surface, not a silent deletion. Anything the reader cannot recognize refuses too.
34
+ export function sendCodeBuddyPanePrompt(commands, paneId, text, restoredPrompt, guard, reportUnreadable) {
35
+ return serializePaneInput(paneId, async () => {
36
+ if (guard && !await guard.validate())
37
+ return { nativeMutation: false };
38
+ await commands.exitCopyModeIfActive(paneId);
39
+ await commands.sendKey(paneId, 'End');
40
+ await delay(SUBMIT_GAP_MS);
41
+ const readEditor = async () => {
42
+ const screen = await commands.capturePlain(paneId);
43
+ const cursor = await commands.paneInfo(paneId);
44
+ return { screen, cursor, draft: codebuddySingleLineDraft(screen, cursor) };
45
+ };
46
+ const draft = await readEditor();
47
+ if (draft.draft === null) {
48
+ // Unreadable: record the shape, never the text we could not read (see describeEditorArea).
49
+ reportUnreadable?.(describeEditorArea(draft.screen, draft.cursor));
50
+ return { nativeMutation: false, reason: 'terminal_draft_conflict' };
51
+ }
52
+ if (draft.draft !== '' && draft.draft !== restoredPrompt()) {
53
+ return { nativeMutation: false, reason: 'terminal_draft_conflict' };
54
+ }
55
+ if (guard && !await guard.validate())
56
+ return { nativeMutation: false };
57
+ if (draft.draft) {
58
+ // Clear our own stale prompt. The key is unverified against a live CodeBuddy editor (its published
59
+ // keybindings cover app-level keys only), so the result is verified before anything is sent — a
60
+ // binding that does not clear leaves us refusing, never appending to unseen text.
61
+ await commands.sendKey(paneId, 'C-u');
62
+ await delay(SUBMIT_GAP_MS);
63
+ const cleared = await readEditor();
64
+ if (cleared.draft !== '') {
65
+ if (cleared.draft === null)
66
+ reportUnreadable?.(describeEditorArea(cleared.screen, cleared.cursor));
67
+ return { nativeMutation: false, reason: 'terminal_draft_conflict' };
68
+ }
69
+ }
70
+ await commands.sendText(paneId, text);
71
+ if (text)
72
+ await delay(SUBMIT_GAP_MS);
73
+ await commands.sendEnter(paneId);
74
+ return { nativeMutation: true };
75
+ });
76
+ }
@@ -0,0 +1,178 @@
1
+ const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null);
2
+ // A bare slash command (`/compact`, `/clear`, `/model sonnet`) is logged as an ordinary user turn. It IS
3
+ // part of the conversation, so it becomes a quiet 'slash' marker instead of a prominent bubble. Anchored to
4
+ // a single leading lowercase command token followed by whitespace/EOL, so "/Users/demo/foo.js" (uppercase,
5
+ // then a path separator) and prose mentioning a path are never eaten.
6
+ const SLASH_CMD_RE = /^\s*\/([a-z][\w-]*)(?:\s+([\s\S]*?))?\s*$/i;
7
+ // The compaction summary wrapper: `<cb_summary>` / `<conversation_history_summary>` around the real text.
8
+ const SUMMARY_WRAP_RE = /^\s*<(?:cb_summary|conversation_history_summary)>([\s\S]*?)(?:<\/(?:cb_summary|conversation_history_summary)>)?\s*$/i;
9
+ const SUMMARY_LEAD_RE = /^\s*Summary(?: of the conversation so far)?:\s*/i;
10
+ const SUMMARY_CAP = 4_096;
11
+ function text(value) {
12
+ return typeof value === 'string' ? value : '';
13
+ }
14
+ // The record's wall-clock, in the ISO form the shared model carries. CodeBuddy logs epoch milliseconds
15
+ // (Claude logs an ISO string), and a record without a usable one simply gets no time.
16
+ function isoTime(value) {
17
+ if (typeof value !== 'number' || !Number.isFinite(value))
18
+ return undefined;
19
+ const date = new Date(value);
20
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
21
+ }
22
+ // `arguments` is a JSON STRING (measured). Parse it so the projection can sanitize a real object; an
23
+ // unparsable payload is passed through as the raw string rather than dropped — a tool call the user cannot
24
+ // see is worse than one whose arguments are opaque.
25
+ function toolInput(value) {
26
+ if (typeof value !== 'string')
27
+ return value ?? {};
28
+ const trimmed = value.trim();
29
+ if (!trimmed)
30
+ return {};
31
+ try {
32
+ return JSON.parse(trimmed);
33
+ }
34
+ catch {
35
+ return trimmed;
36
+ }
37
+ }
38
+ function contentText(value) {
39
+ if (typeof value === 'string')
40
+ return value;
41
+ if (!Array.isArray(value))
42
+ return '';
43
+ return value
44
+ .map(record)
45
+ .filter((block) => block !== null)
46
+ .map((block) => text(block.text))
47
+ .join('');
48
+ }
49
+ export function createCodeBuddyTranscriptParser() {
50
+ const messages = [];
51
+ const pending = new Map();
52
+ let i = 0;
53
+ function push(lines) {
54
+ for (const raw of lines) {
55
+ const line = typeof raw === 'string' ? raw.trim() : '';
56
+ if (!line) {
57
+ i++;
58
+ continue;
59
+ }
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(line);
63
+ }
64
+ catch {
65
+ i++;
66
+ continue;
67
+ }
68
+ const row = record(parsed);
69
+ const type = text(row?.type);
70
+ if (!row || !type) {
71
+ i++;
72
+ continue;
73
+ }
74
+ const provider = record(row.providerData) ?? {};
75
+ const ts = isoTime(row.timestamp);
76
+ const index = i;
77
+ if (type === 'message') {
78
+ if (provider.isMeta === true || provider.skipRun === true || provider.isPartialAborted === true) {
79
+ i++;
80
+ continue;
81
+ }
82
+ const role = row.role === 'user' ? 'user' : row.role === 'assistant' ? 'assistant' : null;
83
+ if (!role) {
84
+ i++;
85
+ continue;
86
+ }
87
+ const body = contentText(row.content);
88
+ if (provider.isCompacted === true || provider.isSummary === true) {
89
+ // A compaction happened here. Keep the divider (the 对话 lens shows "上下文已压缩"), cap the text.
90
+ const wrapped = SUMMARY_WRAP_RE.exec(body);
91
+ const summary = (wrapped?.[1] ?? body).replace(SUMMARY_LEAD_RE, '').trim();
92
+ const message = { i, type: 'compact', ts };
93
+ if (summary) {
94
+ message.summary = summary.length > SUMMARY_CAP ? summary.slice(0, SUMMARY_CAP - 1) + '…' : summary;
95
+ if (summary.length > SUMMARY_CAP) {
96
+ message.summaryTruncated = true;
97
+ message.summaryOriginalBytes = Buffer.byteLength(summary, 'utf8');
98
+ }
99
+ }
100
+ messages.push(message);
101
+ i++;
102
+ continue;
103
+ }
104
+ // The synthetic "continue from the summary" prompt is not a human turn.
105
+ if (provider.isCompactInternal === true) {
106
+ i++;
107
+ continue;
108
+ }
109
+ if (role === 'user') {
110
+ const command = SLASH_CMD_RE.exec(body);
111
+ if (command?.[1]) {
112
+ const message = { i, type: 'slash', name: `/${command[1]}`, ts };
113
+ if (command[2]?.trim())
114
+ message.args = command[2].trim();
115
+ messages.push(message);
116
+ i++;
117
+ continue;
118
+ }
119
+ }
120
+ if (!body.trim()) {
121
+ i++;
122
+ continue;
123
+ }
124
+ const message = { i, type: 'text', role, text: body, ts };
125
+ messages.push(message);
126
+ i++;
127
+ continue;
128
+ }
129
+ if (type === 'reasoning') {
130
+ // Only the provider's PUBLIC projection; `rawContent` is the raw chain of thought.
131
+ const reasoning = contentText(row.content).trim();
132
+ if (reasoning) {
133
+ const message = { i, type: 'thinking', role: 'assistant', text: reasoning, ts };
134
+ messages.push(message);
135
+ }
136
+ i++;
137
+ continue;
138
+ }
139
+ if (type === 'function_call') {
140
+ const tool = {
141
+ name: text(row.name),
142
+ input: toolInput(row.arguments),
143
+ result: null,
144
+ isError: false,
145
+ };
146
+ const message = {
147
+ i, type: 'tool', role: 'assistant', ts, tool,
148
+ };
149
+ const callId = text(row.callId);
150
+ if (callId)
151
+ pending.set(callId, message);
152
+ messages.push(message);
153
+ i++;
154
+ continue;
155
+ }
156
+ if (type === 'function_call_result') {
157
+ const message = pending.get(text(row.callId));
158
+ if (message) {
159
+ const result = record(row.providerData);
160
+ const toolResult = record(result?.toolResult);
161
+ const output = record(row.output);
162
+ const body = text(output?.text) || text(toolResult?.content);
163
+ message.tool.result = body;
164
+ // `status` is the provider's own verdict (measured: 'completed'); an `error` on the tool result
165
+ // is the same fact stated a second way. Either one marks the failure.
166
+ message.tool.isError = (text(row.status) !== 'completed' && text(row.status) !== '')
167
+ || toolResult?.error !== undefined;
168
+ pending.delete(text(row.callId));
169
+ }
170
+ i++;
171
+ continue;
172
+ }
173
+ i++;
174
+ }
175
+ return messages;
176
+ }
177
+ return { push, messages };
178
+ }
@@ -0,0 +1,143 @@
1
+ // The shared Hook-event layer. Claude Code and CodeBuddy both drive Handmux through filesystem Hooks, and
2
+ // CodeBuddy's Hook protocol is Claude Code's: the same `src` argument convention, the same raw payload on
3
+ // stdin, and the same JSON latest-state file plus bounded durable spool written by the SHARED
4
+ // server/hooks/handmux-write.cjs. So the two things those providers genuinely have in common live here:
5
+ //
6
+ // 1. the lifecycle VOCABULARY every Hook-driven Inbox state is normalized into;
7
+ // 2. the on-disk RECORD FORMAT (state row, spool event, owning-process fingerprint).
8
+ //
9
+ // What stays per-Agent — and therefore does NOT live here — is which `src` string means what, which payload
10
+ // fields carry the message, and how a pane proves the Agent is still its foreground process. Those are
11
+ // product facts and belong with the provider (agents/claude.ts, codebuddyEvents.ts).
12
+ import fs from 'node:fs';
13
+ const EVENT_KINDS = new Set([
14
+ 'done', 'working', 'permission', 'compacting', 'error', 'end', 'idle',
15
+ ]);
16
+ // Validate an untrusted kind string against the shared vocabulary.
17
+ export function hookEventKind(value) {
18
+ return typeof value === 'string' && EVENT_KINDS.has(value)
19
+ ? value : null;
20
+ }
21
+ // A normalized kind → the Inbox state the Core understands, or null when it carries no state at all
22
+ // (`end`/`idle`/unknown). Compacted work is still work; a permission gate is waiting on the user.
23
+ export function canonicalInboxState(kind) {
24
+ if (kind === 'working' || kind === 'compacting')
25
+ return 'working';
26
+ if (kind === 'permission')
27
+ return 'waiting';
28
+ if (kind === 'done')
29
+ return 'done';
30
+ if (kind === 'error')
31
+ return 'error';
32
+ return null;
33
+ }
34
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
35
+ const bounded = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max;
36
+ // ---------------------------------------------------------------------------------------------
37
+ // On-disk format shared with server/hooks/handmux-write.cjs
38
+ // ---------------------------------------------------------------------------------------------
39
+ export const HOOK_EVENT_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$/;
40
+ export function parseHookProcessFingerprint(value) {
41
+ if (!isRecord(value) || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0
42
+ || typeof value.startedAt !== 'number' || !Number.isFinite(value.startedAt)
43
+ || value.startedAt < 0 || !bounded(value.tty, 1024))
44
+ return null;
45
+ return { pid: Number(value.pid), startedAt: value.startedAt, tty: value.tty };
46
+ }
47
+ export function normalizedTty(value) {
48
+ if (!value)
49
+ return undefined;
50
+ return value.startsWith('/dev/') ? value : `/dev/${value}`;
51
+ }
52
+ // An absent fingerprint is accepted: it is the additive field of a newer writer, and records produced by an
53
+ // older installed Hook must keep working.
54
+ export function matchesHookProcess(source, current) {
55
+ if (!source)
56
+ return true;
57
+ return source.pid === current.pid
58
+ && source.startedAt === current.startedAt
59
+ && normalizedTty(source.tty) === normalizedTty(current.tty);
60
+ }
61
+ // `acceptAgent` decides which provider's marking a row may carry: an absent `agent` is always legacy
62
+ // compatibility, and every explicit foreign marking must fail closed rather than borrow another Agent's state.
63
+ export function readHookStateRows(file, acceptAgent) {
64
+ try {
65
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
66
+ if (!isRecord(value))
67
+ return new Map();
68
+ return new Map(Object.entries(value).flatMap(([paneId, raw]) => {
69
+ const row = isRecord(raw) ? raw : null;
70
+ const payload = isRecord(row?.payload) ? row.payload : null;
71
+ if (!bounded(paneId, 256) || !row || !payload || !bounded(row.src, 64)
72
+ || (row.agent !== undefined && !acceptAgent(row.agent)))
73
+ return [];
74
+ const ts = typeof row.ts === 'number' && Number.isFinite(row.ts) ? row.ts : 0;
75
+ const sequence = Number.isSafeInteger(row.sequence) && Number(row.sequence) > 0
76
+ ? Number(row.sequence) : undefined;
77
+ const process = row.process === undefined ? undefined : parseHookProcessFingerprint(row.process);
78
+ if (process === null)
79
+ return [];
80
+ return [[paneId, {
81
+ ts,
82
+ src: row.src,
83
+ ...(typeof row.agent === 'string' ? { agent: row.agent } : {}),
84
+ payload,
85
+ ...(sequence === undefined ? {} : { sequence }),
86
+ ...(process === undefined ? {} : { process }),
87
+ }]];
88
+ }));
89
+ }
90
+ catch {
91
+ return new Map();
92
+ }
93
+ }
94
+ export function parseHookBridgeEvent(value, acceptAgent) {
95
+ if (!isRecord(value) || value.version !== 1 || (value.type !== 'event' && value.type !== 'gap')
96
+ || (value.agent !== undefined && !acceptAgent(value.agent))
97
+ || !bounded(value.eventId, 256) || !HOOK_EVENT_ID_RE.test(value.eventId)
98
+ || !bounded(value.paneId, 256)
99
+ || (value.sequence !== undefined && (!Number.isSafeInteger(value.sequence) || Number(value.sequence) <= 0))
100
+ || (value.src !== undefined && !bounded(value.src, 64))
101
+ || (value.sessionId !== undefined && !bounded(value.sessionId, 1024))
102
+ || (value.sourceOccurredAt !== undefined
103
+ && (typeof value.sourceOccurredAt !== 'number' || !Number.isFinite(value.sourceOccurredAt)))
104
+ || (value.process !== undefined && parseHookProcessFingerprint(value.process) === null)
105
+ || (value.payload !== undefined && !isRecord(value.payload)))
106
+ return null;
107
+ return {
108
+ ...value,
109
+ ...(value.process === undefined ? {} : { process: parseHookProcessFingerprint(value.process) }),
110
+ };
111
+ }
112
+ // The durable source sequence embedded in a spool event ID (`<agent>-hook-<n>`). The writer is shared, so
113
+ // this deliberately reads the suffix rather than pinning one Agent's prefix: the prefix is the writer's
114
+ // choice, not a contract this reader may tighten.
115
+ export function hookEventSequence(eventId) {
116
+ const match = /^[a-z][a-z0-9-]*-hook-([1-9]\d*)$/.exec(eventId);
117
+ if (!match)
118
+ return null;
119
+ const sequence = Number(match[1]);
120
+ return Number.isSafeInteger(sequence) ? sequence : null;
121
+ }
122
+ // Friendly Chinese for an API-error turn end. The payload shape for a StopFailure is not verified against
123
+ // every provider, so read the type defensively from several likely fields and always fall back to a bare
124
+ // 本轮出错 — a wrong field name degrades to the generic label, never throws.
125
+ const ERROR_LABEL = {
126
+ rate_limit: '触发限流', overloaded: '服务过载', authentication_failed: '认证失败',
127
+ oauth_org_not_allowed: '组织未授权', billing_error: '额度/账单问题', invalid_request: '请求无效',
128
+ model_not_found: '模型不可用', server_error: '服务端错误', max_output_tokens: '输出超长', unknown: '未知错误',
129
+ };
130
+ export function hookErrorMessage(body) {
131
+ const error = isRecord(body.error) ? body.error : null;
132
+ const type = typeof body.error_type === 'string' ? body.error_type
133
+ : typeof body.reason === 'string' ? body.reason
134
+ : typeof body.type === 'string' ? body.type
135
+ : typeof body.error === 'string' ? body.error
136
+ : typeof error?.type === 'string' ? error.type : '';
137
+ const label = ERROR_LABEL[type];
138
+ if (label)
139
+ return label;
140
+ const raw = typeof body.error === 'string' ? body.error
141
+ : typeof body.message === 'string' ? body.message : '';
142
+ return raw ? raw.replace(/\s+/g, ' ').trim().slice(0, 80) : '';
143
+ }
@@ -0,0 +1,178 @@
1
+ // The pending-interaction adapter shared by the Hook-driven Agents (Claude, CodeBuddy). Their TUIs are the
2
+ // same one-line-editor lineage, so the prompts they put on screen have the same shape — a cursor-marked
3
+ // numbered option list with a description row under an option — and they parse with the SAME
4
+ // `parsePendingPrompt`. What differs is the KEYSTROKE that picks an option, and that turns out to depend on
5
+ // the SCREEN rather than on the provider — so both bind one shared sender (paneInput.sendPaneMenuChoice):
6
+ //
7
+ // question menu a digit only MOVES the highlight. Measured live on CodeBuddy 2.156.0, whose picker
8
+ // ignores the digit outright (the highlight does not budge); and read out of Claude
9
+ // Code's own bundle (2.1.278), where `1`–`9` go through the same `nme` that ↑/↓ call
10
+ // while only the `return` branch commits. Answering therefore means walking to the
11
+ // option and pressing Enter — on both providers.
12
+ // permission gate the digit commits: measured on CodeBuddy, where a `1` approved a command that really
13
+ // ran. It is also the safer key there — it names the row instead of stepping to it, so a
14
+ // misread cursor cannot commit a different answer than the user tapped.
15
+ //
16
+ // A provider supplies its own naming (the ids the phone stores and the fallback wording a user reads);
17
+ // everything else is the shared contract. Where a provider's wording is NOT verifiable — a permission gate
18
+ // whose screen shows no options — the adapter emits a `local_only` prompt with no options rather than
19
+ // guessing what the buttons mean.
20
+ import { createHash } from 'node:crypto';
21
+ import { parsePendingPrompt } from '../pendingPrompt.js';
22
+ // A pane that is mid-turn says so on its own screen: both providers print the interrupt hint while the model
23
+ // or a tool is running — `✹ Buzzing… (esc to interrupt)`, measured on CodeBuddy 2.156.0, and Claude prints the
24
+ // same hint. A gate that is really up cannot be mid-turn; the pane is blocked ON the gate. So this is what
25
+ // keeps the no-options fallback quiet in the one window where its `permreq` row is stale by construction:
26
+ // CodeBuddy fires no Hook when a gate is answered, so an approved tool keeps its row (and the roster at 需要你)
27
+ // until the turn's next Hook — measured on pane %211, where an approved `sleep 90` said 需要你授权:Bash for the
28
+ // whole minute while the tool ran, and the fallback turned that into a 去终端 card nothing was waiting for.
29
+ const INTERRUPT_HINT_RE = /esc to interrupt/i;
30
+ function normalizedPrompt(text, provider, pendingKind) {
31
+ const prompt = parsePendingPrompt(text);
32
+ if (!prompt) {
33
+ if (pendingKind !== 'permission' || INTERRUPT_HINT_RE.test(text))
34
+ return null;
35
+ const tail = text.split('\n').map((line) => line.trim()).filter(Boolean).slice(-3).join('\n');
36
+ const promptText = tail.slice(0, 2_000) || `${provider.label} is waiting for permission in the terminal.`;
37
+ const id = `${provider.id}-permission:${createHash('sha256').update(promptText).digest('hex').slice(0, 24)}`;
38
+ return {
39
+ id, type: 'local_only', prompt: promptText,
40
+ };
41
+ }
42
+ // The card must survive a re-render. The parse carries two volatile pieces: `cursor`, which moves whenever
43
+ // anyone navigates the menu, and the lead-in prose above the question, which the turn retypes as it streams.
44
+ // Hashing the whole parse therefore minted a NEW card whenever either changed, retiring the live one as
45
+ // "resolved" while its gate was still on screen — so an answer could be swallowed AND its card taken away
46
+ // (measured: a user's answer to a live question was accepted by the server and never reached the pane).
47
+ // Identify the gate by what IS the gate: the menu kind, its own question line, and the options offered.
48
+ //
49
+ // The price of a shape-named id is that two DIFFERENT gates of the same shape carry the same id — every
50
+ // review screen, for one. That is Core's problem to tell apart, and it does: a shape that comes back after
51
+ // its card was settled is published as a new interaction with a new id and token, never silently dropped.
52
+ // So this id must stay shape-stable within one occurrence and must NOT try to encode an instance number,
53
+ // which this adapter cannot know (a fresh observation has no memory of what it saw before).
54
+ const signature = JSON.stringify({
55
+ kind: prompt.kind,
56
+ question: prompt.title.split(' — ').at(-1) ?? prompt.title,
57
+ options: prompt.options.map((option) => option.label),
58
+ submit: prompt.submit === true,
59
+ });
60
+ const id = `${provider.id}-prompt:${createHash('sha256').update(signature).digest('hex').slice(0, 24)}`;
61
+ return {
62
+ id,
63
+ type: 'select',
64
+ prompt: prompt.leadIn ? `${prompt.leadIn}\n${prompt.title}` : prompt.title,
65
+ options: prompt.options.map((option) => ({
66
+ id: `choice:${option.n}`,
67
+ label: option.description ? `${option.label} — ${option.description}` : option.label,
68
+ })),
69
+ };
70
+ }
71
+ export function createHookInteractionAdapter(control, provider, pollMs = 750, reportHealth = () => { }) {
72
+ if (!control || typeof control.capturePlain !== 'function'
73
+ || typeof control.sendChoice !== 'function' || !Number.isFinite(pollMs) || pollMs <= 0) {
74
+ throw new TypeError('Interaction adapter requires pane capture and choice control');
75
+ }
76
+ const active = new Map();
77
+ return {
78
+ apiVersion: 1,
79
+ async observeNative(run, sink) {
80
+ let closed = false;
81
+ let cursor = 0;
82
+ let pending = normalizedPrompt(await control.capturePlain(run.ref.paneId), provider, control.pendingKind?.(run.ref.paneId));
83
+ let timer;
84
+ let tail = Promise.resolve();
85
+ let failures = 0;
86
+ const binding = { run, pending };
87
+ active.set(run.ref.runId, binding);
88
+ const close = () => {
89
+ closed = true;
90
+ if (timer)
91
+ clearTimeout(timer);
92
+ timer = undefined;
93
+ if (active.get(run.ref.runId) === binding)
94
+ active.delete(run.ref.runId);
95
+ };
96
+ const schedule = (delay = pollMs) => {
97
+ if (closed || run.signal.aborted)
98
+ return;
99
+ timer = setTimeout(() => {
100
+ tail = tail.then(async () => {
101
+ if (closed || run.signal.aborted)
102
+ return;
103
+ const next = normalizedPrompt(await control.capturePlain(run.ref.paneId), provider, control.pendingKind?.(run.ref.paneId));
104
+ if (failures > 0)
105
+ reportHealth('ready');
106
+ failures = 0;
107
+ if (pending?.id === next?.id)
108
+ return;
109
+ if (pending)
110
+ await sink({
111
+ type: 'resolved', sourceCursor: `${provider.id}-interaction:${++cursor}`,
112
+ interactionId: pending.id,
113
+ });
114
+ pending = next;
115
+ binding.pending = next;
116
+ if (next)
117
+ await sink({
118
+ type: 'opened', sourceCursor: `${provider.id}-interaction:${++cursor}`, interaction: next,
119
+ });
120
+ }).catch(async () => {
121
+ failures += 1;
122
+ if (failures >= 2) {
123
+ reportHealth('degraded', 'Interaction polling is temporarily unavailable');
124
+ if (pending) {
125
+ const abandoned = pending;
126
+ pending = null;
127
+ binding.pending = null;
128
+ await Promise.resolve(sink({
129
+ type: 'cancelled', sourceCursor: `${provider.id}-interaction:${++cursor}`,
130
+ interactionId: abandoned.id, reason: 'temporarily_unavailable',
131
+ })).catch(close);
132
+ }
133
+ }
134
+ }).finally(() => schedule(Math.min(10_000, pollMs * (2 ** Math.min(failures, 4)))));
135
+ }, delay);
136
+ timer.unref?.();
137
+ };
138
+ const onAbort = () => close();
139
+ run.signal.addEventListener('abort', onAbort, { once: true });
140
+ schedule();
141
+ return {
142
+ checkpoint: {
143
+ sourceCursor: `${provider.id}-interaction:${cursor}`,
144
+ pending: pending ? [pending] : [],
145
+ },
146
+ close() {
147
+ run.signal.removeEventListener('abort', onAbort);
148
+ close();
149
+ },
150
+ };
151
+ },
152
+ async dispatchResponse(run, request) {
153
+ const binding = active.get(run.ref.runId);
154
+ if (!binding || binding.run !== run || run.signal.aborted)
155
+ return { status: 'stale_run' };
156
+ if (!binding.pending || binding.pending.id !== request.interactionId) {
157
+ return { status: 'already_resolved' };
158
+ }
159
+ const optionIds = request.value.type === 'selection' ? request.value.optionIds
160
+ : request.value.type === 'approval' ? [request.value.optionId] : [];
161
+ if (optionIds.length !== 1) {
162
+ return { status: 'rejected', reason: 'invalid_value' };
163
+ }
164
+ const optionId = optionIds[0];
165
+ const match = optionId.match(/^choice:(\d+)$/);
166
+ if (!match || !binding.pending.options?.some((option) => option.id === optionId)) {
167
+ return { status: 'rejected', reason: 'invalid_value' };
168
+ }
169
+ try {
170
+ await control.sendChoice(run.ref.paneId, match[1]);
171
+ return { status: 'accepted' };
172
+ }
173
+ catch {
174
+ return { status: 'unknown', reason: 'temporarily_unavailable' };
175
+ }
176
+ },
177
+ };
178
+ }
@@ -1,10 +1,11 @@
1
1
  // The single built-in Agent registry. Runtime consumes the root AgentAdapter contract; legacy
2
2
  // orphan/transcript code sees a filtered projection of the same objects until those services migrate.
3
3
  import { claude } from './claude.js';
4
+ import { codebuddy } from './codebuddy.js';
4
5
  import { codex } from './codex.js';
5
6
  import { piAgentAdapter } from './pi.js';
6
7
  import { validateAgentAdapters } from '../agent-runtime/adapter.js';
7
- export const BUILTIN_AGENT_ADAPTERS = [claude, codex, piAgentAdapter];
8
+ export const BUILTIN_AGENT_ADAPTERS = [claude, codex, piAgentAdapter, codebuddy];
8
9
  export const BUILTIN_AGENT_ADAPTER_VALIDATION = validateAgentAdapters(BUILTIN_AGENT_ADAPTERS);
9
10
  function isLegacyDriver(adapter) {
10
11
  const candidate = adapter;
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { getAgent, agentForProc } from './agents/index.js';
5
+ import { hookEventKind } from './agents/hookEvents.js';
5
6
  import { resolveVersionedComms } from './agents/claude.js';
6
7
  import { resolveCodexComms } from './agents/codex.js';
7
8
  import { defaultRun } from './agents/scanUtils.js';
@@ -14,14 +15,12 @@ const here = path.dirname(fileURLToPath(import.meta.url));
14
15
  export const DEFAULT_STATE_FILE = process.env.CLAUDE_STATE_FILE || path.resolve(here, '../data/claude-state.json');
15
16
  // Classify a Claude hook event → inbox kind. Re-exported because tests and callers import this path.
16
17
  export const classifyEvent = claude.classify;
17
- const EVENT_KINDS = new Set([
18
- 'done', 'working', 'permission', 'compacting', 'error', 'end', 'idle',
19
- ]);
20
18
  const VIEW_LABEL = { needs: '需要你', done: '已完成' };
21
19
  const recordOf = (value) => value && typeof value === 'object' && !Array.isArray(value)
22
20
  ? value : null;
23
- const eventKind = (value) => typeof value === 'string' && EVENT_KINDS.has(value)
24
- ? value : null;
21
+ // The kind vocabulary itself is shared with every other Hook-driven provider; only Claude's own
22
+ // classification decisions live in agents/claude.ts.
23
+ const eventKind = hookEventKind;
25
24
  const pushView = (kind) => kind === 'permission' ? 'needs' : kind === 'done' ? 'done' : null;
26
25
  // The dedup key for a pane's current push view. `needs` is view-only: Claude signals one permission gate via
27
26
  // TWO hooks (permreq then permission_prompt) at different ts, and both must collapse to a SINGLE 需要你. But