@snowyroad/braid 0.94.0 → 0.96.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/README.md +29 -10
- package/dist/{chunk-267GOUH7.js → chunk-2TZT7YDG.js} +610 -109
- package/dist/cli.js +129 -9
- package/dist/{http-JA7L7WBG.js → http-5RVYBQJV.js} +1 -1
- package/package.json +2 -1
- package/service-shrinkwrap.json +155 -151
package/dist/cli.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
classifyFatal,
|
|
30
30
|
configDir,
|
|
31
31
|
createAdapter,
|
|
32
|
+
createEphemeralLisaAdapter,
|
|
32
33
|
createWorkspaceNoticeGate,
|
|
33
34
|
decodeInvite,
|
|
34
35
|
distinctWorkspaceNames,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
interactiveResumeSpecFor,
|
|
41
42
|
isAddressed,
|
|
42
43
|
isAddressedByReply,
|
|
44
|
+
isFilesystemRoot,
|
|
43
45
|
isShellSafeName,
|
|
44
46
|
legacyTwinPath,
|
|
45
47
|
listAgents,
|
|
@@ -74,7 +76,7 @@ import {
|
|
|
74
76
|
updateAgentWorkspace,
|
|
75
77
|
validateRelayWsUrl,
|
|
76
78
|
wsToHttp
|
|
77
|
-
} from "./chunk-
|
|
79
|
+
} from "./chunk-2TZT7YDG.js";
|
|
78
80
|
import {
|
|
79
81
|
clampUntrusted,
|
|
80
82
|
externalSourceLabel,
|
|
@@ -289,7 +291,8 @@ function buildFlowPrompt(signal, agentName, channelId, toolMode = "readonly", ca
|
|
|
289
291
|
].filter((line, i, arr) => !(line === "" && arr[i - 1] === "")).join("\n");
|
|
290
292
|
}
|
|
291
293
|
const isSynthesis = signal.kind === "synthesis";
|
|
292
|
-
const
|
|
294
|
+
const lisaRole = signal.lisa?.role;
|
|
295
|
+
const header = lisaRole === "builder" ? `You are the BUILDER in an iteration loop. Produce the next revision of the artifact. End with the COMMIT: <ref> line.` : lisaRole === "critic" ? `You are a BLIND CRITIC. Judge ONLY the artifact below \u2014 you cannot see the builder's reasoning or other critics. Your report MUST contain the ARTIFACT-SHA, SCORE, and WORST lines exactly as the instructions specify.` : isSynthesis ? `You are ${agentName} and the TEAM LEAD for this Braid bounded discussion.` : `You are ${agentName} responding in an Braid bounded discussion.`;
|
|
293
296
|
const role = signal.rolePrompt ? `${fence("flow role description (supplied by flow configuration)", signal.rolePrompt)}
|
|
294
297
|
` : "";
|
|
295
298
|
const ctx = signal.contextPrompt ? `${fence("flow context (supplied by flow configuration)", signal.contextPrompt)}
|
|
@@ -362,7 +365,7 @@ function externalTaintOf(messages) {
|
|
|
362
365
|
return { preambleLabel: "an external source", fenceLabel: "external message" };
|
|
363
366
|
}
|
|
364
367
|
var ChannelSession = class {
|
|
365
|
-
constructor(adapter, onReply, agentName, channelId, flow, fetchContext, beacon, toolMode = "readonly", holdCatchUpMaxMentions = 3, fetchPrivateContext, channelName, operator) {
|
|
368
|
+
constructor(adapter, onReply, agentName, channelId, flow, fetchContext, beacon, toolMode = "readonly", holdCatchUpMaxMentions = 3, fetchPrivateContext, channelName, operator, makeEphemeralAdapter) {
|
|
366
369
|
this.adapter = adapter;
|
|
367
370
|
this.onReply = onReply;
|
|
368
371
|
this.agentName = agentName;
|
|
@@ -375,6 +378,7 @@ var ChannelSession = class {
|
|
|
375
378
|
this.fetchPrivateContext = fetchPrivateContext;
|
|
376
379
|
this.channelName = channelName;
|
|
377
380
|
this.operator = operator;
|
|
381
|
+
this.makeEphemeralAdapter = makeEphemeralAdapter;
|
|
378
382
|
}
|
|
379
383
|
adapter;
|
|
380
384
|
onReply;
|
|
@@ -388,6 +392,7 @@ var ChannelSession = class {
|
|
|
388
392
|
fetchPrivateContext;
|
|
389
393
|
channelName;
|
|
390
394
|
operator;
|
|
395
|
+
makeEphemeralAdapter;
|
|
391
396
|
session = null;
|
|
392
397
|
roster = [];
|
|
393
398
|
/** Replace the known peer roster (situational facts surfaced per message). */
|
|
@@ -434,6 +439,17 @@ var ChannelSession = class {
|
|
|
434
439
|
* invocation regardless; a flow turn before any message frame narrows nothing.
|
|
435
440
|
*/
|
|
436
441
|
lastBundleMode;
|
|
442
|
+
/** The options this session was started with (model, mcpServers, env, health, ...).
|
|
443
|
+
* Reused verbatim to construct each lisa ephemeral adapter's start() call, so a
|
|
444
|
+
* fresh-session turn runs with the SAME provider/tool wiring as the warm session
|
|
445
|
+
* minus persistence. Set once in start(); null before then.
|
|
446
|
+
* G2 (lisa loop, D13 review Minor 3): `mcpServers` here is this channel's broker
|
|
447
|
+
* tool advertisement, replayed VERBATIM into the ephemeral (possibly blind-critic)
|
|
448
|
+
* session below. Safe today only because the broker exposes no channel/flow
|
|
449
|
+
* message-READ route (see the matching note in src/mcp/broker.ts's handle()) — a
|
|
450
|
+
* future one would leak the transcript the relay deliberately withholds from a
|
|
451
|
+
* blind critic straight through this replay. */
|
|
452
|
+
startOpts = null;
|
|
437
453
|
/** Mode-aware prompt head: the untrusted-content framing plus the one-line
|
|
438
454
|
* truth about tool access (both OUTSIDE all fences, once per prompt).
|
|
439
455
|
* `externalLabel`, when present (e.g. "Slack", "Discord"), selects the
|
|
@@ -456,6 +472,7 @@ ${toolStatusLine(mode, cause)}
|
|
|
456
472
|
${operatorFacts}`;
|
|
457
473
|
}
|
|
458
474
|
async start(opts) {
|
|
475
|
+
this.startOpts = opts;
|
|
459
476
|
this.session = await this.adapter.start(opts);
|
|
460
477
|
const notifier = makeTurnErrorNotifier((_ch, text) => this.onReply(text));
|
|
461
478
|
this.session.onTurn((full, usage, turnId) => {
|
|
@@ -540,6 +557,80 @@ ${fence("reactions", formatReactions(msg.reactions))}
|
|
|
540
557
|
this.pendingModes.push(effectiveMode);
|
|
541
558
|
this.session.submit(capPrompt(head + instructions), narrowestOverride(effectiveMode, this.toolMode));
|
|
542
559
|
}
|
|
560
|
+
/** BL-193: flow turns honor the channel's bound bundle mode for parity with the
|
|
561
|
+
* channel_message path — same narrow-only rule (G7: never widens), fed by the
|
|
562
|
+
* last-seen frame mode since flow signals carry none. The override threaded to
|
|
563
|
+
* converseLocal is the enforcement-layer half; effectiveMode + cause make the
|
|
564
|
+
* prompt framing truthful (BL-203: a full operator narrowed by the channel is
|
|
565
|
+
* told the CHANNEL restricts it, not its own mode). Shared by both the warm and
|
|
566
|
+
* the lisa-ephemeral flow-turn paths so they can never drift. */
|
|
567
|
+
flowModeAndCause() {
|
|
568
|
+
const effectiveMode = narrowestMode(this.lastBundleMode, this.toolMode);
|
|
569
|
+
const cause = effectiveMode === "readonly" && this.toolMode !== "readonly" ? "channel" : "operator";
|
|
570
|
+
return { effectiveMode, cause };
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* D13/D20 (lisa fresh-session turns): run the turn on a FRESH, ONE-SHOT provider
|
|
574
|
+
* session — never the channel's warm session/sessionStore entry, never
|
|
575
|
+
* saveStoredSession. Constructs the ephemeral adapter, starts it (no persistence
|
|
576
|
+
* wired ⇒ AcpClient's newSession path), submits EXACTLY ONE turn via
|
|
577
|
+
* converseLocalWithUsage, and tears it down in a finally so a failure mid-turn
|
|
578
|
+
* still disposes it. Posts the reply with lisaFresh:true and the turn's REAL usage
|
|
579
|
+
* (from the ephemeral adapter's own isolated usage source — see
|
|
580
|
+
* createEphemeralLisaAdapter). FAILURE POLICY (hard rule, D20): if the ephemeral
|
|
581
|
+
* adapter fails to start, OR the turn itself fails, this REJECTS — nothing posts,
|
|
582
|
+
* and the relay watchdog re-notifies the builder. Never falls back to the warm
|
|
583
|
+
* session or fabricates freshness/usage.
|
|
584
|
+
*/
|
|
585
|
+
async runLisaFreshTurn(signal) {
|
|
586
|
+
if (!this.flow) {
|
|
587
|
+
throw new Error(
|
|
588
|
+
"[braid] lisa fresh-session turn requested but flow routing is unavailable for this session"
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
if (!this.makeEphemeralAdapter) {
|
|
592
|
+
throw new Error(
|
|
593
|
+
"[braid] lisa fresh-session turn requested but no ephemeral adapter factory is wired for this session"
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const startOpts = this.startOpts;
|
|
597
|
+
if (!startOpts) throw new Error("ChannelSession not started");
|
|
598
|
+
const { effectiveMode, cause } = this.flowModeAndCause();
|
|
599
|
+
const prompt = buildFlowPrompt(
|
|
600
|
+
signal,
|
|
601
|
+
this.agentName,
|
|
602
|
+
this.channelId,
|
|
603
|
+
effectiveMode,
|
|
604
|
+
cause,
|
|
605
|
+
this.channelName?.(this.channelId)
|
|
606
|
+
);
|
|
607
|
+
this.beacon?.begin();
|
|
608
|
+
try {
|
|
609
|
+
const ephemeral = this.makeEphemeralAdapter();
|
|
610
|
+
const ephemeralSession = await ephemeral.start(startOpts);
|
|
611
|
+
try {
|
|
612
|
+
if (!ephemeralSession.converseLocalWithUsage) {
|
|
613
|
+
throw new Error("[braid] ephemeral lisa adapter does not support converseLocalWithUsage");
|
|
614
|
+
}
|
|
615
|
+
const { text, usage } = await ephemeralSession.converseLocalWithUsage(
|
|
616
|
+
capPrompt(prompt),
|
|
617
|
+
narrowestOverride(effectiveMode, this.toolMode)
|
|
618
|
+
);
|
|
619
|
+
await this.flow.postReply(signal.flowId, text.trim() || "(no response)", usage, signal.dispatchId, true);
|
|
620
|
+
} finally {
|
|
621
|
+
try {
|
|
622
|
+
await ephemeralSession.stop();
|
|
623
|
+
} catch (stopErr) {
|
|
624
|
+
console.warn(
|
|
625
|
+
"[braid] failed to dispose the ephemeral lisa adapter (leaked subprocess risk):",
|
|
626
|
+
String(stopErr?.message ?? stopErr)
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
} finally {
|
|
631
|
+
this.beacon?.end();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
543
634
|
/**
|
|
544
635
|
* Run one bounded-flow turn or synthesis. Frames a MUST-RESPOND prompt (no
|
|
545
636
|
* `<<silent>>`), runs it through the SAME warm session via converseLocal (so the
|
|
@@ -547,9 +638,18 @@ ${fence("reactions", formatReactions(msg.reactions))}
|
|
|
547
638
|
* endpoint. A minimal turn_notification carries no transcript, so we backfill it
|
|
548
639
|
* from the relay first. An empty reply still posts a fallback so the relay-blocked
|
|
549
640
|
* flow advances rather than timing out.
|
|
641
|
+
*
|
|
642
|
+
* D13/D20: a signal carrying `lisa.freshSession:true` is routed to runLisaFreshTurn
|
|
643
|
+
* instead — an entirely separate, one-shot ephemeral session. Every other signal
|
|
644
|
+
* (including a lisa signal without freshSession) runs unchanged, below, on the warm
|
|
645
|
+
* session.
|
|
550
646
|
*/
|
|
551
647
|
async runFlowTurn(signal) {
|
|
552
648
|
if (!this.session) throw new Error("ChannelSession not started");
|
|
649
|
+
if (signal.lisa?.freshSession) {
|
|
650
|
+
await this.runLisaFreshTurn(signal);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
553
653
|
if (!this.session.converseLocal || !this.flow) {
|
|
554
654
|
console.warn("[braid] flow turn skipped: warm aside / flow routing unavailable");
|
|
555
655
|
return;
|
|
@@ -557,9 +657,8 @@ ${fence("reactions", formatReactions(msg.reactions))}
|
|
|
557
657
|
this.beacon?.begin();
|
|
558
658
|
try {
|
|
559
659
|
let history = signal.history;
|
|
560
|
-
if (history.length === 0) history = await this.flow.fetchHistory(signal.flowId);
|
|
561
|
-
const effectiveMode =
|
|
562
|
-
const cause = effectiveMode === "readonly" && this.toolMode !== "readonly" ? "channel" : "operator";
|
|
660
|
+
if (history.length === 0 && !signal.lisa) history = await this.flow.fetchHistory(signal.flowId);
|
|
661
|
+
const { effectiveMode, cause } = this.flowModeAndCause();
|
|
563
662
|
const prompt = buildFlowPrompt(
|
|
564
663
|
{ ...signal, history },
|
|
565
664
|
this.agentName,
|
|
@@ -2537,7 +2636,8 @@ async function startBridge(cfg, relay, deps) {
|
|
|
2537
2636
|
channelId,
|
|
2538
2637
|
{
|
|
2539
2638
|
// BL-126: thread dispatchId from the FlowSignal through to the POST body.
|
|
2540
|
-
|
|
2639
|
+
// D13: also thread lisaFresh (true only for a lisa fresh-session reply).
|
|
2640
|
+
postReply: (flowId, content, usage, dispatchId, lisaFresh) => relay.postFlowMessage(channelId, flowId, content, usage, dispatchId, lisaFresh),
|
|
2541
2641
|
fetchHistory: (flowId) => relay.fetchFlowMessages(channelId, flowId)
|
|
2542
2642
|
},
|
|
2543
2643
|
(queryHint) => relay.fetchChannelContext(channelId, queryHint),
|
|
@@ -2559,7 +2659,13 @@ async function startBridge(cfg, relay, deps) {
|
|
|
2559
2659
|
// BL-272 Task 5 (D3/D6/D7/D8): the relay's live, per-hello platform-verified
|
|
2560
2660
|
// operator attestation for THIS agent — a thunk (not a snapshot) so a
|
|
2561
2661
|
// reconnect's fresh hello is reflected on the very next prompt.
|
|
2562
|
-
() => relay.operator()
|
|
2662
|
+
() => relay.operator(),
|
|
2663
|
+
// D13/D20: construct a FRESH ephemeral adapter per lisa turn (never memoized).
|
|
2664
|
+
// Absent when the caller's deps omit makeEphemeralAdapter (e.g. a test using
|
|
2665
|
+
// only makeAdapter) — a lisa fresh-session signal then fails loudly instead of
|
|
2666
|
+
// silently running on the warm session. Ordered AFTER operator to match the
|
|
2667
|
+
// merged ChannelSession constructor param order (session.ts).
|
|
2668
|
+
deps.makeEphemeralAdapter ? () => deps.makeEphemeralAdapter(cfg) : void 0
|
|
2563
2669
|
);
|
|
2564
2670
|
const broker = new SourceBroker(channelId, relay);
|
|
2565
2671
|
const brokerInfo = await broker.start();
|
|
@@ -2847,7 +2953,9 @@ async function createAndStartBridge(cfg, deps = {}) {
|
|
|
2847
2953
|
);
|
|
2848
2954
|
const rawMakeAdapter = deps.makeAdapter ?? createAdapter;
|
|
2849
2955
|
const makeAdapter = (c, channelId) => rawMakeAdapter(c, channelId, deps.telemetry);
|
|
2850
|
-
|
|
2956
|
+
const rawMakeEphemeralAdapter = deps.makeEphemeralAdapter ?? createEphemeralLisaAdapter;
|
|
2957
|
+
const makeEphemeralAdapter = (c) => rawMakeEphemeralAdapter(c, deps.telemetry);
|
|
2958
|
+
handle = await startBridge(cfg, relay, { makeAdapter, makeEphemeralAdapter, onReady: deps.onReady, cardCache: deps.cardCache, controlSocket: deps.controlSocket });
|
|
2851
2959
|
return { relay, sessions: handle.sessions, channelNames: handle.channelNames, brokers: handle.brokers, hub: handle.hub, ensureSession: handle.ensureSession, controlSocketClose: handle.controlSocketClose };
|
|
2852
2960
|
}
|
|
2853
2961
|
|
|
@@ -6370,6 +6478,18 @@ async function main() {
|
|
|
6370
6478
|
await initScopeRuntime(cfg, cfg.agent);
|
|
6371
6479
|
console.log(banner("braid", "SCOPE", resolveScope(cfg, cfg.agent, process.env).describe()));
|
|
6372
6480
|
}
|
|
6481
|
+
if (cfg.toolMode === "readonly") {
|
|
6482
|
+
const scopeCwd = process.cwd();
|
|
6483
|
+
if (scopeCwd === homedir6()) {
|
|
6484
|
+
console.error(
|
|
6485
|
+
"[braid] note: started in your home directory; read-and-reply may read any non-credential file under it. Start from a project directory to narrow it."
|
|
6486
|
+
);
|
|
6487
|
+
} else if (isFilesystemRoot(scopeCwd, process.platform)) {
|
|
6488
|
+
console.error(
|
|
6489
|
+
"[braid] WARNING: no working directory (started at the filesystem root); read-and-reply will refuse file reads until you pass --allow-read <dir>."
|
|
6490
|
+
);
|
|
6491
|
+
}
|
|
6492
|
+
}
|
|
6373
6493
|
const runVerbose = invocation.argv.includes("--verbose");
|
|
6374
6494
|
const sticky = createStickyLine(process.stdout);
|
|
6375
6495
|
const connStatus = createConnStatus({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snowyroad/braid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.96.0",
|
|
4
4
|
"description": "Braid: connect your own coding agent (Claude Code, Codex, Gemini, Grok) to a Braid channel and collaborate with other agents and humans.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "SnowyRoad",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
],
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsup",
|
|
39
|
+
"prepare": "git config core.hooksPath .githooks || true",
|
|
39
40
|
"shrinkwrap:gen": "node scripts/generate-shrinkwrap.mjs",
|
|
40
41
|
"prepublishOnly": "pnpm build && node scripts/generate-shrinkwrap.mjs",
|
|
41
42
|
"dev": "tsx src/index.ts",
|