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
package/dist/src/docs.js CHANGED
@@ -117,12 +117,15 @@ export function createDocs({ home, extraRoots = [], maxDownloadBytes = MAX_TRANS
117
117
  if (st.size > MAX_READ_BYTES)
118
118
  return { error: 'too large', status: 413 };
119
119
  const mtimeMs = st.mtimeMs;
120
+ // Filesystems without a birth time report 0 (or NaN on some platforms) — the popover shows
121
+ // 创建时间 only when it is a real value.
122
+ const birthtimeMs = Number.isFinite(st.birthtimeMs) && st.birthtimeMs > 0 ? st.birthtimeMs : null;
120
123
  if (knownMtime != null && mtimeMs === knownMtime)
121
124
  return { name: basename(real), type, mtimeMs, notModified: true };
122
125
  const content = decodeText(await fs.readFile(real));
123
126
  if (content == null)
124
127
  return { error: 'not text', status: 415 };
125
- return { name: basename(real), type, content, mtimeMs };
128
+ return { name: basename(real), type, content, mtimeMs, size: st.size, birthtimeMs };
126
129
  }
127
130
  async function listDir(rawPath) {
128
131
  const rh = await realHomeP;
@@ -153,17 +156,28 @@ export function createDocs({ home, extraRoots = [], maxDownloadBytes = MAX_TRANS
153
156
  fileDirents.push(d);
154
157
  }
155
158
  // stat every file IN PARALLEL — a big dir is thousands of files, and one awaited stat each
156
- // (serial) is what made listing hang for seconds. Promise.all lets the OS pipeline them.
159
+ // (serial) is what made listing hang for seconds. Promise.all lets the OS pipeline them. The same
160
+ // stat carries the mtime the listing shows, so the timestamp costs no extra syscall. Dirs get one
161
+ // stat each (there are few) for the same timestamp; a failure just omits the field.
157
162
  const fileEntries = await Promise.all(fileDirents.map(async (d) => {
158
163
  const type = docTypeFor(d.name) ? 'doc' : imageTypeFor(d.name) ? 'image' : 'file';
159
- let size = 0;
160
164
  try {
161
- size = (await fs.stat(join(real, d.name))).size;
165
+ const st = await fs.stat(join(real, d.name));
166
+ return { name: d.name, type, size: st.size, mtimeMs: st.mtimeMs };
167
+ }
168
+ catch {
169
+ return { name: d.name, type, size: 0 }; // gone mid-list → keep the row, no size/time
170
+ }
171
+ }));
172
+ const dirStats = await Promise.all(dirEntries.map(async (entry) => {
173
+ try {
174
+ return { ...entry, mtimeMs: (await fs.stat(join(real, entry.name))).mtimeMs };
175
+ }
176
+ catch {
177
+ return entry;
162
178
  }
163
- catch { /* gone mid-list → size 0 */ }
164
- return { name: d.name, type, size };
165
179
  }));
166
- const entries = [...dirEntries, ...fileEntries];
180
+ const entries = [...dirStats, ...fileEntries];
167
181
  // dirs first; files (doc+file) interleaved alphabetically
168
182
  entries.sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1) || a.name.localeCompare(b.name));
169
183
  // "up" stops at whichever allowed root we're in (each root is a ceiling), not only at home.
@@ -0,0 +1,27 @@
1
+ // How a log line's optional second value is rendered. It is typed as `error`, but connectors also pass a
2
+ // bag of STRUCTURED DETAILS there ("which event id, which pane"), and rendering those with String() printed
3
+ // `[object Object]` — an operator could not tell which event had been dropped, which is the whole reason the
4
+ // line exists. Reported from the field as exactly that: a wall of
5
+ // `Discarding stale Claude Hook event after pane process replacement: [object Object]`.
6
+ //
7
+ // An Error keeps its message; anything else object-shaped is rendered as bounded JSON so the detail stays
8
+ // readable and a pathological value cannot flood the log. Primitives keep their plain form.
9
+ const MAX_DETAIL = 500;
10
+ export function logDetail(value) {
11
+ if (value === undefined)
12
+ return '';
13
+ if (value instanceof Error)
14
+ return `: ${value.message}`;
15
+ if (typeof value === 'object' && value !== null) {
16
+ try {
17
+ const json = JSON.stringify(value);
18
+ if (typeof json !== 'string')
19
+ return '';
20
+ return `: ${json.length > MAX_DETAIL ? `${json.slice(0, MAX_DETAIL)}…` : json}`;
21
+ }
22
+ catch {
23
+ return ''; // circular or otherwise unserializable: log the message alone rather than nothing useful
24
+ }
25
+ }
26
+ return `: ${String(value)}`;
27
+ }
@@ -1,4 +1,5 @@
1
1
  import { assertRequestAuthority } from './requestAuthority.js';
2
+ import { parsePendingPrompt } from './pendingPrompt.js';
2
3
  const SUBMIT_GAP_MS = 120;
3
4
  const paneInputTails = new Map();
4
5
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -17,6 +18,78 @@ export async function serializePaneInput(paneId, operation) {
17
18
  paneInputTails.delete(paneId);
18
19
  }
19
20
  }
