claws-code 0.8.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 (180) hide show
  1. package/.claude/commands/claws-auto.md +90 -0
  2. package/.claude/commands/claws-bin.md +28 -0
  3. package/.claude/commands/claws-cleanup.md +28 -0
  4. package/.claude/commands/claws-do.md +82 -0
  5. package/.claude/commands/claws-fix.md +40 -0
  6. package/.claude/commands/claws-goal.md +111 -0
  7. package/.claude/commands/claws-help.md +54 -0
  8. package/.claude/commands/claws-plan.md +103 -0
  9. package/.claude/commands/claws-report.md +29 -0
  10. package/.claude/commands/claws-status.md +37 -0
  11. package/.claude/commands/claws-update.md +32 -0
  12. package/.claude/commands/claws.md +64 -0
  13. package/.claude/rules/claws-default-behavior.md +76 -0
  14. package/.claude/settings.json +112 -0
  15. package/.claude/settings.local.json +19 -0
  16. package/.claude/skills/claws-auto-engine/SKILL.md +97 -0
  17. package/.claude/skills/claws-goal-tracker/SKILL.md +106 -0
  18. package/.claude/skills/claws-prompt-templates/SKILL.md +203 -0
  19. package/.claude/skills/claws-wave-lead/SKILL.md +126 -0
  20. package/.claude/skills/claws-wave-subworker/SKILL.md +60 -0
  21. package/CHANGELOG.md +1949 -0
  22. package/LICENSE +21 -0
  23. package/README.md +420 -0
  24. package/bin/cli.js +84 -0
  25. package/cli.js +223 -0
  26. package/docs/ARCHITECTURE.md +511 -0
  27. package/docs/event-protocol.md +588 -0
  28. package/docs/features.md +562 -0
  29. package/docs/guide.md +891 -0
  30. package/docs/index.html +716 -0
  31. package/docs/protocol.md +323 -0
  32. package/extension/.vscodeignore +15 -0
  33. package/extension/CHANGELOG.md +1906 -0
  34. package/extension/LICENSE +21 -0
  35. package/extension/README.md +137 -0
  36. package/extension/docs/features.md +424 -0
  37. package/extension/docs/protocol.md +197 -0
  38. package/extension/esbuild.mjs +25 -0
  39. package/extension/icon.png +0 -0
  40. package/extension/native/.metadata.json +10 -0
  41. package/extension/native/node-pty/LICENSE +69 -0
  42. package/extension/native/node-pty/README.md +165 -0
  43. package/extension/native/node-pty/lib/conpty_console_list_agent.js +16 -0
  44. package/extension/native/node-pty/lib/conpty_console_list_agent.js.map +1 -0
  45. package/extension/native/node-pty/lib/eventEmitter2.js +47 -0
  46. package/extension/native/node-pty/lib/eventEmitter2.js.map +1 -0
  47. package/extension/native/node-pty/lib/index.js +52 -0
  48. package/extension/native/node-pty/lib/index.js.map +1 -0
  49. package/extension/native/node-pty/lib/interfaces.js +7 -0
  50. package/extension/native/node-pty/lib/interfaces.js.map +1 -0
  51. package/extension/native/node-pty/lib/shared/conout.js +11 -0
  52. package/extension/native/node-pty/lib/shared/conout.js.map +1 -0
  53. package/extension/native/node-pty/lib/terminal.js +190 -0
  54. package/extension/native/node-pty/lib/terminal.js.map +1 -0
  55. package/extension/native/node-pty/lib/types.js +7 -0
  56. package/extension/native/node-pty/lib/types.js.map +1 -0
  57. package/extension/native/node-pty/lib/unixTerminal.js +346 -0
  58. package/extension/native/node-pty/lib/unixTerminal.js.map +1 -0
  59. package/extension/native/node-pty/lib/utils.js +39 -0
  60. package/extension/native/node-pty/lib/utils.js.map +1 -0
  61. package/extension/native/node-pty/lib/windowsConoutConnection.js +125 -0
  62. package/extension/native/node-pty/lib/windowsConoutConnection.js.map +1 -0
  63. package/extension/native/node-pty/lib/windowsPtyAgent.js +320 -0
  64. package/extension/native/node-pty/lib/windowsPtyAgent.js.map +1 -0
  65. package/extension/native/node-pty/lib/windowsTerminal.js +199 -0
  66. package/extension/native/node-pty/lib/windowsTerminal.js.map +1 -0
  67. package/extension/native/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  68. package/extension/native/node-pty/lib/worker/conoutSocketWorker.js.map +1 -0
  69. package/extension/native/node-pty/package.json +64 -0
  70. package/extension/native/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  71. package/extension/native/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  72. package/extension/native/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  73. package/extension/native/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
  74. package/extension/native/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
  75. package/extension/native/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
  76. package/extension/native/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  77. package/extension/native/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  78. package/extension/native/node-pty/prebuilds/win32-arm64/pty.node +0 -0
  79. package/extension/native/node-pty/prebuilds/win32-arm64/winpty-agent.exe +0 -0
  80. package/extension/native/node-pty/prebuilds/win32-arm64/winpty.dll +0 -0
  81. package/extension/native/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
  82. package/extension/native/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
  83. package/extension/native/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  84. package/extension/native/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  85. package/extension/native/node-pty/prebuilds/win32-x64/pty.node +0 -0
  86. package/extension/native/node-pty/prebuilds/win32-x64/winpty-agent.exe +0 -0
  87. package/extension/native/node-pty/prebuilds/win32-x64/winpty.dll +0 -0
  88. package/extension/package-lock.json +605 -0
  89. package/extension/package.json +343 -0
  90. package/extension/scripts/bundle-native.mjs +104 -0
  91. package/extension/scripts/deploy-dev.mjs +60 -0
  92. package/extension/src/ansi-strip.ts +52 -0
  93. package/extension/src/backends/vscode/claws-pty.ts +483 -0
  94. package/extension/src/backends/vscode/status-bar.ts +99 -0
  95. package/extension/src/backends/vscode/vscode-backend.ts +282 -0
  96. package/extension/src/capture-store.ts +125 -0
  97. package/extension/src/event-log.ts +629 -0
  98. package/extension/src/event-schemas.ts +478 -0
  99. package/extension/src/extension.js +492 -0
  100. package/extension/src/extension.ts +873 -0
  101. package/extension/src/lifecycle-engine.ts +60 -0
  102. package/extension/src/lifecycle-rules.ts +171 -0
  103. package/extension/src/lifecycle-store.ts +506 -0
  104. package/extension/src/peer-registry.ts +176 -0
  105. package/extension/src/pipeline-registry.ts +82 -0
  106. package/extension/src/platform.ts +64 -0
  107. package/extension/src/protocol.ts +532 -0
  108. package/extension/src/server-config.ts +98 -0
  109. package/extension/src/server.ts +2210 -0
  110. package/extension/src/task-registry.ts +51 -0
  111. package/extension/src/terminal-backend.ts +211 -0
  112. package/extension/src/terminal-manager.ts +395 -0
  113. package/extension/src/topic-registry.ts +70 -0
  114. package/extension/src/topic-utils.ts +46 -0
  115. package/extension/src/transport.ts +45 -0
  116. package/extension/src/uninstall-cleanup.ts +232 -0
  117. package/extension/src/wave-registry.ts +314 -0
  118. package/extension/src/websocket-transport.ts +153 -0
  119. package/extension/tsconfig.json +23 -0
  120. package/lib/capabilities.js +145 -0
  121. package/lib/dry-run.js +43 -0
  122. package/lib/install.js +1018 -0
  123. package/lib/mcp-setup.js +92 -0
  124. package/lib/platform.js +240 -0
  125. package/lib/preflight.js +152 -0
  126. package/lib/shell-hook.js +343 -0
  127. package/lib/uninstall.js +162 -0
  128. package/lib/verify.js +166 -0
  129. package/mcp_server.js +3529 -0
  130. package/package.json +48 -0
  131. package/rules/claws-default-behavior.md +72 -0
  132. package/scripts/_helpers/atomic-file.mjs +137 -0
  133. package/scripts/_helpers/fix-repair.js +64 -0
  134. package/scripts/_helpers/json-safe.mjs +218 -0
  135. package/scripts/bump-version.sh +84 -0
  136. package/scripts/codegen/gen-docs.mjs +61 -0
  137. package/scripts/codegen/gen-json-schema.mjs +62 -0
  138. package/scripts/codegen/gen-mcp-tools.mjs +358 -0
  139. package/scripts/codegen/gen-types.mjs +172 -0
  140. package/scripts/codegen/index.mjs +42 -0
  141. package/scripts/dev-hooks/check-extension-dirs.js +77 -0
  142. package/scripts/dev-hooks/check-open-claws-terminals.js +70 -0
  143. package/scripts/dev-hooks/check-stale-main.js +55 -0
  144. package/scripts/dev-hooks/check-tag-pushed.js +51 -0
  145. package/scripts/dev-hooks/check-tag-vs-main.js +56 -0
  146. package/scripts/dev-vsix-install.sh +60 -0
  147. package/scripts/fix.sh +702 -0
  148. package/scripts/gen-client-types.mjs +81 -0
  149. package/scripts/git-hooks/pre-commit +31 -0
  150. package/scripts/hooks/lifecycle-state.js +61 -0
  151. package/scripts/hooks/package.json +4 -0
  152. package/scripts/hooks/post-tool-use-claws.js +292 -0
  153. package/scripts/hooks/pre-bash-no-verify-block.js +72 -0
  154. package/scripts/hooks/pre-tool-use-claws.js +206 -0
  155. package/scripts/hooks/session-start-claws.js +97 -0
  156. package/scripts/hooks/stop-claws.js +88 -0
  157. package/scripts/inject-claude-md.js +205 -0
  158. package/scripts/inject-dev-hooks.js +96 -0
  159. package/scripts/inject-global-claude-md.js +140 -0
  160. package/scripts/inject-settings-hooks.js +370 -0
  161. package/scripts/install.ps1 +146 -0
  162. package/scripts/install.sh +1729 -0
  163. package/scripts/monitor-arm-watch.js +155 -0
  164. package/scripts/rebuild-node-pty.sh +245 -0
  165. package/scripts/report.sh +232 -0
  166. package/scripts/shell-hook.fish +164 -0
  167. package/scripts/shell-hook.ps1 +33 -0
  168. package/scripts/shell-hook.sh +232 -0
  169. package/scripts/stream-events.js +399 -0
  170. package/scripts/terminal-wrapper.sh +36 -0
  171. package/scripts/test-enforcement.sh +132 -0
  172. package/scripts/test-install.sh +174 -0
  173. package/scripts/test-installer-parity.sh +135 -0
  174. package/scripts/test-template-enforcement.sh +76 -0
  175. package/scripts/uninstall.sh +143 -0
  176. package/scripts/update.sh +337 -0
  177. package/scripts/verify-release.sh +323 -0
  178. package/scripts/verify-wrapped.sh +194 -0
  179. package/templates/CLAUDE.global.md +135 -0
  180. package/templates/CLAUDE.project.md +37 -0
