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.
- package/.claude/commands/claws-auto.md +90 -0
- package/.claude/commands/claws-bin.md +28 -0
- package/.claude/commands/claws-cleanup.md +28 -0
- package/.claude/commands/claws-do.md +82 -0
- package/.claude/commands/claws-fix.md +40 -0
- package/.claude/commands/claws-goal.md +111 -0
- package/.claude/commands/claws-help.md +54 -0
- package/.claude/commands/claws-plan.md +103 -0
- package/.claude/commands/claws-report.md +29 -0
- package/.claude/commands/claws-status.md +37 -0
- package/.claude/commands/claws-update.md +32 -0
- package/.claude/commands/claws.md +64 -0
- package/.claude/rules/claws-default-behavior.md +76 -0
- package/.claude/settings.json +112 -0
- package/.claude/settings.local.json +19 -0
- package/.claude/skills/claws-auto-engine/SKILL.md +97 -0
- package/.claude/skills/claws-goal-tracker/SKILL.md +106 -0
- package/.claude/skills/claws-prompt-templates/SKILL.md +203 -0
- package/.claude/skills/claws-wave-lead/SKILL.md +126 -0
- package/.claude/skills/claws-wave-subworker/SKILL.md +60 -0
- package/CHANGELOG.md +1949 -0
- package/LICENSE +21 -0
- package/README.md +420 -0
- package/bin/cli.js +84 -0
- package/cli.js +223 -0
- package/docs/ARCHITECTURE.md +511 -0
- package/docs/event-protocol.md +588 -0
- package/docs/features.md +562 -0
- package/docs/guide.md +891 -0
- package/docs/index.html +716 -0
- package/docs/protocol.md +323 -0
- package/extension/.vscodeignore +15 -0
- package/extension/CHANGELOG.md +1906 -0
- package/extension/LICENSE +21 -0
- package/extension/README.md +137 -0
- package/extension/docs/features.md +424 -0
- package/extension/docs/protocol.md +197 -0
- package/extension/esbuild.mjs +25 -0
- package/extension/icon.png +0 -0
- package/extension/native/.metadata.json +10 -0
- package/extension/native/node-pty/LICENSE +69 -0
- package/extension/native/node-pty/README.md +165 -0
- package/extension/native/node-pty/lib/conpty_console_list_agent.js +16 -0
- package/extension/native/node-pty/lib/conpty_console_list_agent.js.map +1 -0
- package/extension/native/node-pty/lib/eventEmitter2.js +47 -0
- package/extension/native/node-pty/lib/eventEmitter2.js.map +1 -0
- package/extension/native/node-pty/lib/index.js +52 -0
- package/extension/native/node-pty/lib/index.js.map +1 -0
- package/extension/native/node-pty/lib/interfaces.js +7 -0
- package/extension/native/node-pty/lib/interfaces.js.map +1 -0
- package/extension/native/node-pty/lib/shared/conout.js +11 -0
- package/extension/native/node-pty/lib/shared/conout.js.map +1 -0
- package/extension/native/node-pty/lib/terminal.js +190 -0
- package/extension/native/node-pty/lib/terminal.js.map +1 -0
- package/extension/native/node-pty/lib/types.js +7 -0
- package/extension/native/node-pty/lib/types.js.map +1 -0
- package/extension/native/node-pty/lib/unixTerminal.js +346 -0
- package/extension/native/node-pty/lib/unixTerminal.js.map +1 -0
- package/extension/native/node-pty/lib/utils.js +39 -0
- package/extension/native/node-pty/lib/utils.js.map +1 -0
- package/extension/native/node-pty/lib/windowsConoutConnection.js +125 -0
- package/extension/native/node-pty/lib/windowsConoutConnection.js.map +1 -0
- package/extension/native/node-pty/lib/windowsPtyAgent.js +320 -0
- package/extension/native/node-pty/lib/windowsPtyAgent.js.map +1 -0
- package/extension/native/node-pty/lib/windowsTerminal.js +199 -0
- package/extension/native/node-pty/lib/windowsTerminal.js.map +1 -0
- package/extension/native/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/extension/native/node-pty/lib/worker/conoutSocketWorker.js.map +1 -0
- package/extension/native/node-pty/package.json +64 -0
- package/extension/native/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/extension/native/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty/OpenConsole.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty/conpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/winpty-agent.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-arm64/winpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty/OpenConsole.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty/conpty.dll +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/pty.node +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/winpty-agent.exe +0 -0
- package/extension/native/node-pty/prebuilds/win32-x64/winpty.dll +0 -0
- package/extension/package-lock.json +605 -0
- package/extension/package.json +343 -0
- package/extension/scripts/bundle-native.mjs +104 -0
- package/extension/scripts/deploy-dev.mjs +60 -0
- package/extension/src/ansi-strip.ts +52 -0
- package/extension/src/backends/vscode/claws-pty.ts +483 -0
- package/extension/src/backends/vscode/status-bar.ts +99 -0
- package/extension/src/backends/vscode/vscode-backend.ts +282 -0
- package/extension/src/capture-store.ts +125 -0
- package/extension/src/event-log.ts +629 -0
- package/extension/src/event-schemas.ts +478 -0
- package/extension/src/extension.js +492 -0
- package/extension/src/extension.ts +873 -0
- package/extension/src/lifecycle-engine.ts +60 -0
- package/extension/src/lifecycle-rules.ts +171 -0
- package/extension/src/lifecycle-store.ts +506 -0
- package/extension/src/peer-registry.ts +176 -0
- package/extension/src/pipeline-registry.ts +82 -0
- package/extension/src/platform.ts +64 -0
- package/extension/src/protocol.ts +532 -0
- package/extension/src/server-config.ts +98 -0
- package/extension/src/server.ts +2210 -0
- package/extension/src/task-registry.ts +51 -0
- package/extension/src/terminal-backend.ts +211 -0
- package/extension/src/terminal-manager.ts +395 -0
- package/extension/src/topic-registry.ts +70 -0
- package/extension/src/topic-utils.ts +46 -0
- package/extension/src/transport.ts +45 -0
- package/extension/src/uninstall-cleanup.ts +232 -0
- package/extension/src/wave-registry.ts +314 -0
- package/extension/src/websocket-transport.ts +153 -0
- package/extension/tsconfig.json +23 -0
- package/lib/capabilities.js +145 -0
- package/lib/dry-run.js +43 -0
- package/lib/install.js +1018 -0
- package/lib/mcp-setup.js +92 -0
- package/lib/platform.js +240 -0
- package/lib/preflight.js +152 -0
- package/lib/shell-hook.js +343 -0
- package/lib/uninstall.js +162 -0
- package/lib/verify.js +166 -0
- package/mcp_server.js +3529 -0
- package/package.json +48 -0
- package/rules/claws-default-behavior.md +72 -0
- package/scripts/_helpers/atomic-file.mjs +137 -0
- package/scripts/_helpers/fix-repair.js +64 -0
- package/scripts/_helpers/json-safe.mjs +218 -0
- package/scripts/bump-version.sh +84 -0
- package/scripts/codegen/gen-docs.mjs +61 -0
- package/scripts/codegen/gen-json-schema.mjs +62 -0
- package/scripts/codegen/gen-mcp-tools.mjs +358 -0
- package/scripts/codegen/gen-types.mjs +172 -0
- package/scripts/codegen/index.mjs +42 -0
- package/scripts/dev-hooks/check-extension-dirs.js +77 -0
- package/scripts/dev-hooks/check-open-claws-terminals.js +70 -0
- package/scripts/dev-hooks/check-stale-main.js +55 -0
- package/scripts/dev-hooks/check-tag-pushed.js +51 -0
- package/scripts/dev-hooks/check-tag-vs-main.js +56 -0
- package/scripts/dev-vsix-install.sh +60 -0
- package/scripts/fix.sh +702 -0
- package/scripts/gen-client-types.mjs +81 -0
- package/scripts/git-hooks/pre-commit +31 -0
- package/scripts/hooks/lifecycle-state.js +61 -0
- package/scripts/hooks/package.json +4 -0
- package/scripts/hooks/post-tool-use-claws.js +292 -0
- package/scripts/hooks/pre-bash-no-verify-block.js +72 -0
- package/scripts/hooks/pre-tool-use-claws.js +206 -0
- package/scripts/hooks/session-start-claws.js +97 -0
- package/scripts/hooks/stop-claws.js +88 -0
- package/scripts/inject-claude-md.js +205 -0
- package/scripts/inject-dev-hooks.js +96 -0
- package/scripts/inject-global-claude-md.js +140 -0
- package/scripts/inject-settings-hooks.js +370 -0
- package/scripts/install.ps1 +146 -0
- package/scripts/install.sh +1729 -0
- package/scripts/monitor-arm-watch.js +155 -0
- package/scripts/rebuild-node-pty.sh +245 -0
- package/scripts/report.sh +232 -0
- package/scripts/shell-hook.fish +164 -0
- package/scripts/shell-hook.ps1 +33 -0
- package/scripts/shell-hook.sh +232 -0
- package/scripts/stream-events.js +399 -0
- package/scripts/terminal-wrapper.sh +36 -0
- package/scripts/test-enforcement.sh +132 -0
- package/scripts/test-install.sh +174 -0
- package/scripts/test-installer-parity.sh +135 -0
- package/scripts/test-template-enforcement.sh +76 -0
- package/scripts/uninstall.sh +143 -0
- package/scripts/update.sh +337 -0
- package/scripts/verify-release.sh +323 -0
- package/scripts/verify-wrapped.sh +194 -0
- package/templates/CLAUDE.global.md +135 -0
- 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
|
+
}
|