brainclaw 1.7.0 → 1.7.2
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/README.md +36 -5
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +12 -2
- package/dist/commands/dispatch.js +2 -0
- package/dist/commands/doctor.js +17 -0
- package/dist/commands/mcp.js +31 -7
- package/dist/commands/switch.js +40 -8
- package/dist/core/agent-capability.js +67 -0
- package/dist/core/agentrun-reconciler.js +126 -52
- package/dist/core/coordination.js +10 -9
- package/dist/core/dispatcher.js +99 -29
- package/dist/core/entity-operations.js +54 -1
- package/dist/core/execution-adapters.js +32 -51
- package/dist/core/execution.js +14 -8
- package/dist/core/identity.js +40 -13
- package/dist/core/instruction-templates.js +4 -3
- package/dist/core/runtime-signals.js +102 -0
- package/dist/core/spawn-check.js +125 -0
- package/dist/facts.js +3 -3
- package/dist/facts.json +2 -2
- package/docs/cli.md +8 -4
- package/docs/integrations/mcp.md +48 -15
- package/docs/mcp-schema-changelog.md +16 -5
- package/docs/playbooks/team/index.md +7 -5
- package/package.json +1 -1
|
@@ -4,12 +4,21 @@ import path from 'node:path';
|
|
|
4
4
|
import { buildClaimEnvPrefix } from './execution-profile.js';
|
|
5
5
|
import { getCapabilityProfile } from './agent-capability.js';
|
|
6
6
|
import { nowISO } from './ids.js';
|
|
7
|
+
import { ensureRuntimeDirs, getRuntimeLogPath, getRuntimeSignalPath, } from './runtime-signals.js';
|
|
8
|
+
export function buildAckWrapCommand(bashCommand, paths, isWin32) {
|
|
9
|
+
const touch = isWin32
|
|
10
|
+
? (p) => `type nul > "${p}"`
|
|
11
|
+
: (p) => `touch "${p}"`;
|
|
12
|
+
const redirected = `${bashCommand} > "${paths.stdoutLog}" 2> "${paths.stderrLog}"`;
|
|
13
|
+
return (`${touch(paths.ackPath)} && ` +
|
|
14
|
+
`( ${redirected} && ${touch(paths.completedPath)} || ${touch(paths.failedPath)} )`);
|
|
15
|
+
}
|
|
7
16
|
/**
|
|
8
17
|
* Check if a binary is resolvable on the system PATH.
|
|
9
18
|
* On Windows, `spawn({shell:true})` always succeeds (launches cmd.exe),
|
|
10
19
|
* masking ENOENT for missing binaries. This pre-check catches that.
|
|
11
20
|
*/
|
|
12
|
-
function resolveBinaryOnPath(binary) {
|
|
21
|
+
export function resolveBinaryOnPath(binary) {
|
|
13
22
|
// Absolute or relative path — check directly
|
|
14
23
|
if (binary.includes('/') || binary.includes('\\')) {
|
|
15
24
|
return fs.existsSync(binary) ? binary : undefined;
|
|
@@ -90,48 +99,32 @@ export class CliExecutionAdapter {
|
|
|
90
99
|
const spawnExecutable = resolvedExecutable ?? invoke.executable;
|
|
91
100
|
const useShell = isWin32 && /\.(cmd|bat)$/i.test(spawnExecutable);
|
|
92
101
|
const needsStdin = invoke.promptDelivery === 'stdin_pipe' && invoke.promptText;
|
|
93
|
-
// pln#
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
102
|
+
// pln#520 step 4: when we ack-wrap, the SHELL redirects stdout/stderr to the
|
|
103
|
+
// per-assignment log files (fds passed via stdio are NOT inherited through
|
|
104
|
+
// the cmd.exe → .cmd → node shim — the empty-logs bug of can_f792cacd), and
|
|
105
|
+
// the wrapper emits completed/failed sentinels mechanically. So the spawned
|
|
106
|
+
// process just ignores stdout/stderr here. stdin stays a pipe when the
|
|
107
|
+
// prompt is delivered that way (the grouped agent command inherits it).
|
|
98
108
|
const useAckWrap = !!(options.assignmentId && (options.ackRoot ?? options.worktreePath));
|
|
99
|
-
let logFds;
|
|
100
|
-
if (useAckWrap) {
|
|
101
|
-
try {
|
|
102
|
-
const logRoot = options.ackRoot ?? options.worktreePath;
|
|
103
|
-
const logDir = path.join(logRoot, '.brainclaw', 'coordination', 'runtime', 'log');
|
|
104
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
105
|
-
logFds = {
|
|
106
|
-
stdout: fs.openSync(path.join(logDir, `${options.assignmentId}.stdout.log`), 'a'),
|
|
107
|
-
stderr: fs.openSync(path.join(logDir, `${options.assignmentId}.stderr.log`), 'a'),
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
// Log capture is best-effort — never block the spawn on logging issues.
|
|
112
|
-
logFds = undefined;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
109
|
const stdinTarget = needsStdin ? 'pipe' : 'ignore';
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
// to call bclaw_assignment_update). When ackRoot/assignmentId are
|
|
124
|
-
// omitted, we keep the original direct-binary spawn.
|
|
110
|
+
const stdio = [stdinTarget, 'ignore', 'ignore'];
|
|
111
|
+
// pln#476 + pln#520 step 4: wrap the spawn so the worker shell touches the
|
|
112
|
+
// pre-exec `ack` sentinel, redirects logs at the shell level, and emits a
|
|
113
|
+
// completed/failed sentinel from the agent's exit code. waitForAssignmentHandshake
|
|
114
|
+
// checks the ack file; the reconciler trusts the completed/failed/heartbeat
|
|
115
|
+
// sentinels rather than the (untrustworthy) wrapper pid. When ackRoot/
|
|
116
|
+
// assignmentId are omitted, we keep the original direct-binary spawn.
|
|
125
117
|
let child;
|
|
126
118
|
if (useAckWrap) {
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
:
|
|
134
|
-
|
|
119
|
+
const signalRoot = options.ackRoot ?? options.worktreePath;
|
|
120
|
+
ensureRuntimeDirs(signalRoot);
|
|
121
|
+
const wrappedCmd = buildAckWrapCommand(invoke.bashCommand, {
|
|
122
|
+
ackPath: getRuntimeSignalPath(signalRoot, options.assignmentId, 'ack'),
|
|
123
|
+
completedPath: getRuntimeSignalPath(signalRoot, options.assignmentId, 'completed'),
|
|
124
|
+
failedPath: getRuntimeSignalPath(signalRoot, options.assignmentId, 'failed'),
|
|
125
|
+
stdoutLog: getRuntimeLogPath(signalRoot, options.assignmentId, 'stdout'),
|
|
126
|
+
stderrLog: getRuntimeLogPath(signalRoot, options.assignmentId, 'stderr'),
|
|
127
|
+
}, isWin32);
|
|
135
128
|
child = spawn(wrappedCmd, [], {
|
|
136
129
|
detached: !isWin32,
|
|
137
130
|
shell: true,
|
|
@@ -163,18 +156,6 @@ export class CliExecutionAdapter {
|
|
|
163
156
|
child.stdin.end();
|
|
164
157
|
}
|
|
165
158
|
child.unref();
|
|
166
|
-
// Close the parent's copies of the log file descriptors. The child has its
|
|
167
|
-
// own dup'd copies and will keep writing to them after we return.
|
|
168
|
-
if (logFds) {
|
|
169
|
-
try {
|
|
170
|
-
fs.closeSync(logFds.stdout);
|
|
171
|
-
}
|
|
172
|
-
catch { /* best-effort */ }
|
|
173
|
-
try {
|
|
174
|
-
fs.closeSync(logFds.stderr);
|
|
175
|
-
}
|
|
176
|
-
catch { /* best-effort */ }
|
|
177
|
-
}
|
|
178
159
|
const pid = child.pid;
|
|
179
160
|
if (!pid) {
|
|
180
161
|
throw new Error(`Failed to spawn agent ${options.agent}: no PID returned`);
|
package/dist/core/execution.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
10
|
import fs from 'node:fs';
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
11
|
+
import { resolveConcurrencyLimit, resolveResourceKey } from './agent-capability.js';
|
|
12
|
+
import { getRuntimeSignalPath } from './runtime-signals.js';
|
|
13
13
|
import { appendAuditEntry } from './audit.js';
|
|
14
14
|
import { loadAllSessions } from './identity.js';
|
|
15
15
|
import { loadConfig } from './config.js';
|
|
@@ -30,7 +30,7 @@ function sleep(ms) {
|
|
|
30
30
|
* spawn anyway).
|
|
31
31
|
*/
|
|
32
32
|
export function getAssignmentAckPath(cwd, assignmentId) {
|
|
33
|
-
return
|
|
33
|
+
return getRuntimeSignalPath(cwd, assignmentId, 'ack');
|
|
34
34
|
}
|
|
35
35
|
function isAssignmentAcked(assignmentId, cwd) {
|
|
36
36
|
// Fast path: the brief-ack sentinel was written by the worker shell.
|
|
@@ -73,9 +73,13 @@ export function checkActiveInstance(agentName, cwd) {
|
|
|
73
73
|
catch { /* use default */ }
|
|
74
74
|
const SESSION_STALE_MS = parseDurationMs(ttlStr);
|
|
75
75
|
const now = Date.now();
|
|
76
|
+
// pln#520 step 3: pool active sessions by host-binary resource so all
|
|
77
|
+
// identities of one binary (e.g. claude-code + claude-sonnet → `claude`)
|
|
78
|
+
// count together against a shared cap.
|
|
79
|
+
const targetResource = resolveResourceKey(agentName);
|
|
76
80
|
const activeSessions = [];
|
|
77
81
|
for (const session of sessions) {
|
|
78
|
-
if (session.agent !==
|
|
82
|
+
if (resolveResourceKey(session.agent) !== targetResource)
|
|
79
83
|
continue;
|
|
80
84
|
const lastSeen = new Date(session.last_seen_at).getTime();
|
|
81
85
|
if (isNaN(lastSeen))
|
|
@@ -84,18 +88,20 @@ export function checkActiveInstance(agentName, cwd) {
|
|
|
84
88
|
activeSessions.push(session.session_id);
|
|
85
89
|
}
|
|
86
90
|
}
|
|
87
|
-
|
|
88
|
-
|
|
91
|
+
// Limit resolved from the chain (default unlimited for parallelizable CLI
|
|
92
|
+
// agents; structural floor for non-spawnable IDE agents). Infinity → no cap.
|
|
93
|
+
const maxAllowed = resolveConcurrencyLimit(agentName);
|
|
89
94
|
const activeCount = activeSessions.length;
|
|
90
95
|
const canSpawnMore = activeCount < maxAllowed;
|
|
96
|
+
const capLabel = Number.isFinite(maxAllowed) ? String(maxAllowed) : '∞';
|
|
91
97
|
return {
|
|
92
98
|
active: !canSpawnMore, // backward compat: active=true means "cannot spawn more"
|
|
93
99
|
canSpawnMore,
|
|
94
100
|
activeCount,
|
|
95
101
|
maxAllowed,
|
|
96
102
|
reason: canSpawnMore
|
|
97
|
-
? `Agent ${agentName} has capacity (${activeCount}/${
|
|
98
|
-
: `Agent ${agentName} at capacity (${activeCount}/${
|
|
103
|
+
? `Agent ${agentName} has capacity (${activeCount}/${capLabel} slots used)`
|
|
104
|
+
: `Agent ${agentName} at capacity (${activeCount}/${capLabel} slots used)`,
|
|
99
105
|
activeSessions,
|
|
100
106
|
};
|
|
101
107
|
}
|
package/dist/core/identity.js
CHANGED
|
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import { detectAiAgent } from './ai-agent-detection.js';
|
|
5
6
|
import { requireRegisteredAgentIdentity } from './agent-registry.js';
|
|
6
7
|
import { loadConfig } from './config.js';
|
|
7
8
|
import { resolveCurrentHostId } from './host.js';
|
|
@@ -75,31 +76,42 @@ export function loadCurrentSession(cwd) {
|
|
|
75
76
|
const dir = sessionsDir(cwd);
|
|
76
77
|
const currentUser = resolveCurrentUser();
|
|
77
78
|
const currentAgent = resolveCurrentAgentName();
|
|
78
|
-
|
|
79
|
+
const explicitSessionId = resolveExplicitSessionId();
|
|
80
|
+
const ttlMs = parseDurationToMs(loadConfigSafe(cwd)?.implicit_session_ttl ?? '4h');
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
if (explicitSessionId) {
|
|
83
|
+
const explicit = loadSessionById(explicitSessionId, cwd);
|
|
84
|
+
return explicit && isSessionAlive(explicit, ttlMs, now) ? explicit : undefined;
|
|
85
|
+
}
|
|
86
|
+
// 1. Look in sessions/ directory for the session owned by this process.
|
|
87
|
+
// Multiple parallel agents can have the same agent name/user in one repo;
|
|
88
|
+
// a live different PID is a different agent instance, not our session.
|
|
79
89
|
if (fs.existsSync(dir) && currentAgent) {
|
|
80
90
|
const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
81
|
-
const
|
|
82
|
-
const now = Date.now();
|
|
91
|
+
const legacyPidlessCandidates = [];
|
|
83
92
|
for (const file of files) {
|
|
84
93
|
try {
|
|
85
|
-
const
|
|
86
|
-
const session = {
|
|
87
|
-
...CurrentSessionStateSchema.parse(migration.document),
|
|
88
|
-
schema_version: migration.metadata.currentVersion,
|
|
89
|
-
};
|
|
94
|
+
const session = loadSessionFile(path.join(dir, file));
|
|
90
95
|
// Strict match: agent name must match, user must match (when both are known)
|
|
91
96
|
if (session.agent !== currentAgent)
|
|
92
97
|
continue;
|
|
93
98
|
const userMatch = !session.user || !currentUser || session.user === currentUser;
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
if (!userMatch || !isSessionAlive(session, ttlMs, now))
|
|
100
|
+
continue;
|
|
101
|
+
if (session.pid === process.pid) {
|
|
96
102
|
return session;
|
|
97
103
|
}
|
|
104
|
+
if (session.pid === undefined) {
|
|
105
|
+
legacyPidlessCandidates.push(session);
|
|
106
|
+
}
|
|
98
107
|
}
|
|
99
108
|
catch {
|
|
100
109
|
// skip invalid session files
|
|
101
110
|
}
|
|
102
111
|
}
|
|
112
|
+
if (legacyPidlessCandidates.length === 1) {
|
|
113
|
+
return legacyPidlessCandidates[0];
|
|
114
|
+
}
|
|
103
115
|
}
|
|
104
116
|
// 2. Legacy fallback: .current-session
|
|
105
117
|
const legacyPath = path.join(memoryDir(cwd), LEGACY_SESSION_FILE);
|
|
@@ -246,9 +258,24 @@ function resolveCurrentUser() {
|
|
|
246
258
|
function resolveCurrentAgentName() {
|
|
247
259
|
if (process.env.BRAINCLAW_AGENT_NAME)
|
|
248
260
|
return process.env.BRAINCLAW_AGENT_NAME;
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
261
|
+
return detectAiAgent()?.name;
|
|
262
|
+
}
|
|
263
|
+
function resolveExplicitSessionId(env = process.env) {
|
|
264
|
+
return env.BRAINCLAW_SESSION_ID?.trim()
|
|
265
|
+
|| env.OPENCLAW_SESSION_ID?.trim()
|
|
266
|
+
|| env.CLAUDE_SESSION_ID?.trim()
|
|
267
|
+
|| env.COPILOT_SESSION_ID?.trim()
|
|
268
|
+
|| undefined;
|
|
269
|
+
}
|
|
270
|
+
function loadSessionFile(filepath) {
|
|
271
|
+
const migration = loadVersionedJsonFile('current_session', filepath);
|
|
272
|
+
return {
|
|
273
|
+
...CurrentSessionStateSchema.parse(migration.document),
|
|
274
|
+
schema_version: migration.metadata.currentVersion,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function isSessionAlive(session, ttlMs, now) {
|
|
278
|
+
return now - Date.parse(session.last_seen_at) <= ttlMs;
|
|
252
279
|
}
|
|
253
280
|
function loadConfigSafe(cwd) {
|
|
254
281
|
try {
|
|
@@ -382,21 +382,22 @@ function renderAvailableTools() {
|
|
|
382
382
|
'- `bclaw_remove(entity, id, purge?)` — soft-delete (or purge)',
|
|
383
383
|
'- `bclaw_transition(entity, id, to)` — change status (e.g. plan todo→in_progress→done)',
|
|
384
384
|
'',
|
|
385
|
-
'Entities supported by the grammar: plan, decision, constraint, trap, handoff, runtime_note, candidate, claim, action, assignment, agent_run.',
|
|
385
|
+
'Entities supported by the grammar: plan, decision, constraint, trap, handoff, runtime_note, candidate, sequence, claim, action, assignment, agent_run.',
|
|
386
386
|
'',
|
|
387
387
|
'**Cross-project access (pln#359):** every canonical-grammar call, `bclaw_context`, and `bclaw_coordinate` accept an optional `project: <name>` argument that routes the operation to a linked project (cross_project_links from `brainclaw link list` OR a workspace store-chain child). Identity is sourced from the caller; writes + audit land in the target. Unknown project names throw — no silent fallback. The CLI exposes the same as `--project <name>` (mutually exclusive with `--cwd`). Example: `bclaw_get(entity="trap", id="trp#36", project="brainclaw-site")`. Cross-project `bclaw_coordinate` is inbox-only — auto-spawn is force-disabled because the spawn cwd / worktree are tied to the target repo; the target agent picks the brief up async via its own `bclaw_work`.',
|
|
388
388
|
'',
|
|
389
389
|
'**Session + claims:** `bclaw_session_start`, `bclaw_session_end`, `bclaw_claim`, `bclaw_release_claim`',
|
|
390
390
|
'**Plan steps:** `bclaw_add_step`, `bclaw_complete_step`, `bclaw_update_step`, `bclaw_delete_step`',
|
|
391
|
+
'**Sequences:** `bclaw_list_sequences`, `bclaw_create_sequence`, `bclaw_update_sequence`, `bclaw_delete_sequence` — create/activate ordered lanes for parallel dispatch. Item shape: `{ planId, stepId?, rank, hard_after?, soft_after?, lane?, scope_hint?, rationale? }`.',
|
|
391
392
|
'**Inbox + handoffs:** `bclaw_read_inbox`, `bclaw_ack_message`, `bclaw_send_message`, `bclaw_correct_handoff`',
|
|
392
393
|
'**Notes + search:** `bclaw_write_note`, `bclaw_quick_capture`, `bclaw_search`',
|
|
393
394
|
'**Escalation (orchestrator path):**',
|
|
394
395
|
'- Review / consult / assign another agent → `bclaw_coordinate(intent=review|consult|assign)` (use `open_loop=true` on review to also dispatch the reviewer turn)',
|
|
395
|
-
'- Parallel execute across a sequence\'s lanes → `bclaw_dispatch(intent=execute)`',
|
|
396
|
+
'- Parallel execute across a sequence\'s lanes → create/update an active sequence, then `bclaw_dispatch(intent=analysis)` and `bclaw_dispatch(intent=execute)`',
|
|
396
397
|
'- Drive your turn in an already-opened loop → `bclaw_loop(intent=turn|complete_turn|advance|close)`',
|
|
397
398
|
'**Setup + navigation:** `bclaw_setup`, `bclaw_bootstrap`, `bclaw_switch`, `bclaw_release_notes`',
|
|
398
399
|
'',
|
|
399
|
-
'Legacy per-entity tools (`bclaw_list_plans`, `bclaw_accept`, `bclaw_get_context`, `bclaw_dispatch_review`, …) were removed from the catalog at v1.0 — direct calls still succeed as a migration escape hatch but emit a redirect warning. See `docs/integrations/mcp.md` + `docs/concepts/mcp-governance.md` for the full catalog and stability contract
|
|
400
|
+
'Legacy per-entity tools (`bclaw_list_plans`, `bclaw_accept`, `bclaw_get_context`, `bclaw_dispatch_review`, …) were removed from the catalog at v1.0 — direct calls still succeed as a migration escape hatch but emit a redirect warning. See `docs/integrations/mcp.md` + `docs/concepts/mcp-governance.md` for the full catalog and stability contract.',
|
|
400
401
|
].join('\n');
|
|
401
402
|
}
|
|
402
403
|
// ─── Live section renderers ─────────────────────────────────────────────────
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime spawn signals (pln#520 steps 1 + 4) — the file-based, zero-MCP
|
|
3
|
+
* liveness channel between a dispatched worker and brainclaw.
|
|
4
|
+
*
|
|
5
|
+
* Why files, not the tracked pid: on Windows the ack-wrap spawn runs under
|
|
6
|
+
* `shell:true`, so `child.pid` is the cmd.exe wrapper (which dies early),
|
|
7
|
+
* NOT the real worker (cmd.exe → claude.cmd → node.exe). Reading that pid as
|
|
8
|
+
* dead produced false-negative `pid_dead_at_read` cancellations while the
|
|
9
|
+
* worker was alive and committing (can_f792cacd: 6 workers cancelled, then
|
|
10
|
+
* committed 4-7 min later). The fix is to stop trusting the wrapper pid and
|
|
11
|
+
* trust sentinels the worker / wrapper actually write:
|
|
12
|
+
*
|
|
13
|
+
* - `ack` — pre-exec; the spawn shell touched it BEFORE the agent ran
|
|
14
|
+
* (pln#476). Proves delivery, NOT that work started.
|
|
15
|
+
* - `heartbeat` — the worker writes `work_loop_reached{run_id,nonce}` as its
|
|
16
|
+
* FIRST action (step 0 of the generated brief) and refreshes
|
|
17
|
+
* it periodically. Distinct from `ack`: this is what flips
|
|
18
|
+
* execution_status to `started`.
|
|
19
|
+
* - `completed` / `failed` — emitted MECHANICALLY by the spawn wrapper
|
|
20
|
+
* (`agentcmd && completed || failed`) so a dead wrapper pid
|
|
21
|
+
* is never misread as a silent failure.
|
|
22
|
+
*
|
|
23
|
+
* All paths are absolute under the project coordination dir so a worker in a
|
|
24
|
+
* worktree (or a sandboxed agent without MCP) can write them with a plain
|
|
25
|
+
* shell redirect.
|
|
26
|
+
*
|
|
27
|
+
* @module
|
|
28
|
+
*/
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
function runtimeDir(root) {
|
|
32
|
+
return path.join(root, '.brainclaw', 'coordination', 'runtime');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Absolute path for a runtime signal sentinel. `ack` keeps its historical
|
|
36
|
+
* `runtime/ack/<id>.ack` location (pln#476); the liveness signals live under
|
|
37
|
+
* `runtime/signal/<id>.<signal>`.
|
|
38
|
+
*/
|
|
39
|
+
export function getRuntimeSignalPath(root, assignmentId, signal) {
|
|
40
|
+
if (signal === 'ack') {
|
|
41
|
+
return path.join(runtimeDir(root), 'ack', `${assignmentId}.ack`);
|
|
42
|
+
}
|
|
43
|
+
return path.join(runtimeDir(root), 'signal', `${assignmentId}.${signal}`);
|
|
44
|
+
}
|
|
45
|
+
/** Absolute path for a captured stream log (`runtime/log/<id>.{stdout,stderr}.log`). */
|
|
46
|
+
export function getRuntimeLogPath(root, assignmentId, stream) {
|
|
47
|
+
return path.join(runtimeDir(root), 'log', `${assignmentId}.${stream}.log`);
|
|
48
|
+
}
|
|
49
|
+
/** Ensure the ack / signal / log directories exist (best-effort, recursive). */
|
|
50
|
+
export function ensureRuntimeDirs(root) {
|
|
51
|
+
const base = runtimeDir(root);
|
|
52
|
+
for (const sub of ['ack', 'signal', 'log']) {
|
|
53
|
+
fs.mkdirSync(path.join(base, sub), { recursive: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function signalExists(root, assignmentId, signal) {
|
|
57
|
+
try {
|
|
58
|
+
return fs.existsSync(getRuntimeSignalPath(root, assignmentId, signal));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Read the heartbeat sentinel. The body is expected to be
|
|
66
|
+
* `work_loop_reached{run_id,nonce}` JSON, but a bare `touch` (empty file) still
|
|
67
|
+
* counts as a heartbeat — the mtime alone is a valid life-sign.
|
|
68
|
+
*/
|
|
69
|
+
export function readHeartbeat(root, assignmentId) {
|
|
70
|
+
const p = getRuntimeSignalPath(root, assignmentId, 'heartbeat');
|
|
71
|
+
try {
|
|
72
|
+
const stat = fs.statSync(p);
|
|
73
|
+
const info = { exists: true, mtimeMs: stat.mtimeMs };
|
|
74
|
+
try {
|
|
75
|
+
const raw = fs.readFileSync(p, 'utf-8').trim();
|
|
76
|
+
if (raw) {
|
|
77
|
+
const parsed = JSON.parse(raw);
|
|
78
|
+
if (typeof parsed.run_id === 'string')
|
|
79
|
+
info.runId = parsed.run_id;
|
|
80
|
+
if (typeof parsed.nonce === 'string')
|
|
81
|
+
info.nonce = parsed.nonce;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch { /* empty / non-JSON body — mtime still counts */ }
|
|
85
|
+
return info;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return { exists: false };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Read the tail of a captured stream log (for failed_silent diagnostics). */
|
|
92
|
+
export function readLogTail(root, assignmentId, stream, maxBytes = 2000) {
|
|
93
|
+
try {
|
|
94
|
+
const p = getRuntimeLogPath(root, assignmentId, stream);
|
|
95
|
+
const content = fs.readFileSync(p, 'utf-8');
|
|
96
|
+
return content.length > maxBytes ? content.slice(content.length - maxBytes) : content;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=runtime-signals.js.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pln#520 step 2 — `brainclaw doctor --spawn-check`.
|
|
3
|
+
*
|
|
4
|
+
* A real, minimal spawn round-trip per installed agent BEFORE any real dispatch
|
|
5
|
+
* (and in CI). For each CLI-spawnable agent whose binary is on PATH, it spawns
|
|
6
|
+
* an ack-wrapped probe and waits for the ack + completed sentinels on the
|
|
7
|
+
* current host. This validates delivery + the spawn/handshake/sentinel
|
|
8
|
+
* mechanism on the actual agent×OS cell — it would have caught the 6 silent
|
|
9
|
+
* deaths of can_f792cacd before launching them (a worker that spawns but never
|
|
10
|
+
* reaches completion shows up here as `delivered_no_completion`).
|
|
11
|
+
*
|
|
12
|
+
* Uninstalled agents are skipped (`not_installed`), so this is safe to run in
|
|
13
|
+
* CI where most agent CLIs are absent.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { buildInvokeCommand, getSpawnableAgents, getCapabilityProfile, } from './agent-capability.js';
|
|
21
|
+
import { defaultExecutionAdapter, resolveBinaryOnPath } from './execution-adapters.js';
|
|
22
|
+
import { signalExists, readLogTail } from './runtime-signals.js';
|
|
23
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
|
|
24
|
+
const DEFAULT_PROBE_PROMPT = 'Reply with exactly: OK';
|
|
25
|
+
async function sleep(ms) {
|
|
26
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
27
|
+
}
|
|
28
|
+
/** Check one agent's spawn round-trip. Exposed for focused testing. */
|
|
29
|
+
export async function checkAgentSpawn(agent, options = {}) {
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
const profile = getCapabilityProfile(agent);
|
|
32
|
+
if (!profile?.invoke_template || !profile?.invoke_binary || !profile.runtime.canBeSpawnedCli) {
|
|
33
|
+
return { agent, status: 'no_template', delivered: false, completed: false, duration_ms: 0, detail: 'no CLI invoke template' };
|
|
34
|
+
}
|
|
35
|
+
const binary = resolveBinaryOnPath(profile.invoke_binary);
|
|
36
|
+
if (!binary) {
|
|
37
|
+
return { agent, binary: profile.invoke_binary, status: 'not_installed', delivered: false, completed: false, duration_ms: 0, detail: `binary '${profile.invoke_binary}' not on PATH` };
|
|
38
|
+
}
|
|
39
|
+
const invoke = options.probeFor?.(agent)
|
|
40
|
+
?? buildInvokeCommand(agent, options.probePrompt ?? DEFAULT_PROBE_PROMPT, { mode: 'consult' });
|
|
41
|
+
if (!invoke) {
|
|
42
|
+
return { agent, binary, status: 'no_template', delivered: false, completed: false, duration_ms: 0, detail: 'could not build invoke command' };
|
|
43
|
+
}
|
|
44
|
+
// Isolated signals root so the probe never pollutes the project's runtime dir.
|
|
45
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), `bclaw-spawncheck-${agent}-`));
|
|
46
|
+
const assignmentId = 'spawn_check';
|
|
47
|
+
try {
|
|
48
|
+
defaultExecutionAdapter.start(invoke, { agent, assignmentId, ackRoot: root, worktreePath: root });
|
|
49
|
+
const timeout = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
50
|
+
const deadline = Date.now() + timeout;
|
|
51
|
+
while (Date.now() < deadline) {
|
|
52
|
+
if (signalExists(root, assignmentId, 'completed'))
|
|
53
|
+
break;
|
|
54
|
+
if (signalExists(root, assignmentId, 'failed'))
|
|
55
|
+
break;
|
|
56
|
+
await sleep(100);
|
|
57
|
+
}
|
|
58
|
+
const delivered = signalExists(root, assignmentId, 'ack');
|
|
59
|
+
const completed = signalExists(root, assignmentId, 'completed');
|
|
60
|
+
const failed = signalExists(root, assignmentId, 'failed');
|
|
61
|
+
const duration_ms = Date.now() - start;
|
|
62
|
+
if (completed) {
|
|
63
|
+
return { agent, binary, status: 'ok', delivered, completed: true, duration_ms, detail: 'ack + completed round-trip' };
|
|
64
|
+
}
|
|
65
|
+
if (failed) {
|
|
66
|
+
const tail = readLogTail(root, assignmentId, 'stderr', 400).trim() || readLogTail(root, assignmentId, 'stdout', 400).trim();
|
|
67
|
+
return { agent, binary, status: 'failed', delivered, completed: false, duration_ms, detail: `wrapper reported failure${tail ? ` — ${tail.replace(/\s+/g, ' ').slice(0, 200)}` : ''}` };
|
|
68
|
+
}
|
|
69
|
+
if (delivered) {
|
|
70
|
+
return { agent, binary, status: 'delivered_no_completion', delivered: true, completed: false, duration_ms, detail: `spawned + ack but no completion within ${timeout}ms (silent-death symptom)` };
|
|
71
|
+
}
|
|
72
|
+
return { agent, binary, status: 'failed', delivered: false, completed: false, duration_ms, detail: `no ack within ${timeout}ms — delivery failed` };
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
try {
|
|
76
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
catch { /* best-effort */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function runSpawnCheck(options = {}) {
|
|
82
|
+
const agentNames = options.agents ?? getSpawnableAgents().map((a) => a.name);
|
|
83
|
+
const entries = [];
|
|
84
|
+
for (const agent of agentNames) {
|
|
85
|
+
try {
|
|
86
|
+
entries.push(await checkAgentSpawn(agent, options));
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
entries.push({
|
|
90
|
+
agent, status: 'failed', delivered: false, completed: false, duration_ms: 0,
|
|
91
|
+
detail: `spawn-check threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const installed = entries.filter((e) => e.status !== 'not_installed' && e.status !== 'no_template');
|
|
96
|
+
const ok = installed.filter((e) => e.status === 'ok').length;
|
|
97
|
+
const failures = installed.filter((e) => e.status === 'failed' || e.status === 'delivered_no_completion').length;
|
|
98
|
+
const not_installed = entries.filter((e) => e.status === 'not_installed').length;
|
|
99
|
+
return {
|
|
100
|
+
host_os: process.platform,
|
|
101
|
+
total: entries.length,
|
|
102
|
+
ok,
|
|
103
|
+
failures,
|
|
104
|
+
not_installed,
|
|
105
|
+
entries,
|
|
106
|
+
exit_code: failures > 0 ? 1 : 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function renderSpawnCheckReport(report) {
|
|
110
|
+
const lines = [];
|
|
111
|
+
lines.push(`Spawn-check — ${report.ok} ok / ${report.failures} failed / ${report.not_installed} not installed (host: ${report.host_os})`);
|
|
112
|
+
lines.push('');
|
|
113
|
+
for (const e of report.entries) {
|
|
114
|
+
const icon = e.status === 'ok' ? '✔'
|
|
115
|
+
: e.status === 'not_installed' || e.status === 'no_template' ? '·'
|
|
116
|
+
: '✗';
|
|
117
|
+
lines.push(` ${icon} ${e.agent.padEnd(16)} ${e.status}${e.duration_ms ? ` (${e.duration_ms}ms)` : ''} — ${e.detail}`);
|
|
118
|
+
}
|
|
119
|
+
if (report.failures > 0) {
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push('✗ One or more installed agents failed their spawn round-trip — fix before dispatching.');
|
|
122
|
+
}
|
|
123
|
+
return lines.join('\n');
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=spawn-check.js.map
|
package/dist/facts.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
|
|
2
|
-
// Source: brainclaw v1.7.
|
|
2
|
+
// Source: brainclaw v1.7.2 on 2026-06-04T21:46:11.832Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.7.
|
|
5
|
-
"generated_at": "2026-
|
|
4
|
+
"version": "1.7.2",
|
|
5
|
+
"generated_at": "2026-06-04T21:46:11.832Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 62,
|
|
8
8
|
"published_count": 61,
|
package/dist/facts.json
CHANGED
package/docs/cli.md
CHANGED
|
@@ -1933,7 +1933,7 @@ The default catalog is intentionally small and centred on the canonical grammar.
|
|
|
1933
1933
|
| `bclaw_work(intent)` | Start a session, load context, and (for `intent="execute"`) claim a scope in one call. Returns a compact payload by default (pass `compact: false` for the full context). |
|
|
1934
1934
|
| `bclaw_context(kind)` | Unified context read: `kind` is one of `memory`, `execution`, `board`, `board_summary`, or `delta`. |
|
|
1935
1935
|
|
|
1936
|
-
**Canonical grammar** — your main tool for working with memory entities (plan, decision, constraint, trap, handoff, runtime_note, candidate, claim, action, assignment, agent_run):
|
|
1936
|
+
**Canonical grammar** — your main tool for working with memory entities (plan, decision, constraint, trap, handoff, runtime_note, candidate, sequence, claim, action, assignment, agent_run):
|
|
1937
1937
|
|
|
1938
1938
|
| Tool | Purpose |
|
|
1939
1939
|
|---|---|
|
|
@@ -1944,13 +1944,17 @@ The default catalog is intentionally small and centred on the canonical grammar.
|
|
|
1944
1944
|
| `bclaw_remove(entity, id, purge?)` | Soft-delete (or purge) an entity. |
|
|
1945
1945
|
| `bclaw_transition(entity, id, to)` | Change an entity's status (e.g. plan `todo` → `in_progress` → `done`, candidate `pending` → `accepted`). |
|
|
1946
1946
|
|
|
1947
|
-
**Multi-agent coordination** (escalation path when delegating to other agents):
|
|
1947
|
+
**Multi-agent coordination** (escalation path when delegating to other agents):
|
|
1948
1948
|
|
|
1949
1949
|
| Tool | Purpose |
|
|
1950
1950
|
|---|---|
|
|
1951
1951
|
| `bclaw_coordinate(intent)` | Assign, consult, review, reroute, or summarize across agents. Pass `open_loop: true` on `intent="review"` to also dispatch the reviewer turn. |
|
|
1952
1952
|
| `bclaw_dispatch(intent)` | Parallelize execute across a sequence's lanes (analysis / execute / review). |
|
|
1953
|
-
| `bclaw_loop(intent)` | Drive a turn in an existing multi-turn loop (`turn`, `complete_turn`, `advance`, `close`). Do not call `bclaw_loop(intent="open")` directly without dispatch — use `bclaw_coordinate(intent="review", open_loop: true)` instead. |
|
|
1953
|
+
| `bclaw_loop(intent)` | Drive a turn in an existing multi-turn loop (`turn`, `complete_turn`, `advance`, `close`). Do not call `bclaw_loop(intent="open")` directly without dispatch — use `bclaw_coordinate(intent="review", open_loop: true)` instead. |
|
|
1954
|
+
|
|
1955
|
+
**Sequences**:
|
|
1956
|
+
|
|
1957
|
+
`bclaw_list_sequences`, `bclaw_create_sequence`, `bclaw_update_sequence`, `bclaw_delete_sequence`. Sequence items use `{ planId, stepId?, rank, hard_after?, soft_after?, lane?, scope_hint?, rationale? }`. Create/update a sequence to `active`, inspect readiness with `bclaw_dispatch(intent="analysis")`, then dispatch ready lanes with `bclaw_dispatch(intent="execute")`.
|
|
1954
1958
|
|
|
1955
1959
|
**Session, claims, plans, handoffs**:
|
|
1956
1960
|
|
|
@@ -1960,7 +1964,7 @@ The default catalog is intentionally small and centred on the canonical grammar.
|
|
|
1960
1964
|
|
|
1961
1965
|
`bclaw_write_note`, `bclaw_quick_capture`, `bclaw_search`, `bclaw_setup`, `bclaw_bootstrap`, `bclaw_switch`, `bclaw_release_notes`, `bclaw_doctor`.
|
|
1962
1966
|
|
|
1963
|
-
**Legacy per-entity tools** (`bclaw_list_plans` / `bclaw_list_claims` / `bclaw_list_candidates
|
|
1967
|
+
**Legacy per-entity tools** (`bclaw_list_plans` / `bclaw_list_claims` / `bclaw_list_candidates`, `bclaw_get_context` / `bclaw_get_execution_context` / `bclaw_get_agent_board`, `bclaw_create_plan` / `bclaw_create_candidate`, `bclaw_update_plan` / `bclaw_update_handoff` / `bclaw_update_memory`, `bclaw_read_handoff` / `bclaw_delete_memory`, `bclaw_accept` / `bclaw_reject`, `bclaw_dispatch_analysis` / `bclaw_dispatch_review`, and others) were removed from the discoverable catalog at v1.0. Direct calls still succeed as a migration escape hatch but emit a redirect warning pointing at the canonical grammar. The full removal list and replacement map lives in [`docs/mcp-schema-changelog.md`](mcp-schema-changelog.md) under the v1.0 "Removed" section. Raw MCP clients can request the full advanced catalog with `tools/list` params `{ catalog: "all" }`.
|
|
1964
1968
|
|
|
1965
1969
|
---
|
|
1966
1970
|
|