beehaven 0.8.36 → 0.8.39
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/dist/agent-limit-refusal.js +43 -0
- package/dist/agent-mint-response.js +10 -0
- package/dist/agent-state.js +8 -1
- package/dist/build-channel.js +2 -2
- package/dist/call-brief.js +172 -0
- package/dist/claude-bridge.js +4 -1
- package/dist/clearly.js +0 -0
- package/dist/cli.js +19 -4
- package/dist/commands/agent.js +27 -3
- package/dist/commands/daemon-cmd.js +17 -1
- package/dist/commands/help.js +3 -1
- package/dist/commands/workspace.js +50 -11
- package/dist/connection-ready.js +25 -0
- package/dist/daemon-client.js +7 -2
- package/dist/daemon-process.js +26 -11
- package/dist/daemon.js +4 -3
- package/dist/ipc-client.js +4 -1
- package/dist/ipc-server.js +4 -1
- package/dist/limit-advice.js +32 -0
- package/dist/relay.js +19 -6
- package/dist/utf8-stream.js +23 -0
- package/package.json +1 -1
- package/scripts/sync-skills.mjs +12 -0
- package/skills/clearly-agent/SKILL.md +13 -1
- package/skills/clearly-artifacts/SKILL.md +6 -5
- package/skills/clearly-canvas/SKILL.md +16 -8
- package/skills/clearly-canvas/authoring.md +40 -17
- package/skills/clearly-canvas/review.mjs +25 -4
- package/skills/clearly-docs/SKILL.md +39 -2
- package/skills/clearly-init/SKILL.md +19 -10
- package/skills/clearly-workflows/SKILL.md +3 -3
- package/skills/index.json +22 -7
- package/skills/print-production/SKILL.md +96 -0
- package/skills/print-production/formats.md +90 -0
- package/skills/social-posts/SKILL.md +46 -0
- package/skills/visualize/SKILL.md +5 -6
- package/dist/daemon-codex.js +0 -349
- package/dist/daemon-write-sandbox.js +0 -191
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP2-65 — what `beehaven agent login <new name>` says when the workspace already has all the
|
|
3
|
+
* agents its plan includes (Free includes one).
|
|
4
|
+
*
|
|
5
|
+
* The identity mints at the account; joining the workspace is where the cap refuses
|
|
6
|
+
* (`agent-session-start` → automatic admission → `agent-create`, code `agent-limit-reached`). The
|
|
7
|
+
* reason alone left the person at a wall. The three choices, in this order:
|
|
8
|
+
* 1. log in as an agent they already have — one agent works from several apps;
|
|
9
|
+
* 2. upgrade for more agents (a pricing link: this is the terminal, not an MCP tool result);
|
|
10
|
+
* 3. archive one in Agents settings, then log in again.
|
|
11
|
+
*
|
|
12
|
+
* Pure: returns plain lines, the command colours them.
|
|
13
|
+
*/
|
|
14
|
+
export const AGENT_LIMIT_CODE = 'agent-limit-reached';
|
|
15
|
+
export function isAgentLimitRefusal(code) {
|
|
16
|
+
return String(code ?? '') === AGENT_LIMIT_CODE;
|
|
17
|
+
}
|
|
18
|
+
/** An absolute pricing link from the refusal's relative `upgradeUrl` and the web origin for this env. */
|
|
19
|
+
export function upgradeLink(origin, upgradeUrl) {
|
|
20
|
+
const base = String(origin || 'https://www.clearly.sh').replace(/\/+$/, '');
|
|
21
|
+
const raw = typeof upgradeUrl === 'string' ? upgradeUrl.trim() : '';
|
|
22
|
+
// Only a site-relative path is joined; anything else falls back to the pricing page.
|
|
23
|
+
const path = /^\/(?!\/)[A-Za-z0-9/_?=&.-]*$/.test(raw) ? raw : '/pricing';
|
|
24
|
+
return `${base}${path}`;
|
|
25
|
+
}
|
|
26
|
+
export function agentLimitLoginLines(opts) {
|
|
27
|
+
const agents = (Array.isArray(opts.details?.activeAgents) ? opts.details.activeAgents : [])
|
|
28
|
+
.map((a) => ({ id: String(a?.id ?? '').trim(), name: String(a?.name ?? '').trim() }))
|
|
29
|
+
.filter((a) => a.id && a.id !== opts.name);
|
|
30
|
+
const first = agents[0];
|
|
31
|
+
const listed = agents.map((a) => (a.name && a.name !== a.id ? `${a.id} ("${a.name}")` : a.id)).join(', ');
|
|
32
|
+
const reason = String(opts.reason || '').trim();
|
|
33
|
+
return {
|
|
34
|
+
headline: `Signed in, but ${opts.workspace || 'this workspace'} is at its agent limit${reason ? `: ${reason}` : '.'}`,
|
|
35
|
+
choices: [
|
|
36
|
+
first
|
|
37
|
+
? `Use the agent you already have (one agent works from several apps): beehaven agent login ${first.id}${agents.length > 1 ? ` · also yours here: ${listed}` : ''}`
|
|
38
|
+
: 'Use an agent you already have (one agent works from several apps): beehaven agent login <its name>',
|
|
39
|
+
`Upgrade for more agents: ${upgradeLink(opts.origin, opts.details?.upgradeUrl)}`,
|
|
40
|
+
`Or archive one in Agents settings, then run: beehaven agent login ${opts.name}`,
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Only an explicit appearance choice may rebind a portable identity on another device. */
|
|
2
|
+
export function accountMintRequest(agentId, requestedAvatarId) {
|
|
3
|
+
return { agentId, ...(requestedAvatarId ? { avatarId: requestedAvatarId } : {}) };
|
|
4
|
+
}
|
|
5
|
+
export function accountMintResponse(response) {
|
|
6
|
+
const value = response?.result ?? response;
|
|
7
|
+
if (!value?.token)
|
|
8
|
+
throw Object.assign(new Error(`${String(value?.error || 'The server returned no agent token')}${value?.code ? ` (${value.code})` : ''}`), { code: value?.code || 'mint-refused' });
|
|
9
|
+
return value;
|
|
10
|
+
}
|
package/dist/agent-state.js
CHANGED
|
@@ -365,11 +365,18 @@ export function loginSessionOutcome(started, thrown) {
|
|
|
365
365
|
return { kind: 'started', sessionId: id, brief: payload?.brief };
|
|
366
366
|
const err = payload?.error ?? started?.error;
|
|
367
367
|
if (err) {
|
|
368
|
+
const code = String(payload?.code ?? started?.code ?? '');
|
|
368
369
|
return {
|
|
369
370
|
kind: 'refused',
|
|
370
371
|
reason: typeof err === 'string' ? err : String(err?.message ?? JSON.stringify(err)),
|
|
371
|
-
code
|
|
372
|
+
code,
|
|
372
373
|
scope: String(payload?.scope ?? started?.scope ?? ''),
|
|
374
|
+
// MCP2-65: the agent-cap refusal names the caller's live agents and a pricing path, so the
|
|
375
|
+
// login can print the three choices instead of a bare reason.
|
|
376
|
+
...(code === 'agent-limit-reached' ? { details: {
|
|
377
|
+
activeAgents: Array.isArray(payload?.activeAgents) ? payload.activeAgents : [],
|
|
378
|
+
...(typeof payload?.upgradeUrl === 'string' ? { upgradeUrl: payload.upgradeUrl } : {}),
|
|
379
|
+
} } : {}),
|
|
373
380
|
};
|
|
374
381
|
}
|
|
375
382
|
return { kind: 'refused', reason: 'the workspace returned no session', code: '', scope: '' };
|
package/dist/build-channel.js
CHANGED
|
@@ -53,9 +53,9 @@ export const PUBLIC_BUILD = true;
|
|
|
53
53
|
* ⚠ REWRITTEN IN `dist/` AT BUILD TIME, like `PUBLIC_BUILD`. `unstamped` is what a source checkout
|
|
54
54
|
* carries and what `tsx`/`vitest` see, and it is honest: nothing has stamped them.
|
|
55
55
|
*/
|
|
56
|
-
export const BUILD_SHA = '
|
|
56
|
+
export const BUILD_SHA = '3026e5a147f9';
|
|
57
57
|
/** ISO-8601 minute precision, UTC. Rewritten in `dist/` by `scripts/stamp-build-channel.mjs`. */
|
|
58
|
-
export const BUILD_TIME = '2026-
|
|
58
|
+
export const BUILD_TIME = '2026-11-12T22:52Z';
|
|
59
59
|
/**
|
|
60
60
|
* The build identity as one short string, for `--version`.
|
|
61
61
|
*
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `beehaven call <action> … --brief` — a receipt a person (or an agent's context window) can read.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ WHY THIS EXISTS. A `canvas-act` batch answers with every step's full value plus the lint of
|
|
5
|
+
* every touched artboard — routinely tens of kilobytes. Agents learned to pipe it into a summariser
|
|
6
|
+
* they wrote on the spot (`… 2>&1 | node summ.mjs`), and in the first recorded canvas take (MCP2-20)
|
|
7
|
+
* that improvisation cost four failed tool calls: shell pipes and `/tmp` redirects need approval in
|
|
8
|
+
* a headless agent, and the summariser crashed on the stderr breadcrumb it was fed. The receipt the
|
|
9
|
+
* agent wanted is printed here instead, from the same result, with the exit code unchanged.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ IT DROPS NOTHING THAT DECIDES THE NEXT STEP: failed steps (with their error), rollbacks and
|
|
12
|
+
* skips, lint findings that need fixing, a review's verdict and blocking count. What it leaves out
|
|
13
|
+
* is bulk — per-step echoes, previews, font metadata. `--json` still prints the whole payload.
|
|
14
|
+
*/
|
|
15
|
+
const clip = (s, n) => {
|
|
16
|
+
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
17
|
+
return t.length > n ? `${t.slice(0, n - 1)}…` : t;
|
|
18
|
+
};
|
|
19
|
+
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
20
|
+
/** `canvas.create-node` → `create-node`; `frame.create` stays as written. */
|
|
21
|
+
const verb = (a) => (a.startsWith('canvas.') ? a.slice(7) : a);
|
|
22
|
+
export function briefReceipt(action, result) {
|
|
23
|
+
const r = (result && typeof result === 'object' ? result : { value: result });
|
|
24
|
+
const error = typeof r.error === 'string' && r.error ? r.error : null;
|
|
25
|
+
/**
|
|
26
|
+
* MCP2-28 (codex review) — A FAILED BATCH IS STILL A BATCH. This returned the one-line refusal for
|
|
27
|
+
* any `ok:false`, before looking at `results`, so a rolled-back canvas-act (step 2 of 2 refused)
|
|
28
|
+
* printed a truncated error with no step number and no "rolled back". The batch receipt is read
|
|
29
|
+
* first; the top-level failure is kept on it, so the exit code still says it failed.
|
|
30
|
+
*/
|
|
31
|
+
if (Array.isArray(r.results) && r.results.length) {
|
|
32
|
+
const b = batchReceipt(action, r);
|
|
33
|
+
if (error || r.ok === false) {
|
|
34
|
+
b.ok = false;
|
|
35
|
+
b.lines[0] = b.lines[0].replace(/^✓/, '✗');
|
|
36
|
+
const why = clip(error ?? r.message ?? r.code ?? '', 200);
|
|
37
|
+
// Not repeated when a failed step already says it (the step line clips at 140, this at 200).
|
|
38
|
+
if (why && !b.lines.some((l) => l.includes(why.slice(0, 60))))
|
|
39
|
+
b.lines.splice(1, 0, ` ${why}`);
|
|
40
|
+
}
|
|
41
|
+
return b;
|
|
42
|
+
}
|
|
43
|
+
if (error || r.ok === false) {
|
|
44
|
+
return { ok: false, lines: [`✗ ${action} · ${clip(error ?? r.message ?? r.code ?? 'refused', 160)}`] };
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(r.results))
|
|
47
|
+
return batchReceipt(action, r);
|
|
48
|
+
return { ok: true, lines: [`✓ ${action}${describeValue(r)}`] };
|
|
49
|
+
}
|
|
50
|
+
function batchReceipt(action, r) {
|
|
51
|
+
const steps = r.results;
|
|
52
|
+
const failed = steps.map((s, i) => ({ s, i })).filter(({ s }) => s?.ok === false);
|
|
53
|
+
const lines = [];
|
|
54
|
+
// What ran: a review or a set of measurements reads better as its result than as a step count.
|
|
55
|
+
const reviews = steps.filter((s) => /canvas\.review/.test(s?.action) && s?.ok !== false);
|
|
56
|
+
const measures = steps.filter((s) => s?.action === 'canvas.measure-text' && s?.ok !== false);
|
|
57
|
+
const counts = new Map();
|
|
58
|
+
for (const s of steps)
|
|
59
|
+
counts.set(verb(s?.action ?? '?'), (counts.get(verb(s?.action ?? '?')) ?? 0) + 1);
|
|
60
|
+
const mix = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([a, n]) => (n > 1 ? `${a} ×${n}` : a)).join(', ');
|
|
61
|
+
/**
|
|
62
|
+
* CDS-163 — THE BATCH'S OWN DELTA WINS. Only update steps carry a per-step `canvasChanges`, so
|
|
63
|
+
* summing steps printed "+0 ~69 −0 layers" for a 343-step build that created 211 layers. The worker
|
|
64
|
+
* returns one batch-level `canvasChanges`, diffed over every node the batch addressed (creates
|
|
65
|
+
* included); the per-step sum is kept for an older worker that does not send it.
|
|
66
|
+
*/
|
|
67
|
+
let added = 0, changed = 0, removed = 0, sawDelta = false;
|
|
68
|
+
const isDelta = (d) => !!d && typeof d === 'object' && !Array.isArray(d);
|
|
69
|
+
if (isDelta(r.canvasChanges)) {
|
|
70
|
+
sawDelta = true;
|
|
71
|
+
added = Number(r.canvasChanges.added || 0);
|
|
72
|
+
changed = Number(r.canvasChanges.changed || 0);
|
|
73
|
+
removed = Number(r.canvasChanges.removed || 0);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
for (const s of steps) {
|
|
77
|
+
const d = s?.value?.canvasChanges;
|
|
78
|
+
if (isDelta(d)) {
|
|
79
|
+
sawDelta = true;
|
|
80
|
+
added += Number(d.added || 0);
|
|
81
|
+
changed += Number(d.changed || 0);
|
|
82
|
+
removed += Number(d.removed || 0);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const rev = r.canvasNodesRev ?? [...steps].reverse().find((s) => s?.value?.canvasNodesRev != null)?.value?.canvasNodesRev;
|
|
87
|
+
const head = [`${failed.length ? '✗' : '✓'} ${action}`, `${plural(steps.length, 'step')}${mix ? ` (${mix})` : ''}`];
|
|
88
|
+
if (sawDelta && added + changed + removed)
|
|
89
|
+
head.push(`+${added} ~${changed} −${removed} layers`);
|
|
90
|
+
if (rev != null)
|
|
91
|
+
head.push(`rev ${rev}`);
|
|
92
|
+
if (r.rolledBack)
|
|
93
|
+
head.push('rolled back');
|
|
94
|
+
if (r.partial)
|
|
95
|
+
head.push('partly applied');
|
|
96
|
+
if (Array.isArray(r.skipped) ? r.skipped.length : r.skipped)
|
|
97
|
+
head.push(`${Array.isArray(r.skipped) ? r.skipped.length : r.skipped} skipped`);
|
|
98
|
+
lines.push(head.join(' · '));
|
|
99
|
+
for (const { s, i } of failed.slice(0, 3)) {
|
|
100
|
+
lines.push(` ✗ step ${i + 1} ${verb(s.action ?? '?')}: ${clip(s.error ?? s.value?.error ?? 'failed', 140)}`);
|
|
101
|
+
}
|
|
102
|
+
if (failed.length > 3)
|
|
103
|
+
lines.push(` … ${failed.length - 3} more failed steps`);
|
|
104
|
+
for (const m of measures.slice(0, 6)) {
|
|
105
|
+
const v = m.value ?? {};
|
|
106
|
+
lines.push(` ${Math.round(v.width ?? 0)}×${Math.round(v.height ?? 0)} · ${plural(v.lineCount ?? 1, 'line')}${v.exact === false ? ' · estimated' : ''} — ${v.family ?? ''} ${v.size ?? ''}/${v.fontWeight ?? ''}`.trimEnd());
|
|
107
|
+
}
|
|
108
|
+
for (const rv of reviews) {
|
|
109
|
+
const v = rv.value ?? {};
|
|
110
|
+
const reports = Array.isArray(v.reviews) ? v.reviews : [v];
|
|
111
|
+
for (const rep of reports) {
|
|
112
|
+
const blocking = rep.blocking?.craft ?? 0;
|
|
113
|
+
const where = rep.scope?.name ?? rep.scope?.ids?.[0] ?? '';
|
|
114
|
+
lines.push(` review${where ? ` ${clip(where, 40)}` : ''}: ${rep.verdict ?? 'unknown'}${blocking ? ` · ${plural(blocking, 'blocking finding')}` : ''}`);
|
|
115
|
+
for (const f of (rep.craft?.findings ?? []).filter((x) => x.severity === 'warn').slice(0, 3)) {
|
|
116
|
+
lines.push(` ${f.check}: ${clip(f.detail, 120)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Lint of the touched artboards: what must be fixed next, never the whole report.
|
|
121
|
+
for (const d of (Array.isArray(r.diagnostics) ? r.diagnostics : [])) {
|
|
122
|
+
const errors = d.errors ?? [];
|
|
123
|
+
if (!errors.length && d.verdict !== 'fix')
|
|
124
|
+
continue;
|
|
125
|
+
lines.push(` lint ${clip(d.artboard ?? d.id, 40)}: ${d.verdict ?? 'fix'}`);
|
|
126
|
+
for (const e of errors.slice(0, 3))
|
|
127
|
+
lines.push(` ${clip(e, 140)}`);
|
|
128
|
+
if (errors.length > 3 || d.moreErrors)
|
|
129
|
+
lines.push(` … ${(errors.length - 3 > 0 ? errors.length - 3 : 0) + (d.moreErrors ?? 0)} more`);
|
|
130
|
+
}
|
|
131
|
+
const clean = (Array.isArray(r.diagnostics) ? r.diagnostics : []).filter((d) => d.verdict && d.verdict !== 'fix');
|
|
132
|
+
if (clean.length)
|
|
133
|
+
lines.push(` lint ${[...new Set(clean.map((d) => d.verdict))].join('/')} on ${plural(clean.length, 'artboard')}`);
|
|
134
|
+
/**
|
|
135
|
+
* MCP2-25 — THE CARRIED TICKET'S OUTCOME DECIDES THE NEXT STEP TOO. A batch carrying `ticket` moves
|
|
136
|
+
* it only when its review passes; the receipt said nothing either way, so an agent could not tell a
|
|
137
|
+
* done commit from one the review held back.
|
|
138
|
+
*/
|
|
139
|
+
const t = r.ticket;
|
|
140
|
+
if (t && typeof t === 'object') {
|
|
141
|
+
if (t.applied)
|
|
142
|
+
lines.push(` ticket ${t.key ?? t.id} → ${t.status ?? 'updated'}${t.frame?.url ? ` · frame ${t.frame.url}` : ''}`);
|
|
143
|
+
else
|
|
144
|
+
lines.push(` ✗ ticket ${t.id ?? ''} not moved: ${clip(t.reason ?? t.error ?? 'refused', 200)}`);
|
|
145
|
+
}
|
|
146
|
+
return { ok: failed.length === 0, lines };
|
|
147
|
+
}
|
|
148
|
+
/** A one-line description of an ordinary result: its identity and what it holds. */
|
|
149
|
+
function describeValue(r) {
|
|
150
|
+
const bits = [];
|
|
151
|
+
const id = r.key ?? r.id ?? r.compositionId ?? r.documentId;
|
|
152
|
+
if (id)
|
|
153
|
+
bits.push(String(id).length > 24 ? String(id).slice(0, 8) : String(id));
|
|
154
|
+
const title = r.title ?? r.name;
|
|
155
|
+
if (typeof title === 'string' && title)
|
|
156
|
+
bits.push(`"${clip(title, 60)}"`);
|
|
157
|
+
if (typeof r.status === 'string')
|
|
158
|
+
bits.push(r.status);
|
|
159
|
+
if (r.verdict)
|
|
160
|
+
bits.push(`verdict ${r.verdict}`);
|
|
161
|
+
for (const [k, v] of Object.entries(r)) {
|
|
162
|
+
if (Array.isArray(v) && v.length && bits.length < 4 && !['results', 'diagnostics'].includes(k))
|
|
163
|
+
bits.push(`${v.length} ${k}`);
|
|
164
|
+
}
|
|
165
|
+
if (typeof r.count === 'number' && !bits.some((b) => b.endsWith(' items')))
|
|
166
|
+
bits.push(`count ${r.count}`);
|
|
167
|
+
if (typeof r.url === 'string')
|
|
168
|
+
bits.push(r.url);
|
|
169
|
+
if (r.value !== undefined && typeof r.value !== 'object')
|
|
170
|
+
bits.push(clip(r.value, 120));
|
|
171
|
+
return bits.length ? ` · ${bits.join(' · ')}` : '';
|
|
172
|
+
}
|
package/dist/claude-bridge.js
CHANGED
|
@@ -22,6 +22,7 @@ import { homedir } from 'os';
|
|
|
22
22
|
import { EventEmitter } from 'events';
|
|
23
23
|
import { activeAgentWrap } from './agent-attribution.js';
|
|
24
24
|
import { ipcSocketPath, watcherSocketPath, socketMayExist } from './ipc-path.js';
|
|
25
|
+
import { utf8Stream } from './utf8-stream.js';
|
|
25
26
|
const CONFIG_DIR = join(homedir(), '.beehaven');
|
|
26
27
|
export class ClaudeBridge extends EventEmitter {
|
|
27
28
|
socket = null;
|
|
@@ -56,8 +57,10 @@ export class ClaudeBridge extends EventEmitter {
|
|
|
56
57
|
this.send({ type: 'session:announce', label: 'claude-code', app: 'bridge' });
|
|
57
58
|
resolve(true);
|
|
58
59
|
});
|
|
60
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
61
|
+
const decode = utf8Stream();
|
|
59
62
|
socket.on('data', (data) => {
|
|
60
|
-
this.buffer += data
|
|
63
|
+
this.buffer += decode(data);
|
|
61
64
|
const lines = this.buffer.split('\n');
|
|
62
65
|
this.buffer = lines.pop() || '';
|
|
63
66
|
for (const line of lines) {
|
package/dist/clearly.js
CHANGED
|
File without changes
|
package/dist/cli.js
CHANGED
|
@@ -408,7 +408,7 @@ ${DEV_ONLY_HELP ? `
|
|
|
408
408
|
beehaven actions --all …including ones this caller cannot invoke
|
|
409
409
|
beehaven actions --search <q> Find one by substring
|
|
410
410
|
beehaven explain <action> Its exact arguments, from the live schema
|
|
411
|
-
beehaven call <action> [json] Invoke it; returns the raw JSON result
|
|
411
|
+
beehaven call <action> [json] Invoke it; returns the raw JSON result (--brief: a short receipt; --out <file>: the JSON to a file)
|
|
412
412
|
|
|
413
413
|
# discover, then run
|
|
414
414
|
beehaven actions --search ticket
|
|
@@ -1443,6 +1443,8 @@ async function run() {
|
|
|
1443
1443
|
address: { type: 'string' },
|
|
1444
1444
|
protocol: { type: 'string' },
|
|
1445
1445
|
json: { type: 'boolean', default: false },
|
|
1446
|
+
// `beehaven call … --brief`: a short receipt instead of the full JSON (call-brief.ts).
|
|
1447
|
+
brief: { type: 'boolean', default: false },
|
|
1446
1448
|
// `--conversation <id>` threads `beehaven agent "<msg>"` onto an existing
|
|
1447
1449
|
// Omni thread instead of minting a new one per invocation.
|
|
1448
1450
|
conversation: { type: 'string' },
|
|
@@ -1879,6 +1881,7 @@ async function run() {
|
|
|
1879
1881
|
* would be refused while `conversations --json` worked.
|
|
1880
1882
|
*/
|
|
1881
1883
|
const JSON_CAPABLE = new Set([
|
|
1884
|
+
'doc', 'search',
|
|
1882
1885
|
'admin', 'apps', 'd1', 'describe', 'explain', 'marketplace', 'secrets',
|
|
1883
1886
|
'shop', 'user', 'webhook', 'webhooks', 'workspaces', 'ws',
|
|
1884
1887
|
// Honour what the modules already implement.
|
|
@@ -2090,6 +2093,8 @@ async function run() {
|
|
|
2090
2093
|
'slides', 'deck',
|
|
2091
2094
|
// `brand tokens -o tokens.css` · `component render -o button.svg`
|
|
2092
2095
|
'brand', 'brands', 'component', 'components',
|
|
2096
|
+
// MCP2-25 — `call <action> … --out result.json`: the whole result to a file, the receipt to the console.
|
|
2097
|
+
'call',
|
|
2093
2098
|
]);
|
|
2094
2099
|
/**
|
|
2095
2100
|
* ⚠ ONE REFUSAL, because there is now one flag. Both spellings are named in the message so a
|
|
@@ -3060,13 +3065,13 @@ async function run() {
|
|
|
3060
3065
|
}
|
|
3061
3066
|
console.log('');
|
|
3062
3067
|
console.log(` ${color.green('✓')} Signed in as ${color.bold(captured.email ?? captured.userId)}`);
|
|
3063
|
-
console.log(`
|
|
3068
|
+
console.log(` Sign-in saved on this device`);
|
|
3064
3069
|
// Auto-restart the daemon. New tokens won't take effect until
|
|
3065
3070
|
// the daemon restarts (it reads creds at startup, keeps them
|
|
3066
3071
|
// in memory, has no hot-reload IPC verb). Doing the restart
|
|
3067
3072
|
// here matches gh / firebase-tools UX — login-then-go.
|
|
3068
3073
|
const { findLiveDaemons, stopDaemonProcess } = await import('./daemon.js');
|
|
3069
|
-
const { spawnStandaloneDaemon } = await import('./commands/daemon-cmd.js');
|
|
3074
|
+
const { spawnStandaloneDaemon, waitForLoginConnection } = await import('./commands/daemon-cmd.js');
|
|
3070
3075
|
/**
|
|
3071
3076
|
* ⚠⚠ THE RESTART IS PLUMBING AND WAS NARRATED AS THOUGH IT WERE THE POINT. What a person saw
|
|
3072
3077
|
* after signing in was:
|
|
@@ -3111,6 +3116,7 @@ async function run() {
|
|
|
3111
3116
|
* started with developer scope.
|
|
3112
3117
|
*/
|
|
3113
3118
|
const pid = spawnStandaloneDaemon(values);
|
|
3119
|
+
const connection = await waitForLoginConnection();
|
|
3114
3120
|
/**
|
|
3115
3121
|
* ⚠ ONE OUTCOME LINE, IN THE SAME SHAPE AS THE ONE ABOVE IT. `✓ (PID 73532)` was a tick, a
|
|
3116
3122
|
* bracket and a number — grammatically nothing, and unreadable as a result. This says what
|
|
@@ -3121,7 +3127,16 @@ async function run() {
|
|
|
3121
3127
|
* `beehaven status` labels the same thing `Relay:`. Naming the outcome rather than the
|
|
3122
3128
|
* process is what stops a sign-in ending on the word "stopped".
|
|
3123
3129
|
*/
|
|
3124
|
-
|
|
3130
|
+
if (connection.ready) {
|
|
3131
|
+
console.log(` ${color.green('✓')} Connected — your new sign-in is live`);
|
|
3132
|
+
}
|
|
3133
|
+
else {
|
|
3134
|
+
console.error(` ${color.yellow('!')} Signed in, but the workspace connection is not ready.`);
|
|
3135
|
+
console.error(connection.reason === 'environment-mismatch'
|
|
3136
|
+
? ' The running service is on a different environment. Run `beehaven status` before retrying.'
|
|
3137
|
+
: ' Run `beehaven status` to check the connection, then retry your workspace command.');
|
|
3138
|
+
process.exitCode = 2;
|
|
3139
|
+
}
|
|
3125
3140
|
console.log(` Background service ${wasRunning ? 'restarted' : 'started'} (PID ${pid})`);
|
|
3126
3141
|
/**
|
|
3127
3142
|
* ⚠⚠ TEACH THE AGENTS AS PART OF SIGNING IN (PPO-190). "Install the CLI, log in, and your
|
package/dist/commands/agent.js
CHANGED
|
@@ -25,6 +25,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
|
25
25
|
import { join, dirname } from 'node:path';
|
|
26
26
|
import { homedir } from 'node:os';
|
|
27
27
|
import { rpcOnce } from '../ipc-client.js';
|
|
28
|
+
import { accountMintRequest, accountMintResponse } from '../agent-mint-response.js';
|
|
29
|
+
import { agentLimitLoginLines, isAgentLimitRefusal } from '../agent-limit-refusal.js';
|
|
28
30
|
import { saveAgent, loadAgent, listAgents, deleteAgent, findAgentByAvatar, isValidAgentName, resolveActiveAgent, activeAgentName, saveAgentSession, clearAgentSession, clearAgentSessionId, decodeAgentTokenClaims, } from '../agent-state.js';
|
|
29
31
|
const VALID_KINDS = ['claude-code', 'daemon-agent', 'clearly-ai'];
|
|
30
32
|
export const FREE_SKINS = [
|
|
@@ -643,7 +645,7 @@ async function agentLogin(args, values) {
|
|
|
643
645
|
}
|
|
644
646
|
const avatarId = requestedAvatarId || existingRecord?.avatarId || chosenSkin || name;
|
|
645
647
|
// Local-first avatar-collision guard (durable guard is the cloud pool).
|
|
646
|
-
const collision = findAgentByAvatar(
|
|
648
|
+
const collision = requestedAvatarId ? findAgentByAvatar(requestedAvatarId, name) : null;
|
|
647
649
|
if (collision) {
|
|
648
650
|
console.error(` Avatar "${avatarId}" is already claimed by agent "${collision}".`);
|
|
649
651
|
console.error(` Pick another: beehaven agent login ${name} --avatar <other-id>`);
|
|
@@ -670,7 +672,7 @@ async function agentLogin(args, values) {
|
|
|
670
672
|
* credential.
|
|
671
673
|
*/
|
|
672
674
|
try {
|
|
673
|
-
const cloud = await rpcOnce('agent-account-mint',
|
|
675
|
+
const cloud = accountMintResponse(await rpcOnce('agent-account-mint', accountMintRequest(name, requestedAvatarId), 15_000));
|
|
674
676
|
const tok = cloud?.token ?? cloud?.result?.token;
|
|
675
677
|
if (tok) {
|
|
676
678
|
minted = {
|
|
@@ -698,7 +700,14 @@ async function agentLogin(args, values) {
|
|
|
698
700
|
}
|
|
699
701
|
catch (err) {
|
|
700
702
|
console.error(` Could not mint identity: ${err?.message ?? err}`);
|
|
701
|
-
|
|
703
|
+
if (String(err?.code) === 'identity-limit-reached') {
|
|
704
|
+
// MCP2-71: the account's safety ceiling — no plan lifts it; signing in as one you have always works.
|
|
705
|
+
console.error(' Sign in as an agent you already have: beehaven agent list, then beehaven agent login <name>');
|
|
706
|
+
console.error(' Or delete one you no longer use in Agents, then run this again.');
|
|
707
|
+
process.exit(1);
|
|
708
|
+
}
|
|
709
|
+
if (!err?.code || /^(ECONN|ENOENT|ETIMEDOUT)/.test(String(err.code)))
|
|
710
|
+
console.error(' Is the daemon connected? Check with: beehaven status');
|
|
702
711
|
process.exit(1);
|
|
703
712
|
}
|
|
704
713
|
/**
|
|
@@ -962,6 +971,21 @@ async function agentLogin(args, values) {
|
|
|
962
971
|
console.log(` ${green('◷')} Workspace work session ${cyan(outcome.sessionId.slice(0, 8))} started.`);
|
|
963
972
|
printBrief(outcome.brief);
|
|
964
973
|
}
|
|
974
|
+
else if (outcome.kind === 'refused' && isAgentLimitRefusal(outcome.code)) {
|
|
975
|
+
// MCP2-65: at the plan's agent limit, say what to do — not just that it was refused.
|
|
976
|
+
const { resolveAuthOrigin } = await import('../browser-login.js');
|
|
977
|
+
const lines = agentLimitLoginLines({
|
|
978
|
+
name, workspace: (await workspaceLabel(chosenWorkspace)).split(/ {2}\(| — /)[0], reason: outcome.reason,
|
|
979
|
+
details: outcome.details, origin: resolveAuthOrigin().authOrigin,
|
|
980
|
+
});
|
|
981
|
+
console.log('');
|
|
982
|
+
console.error(` ${yellow('⚠')} ${lines.headline}`);
|
|
983
|
+
console.error(' Your choices:');
|
|
984
|
+
for (const line of lines.choices)
|
|
985
|
+
console.error(` · ${line}`);
|
|
986
|
+
console.error(dim(` Calls from this session run as ${name} and will be refused — they will NOT fall back to another agent.`));
|
|
987
|
+
process.exitCode = 1;
|
|
988
|
+
}
|
|
965
989
|
else if (outcome.kind === 'refused') {
|
|
966
990
|
console.log('');
|
|
967
991
|
console.error(` ${yellow('⚠')} Signed in, but this workspace refused ${cyan(name)}: ${outcome.reason}`);
|
|
@@ -16,6 +16,22 @@ import { fileURLToPath } from 'node:url';
|
|
|
16
16
|
import { existsSync } from 'node:fs';
|
|
17
17
|
import { dirname, join } from 'node:path';
|
|
18
18
|
import { findLiveDaemons, resolveRelayUrlForDaemon } from '../daemon.js';
|
|
19
|
+
import { waitForConnection } from '../connection-ready.js';
|
|
20
|
+
/** Each attempt authenticates over IPC; a PID or greeting alone cannot prove a usable sign-in. */
|
|
21
|
+
export async function waitForLoginConnection() {
|
|
22
|
+
const { IpcClient } = await import('../ipc-client.js');
|
|
23
|
+
return waitForConnection(async () => {
|
|
24
|
+
const client = new IpcClient({ timeout: 1000 });
|
|
25
|
+
try {
|
|
26
|
+
await client.connect();
|
|
27
|
+
const state = await client.call('daemon:session-status', {}, 1000);
|
|
28
|
+
return { connected: state?.relay?.connected === true, endpoint: state?.relay?.endpoint };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
client.disconnect();
|
|
32
|
+
}
|
|
33
|
+
}, resolveRelayUrlForDaemon());
|
|
34
|
+
}
|
|
19
35
|
/** dist/daemon-entry.js — sibling of dist/cli.js (this file is dist/commands/). */
|
|
20
36
|
function daemonEntryPath() {
|
|
21
37
|
return join(dirname(fileURLToPath(import.meta.url)), '..', 'daemon-entry.js');
|
|
@@ -82,7 +98,7 @@ export async function startHeadlessDaemon(values) {
|
|
|
82
98
|
const { daemonRpc } = await import('../daemon-client.js');
|
|
83
99
|
let activeRelay = null;
|
|
84
100
|
try {
|
|
85
|
-
const status = await daemonRpc('daemon:status', {});
|
|
101
|
+
const status = await daemonRpc('daemon:session-status', {});
|
|
86
102
|
activeRelay = status?.relay?.endpoint ?? null;
|
|
87
103
|
}
|
|
88
104
|
catch { /* status is advisory; the socket probe already succeeded */ }
|
package/dist/commands/help.js
CHANGED
|
@@ -715,7 +715,7 @@ const PAGES = {
|
|
|
715
715
|
related: ['call', 'pwd', 'workspace'],
|
|
716
716
|
},
|
|
717
717
|
call: {
|
|
718
|
-
synopsis: 'beehaven call <action> [json-input] [--building <type>:<id>] [--json]',
|
|
718
|
+
synopsis: 'beehaven call <action> [json-input | @file | -] [--building <type>:<id>] [--json | --brief]',
|
|
719
719
|
description: `Invoke an RPC on the current target. Input is a JSON string.
|
|
720
720
|
Schema comes from the action's manifest — use \`beehaven actions\` first
|
|
721
721
|
to see what's available, or \`beehaven explain <action>\` for the exact
|
|
@@ -725,10 +725,12 @@ const PAGES = {
|
|
|
725
725
|
flags: [
|
|
726
726
|
{ name: '--building <type>:<id>', desc: 'Explicit target (e.g. shopifyStore:clearly-demo)' },
|
|
727
727
|
{ name: '--json', desc: 'Suppress breadcrumb + hint blocks; raw stdout only' },
|
|
728
|
+
{ name: '--brief', desc: 'A short receipt instead of the JSON: steps, layer changes, failures, lint to fix, review verdicts. Same exit code.' },
|
|
728
729
|
{ name: '--timeout <ms>', desc: 'Override the 30s default' },
|
|
729
730
|
],
|
|
730
731
|
examples: [
|
|
731
732
|
{ cmd: 'beehaven call note-create \'{"title":"Hello"}\'' },
|
|
733
|
+
{ cmd: 'beehaven call canvas-act @build.json --brief', note: 'a canvas batch, read as a one-line receipt' },
|
|
732
734
|
{ cmd: "beehaven call project-list '{}' --building home" },
|
|
733
735
|
{
|
|
734
736
|
cmd: 'beehaven call admin-agent-chat \'{"conversationId":"c1","message":"top sellers?","scope":"read"}\'',
|
|
@@ -26,6 +26,8 @@ import { resolveActiveAgent } from '../agent-state.js';
|
|
|
26
26
|
import { isAgentRuntime } from '../agent-scope.js';
|
|
27
27
|
import { getTarget } from '../cli-state.js';
|
|
28
28
|
import { attachShellCwd, absorbShellCwd, shellTargetKey } from '../shell-session.js';
|
|
29
|
+
import { planLimitAdvice } from '../limit-advice.js';
|
|
30
|
+
import { resolveAuthOrigin } from '../browser-login.js';
|
|
29
31
|
/** Workspace-agent jobs wait for the server's ceiling (900s) plus the report's tail. */
|
|
30
32
|
export const AGENT_TURN_TIMEOUT_MS = 930_000;
|
|
31
33
|
const AGENT_TURN_ACTIONS = new Set(['agent-prompt', 'agent-turn']);
|
|
@@ -1132,7 +1134,36 @@ export async function cmdCall(action, rawInput, values) {
|
|
|
1132
1134
|
printCallErrorHints(raw.message, action, values);
|
|
1133
1135
|
process.exit(1);
|
|
1134
1136
|
}
|
|
1135
|
-
|
|
1137
|
+
/**
|
|
1138
|
+
* MCP2-25 — `call … --out <file>` writes the whole JSON result to a file. The canvas skill tells
|
|
1139
|
+
* agents to save a large response once and read a summary, and the only way was `> file`, which a
|
|
1140
|
+
* headless agent needs permission for: a recorded Claude Code take lost a call to "`--out` is not
|
|
1141
|
+
* supported by `beehaven call`". The receipt (or a one-line note) still prints; the exit code is
|
|
1142
|
+
* the result's, exactly as without the flag.
|
|
1143
|
+
*/
|
|
1144
|
+
const outPath = values.out ?? values.output;
|
|
1145
|
+
if (typeof outPath === 'string' && outPath) {
|
|
1146
|
+
const { mkdirSync, writeFileSync } = await import('node:fs');
|
|
1147
|
+
const { dirname, resolve } = await import('node:path');
|
|
1148
|
+
const file = resolve(outPath);
|
|
1149
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1150
|
+
const text = result && typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
|
1151
|
+
writeFileSync(file, text + '\n');
|
|
1152
|
+
if (!values.brief)
|
|
1153
|
+
console.log(`wrote ${text.length.toLocaleString()} bytes to ${outPath}`);
|
|
1154
|
+
}
|
|
1155
|
+
let briefFailed = false;
|
|
1156
|
+
if (values.brief && !values.json) {
|
|
1157
|
+
// ⚠ THE SAME RESULT, READ FOR A PERSON — see call-brief.ts for what is kept and why.
|
|
1158
|
+
const { briefReceipt } = await import('../call-brief.js');
|
|
1159
|
+
const receipt = briefReceipt(action, result);
|
|
1160
|
+
console.log(receipt.lines.join('\n'));
|
|
1161
|
+
briefFailed = !receipt.ok;
|
|
1162
|
+
}
|
|
1163
|
+
else if (typeof outPath === 'string' && outPath) {
|
|
1164
|
+
// Written above; the console keeps only the note, so a large result stays out of the context.
|
|
1165
|
+
}
|
|
1166
|
+
else if (result && typeof result === 'object') {
|
|
1136
1167
|
console.log(JSON.stringify(result, null, 2));
|
|
1137
1168
|
}
|
|
1138
1169
|
else {
|
|
@@ -1234,7 +1265,7 @@ export async function cmdCall(action, rawInput, values) {
|
|
|
1234
1265
|
}
|
|
1235
1266
|
printKnownActionAdvice(action, input, result);
|
|
1236
1267
|
}
|
|
1237
|
-
if (isFailureResult(result))
|
|
1268
|
+
if (briefFailed || isFailureResult(result))
|
|
1238
1269
|
process.exit(1);
|
|
1239
1270
|
}
|
|
1240
1271
|
catch (err) {
|
|
@@ -1258,6 +1289,16 @@ function printKnownActionAdvice(action, input, result) {
|
|
|
1258
1289
|
if (action === 'ticket-get' && result?.code === 'not-found') {
|
|
1259
1290
|
console.error(' If this key belongs to a document, read it with document-get; ticket-get reads board cards only.');
|
|
1260
1291
|
}
|
|
1292
|
+
// MCP2-70 / MCP2-71: a plan or account limit says what is true and what to do, in words.
|
|
1293
|
+
if (/-limit-reached$/.test(String(result?.code ?? ''))) {
|
|
1294
|
+
let origin = 'https://www.clearly.sh';
|
|
1295
|
+
try {
|
|
1296
|
+
origin = resolveAuthOrigin().authOrigin;
|
|
1297
|
+
}
|
|
1298
|
+
catch { /* the default web origin */ }
|
|
1299
|
+
for (const line of planLimitAdvice(result, origin))
|
|
1300
|
+
console.error(line);
|
|
1301
|
+
}
|
|
1261
1302
|
}
|
|
1262
1303
|
/**
|
|
1263
1304
|
* Emit navigation hints for common call failures. Agents parse these to
|
|
@@ -2434,8 +2475,8 @@ export async function cmdTask(title, values) {
|
|
|
2434
2475
|
}
|
|
2435
2476
|
// ── beehaven doc add <file> — local file → searchable project document ───
|
|
2436
2477
|
//
|
|
2437
|
-
// One-shot: reads a local file
|
|
2438
|
-
//
|
|
2478
|
+
// One-shot: reads a local file and creates it through document-create.
|
|
2479
|
+
// Content lands in the workspace SQLite note-content
|
|
2439
2480
|
// table + FTS5 index, so `search-notes` and the cloud agent can retrieve it.
|
|
2440
2481
|
// This is the DOCUMENT path; `teleport` is the raw-blob (R2) path. Markdown is
|
|
2441
2482
|
// stored verbatim; code is wrapped in a fenced block (with the language) so it
|
|
@@ -2496,16 +2537,14 @@ export async function cmdDocAdd(filePath, values) {
|
|
|
2496
2537
|
const title = values.title ||
|
|
2497
2538
|
(isMarkdown ? raw.match(/^#\s+(.+)$/m)?.[1]?.trim() || fileName : fileName);
|
|
2498
2539
|
try {
|
|
2499
|
-
const created = await rpcOnce('
|
|
2500
|
-
|
|
2501
|
-
fileType: 'document',
|
|
2540
|
+
const created = await rpcOnce('document-create', {
|
|
2541
|
+
projectId: values.project || undefined, title, markdown: data,
|
|
2502
2542
|
}, getTimeout(values));
|
|
2503
|
-
|
|
2543
|
+
assertNoRpcError(created, 'document-create');
|
|
2544
|
+
const noteId = created?.id;
|
|
2504
2545
|
if (!noteId) {
|
|
2505
|
-
|
|
2506
|
-
process.exit(1);
|
|
2546
|
+
throw new Error(`document-create failed: ${JSON.stringify(created)}`);
|
|
2507
2547
|
}
|
|
2508
|
-
await rpcOnce('note-content-seed', { noteId, data, title }, getTimeout(values));
|
|
2509
2548
|
if (values.json) {
|
|
2510
2549
|
console.log(JSON.stringify({
|
|
2511
2550
|
noteId,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export async function waitForConnection(probe, expectedEndpoint, options = {}) {
|
|
2
|
+
const now = options.now ?? Date.now;
|
|
3
|
+
const sleep = options.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
|
|
4
|
+
const deadline = now() + (options.timeoutMs ?? 30_000);
|
|
5
|
+
const normalize = (value) => value.replace(/\/+$/, '');
|
|
6
|
+
let reason = 'starting';
|
|
7
|
+
do {
|
|
8
|
+
try {
|
|
9
|
+
const state = await probe();
|
|
10
|
+
if (state.endpoint && normalize(state.endpoint) !== normalize(expectedEndpoint))
|
|
11
|
+
return { ready: false, reason: 'environment-mismatch' };
|
|
12
|
+
if (state.connected && state.endpoint && normalize(state.endpoint) === normalize(expectedEndpoint))
|
|
13
|
+
return { ready: true, reason: 'connected' };
|
|
14
|
+
reason = 'relay-unavailable';
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
reason = 'starting';
|
|
18
|
+
}
|
|
19
|
+
const remaining = deadline - now();
|
|
20
|
+
if (remaining <= 0)
|
|
21
|
+
break;
|
|
22
|
+
await sleep(Math.min(options.pollMs ?? 250, remaining));
|
|
23
|
+
} while (now() < deadline);
|
|
24
|
+
return { ready: false, reason };
|
|
25
|
+
}
|
package/dist/daemon-client.js
CHANGED
|
@@ -18,6 +18,7 @@ import net from 'net';
|
|
|
18
18
|
import { resolveClientAuthToken } from './ipc-auth.js';
|
|
19
19
|
import { activeAgentWrap } from './agent-attribution.js';
|
|
20
20
|
import { ipcSocketPath, socketMayExist } from './ipc-path.js';
|
|
21
|
+
import { utf8Stream } from './utf8-stream.js';
|
|
21
22
|
const TIMEOUT_MS = 60_000;
|
|
22
23
|
const AUTH_TIMEOUT_MS = 3_000;
|
|
23
24
|
/** One attempt. Resolves, never rejects. */
|
|
@@ -112,8 +113,10 @@ function handshake(socket) {
|
|
|
112
113
|
socket.removeListener('data', onData);
|
|
113
114
|
reject(new Error('IPC auth handshake timeout'));
|
|
114
115
|
}, AUTH_TIMEOUT_MS);
|
|
116
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
117
|
+
const decode = utf8Stream();
|
|
115
118
|
const onData = (chunk) => {
|
|
116
|
-
buffer += chunk
|
|
119
|
+
buffer += decode(chunk);
|
|
117
120
|
const lines = buffer.split('\n');
|
|
118
121
|
buffer = lines.pop() || '';
|
|
119
122
|
for (const line of lines) {
|
|
@@ -218,8 +221,10 @@ function sendIpc(socket, msg) {
|
|
|
218
221
|
socket.removeListener('data', onData);
|
|
219
222
|
reject(new Error(`Daemon RPC timeout after ${TIMEOUT_MS}ms`));
|
|
220
223
|
}, TIMEOUT_MS);
|
|
224
|
+
// CA-63 — decode the stream, not each chunk: a character split across two reads stays whole.
|
|
225
|
+
const decode = utf8Stream();
|
|
221
226
|
const onData = (chunk) => {
|
|
222
|
-
buffer += chunk
|
|
227
|
+
buffer += decode(chunk);
|
|
223
228
|
const lines = buffer.split('\n');
|
|
224
229
|
buffer = lines.pop() || '';
|
|
225
230
|
for (const line of lines) {
|