package/mcp_server.js ADDED
@@ -0,0 +1,3529 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claws MCP Server — expose terminal control as native Claude Code tools.
4
+ * Zero dependencies. Node.js stdlib only.
5
+ *
6
+ * Powered by Claude Opus.
7
+ *
8
+ * Install Claws (auto-registers this MCP server globally):
9
+ * bash <(curl -fsSL https://raw.githubusercontent.com/neunaha/claws/main/scripts/install.sh)
10
+ *
11
+ * Or register manually (use FULL absolute path):
12
+ * "mcpServers": {
13
+ * "claws": {
14
+ * "command": "node",
15
+ * "args": ["/home/YOUR_USER/.claws-src/mcp_server.js"]
16
+ * }
17
+ * }
18
+ *
19
+ * Tools: claws_list, claws_create, claws_send, claws_exec,
20
+ * claws_read_log, claws_poll, claws_close, claws_worker,
21
+ * claws_hello, claws_subscribe, claws_publish, claws_broadcast,
22
+ * claws_ping, claws_peers
23
+ */
24
+
25
+ const net = require('net');
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const os = require('os');
29
+ const { randomUUID } = require('crypto');
30
+
31
+ // Resolve stream-events.js path at load time — correct across all install layouts:
32
+ // .claws-bin install: __dirname === <project>/.claws-bin → candidates[0]
33
+ // dev source tree: __dirname === <repo-root> → candidates[2]
34
+ const STREAM_EVENTS_JS = (() => {
35
+ const candidates = [
36
+ path.join(__dirname, 'stream-events.js'),
37
+ path.join(__dirname, '.claws-bin', 'stream-events.js'),
38
+ path.join(__dirname, 'scripts', 'stream-events.js'),
39
+ ];
40
+ for (const p of candidates) {
41
+ try { if (fs.existsSync(p)) return p; } catch (_) {}
42
+ }
43
+ return candidates[0];
44
+ })();
45
+
46
+ // Forward-slash form for embedding in Monitor command strings — single backslashes in the path
47
+ // are eaten by shell quoting at the Monitor tool receiver on Windows; Node accepts forward slashes.
48
+ const STREAM_EVENTS_JS_FOR_CMD = process.platform === 'win32'
49
+ ? STREAM_EVENTS_JS.replace(/\\/g, '/')
50
+ : STREAM_EVENTS_JS;
51
+
52
+ // Guard: schemas/mcp-tools.json is generated by `npm run schemas` in extension/.
53
+ // If missing, fail fast with a clear message rather than a confusing crash.
54
+ const _mcpToolsPath = path.join(__dirname, 'schemas', 'mcp-tools.json');
55
+ if (!fs.existsSync(_mcpToolsPath)) {
56
+ process.stderr.write(
57
+ '[claws-mcp] ERROR: schemas/mcp-tools.json not found.\n' +
58
+ ' Run: cd extension && npm run schemas\n',
59
+ );
60
+ process.exit(1);
61
+ }
62
+
63
+ // ─── MCP protocol (stdio, newline-delimited JSON) ──────────────────────────
64
+ // MCP spec: each JSON-RPC message is a single line on stdin/stdout, terminated
65
+ // by '\n'. (NOT LSP-style Content-Length framing — that was the prior bug.)
66
+
67
+ let inputBuf = '';
68
+
69
+ function readMessage() {
70
+ return new Promise((resolve) => {
71
+ const tryParse = () => {
72
+ const nl = inputBuf.indexOf('\n');
73
+ if (nl === -1) return false;
74
+ const line = inputBuf.slice(0, nl).replace(/\r$/, '');
75
+ inputBuf = inputBuf.slice(nl + 1);
76
+ if (line.trim() === '') return tryParse();
77
+ try {
78
+ resolve(JSON.parse(line));
79
+ return true;
80
+ } catch {
81
+ return tryParse();
82
+ }
83
+ };
84
+ if (tryParse()) return;
85
+ const onData = (chunk) => {
86
+ inputBuf += chunk.toString('utf8');
87
+ if (tryParse()) process.stdin.removeListener('data', onData);
88
+ };
89
+ process.stdin.on('data', onData);
90
+ });
91
+ }
92
+
93
+ function writeMessage(msg) {
94
+ process.stdout.write(JSON.stringify(msg) + '\n');
95
+ }
96
+
97
+ function respond(id, result) {
98
+ writeMessage({ jsonrpc: '2.0', id, result });
99
+ }
100
+
101
+ function respondError(id, code, message) {
102
+ writeMessage({ jsonrpc: '2.0', id, error: { code, message } });
103
+ }
104
+
105
+ function log(msg) {
106
+ process.stderr.write('[claws-mcp] ' + msg + '\n');
107
+ }
108
+
109
+ // Keep the bridge alive on unexpected async failures.
110
+ // Under Node ≥ 15 the default for unhandled rejections is throw → process exit,
111
+ // which causes the "MCP error -32000: Connection closed" symptom. These handlers
112
+ // log the cause and keep the process running so Claude Code receives a per-tool
113
+ // JSON-RPC error instead of a disconnected bridge.
114
+ process.on('unhandledRejection', (reason, _p) => {
115
+ process.stderr.write('[claws-mcp][FATAL] unhandledRejection: ' + (reason && reason.stack || String(reason)) + '\n');
116
+ });
117
+ process.on('uncaughtException', (err) => {
118
+ process.stderr.write('[claws-mcp][FATAL] uncaughtException: ' + (err && err.stack || String(err)) + '\n');
119
+ });
120
+
121
+ // L2 investigation helper — writes to stderr AND .claws/l2-debug.log for file-based capture.
122
+ function _logL2File(sockPath, msg) {
123
+ // Named pipes are kernel objects with no filesystem directory — skip on win32.
124
+ if (sockPath && sockPath.startsWith('\\\\.\\pipe\\')) return;
125
+ try {
126
+ const logPath = path.join(path.dirname(path.resolve(sockPath)), 'l2-debug.log');
127
+ fs.appendFileSync(logPath, new Date().toISOString() + ' ' + msg + '\n');
128
+ } catch (_e) { /* non-fatal */ }
129
+ }
130
+
131
+ function toolError(message) {
132
+ return { content: [{ type: 'text', text: message }], isError: true };
133
+ }
134
+
135
+ // ─── Claws socket client ───────────────────────────────────────────────────
136
+
137
+ let counter = 0;
138
+
139
+ // Per-call socket — stateless claws/1 commands (list, create, send, close,
140
+ // readLog, poll, exec, introspect, ping, lifecycle.*). Each call opens a
141
+ // fresh socket, sends one frame, receives one response, destroys the socket.
142
+ function clawsRpc(sockPath, req, timeout = 30000) {
143
+ return new Promise((resolve) => {
144
+ counter++;
145
+ req = { id: counter, ...req };
146
+ const sock = net.createConnection(sockPath);
147
+ sock.setTimeout(timeout);
148
+ let buf = '';
149
+ sock.on('connect', () => {
150
+ sock.write(JSON.stringify(req) + '\n');
151
+ });
152
+ sock.on('data', (data) => {
153
+ buf += data.toString('utf8');
154
+ const nl = buf.indexOf('\n');
155
+ if (nl !== -1) {
156
+ try { resolve(JSON.parse(buf.slice(0, nl))); }
157
+ catch { resolve({ ok: false, error: 'bad json from extension' }); }
158
+ sock.destroy();
159
+ }
160
+ });
161
+ sock.on('error', (err) => {
162
+ log('socket error [' + req.cmd + ']: ' + err.message);
163
+ resolve({ ok: false, error: `socket error: ${err.message}` });
164
+ sock.destroy();
165
+ });
166
+ sock.on('timeout', () => {
167
+ log('socket timeout [' + req.cmd + '] after ' + timeout + 'ms');
168
+ resolve({ ok: false, error: 'socket timeout' });
169
+ sock.destroy();
170
+ });
171
+ });
172
+ }
173
+
174
+ // ─── Worker binary resolution (project-scoped override) ─────────────────────
175
+ // Returns the claude binary name to spawn for workers. Lookup order:
176
+ // 1. <cwd>/.claws/claude-bin file (per-project override, gitignored) — read
177
+ // first line, trimmed. Used by Claws-on-Claws development to spawn workers
178
+ // under a different Claude account (e.g. 'claude-neu' shell alias).
179
+ // 2. CLAWS_CLAUDE_BIN env var (per-shell override).
180
+ // 3. Default: 'claude' (end-user default — no behavior change for downstream).
181
+ // File-based override is preferred because .claws/ is project-local and survives
182
+ // MCP restarts without requiring shell-env propagation through Claude Code.
183
+ function getClaudeBin(cwd) {
184
+ try {
185
+ const fs = require('fs');
186
+ const path = require('path');
187
+ const f = path.join(cwd || process.cwd(), '.claws', 'claude-bin');
188
+ if (fs.existsSync(f)) {
189
+ const v = fs.readFileSync(f, 'utf8').trim().split(/\r?\n/)[0];
190
+ if (v) return v;
191
+ }
192
+ } catch (_e) { /* fall through */ }
193
+ return process.env.CLAWS_CLAUDE_BIN || 'claude';
194
+ }
195
+
196
+ // ─── Persistent connection for stateful claws/2 commands ─────────────────────
197
+ // claws/2 peer state (registered by hello, consumed by publish/subscribe/
198
+ // broadcast/task.*) is bound to a single TCP connection on the server. Per-call
199
+ // sockets break multi-step flows: hello registers on socket A, then A is
200
+ // destroyed; the next call opens socket B which has no peer, and publish fails
201
+ // with "call hello first". One persistent socket is shared across all stateful
202
+ // tool calls for the lifetime of this MCP server process.
203
+
204
+ const _pconn = {
205
+ socket: null, // net.Socket | null
206
+ pending: new Map(), // rid → { resolve, reject, timer }
207
+ buf: '', // line-buffered receive buffer
208
+ nextRid: 1_000_000, // high range avoids collision with per-call counter
209
+ peerId: null, // allocated by server after successful hello
210
+ role: null, // cached for reconnect re-registration
211
+ peerName: null,
212
+ capabilities: null,
213
+ sockPath: null, // set on first successful connect
214
+ connected: false,
215
+ socketId: 0, // monotonic counter incremented per _pconnConnect; identifies current socket
216
+ helloSocketId: null, // socketId of the socket on which hello last succeeded; null if none
217
+ };
218
+
219
+ // ─── Push-frame ring buffer ───────────────────────────────────────────────────
220
+ // Captures every push frame delivered on the persistent socket so the
221
+ // orchestrator can drain them via claws_drain_events without needing an active
222
+ // subscription loop.
223
+ const _eventBuffer = {
224
+ ring: [], // { absoluteIndex, topic, from, payload, sentAt, sequence }
225
+ maxSize: 1000, // evict oldest when full
226
+ totalReceived: 0, // monotonically increasing; used as cursor by callers
227
+ waiters: [], // { resolve, timer } registered by drain with wait_ms > 0
228
+ maxWaiters: 10, // cap: reject new wait_ms requests beyond this
229
+ subscribed: false, // true once we've sent hello + subscribe("**") on _pconn
230
+ subscribeCursor: null, // event-log cursor at moment of subscription (Fix-D late-join detection)
231
+ _overflowPending: false, // guards single system.bus.ring-overflow per eviction batch
232
+ seenSequences: new Set(), // BUG-01: dedup frames delivered twice by overlapping subscriptions
233
+ };
234
+
235
+ let _pconnConnecting = null; // Promise | null — guards concurrent connect attempts
236
+ let _pconnSocketSeq = 0; // PCONN-DEBUG: monotonic socket ID for tracing socket identity
237
+
238
+ // Wave D — terminal IDs (strings) for which system.worker.terminated was received.
239
+ // Populated by _pconnHandleData; read by detectCompletion as 4th completion signal.
240
+ const _workerTerminatedSet = new Set();
241
+
242
+ // AF-AC Phase 1 — terminal IDs (strings) for which system.worker.process_exited was received.
243
+ // Populated by _pconnHandleData; checked by _setupDetachWatcher as the new primary completion signal.
244
+ // Keyed by termId string; value is { exitCode, correlationId, ts }.
245
+ const _workerProcessExitedSet = new Map();
246
+
247
+ // LH-9 hygiene: VS Code extension reload restarts the terminal id counter,
248
+ // so a fresh spawn can legitimately reuse an id that lives in the stale
249
+ // _workerTerminatedSet / _workerCompletedViaBusSet from a prior session.
250
+ // Without this clear, the very first watcher tick after spawn fires the
251
+ // terminated/completed branch in detectCompletion and instantly auto-closes
252
+ // the new terminal (~1.5s lifespan). Every spawn handler must call this
253
+ // before entering its watcher loop.
254
+ function _clearStaleCompletionSignals(termId) {
255
+ const key = String(termId);
256
+ _workerTerminatedSet.delete(key);
257
+ _workerCompletedViaBusSet.delete(key);
258
+ _workerProcessExitedSet.delete(key);
259
+ }
260
+ // True once _pconn has subscribed to system.worker.terminated.
261
+ let _workerTerminatedSubscribed = false;
262
+ // AF-AC Phase 1: true once _pconn has subscribed to system.worker.process_exited.
263
+ let _workerProcessExitedSubscribed = false;
264
+ // Phase 4a — bus-based completion: termId -> { payload, ts } for workers that published
265
+ // worker.<termId>.complete directly to the bus. Checked first in detectCompletion,
266
+ // bypassing pty scraping entirely. Map is append-only; entries survive until the MCP server exits.
267
+ const _workerCompletedViaBusSet = new Map();
268
+ let _workerBusCompletedSubscribed = false;
269
+ // W8ac-2: one-shot listeners for _waitForWorkerReady, keyed by "${topic}:${corrId}".
270
+ const _workerReadyWaiters = new Map();
271
+ let _workerPeerConnectedSubscribed = false;
272
+ let _workerTerminalReadySubscribed = false;
273
+ // AE-7: submit event waiters. Keys:
274
+ // 'vehicle-content:<termId>' — fires on vehicle.<termId>.content bus event
275
+ // 'tool-invoked:<corrId>' — fires on tool.+.invoked from matching worker peer
276
+ // 'system.terminal.closed:<corrId>' — lifecycle abort
277
+ // 'system.worker.terminated:<corrId>' — lifecycle abort
278
+ const _submitWaiters = new Map();
279
+ // AE-7: peerId → corrId map, populated on system.peer.connected for tool.invoked routing.
280
+ const _peerIdToCorrId = new Map();
281
+ let _vehicleContentSubscribed = false;
282
+ let _toolInvokedSubscribed = false;
283
+ let _helloInFlight = null; // Promise | null — deduplicates concurrent hello sends
284
+
285
+ // Circuit breaker for _pconnEnsureRegistered and _scanAndPublishCLAWSPUB.
286
+ // Prevents repeated expensive reconnect attempts when the extension socket is down.
287
+ const _circuitBreaker = {
288
+ lastFailureTs: 0, // epoch ms of most recent connect failure
289
+ scanConsecutiveErrors: 0, // consecutive _scanAndPublishCLAWSPUB socket failures
290
+ scanDisabled: false, // true after 3 consecutive scan errors; reset on explicit reconnect
291
+ };
292
+
293
+ function _pconnHandleData(data) {
294
+ _pconn.buf += data.toString('utf8');
295
+ let nl;
296
+ while ((nl = _pconn.buf.indexOf('\n')) !== -1) {
297
+ const line = _pconn.buf.slice(0, nl);
298
+ _pconn.buf = _pconn.buf.slice(nl + 1);
299
+ if (!line.trim()) continue;
300
+ try {
301
+ const msg = JSON.parse(line);
302
+ // Server encodes both `id` and `rid`; match on `rid` first.
303
+ const rid = msg.rid != null ? msg.rid : msg.id;
304
+ if (rid != null && _pconn.pending.has(rid)) {
305
+ const { resolve, timer } = _pconn.pending.get(rid);
306
+ clearTimeout(timer);
307
+ _pconn.pending.delete(rid);
308
+ resolve(msg);
309
+ } else if (rid == null && (msg.push === 'message' || msg.topic != null)) {
310
+ // BUG-01: skip duplicate delivery caused by overlapping subscriptions sharing a sequence.
311
+ if (msg.sequence != null) {
312
+ if (_eventBuffer.seenSequences.has(msg.sequence)) continue;
313
+ _eventBuffer.seenSequences.add(msg.sequence);
314
+ if (_eventBuffer.seenSequences.size > 2000) {
315
+ const _seqArr = [..._eventBuffer.seenSequences];
316
+ _eventBuffer.seenSequences = new Set(_seqArr.slice(-1000));
317
+ }
318
+ }
319
+ // Push frame (no rid) — buffer for claws_drain_events.
320
+ const entry = {
321
+ absoluteIndex: ++_eventBuffer.totalReceived,
322
+ topic: msg.topic || '',
323
+ from: msg.from || '',
324
+ payload: msg.payload != null ? msg.payload : null,
325
+ sentAt: msg.sentAt || null,
326
+ sequence: msg.sequence != null ? msg.sequence : null,
327
+ };
328
+ _eventBuffer.ring.push(entry);
329
+ // Wave D: track terminated terminals so detectCompletion can use them.
330
+ if (entry.topic === 'system.worker.terminated' && entry.payload && entry.payload.terminal_id) {
331
+ _workerTerminatedSet.add(String(entry.payload.terminal_id));
332
+ }
333
+ // AF-AC Phase 1: track process_exited events so detach watcher can resolve completion.
334
+ if (entry.topic === 'system.worker.process_exited' && entry.payload && entry.payload.terminal_id) {
335
+ const _pexTermId = String(entry.payload.terminal_id);
336
+ _workerProcessExitedSet.set(_pexTermId, {
337
+ exitCode: entry.payload.exit_code,
338
+ correlationId: entry.payload.correlation_id || null,
339
+ ts: Date.now(),
340
+ });
341
+ log(`AF-AC: system.worker.process_exited received termId=${_pexTermId} corrId=${entry.payload.correlation_id || 'none'} exitCode=${entry.payload.exit_code}`);
342
+ }
343
+ // Phase 4a: bus-based completion — worker.<termId>.complete wildcard.
344
+ if (entry.topic && /^worker\.[^.]+\.complete$/.test(entry.topic) && entry.payload) {
345
+ const _busTermId = entry.topic.split('.')[1];
346
+ _workerCompletedViaBusSet.set(String(_busTermId), { payload: entry.payload, ts: Date.now() });
347
+ log(`Phase 4a: received worker.${_busTermId}.complete via bus, payload: ${JSON.stringify(entry.payload).slice(0, 120)}`);
348
+ }
349
+ // W8ac-2: resolve _waitForWorkerReady listeners keyed by topic + correlation_id.
350
+ if ((entry.topic === 'system.peer.connected' || entry.topic === 'system.terminal.ready')
351
+ && entry.payload && entry.payload.correlation_id) {
352
+ const waiterKey = `${entry.topic}:${entry.payload.correlation_id}`;
353
+ const waiterResolve = _workerReadyWaiters.get(waiterKey);
354
+ if (waiterResolve) {
355
+ _workerReadyWaiters.delete(waiterKey);
356
+ waiterResolve(entry.payload);
357
+ }
358
+ }
359
+ // AE-7: populate peerId→corrId map on system.peer.connected for tool.invoked routing.
360
+ if (entry.topic === 'system.peer.connected' && entry.payload
361
+ && entry.payload.peer_id && entry.payload.correlation_id) {
362
+ _peerIdToCorrId.set(entry.payload.peer_id, entry.payload.correlation_id);
363
+ }
364
+ // AE-6.b: fire cancel-key listeners when worker lifecycle ends before boot signal.
365
+ if ((entry.topic === 'system.terminal.closed' || entry.topic === 'system.worker.terminated')
366
+ && entry.payload && entry.payload.correlation_id) {
367
+ const cancelKey = `${entry.topic}:${entry.payload.correlation_id}`;
368
+ const cancelFn = _workerReadyWaiters.get(cancelKey);
369
+ if (cancelFn) cancelFn();
370
+ // AE-7: also fire submit waiters keyed by lifecycle-end topic + corrId.
371
+ const submitCancelFn = _submitWaiters.get(cancelKey);
372
+ if (submitCancelFn) submitCancelFn();
373
+ }
374
+ // AE-7: dispatch vehicle.<termId>.content → _submitWaiters for submit confirmation.
375
+ if (entry.topic && /^vehicle\.[^.]+\.content$/.test(entry.topic)) {
376
+ const _vcTermId = entry.topic.split('.')[1];
377
+ const fn = _submitWaiters.get(`vehicle-content:${_vcTermId}`);
378
+ if (fn) fn(entry.payload);
379
+ }
380
+ // AF-3 Layer 2: dispatch system.terminal.paste_complete → _submitWaiters.
381
+ if (entry.topic === 'system.terminal.paste_complete' && entry.payload && entry.payload.terminalId) {
382
+ const fn = _submitWaiters.get(`paste-complete:${entry.payload.terminalId}`);
383
+ if (fn) fn(entry.payload);
384
+ }
385
+ // AE-7: dispatch tool.+.invoked → _submitWaiters via peerId→corrId lookup.
386
+ // AF-3 Layer 4: prefer payload.correlation_id (race-immune) over map lookup.
387
+ if (entry.topic && /^tool\.[^.]+\.invoked$/.test(entry.topic)) {
388
+ const _fromPeer = (entry.payload && entry.payload.peerId) || entry.from;
389
+ const _toolCorrId = (entry.payload && entry.payload.correlation_id)
390
+ || (_fromPeer ? _peerIdToCorrId.get(_fromPeer) : null);
391
+ if (_fromPeer && _toolCorrId && !_peerIdToCorrId.has(_fromPeer)) {
392
+ _peerIdToCorrId.set(_fromPeer, _toolCorrId);
393
+ }
394
+ if (_toolCorrId) {
395
+ const fn = _submitWaiters.get(`tool-invoked:${_toolCorrId}`);
396
+ if (fn) fn(entry.payload);
397
+ }
398
+ }
399
+ if (_eventBuffer.ring.length > _eventBuffer.maxSize) {
400
+ _eventBuffer.ring.shift();
401
+ // Emit ring-overflow once per eviction so callers know events were dropped.
402
+ if (!_eventBuffer._overflowPending) {
403
+ _eventBuffer._overflowPending = true;
404
+ setImmediate(() => {
405
+ _eventBuffer._overflowPending = false;
406
+ const overflowEntry = {
407
+ absoluteIndex: ++_eventBuffer.totalReceived,
408
+ topic: 'system.bus.ring-overflow',
409
+ from: 'mcp-server',
410
+ payload: { droppedAt: new Date().toISOString(), ringSize: _eventBuffer.maxSize },
411
+ sentAt: new Date().toISOString(),
412
+ sequence: null,
413
+ };
414
+ _eventBuffer.ring.push(overflowEntry);
415
+ });
416
+ }
417
+ }
418
+ const woke = _eventBuffer.waiters.splice(0);
419
+ for (const w of woke) { clearTimeout(w.timer); w.resolve(); }
420
+ }
421
+ } catch { /* ignore malformed frames */ }
422
+ }
423
+ }
424
+
425
+ function _pconnHandleClose() {
426
+ _pconn.connected = false;
427
+ _pconn.socket = null;
428
+ _pconn.buf = '';
429
+ _pconn.peerId = null;
430
+ _pconn.helloSocketId = null; // invalidate hello state — new socket requires fresh hello
431
+ _eventBuffer.subscribed = false; // reconnect requires fresh auto-subscribe
432
+ _workerTerminatedSubscribed = false; // reconnect requires re-subscribe
433
+ _workerBusCompletedSubscribed = false; // reconnect requires re-subscribe
434
+ _workerPeerConnectedSubscribed = false;
435
+ _workerTerminalReadySubscribed = false;
436
+ _vehicleContentSubscribed = false;
437
+ _toolInvokedSubscribed = false;
438
+ for (const [, { reject, timer }] of _pconn.pending) {
439
+ clearTimeout(timer);
440
+ reject(new Error('persistent socket closed'));
441
+ }
442
+ _pconn.pending.clear();
443
+ // Schedule reconnect; on success, re-issue hello if we had a prior identity.
444
+ if (_pconn.sockPath) {
445
+ const sp = _pconn.sockPath;
446
+ setTimeout(async () => {
447
+ try {
448
+ await _pconnEnsure(sp);
449
+ log(`PCONN-DEBUG: reconnect-ensure-done | peerId=${_pconn.peerId} | role=${_pconn.role}`);
450
+ if (_pconn.role && !_pconn.peerId) {
451
+ log(`PCONN-DEBUG: reconnect-about-to-hello | role=${_pconn.role}`);
452
+ const resp = await _pconnWriteOrThrow({
453
+ cmd: 'hello', protocol: 'claws/2',
454
+ role: _pconn.role, peerName: _pconn.peerName,
455
+ capabilities: _pconn.capabilities || undefined,
456
+ }, 5000);
457
+ log(`PCONN-DEBUG: reconnect-hello-ack | ok=${resp && resp.ok} | peerId=${resp && resp.peerId}`);
458
+ _pconn.peerId = resp.peerId;
459
+ _pconn.helloSocketId = _pconn.socketId; // bind hello to this socket
460
+ log(`reconnected and re-registered as ${resp.peerId} role=${_pconn.role}`);
461
+ }
462
+ } catch (e) { log(`PCONN-DEBUG: reconnect-failed | err=${e && e.message || e}`); }
463
+ }, 1000);
464
+ }
465
+ }
466
+
467
+ function _pconnConnect(sockPath) {
468
+ return new Promise((resolve, reject) => {
469
+ const sock = net.createConnection(sockPath);
470
+ const _sockId = ++_pconnSocketSeq;
471
+ log(`PCONN-DEBUG: connect-attempt | sockId=${_sockId} | socket=${sockPath}`);
472
+ // M-50: 5s connect timeout — prevents _pconnConnect from hanging forever
473
+ // when the extension socket exists but VS Code is reloading (ECONNREFUSED
474
+ // can take arbitrarily long on some platforms without a ceiling).
475
+ sock.setTimeout(5000);
476
+ sock.on('timeout', () => sock.destroy(new Error('persistent socket connect timed out')));
477
+ sock.on('connect', () => {
478
+ sock.setTimeout(0); // clear connect-phase timeout once connected
479
+ _pconn.socket = sock;
480
+ _pconn.sockPath = sockPath;
481
+ _pconn.connected = true;
482
+ _pconn.socketId = _sockId; // record which socket is now active
483
+ sock._pconnSocketId = _sockId; // attach for event correlation in close/error handlers
484
+ log(`PCONN-DEBUG: connected | sockId=${_sockId}`);
485
+ resolve();
486
+ });
487
+ sock.on('data', _pconnHandleData);
488
+ sock.on('error', (err) => {
489
+ if (!_pconn.connected) {
490
+ log(`PCONN-DEBUG: connect-error | sockId=${_sockId} | err=${err.message}`);
491
+ reject(err);
492
+ } else {
493
+ log(`PCONN-DEBUG: socket-event | type=error | sockId=${_sockId} | err=${err.message}`);
494
+ log('persistent socket error: ' + err.message);
495
+ }
496
+ });
497
+ sock.on('close', () => {
498
+ log(`PCONN-DEBUG: socket-event | type=close | sockId=${_sockId} | peerId=${_pconn.peerId}`);
499
+ _pconnHandleClose();
500
+ });
501
+ });
502
+ }
503
+
504
+ function _pconnEnsure(sockPath) {
505
+ if (_pconn.connected) return Promise.resolve();
506
+ if (_pconnConnecting) return _pconnConnecting;
507
+ _pconnConnecting = _pconnConnect(sockPath).finally(() => { _pconnConnecting = null; });
508
+ return _pconnConnecting;
509
+ }
510
+
511
+ function _pconnWrite(req, timeout) {
512
+ return new Promise((resolve, reject) => {
513
+ if (!_pconn.socket || !_pconn.connected) {
514
+ return reject(new Error('persistent socket not connected'));
515
+ }
516
+ const rid = _pconn.nextRid++;
517
+ const ms = timeout || 30000;
518
+ const _topic = req.topic || '';
519
+ log(`PCONN-DEBUG: write-start | rid=${rid} | cmd=${req.cmd} | topic=${_topic} | peerId=${_pconn.peerId}`);
520
+ const timer = setTimeout(() => {
521
+ _pconn.pending.delete(rid);
522
+ reject(new Error('stateful socket timeout'));
523
+ }, ms);
524
+ _pconn.pending.set(rid, {
525
+ resolve: (resp) => {
526
+ log(`PCONN-DEBUG: write-response | rid=${rid} | cmd=${req.cmd} | ok=${resp && resp.ok} | error=${resp && resp.error || ''}`);
527
+ if (resp && resp.ok === false) {
528
+ log(`PCONN-DEBUG: write-failed-silently | rid=${rid} | cmd=${req.cmd} | topic=${_topic} | error=${resp.error}`);
529
+ }
530
+ resolve(resp);
531
+ },
532
+ reject,
533
+ timer,
534
+ });
535
+ try {
536
+ // Stateful commands route via taskId/assignee/subscriptionId/peerId — never 'id'.
537
+ // Explicitly drop any user-supplied 'id' before stamping the RPC correlation id,
538
+ // so a future command carrying 'id' as a routing field cannot be silently misrouted.
539
+ const { id: _discarded, ...reqBody } = req;
540
+ _pconn.socket.write(JSON.stringify({ ...reqBody, id: rid }) + '\n');
541
+ } catch (err) {
542
+ clearTimeout(timer);
543
+ _pconn.pending.delete(rid);
544
+ reject(err);
545
+ }
546
+ });
547
+ }
548
+
549
+ // Like _pconnWrite but throws if the response has ok !== true. Use at every
550
+ // call site that publishes or does stateful work so silent ok:false failures
551
+ // surface to the nearest catch block instead of being logged as success.
552
+ async function _pconnWriteOrThrow(req, timeout) {
553
+ const resp = await _pconnWrite(req, timeout);
554
+ if (!resp || resp.ok !== true) {
555
+ throw new Error(`pconn write failed: ${resp && resp.error || 'no response'} (cmd=${req.cmd}${req.topic ? ' topic=' + req.topic : ''})`);
556
+ }
557
+ return resp;
558
+ }
559
+
560
+ // Persistent-socket RPC for stateful claws/2 commands. Connects the persistent
561
+ // socket on first call; reuses it for all subsequent calls.
562
+ async function clawsRpcStateful(sockPath, req, timeout) {
563
+ try {
564
+ await _pconnEnsure(sockPath);
565
+ } catch (err) {
566
+ return { ok: false, error: `persistent socket connect failed: ${err.message}` };
567
+ }
568
+ try {
569
+ return await _pconnWrite(req, timeout);
570
+ } catch (err) {
571
+ return { ok: false, error: `persistent socket error: ${err.message}` };
572
+ }
573
+ }
574
+
575
+ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
576
+
577
+ async function fileExec(sockPath, termId, command, timeoutMs = 180000) {
578
+ const execId = randomUUID().slice(0, 10);
579
+ const base = path.join(os.tmpdir(), 'claws-exec');
580
+ fs.mkdirSync(base, { recursive: true });
581
+ const outPath = path.join(base, `${execId}.out`);
582
+ const donePath = path.join(base, `${execId}.done`);
583
+ const wrapper = process.platform === 'win32'
584
+ ? `& { ${command} } *> '${outPath}' ; $LASTEXITCODE | Out-File -FilePath '${donePath}' -Encoding ascii`
585
+ : `{ ${command}; } > ${outPath} 2>&1; echo $? > ${donePath}`;
586
+ await clawsRpc(sockPath, { cmd: 'send', id: termId, text: wrapper, newline: true });
587
+ const deadline = Date.now() + timeoutMs;
588
+ while (Date.now() < deadline) {
589
+ if (fs.existsSync(donePath)) break;
590
+ await sleep(150);
591
+ }
592
+ if (!fs.existsSync(donePath)) {
593
+ const partial = fs.existsSync(outPath) ? fs.readFileSync(outPath, 'utf8') : '';
594
+ return { ok: false, error: `timeout after ${timeoutMs}ms`, partial };
595
+ }
596
+ const exitRaw = fs.readFileSync(donePath, 'utf8').trim();
597
+ const exitCode = /^\d+$/.test(exitRaw) ? parseInt(exitRaw, 10) : null;
598
+ const output = fs.existsSync(outPath) ? fs.readFileSync(outPath, 'utf8') : '';
599
+ try { fs.unlinkSync(outPath); } catch {}
600
+ try { fs.unlinkSync(donePath); } catch {}
601
+ return { ok: true, terminal_id: termId, command, output, exit_code: exitCode };
602
+ }
603
+
604
+ // ─── Tool definitions (generated — run: cd extension && npm run schemas) ──────
605
+
606
+ const TOOLS = require('./schemas/mcp-tools.json');
607
+
608
+ // ─── Blocking worker lifecycle ─────────────────────────────────────────────
609
+
610
+ function escapeRegex(s) {
611
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
612
+ }
613
+
614
+ // LH-15: server-side auto-wrap for claws_worker(command=...) shell missions.
615
+ // Appends bus-event publish + canonical __CLAWS_DONE__ marker. Idempotent —
616
+ // if user's command already contains the marker or [CLAWS_PUB] line, the
617
+ // suffix is harmless (first occurrence triggers close, later ones ignored).
618
+ function wrapShellCommand(rawCommand, terminalId) {
619
+ if (!rawCommand || typeof rawCommand !== 'string') return rawCommand;
620
+ const trimmed = rawCommand.trim();
621
+ const sep = (trimmed.endsWith(';') || trimmed.endsWith('&')) ? ' ' : ' ; ';
622
+ const tid = String(terminalId);
623
+ // Use semicolons (not &&) so wrapping fires even if user's command exits non-zero.
624
+ const suffix = process.platform === 'win32'
625
+ ? `${sep}Write-Output '[CLAWS_PUB] topic=worker.${tid}.complete data={"ok":true}' ; Write-Output '__CLAWS_DONE__'`
626
+ : `${sep}printf '[CLAWS_PUB] topic=worker.${tid}.complete data={"ok":true}\\n' ; printf '%s\\n' '__CLAWS_DONE__'`;
627
+ return `${rawCommand}${suffix}`;
628
+ }
629
+
630
+ // Find marker only when it appears in tool/output context — never in prose.
631
+ // Real Claude pty patterns the matcher MUST accept:
632
+ // • bare-shell printf: "MARKER\n"
633
+ // • Claude text response: "⏺MARKER<trailing-spaces>\r…"
634
+ // • Claude tool-result line: " ⎿ MARKER\r…"
635
+ // • shell worker (LH-15): "\MARKER\n" (zsh wrap artifact)
636
+ // Prose collisions the matcher MUST reject:
637
+ // • mission restatement: "step 4. Echo MARKER on its own line."
638
+ // • shell command echo: "% echo MARKER"
639
+ // • Claude action header: "⏺Bash(echo 'MARKER')" — wait for the actual
640
+ // tool result instead, never false-complete on
641
+ // a Bash command that hasn't run yet.
642
+ // Anchor: line start → optional whitespace OR backslash (zsh wrap artifact) →
643
+ // optional ⏺/⎿ indicator → optional whitespace → exact marker →
644
+ // whitespace/newline/EOT.
645
+ function findStandaloneMarker(text, marker) {
646
+ if (!marker || typeof marker !== 'string' || !text) return null;
647
+ // Leading class tolerates zsh's `\` line-wrap artifact (emitted when prompt/echo wraps near
648
+ // right margin in long bracketed-paste commands). Without this, shell workers with long command
649
+ // lines miss marker detection. Verified live LH-15 (term 18 in v0.7.13).
650
+ const re = new RegExp(
651
+ '(?:^|[\\r\\n])[\\t \\\\]*[⏺⎿]?[\\t ]*' + escapeRegex(marker) + '(?=[\\t \\r\\n]|$)'
652
+ );
653
+ const m = re.exec(text);
654
+ if (m === null) return null;
655
+ const ls = text.lastIndexOf('\n', m.index);
656
+ const lineStart = ls === -1 ? 0 : ls + 1;
657
+ const lineEnd = text.indexOf('\n', lineStart);
658
+ return text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd).trim();
659
+ }
660
+
661
+ // Backward-compat alias — internal callsites should prefer findStandaloneMarker.
662
+ function findMarkerLine(text, marker) {
663
+ return findStandaloneMarker(text, marker);
664
+ }
665
+
666
+ // ─── [CLAWS_PUB] line scanner ─────────────────────────────────────────────────
667
+ // SDK-less workers print [CLAWS_PUB] topic=<topic> key=val ... lines to stdout.
668
+ // The MCP server scans new pty output for these markers each poll tick and
669
+ // publishes on the worker's behalf — no socket, peerId, or SDK required.
670
+ // Grammar: [CLAWS_PUB] topic=<topic> key1=val key2="quoted val" key3=42
671
+ // Values: "..." → string | true/false → boolean | digits → number
672
+ async function _scanAndPublishCLAWSPUB(newText, sockPath) {
673
+ // Circuit breaker: if 3 consecutive socket errors, stop scanning until reconnect.
674
+ if (_circuitBreaker.scanDisabled) return;
675
+ const MARKER_RE = /^\[CLAWS_PUB\]\s+topic=(\S+)\s*(.*)?$/;
676
+ const KV_RE = /(\w+)=("([^"]*)"|(\S+))/g;
677
+ for (const line of newText.split('\n')) {
678
+ const m = MARKER_RE.exec(line);
679
+ if (!m) continue;
680
+ const topic = m[1];
681
+ const rest = m[2] || '';
682
+ const payload = {};
683
+ KV_RE.lastIndex = 0;
684
+ let kv;
685
+ while ((kv = KV_RE.exec(rest)) !== null) {
686
+ const key = kv[1];
687
+ const rawVal = kv[3] !== undefined ? kv[3] : kv[4];
688
+ if (rawVal === 'true') payload[key] = true;
689
+ else if (rawVal === 'false') payload[key] = false;
690
+ else if (/^-?\d+(\.\d+)?$/.test(rawVal)) payload[key] = parseFloat(rawVal);
691
+ else payload[key] = rawVal;
692
+ }
693
+ try {
694
+ await _pconnEnsureRegistered(sockPath);
695
+ await _pconnWriteOrThrow({ cmd: 'publish', protocol: 'claws/2', topic, payload }, 3000);
696
+ _circuitBreaker.scanConsecutiveErrors = 0;
697
+ } catch (e) {
698
+ _circuitBreaker.scanConsecutiveErrors++;
699
+ log('[CLAWS_PUB] publish failed topic=' + topic + ': ' + (e && e.message || e));
700
+ if (_circuitBreaker.scanConsecutiveErrors >= 3) {
701
+ _circuitBreaker.scanDisabled = true;
702
+ log('[CLAWS_PUB] circuit breaker tripped after 3 consecutive socket errors — scan disabled until reconnect');
703
+ return;
704
+ }
705
+ }
706
+ }
707
+ }
708
+
709
+ // ─── HB-L1: Heartbeat parser primitives (pure functions, no side effects) ────
710
+ // Per docs/heartbeat-architecture.md §V.E (TUI parsing patterns).
711
+ // These are pure input→output helpers. They are NOT wired to any watcher yet;
712
+ // integration comes in HB-L4. All functions operate on ANSI-stripped pty text.
713
+
714
+ /**
715
+ * Parse Claude TUI tool indicators (⏺ Tool(args)) from new bytes.
716
+ * @param {string} text — full pty log (ANSI-stripped)
717
+ * @param {number} sinceOffset — start parsing from this byte offset
718
+ * @returns {Array<{tool: string, target: string, summary: string, atOffset: number}>}
719
+ */
720
+ function parseToolIndicators(text, sinceOffset) {
721
+ if (!text || typeof text !== 'string') return [];
722
+ const slice = text.slice(sinceOffset || 0);
723
+ if (!slice) return [];
724
+
725
+ // Maps tool name → human-readable verb for summary field
726
+ const VERB = {
727
+ Read: 'reading', Edit: 'editing', Write: 'writing', Bash: 'running',
728
+ Grep: 'searching', Glob: 'globbing', Task: 'delegating',
729
+ TodoWrite: 'planning', WebFetch: 'fetching', WebSearch: 'searching web',
730
+ NotebookEdit: 'editing notebook',
731
+ };
732
+
733
+ // ⏺ ToolName(args) — Claude Code's standard indicator line
734
+ // \s* not \s+: TUI renders ⏺Bash( with zero whitespace (audit 6c1bd43)
735
+ const RE = /⏺\s*([\w]+)\(([^)]*)\)/g;
736
+ const results = [];
737
+ let m;
738
+ while ((m = RE.exec(slice)) !== null) {
739
+ const tool = m[1];
740
+ const rawArgs = (m[2] || '').trim();
741
+ const atOffset = (sinceOffset || 0) + m.index;
742
+
743
+ let target = rawArgs;
744
+ let summary;
745
+ if (tool === 'Bash') {
746
+ // Truncate long bash commands for readability
747
+ summary = 'running: ' + (rawArgs.length > 60 ? rawArgs.slice(0, 57) + '…' : rawArgs);
748
+ target = rawArgs;
749
+ } else if (tool === 'TodoWrite') {
750
+ summary = 'planning: tasks';
751
+ target = '';
752
+ } else if (tool === 'Task') {
753
+ summary = 'delegating to subagent';
754
+ target = rawArgs;
755
+ } else if (tool === 'WebSearch') {
756
+ summary = 'searching web';
757
+ target = rawArgs;
758
+ } else if (tool === 'NotebookEdit') {
759
+ summary = 'editing notebook';
760
+ target = rawArgs;
761
+ } else {
762
+ // For file-path tools (Read/Edit/Write/Grep/Glob/WebFetch) use basename
763
+ const basename = rawArgs.split('/').pop() || rawArgs;
764
+ const verb = VERB[tool] || tool.toLowerCase();
765
+ summary = verb + ' ' + basename;
766
+ target = rawArgs;
767
+ }
768
+ results.push({ tool, target, summary, atOffset });
769
+ }
770
+ return results;
771
+ }
772
+
773
+ /**
774
+ * Parse the latest cost/tokens footer line from Claude TUI render.
775
+ * Pattern: [████░░░░░░] 51% in:2.6k out:26.3k cost:$2369.32
776
+ * @param {string} text — full pty log (ANSI-stripped)
777
+ * @returns {{tokens_in: number, tokens_out: number, cost_usd: number, percent: number} | null}
778
+ */
779
+ function parseCostFooter(text) {
780
+ if (!text || typeof text !== 'string') return null;
781
+ // Match the last occurrence of the footer pattern (most up-to-date)
782
+ const RE = /\[\S+\]\s+(\d+)%\s+in:([\d.]+)([kKmM]?)\s+out:([\d.]+)([kKmM]?)\s+cost:\$([\d.]+)/g;
783
+ let last = null;
784
+ let m;
785
+ while ((m = RE.exec(text)) !== null) {
786
+ last = m;
787
+ }
788
+ if (!last) return null;
789
+
790
+ function expandK(num, suffix) {
791
+ const n = parseFloat(num);
792
+ const s = (suffix || '').toLowerCase();
793
+ if (s === 'k') return Math.round(n * 1000);
794
+ if (s === 'm') return Math.round(n * 1000000);
795
+ return n;
796
+ }
797
+
798
+ return {
799
+ percent: parseInt(last[1], 10),
800
+ tokens_in: expandK(last[2], last[3]),
801
+ tokens_out: expandK(last[4], last[5]),
802
+ cost_usd: parseFloat(last[6]),
803
+ };
804
+ }
805
+
806
+ /**
807
+ * Detect spinner activity (✻/✶/✳/✢/✽/✺/· prefix with "X for Ns" pattern).
808
+ * @param {string} text — full pty log
809
+ * @param {number} sinceOffset
810
+ * @returns {{lastSpinnerAt: number | null, isActive: boolean}}
811
+ * lastSpinnerAt is byte offset of the last spinner indicator found
812
+ */
813
+ function parseSpinnerActivity(text, sinceOffset) {
814
+ if (!text || typeof text !== 'string') return { lastSpinnerAt: null, isActive: false };
815
+ const slice = text.slice(sinceOffset || 0);
816
+ if (!slice) return { lastSpinnerAt: null, isActive: false };
817
+
818
+ // Claude's spinner: one of these unicode chars followed by a word and "for Ns"
819
+ const RE = /[✻✶✳✢✽✺·]\s+\w[\w\s]*for\s+\d+s/g;
820
+ let lastMatch = null;
821
+ let m;
822
+ while ((m = RE.exec(slice)) !== null) {
823
+ lastMatch = m;
824
+ }
825
+
826
+ if (!lastMatch) return { lastSpinnerAt: null, isActive: false };
827
+
828
+ const lastSpinnerAt = (sinceOffset || 0) + lastMatch.index;
829
+ // "active" means the spinner appeared near the end of the captured slice
830
+ // (within the last 200 chars — roughly one TUI render frame)
831
+ const isActive = lastMatch.index >= slice.length - 200;
832
+ return { lastSpinnerAt, isActive };
833
+ }
834
+
835
+ /**
836
+ * Parse TodoWrite tool blocks for the planned tasks.
837
+ * @param {string} text — full pty log
838
+ * @param {number} sinceOffset
839
+ * @returns {{todoItems: string[], atOffset: number} | null}
840
+ */
841
+ function parseTodoWrite(text, sinceOffset) {
842
+ if (!text || typeof text !== 'string') return null;
843
+ const slice = text.slice(sinceOffset || 0);
844
+ if (!slice) return null;
845
+
846
+ // TodoWrite indicator followed by a todo block (lines prefixed with ☐/☑/□/✓/- /• or numbered)
847
+ // Claude renders TodoWrite then shows the task list indented under the tool call
848
+ const todoCallIdx = slice.indexOf('⏺ TodoWrite');
849
+ if (todoCallIdx === -1) return null;
850
+
851
+ // Collect lines after the TodoWrite indicator
852
+ const afterCall = slice.slice(todoCallIdx);
853
+ const lines = afterCall.split(/\r?\n/);
854
+ const items = [];
855
+
856
+ // Skip the first line (the ⏺ TodoWrite(…) line itself) and collect task items
857
+ for (let i = 1; i < lines.length; i++) {
858
+ const raw = lines[i].trim();
859
+ if (!raw) continue;
860
+ // Stop if we hit a new tool indicator or ANSI reset / empty block
861
+ if (/^[⏺⎿]/.test(raw)) break;
862
+ // Match common list markers: ☐ ☑ □ ✓ ✗ - • * or "N." numbered
863
+ const itemMatch = raw.match(/^(?:[☐☑□✓✗•\-*]|\d+\.)\s+(.+)$/);
864
+ if (itemMatch) {
865
+ items.push(itemMatch[1].trim());
866
+ }
867
+ }
868
+
869
+ if (items.length === 0) return null;
870
+ return { todoItems: items, atOffset: (sinceOffset || 0) + todoCallIdx };
871
+ }
872
+
873
+ /**
874
+ * Detect error indicators in tool results (⎿ Error: ... or non-zero exit codes).
875
+ * Conservative — only fires on unambiguous patterns to avoid false positives.
876
+ * @param {string} text — full pty log
877
+ * @param {number} sinceOffset
878
+ * @returns {Array<{kind: 'error' | 'exit_nonzero', detail: string, atOffset: number}>}
879
+ */
880
+ function parseErrorIndicators(text, sinceOffset) {
881
+ if (!text || typeof text !== 'string') return [];
882
+ const slice = text.slice(sinceOffset || 0);
883
+ if (!slice) return [];
884
+
885
+ const results = [];
886
+
887
+ // Pattern 1: ⎿ Error: <message> — Claude's tool result error prefix (unambiguous)
888
+ const ERR_RE = /⎿\s+Error:\s*([^\n\r]+)/g;
889
+ let m;
890
+ while ((m = ERR_RE.exec(slice)) !== null) {
891
+ results.push({
892
+ kind: 'error',
893
+ detail: m[1].trim().slice(0, 200),
894
+ atOffset: (sinceOffset || 0) + m.index,
895
+ });
896
+ }
897
+
898
+ // Pattern 2: ⎿ … exit code N (N ≠ 0) — bash non-zero exits via tool result
899
+ const EXIT_RE = /⎿[^\n\r]*exit\s+code\s+(\d+)/g;
900
+ while ((m = EXIT_RE.exec(slice)) !== null) {
901
+ const code = parseInt(m[1], 10);
902
+ if (code !== 0) {
903
+ results.push({
904
+ kind: 'exit_nonzero',
905
+ detail: 'exit code ' + code,
906
+ atOffset: (sinceOffset || 0) + m.index,
907
+ });
908
+ }
909
+ }
910
+
911
+ // Sort by offset so results are in source order
912
+ results.sort((a, b) => a.atOffset - b.atOffset);
913
+ return results;
914
+ }
915
+
916
+ // ─── HB-L3: WorkerHeartbeatStateMachine ──────────────────────────────────────
917
+ // Per docs/heartbeat-architecture.md §V.C and docs/heartbeat-action-plan.md
918
+ // §II.A3 Layer 3. Pure state-tracking logic — uses L1 parsers, derives
919
+ // transitions, but does NOT publish anything (that's HB-L4+).
920
+
921
+ /**
922
+ * Heartbeat state machine for one worker terminal. Observes pty bytes,
923
+ * derives state transitions, surfaces transition events. Does NOT publish
924
+ * to the bus (that's HB-L4+). Pure logic.
925
+ *
926
+ * States: BOOTING → READY → WORKING (observability only; completion via external signals)
927
+ *
928
+ * Per docs/heartbeat-architecture.md §V.C.
929
+ */
930
+ class WorkerHeartbeatStateMachine {
931
+ constructor({ terminalId, correlationId }) {
932
+ this.terminalId = terminalId;
933
+ this.correlationId = correlationId;
934
+ this.state = 'BOOTING';
935
+ this.scanOffset = 0;
936
+ this.firstActivityAt = null; // first time we saw any tool
937
+ this.lastSpinnerAt = null; // last timestamp spinner was active
938
+ this.lastToolAt = null; // last timestamp a tool indicator was seen
939
+ this.lastNewBytesAt = null; // last timestamp new pty bytes arrived
940
+ this.toolCount = 0; // total tools observed (mission_complete gate requires ≥1)
941
+ this.postWorkEnteredAt = null; // when POST_WORK conditions first held
942
+ this.startedAt = Date.now();
943
+ this.cumulative = { tokens_in: 0, tokens_out: 0 };
944
+ this.todoItems = null; // last TodoWrite seen
945
+ this.lastErrors = []; // accumulator for error events
946
+ this.transitions = []; // log of all state transitions for inspection
947
+ }
948
+
949
+ /**
950
+ * Observe new pty content. Updates state, returns transitions detected this tick.
951
+ * @param {string} text — full pty log (ANSI-stripped)
952
+ * @param {number} [now] — current timestamp (injectable for deterministic testing)
953
+ * @returns {Array<{from: string, to: string, reason: string, at: number}>}
954
+ */
955
+ observe(text, now = Date.now()) {
956
+ if (!text || typeof text !== 'string') return [];
957
+
958
+ const prevOffset = this.scanOffset;
959
+ const detected = [];
960
+
961
+ // 1. Track new bytes
962
+ if (text.length > prevOffset) {
963
+ this.lastNewBytesAt = now;
964
+ }
965
+
966
+ // 2. Run L1 parsers on new bytes since prevOffset
967
+
968
+ const spinnerResult = parseSpinnerActivity(text, prevOffset);
969
+ if (spinnerResult.lastSpinnerAt !== null) {
970
+ this.lastSpinnerAt = now;
971
+ }
972
+
973
+ const tools = parseToolIndicators(text, prevOffset);
974
+ if (tools.length > 0) {
975
+ if (this.firstActivityAt === null) this.firstActivityAt = now;
976
+ this.lastToolAt = now;
977
+ this.toolCount += tools.length;
978
+ }
979
+
980
+ const footer = parseCostFooter(text);
981
+ if (footer) {
982
+ this.cumulative.tokens_in = footer.tokens_in;
983
+ this.cumulative.tokens_out = footer.tokens_out;
984
+ }
985
+
986
+ const todo = parseTodoWrite(text, prevOffset);
987
+ if (todo) {
988
+ this.todoItems = todo.todoItems;
989
+ }
990
+
991
+ const errors = parseErrorIndicators(text, prevOffset);
992
+ for (const err of errors) {
993
+ this.lastErrors.push(err);
994
+ }
995
+
996
+ // 3. State machine transitions (one transition per observe() tick max per branch)
997
+ if (this.state === 'BOOTING') {
998
+ // BOOTING → READY: bypass permissions text is the unambiguous boot signal
999
+ if (text.includes('bypass permissions')) {
1000
+ detected.push(this._transition('BOOTING', 'READY', 'bypass-permissions-detected', now));
1001
+ }
1002
+
1003
+ } else if (this.state === 'READY') {
1004
+ // READY → WORKING: first tool call observed (cumulative so cascade tick still fires)
1005
+ if (this.toolCount > 0) {
1006
+ detected.push(this._transition('READY', 'WORKING', 'first-tool-call', now));
1007
+ }
1008
+
1009
+ }
1010
+
1011
+ // 4. Advance scan offset to avoid re-parsing seen bytes
1012
+ this.scanOffset = text.length;
1013
+
1014
+ return detected;
1015
+ }
1016
+
1017
+ _transition(from, to, reason, at) {
1018
+ this.state = to;
1019
+ const t = { from, to, reason, at };
1020
+ this.transitions.push(t);
1021
+ return t;
1022
+ }
1023
+
1024
+ /** Snapshot current state for debugging / tests. */
1025
+ snapshot() {
1026
+ return {
1027
+ state: this.state,
1028
+ toolCount: this.toolCount,
1029
+ lastSpinnerAt: this.lastSpinnerAt,
1030
+ lastToolAt: this.lastToolAt,
1031
+ lastNewBytesAt: this.lastNewBytesAt,
1032
+ firstActivityAt: this.firstActivityAt,
1033
+ postWorkEnteredAt: this.postWorkEnteredAt,
1034
+ durationMs: Date.now() - this.startedAt,
1035
+ cumulative: { ...this.cumulative },
1036
+ todoItems: this.todoItems,
1037
+ errorsCount: this.lastErrors.length,
1038
+ };
1039
+ }
1040
+ }
1041
+
1042
+ // ─── Multi-signal completion detector (Task #58 — idle-timeout removed) ──────
1043
+ // Pure function. Returns { status, line, signal } if an event-driven signal
1044
+ // fires, else null. signal in {'marker','error','pub_complete'}.
1045
+ // Priority: marker > error > pub_complete.
1046
+ //
1047
+ // idle_timeout (polling-based) was removed in v0.7.10 — it killed Claude TUI
1048
+ // workers mid-thinking and contradicted the pub/sub event-driven architecture.
1049
+ // Proper completion fallback will come from VS Code's onDidCloseTerminal →
1050
+ // system.worker.terminated bus event (planned).
1051
+ function detectCompletion(text, opt, termId, terminatedSet, busCompletedSet) {
1052
+ // 1. Phase 4a: bus completion — highest priority, bypasses pty scraping entirely.
1053
+ if (busCompletedSet && busCompletedSet.has(String(termId))) {
1054
+ const _busEntry = busCompletedSet.get(String(termId));
1055
+ const _busStatus = _busEntry.payload && _busEntry.payload.status === 'failed' ? 'failed' : 'completed';
1056
+ return { status: _busStatus, line: _busEntry.payload && _busEntry.payload.summary || null, signal: 'pub_complete_v2' };
1057
+ }
1058
+ // 2. marker
1059
+ const m = findStandaloneMarker(text, opt.complete_marker);
1060
+ if (m !== null) return { status: 'completed', line: m, signal: 'marker' };
1061
+ // 3. error
1062
+ for (const em of (opt.error_markers || [])) {
1063
+ const e = em ? findStandaloneMarker(text, em) : null;
1064
+ if (e !== null) return { status: 'failed', line: e, signal: 'error' };
1065
+ }
1066
+ // 4. explicit pub-complete: [CLAWS_PUB] topic=worker.<termId>.complete
1067
+ const pubTopic = `worker.${termId}.complete`;
1068
+ const pubLine = findStandaloneMarker(text, `[CLAWS_PUB] topic=${pubTopic}`);
1069
+ if (pubLine !== null) return { status: 'completed', line: pubLine, signal: 'pub_complete' };
1070
+ // 5. terminated: VS Code onDidCloseTerminal → system.worker.terminated bus event.
1071
+ // Belt-and-suspenders for workers that succeed but skip the printf marker.
1072
+ if (terminatedSet && terminatedSet.has(String(termId))) {
1073
+ return { status: 'terminated', line: null, signal: 'terminated' };
1074
+ }
1075
+ return null;
1076
+ }
1077
+
1078
+ // ─── Shared detach-watcher factory ───────────────────────────────────────────
1079
+ // _fpTick (claws_worker fast-path) and _dswTick (claws_dispatch_subworker)
1080
+ // were near-identical ~150-line implementations that drifted: BUG-26 was
1081
+ // applied asymmetrically; _dswCwd (Bug 11) lived undetected for the same
1082
+ // reason. Single implementation below; applied at both sites.
1083
+ // tick (runBlockingWorker detach) is structurally different (no HB state
1084
+ // machine, dead _msState tracking, different timeout behavior) and is kept
1085
+ // in place.
1086
+ //
1087
+ // Params:
1088
+ // sock — Claws socket path
1089
+ // termId — terminal ID
1090
+ // corrId — correlation UUID for this worker
1091
+ // opt — { complete_marker, error_markers, timeout_ms, poll_interval_ms, close_on_complete }
1092
+ // markerScanFrom — log offset to start scanning for completion markers (0 for Claude TUI workers)
1093
+ // hbState — WorkerHeartbeatStateMachine instance (required — always pass one)
1094
+ // startedAt — spawn timestamp (ms)
1095
+ // extraPayload — extra fields merged into system.worker.completed payload
1096
+ // fp: { booted: launchClaude } dsw: { waveId, role }
1097
+ // closeOnTimeout — if true, close terminal + mark closed on timeout (dsw behavior; fp=false)
1098
+ function _setupDetachWatcher({ sock, termId, corrId, opt, markerScanFrom, hbState, startedAt, extraPayload, closeOnTimeout = false }) {
1099
+ let pubScanOffset = 0;
1100
+ let hbLastPublishedAt = Date.now();
1101
+ let progressBurst = [];
1102
+ let progressBurstStart = 0;
1103
+ let lastPublishedToolCount = 0;
1104
+ let lastTodoSig = '';
1105
+ let lastPublishedErrorsCount = 0;
1106
+
1107
+ const tick = async () => {
1108
+ try {
1109
+ if (Date.now() > startedAt + opt.timeout_ms) {
1110
+ clearInterval(intervalId);
1111
+ _detachWatchers.delete(termId);
1112
+ try {
1113
+ await _pconnEnsureRegistered(sock);
1114
+ await _pconnWriteOrThrow({ cmd: 'publish', protocol: 'claws/2', topic: 'system.worker.completed',
1115
+ payload: { terminal_id: termId, status: 'timeout', duration_ms: Date.now() - startedAt, marker_line: null, detach: true, correlation_id: corrId, completion_signal: null, ...extraPayload } });
1116
+ } catch (e) { log('detach watcher publish failed: ' + (e && e.message || e)); }
1117
+ try { await clawsRpc(sock, { cmd: 'lifecycle.mark-worker-status', terminalId: String(termId), status: 'timeout' }); } catch (e) { /* non-fatal */ }
1118
+ if (closeOnTimeout) {
1119
+ try { await clawsRpc(sock, { cmd: 'close', id: termId, close_origin: 'timeout' }); } catch {}
1120
+ try { await clawsRpc(sock, { cmd: 'lifecycle.mark-worker-status', terminalId: String(termId), status: 'closed' }); } catch {}
1121
+ }
1122
+ return;
1123
+ }
1124
+ const snap = await clawsRpc(sock, { cmd: 'readLog', id: termId, strip: true, limit: 64 * 1024 });
1125
+ const text = snap.ok && typeof snap.bytes === 'string' ? snap.bytes : '';
1126
+ try {
1127
+ hbState.observe(text);
1128
+ const hbNow = Date.now();
1129
+ if (hbNow - hbLastPublishedAt >= 30000) {
1130
+ const hbSnap = hbState.snapshot();
1131
+ const elapsedSec = Math.floor(hbSnap.durationMs / 1000);
1132
+ await _pconnEnsureRegistered(sock);
1133
+ await _pconnWriteOrThrow({
1134
+ cmd: 'publish', protocol: 'claws/2',
1135
+ topic: `worker.${termId}.heartbeat`,
1136
+ payload: {
1137
+ kind: 'heartbeat',
1138
+ summary: `still active · ${elapsedSec}s elapsed`,
1139
+ current_action: hbSnap.state,
1140
+ duration_ms: hbSnap.durationMs,
1141
+ tokens_in: hbSnap.cumulative.tokens_in || undefined,
1142
+ tokens_out: hbSnap.cumulative.tokens_out || undefined,
1143
+ captured_at: new Date().toISOString(),
1144
+ correlation_id: corrId,
1145
+ },
1146
+ });
1147
+ hbLastPublishedAt = hbNow;
1148
+ }
1149
+ // HB-L5: progress event burst aggregation (5s window)
1150
+ const l5Snap = hbState.snapshot();
1151
+ const newTools = l5Snap.toolCount - lastPublishedToolCount;
1152
+ if (newTools > 0) {
1153
+ progressBurst.push({ kind: 'tools', count: newTools, ts: Date.now() });
1154
+ if (progressBurstStart === 0) progressBurstStart = Date.now();
1155
+ lastPublishedToolCount = l5Snap.toolCount;
1156
+ }
1157
+ if (progressBurst.length > 0 && (Date.now() - progressBurstStart) >= 5000) {
1158
+ const burstTotal = progressBurst.reduce((a, e) => a + e.count, 0);
1159
+ const burstElapsed = Math.round((Date.now() - progressBurstStart) / 1000);
1160
+ try {
1161
+ await _pconnEnsureRegistered(sock);
1162
+ await _pconnWriteOrThrow({
1163
+ cmd: 'publish', protocol: 'claws/2',
1164
+ topic: `worker.${termId}.heartbeat`,
1165
+ payload: {
1166
+ kind: 'progress',
1167
+ summary: `${burstTotal} tool call${burstTotal !== 1 ? 's' : ''} in last ${burstElapsed}s`,
1168
+ current_action: l5Snap.state,
1169
+ duration_ms: l5Snap.durationMs,
1170
+ total_tool_calls: l5Snap.toolCount,
1171
+ tokens_in: l5Snap.cumulative.tokens_in || undefined,
1172
+ tokens_out: l5Snap.cumulative.tokens_out || undefined,
1173
+ captured_at: new Date().toISOString(),
1174
+ correlation_id: corrId,
1175
+ },
1176
+ });
1177
+ } catch (e) { log('hb-l5 progress publish failed: ' + (e && e.message || e)); }
1178
+ progressBurst = [];
1179
+ progressBurstStart = 0;
1180
+ }
1181
+ // HB-L6: kind=approach (TodoWrite changes) — single-fire per distinct todoItems content
1182
+ const l6Snap = hbState.snapshot();
1183
+ const todoSig = l6Snap.todoItems ? JSON.stringify(l6Snap.todoItems) : '';
1184
+ if (todoSig && todoSig !== lastTodoSig) {
1185
+ lastTodoSig = todoSig;
1186
+ try {
1187
+ await _pconnEnsureRegistered(sock);
1188
+ await _pconnWriteOrThrow({
1189
+ cmd: 'publish', protocol: 'claws/2',
1190
+ topic: `worker.${termId}.heartbeat`,
1191
+ payload: {
1192
+ kind: 'approach',
1193
+ summary: `planning: ${l6Snap.todoItems.length} task${l6Snap.todoItems.length !== 1 ? 's' : ''}`,
1194
+ approach_detail: l6Snap.todoItems,
1195
+ current_action: l6Snap.state,
1196
+ duration_ms: l6Snap.durationMs,
1197
+ captured_at: new Date().toISOString(),
1198
+ correlation_id: corrId,
1199
+ },
1200
+ });
1201
+ } catch (e) { log('hb-l6 approach publish failed: ' + (e && e.message || e)); }
1202
+ }
1203
+ // HB-L6: kind=error (new error indicators) — single-fire per new batch
1204
+ if (l6Snap.errorsCount > lastPublishedErrorsCount) {
1205
+ const newErrors = hbState.lastErrors.slice(lastPublishedErrorsCount);
1206
+ lastPublishedErrorsCount = l6Snap.errorsCount;
1207
+ try {
1208
+ await _pconnEnsureRegistered(sock);
1209
+ await _pconnWriteOrThrow({
1210
+ cmd: 'publish', protocol: 'claws/2',
1211
+ topic: `worker.${termId}.heartbeat`,
1212
+ payload: {
1213
+ kind: 'error',
1214
+ summary: `${newErrors.length} error${newErrors.length !== 1 ? 's' : ''} detected`,
1215
+ error_detail: newErrors.map(e => e.detail || e.summary || String(e)).slice(0, 5).join('; '),
1216
+ current_action: l6Snap.state,
1217
+ duration_ms: l6Snap.durationMs,
1218
+ captured_at: new Date().toISOString(),
1219
+ correlation_id: corrId,
1220
+ },
1221
+ });
1222
+ } catch (e) { log('hb-l6 error publish failed: ' + (e && e.message || e)); }
1223
+ }
1224
+ } catch (e) { log('hb backstop publish failed: ' + (e && e.message || e)); }
1225
+ if (text.length > pubScanOffset) await _scanAndPublishCLAWSPUB(text.slice(pubScanOffset), sock);
1226
+ pubScanOffset = text.length;
1227
+ // AF-AC Phase 1: check process_exited event (highest priority — OS-authoritative signal).
1228
+ if (_workerProcessExitedSet.has(String(termId))) {
1229
+ const _pexEntry = _workerProcessExitedSet.get(String(termId));
1230
+ clearInterval(intervalId);
1231
+ _detachWatchers.delete(termId);
1232
+ log(`AF-AC: detach watcher resolving via process_exited termId=${termId} exitCode=${_pexEntry && _pexEntry.exitCode}`);
1233
+ try {
1234
+ await _pconnEnsureRegistered(sock);
1235
+ await _pconnWriteOrThrow({ cmd: 'publish', protocol: 'claws/2', topic: 'system.worker.completed',
1236
+ payload: { terminal_id: termId, status: 'completed', duration_ms: Date.now() - startedAt, marker_line: null, detach: true, correlation_id: corrId, completion_signal: 'process_exited', completion_source: 'process_exited', ...extraPayload } });
1237
+ } catch (e) { log('AF-AC detach watcher publish failed: ' + (e && e.message || e)); }
1238
+ try { await clawsRpc(sock, { cmd: 'lifecycle.mark-worker-status', terminalId: String(termId), status: 'completed' }); } catch (_e) { /* non-fatal */ }
1239
+ if (opt.close_on_complete !== false) {
1240
+ try { await clawsRpc(sock, { cmd: 'close', id: termId, close_origin: 'process_exit' }); } catch {}
1241
+ try { await clawsRpc(sock, { cmd: 'lifecycle.mark-worker-status', terminalId: String(termId), status: 'closed' }); } catch {}
1242
+ }
1243
+ return;
1244
+ }
1245
+ const scanText = text.length > markerScanFrom ? text.slice(markerScanFrom) : '';
1246
+ const det = detectCompletion(scanText, opt, String(termId), _workerTerminatedSet, _workerCompletedViaBusSet);
1247
+ const detStatus = det ? det.status : null;
1248
+ const markerLine = det ? det.line : null;
1249
+ const signal = det ? det.signal : null;
1250
+ if (detStatus !== null) {
1251
+ clearInterval(intervalId);
1252
+ _detachWatchers.delete(termId);
1253
+ try {
1254
+ await _pconnEnsureRegistered(sock);
1255
+ await _pconnWriteOrThrow({ cmd: 'publish', protocol: 'claws/2', topic: 'system.worker.completed',
1256
+ payload: { terminal_id: termId, status: detStatus, duration_ms: Date.now() - startedAt, marker_line: markerLine, detach: true, correlation_id: corrId, completion_signal: signal, ...extraPayload } });
1257
+ } catch (e) { log('detach watcher publish failed: ' + (e && e.message || e)); }
1258
+ try { await clawsRpc(sock, { cmd: 'lifecycle.mark-worker-status', terminalId: String(termId), status: detStatus }); } catch (e) { /* non-fatal */ }
1259
+ if (opt.close_on_complete !== false) {
1260
+ const closeOrigin = detStatus === 'completed' ? 'marker' : detStatus === 'failed' ? 'error' : detStatus === 'timeout' ? 'timeout' : 'orchestrator';
1261
+ try { await clawsRpc(sock, { cmd: 'close', id: termId, close_origin: closeOrigin }); } catch {}
1262
+ try { await clawsRpc(sock, { cmd: 'lifecycle.mark-worker-status', terminalId: String(termId), status: 'closed' }); } catch {}
1263
+ }
1264
+ }
1265
+ } catch (e) { log('detach watcher tick error: ' + (e && e.message || e)); }
1266
+ };
1267
+ const intervalId = setInterval(tick, opt.poll_interval_ms);
1268
+ _detachWatchers.set(termId, { intervalId, opt, startedAt });
1269
+ return intervalId;
1270
+ }
1271
+
1272
+ // W8k-1: shared mission-delivery helper used by both runBlockingWorker (fleet path)
1273
+ // and the claws_worker fast path. Guarantees identical send timing on Mac pty and
1274
+ // Windows ConPTY. Proven sequence (line 1380):
1275
+ // bracketed-paste (newline:false) → 300ms gap → explicit \r → SUBMITS ✓
1276
+ // Using newline:true (internal 30ms CR) fails on Windows ConPTY — mission sits
1277
+ // unsubmitted in the prompt buffer until the user manually presses Enter.
1278
+ // AE-7: escalating submit keystroke strategies — tried in order when the event-driven
1279
+ // submit confirmation doesn't arrive within RETRY_INTERVAL_MS. strat 0 ('\r') is sent
1280
+ // first and covers Mac + Windows nominal path. strats 1-4 handle edge cases (sticky
1281
+ // bracketed-paste mode, Ink readline alt-paths, ConPTY flush variants).
1282
+ // Zero platform branches — the same array is used on darwin/linux/win32.
1283
+ const SUBMIT_STRATEGIES = [
1284
+ '\r', // strat 0: baseline CR — correct universal submit key
1285
+ '\r\n', // strat 1: Windows CRLF variant
1286
+ '\x1b[201~\r', // strat 2: paste-end marker + CR (unstick stuck bracketed-paste mode)
1287
+ '\n\r', // strat 3: LF then CR (Ink readline alt-path)
1288
+ '\x1b\r', // strat 4: ESC + CR (force exit any input mode before submit)
1289
+ ];
1290
+ const RETRY_INTERVAL_MS = 3000; // wait this long for a bus event before escalating strategy
1291
+ const SUBMIT_CEILING_MS = 60000; // true-hang safety net — NOT a submit budget
1292
+
1293
+ // AE-7: wait for bus evidence that the worker received and started processing the mission.
1294
+ // Returns { kind: 'submitted', signal } | { kind: 'lifecycle_ended' } | { kind: 'timeout' }.
1295
+ //
1296
+ // 'submitted' signals:
1297
+ // - 'tool.invoked': a tool.+.invoked event from the worker's peer (strongest — mission executing)
1298
+ // - 'vehicle.content': vehicle.<termId>.content fired AND pty log grew past sizeThreshold
1299
+ //
1300
+ // 'lifecycle_ended': terminal closed or worker terminated — no point retrying.
1301
+ // 'timeout': RETRY_INTERVAL_MS elapsed with no event — caller escalates to next strategy.
1302
+ async function _waitForSubmitEvent(sock, termId, corrId, sizeThreshold, timeoutMs) {
1303
+ const contentKey = `vehicle-content:${termId}`;
1304
+ const pasteCompleteKey = `paste-complete:${termId}`;
1305
+ const toolKey = `tool-invoked:${corrId}`;
1306
+ const termClosedKey = `system.terminal.closed:${corrId}`;
1307
+ const workerTermKey = `system.worker.terminated:${corrId}`;
1308
+
1309
+ return new Promise((resolve) => {
1310
+ let resolved = false;
1311
+
1312
+ const finish = (result) => {
1313
+ if (resolved) return;
1314
+ resolved = true;
1315
+ cleanup();
1316
+ resolve(result);
1317
+ };
1318
+
1319
+ const cleanup = () => {
1320
+ clearTimeout(timer);
1321
+ _submitWaiters.delete(contentKey);
1322
+ _submitWaiters.delete(pasteCompleteKey);
1323
+ _submitWaiters.delete(toolKey);
1324
+ _submitWaiters.delete(termClosedKey);
1325
+ _submitWaiters.delete(workerTermKey);
1326
+ };
1327
+
1328
+ const timer = setTimeout(() => finish({ kind: 'timeout' }), timeoutMs);
1329
+
1330
+ // vehicle.content: async size check; re-register on miss so it's effectively multi-fire
1331
+ // within the window. Only resolves "submitted" if pty log actually grew past threshold.
1332
+ const handleContent = () => {
1333
+ clawsRpc(sock, { cmd: 'readLog', id: termId, strip: true, limit: 1024 }).then(snap => {
1334
+ if (resolved) return;
1335
+ const size = (snap.ok && typeof snap.totalSize === 'number') ? snap.totalSize
1336
+ : (snap.ok && typeof snap.bytes === 'string' ? snap.bytes.length : 0);
1337
+ if (size > sizeThreshold) {
1338
+ finish({ kind: 'submitted', signal: 'vehicle.content' });
1339
+ } else {
1340
+ if (!resolved) _submitWaiters.set(contentKey, handleContent);
1341
+ }
1342
+ }).catch(() => { if (!resolved) _submitWaiters.set(contentKey, handleContent); });
1343
+ };
1344
+ _submitWaiters.set(contentKey, handleContent);
1345
+ // AF-3 Layer 2: paste_complete fires from extension when captureStore grows (Windows fix).
1346
+ // AF-3 Layer 1: eager check — on Windows vehicle.content already fired before this listener
1347
+ // was installed; run handleContent immediately so the size check resolves without waiting.
1348
+ _submitWaiters.set(pasteCompleteKey, handleContent);
1349
+ handleContent();
1350
+
1351
+ // tool.invoked: strongest confirmation — worker's inner claude is calling MCP tools
1352
+ _submitWaiters.set(toolKey, () => finish({ kind: 'submitted', signal: 'tool.invoked' }));
1353
+
1354
+ // lifecycle ended — terminal died before submission confirmed; no point retrying
1355
+ _submitWaiters.set(termClosedKey, () => finish({ kind: 'lifecycle_ended' }));
1356
+ _submitWaiters.set(workerTermKey, () => finish({ kind: 'lifecycle_ended' }));
1357
+ });
1358
+ }
1359
+
1360
+ async function _sendAndSubmitMission(sock, termId, corrId, payload, launchClaude) {
1361
+ let markerScanFrom = 0;
1362
+ if (!payload) return markerScanFrom;
1363
+
1364
+ if (launchClaude) {
1365
+ // BUG-C: record log offset BEFORE mission send so detectCompletion never
1366
+ // false-positives on marker strings embedded in the mission body itself.
1367
+ const _preSnap = await clawsRpc(sock, { cmd: 'readLog', id: termId, strip: true, limit: 32 * 1024 });
1368
+ const _preLen = (_preSnap.ok && typeof _preSnap.totalSize === 'number') ? _preSnap.totalSize
1369
+ : (_preSnap.ok && typeof _preSnap.bytes === 'string' ? _preSnap.bytes.length : 0);
1370
+
1371
+ await clawsRpc(sock, { cmd: 'send', id: termId, text: payload, newline: false, paste: true });
1372
+ await sleep(300);
1373
+ // AE-6 (reverts AE-4): '\r' is the correct universal submit key.
1374
+ // AE-7: strat 0 of SUBMIT_STRATEGIES — subsequent strategies escalate on timeout.
1375
+ await clawsRpc(sock, { cmd: 'send', id: termId, text: SUBMIT_STRATEGIES[0], newline: false });
1376
+
1377
+ // AE-7: event-driven submit confirmation with escalating retry.
1378
+ // Waits up to RETRY_INTERVAL_MS for a bus event proving Claude received the mission.
1379
+ // On timeout, escalates to the next strategy. Safety ceiling prevents true-hang.
1380
+ // No fixed-cadence polling, no pty-content regex — bus events only.
1381
+ const _submitDeadline = Date.now() + SUBMIT_CEILING_MS;
1382
+ let _stratIdx = 0;
1383
+ let _submitVerified = false;
1384
+
1385
+ while (Date.now() < _submitDeadline && !_submitVerified) {
1386
+ const evt = await _waitForSubmitEvent(
1387
+ sock, termId, corrId,
1388
+ _preLen + payload.length + 200,
1389
+ RETRY_INTERVAL_MS,
1390
+ );
1391
+ if (evt.kind === 'submitted') { _submitVerified = true; break; }
1392
+ if (evt.kind === 'lifecycle_ended') { break; }
1393
+ // timeout — escalate to next strategy (cycle through if exhausted)
1394
+ _stratIdx = (_stratIdx + 1) % SUBMIT_STRATEGIES.length;
1395
+ await clawsRpc(sock, { cmd: 'send', id: termId, text: SUBMIT_STRATEGIES[_stratIdx], newline: false });
1396
+ }
1397
+
1398
+ if (!_submitVerified) {
1399
+ log(`_sendAndSubmitMission: submit ceiling hit (${SUBMIT_CEILING_MS}ms) — true hang or all strategies exhausted`);
1400
+ }
1401
+ // Snapshot post-mission log size for markerScanFrom.
1402
+ try {
1403
+ const _postSnap = await clawsRpc(sock, { cmd: 'readLog', id: termId, strip: true, limit: 1024 });
1404
+ markerScanFrom = (_postSnap.ok && typeof _postSnap.totalSize === 'number')
1405
+ ? _postSnap.totalSize
1406
+ : (_postSnap.ok && typeof _postSnap.bytes === 'string' ? _postSnap.bytes.length : 0);
1407
+ } catch (e) { /* non-fatal — defaults to 0, degraded but safe */ }
1408
+ } else {
1409
+ // Bare shell command: no paste needed; trailing \n submits via the prompt.
1410
+ // BUG-26 analog: markerScanFrom stays 0 so the watcher scans the full log —
1411
+ // the marker fires synchronously after the command exits.
1412
+ await clawsRpc(sock, { cmd: 'send', id: termId, text: payload, newline: true });
1413
+ }
1414
+
1415
+ return markerScanFrom;
1416
+ }
1417
+
1418
+ // W8ac-2 + AE-6.b: event-driven boot signal. Resolves when the matching bus event fires
1419
+ // for corrId. Uses _workerReadyWaiters populated by _pconnHandleData as the one-shot listener store.
1420
+ // AE-6.b: ceilingMs is a SAFETY CEILING for true hangs, NOT a boot budget. Default 120s —
1421
+ // generous enough that no real device (slow VMs, ARM laptops, first-boot cold caches) races
1422
+ // against it. We abort early if the worker's lifecycle ends (terminated/closed) before the
1423
+ // ready event arrives.
1424
+ async function _waitForWorkerReady(sock, corrId, opts) {
1425
+ const topic = opts.type === 'claude' ? 'system.peer.connected' : 'system.terminal.ready';
1426
+ const ceilingMs = opts.timeoutMs || 120000;
1427
+ return new Promise((resolve, reject) => {
1428
+ const waiterKey = `${topic}:${corrId}`;
1429
+ const cancelKeys = [
1430
+ `system.terminal.closed:${corrId}`,
1431
+ `system.worker.terminated:${corrId}`,
1432
+ ];
1433
+ const cleanup = () => {
1434
+ clearTimeout(timer);
1435
+ _workerReadyWaiters.delete(waiterKey);
1436
+ for (const k of cancelKeys) _workerReadyWaiters.delete(k);
1437
+ };
1438
+ const timer = setTimeout(() => {
1439
+ cleanup();
1440
+ reject(new Error(`worker ready ceiling hit (${opts.type}, ${ceilingMs}ms) — likely worker hung pre-boot`));
1441
+ }, ceilingMs);
1442
+ _workerReadyWaiters.set(waiterKey, (event) => { cleanup(); resolve(event); });
1443
+ for (const k of cancelKeys) {
1444
+ _workerReadyWaiters.set(k, () => {
1445
+ cleanup();
1446
+ reject(new Error(`worker lifecycle ended before ready signal (${k})`));
1447
+ });
1448
+ }
1449
+ });
1450
+ }
1451
+
1452
+ // AD-1 + AE-1: gate mission paste on event-driven pty-claim signal.
1453
+ // The signal is system.peer.connected{correlation_id} — published by the
1454
+ // extension when the worker's child mcp_server.js hellos (now eagerly at
1455
+ // startup; see AE-1 block in main()). On timeout, we abort cleanly and
1456
+ // publish system.worker.boot_failed. NO regex / pty-log fallback (rejected
1457
+ // by user 2026-05-16 as fragile across platforms — see audit
1458
+ // .local/audits/peer-connected-blackout-root-cause.md).
1459
+ async function _gatePasteOnClaudeClaim(sock, termId, corrId, opts) {
1460
+ const ceilingMs = (opts && opts.timeoutMs) || 120000;
1461
+ try {
1462
+ await _waitForWorkerReady(sock, corrId, { type: 'claude', timeoutMs: opts && opts.timeoutMs });
1463
+ await sleep(200); // event fires before paste pipeline is fully open
1464
+ return { booted: true, source: 'event' };
1465
+ } catch (e) {
1466
+ log(`paste-gate term=${termId} corr=${corrId}: BOOT FAILED — ${(e && e.message) || ('no event in ' + ceilingMs + 'ms')}`);
1467
+ try {
1468
+ await _pconnEnsureRegistered(sock);
1469
+ await _pconnWriteOrThrow({
1470
+ cmd: 'publish', protocol: 'claws/2',
1471
+ topic: 'system.worker.boot_failed',
1472
+ payload: {
1473
+ terminal_id: String(termId),
1474
+ correlation_id: corrId,
1475
+ cause: 'event_driven_boot_timeout',
1476
+ timeout_ms: ceilingMs,
1477
+ ts: new Date().toISOString(),
1478
+ },
1479
+ });
1480
+ } catch (_pe) { /* best-effort */ }
1481
+ return { booted: false, source: 'aborted', cause: 'event_driven_boot_timeout' };
1482
+ }
1483
+ }
1484
+
1485
+ async function runBlockingWorker(sock, args) {
1486
+ const DEFAULTS = {
1487
+ timeout_ms: 5 * 60 * 1000,
1488
+ // AE-6.b: boot_wait_ms intentionally absent from DEFAULTS. _waitForWorkerReady uses a
1489
+ // 120s safety ceiling — not a boot budget. Callers may pass explicit boot_wait_ms to override.
1490
+ // v0.7.9: was 'Claude Code' (with a space) — never matched the ANSI-stripped
1491
+ // Claude Code v2.x banner ("ClaudeCodev2.1.123" — no space). Worker burned the
1492
+ // full boot_wait_ms on every spawn before falling through. 'bypass permissions'
1493
+ // is in the bypass-mode footer/banner and matches reliably.
1494
+ boot_marker: 'bypass permissions',
1495
+ complete_marker: '__CLAWS_DONE__',
1496
+ error_markers: ['MISSION_FAILED'],
1497
+ poll_interval_ms: 1500,
1498
+ harvest_lines: 200,
1499
+ close_on_complete: true,
1500
+ model: 'claude-sonnet-4-6',
1501
+ };
1502
+ const opt = { ...DEFAULTS, ...args };
1503
+ if (!/^[a-zA-Z0-9._-]+$/.test(opt.model)) {
1504
+ return { status: 'error', error: `invalid model name '${opt.model}' — must match /^[a-zA-Z0-9._-]+$/` };
1505
+ }
1506
+ const hasMission = typeof args.mission === 'string' && args.mission.length > 0;
1507
+ const hasCommand = typeof args.command === 'string' && args.command.length > 0;
1508
+ const launchClaude = args.launch_claude !== undefined
1509
+ ? !!args.launch_claude
1510
+ : hasMission;
1511
+ // Default cwd to the MCP server's working directory (project root) so workers
1512
+ // never land in $HOME — which would trigger the trust dialog and break the
1513
+ // project MCP socket walk-up. Caller may override.
1514
+ const workerCwd = typeof args.cwd === 'string' && args.cwd.length > 0
1515
+ ? args.cwd
1516
+ : process.cwd();
1517
+
1518
+ // 1. Create wrapped terminal
1519
+ const _bCorrId = randomUUID();
1520
+ const cr = await clawsRpc(sock, {
1521
+ cmd: 'create', name: args.name || 'claws-worker', wrapped: true, show: true,
1522
+ cwd: workerCwd, env: { CLAWS_WORKER: '1' }, correlation_id: _bCorrId,
1523
+ });
1524
+ if (!cr.ok) return { status: 'error', error: `create failed: ${_lifecycleErrMsg(cr.error)}` };
1525
+ const termId = cr.id;
1526
+ _clearStaleCompletionSignals(termId);
1527
+ const startedAt = Date.now();
1528
+
1529
+ // Publish system.worker.spawned — best-effort, never aborts on failure.
1530
+ log(`runBlockingWorker: publishing system.worker.spawned for terminal ${termId}`);
1531
+ try {
1532
+ await _pconnEnsureRegistered(sock);
1533
+ await _pconnWriteOrThrow({
1534
+ cmd: 'publish', protocol: 'claws/2',
1535
+ topic: 'system.worker.spawned',
1536
+ payload: { terminal_id: termId, name: args.name || 'claws-worker', wrapped: true, started_at: new Date(startedAt).toISOString(), correlation_id: _bCorrId },
1537
+ });
1538
+ log(`runBlockingWorker: system.worker.spawned published ok for terminal ${termId}`);
1539
+ } catch (e) { log('runBlockingWorker: system.worker.spawned publish FAILED: ' + (e && e.message || e)); }
1540
+
1541
+ // D+F: register spawn + monitor atomically before proceeding. Best-effort (lifecycle may not be in active mission).
1542
+ try { await clawsRpc(sock, { cmd: 'lifecycle.register-spawn', terminalId: String(termId), correlationId: _bCorrId, name: args.name || 'claws-worker' }); } catch (e) { /* non-fatal */ }
1543
+ const _bMonitorCmd = `Monitor(command="node ${STREAM_EVENTS_JS_FOR_CMD} --wait ${_bCorrId} --keep-alive-on ${termId}", description="claws monitor | term=${termId} | corr=${_bCorrId.slice(0,8)} | sess=${new Date().toISOString().slice(0,13)}", timeout_ms=3600000, persistent=false)`;
1544
+ try { await clawsRpc(sock, { cmd: 'lifecycle.register-monitor', terminalId: String(termId), correlationId: _bCorrId, command: _bMonitorCmd }); } catch (e) { /* non-fatal */ }
1545
+ try { await clawsRpc(sock, { cmd: 'monitors.register-intent', correlation_id: _bCorrId }); } catch (_e) { /* non-fatal */ }
1546
+ // T4: monitor-arm grace warning — 5s after spawn, warn if no monitor registered in lifecycle.
1547
+ const _bMonitorGraceMs = 5000;
1548
+ const _bTermIdStr = String(termId);
1549
+ setTimeout(async () => {
1550
+ try {
1551
+ const snap = await clawsRpc(sock, { cmd: 'lifecycle.snapshot' });
1552
+ if (snap.ok && snap.state) {
1553
+ const hasMonitor = (snap.state.monitors || []).some(m => m.terminal_id === _bTermIdStr);
1554
+ if (!hasMonitor) {
1555
+ log(`T4-warn: runBlockingWorker term=${_bTermIdStr} corr=${_bCorrId} — no monitor registered within ${_bMonitorGraceMs}ms grace. Orchestrator may be flying blind. Use monitor_arm_command from spawn response.`);
1556
+ }
1557
+ }
1558
+ } catch (_e) { /* non-fatal */ }
1559
+ }, _bMonitorGraceMs);
1560
+ // Layer 2 (Bug-6 / Bug-13): 30s first check — true orphan vs arm-in-flight (two-stage state machine).
1561
+ setTimeout(async () => {
1562
+ const _l2f = (m) => { log(m); _logL2File(sock, m); };
1563
+ _l2f(`L2-DEBUG: callback-entered | site=runBlockingWorker | corrId=${_bCorrId} | termId=${_bTermIdStr}`);
1564
+ try {
1565
+ const armResp = await clawsRpc(sock, { cmd: 'monitors.is-corr-armed', correlation_id: _bCorrId });
1566
+ _l2f(`L2-DEBUG: rpc-result | site=runBlockingWorker | ok=${armResp.ok} | armed=${armResp.armed} | claimed=${armResp.claimed} | pending=${armResp.pending} | peerId=${armResp.peerId}`);
1567
+ if (!armResp.ok) return; // RPC error, can't decide
1568
+ if (armResp.claimed) {
1569
+ _l2f(`L2-DEBUG: skip-publish | site=runBlockingWorker | reason=claimed | corrId=${_bCorrId}`);
1570
+ return;
1571
+ }
1572
+ if (!armResp.pending) {
1573
+ // True orphan — neither intent nor execution registered.
1574
+ log(`L2-warn: runBlockingWorker term=${_bTermIdStr} corr=${_bCorrId} — no intent or execution at 30s. True orphan.`);
1575
+ _l2f(`L2-DEBUG: about-to-publish | site=runBlockingWorker | corrId=${_bCorrId} | reason=no-intent`);
1576
+ try {
1577
+ await _pconnEnsureRegistered(sock);
1578
+ _l2f(`L2-DEBUG: pconn-registered | site=runBlockingWorker`);
1579
+ await _pconnWriteOrThrow({
1580
+ cmd: 'publish', protocol: 'claws/2',
1581
+ topic: 'system.monitor.unarmed',
1582
+ payload: { terminal_id: _bTermIdStr, correlation_id: _bCorrId, layer: 2,
1583
+ detected_at: new Date().toISOString(), grace_ms: 30000, reason: 'no-intent' },
1584
+ });
1585
+ _l2f(`L2-DEBUG: publish-succeeded | site=runBlockingWorker | corrId=${_bCorrId}`);
1586
+ } catch (_pe) {
1587
+ _l2f(`L2-DEBUG: publish-failed | site=runBlockingWorker | err=${_pe && _pe.message || _pe}`);
1588
+ }
1589
+ return;
1590
+ }
1591
+ // pending=true, claimed=false — arm in flight. Re-check at +60s.
1592
+ _l2f(`L2-DEBUG: arm-in-flight | site=runBlockingWorker | corrId=${_bCorrId} | scheduling-recheck-60s`);
1593
+ setTimeout(async () => {
1594
+ _l2f(`L2-DEBUG: recheck-entered | site=runBlockingWorker | corrId=${_bCorrId} | termId=${_bTermIdStr}`);
1595
+ try {
1596
+ const r2 = await clawsRpc(sock, { cmd: 'monitors.is-corr-armed', correlation_id: _bCorrId });
1597
+ _l2f(`L2-DEBUG: recheck-rpc-result | site=runBlockingWorker | ok=${r2.ok} | armed=${r2.armed} | claimed=${r2.claimed} | pending=${r2.pending} | peerId=${r2.peerId}`);
1598
+ if (!r2.ok || r2.claimed) {
1599
+ _l2f(`L2-DEBUG: recheck-skip | site=runBlockingWorker | reason=${!r2.ok ? 'rpc-error' : 'claimed'}`);
1600
+ return;
1601
+ }
1602
+ const reason = r2.pending ? 'pending-timeout' : 'pending-vacated';
1603
+ log(`L2-warn: runBlockingWorker term=${_bTermIdStr} corr=${_bCorrId} — ${reason} at 90s. Publishing unarmed.`);
1604
+ _l2f(`L2-DEBUG: about-to-publish | site=runBlockingWorker | corrId=${_bCorrId} | reason=${reason}`);
1605
+ try {
1606
+ await _pconnEnsureRegistered(sock);
1607
+ _l2f(`L2-DEBUG: pconn-registered | site=runBlockingWorker`);
1608
+ await _pconnWriteOrThrow({
1609
+ cmd: 'publish', protocol: 'claws/2',
1610
+ topic: 'system.monitor.unarmed',
1611
+ payload: { terminal_id: _bTermIdStr, correlation_id: _bCorrId, layer: 2,
1612
+ detected_at: new Date().toISOString(), grace_ms: 90000, reason },
1613
+ });
1614
+ _l2f(`L2-DEBUG: publish-succeeded | site=runBlockingWorker | corrId=${_bCorrId}`);
1615
+ } catch (_pe) {
1616
+ _l2f(`L2-DEBUG: publish-failed | site=runBlockingWorker | err=${_pe && _pe.message || _pe}`);
1617
+ }
1618
+ } catch (_e) { _l2f(`L2-DEBUG: recheck-error | site=runBlockingWorker | err=${_e && _e.message || _e}`); }
1619
+ }, 60000);
1620
+ } catch (_e) { _l2f(`L2-DEBUG: outer-error | site=runBlockingWorker | err=${_e && _e.message || _e}`); }
1621
+ }, 30000);
1622
+
1623
+ // 2. Give shell a moment to emit prompt
1624
+ await sleep(400);
1625
+
1626
+ // 3. Optional claude boot + detection — single attempt, event-driven.
1627
+ // Sends the launch command ONCE, waits for system.peer.connected event keyed
1628
+ // by _bCorrId. AE-6.b: 120s safety ceiling, early-abort on terminal.closed/terminated.
1629
+ let booted = !launchClaude;
1630
+ if (launchClaude) {
1631
+ await clawsRpc(sock, {
1632
+ cmd: 'send', id: termId,
1633
+ text: `${getClaudeBin(args && args.cwd)} --dangerously-skip-permissions --model ${opt.model}`, newline: true,
1634
+ });
1635
+ // AD-1 + AE-1: gate mission paste on confirmed event-driven pty-claim.
1636
+ const _gate = await _gatePasteOnClaudeClaim(sock, termId, _bCorrId, { timeoutMs: opt.boot_wait_ms });
1637
+ if (!_gate.booted) {
1638
+ if (opt.close_on_complete) { try { await clawsRpc(sock, { cmd: 'close', id: termId }); } catch {} }
1639
+ return { status: 'error', error: `boot failed: ${_gate.cause}`, terminal_id: termId, correlation_id: _bCorrId };
1640
+ }
1641
+ booted = true;
1642
+ // BUG-07: secondary MCP auth check — bypass banner can appear before /mcp auth resolves.
1643
+ if (booted) {
1644
+ const _authSnap = await clawsRpc(sock, { cmd: 'readLog', id: termId, strip: true, limit: 32 * 1024 });
1645
+ if (_authSnap.ok && typeof _authSnap.bytes === 'string' &&
1646
+ (_authSnap.bytes.includes('MCP server need') || _authSnap.bytes.includes(' /mcp'))) {
1647
+ if (opt.close_on_complete) try { await clawsRpc(sock, { cmd: 'close', id: termId }); } catch {}
1648
+ return { status: 'error', error: `boot_marker seen but MCP auth banner active on terminal ${termId} — prompt is blocked. Spawn with minimal MCP config or pre-auth.` };
1649
+ }
1650
+ }
1651
+ }
1652
+
1653
+ // 4. Send the mission AS A USER PROMPT — direct, the way it works in v0.7.4.
1654
+ // ──────────────────────────────────────────────────────────────────────────
1655
+ // No file abstractions. The user's mission text becomes Claude Code's input
1656
+ // exactly as if a human typed it, because that's what users expect.
1657
+ //
1658
+ // DO NOT add a file-referrer pattern here. If a future contributor is tempted
1659
+ // to write the mission to /tmp and send a "Read <file>..." referrer to dodge
1660
+ // some pty quirk, the answer is NO. The mission is a user prompt. Period.
1661
+ // Strip bracketed-paste escape sequences so a crafted mission cannot break out of paste mode.
1662
+ // Phase 4a: one-line completion hint — claws_done() handles the rest.
1663
+ const _phase4Header = hasMission ? `When you're finished, call \`claws_done()\`. That's it.\n\n---\n` : '';
1664
+ const safeMission = hasMission
1665
+ ? (_phase4Header + args.mission).replace(/\x1b\[200~/g, '').replace(/\x1b\[201~/g, '')
1666
+ : args.mission;
1667
+ // ROOT CAUSE FIX (2026-05-02): mission augmentation with CLAWS_PUB preamble was
1668
+ // breaking Claude TUI multi-line submission. Preamble added ~5 extra lines +
1669
+ // special chars (single quotes around topics), pushing total text past Claude's
1670
+ // paste-collapse threshold. Multi-line input rendered expanded; single \r
1671
+ // treated as newline-within-input, never submitted.
1672
+ // Verified: identical mission via claws_send (no augmentation) submits ✓.
1673
+ // Send mission DIRECTLY, no preamble.
1674
+ const payload = hasMission ? safeMission
1675
+ : hasCommand ? wrapShellCommand(args.command, termId)
1676
+ : '';
1677
+
1678
+ // W8k-1: delegate to shared helper — same proven sequence as claws_worker fast path.
1679
+ // newline:false + 300ms + explicit \r submits reliably on Mac pty and Windows ConPTY;
1680
+ // the old newline:true (internal 30ms CR) left fleet missions unsubmitted on ConPTY.
1681
+ const markerScanFrom = await _sendAndSubmitMission(sock, termId, _bCorrId, payload, launchClaude);
1682
+
1683
+ // 5. Detach shortcut — register background watcher that runs the SAME poll
1684
+ // body as the blocking path so [CLAWS_PUB] scanner, system.worker.completed
1685
+ // publish, and auto-close all keep working in detach mode. Server-side only,
1686
+ // never holds the MCP socket.
1687
+ if (args.detach === true) {
1688
+ let pubScanOffset = markerScanFrom;
1689
+ let status = 'timeout';
1690
+ let markerLine = null;
1691
+ let completionSignal = null;
1692
+ const _msState = { firstActivityAt: null, lastLen: 0, lastGrowthAt: Date.now(), startedAt };
1693
+ const timeoutDeadline = startedAt + opt.timeout_ms;
1694
+ const tick = async () => {
1695
+ try {
1696
+ if (Date.now() > timeoutDeadline) {
1697
+ clearInterval(intervalId);
1698
+ _detachWatchers.delete(termId);
1699
+ try {
1700
+ await _pconnEnsureRegistered(sock);
1701
+ await _pconnWriteOrThrow({
1702
+ cmd: 'publish', protocol: 'claws/2',
1703
+ topic: 'system.worker.completed',
1704
+ payload: { terminal_id: termId, status, duration_ms: Date.now() - startedAt, marker_line: markerLine, booted, detach: true, correlation_id: _bCorrId, completion_signal: completionSignal },
1705
+ });
1706
+ } catch (e) { log('detach watcher publish failed: ' + (e && e.message || e)); }
1707
+ try { await clawsRpc(sock, { cmd: 'lifecycle.mark-worker-status', terminalId: String(termId), status: 'timeout' }); } catch (e) { /* non-fatal */ }
1708
+ return;
1709
+ }
1710
+ const snap = await clawsRpc(sock, { cmd: 'readLog', id: termId, strip: true, limit: 64 * 1024 });
1711
+ const text = snap.ok && typeof snap.bytes === 'string' ? snap.bytes : '';
1712
+ const scanText = text.length > markerScanFrom ? text.slice(markerScanFrom) : '';
1713
+ const scanStart = Math.min(pubScanOffset, text.length);
1714
+ if (text.length > scanStart) await _scanAndPublishCLAWSPUB(text.slice(scanStart), sock);
1715
+ pubScanOffset = text.length;
1716
+ const _now = Date.now();
1717
+ if (text.length > _msState.lastLen) {
1718
+ if (_msState.firstActivityAt === null) _msState.firstActivityAt = _now;
1719
+ _msState.lastGrowthAt = _now;
1720
+ _msState.lastLen = text.length;
1721
+ }
1722
+ const _det = detectCompletion(scanText, opt, String(termId), _workerTerminatedSet, _workerCompletedViaBusSet);
1723
+ if (_det !== null) {
1724
+ status = _det.status;
1725
+ markerLine = _det.line;
1726
+ completionSignal = _det.signal;
1727
+ }
1728
+ if (status !== 'timeout') {
1729
+ clearInterval(intervalId);
1730
+ _detachWatchers.delete(termId);
1731
+ try {
1732
+ await _pconnEnsureRegistered(sock);
1733
+ await _pconnWriteOrThrow({
1734
+ cmd: 'publish', protocol: 'claws/2',
1735
+ topic: 'system.worker.completed',
1736
+ payload: { terminal_id: termId, status, duration_ms: Date.now() - startedAt, marker_line: markerLine, booted, detach: true, correlation_id: _bCorrId, completion_signal: completionSignal },
1737
+ });
1738
+ } catch (e) { log('detach watcher publish failed: ' + (e && e.message || e)); }
1739
+ try { await clawsRpc(sock, { cmd: 'lifecycle.mark-worker-status', terminalId: String(termId), status }); } catch (e) { /* non-fatal */ }
1740
+ if (opt.close_on_complete !== false) {
1741
+ const _bCloseOrigin = status === 'completed' ? 'marker' : status === 'failed' ? 'error' : status === 'timeout' ? 'timeout' : 'orchestrator';
1742
+ try { await clawsRpc(sock, { cmd: 'close', id: termId, close_origin: _bCloseOrigin }); } catch {}
1743
+ // BUG-B fix: notify lifecycle store so workers[].closed flips to true.
1744
+ try { await clawsRpc(sock, { cmd: 'lifecycle.mark-worker-status', terminalId: String(termId), status: 'closed' }); } catch {}
1745
+ }
1746
+ }
1747
+ } catch (e) { log('detach watcher tick error: ' + (e && e.message || e)); }
1748
+ };
1749
+ const intervalId = setInterval(tick, opt.poll_interval_ms);
1750
+ _detachWatchers.set(termId, { intervalId, opt, startedAt });
1751
+ return { status: 'spawned', terminal_id: termId, booted, duration_ms: Date.now() - startedAt, correlation_id: _bCorrId };
1752
+ }
1753
+
1754
+ // 6. Poll for completion / errors / timeout
1755
+ const timeoutDeadline = startedAt + opt.timeout_ms;
1756
+ let status = 'timeout';
1757
+ let markerLine = null;
1758
+ let completionSignal = null;
1759
+ let pubScanOffset = markerScanFrom; // start CLAWS_PUB scan from the same offset
1760
+
1761
+ while (Date.now() < timeoutDeadline) {
1762
+ const snap = await clawsRpc(sock, {
1763
+ cmd: 'readLog', id: termId, strip: true, limit: 64 * 1024,
1764
+ });
1765
+ const text = snap.ok && typeof snap.bytes === 'string' ? snap.bytes : '';
1766
+
1767
+ // Only scan bytes produced AFTER the payload was sent + echoed. This
1768
+ // prevents the marker substring from false-matching on the input echo
1769
+ // of the user's mission text.
1770
+ const scanText = text.length > markerScanFrom ? text.slice(markerScanFrom) : '';
1771
+
1772
+ // Scan lines added since the last poll tick for [CLAWS_PUB] publish markers.
1773
+ const scanStart = Math.min(pubScanOffset, text.length);
1774
+ if (text.length > scanStart) {
1775
+ await _scanAndPublishCLAWSPUB(text.slice(scanStart), sock);
1776
+ }
1777
+ pubScanOffset = text.length;
1778
+
1779
+ const _bNow = Date.now();
1780
+ const _bDet = detectCompletion(scanText, opt, String(termId), _workerTerminatedSet, _workerCompletedViaBusSet);
1781
+ if (_bDet !== null) {
1782
+ status = _bDet.status;
1783
+ markerLine = _bDet.line;
1784
+ completionSignal = _bDet.signal;
1785
+ break;
1786
+ }
1787
+
1788
+ await sleep(opt.poll_interval_ms);
1789
+ }
1790
+
1791
+ // Publish system.worker.completed — best-effort, never aborts on failure.
1792
+ try {
1793
+ await _pconnEnsureRegistered(sock);
1794
+ await _pconnWriteOrThrow({
1795
+ cmd: 'publish', protocol: 'claws/2',
1796
+ topic: 'system.worker.completed',
1797
+ payload: { terminal_id: termId, status, duration_ms: Date.now() - startedAt, marker_line: markerLine, booted, correlation_id: _bCorrId, completion_signal: completionSignal },
1798
+ });
1799
+ } catch (e) { log('system.worker.completed publish failed: ' + (e && e.message || e)); }
1800
+ try { await clawsRpc(sock, { cmd: 'lifecycle.mark-worker-status', terminalId: String(termId), status }); } catch (e) { /* non-fatal */ }
1801
+
1802
+ // 7. Harvest final output
1803
+ const final = await clawsRpc(sock, {
1804
+ cmd: 'readLog', id: termId, strip: true, limit: 256 * 1024,
1805
+ });
1806
+ const allLines = (final.ok && typeof final.bytes === 'string' ? final.bytes : '').split('\n');
1807
+ const harvest = allLines.slice(-opt.harvest_lines).join('\n');
1808
+
1809
+ // 8. Auto-close
1810
+ let cleanedUp = false;
1811
+ if (opt.close_on_complete) {
1812
+ const _rbCloseOrigin = status === 'completed' ? 'marker' : status === 'failed' ? 'error' : status === 'timeout' ? 'timeout' : 'orchestrator';
1813
+ const cl = await clawsRpc(sock, { cmd: 'close', id: termId, close_origin: _rbCloseOrigin });
1814
+ cleanedUp = !!cl.ok;
1815
+ // BUG-B fix: notify lifecycle store so workers[].closed flips to true.
1816
+ try { await clawsRpc(sock, { cmd: 'lifecycle.mark-worker-status', terminalId: String(termId), status: 'closed' }); } catch {}
1817
+ }
1818
+
1819
+ return {
1820
+ status,
1821
+ terminal_id: termId,
1822
+ booted,
1823
+ duration_ms: Date.now() - startedAt,
1824
+ marker_line: markerLine,
1825
+ cleaned_up: cleanedUp,
1826
+ harvest,
1827
+ correlation_id: _bCorrId,
1828
+ completion_signal: completionSignal,
1829
+ };
1830
+ }
1831
+
1832
+ // ─── Sidecar auto-spawn ───────────────────────────────────────────────────────
1833
+ let _sidecarPid = null;
1834
+ let _sidecarSubscribed = false; // true only after sidecar.subscribed JSON confirmed
1835
+ let _sidecarStdout = null; // kept open to prevent SIGPIPE
1836
+ let _sidecarEnsureInFlight = null; // singleton in-flight Promise dedup
1837
+
1838
+ function _isSidecarAlive() {
1839
+ if (!_sidecarPid || !_sidecarSubscribed) return false;
1840
+ try { process.kill(_sidecarPid, 0); return true; } catch { return false; }
1841
+ }
1842
+
1843
+ async function _spawnAndVerifySidecar(maxWaitMs = 3000) {
1844
+ const { spawn, spawnSync } = require('child_process');
1845
+ // BUG-19 fix: __dirname resolves to repo root when .claws-bin/mcp_server.js is symlinked
1846
+ // back to ../mcp_server.js (Wave A's dedup). Try multiple candidate locations.
1847
+ const sidecarCandidates = [
1848
+ path.join(__dirname, 'stream-events.js'), // .claws-bin layout (non-symlinked install)
1849
+ path.join(__dirname, '.claws-bin', 'stream-events.js'), // repo root + .claws-bin
1850
+ path.join(__dirname, 'scripts', 'stream-events.js'), // repo root + scripts (dev source)
1851
+ ];
1852
+ const sidecarPath = sidecarCandidates.find(p => fs.existsSync(p));
1853
+ if (!sidecarPath) throw new Error('stream-events.js not found in any candidate: ' + sidecarCandidates.join(', '));
1854
+
1855
+ // GAP-A1: detect existing auto-sidecar (spawned by session-start hook) to avoid
1856
+ // two concurrent sidecars both writing to events.log, causing 4x event duplication.
1857
+ const socketPath = getSocket();
1858
+
1859
+ // W8Q3-3: on win32 pgrep is unavailable (ENOENT); use a pid-file instead.
1860
+ const pidFile = process.platform === 'win32'
1861
+ ? path.join(
1862
+ process.env.CLAWS_WORKSPACE_ROOT
1863
+ || _findWorkspaceRoot(process.cwd())
1864
+ || _findWorkspaceRoot(__dirname)
1865
+ || os.tmpdir(),
1866
+ '.claws', 'sidecar.pid')
1867
+ : null;
1868
+ if (process.platform === 'win32' && pidFile && fs.existsSync(pidFile)) {
1869
+ try {
1870
+ const existingPid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
1871
+ process.kill(existingPid, 0); // signal 0: liveness probe — throws if dead
1872
+ log('[sidecar] adopting via pid-file pid=' + existingPid + ' (W8Q3-3 dedup)');
1873
+ _sidecarPid = existingPid;
1874
+ _sidecarSubscribed = true;
1875
+ return;
1876
+ } catch {
1877
+ // stale pidfile — remove and fall through to spawn
1878
+ try { fs.unlinkSync(pidFile); } catch {}
1879
+ }
1880
+ }
1881
+
1882
+ const escapedSocket = socketPath.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
1883
+ const pgResult = spawnSync('pgrep', ['-f', `stream-events\\.js.*--auto-sidecar.*${escapedSocket}`], { encoding: 'utf8' });
1884
+ if (pgResult.status === 0) {
1885
+ const existingPid = parseInt((pgResult.stdout || '').trim().split('\n')[0], 10);
1886
+ if (!isNaN(existingPid)) {
1887
+ log(`[sidecar] adopting existing auto-sidecar pid=${existingPid} (GAP-A1 dedup)`);
1888
+ _sidecarPid = existingPid;
1889
+ _sidecarSubscribed = true;
1890
+ return;
1891
+ }
1892
+ }
1893
+
1894
+ // Open events.log for appending — after verification confirmed, stdout is
1895
+ // piped here so every sidecar push frame is persisted for tail-F monitoring.
1896
+ const eventsLogFilePath = _eventsLogPath(socketPath);
1897
+ const eventsLogStream = fs.createWriteStream(eventsLogFilePath, { flags: 'a' });
1898
+
1899
+ // GAP-A1: pass --auto-sidecar + socketPath as args so session-start pgrep can detect us.
1900
+ const child = spawn(process.execPath, [sidecarPath, '--auto-sidecar', socketPath], {
1901
+ detached: true,
1902
+ stdio: ['ignore', 'pipe', 'ignore'],
1903
+ env: { ...process.env, CLAWS_TOPIC: '**', CLAWS_PEER_NAME: 'auto-sidecar', CLAWS_ROLE: 'observer' },
1904
+ });
1905
+
1906
+ if (!child.pid) throw new Error('sidecar spawn returned no pid (ENOENT or permission denied)');
1907
+ _sidecarPid = child.pid;
1908
+ _sidecarStdout = child.stdout;
1909
+ if (process.platform === 'win32' && pidFile) {
1910
+ try { fs.mkdirSync(path.dirname(pidFile), { recursive: true }); } catch {}
1911
+ fs.writeFileSync(pidFile, String(child.pid));
1912
+ }
1913
+
1914
+ // Named drain — removed after verification so stdout can be piped to events.log.
1915
+ function drainHandler() {}
1916
+ child.stdout.on('data', drainHandler);
1917
+ child.on('exit', (code) => {
1918
+ log(`[sidecar] exited code=${code}`);
1919
+ eventsLogStream.end();
1920
+ if (process.platform === 'win32' && pidFile) { try { fs.unlinkSync(pidFile); } catch {} }
1921
+ _sidecarPid = null;
1922
+ _sidecarSubscribed = false;
1923
+ _sidecarStdout = null;
1924
+ });
1925
+
1926
+ return new Promise((resolve, reject) => {
1927
+ const timer = setTimeout(() => {
1928
+ child.stdout.removeListener('data', verifyHandler);
1929
+ reject(new Error(`sidecar did not reach SUBSCRIBED within ${maxWaitMs}ms`));
1930
+ }, maxWaitMs);
1931
+
1932
+ let buf = '';
1933
+ function verifyHandler(chunk) {
1934
+ buf += chunk.toString('utf8');
1935
+ let nl;
1936
+ while ((nl = buf.indexOf('\n')) !== -1) {
1937
+ const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
1938
+ try {
1939
+ const obj = JSON.parse(line);
1940
+ if (obj.type === 'sidecar.subscribed' && obj.ok !== false) {
1941
+ clearTimeout(timer);
1942
+ child.stdout.removeListener('data', verifyHandler);
1943
+ child.stdout.removeListener('data', drainHandler);
1944
+ child.stdout.pipe(eventsLogStream);
1945
+ child.unref();
1946
+ _sidecarSubscribed = true;
1947
+ log(`[sidecar] SUBSCRIBED confirmed — piping stdout to ${eventsLogFilePath}`);
1948
+ resolve();
1949
+ return;
1950
+ }
1951
+ if (obj.type === 'sidecar.error') {
1952
+ clearTimeout(timer);
1953
+ child.stdout.removeListener('data', verifyHandler);
1954
+ reject(new Error('sidecar error: ' + obj.error));
1955
+ return;
1956
+ }
1957
+ } catch { /* incomplete JSON line, skip */ }
1958
+ }
1959
+ }
1960
+ child.stdout.on('data', verifyHandler);
1961
+
1962
+ child.on('exit', (code) => {
1963
+ clearTimeout(timer);
1964
+ child.stdout.removeListener('data', verifyHandler);
1965
+ if (code !== 0) reject(new Error(`sidecar crashed (exit ${code}) before reaching SUBSCRIBED`));
1966
+ });
1967
+ });
1968
+ }
1969
+
1970
+ async function _ensureSidecarOrThrow(maxWaitMs = 3000) {
1971
+ if (_isSidecarAlive()) return;
1972
+ if (!_sidecarEnsureInFlight) {
1973
+ _sidecarEnsureInFlight = _spawnAndVerifySidecar(maxWaitMs)
1974
+ .finally(() => { _sidecarEnsureInFlight = null; });
1975
+ }
1976
+ await _sidecarEnsureInFlight;
1977
+ }
1978
+
1979
+ // ─── Persistent-socket identity helper ────────────────────────────────────────
1980
+ // Ensures the persistent socket is connected AND this process has a peerId.
1981
+ // If not yet registered, sends hello with role=orchestrator, peerName=mcp-orchestrator.
1982
+ // Called once per process lifetime in practice (peerId cached on _pconn).
1983
+ async function _pconnEnsureRegistered(sockPath) {
1984
+ // 30s failure cache: skip reconnect attempt entirely if we failed recently.
1985
+ if (!_pconn.connected && _circuitBreaker.lastFailureTs > 0) {
1986
+ if (Date.now() - _circuitBreaker.lastFailureTs < 30_000) {
1987
+ throw new Error('circuit-breaker: last connect failed < 30s ago, skipping');
1988
+ }
1989
+ }
1990
+ try {
1991
+ await _pconnEnsure(sockPath);
1992
+ } catch (err) {
1993
+ _circuitBreaker.lastFailureTs = Date.now();
1994
+ throw err;
1995
+ }
1996
+ // Reset failure timestamp on successful connect.
1997
+ _circuitBreaker.lastFailureTs = 0;
1998
+ // Socket identity guard: skip hello ONLY if peerId is set AND it was negotiated on
1999
+ // the current socket. If the socket disconnected and reconnected, socketId changes
2000
+ // but helloSocketId does not — the mismatch forces a re-hello on the new socket.
2001
+ if (_pconn.peerId && _pconn.helloSocketId === _pconn.socketId) {
2002
+ // valid registration on the current socket — skip hello
2003
+ } else {
2004
+ // Either no peerId, or hello was on a previous (now-closed) socket — re-hello required.
2005
+ _pconn.peerId = null;
2006
+ _pconn.helloSocketId = null;
2007
+ log(`PCONN-DEBUG: about-to-hello | socketId=${_pconn.socketId} | helloSocketId=${_pconn.helloSocketId}`);
2008
+ if (!_helloInFlight) {
2009
+ _helloInFlight = _pconnWrite({
2010
+ cmd: 'hello', protocol: 'claws/2',
2011
+ role: 'orchestrator', peerName: 'mcp-orchestrator',
2012
+ ...(process.env.CLAWS_TERMINAL_CORR_ID ? { correlation_id: process.env.CLAWS_TERMINAL_CORR_ID } : {}),
2013
+ }, 5000).finally(() => { _helloInFlight = null; });
2014
+ }
2015
+ let hr = await _helloInFlight;
2016
+ log(`PCONN-DEBUG: hello-ack | ok=${hr && hr.ok} | peerId=${hr && hr.peerId} | error=${hr && hr.error}`);
2017
+ // Retry-on-already-registered: extension may still have the old peer in its Map
2018
+ // if the closed socket's FIN hasn't been processed by its event loop yet.
2019
+ // Exponential backoff gives the extension time to delete the stale peer before each retry.
2020
+ let _helloRetries = 0;
2021
+ while (hr && hr.ok === false && /already registered/i.test(hr.error || '') && _helloRetries < 3) {
2022
+ _helloRetries++;
2023
+ const _retryDelay = 100 * Math.pow(2, _helloRetries - 1); // 100, 200, 400 ms
2024
+ log(`PCONN-DEBUG: hello-retry | attempt=${_helloRetries} | delay=${_retryDelay}ms | last_error=${hr.error}`);
2025
+ await new Promise(r => setTimeout(r, _retryDelay));
2026
+ if (!_helloInFlight) {
2027
+ _helloInFlight = _pconnWrite({
2028
+ cmd: 'hello', protocol: 'claws/2', role: 'orchestrator', peerName: 'mcp-orchestrator',
2029
+ ...(process.env.CLAWS_TERMINAL_CORR_ID ? { correlation_id: process.env.CLAWS_TERMINAL_CORR_ID } : {}),
2030
+ }, 5000).finally(() => { _helloInFlight = null; });
2031
+ }
2032
+ hr = await _helloInFlight;
2033
+ log(`PCONN-DEBUG: hello-ack | ok=${hr && hr.ok} | peerId=${hr && hr.peerId} | error=${hr && hr.error}`);
2034
+ }
2035
+ // Verify hello actually succeeded — silent failure is the root cause of Bug 12.
2036
+ // If the extension returned ok:false (e.g., 'root orchestrator already registered' race),
2037
+ // throw so callers see the failure instead of proceeding with peerId=null.
2038
+ if (!hr || hr.ok !== true) {
2039
+ const err = (hr && hr.error) || 'no response';
2040
+ throw new Error(`pconn hello failed: ${err}${_helloRetries > 0 ? ` (after ${_helloRetries} retries)` : ''}`);
2041
+ }
2042
+ // Capture identity and bind it to the current socket.
2043
+ _pconn.peerId = hr.peerId;
2044
+ _pconn.helloSocketId = _pconn.socketId; // hello succeeded on THIS socket
2045
+ _pconn.role = 'orchestrator';
2046
+ _pconn.peerName = 'mcp-orchestrator';
2047
+ // Re-enable scan if it was disabled — explicit reconnect counts as resume.
2048
+ _circuitBreaker.scanDisabled = false;
2049
+ _circuitBreaker.scanConsecutiveErrors = 0;
2050
+ log('auto-registered as peer ' + hr.peerId + ' role=orchestrator socketId=' + _pconn.socketId);
2051
+ }
2052
+ // Wave D: subscribe to system.worker.terminated once registered so push frames
2053
+ // arrive and populate _workerTerminatedSet for the terminated completion signal.
2054
+ if (_pconn.peerId && !_workerTerminatedSubscribed) {
2055
+ try {
2056
+ await _pconnWriteOrThrow({ cmd: 'subscribe', protocol: 'claws/2', topic: 'system.worker.terminated' }, 5000);
2057
+ _workerTerminatedSubscribed = true;
2058
+ } catch { /* non-fatal; watcher falls back to marker/error/pub_complete signals */ }
2059
+ }
2060
+ // AF-AC Phase 1: subscribe to system.worker.process_exited — new primary completion signal.
2061
+ if (_pconn.peerId && !_workerProcessExitedSubscribed) {
2062
+ try {
2063
+ await _pconnWriteOrThrow({ cmd: 'subscribe', protocol: 'claws/2', topic: 'system.worker.process_exited' }, 5000);
2064
+ _workerProcessExitedSubscribed = true;
2065
+ } catch { /* non-fatal; existing detach watcher layers remain active */ }
2066
+ }
2067
+ // Phase 4a: subscribe to worker.+.complete wildcard so bus-published completions
2068
+ // populate _workerCompletedViaBusSet for the highest-priority completion signal.
2069
+ if (_pconn.peerId && !_workerBusCompletedSubscribed) {
2070
+ try {
2071
+ await _pconnWriteOrThrow({ cmd: 'subscribe', protocol: 'claws/2', topic: 'worker.+.complete' }, 5000);
2072
+ _workerBusCompletedSubscribed = true;
2073
+ } catch { /* non-fatal */ }
2074
+ }
2075
+ // W8ac-2: subscribe to boot-readiness events so _waitForWorkerReady listeners resolve.
2076
+ if (_pconn.peerId && !_workerPeerConnectedSubscribed) {
2077
+ try {
2078
+ await _pconnWriteOrThrow({ cmd: 'subscribe', protocol: 'claws/2', topic: 'system.peer.connected' }, 5000);
2079
+ _workerPeerConnectedSubscribed = true;
2080
+ } catch { /* non-fatal */ }
2081
+ }
2082
+ if (_pconn.peerId && !_workerTerminalReadySubscribed) {
2083
+ try {
2084
+ await _pconnWriteOrThrow({ cmd: 'subscribe', protocol: 'claws/2', topic: 'system.terminal.ready' }, 5000);
2085
+ _workerTerminalReadySubscribed = true;
2086
+ } catch { /* non-fatal */ }
2087
+ }
2088
+ // AE-7: subscribe to submit confirmation event topics.
2089
+ if (_pconn.peerId && !_vehicleContentSubscribed) {
2090
+ try {
2091
+ await _pconnWriteOrThrow({ cmd: 'subscribe', protocol: 'claws/2', topic: 'vehicle.+.content' }, 5000);
2092
+ _vehicleContentSubscribed = true;
2093
+ } catch { /* non-fatal */ }
2094
+ }
2095
+ if (_pconn.peerId && !_toolInvokedSubscribed) {
2096
+ try {
2097
+ await _pconnWriteOrThrow({ cmd: 'subscribe', protocol: 'claws/2', topic: 'tool.+.invoked' }, 5000);
2098
+ _toolInvokedSubscribed = true;
2099
+ } catch { /* non-fatal */ }
2100
+ }
2101
+ }
2102
+
2103
+ // ─── Tool handlers ─────────────────────────────────────────────────────────
2104
+
2105
+ // Compute a named pipe path from a workspace root path (win32 only).
2106
+ // Same algorithm as extension/src/transport.ts getServerEndpoint().
2107
+ function _winPipeName(workspaceRoot) {
2108
+ const hash = require('crypto')
2109
+ .createHash('sha256')
2110
+ .update(workspaceRoot.toLowerCase())
2111
+ .digest('hex')
2112
+ .slice(0, 8);
2113
+ return `\\\\.\\pipe\\claws-${hash}`;
2114
+ }
2115
+
2116
+ // Walk up from a starting directory looking for a .claws/ directory.
2117
+ // Used on win32 to locate the workspace root (pipes have no filesystem presence).
2118
+ function _findWorkspaceRoot(startDir) {
2119
+ let dir = startDir;
2120
+ while (dir) {
2121
+ try { if (fs.statSync(path.join(dir, '.claws')).isDirectory()) return dir; } catch {}
2122
+ const parent = path.dirname(dir);
2123
+ if (parent === dir) break;
2124
+ dir = parent;
2125
+ }
2126
+ return null;
2127
+ }
2128
+
2129
+ // Return the filesystem path to .claws/events.log regardless of platform.
2130
+ // On win32 sock is a named pipe (\\.\pipe\...), not a filesystem path, so we
2131
+ // derive the workspace root via env var or directory walk instead.
2132
+ function _eventsLogPath(sock) {
2133
+ if (process.platform === 'win32') {
2134
+ const root = process.env.CLAWS_WORKSPACE_ROOT
2135
+ || _findWorkspaceRoot(process.cwd())
2136
+ || _findWorkspaceRoot(__dirname)
2137
+ || process.cwd();
2138
+ return path.join(root, '.claws', 'events.log');
2139
+ }
2140
+ return path.join(path.dirname(path.resolve(sock)), 'events.log');
2141
+ }
2142
+
2143
+ function getSocket() {
2144
+ // Absolute override wins immediately.
2145
+ const envSock = process.env.CLAWS_SOCKET;
2146
+ if (envSock && path.isAbsolute(envSock)) return envSock;
2147
+
2148
+ if (process.platform === 'win32') {
2149
+ // Named pipes have no filesystem presence — derive the name from the
2150
+ // workspace root path using the same sha256[0:8] algorithm as transport.ts.
2151
+ const workspaceRoot = process.env.CLAWS_WORKSPACE_ROOT
2152
+ || _findWorkspaceRoot(process.cwd())
2153
+ || _findWorkspaceRoot(__dirname)
2154
+ || process.cwd();
2155
+ return _winPipeName(workspaceRoot);
2156
+ }
2157
+
2158
+ // Mac / Linux: walk up from CWD to find .claws/claws.sock.
2159
+ // Works when Claude Code chdir'd to the workspace root before spawning the MCP server.
2160
+ for (let dir = process.cwd();;) {
2161
+ const candidate = path.join(dir, '.claws', 'claws.sock');
2162
+ try { if (fs.statSync(candidate).isSocket()) return candidate; } catch {}
2163
+ const parent = path.dirname(dir);
2164
+ if (parent === dir) break;
2165
+ dir = parent;
2166
+ }
2167
+
2168
+ // Walk up from __dirname — self-locating regardless of CWD.
2169
+ // Project install: .claws-bin/mcp_server.js -> parent = project root -> .claws/claws.sock
2170
+ // Global install: ~/.claws-src/mcp_server.js -> walks up, finds nothing, falls through.
2171
+ for (let dir = __dirname;;) {
2172
+ const candidate = path.join(dir, '.claws', 'claws.sock');
2173
+ try { if (fs.statSync(candidate).isSocket()) return candidate; } catch {}
2174
+ const parent = path.dirname(dir);
2175
+ if (parent === dir) break;
2176
+ dir = parent;
2177
+ }
2178
+
2179
+ // Fall back to env var (may be relative) or the conventional default.
2180
+ return envSock || '.claws/claws.sock';
2181
+ }
2182
+
2183
+ const _detachWatchers = new Map(); // termId -> { intervalId, opt, startedAt }
2184
+
2185
+ // Safety guard: cap any single blocking tool response to 8 s over MCP stdio.
2186
+ async function withMaxHold(promise, maxMs = 8000, partialBuilder) {
2187
+ let timer;
2188
+ const timeout = new Promise(r => { timer = setTimeout(() => r({ __maxHold: true }), maxMs); });
2189
+ const winner = await Promise.race([promise, timeout]);
2190
+ clearTimeout(timer);
2191
+ if (winner && winner.__maxHold) return partialBuilder();
2192
+ return winner;
2193
+ }
2194
+
2195
+ // BUG-12: translate opaque lifecycle gate codes into actionable messages.
2196
+ function _lifecycleErrMsg(rawErr) {
2197
+ if (typeof rawErr === 'string' && rawErr.startsWith('lifecycle:')) {
2198
+ return `lifecycle gate active (${rawErr}) — call claws_lifecycle_plan to start a new cycle before spawning new terminals`;
2199
+ }
2200
+ return rawErr;
2201
+ }
2202
+
2203
+ // Per-tool error boundary: converts a thrown exception into a JSON-RPC error
2204
+ // response so one handler bug cannot propagate and kill the bridge process.
2205
+ // Applied at the handleTool dispatch level, covering all 41 tools uniformly.
2206
+ async function safeInvoke(handler, args) {
2207
+ try {
2208
+ return await handler(args);
2209
+ } catch (err) {
2210
+ return {
2211
+ content: [{ type: 'text', text: '[claws-mcp][error] ' + (err && err.stack || String(err)) }],
2212
+ isError: true,
2213
+ };
2214
+ }
2215
+ }
2216
+
2217
+ async function handleTool(name, args) {
2218
+ const sock = getSocket();
2219
+ _ensureSidecarOrThrow(2000).catch(() => {}); // best-effort warm-up
2220
+ const _t0 = Date.now();
2221
+ try {
2222
+ await _pconnEnsureRegistered(sock);
2223
+ await _pconnWriteOrThrow({ cmd: 'publish', protocol: 'claws/2', topic: `tool.${name}.invoked`, payload: { peerId: _pconn.peerId, correlation_id: process.env.CLAWS_TERMINAL_CORR_ID || null, args_keys: Object.keys(args) } });
2224
+ } catch (_pe) { /* best-effort */ }
2225
+ let _r;
2226
+ try {
2227
+ _r = await _dispatchTool(name, args, sock);
2228
+ } catch (_te) {
2229
+ try {
2230
+ await _pconnEnsureRegistered(sock);
2231
+ await _pconnWriteOrThrow({ cmd: 'publish', protocol: 'claws/2', topic: `tool.${name}.failed`, payload: { duration_ms: Date.now() - _t0, error: _te && _te.message || String(_te) } });
2232
+ } catch (_pe) { /* best-effort */ }
2233
+ // Return error response instead of re-throwing so the bridge stays alive.
2234
+ // process.on('unhandledRejection') is the outer safety net; this is the
2235
+ // per-tool net that converts handler exceptions to proper JSON-RPC errors.
2236
+ _r = await safeInvoke(() => { throw _te; });
2237
+ }
2238
+ try {
2239
+ await _pconnEnsureRegistered(sock);
2240
+ await _pconnWriteOrThrow({ cmd: 'publish', protocol: 'claws/2', topic: `tool.${name}.completed`, payload: { duration_ms: Date.now() - _t0 } });
2241
+ } catch (_pe) { /* best-effort */ }
2242
+ return _r;
2243
+ }
2244
+
2245
+ async function _dispatchTool(name, args, sock) {
2246
+
2247
+ if (name === 'claws_list') {
2248
+ const resp = await clawsRpc(sock, { cmd: 'list' });
2249
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
2250
+ const terms = resp.terminals || [];
2251
+ if (!terms.length) return { content: [{ type: 'text', text: '[no terminals open]' }] };
2252
+ const lines = terms.map(t => {
2253
+ // R4: trust the `wrapped` boolean. Old code keyed off `logPath` which is
2254
+ // always null in the Pseudoterminal capture model — every wrapped
2255
+ // terminal was misreported as unwrapped.
2256
+ // Annotate pipe-mode degradation explicitly so the user sees it at a glance.
2257
+ let wrap;
2258
+ if (!t.wrapped) {
2259
+ wrap = 'unwrapped';
2260
+ } else if (t.ptyMode === 'pipe') {
2261
+ wrap = 'WRAPPED-DEGRADED-pipe-mode';
2262
+ } else if (t.ptyMode === 'pty') {
2263
+ wrap = 'WRAPPED';
2264
+ } else if (t.ptyMode === 'none') {
2265
+ wrap = 'WRAPPED-pending'; // pty.open() not called yet (panel hidden?)
2266
+ } else {
2267
+ wrap = 'WRAPPED';
2268
+ }
2269
+ const marker = t.active ? '*' : ' ';
2270
+ // R7: Pseudoterminal terminals report pid=-1 from VS Code's API
2271
+ // (VS Code didn't spawn the shell — we did). Prefer ptyPid.
2272
+ const realPid = (typeof t.ptyPid === 'number' && t.ptyPid > 0) ? t.ptyPid : t.pid;
2273
+ return `${marker} ${t.id} ${(t.name || '').padEnd(25)} pid=${realPid} [${wrap}]`;
2274
+ });
2275
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
2276
+ }
2277
+
2278
+ if (name === 'claws_create') {
2279
+ try { await _ensureSidecarOrThrow(); } catch (e) { return toolError('SPAWN REFUSED: sidecar unavailable — ' + e.message); }
2280
+ const _createCorrId = (typeof args.correlation_id === 'string' && args.correlation_id) ? args.correlation_id : randomUUID();
2281
+ const resp = await clawsRpc(sock, {
2282
+ cmd: 'create', name: args.name || 'claws',
2283
+ cwd: (typeof args.cwd === 'string' && args.cwd.length > 0) ? args.cwd : process.cwd(), wrapped: args.wrapped !== false, show: true,
2284
+ correlation_id: _createCorrId,
2285
+ });
2286
+ if (!resp.ok) return toolError(`ERROR: ${_lifecycleErrMsg(resp.error)}`);
2287
+ const eventsLogPath = _eventsLogPath(sock);
2288
+ const _createTermId = resp.id;
2289
+ const _createMonitorCmd = `Monitor(command="node ${STREAM_EVENTS_JS_FOR_CMD} --wait ${_createCorrId} --keep-alive-on ${_createTermId}", description="claws monitor | term=${_createTermId} | corr=${_createCorrId.slice(0,8)} | sess=${new Date().toISOString().slice(0,13)}", timeout_ms=3600000, persistent=false)`;
2290
+ // D+F: register spawn + monitor atomically before returning. Best-effort (lifecycle may not be in active mission).
2291
+ try { await clawsRpc(sock, { cmd: 'lifecycle.register-spawn', terminalId: String(_createTermId), correlationId: _createCorrId, name: args.name || 'claws' }); } catch (e) { /* lifecycle not initialized — non-fatal for claws_create */ }
2292
+ try { await clawsRpc(sock, { cmd: 'lifecycle.register-monitor', terminalId: String(_createTermId), correlationId: _createCorrId, command: _createMonitorCmd }); } catch (e) { /* non-fatal */ }
2293
+ try { await clawsRpc(sock, { cmd: 'monitors.register-intent', correlation_id: _createCorrId }); } catch (_e) { /* non-fatal */ }
2294
+ // Layer 2 (Bug-6 / Bug-13): 30s first check — true orphan vs arm-in-flight (two-stage state machine).
2295
+ const _createTermIdStr = String(_createTermId);
2296
+ setTimeout(async () => {
2297
+ const _l2f = (m) => { log(m); _logL2File(sock, m); };
2298
+ _l2f(`L2-DEBUG: callback-entered | site=claws_create | corrId=${_createCorrId} | termId=${_createTermIdStr}`);
2299
+ try {
2300
+ const armResp = await clawsRpc(sock, { cmd: 'monitors.is-corr-armed', correlation_id: _createCorrId });
2301
+ _l2f(`L2-DEBUG: rpc-result | site=claws_create | ok=${armResp.ok} | armed=${armResp.armed} | claimed=${armResp.claimed} | pending=${armResp.pending} | peerId=${armResp.peerId}`);
2302
+ if (!armResp.ok) return; // RPC error, can't decide
2303
+ if (armResp.claimed) {
2304
+ _l2f(`L2-DEBUG: skip-publish | site=claws_create | reason=claimed | corrId=${_createCorrId}`);
2305
+ return;
2306
+ }
2307
+ if (!armResp.pending) {
2308
+ // True orphan — neither intent nor execution registered.
2309
+ log(`L2-warn: claws_create term=${_createTermIdStr} corr=${_createCorrId} — no intent or execution at 30s. True orphan.`);
2310
+ _l2f(`L2-DEBUG: about-to-publish | site=claws_create | corrId=${_createCorrId} | reason=no-intent`);
2311
+ try {
2312
+ await _pconnEnsureRegistered(sock);
2313
+ _l2f(`L2-DEBUG: pconn-registered | site=claws_create`);
2314
+ await _pconnWriteOrThrow({
2315
+ cmd: 'publish', protocol: 'claws/2',
2316
+ topic: 'system.monitor.unarmed',
2317
+ payload: { terminal_id: _createTermIdStr, correlation_id: _createCorrId, layer: 2,
2318
+ detected_at: new Date().toISOString(), grace_ms: 30000, reason: 'no-intent' },
2319
+ });
2320
+ _l2f(`L2-DEBUG: publish-succeeded | site=claws_create | corrId=${_createCorrId}`);
2321
+ } catch (_pe) {
2322
+ _l2f(`L2-DEBUG: publish-failed | site=claws_create | err=${_pe && _pe.message || _pe}`);
2323
+ }
2324
+ return;
2325
+ }
2326
+ // pending=true, claimed=false — arm in flight. Re-check at +60s.
2327
+ _l2f(`L2-DEBUG: arm-in-flight | site=claws_create | corrId=${_createCorrId} | scheduling-recheck-60s`);
2328
+ setTimeout(async () => {
2329
+ _l2f(`L2-DEBUG: recheck-entered | site=claws_create | corrId=${_createCorrId} | termId=${_createTermIdStr}`);
2330
+ try {
2331
+ const r2 = await clawsRpc(sock, { cmd: 'monitors.is-corr-armed', correlation_id: _createCorrId });
2332
+ _l2f(`L2-DEBUG: recheck-rpc-result | site=claws_create | ok=${r2.ok} | armed=${r2.armed} | claimed=${r2.claimed} | pending=${r2.pending} | peerId=${r2.peerId}`);
2333
+ if (!r2.ok || r2.claimed) {
2334
+ _l2f(`L2-DEBUG: recheck-skip | site=claws_create | reason=${!r2.ok ? 'rpc-error' : 'claimed'}`);
2335
+ return;
2336
+ }
2337
+ const reason = r2.pending ? 'pending-timeout' : 'pending-vacated';
2338
+ log(`L2-warn: claws_create term=${_createTermIdStr} corr=${_createCorrId} — ${reason} at 90s. Publishing unarmed.`);
2339
+ _l2f(`L2-DEBUG: about-to-publish | site=claws_create | corrId=${_createCorrId} | reason=${reason}`);
2340
+ try {
2341
+ await _pconnEnsureRegistered(sock);
2342
+ _l2f(`L2-DEBUG: pconn-registered | site=claws_create`);
2343
+ await _pconnWriteOrThrow({
2344
+ cmd: 'publish', protocol: 'claws/2',
2345
+ topic: 'system.monitor.unarmed',
2346
+ payload: { terminal_id: _createTermIdStr, correlation_id: _createCorrId, layer: 2,
2347
+ detected_at: new Date().toISOString(), grace_ms: 90000, reason },
2348
+ });
2349
+ _l2f(`L2-DEBUG: publish-succeeded | site=claws_create | corrId=${_createCorrId}`);
2350
+ } catch (_pe) {
2351
+ _l2f(`L2-DEBUG: publish-failed | site=claws_create | err=${_pe && _pe.message || _pe}`);
2352
+ }
2353
+ } catch (_e) { _l2f(`L2-DEBUG: recheck-error | site=claws_create | err=${_e && _e.message || _e}`); }
2354
+ }, 60000);
2355
+ } catch (_e) { _l2f(`L2-DEBUG: outer-error | site=claws_create | err=${_e && _e.message || _e}`); }
2356
+ }, 30000);
2357
+ const createResult = {
2358
+ ok: true, terminal_id: _createTermId,
2359
+ correlation_id: _createCorrId,
2360
+ ...(resp.logPath ? { log_path: resp.logPath } : {}),
2361
+ monitor_arm_required: true,
2362
+ monitor_arm_command: _createMonitorCmd,
2363
+ events_log_path: eventsLogPath,
2364
+ };
2365
+ return { content: [{ type: 'text', text: JSON.stringify(createResult, null, 2) }] };
2366
+ }
2367
+
2368
+ if (name === 'claws_send') {
2369
+ // Auto-enable bracketed paste for multi-line text. Matches the documented
2370
+ // tool behavior and ensures the trailing CR registers as Enter in TUIs
2371
+ // like Claude Code (the extension splits the CR into a separate write
2372
+ // when bracketed paste is used; see claws-pty.ts:writeInjected).
2373
+ const text = args.text ?? '';
2374
+ const isMultiLine = text.includes('\n') || text.includes('\r');
2375
+ const resp = await clawsRpc(sock, {
2376
+ cmd: 'send', id: args.id, text,
2377
+ newline: args.newline !== false,
2378
+ paste: isMultiLine,
2379
+ });
2380
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
2381
+ return { content: [{ type: 'text', text: 'sent' }] };
2382
+ }
2383
+
2384
+ if (name === 'claws_exec') {
2385
+ const timeoutMs = args.timeout_ms || 180000;
2386
+ return withMaxHold(
2387
+ fileExec(sock, args.id, args.command, timeoutMs).then((result) => {
2388
+ if (!result.ok) {
2389
+ let text = `ERROR: ${result.error}`;
2390
+ if (result.partial) text += `\n[partial output]\n${result.partial}`;
2391
+ return toolError(text);
2392
+ }
2393
+ return { content: [{ type: 'text', text: `exit ${result.exit_code}\n${result.output}` }] };
2394
+ }),
2395
+ 8000,
2396
+ () => ({ content: [{ type: 'text', text: JSON.stringify({ ok: true, partial: true, hint: 'use claws_workers_wait to monitor' }) }] }),
2397
+ );
2398
+ }
2399
+
2400
+ if (name === 'claws_read_log') {
2401
+ const resp = await clawsRpc(sock, { cmd: 'readLog', id: args.id, strip: true });
2402
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
2403
+ const allLines = (resp.bytes || '').split('\n');
2404
+ const n = args.lines || 50;
2405
+ const tail = allLines.length > n ? allLines.slice(-n) : allLines;
2406
+ const header = `[term ${args.id} · ${resp.totalSize || 0} bytes · showing last ${tail.length} of ${allLines.length} lines]`;
2407
+ return { content: [{ type: 'text', text: header + '\n' + tail.join('\n') }] };
2408
+ }
2409
+
2410
+ if (name === 'claws_poll') {
2411
+ const resp = await clawsRpc(sock, { cmd: 'poll', since: args.since || 0 });
2412
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
2413
+ const events = resp.events || [];
2414
+ if (!events.length) return { content: [{ type: 'text', text: `[no events · cursor ${resp.cursor || 0}]` }] };
2415
+ const lines = events.map(e => {
2416
+ let line = `[seq ${e.seq} · ${e.terminalName} · exit ${e.exitCode}] $ ${e.commandLine || ''}`;
2417
+ if (e.output) {
2418
+ const out = e.output.length > 500 ? e.output.slice(0, 500) + '...' : e.output;
2419
+ line += '\n' + out;
2420
+ }
2421
+ return line;
2422
+ });
2423
+ return { content: [{ type: 'text', text: lines.join('\n') + `\n[cursor ${resp.cursor}]` }] };
2424
+ }
2425
+
2426
+ if (name === 'claws_close') {
2427
+ const resp = await clawsRpc(sock, { cmd: 'close', id: args.id });
2428
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
2429
+ // BUG-B-close: cancel matching _detachWatcher when the orchestrator explicitly
2430
+ // closes a worker terminal. Without this, the watcher keeps polling for up to
2431
+ // 10 min then logs a spurious 'timeout'/'user-closed' result.
2432
+ const _bugBCloseTermId = String(args.id);
2433
+ const _bugBCloseWatcher = _detachWatchers.get(_bugBCloseTermId);
2434
+ if (_bugBCloseWatcher) {
2435
+ log(`BUG-B-close: cancelling _detachWatcher for orchestrator-closed term=${_bugBCloseTermId}`);
2436
+ if (_bugBCloseWatcher.intervalId) clearInterval(_bugBCloseWatcher.intervalId);
2437
+ _detachWatchers.delete(_bugBCloseTermId);
2438
+ }
2439
+ return { content: [{ type: 'text', text: `closed terminal ${args.id}` }] };
2440
+ }
2441
+
2442
+ /**
2443
+ * claws_hello — register this Claude session with the Claws server.
2444
+ * Calls the claws/2 `hello` command. Must be invoked before any
2445
+ * subscribe/publish/task/broadcast call so the server can allocate a
2446
+ * stable peerId for this connection.
2447
+ * Role requirements: none (any role may hello).
2448
+ * Returns: peerId, serverCapabilities, rootOrchestratorPresent.
2449
+ */
2450
+ if (name === 'claws_hello') {
2451
+ // Connect the persistent socket if not already connected. All subsequent
2452
+ // stateful calls (publish, subscribe, broadcast) share this socket so that
2453
+ // the server's per-connection peer state survives across tool calls.
2454
+ try {
2455
+ await _pconnEnsure(sock);
2456
+ } catch (err) {
2457
+ return toolError(`ERROR: cannot connect to Claws server: ${err.message}`);
2458
+ }
2459
+ const resp = await _pconnWrite({
2460
+ cmd: 'hello',
2461
+ protocol: 'claws/2',
2462
+ role: args.role,
2463
+ peerName: args.peerName,
2464
+ terminalId: args.terminalId,
2465
+ capabilities: Array.isArray(args.capabilities) ? args.capabilities : undefined,
2466
+ waveId: args.waveId || undefined,
2467
+ subWorkerRole: args.subWorkerRole || undefined,
2468
+ });
2469
+ if (!resp.ok) return toolError(`ERROR: ${resp.error || 'hello failed'}`);
2470
+ // Cache identity so we can re-register after a socket reconnect.
2471
+ _pconn.peerId = resp.peerId;
2472
+ _pconn.role = args.role;
2473
+ _pconn.peerName = args.peerName;
2474
+ _pconn.capabilities = Array.isArray(args.capabilities) ? args.capabilities : null;
2475
+ log(`registered as peer ${resp.peerId} role=${args.role}`);
2476
+ const out = {
2477
+ peerId: resp.peerId,
2478
+ serverCapabilities: resp.serverCapabilities || [],
2479
+ rootOrchestratorPresent: !!resp.rootOrchestratorPresent,
2480
+ };
2481
+ return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
2482
+ }
2483
+
2484
+ /**
2485
+ * claws_subscribe — subscribe this client to a topic pattern on the bus.
2486
+ * Calls the claws/2 `subscribe` command. Server-pushed frames for matching
2487
+ * topics will be delivered on this socket thereafter.
2488
+ * Role requirements: none (orchestrator, worker, and observer may all subscribe).
2489
+ * Returns: subscriptionId (opaque string used with unsubscribe).
2490
+ */
2491
+ if (name === 'claws_subscribe') {
2492
+ const resp = await clawsRpcStateful(sock, { cmd: 'subscribe', topic: args.topic });
2493
+ if (!resp.ok) return toolError(`ERROR: ${resp.error || 'subscribe failed'}`);
2494
+ return { content: [{ type: 'text', text: JSON.stringify({ subscriptionId: resp.subscriptionId, resumeCursor: resp.resumeCursor ?? null }, null, 2) }] };
2495
+ }
2496
+
2497
+ /**
2498
+ * claws_publish — publish a payload to a topic on the Claws message bus.
2499
+ * Calls the claws/2 `publish` command. Delivered to every peer whose
2500
+ * subscription pattern matches the topic; the sender receives the message
2501
+ * too only when `echo: true`.
2502
+ * Role requirements: none (any peer may publish).
2503
+ * Returns: deliveredTo (number of subscribers who received the frame).
2504
+ */
2505
+ if (name === 'claws_publish') {
2506
+ const resp = await clawsRpcStateful(sock, {
2507
+ cmd: 'publish',
2508
+ topic: args.topic,
2509
+ payload: args.payload || {},
2510
+ echo: !!args.echo,
2511
+ });
2512
+ if (!resp.ok) return toolError(`ERROR: ${resp.error || 'publish failed'}`);
2513
+ return { content: [{ type: 'text', text: JSON.stringify({ deliveredTo: resp.deliveredTo || 0 }, null, 2) }] };
2514
+ }
2515
+
2516
+ /**
2517
+ * claws_broadcast — orchestrator-only fan-out to all workers (or all peers).
2518
+ * Calls the claws/2 `broadcast` command. With `inject: true` the server also
2519
+ * sends the text into each target peer's associated terminal via bracketed
2520
+ * paste — this is the kill-switch path for workers that are deep in a tool
2521
+ * call and not reading their socket.
2522
+ * Role requirements: caller must have handshaked with role='orchestrator'.
2523
+ * Returns: deliveredTo (number of peers the broadcast reached).
2524
+ */
2525
+ /**
2526
+ * claws_done — zero-arg completion signal. Reads CLAWS_TERMINAL_ID from env,
2527
+ * publishes system.worker.completed, then closes the terminal. Workers call
2528
+ * this as their final action; no payload or schema required.
2529
+ */
2530
+ if (name === 'claws_done') {
2531
+ const termId = process.env.CLAWS_TERMINAL_ID;
2532
+ if (!termId) return toolError('ERROR: CLAWS_TERMINAL_ID not set — claws_done must be called from inside a Claws worker terminal (wrapped=true).');
2533
+ try {
2534
+ await _pconnEnsureRegistered(sock);
2535
+ await _pconnWriteOrThrow({
2536
+ cmd: 'publish', protocol: 'claws/2',
2537
+ topic: 'system.worker.completed',
2538
+ payload: { terminal_id: termId, status: 'completed', completion_signal: 'claws_done', marker: '__CLAWS_DONE__' },
2539
+ }, 3000);
2540
+ } catch (_e) { /* non-fatal — still close */ }
2541
+ try { await clawsRpc(sock, { cmd: 'close', id: termId, close_origin: 'claws_done' }); } catch (_e) { /* non-fatal */ }
2542
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, terminalId: termId }) }] };
2543
+ }
2544
+
2545
+ /**
2546
+ * claws_reload_window — trigger VS Code window reload from MCP.
2547
+ * Sends reload_window cmd to the extension, which calls
2548
+ * workbench.action.reloadWindow. The response may not arrive (reload kills
2549
+ * the extension host) — that is expected. Bootstrap requires one final manual
2550
+ * reload+reconnect; thereafter zero-friction.
2551
+ */
2552
+ if (name === 'claws_reload_window') {
2553
+ try {
2554
+ await clawsRpc(sock, { cmd: 'reload_window' });
2555
+ } catch (_e) { /* non-fatal — reload kills the socket */ }
2556
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, reloading: true }) }] };
2557
+ }
2558
+
2559
+ /**
2560
+ * claws_reload_mcp — restart the MCP server process.
2561
+ * Returns ok:true immediately, then calls setImmediate(() => process.exit(0))
2562
+ * so the response flushes before exit. Trusts the MCP supervisor to respawn.
2563
+ */
2564
+ if (name === 'claws_reload_mcp') {
2565
+ setImmediate(() => process.exit(0));
2566
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }] };
2567
+ }
2568
+
2569
+ if (name === 'claws_broadcast') {
2570
+ const resp = await clawsRpcStateful(sock, {
2571
+ cmd: 'broadcast',
2572
+ text: args.text,
2573
+ targetRole: args.targetRole || 'worker',
2574
+ inject: !!args.inject,
2575
+ });
2576
+ if (!resp.ok) return toolError(`ERROR: ${resp.error || 'broadcast failed'}`);
2577
+ return { content: [{ type: 'text', text: JSON.stringify({ deliveredTo: resp.deliveredTo || 0 }, null, 2) }] };
2578
+ }
2579
+
2580
+ /**
2581
+ * claws_ping — liveness check.
2582
+ * Calls the claws/2 `ping` command. Also acts as an implicit heartbeat —
2583
+ * the server refreshes the caller's `lastSeen` so it is not reaped as
2584
+ * offline.
2585
+ * Role requirements: none.
2586
+ * Returns: serverTime (ms since epoch as reported by the server).
2587
+ */
2588
+ if (name === 'claws_ping') {
2589
+ const resp = await clawsRpc(sock, { cmd: 'ping' });
2590
+ if (!resp.ok) return toolError(`ERROR: ${resp.error || 'ping failed'}`);
2591
+ return { content: [{ type: 'text', text: JSON.stringify({ serverTime: resp.serverTime }, null, 2) }] };
2592
+ }
2593
+
2594
+ /**
2595
+ * claws_peers — list all registered claws/2 peers.
2596
+ * There is no dedicated `peers` command on the server yet, so this tool
2597
+ * calls `introspect` and surfaces whatever peer info is included in the
2598
+ * snapshot (claws/2 Phase A exposes a peers map on the connection server).
2599
+ * Role requirements: none.
2600
+ * Returns: an array of peer records with { peerId, role, peerName, terminalId, lastSeen }.
2601
+ *
2602
+ * TODO: server-side 'peers' command needed in claws/2 Phase C — until then
2603
+ * we synthesise the list from `introspect` output. If the server already
2604
+ * has a `peers` command available we fall back to that first.
2605
+ */
2606
+ if (name === 'claws_peers') {
2607
+ // Prefer a direct `peers` command via the persistent socket (requires hello).
2608
+ // Fall back to `introspect` via a per-call socket for Phase C parity.
2609
+ let resp = await clawsRpcStateful(sock, { cmd: 'peers' });
2610
+ if (!resp.ok) {
2611
+ resp = await clawsRpc(sock, { cmd: 'introspect' });
2612
+ }
2613
+ if (!resp.ok) return toolError(`ERROR: ${resp.error || 'peers lookup failed'}`);
2614
+ const peers = resp.peers || (resp.snapshot && resp.snapshot.peers) || [];
2615
+ return { content: [{ type: 'text', text: JSON.stringify({ peers }, null, 2) }] };
2616
+ }
2617
+
2618
+ if (name === 'claws_worker') {
2619
+ try { await _ensureSidecarOrThrow(); } catch (e) { return toolError('SPAWN REFUSED: sidecar unavailable — ' + e.message); }
2620
+ const hasCommand = typeof args.command === 'string' && args.command.length > 0;
2621
+ const detach = args.detach !== undefined
2622
+ ? args.detach !== false
2623
+ : !hasCommand; // mission-mode default: detach=true; command-mode default: detach=false
2624
+ // Blocking mode: explicit wait:true OR detach:false (command-mode default) are both treated as blocking.
2625
+ if (args.wait === true || !detach) {
2626
+ // Legacy blocking path — guarded at 8 s to protect MCP stdio.
2627
+ return withMaxHold(
2628
+ runBlockingWorker(sock, args).then((result) => {
2629
+ if (result.status === 'error') return toolError(`ERROR: ${result.error}`);
2630
+ const header = [
2631
+ `worker '${args.name}' ${result.status.toUpperCase()}`,
2632
+ ` terminal: ${result.terminal_id}`,
2633
+ ` duration: ${(result.duration_ms / 1000).toFixed(1)}s`,
2634
+ ` booted: ${result.booted}`,
2635
+ ` cleaned_up: ${result.cleaned_up}`,
2636
+ ];
2637
+ if (result.marker_line) header.push(` marker: ${result.marker_line}`);
2638
+ if (result.status === 'spawned') {
2639
+ header.push('', `detached mode — use claws_read_log id=${result.terminal_id} and claws_close when done`);
2640
+ return { content: [{ type: 'text', text: header.join('\n') }] };
2641
+ }
2642
+ const body = result.harvest || '';
2643
+ return { content: [{ type: 'text', text: header.join('\n') + '\n\n── harvest (last lines) ──\n' + body }] };
2644
+ }),
2645
+ 8000,
2646
+ () => ({ content: [{ type: 'text', text: JSON.stringify({ ok: true, partial: true, hint: 'use claws_workers_wait to monitor' }) }] }),
2647
+ );
2648
+ }
2649
+
2650
+ // Non-blocking fast path (default) — boots terminal, sends mission, returns terminal_id immediately.
2651
+ const model = args.model || 'claude-sonnet-4-6';
2652
+ if (!/^[a-zA-Z0-9._-]+$/.test(model)) {
2653
+ return toolError(`ERROR: invalid model name '${model}' — must match /^[a-zA-Z0-9._-]+$/`);
2654
+ }
2655
+ const hasMission = typeof args.mission === 'string' && args.mission.length > 0;
2656
+ const launchClaude = args.launch_claude !== undefined ? !!args.launch_claude : hasMission;
2657
+ const workerCwd = typeof args.cwd === 'string' && args.cwd.length > 0 ? args.cwd : process.cwd();
2658
+
2659
+ const _fpCorrId = randomUUID();
2660
+ const cr = await clawsRpc(sock, {
2661
+ cmd: 'create', name: args.name || 'claws-worker', wrapped: true, show: true, cwd: workerCwd,
2662
+ env: { CLAWS_WORKER: '1' }, correlation_id: _fpCorrId,
2663
+ });
2664
+ if (!cr.ok) return toolError(`ERROR: create failed: ${_lifecycleErrMsg(cr.error)}`);
2665
+ const termId = cr.id;
2666
+ _clearStaleCompletionSignals(termId);
2667
+ const _fpStartedAt = Date.now();
2668
+
2669
+ // BUG-23: publish system.worker.spawned on non-blocking fast path — best-effort.
2670
+ try {
2671
+ await _pconnEnsureRegistered(sock);
2672
+ await _pconnWriteOrThrow({
2673
+ cmd: 'publish', protocol: 'claws/2',
2674
+ topic: 'system.worker.spawned',
2675
+ payload: { terminal_id: termId, name: args.name || 'claws-worker', wrapped: true, started_at: new Date(_fpStartedAt).toISOString(), correlation_id: _fpCorrId },
2676
+ });
2677
+ } catch (e) { log('system.worker.spawned publish failed: ' + (e && e.message || e)); }
2678
+
2679
+ // D+F: register spawn + monitor atomically. Best-effort (lifecycle may not be in active mission).
2680
+ try { await clawsRpc(sock, { cmd: 'lifecycle.register-spawn', terminalId: String(termId), correlationId: _fpCorrId, name: args.name || 'claws-worker' }); } catch (e) { /* non-fatal */ }
2681
+ const _fpMonitorCmd = `Monitor(command="node ${STREAM_EVENTS_JS_FOR_CMD} --wait ${_fpCorrId} --keep-alive-on ${termId}", description="claws monitor | term=${termId} | corr=${_fpCorrId.slice(0,8)} | sess=${new Date().toISOString().slice(0,13)}", timeout_ms=3600000, persistent=false)`;
2682
+ try { await clawsRpc(sock, { cmd: 'lifecycle.register-monitor', terminalId: String(termId), correlationId: _fpCorrId, command: _fpMonitorCmd }); } catch (e) { /* non-fatal */ }
2683
+ try { await clawsRpc(sock, { cmd: 'monitors.register-intent', correlation_id: _fpCorrId }); } catch (_e) { /* non-fatal */ }
2684
+ // T4: monitor-arm grace warning — 5s after spawn, warn if no monitor registered in lifecycle.
2685
+ const _fpMonitorGraceMs = 5000;
2686
+ const _fpTermIdStr = String(termId);
2687
+ setTimeout(async () => {
2688
+ try {
2689
+ const snap = await clawsRpc(sock, { cmd: 'lifecycle.snapshot' });
2690
+ if (snap.ok && snap.state) {
2691
+ const hasMonitor = (snap.state.monitors || []).some(m => m.terminal_id === _fpTermIdStr);
2692
+ if (!hasMonitor) {
2693
+ log(`T4-warn: claws_worker term=${_fpTermIdStr} corr=${_fpCorrId} — no monitor registered within ${_fpMonitorGraceMs}ms grace. Orchestrator may be flying blind. Use monitor_arm_command from spawn response.`);
2694
+ }
2695
+ }
2696
+ } catch (_e) { /* non-fatal */ }
2697
+ }, _fpMonitorGraceMs);
2698
+ // Layer 2 (Bug-6 / Bug-13): 30s first check — true orphan vs arm-in-flight (two-stage state machine).
2699
+ setTimeout(async () => {
2700
+ const _l2f = (m) => { log(m); _logL2File(sock, m); };
2701
+ _l2f(`L2-DEBUG: callback-entered | site=claws_worker-fast-path | corrId=${_fpCorrId} | termId=${_fpTermIdStr}`);
2702
+ try {
2703
+ const armResp = await clawsRpc(sock, { cmd: 'monitors.is-corr-armed', correlation_id: _fpCorrId });
2704
+ _l2f(`L2-DEBUG: rpc-result | site=claws_worker-fast-path | ok=${armResp.ok} | armed=${armResp.armed} | claimed=${armResp.claimed} | pending=${armResp.pending} | peerId=${armResp.peerId}`);
2705
+ if (!armResp.ok) return; // RPC error, can't decide
2706
+ if (armResp.claimed) {
2707
+ _l2f(`L2-DEBUG: skip-publish | site=claws_worker-fast-path | reason=claimed | corrId=${_fpCorrId}`);
2708
+ return;
2709
+ }
2710
+ if (!armResp.pending) {
2711
+ // True orphan — neither intent nor execution registered.
2712
+ log(`L2-warn: claws_worker term=${_fpTermIdStr} corr=${_fpCorrId} — no intent or execution at 30s. True orphan.`);
2713
+ _l2f(`L2-DEBUG: about-to-publish | site=claws_worker-fast-path | corrId=${_fpCorrId} | reason=no-intent`);
2714
+ try {
2715
+ await _pconnEnsureRegistered(sock);
2716
+ _l2f(`L2-DEBUG: pconn-registered | site=claws_worker-fast-path`);
2717
+ await _pconnWriteOrThrow({
2718
+ cmd: 'publish', protocol: 'claws/2',
2719
+ topic: 'system.monitor.unarmed',
2720
+ payload: { terminal_id: _fpTermIdStr, correlation_id: _fpCorrId, layer: 2,
2721
+ detected_at: new Date().toISOString(), grace_ms: 30000, reason: 'no-intent' },
2722
+ });
2723
+ _l2f(`L2-DEBUG: publish-succeeded | site=claws_worker-fast-path | corrId=${_fpCorrId}`);
2724
+ } catch (_pe) {
2725
+ _l2f(`L2-DEBUG: publish-failed | site=claws_worker-fast-path | err=${_pe && _pe.message || _pe}`);
2726
+ }
2727
+ return;
2728
+ }
2729
+ // pending=true, claimed=false — arm in flight. Re-check at +60s.
2730
+ _l2f(`L2-DEBUG: arm-in-flight | site=claws_worker-fast-path | corrId=${_fpCorrId} | scheduling-recheck-60s`);
2731
+ setTimeout(async () => {
2732
+ _l2f(`L2-DEBUG: recheck-entered | site=claws_worker-fast-path | corrId=${_fpCorrId} | termId=${_fpTermIdStr}`);
2733
+ try {
2734
+ const r2 = await clawsRpc(sock, { cmd: 'monitors.is-corr-armed', correlation_id: _fpCorrId });
2735
+ _l2f(`L2-DEBUG: recheck-rpc-result | site=claws_worker-fast-path | ok=${r2.ok} | armed=${r2.armed} | claimed=${r2.claimed} | pending=${r2.pending} | peerId=${r2.peerId}`);
2736
+ if (!r2.ok || r2.claimed) {
2737
+ _l2f(`L2-DEBUG: recheck-skip | site=claws_worker-fast-path | reason=${!r2.ok ? 'rpc-error' : 'claimed'}`);
2738
+ return;
2739
+ }
2740
+ const reason = r2.pending ? 'pending-timeout' : 'pending-vacated';
2741
+ log(`L2-warn: claws_worker term=${_fpTermIdStr} corr=${_fpCorrId} — ${reason} at 90s. Publishing unarmed.`);
2742
+ _l2f(`L2-DEBUG: about-to-publish | site=claws_worker-fast-path | corrId=${_fpCorrId} | reason=${reason}`);
2743
+ try {
2744
+ await _pconnEnsureRegistered(sock);
2745
+ _l2f(`L2-DEBUG: pconn-registered | site=claws_worker-fast-path`);
2746
+ await _pconnWriteOrThrow({
2747
+ cmd: 'publish', protocol: 'claws/2',
2748
+ topic: 'system.monitor.unarmed',
2749
+ payload: { terminal_id: _fpTermIdStr, correlation_id: _fpCorrId, layer: 2,
2750
+ detected_at: new Date().toISOString(), grace_ms: 90000, reason },
2751
+ });
2752
+ _l2f(`L2-DEBUG: publish-succeeded | site=claws_worker-fast-path | corrId=${_fpCorrId}`);
2753
+ } catch (_pe) {
2754
+ _l2f(`L2-DEBUG: publish-failed | site=claws_worker-fast-path | err=${_pe && _pe.message || _pe}`);
2755
+ }
2756
+ } catch (_e) { _l2f(`L2-DEBUG: recheck-error | site=claws_worker-fast-path | err=${_e && _e.message || _e}`); }
2757
+ }, 60000);
2758
+ } catch (_e) { _l2f(`L2-DEBUG: outer-error | site=claws_worker-fast-path | err=${_e && _e.message || _e}`); }
2759
+ }, 30000);
2760
+
2761
+ await sleep(400);
2762
+
2763
+ if (launchClaude) {
2764
+ await clawsRpc(sock, {
2765
+ cmd: 'send', id: termId,
2766
+ text: `${getClaudeBin(args && args.cwd)} --dangerously-skip-permissions --model ${model}`, newline: true,
2767
+ });
2768
+ // AD-1: gate mission paste on confirmed pty-claim.
2769
+ const _gate = await _gatePasteOnClaudeClaim(sock, termId, _fpCorrId, { timeoutMs: args.boot_wait_ms });
2770
+ if (!_gate.booted) {
2771
+ if (args.close_on_complete !== false) { try { await clawsRpc(sock, { cmd: 'close', id: termId }); } catch {} }
2772
+ return toolError(`boot failed: ${_gate.cause} | terminal_id=${termId} | corr=${_fpCorrId}`);
2773
+ }
2774
+ }
2775
+
2776
+ // Phase 4a: one-line completion hint — claws_done() handles the rest.
2777
+ const _fpPhase4Header = hasMission ? `When you're finished, call \`claws_done()\`. That's it.\n\n---\n` : '';
2778
+ const payload = hasMission
2779
+ ? (_fpPhase4Header + args.mission).replace(/\x1b\[200~/g, '').replace(/\x1b\[201~/g, '')
2780
+ : hasCommand ? wrapShellCommand(args.command, termId) : '';
2781
+ // W8k-1: delegate to shared helper — this fast path proved the sequence;
2782
+ // runBlockingWorker (fleet path) now reuses it verbatim. Returns markerScanFrom.
2783
+ const _fpMarkerScanFrom = await _sendAndSubmitMission(sock, termId, _fpCorrId, payload, launchClaude);
2784
+
2785
+ // BUG-24+25: register detach watcher so system.worker.completed is published
2786
+ // and auto-close fires when complete_marker is detected — mirrors the
2787
+ // runBlockingWorker(detach:true) watcher but without boot-polling overhead.
2788
+ const _fpOpt = {
2789
+ complete_marker: typeof args.complete_marker === 'string' ? args.complete_marker : '__CLAWS_DONE__',
2790
+ error_markers: Array.isArray(args.error_markers) ? args.error_markers : ['MISSION_FAILED'],
2791
+ timeout_ms: typeof args.timeout_ms === 'number' ? args.timeout_ms : 5 * 60 * 1000,
2792
+ poll_interval_ms: typeof args.poll_interval_ms === 'number' ? args.poll_interval_ms : 1500,
2793
+ close_on_complete: args.close_on_complete !== false,
2794
+ };
2795
+ const _fpHbState = new WorkerHeartbeatStateMachine({ terminalId: termId, correlationId: _fpCorrId });
2796
+ _setupDetachWatcher({
2797
+ sock, termId, corrId: _fpCorrId, opt: _fpOpt,
2798
+ markerScanFrom: _fpMarkerScanFrom, hbState: _fpHbState,
2799
+ startedAt: _fpStartedAt, extraPayload: { booted: launchClaude },
2800
+ });
2801
+
2802
+ const run_token = randomUUID().slice(0, 12);
2803
+ const workerEventsLogPath = _eventsLogPath(sock);
2804
+ return {
2805
+ content: [{ type: 'text', text: JSON.stringify({
2806
+ ok: true, terminal_id: termId,
2807
+ name: args.name || 'claws-worker',
2808
+ correlation_id: _fpCorrId,
2809
+ run_token,
2810
+ hint: 'use claws_workers_wait to poll completion',
2811
+ monitor_arm_required: true,
2812
+ monitor_arm_command: _fpMonitorCmd,
2813
+ events_log_path: workerEventsLogPath,
2814
+ }, null, 2) }],
2815
+ };
2816
+ }
2817
+
2818
+ if (name === 'claws_fleet') {
2819
+ try { await _ensureSidecarOrThrow(); } catch (e) { return toolError('SPAWN REFUSED: sidecar unavailable — ' + e.message); }
2820
+ const fleetWorkers = Array.isArray(args.workers) ? args.workers : [];
2821
+ if (fleetWorkers.length === 0) {
2822
+ return toolError('ERROR: workers must be a non-empty array of worker configs');
2823
+ }
2824
+ // detach default flipped to true in v0.7.10 — blocking mode unsafe over MCP stdio
2825
+ const detach = args.detach !== false;
2826
+ log(`fleet: enter workers=${fleetWorkers.length} detach=${detach}`);
2827
+ const sharedDefaults = {};
2828
+ for (const k of ['cwd', 'model', 'timeout_ms', 'boot_wait_ms', 'poll_interval_ms', 'harvest_lines', 'close_on_complete']) {
2829
+ if (args[k] !== undefined) sharedDefaults[k] = args[k];
2830
+ }
2831
+ const fleetStartedAt = Date.now();
2832
+ const fleetPromise = Promise.allSettled(
2833
+ fleetWorkers.map((w, _fi) => {
2834
+ const wClean = {};
2835
+ for (const [k, v] of Object.entries(w)) {
2836
+ if (v !== undefined) wClean[k] = v;
2837
+ }
2838
+ log(`fleet: worker[${_fi}] spawn-begin name=${wClean.name || 'unnamed'} mission-len=${(wClean.mission || '').length}`);
2839
+ return runBlockingWorker(sock, { ...sharedDefaults, ...wClean, ...(detach ? { detach: true } : {}) })
2840
+ .then((r) => {
2841
+ log(`fleet: worker[${_fi}] spawn-ok terminal=${r && r.terminal_id} status=${r && r.status}`);
2842
+ return r;
2843
+ })
2844
+ .catch((e) => {
2845
+ log(`fleet: worker[${_fi}] spawn-error ${e && e.message || String(e)}`);
2846
+ throw e;
2847
+ });
2848
+ }),
2849
+ ).then((settled) => {
2850
+ const results = settled.map((s) => s.status === 'fulfilled'
2851
+ ? s.value
2852
+ : { status: 'error', error: String(s.reason && s.reason.message || s.reason || 'unknown') });
2853
+ const fleetEventsLogPath = _eventsLogPath(sock);
2854
+ const summary = {
2855
+ fleet_size: results.length,
2856
+ detach,
2857
+ wall_clock_ms: Date.now() - fleetStartedAt,
2858
+ max_individual_ms: Math.max(...results.map((r) => r.duration_ms || 0), 0),
2859
+ sum_individual_ms: results.reduce((a, r) => a + (r.duration_ms || 0), 0),
2860
+ workers: results.map((r, i) => {
2861
+ const _fTermId = r.terminal_id;
2862
+ const _fCorrId = r.correlation_id;
2863
+ return {
2864
+ name: fleetWorkers[i] && fleetWorkers[i].name,
2865
+ status: r.status,
2866
+ terminal_id: _fTermId,
2867
+ correlation_id: _fCorrId,
2868
+ duration_ms: r.duration_ms,
2869
+ marker_line: r.marker_line,
2870
+ monitor_arm_command: (_fTermId && _fCorrId)
2871
+ ? `Monitor(command="node ${STREAM_EVENTS_JS_FOR_CMD} --wait ${_fCorrId} --keep-alive-on ${_fTermId}", description="claws monitor | term=${_fTermId} | corr=${_fCorrId.slice(0,8)} | sess=${new Date().toISOString().slice(0,13)}", timeout_ms=3600000, persistent=false)`
2872
+ : null,
2873
+ };
2874
+ }),
2875
+ monitor_arm_required: true,
2876
+ monitor_arm_command: 'arm one monitor_arm_command per worker entry above',
2877
+ events_log_path: fleetEventsLogPath,
2878
+ };
2879
+ log(`fleet: exit wall=${summary.wall_clock_ms}ms workers=${results.length}`);
2880
+ return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] };
2881
+ });
2882
+ // Detach:true (default) returns quickly; detach:false legacy path gets the guard.
2883
+ log(`fleet: dispatched detach=${detach}`);
2884
+ return detach
2885
+ ? fleetPromise
2886
+ : withMaxHold(fleetPromise, 8000, () => ({
2887
+ content: [{ type: 'text', text: JSON.stringify({ ok: true, partial: true, hint: 'use claws_workers_wait to monitor' }) }],
2888
+ }));
2889
+ }
2890
+
2891
+ if (name === 'claws_workers_wait') {
2892
+ const ids = Array.isArray(args.terminal_ids) ? args.terminal_ids : [];
2893
+ if (ids.length === 0) {
2894
+ return toolError('ERROR: terminal_ids must be a non-empty array');
2895
+ }
2896
+ const completeMarker = typeof args.complete_marker === 'string' && args.complete_marker.length > 0
2897
+ ? args.complete_marker : '__CLAWS_DONE__';
2898
+ const errorMarkers = Array.isArray(args.error_markers) ? args.error_markers : ['MISSION_FAILED'];
2899
+ const timeoutMs = typeof args.timeout_ms === 'number' && args.timeout_ms > 0 ? args.timeout_ms : 5 * 60 * 1000;
2900
+ const pollIntervalMs = typeof args.poll_interval_ms === 'number' && args.poll_interval_ms > 0 ? args.poll_interval_ms : 1500;
2901
+ const minComplete = (typeof args.min_complete === 'number' && args.min_complete > 0)
2902
+ ? Math.min(args.min_complete, ids.length) : ids.length;
2903
+ const startedAt = Date.now();
2904
+ const deadline = startedAt + timeoutMs;
2905
+ const detectOpt = { complete_marker: completeMarker, error_markers: errorMarkers };
2906
+ const state = ids.map((id) => ({
2907
+ id: String(id), status: 'pending', signal: null, marker_line: null, duration_ms: null, scanFrom: 0,
2908
+ }));
2909
+ for (const s of state) {
2910
+ try {
2911
+ const snap = await clawsRpc(sock, { cmd: 'readLog', id: s.id, strip: true, limit: 64 * 1024 });
2912
+ if (snap.ok && typeof snap.bytes === 'string') s.scanFrom = snap.bytes.length;
2913
+ } catch (e) { /* keep 0 */ }
2914
+ }
2915
+ const countDone = () => state.filter((s) => s.status !== 'pending').length;
2916
+ while (Date.now() < deadline) {
2917
+ for (const s of state) {
2918
+ if (s.status !== 'pending') continue;
2919
+ try {
2920
+ const snap = await clawsRpc(sock, { cmd: 'readLog', id: s.id, strip: true, limit: 64 * 1024 });
2921
+ const text = snap.ok && typeof snap.bytes === 'string' ? snap.bytes : '';
2922
+ const scanText = text.length > s.scanFrom ? text.slice(s.scanFrom) : '';
2923
+ const det = detectCompletion(scanText, detectOpt, s.id, _workerTerminatedSet, _workerCompletedViaBusSet);
2924
+ if (det !== null) {
2925
+ s.status = det.status;
2926
+ s.signal = det.signal;
2927
+ s.marker_line = det.line;
2928
+ s.duration_ms = Date.now() - startedAt;
2929
+ }
2930
+ } catch (e) { /* terminal vanished — leave pending; will time out */ }
2931
+ }
2932
+ if (countDone() >= minComplete) break;
2933
+ await sleep(pollIntervalMs);
2934
+ }
2935
+ for (const s of state) {
2936
+ if (s.status === 'pending') {
2937
+ s.status = 'timeout';
2938
+ s.duration_ms = Date.now() - startedAt;
2939
+ }
2940
+ }
2941
+ const pendingIds = state.filter((s) => s.status === 'timeout').map((s) => s.id);
2942
+ return { content: [{ type: 'text', text: JSON.stringify({
2943
+ ok: true,
2944
+ complete: state.filter((s) => s.status !== 'timeout').length,
2945
+ target: minComplete,
2946
+ total: state.length,
2947
+ wall_clock_ms: Date.now() - startedAt,
2948
+ pending: pendingIds,
2949
+ results: state.map((s) => ({ terminal_id: s.id, status: s.status, signal: s.signal, marker_line: s.marker_line, duration_ms: s.duration_ms })),
2950
+ }, null, 2) }] };
2951
+ }
2952
+
2953
+ /**
2954
+ * claws_drain_events — drain buffered push frames from the persistent socket.
2955
+ * On first call, auto-subscribes to "**" (all topics) via the persistent socket
2956
+ * so subsequent frames are captured automatically. Returns events received since
2957
+ * since_index, with a dropped count for events that aged out of the ring buffer.
2958
+ * Set wait_ms > 0 to block until at least one new event arrives or the timer fires.
2959
+ */
2960
+ if (name === 'claws_drain_events') {
2961
+ const sinceIndex = typeof args.since_index === 'number' ? args.since_index : 0;
2962
+ const waitMs = typeof args.wait_ms === 'number' ? args.wait_ms : 0;
2963
+ const maxEvents = typeof args.max === 'number' ? args.max : 100;
2964
+
2965
+ // Auto-subscribe on first call.
2966
+ if (!_eventBuffer.subscribed) {
2967
+ try {
2968
+ await _pconnEnsureRegistered(sock);
2969
+ const sr = await _pconnWriteOrThrow({ cmd: 'subscribe', topic: '**' }, 5000);
2970
+ _eventBuffer.subscribed = true;
2971
+ _eventBuffer.subscribeCursor = sr.resumeCursor ?? null;
2972
+ } catch (err) {
2973
+ log('claws_drain_events: auto-subscribe failed: ' + (err && err.message || err));
2974
+ }
2975
+ }
2976
+
2977
+ // If wait_ms > 0 and no new events yet, register a waiter.
2978
+ // Cap waiters at maxWaiters to prevent unbounded accumulation.
2979
+ const hasNew = () => _eventBuffer.ring.some(e => e.absoluteIndex > sinceIndex);
2980
+ if (waitMs > 0 && !hasNew()) {
2981
+ if (_eventBuffer.waiters.length >= _eventBuffer.maxWaiters) {
2982
+ return toolError('ERROR: drain waiter queue full (max 10 concurrent wait_ms requests)');
2983
+ }
2984
+ await new Promise((resolve) => {
2985
+ const timer = setTimeout(resolve, waitMs);
2986
+ _eventBuffer.waiters.push({ resolve, timer });
2987
+ });
2988
+ }
2989
+
2990
+ const ring = _eventBuffer.ring;
2991
+ const totalReceived = _eventBuffer.totalReceived;
2992
+ const filtered = ring.filter(e => e.absoluteIndex > sinceIndex).slice(0, maxEvents);
2993
+
2994
+ // dropped = events in [sinceIndex+1, ring[0].absoluteIndex - 1] that were evicted.
2995
+ let dropped = 0;
2996
+ if (ring.length > 0) {
2997
+ const firstInRing = ring[0].absoluteIndex;
2998
+ if (firstInRing > sinceIndex + 1) dropped = firstInRing - sinceIndex - 1;
2999
+ }
3000
+
3001
+ return {
3002
+ content: [{
3003
+ type: 'text',
3004
+ text: JSON.stringify({ events: filtered, total_received: totalReceived, dropped, subscribe_cursor: _eventBuffer.subscribeCursor }, null, 2),
3005
+ }],
3006
+ };
3007
+ }
3008
+
3009
+ if (name === 'claws_lifecycle_plan') {
3010
+ const _planMode = (typeof args.worker_mode === 'string') ? args.worker_mode : 'single';
3011
+ const _planExpected = (Number.isInteger(args.expected_workers) && args.expected_workers > 0) ? args.expected_workers : 1;
3012
+ const resp = await clawsRpc(sock, { cmd: 'lifecycle.plan', plan: args.plan, workerMode: _planMode, expectedWorkers: _planExpected });
3013
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}\n${resp.message || ''}`);
3014
+ const out = { state: resp.state, idempotent: !!resp.idempotent };
3015
+ return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
3016
+ }
3017
+
3018
+ if (name === 'claws_lifecycle_advance') {
3019
+ const resp = await clawsRpc(sock, { cmd: 'lifecycle.advance', to: args.to, reason: args.reason });
3020
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}\n${resp.message || ''}`);
3021
+ return { content: [{ type: 'text', text: JSON.stringify({ state: resp.state }, null, 2) }] };
3022
+ }
3023
+
3024
+ if (name === 'claws_lifecycle_snapshot') {
3025
+ const resp = await clawsRpc(sock, { cmd: 'lifecycle.snapshot' });
3026
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
3027
+ const text = resp.state
3028
+ ? JSON.stringify(resp.state, null, 2)
3029
+ : '[no lifecycle state — call claws_lifecycle_plan first]';
3030
+ return { content: [{ type: 'text', text }] };
3031
+ }
3032
+
3033
+ if (name === 'claws_lifecycle_reflect') {
3034
+ const resp = await clawsRpc(sock, { cmd: 'lifecycle.reflect', reflect: args.reflect });
3035
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}\n${resp.message || ''}`);
3036
+ return { content: [{ type: 'text', text: JSON.stringify({ state: resp.state }, null, 2) }] };
3037
+ }
3038
+
3039
+ if (name === 'claws_wave_create') {
3040
+ const resp = await clawsRpc(sock, {
3041
+ cmd: 'wave.create',
3042
+ waveId: args.waveId,
3043
+ layers: args.layers,
3044
+ manifest: args.manifest,
3045
+ });
3046
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
3047
+ return { content: [{ type: 'text', text: JSON.stringify({ waveId: resp.waveId, created: resp.created }, null, 2) }] };
3048
+ }
3049
+
3050
+ if (name === 'claws_wave_status') {
3051
+ const resp = await clawsRpc(sock, { cmd: 'wave.status', waveId: args.waveId });
3052
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
3053
+ const tree = {
3054
+ waveId: resp.waveId,
3055
+ complete: resp.complete,
3056
+ lead: resp.lead,
3057
+ subWorkers: resp.subWorkers,
3058
+ subWorkerTerminals: resp.subWorkerTerminals,
3059
+ orphanedTerminals: resp.orphanedTerminals,
3060
+ harvestedAt: resp.harvestedAt,
3061
+ layers: resp.layers,
3062
+ createdAt: resp.createdAt,
3063
+ completedAt: resp.completedAt,
3064
+ summary: resp.summary,
3065
+ commits: resp.commits,
3066
+ regressionClean: resp.regressionClean,
3067
+ };
3068
+ return { content: [{ type: 'text', text: JSON.stringify(tree, null, 2) }] };
3069
+ }
3070
+
3071
+ if (name === 'claws_wave_complete') {
3072
+ const resp = await clawsRpc(sock, {
3073
+ cmd: 'wave.complete',
3074
+ waveId: args.waveId,
3075
+ summary: args.summary,
3076
+ commits: args.commits,
3077
+ regressionClean: args.regressionClean,
3078
+ });
3079
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
3080
+ return { content: [{ type: 'text', text: JSON.stringify({ waveId: resp.waveId, completedAt: resp.completedAt }, null, 2) }] };
3081
+ }
3082
+
3083
+ if (name === 'claws_deliver_cmd') {
3084
+ const payload = typeof args.payload === 'string' ? JSON.parse(args.payload) : args.payload;
3085
+ const resp = await clawsRpcStateful(sock, {
3086
+ cmd: 'deliver-cmd',
3087
+ protocol: 'claws/2',
3088
+ targetPeerId: args.targetPeerId,
3089
+ cmdTopic: args.cmdTopic,
3090
+ payload,
3091
+ idempotencyKey: args.idempotencyKey,
3092
+ });
3093
+ if (!resp.ok && !resp.duplicate) return toolError(`ERROR: ${resp.error}`);
3094
+ return {
3095
+ content: [{
3096
+ type: 'text',
3097
+ text: JSON.stringify({
3098
+ ok: resp.ok,
3099
+ seq: resp.seq,
3100
+ duplicate: resp.duplicate ?? false,
3101
+ }, null, 2),
3102
+ }],
3103
+ };
3104
+ }
3105
+
3106
+ if (name === 'claws_cmd_ack') {
3107
+ const resp = await clawsRpcStateful(sock, {
3108
+ cmd: 'cmd.ack',
3109
+ protocol: 'claws/2',
3110
+ seq: args.seq,
3111
+ status: args.status,
3112
+ ...(args.correlation_id ? { correlation_id: args.correlation_id } : {}),
3113
+ });
3114
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
3115
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true }, null, 2) }] };
3116
+ }
3117
+
3118
+ if (name === 'claws_pipeline_create') {
3119
+ const steps = typeof args.steps === 'string' ? JSON.parse(args.steps) : args.steps;
3120
+ const resp = await clawsRpcStateful(sock, {
3121
+ cmd: 'pipeline.create',
3122
+ name: args.name || 'pipeline',
3123
+ steps,
3124
+ });
3125
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
3126
+ return {
3127
+ content: [{
3128
+ type: 'text',
3129
+ text: JSON.stringify({ pipelineId: resp.pipelineId, pipeline: resp.pipeline }, null, 2),
3130
+ }],
3131
+ };
3132
+ }
3133
+
3134
+ if (name === 'claws_pipeline_list') {
3135
+ const resp = await clawsRpc(sock, { cmd: 'pipeline.list' });
3136
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
3137
+ return { content: [{ type: 'text', text: JSON.stringify(resp.pipelines, null, 2) }] };
3138
+ }
3139
+
3140
+ if (name === 'claws_pipeline_close') {
3141
+ const resp = await clawsRpcStateful(sock, { cmd: 'pipeline.close', pipelineId: args.pipelineId }); // BUG-04: stateful socket carries peerId
3142
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
3143
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, pipelineId: resp.pipelineId }, null, 2) }] };
3144
+ }
3145
+
3146
+ if (name === 'claws_dispatch_subworker') {
3147
+ try { await _ensureSidecarOrThrow(); } catch (e) { return toolError('SPAWN REFUSED: sidecar unavailable — ' + e.message); }
3148
+ // FIX 3: Verify wave was created via claws_wave_create before spawning sub-worker.
3149
+ const waveCheck = await clawsRpc(sock, { cmd: 'wave.status', waveId: args.waveId });
3150
+ if (!waveCheck.ok && typeof waveCheck.error === 'string' && waveCheck.error.includes('not-found')) {
3151
+ return toolError(`SPAWN REFUSED: wave.not-registered — call claws_wave_create({waveId:'${args.waveId}', manifest:[...]}) first`);
3152
+ }
3153
+ const workerName = `wave-${args.waveId}-${args.role}`;
3154
+ const _dswCorrId = randomUUID();
3155
+ const cr = await clawsRpc(sock, {
3156
+ cmd: 'create', name: workerName, wrapped: true, show: true,
3157
+ cwd: typeof args.cwd === 'string' && args.cwd.length > 0 ? args.cwd : process.cwd(),
3158
+ waveId: args.waveId, waveRole: args.role, env: { CLAWS_WORKER: '1' }, correlation_id: _dswCorrId,
3159
+ });
3160
+ if (!cr.ok) return toolError(`ERROR: create failed: ${_lifecycleErrMsg(cr.error)}`);
3161
+ const termId = cr.id;
3162
+ _clearStaleCompletionSignals(termId);
3163
+
3164
+ // BUG-A: publish system.worker.spawned for sub-workers — best-effort.
3165
+ log(`dispatch_subworker: publishing system.worker.spawned for terminal ${termId}`);
3166
+ try {
3167
+ await _pconnEnsureRegistered(sock);
3168
+ await _pconnWriteOrThrow({
3169
+ cmd: 'publish', protocol: 'claws/2',
3170
+ topic: 'system.worker.spawned',
3171
+ payload: { terminal_id: termId, waveId: args.waveId, role: args.role, name: workerName, wrapped: true, started_at: new Date().toISOString(), correlation_id: _dswCorrId },
3172
+ });
3173
+ log(`dispatch_subworker: system.worker.spawned published ok for terminal ${termId}`);
3174
+ } catch (e) { log('dispatch_subworker: system.worker.spawned publish FAILED: ' + (e && e.message || e)); }
3175
+
3176
+ // D+F: register spawn + monitor atomically. Best-effort (lifecycle may not be in active mission).
3177
+ try { await clawsRpc(sock, { cmd: 'lifecycle.register-spawn', terminalId: String(termId), correlationId: _dswCorrId, name: workerName }); } catch (e) { /* non-fatal */ }
3178
+ const _dswMonitorCmd = `Monitor(command="node ${STREAM_EVENTS_JS_FOR_CMD} --wait ${_dswCorrId} --keep-alive-on ${termId}", description="claws monitor | term=${termId} | corr=${_dswCorrId.slice(0,8)} | sess=${new Date().toISOString().slice(0,13)}", timeout_ms=3600000, persistent=false)`;
3179
+ try { await clawsRpc(sock, { cmd: 'lifecycle.register-monitor', terminalId: String(termId), correlationId: _dswCorrId, command: _dswMonitorCmd }); } catch (e) { /* non-fatal */ }
3180
+ try { await clawsRpc(sock, { cmd: 'monitors.register-intent', correlation_id: _dswCorrId }); } catch (_e) { /* non-fatal */ }
3181
+ // T4: monitor-arm grace warning — 5s after spawn, warn if no monitor registered in lifecycle.
3182
+ const _dswMonitorGraceMs = 5000;
3183
+ const _dswTermIdStr = String(termId);
3184
+ setTimeout(async () => {
3185
+ try {
3186
+ const snap = await clawsRpc(sock, { cmd: 'lifecycle.snapshot' });
3187
+ if (snap.ok && snap.state) {
3188
+ const hasMonitor = (snap.state.monitors || []).some(m => m.terminal_id === _dswTermIdStr);
3189
+ if (!hasMonitor) {
3190
+ log(`T4-warn: claws_dispatch_subworker term=${_dswTermIdStr} corr=${_dswCorrId} — no monitor registered within ${_dswMonitorGraceMs}ms grace. Orchestrator may be flying blind. Use monitor_arm_command from spawn response.`);
3191
+ }
3192
+ }
3193
+ } catch (_e) { /* non-fatal */ }
3194
+ }, _dswMonitorGraceMs);
3195
+ // Layer 2 (Bug-6 / Bug-13): 30s first check — true orphan vs arm-in-flight (two-stage state machine).
3196
+ setTimeout(async () => {
3197
+ const _l2f = (m) => { log(m); _logL2File(sock, m); };
3198
+ _l2f(`L2-DEBUG: callback-entered | site=claws_dispatch_subworker | corrId=${_dswCorrId} | termId=${_dswTermIdStr}`);
3199
+ try {
3200
+ const armResp = await clawsRpc(sock, { cmd: 'monitors.is-corr-armed', correlation_id: _dswCorrId });
3201
+ _l2f(`L2-DEBUG: rpc-result | site=claws_dispatch_subworker | ok=${armResp.ok} | armed=${armResp.armed} | claimed=${armResp.claimed} | pending=${armResp.pending} | peerId=${armResp.peerId}`);
3202
+ if (!armResp.ok) return; // RPC error, can't decide
3203
+ if (armResp.claimed) {
3204
+ _l2f(`L2-DEBUG: skip-publish | site=claws_dispatch_subworker | reason=claimed | corrId=${_dswCorrId}`);
3205
+ return;
3206
+ }
3207
+ if (!armResp.pending) {
3208
+ // True orphan — neither intent nor execution registered.
3209
+ log(`L2-warn: claws_dispatch_subworker term=${_dswTermIdStr} corr=${_dswCorrId} — no intent or execution at 30s. True orphan.`);
3210
+ _l2f(`L2-DEBUG: about-to-publish | site=claws_dispatch_subworker | corrId=${_dswCorrId} | reason=no-intent`);
3211
+ try {
3212
+ await _pconnEnsureRegistered(sock);
3213
+ _l2f(`L2-DEBUG: pconn-registered | site=claws_dispatch_subworker`);
3214
+ await _pconnWriteOrThrow({
3215
+ cmd: 'publish', protocol: 'claws/2',
3216
+ topic: 'system.monitor.unarmed',
3217
+ payload: { terminal_id: _dswTermIdStr, correlation_id: _dswCorrId, layer: 2,
3218
+ detected_at: new Date().toISOString(), grace_ms: 30000, reason: 'no-intent' },
3219
+ });
3220
+ _l2f(`L2-DEBUG: publish-succeeded | site=claws_dispatch_subworker | corrId=${_dswCorrId}`);
3221
+ } catch (_pe) {
3222
+ _l2f(`L2-DEBUG: publish-failed | site=claws_dispatch_subworker | err=${_pe && _pe.message || _pe}`);
3223
+ }
3224
+ return;
3225
+ }
3226
+ // pending=true, claimed=false — arm in flight. Re-check at +60s.
3227
+ _l2f(`L2-DEBUG: arm-in-flight | site=claws_dispatch_subworker | corrId=${_dswCorrId} | scheduling-recheck-60s`);
3228
+ setTimeout(async () => {
3229
+ _l2f(`L2-DEBUG: recheck-entered | site=claws_dispatch_subworker | corrId=${_dswCorrId} | termId=${_dswTermIdStr}`);
3230
+ try {
3231
+ const r2 = await clawsRpc(sock, { cmd: 'monitors.is-corr-armed', correlation_id: _dswCorrId });
3232
+ _l2f(`L2-DEBUG: recheck-rpc-result | site=claws_dispatch_subworker | ok=${r2.ok} | armed=${r2.armed} | claimed=${r2.claimed} | pending=${r2.pending} | peerId=${r2.peerId}`);
3233
+ if (!r2.ok || r2.claimed) {
3234
+ _l2f(`L2-DEBUG: recheck-skip | site=claws_dispatch_subworker | reason=${!r2.ok ? 'rpc-error' : 'claimed'}`);
3235
+ return;
3236
+ }
3237
+ const reason = r2.pending ? 'pending-timeout' : 'pending-vacated';
3238
+ log(`L2-warn: claws_dispatch_subworker term=${_dswTermIdStr} corr=${_dswCorrId} — ${reason} at 90s. Publishing unarmed.`);
3239
+ _l2f(`L2-DEBUG: about-to-publish | site=claws_dispatch_subworker | corrId=${_dswCorrId} | reason=${reason}`);
3240
+ try {
3241
+ await _pconnEnsureRegistered(sock);
3242
+ _l2f(`L2-DEBUG: pconn-registered | site=claws_dispatch_subworker`);
3243
+ await _pconnWriteOrThrow({
3244
+ cmd: 'publish', protocol: 'claws/2',
3245
+ topic: 'system.monitor.unarmed',
3246
+ payload: { terminal_id: _dswTermIdStr, correlation_id: _dswCorrId, layer: 2,
3247
+ detected_at: new Date().toISOString(), grace_ms: 90000, reason },
3248
+ });
3249
+ _l2f(`L2-DEBUG: publish-succeeded | site=claws_dispatch_subworker | corrId=${_dswCorrId}`);
3250
+ } catch (_pe) {
3251
+ _l2f(`L2-DEBUG: publish-failed | site=claws_dispatch_subworker | err=${_pe && _pe.message || _pe}`);
3252
+ }
3253
+ } catch (_e) { _l2f(`L2-DEBUG: recheck-error | site=claws_dispatch_subworker | err=${_e && _e.message || _e}`); }
3254
+ }, 60000);
3255
+ } catch (_e) { _l2f(`L2-DEBUG: outer-error | site=claws_dispatch_subworker | err=${_e && _e.message || _e}`); }
3256
+ }, 30000);
3257
+
3258
+ // BUG-08: fire-and-forget — return after create so parallel dispatch_subworker calls don't serialize.
3259
+ const _dswSock = sock;
3260
+ // GAP-D1: strip bracketed-paste escapes from mission to prevent keystroke injection.
3261
+ // Phase 4a: one-line completion hint — claws_done() handles the rest.
3262
+ const _dswPhase4Header = `When you're finished, call \`claws_done()\`. That's it.\n\n---\n`;
3263
+ const _dswMission = (_dswPhase4Header + (args.mission || '')).replace(/\x1b\[200~/g, '').replace(/\x1b\[201~/g, '');
3264
+ const _dswWid = args.waveId;
3265
+ const _dswRole = args.role;
3266
+ // BUG-09: capture watcher options for auto-close watcher registered after mission send.
3267
+ const _dswCompleteMarker = typeof args.complete_marker === 'string' ? args.complete_marker : '__CLAWS_DONE__';
3268
+ const _dswErrorMarkers = Array.isArray(args.error_markers) ? args.error_markers : ['MISSION_FAILED'];
3269
+ const _dswTimeoutMs = typeof args.timeout_ms === 'number' ? args.timeout_ms : 5 * 60 * 1000;
3270
+ const _dswPollMs = typeof args.poll_interval_ms === 'number' ? args.poll_interval_ms : 1500;
3271
+ const _dswCloseOnComplete = args.close_on_complete !== false;
3272
+ const _dswCwd = typeof args.cwd === 'string' && args.cwd.length > 0 ? args.cwd : null;
3273
+ setImmediate(async () => {
3274
+ try {
3275
+ await sleep(400);
3276
+ await clawsRpc(_dswSock, {
3277
+ cmd: 'send', id: termId,
3278
+ text: `${getClaudeBin(_dswCwd)} --model claude-sonnet-4-6 --dangerously-skip-permissions`, newline: true,
3279
+ });
3280
+ // AD-1: gate mission paste on confirmed pty-claim.
3281
+ const _gate = await _gatePasteOnClaudeClaim(_dswSock, termId, _dswCorrId, { timeoutMs: args.boot_wait_ms });
3282
+ if (!_gate.booted) {
3283
+ if (_dswCloseOnComplete) { try { await clawsRpc(_dswSock, { cmd: 'close', id: termId }); } catch {} }
3284
+ return; // abort — L2 grace will publish unarmed naturally
3285
+ }
3286
+ // GAP-D1: _dswMission already sanitized above — send as-is.
3287
+ // W8k-2: delegate to shared helper — pre-snapshot (BUG-C), bracketed-paste (newline:false),
3288
+ // 300ms gap, AE-7 event-driven submit confirmation with escalating retry.
3289
+ // Restores parity with claws_worker / fleet; newline:true failed on Windows ConPTY.
3290
+ const _dswMarkerScanFrom = await _sendAndSubmitMission(_dswSock, termId, _dswCorrId, _dswMission, true);
3291
+
3292
+ // BUG-09: register auto-close watcher via shared _setupDetachWatcher helper.
3293
+ const _dswStartedAt = Date.now();
3294
+ const _dswOpt = { complete_marker: _dswCompleteMarker, error_markers: _dswErrorMarkers, timeout_ms: _dswTimeoutMs, poll_interval_ms: _dswPollMs, close_on_complete: _dswCloseOnComplete };
3295
+ const _dswHbState = new WorkerHeartbeatStateMachine({ terminalId: termId, correlationId: _dswCorrId });
3296
+ _setupDetachWatcher({
3297
+ sock: _dswSock, termId, corrId: _dswCorrId, opt: _dswOpt,
3298
+ markerScanFrom: _dswMarkerScanFrom, hbState: _dswHbState,
3299
+ startedAt: _dswStartedAt, extraPayload: { waveId: _dswWid, role: _dswRole },
3300
+ closeOnTimeout: _dswCloseOnComplete,
3301
+ });
3302
+ } catch (e) { log('claws_dispatch_subworker background boot error: ' + (e && e.message || e)); }
3303
+ });
3304
+
3305
+ const dswEventsLogPath = _eventsLogPath(sock);
3306
+ return {
3307
+ content: [{
3308
+ type: 'text',
3309
+ text: JSON.stringify({
3310
+ terminal_id: termId, waveId: _dswWid, role: _dswRole, name: workerName,
3311
+ correlation_id: _dswCorrId,
3312
+ monitor_arm_required: true,
3313
+ monitor_arm_command: _dswMonitorCmd,
3314
+ events_log_path: dswEventsLogPath,
3315
+ }, null, 2),
3316
+ }],
3317
+ };
3318
+ }
3319
+
3320
+ if (name === 'claws_schema_list') {
3321
+ const resp = await clawsRpc(sock, { cmd: 'schema.list', protocol: 'claws/2' });
3322
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
3323
+ return { content: [{ type: 'text', text: JSON.stringify({ schemas: resp.schemas }, null, 2) }] };
3324
+ }
3325
+
3326
+ if (name === 'claws_schema_get') {
3327
+ const resp = await clawsRpc(sock, { cmd: 'schema.get', protocol: 'claws/2', name: args.name });
3328
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
3329
+ return { content: [{ type: 'text', text: JSON.stringify({ name: resp.name, schema: resp.schema }, null, 2) }] };
3330
+ }
3331
+
3332
+ if (name === 'claws_rpc_call') {
3333
+ const resp = await clawsRpcStateful(sock, {
3334
+ cmd: 'rpc.call',
3335
+ protocol: 'claws/2',
3336
+ targetPeerId: args.targetPeerId,
3337
+ method: args.method,
3338
+ ...(args.params ? { params: args.params } : {}),
3339
+ ...(args.timeoutMs ? { timeoutMs: args.timeoutMs } : {}),
3340
+ });
3341
+ if (!resp.ok) return toolError(`ERROR: ${resp.error}`);
3342
+ return { content: [{ type: 'text', text: JSON.stringify({ requestId: resp.requestId, result: resp.result }, null, 2) }] };
3343
+ }
3344
+
3345
+ if (name === 'claws_task_assign') {
3346
+ const resp = await clawsRpcStateful(sock, {
3347
+ cmd: 'task.assign', protocol: 'claws/2',
3348
+ title: args.title,
3349
+ assignee: args.assignee,
3350
+ prompt: args.prompt,
3351
+ ...(args.timeoutMs != null ? { timeoutMs: args.timeoutMs } : {}),
3352
+ ...(args.deliver ? { deliver: args.deliver } : {}),
3353
+ });
3354
+ if (!resp.ok) return toolError(`ERROR: ${resp.error || 'task.assign failed'}`);
3355
+ return { content: [{ type: 'text', text: JSON.stringify({ taskId: resp.taskId, assignedAt: resp.assignedAt }, null, 2) }] };
3356
+ }
3357
+
3358
+ if (name === 'claws_task_update') {
3359
+ const resp = await clawsRpcStateful(sock, {
3360
+ cmd: 'task.update', protocol: 'claws/2',
3361
+ taskId: args.taskId,
3362
+ status: args.status,
3363
+ ...(args.progressPct != null ? { progressPct: args.progressPct } : {}),
3364
+ ...(args.note != null ? { note: args.note } : {}),
3365
+ });
3366
+ if (!resp.ok) return toolError(`ERROR: ${resp.error || 'task.update failed'}`);
3367
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true }, null, 2) }] };
3368
+ }
3369
+
3370
+ if (name === 'claws_task_complete') {
3371
+ const resp = await clawsRpcStateful(sock, {
3372
+ cmd: 'task.complete', protocol: 'claws/2',
3373
+ taskId: args.taskId,
3374
+ status: args.status,
3375
+ ...(args.result != null ? { result: args.result } : {}),
3376
+ ...(args.artifacts != null ? { artifacts: args.artifacts } : {}),
3377
+ });
3378
+ if (!resp.ok) return toolError(`ERROR: ${resp.error || 'task.complete failed'}`);
3379
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true }, null, 2) }] };
3380
+ }
3381
+
3382
+ if (name === 'claws_task_cancel') {
3383
+ const resp = await clawsRpcStateful(sock, {
3384
+ cmd: 'task.cancel', protocol: 'claws/2',
3385
+ taskId: args.taskId,
3386
+ ...(args.reason != null ? { reason: args.reason } : {}),
3387
+ });
3388
+ if (!resp.ok) return toolError(`ERROR: ${resp.error || 'task.cancel failed'}`);
3389
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true }, null, 2) }] };
3390
+ }
3391
+
3392
+ if (name === 'claws_task_list') {
3393
+ const resp = await clawsRpcStateful(sock, {
3394
+ cmd: 'task.list', protocol: 'claws/2',
3395
+ ...(args.assignee ? { assignee: args.assignee } : {}),
3396
+ ...(args.status ? { status: args.status } : {}),
3397
+ ...(args.since != null ? { since: args.since } : {}),
3398
+ });
3399
+ if (!resp.ok) return toolError(`ERROR: ${resp.error || 'task.list failed'}`);
3400
+ return { content: [{ type: 'text', text: JSON.stringify({ tasks: resp.tasks || [] }, null, 2) }] };
3401
+ }
3402
+
3403
+ if (name === 'claws_set_bin') {
3404
+ try {
3405
+ const clawsDir = path.dirname(path.resolve(sock));
3406
+ const file = path.join(clawsDir, 'claude-bin');
3407
+ if (args && typeof args.name === 'string' && args.name.trim()) {
3408
+ fs.mkdirSync(clawsDir, { recursive: true });
3409
+ fs.writeFileSync(file, args.name.trim() + '\n', 'utf8');
3410
+ return { content: [{ type: 'text', text: `Worker binary set to: ${args.name.trim()}\n → wrote ${file}\n → next claws_worker / claws_fleet spawn will use this binary.` }] };
3411
+ }
3412
+ try { fs.unlinkSync(file); } catch (_e) { /* file didn't exist */ }
3413
+ return { content: [{ type: 'text', text: 'Worker binary cleared. Spawns will use the default ("claude") or CLAWS_CLAUDE_BIN env var if set.' }] };
3414
+ } catch (e) {
3415
+ return toolError(`claws_set_bin failed: ${e.message}`);
3416
+ }
3417
+ }
3418
+
3419
+ if (name === 'claws_get_bin') {
3420
+ try {
3421
+ const projectRoot = path.resolve(path.dirname(path.resolve(sock)), '..');
3422
+ const bin = getClaudeBin(projectRoot);
3423
+ const clawsFile = path.join(projectRoot, '.claws', 'claude-bin');
3424
+ let source;
3425
+ if (fs.existsSync(clawsFile) && fs.readFileSync(clawsFile, 'utf8').trim().split(/\r?\n/)[0]) {
3426
+ source = `file:${clawsFile}`;
3427
+ } else if (process.env.CLAWS_CLAUDE_BIN) {
3428
+ source = 'env:CLAWS_CLAUDE_BIN';
3429
+ } else {
3430
+ source = 'default';
3431
+ }
3432
+ return { content: [{ type: 'text', text: `Worker binary: ${bin}\n source: ${source}` }] };
3433
+ } catch (e) {
3434
+ return toolError(`claws_get_bin failed: ${e.message}`);
3435
+ }
3436
+ }
3437
+
3438
+ return toolError(`unknown tool: ${name}`);
3439
+ }
3440
+
3441
+ // ─── MCP server main loop ──────────────────────────────────────────────────
3442
+
3443
+ async function main() {
3444
+ process.stdin.resume();
3445
+ log('MCP server started, socket: ' + getSocket());
3446
+
3447
+ // AE-1: Eager hello when running as a worker's child mcp_server.js.
3448
+ // CLAWS_TERMINAL_CORR_ID is set by the extension when creating the wrapped pty
3449
+ // (claws-pty.ts:256). Its presence in our env means we are inside a worker
3450
+ // terminal, not the user's root orchestrator. Eagerly hello so the bus
3451
+ // confirms we are alive and so system.peer.connected{correlation_id} fires
3452
+ // for the parent orchestrator's _gatePasteOnClaudeClaim to release on.
3453
+ // Without this, hello is lazy (first tool call only) — an idle Claude TUI
3454
+ // at the empty prompt never hellos and event-driven boot detection times out.
3455
+ //
3456
+ // AE-1.1: do NOT pre-check fs.existsSync(socket) — on win32 the bus is a
3457
+ // named pipe (\\.\pipe\...) which has no filesystem presence and existsSync
3458
+ // always returns false, silently skipping the eager hello on Windows.
3459
+ // _pconnEnsureRegistered already handles connect failures gracefully via the
3460
+ // circuit breaker; a missing/down bus produces one logged warning, no crash.
3461
+ if (process.env.CLAWS_TERMINAL_CORR_ID) {
3462
+ const _aeSock = getSocket();
3463
+ if (_aeSock) {
3464
+ setImmediate(() => {
3465
+ _pconnEnsureRegistered(_aeSock).catch((e) => {
3466
+ log(`AE-1: eager hello failed (will retry lazily): ${e && e.message || e}`);
3467
+ });
3468
+ });
3469
+ }
3470
+ }
3471
+
3472
+ while (true) {
3473
+ const msg = await readMessage();
3474
+ if (!msg) break;
3475
+
3476
+ const { method, id, params = {} } = msg;
3477
+
3478
+ if (method === 'initialize') {
3479
+ log('client connected: ' + ((params && params.clientInfo && params.clientInfo.name) || 'unknown'));
3480
+ respond(id, {
3481
+ protocolVersion: '2024-11-05',
3482
+ // Version must match extension/package.json — bump both together on release.
3483
+ serverInfo: { name: 'claws', version: '0.7.14' },
3484
+ capabilities: { tools: {} },
3485
+ });
3486
+ } else if (method === 'notifications/initialized') {
3487
+ // no response needed
3488
+ } else if (method === 'tools/list') {
3489
+ respond(id, { tools: TOOLS });
3490
+ } else if (method === 'tools/call') {
3491
+ // CONCURRENT TOOL DISPATCH (v0.7.10).
3492
+ // Do NOT await handleTool here — that would block the main loop and
3493
+ // serialize every tool call (the v0.7.4-through-v0.7.9 behavior).
3494
+ // When a long-running tool like claws_worker is in flight, fan-out
3495
+ // patterns (3 parallel workers, wave armies with 4 sub-workers, etc.)
3496
+ // would queue up behind it for up to timeout_ms each, defeating the
3497
+ // entire point of concurrent orchestration.
3498
+ //
3499
+ // Instead: dispatch the handler as a fire-and-forget Promise and let
3500
+ // the main loop immediately read the next message from stdin. Multiple
3501
+ // tool calls now interleave on the JS event loop, each progressing
3502
+ // whenever it await's I/O. State sharing is safe because:
3503
+ // - _pconn requests use unique rids (nextRid++ is atomic in JS event loop)
3504
+ // - _eventBuffer push is single-statement, never interleaved mid-write
3505
+ // - _circuitBreaker fields are simple read/write, racy but benign
3506
+ //
3507
+ // respond() is called from the .then/.catch — JSON-RPC responses can
3508
+ // arrive out-of-order relative to the requests, which is allowed by
3509
+ // the spec (each response carries the matching id).
3510
+ handleTool(params.name || '', params.arguments || {})
3511
+ .then((result) => respond(id, result))
3512
+ .catch((e) => respond(id, {
3513
+ content: [{ type: 'text', text: `ERROR: ${e.message || e}` }],
3514
+ isError: true,
3515
+ }));
3516
+ } else if (method === 'ping') {
3517
+ respond(id, {});
3518
+ } else if (id != null) {
3519
+ respondError(id, -32601, `unknown method: ${method}`);
3520
+ }
3521
+ }
3522
+ }
3523
+
3524
+ main().catch(console.error);
3525
+
3526
+ // Export pure helpers for unit testing (Task #58 + HB-L3).
3527
+ if (typeof module !== 'undefined') {
3528
+ module.exports = { detectCompletion, findStandaloneMarker, WorkerHeartbeatStateMachine };
3529
+ }