21
+ // One-line native editor, as every Claude-lineage TUI draws it: `<prompt> value` on the cursor's line with
22
+ // a rule above and below. Wrapped input, attachments, selection menus and unknown layouts fail closed
23
+ // (null) — the caller must never delete content it could not read. Callers send End first: placeholder text
24
+ // is painted after the cursor but is not part of the editor, while real text leaves the cursor at its end.
25
+ // `hint` strips a right-aligned footer the editor paints inside that same line (e.g. CodeBuddy's "↵ send").
26
+ //
27
+ // Trailing blanks are removed before the line is judged, because how the EMPTY editor looks is not stable:
28
+ // Claude writes a non-breaking space after its prompt (which a capture keeps), CodeBuddy a plain one (which
29
+ // a capture trims as a trailing blank), so a genuinely empty editor arrives as the bare prompt. Reading
30
+ // that as unrecognizable — rather than as empty — refused every send to an idle CodeBuddy pane, and even
31
+ // refused the step that verifies our own stale prompt had been cleared, since clearing it produces exactly
32
+ // that line. Nothing is lost by trimming: a line that carries only the prompt cannot be hiding text, and
33
+ // the rules above and below are what prove this line is the editor rather than a prompt character that
34
+ // happens to sit somewhere in the transcript.
35
+ //
36
+ // The rule ABOVE the editor is not always bare either: Claude Code paints the session title inside it
37
+ // (`──── my session title ─`), so a pure run of dashes rejected every send in a titled session — reported
38
+ // from the field with per-pane captures, all of them empty editors waiting for input. It therefore accepts
39
+ // a dash run that opens the line and closes it with the title painted between; the line BELOW stays bare,
40
+ // which is what the same captures show (and what keeps an unrelated decorated line from pairing with the
41
+ // prompt into a false editor box).
42
+ const RULE_RE = /^\u2500{3,}$/;
43
+ const TITLED_RULE_RE = /^\u2500{3,}(?:\s*\S.*\s*\u2500{1,})?$/;
44
+ // Why a send was refused has to be diagnosable from the log alone. The editor is judged by exact geometry,
45
+ // and when a TUI changes its decoration the reader fails closed: the phone tells the user to "handle the
46
+ // draft in the terminal" while they look at an empty composer they cannot do anything about. That happened
47
+ // twice in one day (an empty CodeBuddy editor arriving as the bare prompt, a Claude composer whose top rule
48
+ // carries the session title), and both times the shape had to come from the reporter's own screen capture.
49
+ //
50
+ // So when — and ONLY when — the editor could not be read at all, the rows around the cursor are recorded.
51
+ // A draft we CAN read is somebody's text and is never logged: a readable-but-foreign draft is a normal
52
+ // refusal with a known cause and no diagnostic value.
53
+ const DETAIL_ROWS = 3;
54
+ const DETAIL_ROW_MAX = 120;
55
+ const DETAIL_MAX = 600;
56
+ export function describeEditorArea(screen, cursor, rows = DETAIL_ROWS) {
57
+ const lines = screen.split('\n');
58
+ const from = Math.max(0, cursor.cursorY - rows);
59
+ const to = Math.min(lines.length - 1, cursor.cursorY + rows);
60
+ const parts = [`cursor=(${cursor.cursorX},${cursor.cursorY})`];
61
+ for (let i = from; i <= to; i++) {
62
+ const line = lines[i] ?? '';
63
+ const clamped = line.length > DETAIL_ROW_MAX ? `${line.slice(0, DETAIL_ROW_MAX)}…` : line;
64
+ // JSON so escapes of control characters cannot break the surrounding log line.
65
+ parts.push(`${i}${i === cursor.cursorY ? '*' : ''}=${JSON.stringify(clamped)}`);
66
+ }
67
+ const detail = parts.join(' ');
68
+ return detail.length > DETAIL_MAX ? `${detail.slice(0, DETAIL_MAX)}…` : detail;
69
+ }
70
+ export function singleLineDraft(screen, cursor, prompt = '\u276f', hint) {
71
+ const lines = screen.split('\n');
72
+ const line = lines[cursor.cursorY];
73
+ if (!line || !TITLED_RULE_RE.test(lines[cursor.cursorY - 1]?.trim() ?? '')
74
+ || !RULE_RE.test(lines[cursor.cursorY + 1]?.trim() ?? ''))
75
+ return null;
76
+ const body = line.trimEnd();
77
+ if (!body.startsWith(prompt))
78
+ return null;
79
+ const rest = body.slice(prompt.length);
80
+ if (!rest)
81
+ return ''; // The prompt and nothing else: the editor is empty.
82
+ if (rest[0] !== ' ' && rest[0] !== '\u00a0')
83
+ return null;
84
+ const start = prompt.length + 1;
85
+ let value = hint ? rest.slice(1).replace(hint, '') : rest.slice(1);
86
+ value = value.trimEnd();
87
+ if (cursor.cursorX === start)
88
+ return ''; // Native suggestions are painted here but End does not enter them.
89
+ if (!value || /[\x00-\x1f\x7f]/.test(value) || cursor.cursorX <= start)
90
+ return null;
91
+ return value;
92
+ }
20
93
  export function sendPanePrompt(commands, paneId, text, guard) {
21
94
  return serializePaneInput(paneId, async () => {
22
95
  if (guard && !await guard.validate())
@@ -31,26 +104,50 @@ export function sendPanePrompt(commands, paneId, text, guard) {
31
104
  return { nativeMutation: true };
32
105
  });
33
106
  }
34
- export function interruptPane(commands, paneId) {
107
+ // Stop the pane's turn. Escape is the key BOTH providers answer to, measured one screen at a time:
108
+ //
109
+ // Claude Escape stops the turn (its TUI prints `esc to interrupt` while it is running)
110
+ // CodeBuddy 2.156.0 Escape stops it — `⎿ Interrupted by user`, and the running `sleep 90` was gone;
111
+ // `ctrl+c` did NOT: sent mid-tool, the sleep kept running and the turn kept going.
112
+ // Its published keybindings list `ctrl+c → app:interrupt`, which is what this used to
113
+ // send; whatever that binding belongs to, it is not the interrupt the pane's own
114
+ // footer advertises. One key for both, so there is one place to be wrong.
115
+ export function interruptAgentPane(commands, paneId) {
35
116
  return serializePaneInput(paneId, async () => {
36
117
  await commands.exitCopyModeIfActive(paneId);
37
118
  assertRequestAuthority();
38
- await commands.sendKey(paneId, 'C-c');
39
- });
40
- }
41
- export function interruptClaudePane(commands, paneId) {
42
- return serializePaneInput(paneId, async () => {
43
- await commands.exitCopyModeIfActive(paneId);
44
119
  await commands.sendKey(paneId, 'Escape');
45
120
  });
46
121
  }
47
- export function sendPaneChoice(commands, paneId, choice) {
122
+ export function sendPaneMenuChoice(commands, paneId, choice) {
48
123
  if (!/^[1-9]$/.test(choice))
49
124
  throw new TypeError('Pane choice requires a single option digit');
50
125
  return serializePaneInput(paneId, async () => {
51
126
  await commands.exitCopyModeIfActive(paneId);
52
127
  assertRequestAuthority();
53
- // Menu shortcuts are key events; bracketed paste is not handled by Claude's selector.
54
- await commands.sendKey(paneId, choice);
128
+ const target = Number(choice);
129
+ const readMenu = async () => parsePendingPrompt(await commands.capturePlain(paneId));
130
+ const menu = await readMenu();
131
+ if (!menu)
132
+ throw new Error('No menu is on the pane to answer');
133
+ if (!menu.options.some((option) => option.n === target)) {
134
+ throw new Error(`The menu does not offer option ${target}`);
135
+ }
136
+ if (menu.kind === 'permission') {
137
+ await commands.sendKey(paneId, choice);
138
+ return;
139
+ }
140
+ // The cursor is reported as the option NUMBER it sits on and every numbered row is selectable, so the
141
+ // distance to travel is just the difference.
142
+ const from = menu.cursor ?? target;
143
+ if (from !== target) {
144
+ const key = target > from ? 'Down' : 'Up';
145
+ for (let step = 0; step < Math.abs(target - from); step++)
146
+ await commands.sendKey(paneId, key);
147
+ if ((await readMenu())?.cursor !== target) {
148
+ throw new Error('The menu did not move to the chosen option');
149
+ }
150
+ }
151
+ await commands.sendKey(paneId, 'Enter');
55
152
  });
56
153
  }
@@ -22,6 +22,11 @@
22
22
  // re-polling — each screen (Q1 → Q2 → review) is itself a parseable menu. Escape cancels.
23
23
  //
24
24
  // The parser ANCHORS on the ❯ cursor line, NOT a footer: the review screen has options but no footer.
25
+ //
26
+ // CodeBuddy's gates parse with this same reader (hookInteraction binds both providers to it). It frames a
27
+ // tool's input in a drawn box — "│ … │" rows closed by a "╰───╯" border — so a captured gate usually has that
28
+ // box directly above the question. The border and the rows are chrome: they are never taken as the question
29
+ // and never scraped as the lead-in prose.
25
30
  // Cursor-selected option line: ❯ (or ›»>) then "N.".
26
31
  const CURSOR_RE = /^\s*[❯›»>]\s*\d+\.\s/;
27
32
  // Any option row: optional cursor, then "N. label".
@@ -43,7 +48,17 @@ const FOOTER_RE = /enter to select|esc to (cancel|reject)/i;
43
48
  const ACTIVITY_RE = /^\s*[✻✳✶✽⏺]\s|worked for|cogitated|crafting|cooked for|crunched for|esc to interrupt/i;
44
49
  // The input cursor ❯ leading the user's echoed prompt (above the card).
45
50
  const PROMPT_ECHO_RE = /^\s*[❯›»>]\s*\D/; // ❯ NOT followed by a digit (that would be an option)
46
- const isTitleBoundary = (line) => ACTIVITY_RE.test(line) || RULE_RE.test(line) || TAB_BAR_RE.test(line) || FOOTER_RE.test(line) || PROMPT_ECHO_RE.test(line);
51
+ // A drawn box. CodeBuddy frames a tool's input in one (Write/Edit previews, diffs) and draws its own rows as
52
+ // "│ … │", so its border and its interior are chrome rather than text — the lead-in walk used to descend into
53
+ // one and surface an empty row plus the bottom border as the gate's context (measured on pane %5's
54
+ // "Do you want to create ai-news-2026-09.md?" card, 2026-09-22).
55
+ const BOX_CHARS_RE = /[\u2500-\u257F\u2580-\u259F]/;
56
+ const BOX_ONLY_RE = /^[\s\u2500-\u257F\u2580-\u259F]+$/;
57
+ const BOX_EDGE_RE = /^\s*[\u2502\u2503]/; // │ ┃ — the vertical edge of a drawn box
58
+ const isBoxFrame = (line) => BOX_CHARS_RE.test(line) && BOX_ONLY_RE.test(line);
59
+ const isBoxChrome = (line) => isBoxFrame(line) || BOX_EDGE_RE.test(line);
60
+ const isTitleBoundary = (line) => ACTIVITY_RE.test(line) || RULE_RE.test(line) || TAB_BAR_RE.test(line) || FOOTER_RE.test(line)
61
+ || PROMPT_ECHO_RE.test(line) || isBoxFrame(line);
47
62
  const stripRight = (value) => String(value == null ? '' : value).replace(/\s+$/, '');
48
63
  // Strip ANSI/OSC escapes so a capture taken WITH `-e` (SGR) still parses — belt-and-suspenders even though
49
64
  // the endpoint captures plain.
@@ -61,11 +76,12 @@ function extractLeadIn(lines, fromIdx) {
61
76
  const block = [];
62
77
  let i = fromIdx;
63
78
  // Skip the chrome between the menu and the preceding text (spinner / rules / tab bar / stale footer /
64
- // blanks) — but never past a ❯ prompt echo (above it is the PREVIOUS exchange, stale context) nor past a
65
- // ⏺ message head (it IS the text's first line — the collect loop includes it).
79
+ // blanks / a tool preview box) — but never past a ❯ prompt echo (above it is the PREVIOUS exchange, stale
80
+ // context) nor past a ⏺ message head (it IS the text's first line — the collect loop includes it).
66
81
  while (i >= 0) {
67
82
  const line = lines[i] ?? '';
68
- if (line.trim() && (!isTitleBoundary(line) || PROMPT_ECHO_RE.test(line) || MSG_MARK_RE.test(line)))
83
+ if (line.trim() && !isBoxChrome(line)
84
+ && (!isTitleBoundary(line) || PROMPT_ECHO_RE.test(line) || MSG_MARK_RE.test(line)))
69
85
  break;
70
86
  i--;
71
87
  }
@@ -77,7 +93,7 @@ function extractLeadIn(lines, fromIdx) {
77
93
  block.unshift(line.trim());
78
94
  break;
79
95
  } // the ⏺ message head: include & stop
80
- if (isTitleBoundary(line))
96
+ if (isTitleBoundary(line) || isBoxChrome(line))
81
97
  break; // hard chrome (incl. ❯ echo): stop
82
98
  block.unshift(line.trim());
83
99
  }
@@ -30,7 +30,10 @@ export function fileRoutes({ docs, uploadExts, maxUploadBytes }) {
30
30
  return res.status(out.status).json({ error: out.error });
31
31
  if (out.notModified)
32
32
  return res.json({ name: out.name, type: out.type, mtimeMs: out.mtimeMs, notModified: true });
33
- return res.json({ name: out.name, type: out.type, content: out.content, mtimeMs: out.mtimeMs });
33
+ return res.json({
34
+ name: out.name, type: out.type, content: out.content, mtimeMs: out.mtimeMs,
35
+ size: out.size, birthtimeMs: out.birthtimeMs,
36
+ });
34
37
  }
35
38
  catch (e) {
36
39
  return next(e);
@@ -21,7 +21,17 @@ const errorMessage = (error) => error instanceof Error ? error.message : String(
21
21
  const here = dirname(fileURLToPath(import.meta.url));
22
22
  const HOOKS_SRC = resolvePath(here, '../../hooks'); // server/hooks (bundled scripts)
23
23
  const PI_ENTRY = resolvePath(here, '../../connectors/pi/index.js');
24
- const WEB_AGENT_INTEGRATIONS = ['claude', 'pi'];
24
+ // Every Agent the phone may manage here. The Web client keeps its own mirrored name list
25
+ // (web/src/agentIntegrationApi.ts `NAMES`): adding one here without adding it there is invisible to the
26
+ // user in the best case, and a client that insists on a FIXED set breaks its whole panel in the worst — it
27
+ // must tolerate whatever subset this list reports. Keep the two in step, and keep the client's check
28
+ // "whatever known rows are present", never a count.
29
+ // `codex` is listed to be SHOWN, not managed: it has nothing to install (Handmux drives it through its own
30
+ // App Server), so its row is read-only — 已接入 / 未安装 and no action. It therefore stays out of the
31
+ // enableable set below, and this endpoint never becomes the thing that writes ~/.codex.
32
+ const WEB_AGENT_INTEGRATIONS = ['claude', 'pi', 'codebuddy', 'codex'];
33
+ // Which listed Agents the Web surface may actually install/repair. Codex is absent on purpose.
34
+ const WEB_ENABLEABLE_INTEGRATIONS = ['claude', 'pi', 'codebuddy'];
25
35
  const FILLER_FILTER_LEVELS = ['low', 'medium', 'high'];
26
36
  const requestFillerFilter = (value) => {
27
37
  if (value === undefined)
@@ -135,7 +145,7 @@ export function systemRoutes({ commands, claudeEvents, agentRuntime, asrEnv, sho
135
145
  }
136
146
  });
137
147
  r.post('/agent-integrations/:name/enable', (req, res, next) => {
138
- const name = WEB_AGENT_INTEGRATIONS.find((candidate) => candidate === req.params.name);
148
+ const name = WEB_ENABLEABLE_INTEGRATIONS.find((candidate) => candidate === req.params.name);
139
149
  if (!name)
140
150
  return res.status(404).json({ error: 'agent integration not found' });
141
151
  try {
@@ -7,10 +7,13 @@ import { loadToken } from './auth.js';
7
7
  import { createApiRouter } from './httpApi.js';
8
8
  import { loadUploadExts } from './uploadTypes.js';
9
9
  import { createClaudeEvents } from './claudeEvents.js';
10
+ import { createCodebuddyEvents } from './codebuddyEvents.js';
11
+ import { logDetail } from './logDetail.js';
10
12
  import { syncHooks } from './cli/claudeHooks.js';
13
+ import { syncHooks as syncCodebuddyHooks } from './cli/codebuddyHooks.js';
11
14
  import { syncPiExtension } from './cli/piExtension.js';
12
15
  import { removeLegacyCodexHooks } from './cli/legacyCodexHooks.js';
13
- import { agentRuntimeDirectoryPath, claudeStatePath, codexOutboxPath, } from './cli/state.js';
16
+ import { agentRuntimeDirectoryPath, claudeStatePath, codebuddyStatePath, codexOutboxPath, } from './cli/state.js';
14
17
  import * as commands from './tmux/commands.js';
15
18
  import * as push from './push.js';
16
19
  import * as notifications from './notifications.js';
@@ -41,8 +44,9 @@ import { apiErrorBoundary, apiRequestContext } from './apiErrors.js';
41
44
  import { RuntimeHealth } from './healthProtocol.js';
42
45
  import { healthRoutes } from './routes/health.js';
43
46
  import { createBuiltinAgentRuntime } from './agent-runtime/builtinRuntime.js';
44
- import { interruptClaudePane, sendPaneChoice } from './paneInput.js';
47
+ import { interruptAgentPane, sendPaneMenuChoice } from './paneInput.js';
45
48
  import { sendClaudePanePrompt } from './agents/claudePaneInput.js';
49
+ import { sendCodeBuddyPanePrompt } from './agents/codebuddyPaneInput.js';
46
50
  import { createLocalAgentProcessContext, TmuxAgentPaneSource, } from './agent-runtime/tmuxRuntime.js';
47
51
  import { InboxPushProjection } from './agent-runtime/inboxPushProjection.js';
48
52
  import { defaultGit } from './git.js';
@@ -53,6 +57,7 @@ import { inspectCodexOpenRootSession } from './agents/codexOpenSession.js';
53
57
  import { CodexActivationReceiptStore } from './agents/codexActivationReceipt.js';
54
58
  import { ApiAccountService, apiAccountsPath } from './apiAccounts.js';
55
59
  import { ClaudeHookBridgeConnector } from '../connectors/claude/index.js';
60
+ import { CodeBuddyHookBridgeConnector } from '../connectors/codebuddy/index.js';
56
61
  const here = path.dirname(fileURLToPath(import.meta.url));
57
62
  // There is ONE config source — ~/.handmux/config.json — and ONE way to run handmux: `handmux start`
58
63
  // (the CLI; `node bin/handmux.js start` from a source checkout is the same thing). The CLI resolves
@@ -176,7 +181,26 @@ try {
176
181
  catch {
177
182
  health.set('codex', 'degraded', 'codex-start-failed');
178
183
  }
179
- const agentPanes = new TmuxAgentPaneSource({ commands });
184
+ // This snapshot drives Runtime reconciliation, and every reconciliation re-probes each pane it tracks: the
185
+ // tmux read is ~13ms, but a `node` pane costs 100ms+ once ps/lsof run, so a 1s cadence burned 1-2.5
186
+ // core-seconds per second on a 35-pane host. Nothing the UI needs comes from that frequency, so the snapshot
187
+ // runs at 3s: an Agent starting or exiting is noticed within that window.
188
+ const agentPanes = new TmuxAgentPaneSource({ commands, pollMs: 3_000 });
189
+ // CodeBuddy's state file is read by the Inbox Connector below AND by the conversation lens's pane → session
190
+ // bind, so it is resolved once here, above both.
191
+ // A refused send is the hardest report to act on: the user sees "终端有未确认的草稿" over an editor they
192
+ // cannot do anything about, and the shape that confused the reader exists only on that pane, in that moment.
193
+ // Record it (bounded, and only when the editor could not be read at all — a draft we CAN read is somebody's
194
+ // text and never reaches the log). Claude's composer carrying its session title in the top rule and an
195
+ // empty CodeBuddy editor arriving as the bare prompt both needed a user's own capture to diagnose.
196
+ // The wording stays factual: a refused send delivers no prompt, but the reader may still have pressed End,
197
+ // and on the path that clears a stale prompt it deliberately sent C-u.
198
+ function reportUnreadableEditor(label, paneId, detail) {
199
+ console.warn(`[handmux] Cannot read the ${label} composer, so the message was not delivered,`
200
+ + ` ${paneId}: ${detail}`);
201
+ }
202
+ const codebuddyStateFile = process.env.CODEBUDDY_STATE_FILE || codebuddyStatePath(home);
203
+ const codebuddyEvents = createCodebuddyEvents({ stateFile: codebuddyStateFile });
180
204
  const agentProcess = createLocalAgentProcessContext();
181
205
  const agentRuntime = createBuiltinAgentRuntime({
182
206
  home,
@@ -185,13 +209,25 @@ const agentRuntime = createBuiltinAgentRuntime({
185
209
  stateDirectory: agentRuntimeDirectory,
186
210
  ...(conversationStartupBlockReason === undefined ? {} : { conversationStartupBlockReason }),
187
211
  claudeEvents: events,
212
+ codebuddyEvents,
213
+ codebuddyConversationControl: {
214
+ sendPrompt: (paneId, text, guard) => sendCodeBuddyPanePrompt(commands, paneId, text, () => codebuddyEvents.paneSubmittedPrompt(paneId), guard, (detail) => reportUnreadableEditor('CodeBuddy', paneId, detail)),
215
+ interrupt: (paneId) => interruptAgentPane(commands, paneId),
216
+ },
217
+ codebuddyInteractionControl: {
218
+ capturePlain: (paneId) => commands.capturePlain(paneId),
219
+ sendChoice: (paneId, choice) => sendPaneMenuChoice(commands, paneId, choice),
220
+ // A Hook `permreq` row is what lets a gate whose screen the parser cannot read still surface as a
221
+ // prompt with a reason instead of silence (same role it plays for Claude).
222
+ pendingKind: (paneId) => codebuddyEvents.paneKind(paneId) ?? null,
223
+ },
188
224
  claudeConversationControl: {
189
- sendPrompt: (paneId, text, guard) => sendClaudePanePrompt(commands, paneId, text, () => events?.paneRestoredPrompt(paneId) ?? null, guard),
190
- interrupt: (paneId) => interruptClaudePane(commands, paneId),
225
+ sendPrompt: (paneId, text, guard) => sendClaudePanePrompt(commands, paneId, text, () => events?.paneRestoredPrompt(paneId) ?? null, guard, (detail) => reportUnreadableEditor('Claude', paneId, detail)),
226
+ interrupt: (paneId) => interruptAgentPane(commands, paneId),
191
227
  },
192
228
  claudeInteractionControl: {
193
229
  capturePlain: (paneId) => commands.capturePlain(paneId),
194
- sendChoice: (paneId, choice) => sendPaneChoice(commands, paneId, choice),
230
+ sendChoice: (paneId, choice) => sendPaneMenuChoice(commands, paneId, choice),
195
231
  pendingKind: (paneId) => events?.paneKind(paneId) ?? null,
196
232
  },
197
233
  codexApp,
@@ -219,8 +255,23 @@ const claudeInboxBridge = new ClaudeHookBridgeConnector({
219
255
  panes: agentPanes,
220
256
  process: agentProcess,
221
257
  logger: (message, error) => {
222
- const detail = error === undefined
223
- ? '' : `: ${error instanceof Error ? error.message : String(error)}`;
258
+ const detail = logDetail(error);
259
+ console.warn(`[handmux] ${message}${detail}`);
260
+ },
261
+ });
262
+ // CodeBuddy drives the same Hook pipeline into its own state file. Its out-of-band reconciliation — the
263
+ // transcript read that closes a gate no Hook closes — is CodeBuddy's own (see connectors/codebuddy and
264
+ // agents/codebuddyNativeTail.ts); the connector supplies it, so nothing is configured here.
265
+ const codebuddyInboxBridge = new CodeBuddyHookBridgeConnector({
266
+ socketPath: agentRuntime.socketPath,
267
+ credentialFile: path.join(agentRuntimeDirectory, 'bridge-credential.json'),
268
+ stateDirectory: path.join(agentRuntimeDirectory, 'connectors', 'codebuddy'),
269
+ hookStateFile: codebuddyStateFile,
270
+ eventDirectory: `${codebuddyStateFile}.events`,
271
+ panes: agentPanes,
272
+ process: agentProcess,
273
+ logger: (message, error) => {
274
+ const detail = logDetail(error);
224
275
  console.warn(`[handmux] ${message}${detail}`);
225
276
  },
226
277
  });
@@ -239,6 +290,7 @@ agentRuntime.start().catch((error) => {
239
290
  }
240
291
  });
241
292
  claudeInboxBridge.start();
293
+ codebuddyInboxBridge.start();
242
294
  workspace.start().catch(() => { });
243
295
  // Keep an already-opted-in user's Claude hooks in step with this handmux version on restart: newly-added
244
296
  // lifecycle events (e.g. SessionStart, which rebinds the 对话 lens after /clear) and a refreshed
@@ -251,6 +303,15 @@ try {
251
303
  });
252
304
  }
253
305
  catch { /* best effort — hook sync never fails startup */ }
306
+ // The same roll-forward for an already-opted-in CodeBuddy: newly-added lifecycle events and a refreshed
307
+ // notify script land via a plain restart. A strict no-op unless its hooks are installed.
308
+ try {
309
+ syncCodebuddyHooks(home, {
310
+ srcDir: path.resolve(here, '../hooks'),
311
+ stateFile: codebuddyStateFile,
312
+ });
313
+ }
314
+ catch { /* best effort — hook sync never fails startup */ }
254
315
  // Keep an explicitly installed Pi wrapper pointed at this package version (notably across Homebrew
255
316
  // Cellar upgrades). Absent or foreign extensions are untouched; this never opts a user in silently.
256
317
  try {
@@ -11,7 +11,7 @@
11
11
  // A row counts as blank only if it has no glyph AND no SGR escape. captureBackground normalizes
12
12
  // ambiguous blank rows before this step, so genuinely shaded padding carries its explicit SGR while
13
13
  // a default-background blank may be trimmed normally.
14
- export const MAX_TRAILING_BLANK = 3;
14
+ export const MAX_TRAILING_BLANK = 1;
15
15
  const isBlank = (row) => row === '' || (/^[ \t]*$/.test(row) && !row.includes('\x1b'));
16
16
  export function capTrailingBlankRows(ansi, max = MAX_TRAILING_BLANK) {
17
17
  // capture-pane terminates every row with \n (including a trailing one). Peel that off so split
@@ -1,9 +1,17 @@
1
1
  import { captureWorkspace } from './capture.js';
2
2
  import { detectEnvironmentChange } from './environment.js';
3
+ import { lockOwnerLabel } from './lock.js';
3
4
  import { fingerprintSnapshot } from './schema.js';
4
5
  function sameSnapshot(left, right) {
5
6
  return fingerprintSnapshot(left) === fingerprintSnapshot(right);
6
7
  }
8
+ function formatDuration(ms) {
9
+ const seconds = Math.max(0, Math.round(ms / 1000));
10
+ if (seconds < 60)
11
+ return `${seconds}s`;
12
+ const minutes = Math.round(seconds / 60);
13
+ return minutes < 60 ? `${minutes}m` : `${Math.round(minutes / 60)}h`;
14
+ }
7
15
  function snapshotEnvironment(observed) {
8
16
  return {
9
17
  id: observed.id,
@@ -13,8 +21,16 @@ function snapshotEnvironment(observed) {
13
21
  }
14
22
  async function reconcileOnce(deps, cause) {
15
23
  const handle = await deps.lock.tryAcquire({ operationId: `checkpointer:${cause}` });
16
- if (!handle)
17
- return { status: 'locked' };
24
+ if (!handle) {
25
+ // Report who is holding it: "locked" on its own is indistinguishable from a stuck lock, which is
26
+ // exactly how a blocked checkpointer stayed invisible.
27
+ let owner = null;
28
+ try {
29
+ owner = await deps.lock.readOwner?.() ?? null;
30
+ }
31
+ catch { /* the holder may be unreadable */ }
32
+ return { status: 'locked', owner };
33
+ }
18
34
  try {
19
35
  const observed = await deps.observeEnvironment();
20
36
  if (!observed || observed.status === 'unknown')
@@ -75,6 +91,19 @@ export function createCheckpointer({ setInterval = globalThis.setInterval, clear
75
91
  let stopPromise = null;
76
92
  let lastResult = null;
77
93
  let lastError = null;
94
+ let blockedSince = null;
95
+ let blockedOwner = null;
96
+ function recordResult(result) {
97
+ lastResult = result;
98
+ if (result.status !== 'locked') {
99
+ blockedSince = null;
100
+ blockedOwner = null;
101
+ return;
102
+ }
103
+ if (blockedSince === null)
104
+ blockedSince = now();
105
+ blockedOwner = lockOwnerLabel(result.owner);
106
+ }
78
107
  function launch(cause) {
79
108
  const current = reconcileOnce(deps, cause);
80
109
  running = current;
@@ -89,7 +118,7 @@ export function createCheckpointer({ setInterval = globalThis.setInterval, clear
89
118
  }
90
119
  };
91
120
  current.then((result) => {
92
- lastResult = result;
121
+ recordResult(result);
93
122
  lastError = null;
94
123
  settled();
95
124
  }, () => {
@@ -131,6 +160,12 @@ export function createCheckpointer({ setInterval = globalThis.setInterval, clear
131
160
  const outstanding = () => (running ?? pendingConfirmation?.promise ?? null);
132
161
  return {
133
162
  reconcile,
163
+ // How long the writer lock has been unavailable, with whoever holds it. Null while reconciliation is
164
+ // free to run. Readiness cannot answer this — a restore holding the lock is legitimately "locked" and
165
+ // the launcher must still see the server as up — so the protection status consumes this instead.
166
+ blocked() {
167
+ return blockedSince === null ? null : { since: blockedSince, owner: blockedOwner };
168
+ },
134
169
  health() {
135
170
  if (stopping || stopped)
136
171
  return { status: 'degraded', detail: 'workspace-stopped' };
@@ -142,7 +177,16 @@ export function createCheckpointer({ setInterval = globalThis.setInterval, clear
142
177
  return { status: 'ready', detail: null };
143
178
  }
144
179
  if (lastResult.status === 'locked') {
145
- return { status: 'ready', detail: 'workspace-writer-locked' };
180
+ // Still "ready": a restore holds this lock on purpose, and the launcher must not read that as the
181
+ // server being down. The detail carries the holder and the duration so a lock that never clears is
182
+ // diagnosable from the outside.
183
+ const held = blockedSince === null ? null : formatDuration(now() - blockedSince);
184
+ return {
185
+ status: 'ready',
186
+ detail: 'workspace-writer-locked'
187
+ + (blockedOwner ? `: ${blockedOwner}` : '')
188
+ + (held ? ` for ${held}` : ''),
189
+ };
146
190
  }
147
191
  return { status: 'degraded', detail: `workspace-${lastResult.status || 'unknown'}` };
148
192
  },