@geohar/un-bien 0.20.15 → 0.20.17

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/index.js CHANGED
@@ -35,28 +35,26 @@ import { createExtensionUiBridge, } from "./extension_ui_bridge.js";
35
35
  import { createPanelBridge } from "./panel_bridge.js";
36
36
  import { initSubagentRooms, subagentRoomsEnabled, } from "./subagent_rooms.js";
37
37
  import { createRpcEnvelope, } from "./session/rpc_envelope.js";
38
- import { dispatchRpcCommand } from "./session/rpc_inbound.js";
39
38
  import { envLog } from "./session/debug_log.js";
40
39
  import { chainUiNotify, notifyOwners, setOwnerNotifyHook } from "./session/owner_notify.js";
41
40
  import { roomIdForSession } from "./rooms.js";
42
41
  import { registerAgentTools } from "./session/tools.js";
43
42
  import { formatPeerInventory } from "./session/peer_inventory.js";
44
43
  import { ensureGlobalDirs, LOCAL_SESSION_NAME, sessionSockPath, skillsDir, } from "./session/global_config.js";
45
- import { effectiveAutoStartRelay, effectiveAllowRemoteLaunch, effectiveAllowRemoteTerminate, loadLocalConfig, localConfigExists, piSessionName, resolveAgentName, saveLocalConfig, } from "./session/local_config.js";
44
+ import { effectiveAutoStartRelay, loadLocalConfig, localConfigExists, piSessionName, resolveAgentName, saveLocalConfig, } from "./session/local_config.js";
46
45
  import { updateFooter } from "./ui/footer.js";
47
46
  import { fileURLToPath } from "node:url";
48
47
  import { realpathSync } from "node:fs";
49
- import { loadConfig, saveConfig, isValidRelayUrl, isWebSocketScheme, } from "./config.js";
50
- import { _expandTilde, _launchSession, launchDirAllowed } from "./launch.js";
48
+ import { saveConfig, isValidRelayUrl, isWebSocketScheme, } from "./config.js";
51
49
  import { _enrichToolArgs } from "./enrich_tool_args.js";
52
50
  import { _inspectPeerRecord, _runtimeOwnerFingerprint, } from "./pairing/peer_trust.js";
53
51
  import { _flushPendingReceivedImagePreviews, _isReceivedImageContextMessage, _registerReceivedImageRenderer, } from "./session/received_images.js";
54
52
  import { _cmdRoot, _cmdStart, } from "./commands/lifecycle.js";
55
53
  import { _cmdClaudeCli, _cmdInstallTarget, _cmdUninstallTarget, parseInstallTarget, _deployAgentNetworkSkill, } from "./commands/housekeeping.js";
56
54
  import { registerUnbienCommands } from "./commands/register.js";
57
- import { takeForkLink } from "./commands/fork_link.js";
55
+ import { makeTestBusEmitter, runTestScenario } from "./commands/test_scenarios.js";
56
+ import { routeRpcCommand, routeUnBienPlane } from "./routing.js";
58
57
  import { createTestHooks } from "./test_hooks.js";
59
- import { createRpcHandlers, } from "./session/rpc_handlers.js";
60
58
  import { CTRL_PREFIX, _anyPeerActive, _broadcastEnvelope, _broadcastEnvelopeFlushed, _controlCtx, _detachPeerChannel, _emitRelayState, _getReconnectTimer, _getRelayLifecycleGeneration, _goIdle, _handleControl as _handleControlImpl, _headlessUi, _onPeerDisconnect as _onPeerDisconnectImpl, _onRelayClose, _panelBroadcast, _pushPairingAllowList, _relayStatus, _setupRelayConnection, _setRelayLifecycleGeneration, _uiBroadcast, } from "./session/relay_lifecycle.js";
61
59
  // Relay-lifecycle seam re-exports: the control-channel sentinel + the
62
60
  // connectivity type moved to ./session/relay_lifecycle.ts; these keep the
@@ -79,7 +77,6 @@ let _relayUrl = null; // URL used by current _relay connection
79
77
  */
80
78
  const _activePeers = new Map();
81
79
  let _peerShort = ""; // shortid of the most recently attached peer (UX hint only)
82
- let _myRoomId = null; // this Pi's room id (derived from the session id)
83
80
  /** True when a relay start was DEFERRED because no session id existed yet
84
81
  * (design 01M1CAW0). The root session_start handler re-runs the start once
85
82
  * the session id becomes available. */
@@ -98,14 +95,6 @@ function _deriveRoomId(_cwd, _name) {
98
95
  const sid = _rootState().sessionManager?.getSessionId();
99
96
  return sid ? roomIdForSession(sid) : null;
100
97
  }
101
- // Plan/28 Wave D.1: `thinking` published alongside `model` so the app's
102
- // Quick Actions sheet hydrates the thinking segmented control on first
103
- // open instead of starting null. The SDK fires `thinking_level_select`
104
- // on every change (initial load + user toggle), mirrored to room_meta
105
- // the same way model is — apps subscribe to one channel for both.
106
- let _myRoomMeta = null;
107
- let _currentModel; // last-known model name
108
- let _currentThinking; // last-known thinking level
109
98
  // ── Agent-network session (plano 19) ──────────────────────────────────────────
110
99
  // MeshNode owns both the local UDS mesh (SessionPeer) and the optional
111
100
  // cross-PC relay bridge (BrokerRemote + PiForwardClient). The bridge is
@@ -170,7 +159,7 @@ function _refreshSessionPeerCount(peer, ctx) {
170
159
  }
171
160
  /** Friendly model name for room_meta (plano 18). undefined when SDK has none yet. */
172
161
  function _currentModelName() {
173
- return _currentModel;
162
+ return _rootState().currentModel ?? undefined;
174
163
  }
175
164
  /**
176
165
  * Cache the active model name and fan it out to subscribed apps via a
@@ -182,13 +171,14 @@ function _currentModelName() {
182
171
  * otherwise never surface their model.
183
172
  */
184
173
  function _setCurrentModel(name) {
185
- _currentModel = name;
186
- if (_myRoomMeta)
187
- _myRoomMeta = { ..._myRoomMeta, model: name };
188
- if (_relay && _myRoomId) {
174
+ const st = _rootState();
175
+ st.currentModel = name;
176
+ if (st.myRoomMeta)
177
+ st.myRoomMeta = { ...st.myRoomMeta, model: name };
178
+ if (_relay && st.myRoomId) {
189
179
  _relay.sendControl({
190
180
  type: "room_meta_update",
191
- room_id: _myRoomId,
181
+ room_id: st.myRoomId,
192
182
  meta: { model: name },
193
183
  });
194
184
  }
@@ -201,12 +191,13 @@ function _setCurrentModel(name) {
201
191
  * so room_meta.working must be bracketed manually around compaction.
202
192
  */
203
193
  function _publishWorking(working) {
204
- if (_myRoomMeta)
205
- _myRoomMeta = { ..._myRoomMeta, working };
206
- if (_relay && _myRoomId) {
194
+ const st = _rootState();
195
+ if (st.myRoomMeta)
196
+ st.myRoomMeta = { ...st.myRoomMeta, working };
197
+ if (_relay && st.myRoomId) {
207
198
  _relay.sendControl({
208
199
  type: "room_meta_update",
209
- room_id: _myRoomId,
200
+ room_id: st.myRoomId,
210
201
  meta: { working },
211
202
  });
212
203
  }
@@ -276,13 +267,13 @@ function _attachBridgeIfReady() {
276
267
  }
277
268
  /**
278
269
  * Prefer an explicit ctx, else the always-fresh session_start ctx
279
- * (`_lastEventCtx`, re-captured on every session_start so it is never a stale
270
+ * (the root record's baseCtx, re-captured on every session_start so it is never stale
280
271
  * capture). Relay/async paths reach the ctx-only surfaces (ui/abort/compact)
281
272
  * through this, since they fire outside any live pi event.
282
273
  * @see https://github.com/jacobaraujo7/remote_pi/issues/55
283
274
  */
284
275
  function _liveCtx(preferred) {
285
- return preferred ?? _lastEventCtx ?? null;
276
+ return preferred ?? _rootState().baseCtx ?? null;
286
277
  }
287
278
  /**
288
279
  * Read `ctx.ui` without letting a stale-ctx getter become an uncaughtException.
@@ -351,10 +342,6 @@ function _refreshFooter(ctx) {
351
342
  // setStatus/setTitle can also throw if the runner went stale mid-call.
352
343
  }
353
344
  }
354
- // Epoch ms when the state machine entered 'started' (last /unbien start).
355
- // Used by session_sync to let the app detect Pi restarts (and force a full
356
- // replay). Cleared on _goIdle.
357
- let _sessionStartedAt = null;
358
345
  let _pendingMeshMessages = [];
359
346
  // agent-run active/generation now live PER-SESSION in _stateFor(sid).agentRun;
360
347
  // mesh delivery targets the ROOT run, so the drain reads _rootState().agentRun.
@@ -377,6 +364,11 @@ function _stateFor(sid) {
377
364
  sessionManager: null,
378
365
  model: null,
379
366
  thinking: null,
367
+ currentModel: null,
368
+ myRoomId: null,
369
+ myRoomMeta: null,
370
+ sessionStartedAt: null,
371
+ baseCtx: null,
380
372
  };
381
373
  _sessions.set(sid, st);
382
374
  }
@@ -460,19 +452,6 @@ export function _getState() {
460
452
  // underlying BUS events and lets the REAL bridges produce the frames (faithful);
461
453
  // the simple ExtensionUIPromptView methods (select/confirm/input/editor) + media
462
454
  // have no bus producer, so they're broadcast directly. See design 01M152YD….
463
- const _TEST_SVG_B64 = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60">' +
464
- '<rect width="120" height="60" rx="8" fill="#4c8bf5"/>' +
465
- '<text x="60" y="37" font-size="16" fill="white" text-anchor="middle"' +
466
- ' font-family="sans-serif">un-bien</text></svg>').toString("base64");
467
- function _emitTestBus(channel, data) {
468
- try {
469
- ;
470
- _pi?.events?.emit(channel, data);
471
- }
472
- catch {
473
- /* bus absent — best effort */
474
- }
475
- }
476
455
  // Owner-notify bridge (transient toasts on the app): /unbien command handlers
477
456
  // report through notifyOwners() - this broadcasts the notice to every attached
478
457
  // owner as extension_ui_request {method:"notify"}, the same shape the ask-flow
@@ -488,622 +467,34 @@ setOwnerNotifyHook((message, level) => {
488
467
  },
489
468
  });
490
469
  });
491
- /** Run one canned UI-test scenario. Returns a short status for the notify. */
492
- function _runTestScenario(scenario) {
493
- const s = (scenario.trim().split(/\s+/)[0] || "help").toLowerCase();
494
- const id = `test-${Date.now()}`;
495
- switch (s) {
496
- case "ask-select":
497
- _broadcastEnvelope(relayDeps, {
498
- rpc: {
499
- type: "extension_ui_request",
500
- id,
501
- method: "select",
502
- title: "Pick one (test)",
503
- options: ["Alpha", "Beta", "Gamma"],
504
- },
505
- });
506
- return "sent ask-select";
507
- case "ask-confirm":
508
- _broadcastEnvelope(relayDeps, {
509
- rpc: {
510
- type: "extension_ui_request",
511
- id,
512
- method: "confirm",
513
- title: "Confirm (test)",
514
- message: "Proceed with the test action?",
515
- },
516
- });
517
- return "sent ask-confirm";
518
- case "ask-input":
519
- _broadcastEnvelope(relayDeps, {
520
- rpc: {
521
- type: "extension_ui_request",
522
- id,
523
- method: "input",
524
- title: "Input (test)",
525
- placeholder: "Type something…",
526
- },
527
- });
528
- return "sent ask-input";
529
- case "ask-editor":
530
- _broadcastEnvelope(relayDeps, {
531
- rpc: {
532
- type: "extension_ui_request",
533
- id,
534
- method: "editor",
535
- title: "Editor (test)",
536
- prefill: "edit me",
537
- },
538
- });
539
- return "sent ask-editor";
540
- case "ask-notify":
541
- _broadcastEnvelope(relayDeps, {
542
- rpc: {
543
- type: "extension_ui_request",
544
- id,
545
- method: "notify",
546
- message: "This is a test notice.",
547
- notify_type: "info",
548
- },
549
- });
550
- return "sent ask-notify";
551
- case "ask-rich":
552
- _emitTestBus("@eko24ive/pi-ask:started", {
553
- version: 1,
554
- flowId: `test-flow-${Date.now()}`,
555
- source: "test",
556
- title: "Rich ask (test)",
557
- questions: [
558
- {
559
- id: "q1",
560
- prompt: "Which approach?",
561
- type: "single",
562
- options: [
563
- { value: "a", label: "Approach A", description: "the safe one" },
564
- { value: "b", label: "Approach B", preview: "preview text here" },
565
- ],
566
- },
567
- {
568
- id: "q2",
569
- prompt: "Anything to add?",
570
- type: "single",
571
- options: [{ value: "ok", label: "Looks good", freeform: true }],
572
- },
573
- ],
574
- });
575
- return "emitted pi-ask:started (rich)";
576
- case "plan":
577
- _emitTestBus("plan:snapshot", {
578
- ns: "test",
579
- seq: 1,
580
- items: [
581
- {
582
- id: "t1",
583
- kind: "plan",
584
- title: "Design the thing",
585
- status: "done",
586
- deps: [],
587
- },
588
- {
589
- id: "t2",
590
- kind: "plan",
591
- title: "Build the thing",
592
- status: "in_progress",
593
- deps: ["t1"],
594
- },
595
- {
596
- id: "t3",
597
- kind: "plan",
598
- title: "Test the thing",
599
- status: "pending",
600
- deps: ["t2"],
601
- },
602
- ],
603
- });
604
- return "emitted plan:snapshot";
605
- case "subagents":
606
- _emitTestBus("subagents:created", {
607
- id: "sa1",
608
- type: "explore",
609
- description: "Explore the codebase",
610
- });
611
- _emitTestBus("subagents:started", { id: "sa1" });
612
- _emitTestBus("subagents:created", {
613
- id: "sa2",
614
- type: "plan",
615
- description: "Draft an implementation plan",
616
- });
617
- _emitTestBus("subagents:completed", { id: "sa2" });
618
- return "emitted subagents lifecycle";
619
- case "svg": {
620
- // A TOOL card renders standalone; the app pulls tool-emitted images from
621
- // INSIDE the tool_execution_end `result` (imagesFromToolResult unwraps
622
- // `{content:[{type:"image",data,mimeType}]}`) and renders them below the
623
- // card (WireImageView -> SVGImageView). Deliver the SVG that way.
624
- const tc = `tc-svg-${Date.now()}`;
625
- _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
626
- _broadcastEnvelope(relayDeps, {
627
- rpc: {
628
- type: "tool_execution_start",
629
- toolCallId: tc,
630
- toolName: "render_svg",
631
- args: { note: "test svg" },
632
- },
633
- });
634
- _broadcastEnvelope(relayDeps, {
635
- rpc: {
636
- type: "tool_execution_end",
637
- toolCallId: tc,
638
- result: {
639
- content: [
640
- { type: "text", text: "rendered a test SVG" },
641
- {
642
- type: "image",
643
- data: _TEST_SVG_B64,
644
- mimeType: "image/svg+xml",
645
- },
646
- ],
647
- },
648
- isError: false,
649
- },
650
- });
651
- _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
652
- return "sent svg (envelope tool_execution_end + image)";
653
- }
654
- case "tool": {
655
- const tc = `tc-${Date.now()}`;
656
- _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
657
- _broadcastEnvelope(relayDeps, {
658
- rpc: {
659
- type: "tool_execution_start",
660
- toolCallId: tc,
661
- toolName: "bash",
662
- args: { command: "echo hello" },
663
- },
664
- });
665
- _broadcastEnvelope(relayDeps, {
666
- rpc: {
667
- type: "tool_execution_end",
668
- toolCallId: tc,
669
- result: "hello\n",
670
- isError: false,
671
- },
672
- });
673
- _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
674
- return "sent tool pair (envelope)";
675
- }
676
- case "diff": {
677
- // Exercise the rich diff rendering: aux `{hunks}` (input Edit diff) rides
678
- // ALONGSIDE the raw edit `tool_execution_start`. OUTPUT is classified
679
- // app-side from the result, so no aux.output rides the end frame.
680
- const tc = `tc-diff-${Date.now()}`;
681
- const hunks = [
682
- {
683
- lines: [
684
- { kind: "context", oldLine: 1, newLine: 1, text: "const a = 1;" },
685
- { kind: "remove", oldLine: 2, text: "const b = 2;" },
686
- { kind: "add", newLine: 2, text: "const b = 3;" },
687
- { kind: "context", oldLine: 3, newLine: 3, text: "const c = 4;" },
688
- ],
689
- },
690
- ];
691
- _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
692
- _broadcastEnvelope(relayDeps, {
693
- rpc: {
694
- type: "tool_execution_start",
695
- toolCallId: tc,
696
- toolName: "edit",
697
- args: {
698
- path: "demo.ts",
699
- old_string: "const b = 2;",
700
- new_string: "const b = 3;",
701
- },
702
- },
703
- aux: { hunks },
704
- });
705
- _broadcastEnvelope(relayDeps, {
706
- rpc: {
707
- type: "tool_execution_end",
708
- toolCallId: tc,
709
- result: "edited demo.ts",
710
- isError: false,
711
- },
712
- });
713
- _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
714
- return "sent diff (edit + aux hunks — shows the Diff⇄Content toggle)";
715
- }
716
- case "code-shell": {
717
- // bash-family result → the app classifies it into a `code` block (lang
718
- // shell), syntax-highlighted. OUTPUT is app-side now — no aux stamped.
719
- const tc = `tc-sh-${Date.now()}`;
720
- _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
721
- _broadcastEnvelope(relayDeps, {
722
- rpc: {
723
- type: "tool_execution_start",
724
- toolCallId: tc,
725
- toolName: "bash",
726
- args: { command: "ls -la" },
727
- },
728
- });
729
- _broadcastEnvelope(relayDeps, {
730
- rpc: {
731
- type: "tool_execution_end",
732
- toolCallId: tc,
733
- result: "total 24\ndrwxr-xr-x 5 geo staff 160 Aug 29 10:00 .\n-rw-r--r-- 1 geo staff 1024 index.ts\n-rw-r--r-- 1 geo staff 512 README.md",
734
- isError: false,
735
- },
736
- });
737
- _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
738
- return "sent code-shell (bash output → code block, lang shell)";
739
- }
740
- case "code-file": {
741
- // read-family with a *.swift path → `code` block, lang inferred from the
742
- // extension (swift) and highlighted.
743
- const tc = `tc-rd-${Date.now()}`;
744
- _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
745
- _broadcastEnvelope(relayDeps, {
746
- rpc: {
747
- type: "tool_execution_start",
748
- toolCallId: tc,
749
- toolName: "read",
750
- args: { path: "/src/Greeter.swift" },
751
- },
752
- });
753
- _broadcastEnvelope(relayDeps, {
754
- rpc: {
755
- type: "tool_execution_end",
756
- toolCallId: tc,
757
- result: 'struct Greeter {\n let name: String\n func greet() -> String {\n return "Hello, \\(name)!"\n }\n}',
758
- isError: false,
759
- },
760
- });
761
- _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
762
- return "sent code-file (read .swift → highlighted code block)";
763
- }
764
- case "diff-output": {
765
- // A tool whose RESULT already embeds a unified diff → the app parses it
766
- // into a `diff` block (re-reading persisted text; replay-safe).
767
- const tc = `tc-do-${Date.now()}`;
768
- _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
769
- _broadcastEnvelope(relayDeps, {
770
- rpc: {
771
- type: "tool_execution_start",
772
- toolCallId: tc,
773
- toolName: "bash",
774
- args: { command: "git diff" },
775
- },
776
- });
777
- _broadcastEnvelope(relayDeps, {
778
- rpc: {
779
- type: "tool_execution_end",
780
- toolCallId: tc,
781
- result: 'diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@ -1,3 +1,3 @@\n const port = 3000;\n-const host = "127.0.0.1";\n+const host = "0.0.0.0";\n start(host, port);',
782
- isError: false,
783
- },
784
- });
785
- _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
786
- return "sent diff-output (result embeds a unified diff → diff block)";
787
- }
788
- case "write": {
789
- // write carries the new file text in args.content; no live diff → the
790
- // card shows the Content view (new text as a code block, replay-safe).
791
- const tc = `tc-wr-${Date.now()}`;
792
- const content = "export function add(a: number, b: number): number {\n return a + b;\n}";
793
- _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
794
- _broadcastEnvelope(relayDeps, {
795
- rpc: {
796
- type: "tool_execution_start",
797
- toolCallId: tc,
798
- toolName: "write",
799
- args: { path: "/src/math.ts", content },
800
- },
801
- });
802
- _broadcastEnvelope(relayDeps, {
803
- rpc: {
804
- type: "tool_execution_end",
805
- toolCallId: tc,
806
- result: "wrote /src/math.ts",
807
- isError: false,
808
- },
809
- });
810
- _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
811
- return "sent write (args.content → content-as-code block)";
812
- }
813
- case "agent": {
814
- _broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
815
- _broadcastEnvelope(relayDeps, {
816
- rpc: {
817
- type: "message_update",
818
- assistantMessageEvent: {
819
- type: "text_delta",
820
- delta: "This is a ",
821
- },
822
- },
823
- });
824
- _broadcastEnvelope(relayDeps, {
825
- rpc: {
826
- type: "message_update",
827
- assistantMessageEvent: {
828
- type: "text_delta",
829
- delta: "test agent message.",
830
- },
831
- },
832
- });
833
- _broadcastEnvelope(relayDeps, {
834
- rpc: {
835
- type: "message_end",
836
- message: {
837
- role: "assistant",
838
- content: [{ type: "text", text: "This is a test agent message." }],
839
- },
840
- },
841
- });
842
- _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
843
- return "sent agent message (envelope)";
844
- }
845
- case "error":
846
- _broadcastEnvelope(relayDeps, {
847
- rpc: {
848
- type: "message_end",
849
- message: {
850
- role: "assistant",
851
- stopReason: "error",
852
- errorMessage: "This is a test error.",
853
- },
854
- },
855
- });
856
- _broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
857
- return "sent error (envelope message_end/error)";
858
- case "all":
859
- for (const sc of [
860
- "ask-notify",
861
- "plan",
862
- "subagents",
863
- "svg",
864
- "tool",
865
- "diff",
866
- "code-shell",
867
- "code-file",
868
- "diff-output",
869
- "write",
870
- "agent",
871
- "error",
872
- ])
873
- _runTestScenario(sc);
874
- return "sent all (ask-notify, plan, subagents, svg, tool, diff, code-shell, code-file, diff-output, write, agent, error)";
875
- default:
876
- return "usage: /unbien test <ask-select|ask-confirm|ask-input|ask-editor|ask-notify|ask-rich|plan|subagents|svg|tool|diff|code-shell|code-file|diff-output|write|agent|error|all>";
877
- }
878
- }
879
470
  /**
880
- * New-protocol inbound: dispatch an envelope-carried pi `RpcCommand` to the SDK
881
- * and answer with a `{ rpc: response }` envelope to the SENDER. Native to the
882
- * envelope wire — does NOT use the stock `_routeClientMessageFrom` switch. The
883
- * SDK primitives (`_wakeAgent`, `_abortCurrentTurn`) are pi, not old protocol.
471
+ * Plane routers live in ./routing.ts (carved, slice 4). These thin wrappers
472
+ * preserve the call sites + the CommandDeps surface; the deps accessors
473
+ * keep module state read LIVE at each dispatch.
884
474
  */
885
475
  function _routeRpcCommandFrom(sender, env) {
886
- const frame = env.rpc;
887
- if (!frame || typeof frame !== "object")
888
- return; // no {evt} inbound today
889
- envLog(`rpc inbound: ${String(frame.type)}`);
890
- // extension_ui_response is a reply to an extension-issued dialog, not a command —
891
- // route it straight to the ui bridge (same target as the stock path).
892
- if (frame.type === "extension_ui_response") {
893
- // SAFETY: the type-discriminator check directly above proves this frame is
894
- // an extension_ui_response envelope, which is the ExtensionUiResponseWire shape.
895
- _extensionUiBridge?.respond(frame);
896
- return;
897
- }
898
- // session_sync (reconstruction) is un-bien's OWN protocol — dispatched on the
899
- // un plane by _routeUnBienPlaneFrom, NOT here. Only byte-faithful pi rpc
900
- // commands + extension_ui_response ride this rpc dispatch.
901
- const handlers = createRpcHandlers(rpcDeps, sender);
902
- void dispatchRpcCommand(frame, handlers)
903
- .then((resp) => {
904
- // Envelope-native ONLY: no stock fallback. An unhandled rpc type is
905
- // ignored (forward-compat). un-bien's own commands (session_sync,
906
- // session_launch) ride the un plane via _routeUnBienPlaneFrom.
907
- if (resp)
908
- sender.sendEnvelope(resp);
909
- })
910
- .catch((err) => {
911
- console.error(`[un-bien] rpc inbound dispatch failed: ${String(err)}`);
912
- });
476
+ routeRpcCommand(planeDeps, sender, env);
913
477
  }
914
- /**
915
- * un-bien plane inbound (`type:"ub"`): dispatch un-bien's OWN protocol frames by
916
- * their inner `.type`. app->ext today: `session_sync` (reconstruction request)
917
- * and `session_launch` (mesh remote-launch). These are NOT pi rpc — the
918
- * EXTENSION acts. The reconstruction REPLAY frames it emits stay byte-faithful
919
- * pi rpc frames on the rpc plane; only the request + `session_sync_end`
920
- * terminator are un-plane frames.
921
- */
922
478
  function _routeUnBienPlaneFrom(sender, env) {
923
- const frame = env.ub;
924
- if (!frame || typeof frame !== "object")
925
- return;
926
- const type = frame.type;
927
- envLog(`ub inbound: ${String(type)}`);
928
- if (type === "session_sync") {
929
- const f = frame;
930
- // session_sync now carries ONLY un-bien's NON-rpc display state: panels +
931
- // pending extension_ui. The TRANSCRIPT is the app's OWN native get_entries
932
- // rpc (reduced by SessionState.applyEntries) — NOT replayed here. Design
933
- // 01M15FMQ: separate the rpc transcript (get_entries) from un-bien panel/ui
934
- // state, each an independent app-driven request issued on open + reconnect.
935
- for (const req of _extensionUiBridge?.pendingRequests() ?? [])
936
- sender.send(req);
937
- const panels = _panelBridge?.pendingPanels() ?? [];
938
- for (const panel of panels)
939
- sender.sendEnvelope({ evt: { channel: "panel", data: panel } });
940
- envLog(`session_sync(ub): panels=${panels.length} + ui (transcript is the app's get_entries rpc)`);
941
- // Terminator/ack on the ub plane; carries the session clock so the app can
942
- // detect a pi restart. `truncated`/`limit` are gone (a replay concern;
943
- // get_entries is unbounded / since-delta). `session_name` (pre-release
944
- // 2026-09-18): the NAME PULL — heals a missed session_info_changed push
945
- // (an iOS backgrounded socket misses live frames; the relay's stored meta
946
- // is stale until our next hello, so reconnects can't carry it either).
947
- // session_sync is issued on EVERY open + reconnect, so the name heals on
948
- // every natural cycle. Old apps ignore the field; old extensions omit it.
949
- //
950
- // SOURCE = the room's DISPLAY name (myRoomMeta.name), NOT deps.sessionName:
951
- // the latter is `_sessionName`, which `_cmdJoin` sets to the mesh session
952
- // id LOCAL_SESSION_NAME ("local"). Sending that here clobbered the tile
953
- // label with "local" on every reconnect (the flaky-name bug) — the
954
- // non-live pull disagreed with the live session_info push. myRoomMeta.name
955
- // is the same value pair_ok sends (deps.displayName) and tracks live
956
- // renames (session_info_changed updates it), so pull == push now.
957
- const displayName = deps.myRoomMeta?.name;
958
- // Fork auto-nav: a fork-born session's FIRST sync echoes the app's
959
- // originating fork request id (design: fork switch). take = read+unlink,
960
- // so it fires once (the app navigates on first receipt; later reconnects
961
- // must not re-jump). Keyed by the session id, so it survives the extension
962
- // module re-eval that ctx.fork triggers.
963
- const forkSm = _rootState().sessionManager;
964
- const forkedFromReq = forkSm && typeof forkSm.getSessionDir === "function"
965
- ? takeForkLink(forkSm.getSessionDir(), forkSm.getSessionId())
966
- : undefined;
967
- sender.sendEnvelope({
968
- ub: {
969
- type: "session_sync_end",
970
- ...(typeof f.id === "string" ? { in_reply_to: f.id } : {}),
971
- session_started_at: _sessionStartedAt ?? 0,
972
- ...(displayName ? { session_name: displayName } : {}),
973
- ...(forkedFromReq ? { forked_from_req: forkedFromReq } : {}),
974
- },
975
- });
976
- return;
977
- }
978
- if (type === "terminate") {
979
- // app->ext: kill THIS session. Root room only — a child room's terminate
980
- // is handled by the child-room router in subagent_rooms (never reaches
981
- // here; that room's conn dispatches its own ub frames). Gate on local
982
- // config; the app confirms + shows red before sending.
983
- if (!effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()))) {
984
- envLog("terminate(ub): remote terminate disabled on this machine");
985
- return;
986
- }
987
- envLog(`terminate(ub): graceful host shutdown requested${typeof frame.reason === "string" ? ` (${String(frame.reason)})` : ""}`);
988
- _safeNotify("Terminated from the app", "warning");
989
- try {
990
- // ctx.shutdown() IS the /quit equivalent — pi's own source lists
991
- // "extension shutdown()" alongside Ctrl+D / /quit (interactive-mode.js
992
- // 3229: same graceful this.shutdown(): drain input, ui.stop() restores
993
- // cooked mode + cursor, extension disposal, resume hint, exit). It sits
994
- // DIRECTLY on the event ctx (ExtensionContext.shutdown — there is no
995
- // ctx.actions sub-object; the first two attempts failed exactly there).
996
- //
997
- // MID-TURN DEFERRAL: the interactive handler fires immediately only when
998
- // idle; otherwise it sets `shutdownRequested` and waits for
999
- // `agent_settled` — which never arrives when nothing is running. So
1000
- // abort() FIRST: aborting a running turn forces the settle that
1001
- // re-checks the flag; on an idle session abort is a no-op and the
1002
- // idle fast-path fires directly.
1003
- //
1004
- // NO process.exit fallback, ever: a raw exit leaves the TTY in raw
1005
- // mode (keypresses echo escape codes until `stty sane`). No live ctx =>
1006
- // log loudly and stay up; the user quits on the machine.
1007
- const ctx = _liveCtx();
1008
- if (ctx?.shutdown) {
1009
- ctx.abort?.();
1010
- ctx.shutdown();
1011
- }
1012
- else {
1013
- envLog("terminate(ub): no live ctx — refusing raw exit (would leave the terminal in raw mode); staying up");
1014
- }
1015
- }
1016
- catch (err) {
1017
- envLog(`terminate(ub) error: ${err instanceof Error ? err.message : String(err)}`);
1018
- }
1019
- return;
1020
- }
1021
- if (type === "close_child_room") {
1022
- // app->ext(PARENT): permanently close one of MY child rooms (done-subagent
1023
- // removal — the room lingers by design otherwise). Tombstone + dispose in
1024
- // subagent_rooms; the relay fires room_ended when the last conn drops.
1025
- if (!effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()))) {
1026
- envLog("close_child_room(ub): remote terminate disabled on this machine");
1027
- return;
1028
- }
1029
- const roomId = frame.room_id;
1030
- if (typeof roomId !== "string" || roomId.length === 0)
1031
- return;
1032
- const closed = _subagentRooms?.closeChildRoom(roomId) ?? false;
1033
- envLog(`close_child_room(ub): room=${roomId.slice(0, 8)} closed=${closed}`);
1034
- return;
1035
- }
1036
- if (type === "session_launch") {
1037
- const f = frame;
1038
- const cwd = _expandTilde(typeof f.cwd === "string" && f.cwd.length > 0 ? f.cwd : process.cwd());
1039
- if (!effectiveAllowRemoteLaunch(loadLocalConfig(cwd))) {
1040
- envLog("session_launch(ub): remote launch disabled on this machine");
1041
- return;
1042
- }
1043
- // Directory allow-list (design 01M211VW9), read FRESH each request.
1044
- if (!launchDirAllowed(cwd, loadConfig().launch?.dirs)) {
1045
- envLog("session_launch(ub): cwd not in launch.dirs allow-list");
1046
- sender.send({
1047
- type: "error",
1048
- code: "permission_denied",
1049
- message: "Remote launch not permitted for this directory",
1050
- });
1051
- return;
1052
- }
1053
- // Backend is a MACHINE config choice (pick-one via launch.backend), not
1054
- // app-chosen; rpc is a fast-follow so only tmux|herdr resolve here.
1055
- const backend = loadConfig().launch?.backend === "herdr" ? "herdr" : "tmux";
1056
- const launchError = _launchSession(backend, cwd, typeof f.name === "string" ? f.name : undefined,
1057
- // Launch correlation: pi is spawned with UNBIEN_LAUNCH_REQ=<id> and
1058
- // echoes it in room_meta, so the launching app can match the announcing
1059
- // room to THIS request and auto-open the chat (resume flow).
1060
- typeof f.resume === "string" && f.resume.trim().length > 0
1061
- ? f.resume.trim()
1062
- : undefined, typeof f.id === "string" && f.id.trim().length > 0
1063
- ? f.id.trim()
1064
- : undefined);
1065
- if (launchError)
1066
- envLog(`session_launch(ub) error: ${launchError}`);
1067
- return;
1068
- }
1069
- if (type === "session_fork" || type === "session_navigate") {
1070
- // app->ext: fork a NEW session / branch IN PLACE from a conversation entry.
1071
- // ctx.fork / ctx.navigateTree live ONLY on the command ctx, so we can't act
1072
- // here (this is the peer-channel dispatch, not a command). Self-dispatch the
1073
- // registered `/unbien fork|branch` command via sendUserMessage — pi runs it
1074
- // with a real ExtensionCommandContext. No stashed ctx, no startup bootstrap:
1075
- // the command ctx lives exactly as long as the op that needs it. Not gated
1076
- // beyond being paired — these are no more privileged than a prompt on this
1077
- // same room (which the app can already send).
1078
- const f = frame;
1079
- const entryId = typeof f.entry_id === "string" ? f.entry_id : "";
1080
- if (!entryId) {
1081
- envLog(`${String(type)}(ub): missing entry_id — ignored`);
1082
- return;
1083
- }
1084
- if (type === "session_fork") {
1085
- // Thread the request id (reqId) so the new session can echo it back as
1086
- // forked_from_req on its first sync — the app's auto-nav correlation.
1087
- // Order: `<entryId> <reqId> [pos]` (see _cmdFork's parse).
1088
- const reqId = typeof f.id === "string" ? f.id : "";
1089
- const posArg = f.position === "at" || f.position === "before" ? ` ${f.position}` : "";
1090
- const reqArg = reqId ? ` ${reqId}` : "";
1091
- envLog(`session_fork(ub): entry=${entryId.slice(0, 8)}${posArg}`);
1092
- _pi?.sendUserMessage(`/unbien fork ${entryId}${reqArg}${posArg}`, {
1093
- deliverAs: "followUp",
1094
- expandPromptTemplates: true,
1095
- });
1096
- }
1097
- else {
1098
- envLog(`session_navigate(ub): entry=${entryId.slice(0, 8)}`);
1099
- _pi?.sendUserMessage(`/unbien branch ${entryId}`, {
1100
- deliverAs: "followUp",
1101
- expandPromptTemplates: true,
1102
- });
1103
- }
1104
- return;
1105
- }
479
+ routeUnBienPlane(planeDeps, sender, env);
1106
480
  }
481
+ // ── Plane-router deps (accessors over module state — live at dispatch) ──────
482
+ const planeDeps = {
483
+ extensionUiBridge: () => _extensionUiBridge,
484
+ panelBridge: () => _panelBridge,
485
+ subagentRooms: () => _subagentRooms,
486
+ sessionStartedAt: () => _rootState().sessionStartedAt ?? 0,
487
+ myRoomMeta: () => _rootState().myRoomMeta,
488
+ rootState: () => _rootState(),
489
+ safeNotify: _safeNotify,
490
+ liveCtx: () => _liveCtx(),
491
+ pi: () => _pi,
492
+ // Getter: rpcDeps is declared later in the file; the dispatch reads it
493
+ // live at call time.
494
+ get rpcDeps() {
495
+ return rpcDeps;
496
+ },
497
+ };
1107
498
  // ── Display-name helpers ──────────────────────────────────────────────────────
1108
499
  /**
1109
500
  * Resolves the name this Pi shows to the mobile app and the relay's
@@ -1240,7 +631,6 @@ function _probeCtx(label, ctx) {
1240
631
  // base-ctx methods (no newSession — that's command-ctx only); command-only ops
1241
632
  // (fork/new/branch) reach a command ctx by self-dispatching a slash command
1242
633
  // instead of retaining one.
1243
- let _lastEventCtx = null;
1244
634
  const _noopCtx = { ui: { notify: () => undefined }, abort: () => undefined };
1245
635
  // A single Pi process can load this extension TWICE in the SAME session:
1246
636
  // when it is launched as `pi -e <dist>/index.js` AND un-bien is ALSO installed
@@ -1310,7 +700,7 @@ const imageDeps = {
1310
700
  },
1311
701
  rootState: _rootState,
1312
702
  get myRoomMeta() {
1313
- return _myRoomMeta;
703
+ return _rootState().myRoomMeta;
1314
704
  },
1315
705
  wakeAgent: _wakeAgent,
1316
706
  };
@@ -1326,7 +716,7 @@ const rpcDeps = {
1326
716
  },
1327
717
  rootState: _rootState,
1328
718
  get lastEventCtx() {
1329
- return _lastEventCtx;
719
+ return _rootState().baseCtx;
1330
720
  },
1331
721
  imageDeps,
1332
722
  wakeAgent: _wakeAgent,
@@ -1408,13 +798,13 @@ const relayDeps = {
1408
798
  return _cachedEd25519;
1409
799
  },
1410
800
  get myRoomId() {
1411
- return _myRoomId;
801
+ return _rootState().myRoomId;
1412
802
  },
1413
803
  get myRoomMeta() {
1414
- return _myRoomMeta;
804
+ return _rootState().myRoomMeta;
1415
805
  },
1416
806
  get sessionStartedAt() {
1417
- return _sessionStartedAt;
807
+ return _rootState().sessionStartedAt;
1418
808
  },
1419
809
  sessionCwd: () => {
1420
810
  const sm = _rootState().sessionManager;
@@ -1504,30 +894,30 @@ const deps = {
1504
894
  _cachedEd25519 = v;
1505
895
  },
1506
896
  get currentModel() {
1507
- return _currentModel;
897
+ return _rootState().currentModel ?? undefined;
1508
898
  },
1509
899
  set currentModel(v) {
1510
- _currentModel = v;
900
+ _rootState().currentModel = v ?? null;
1511
901
  },
1512
902
  get currentThinking() {
1513
- return _currentThinking;
903
+ return _rootState().thinking ?? undefined;
1514
904
  },
1515
905
  abortCurrentTurn: () => _abortCurrentTurn(),
1516
906
  cancelPendingAsks: () => _extensionUiBridge?.cancelAllPending() ?? 0,
1517
907
  set currentThinking(v) {
1518
- _currentThinking = v;
908
+ _rootState().thinking = v ?? null;
1519
909
  },
1520
910
  get myRoomId() {
1521
- return _myRoomId;
911
+ return _rootState().myRoomId;
1522
912
  },
1523
913
  set myRoomId(v) {
1524
- _myRoomId = v;
914
+ _rootState().myRoomId = v;
1525
915
  },
1526
916
  get myRoomMeta() {
1527
- return _myRoomMeta;
917
+ return _rootState().myRoomMeta;
1528
918
  },
1529
919
  set myRoomMeta(v) {
1530
- _myRoomMeta = v;
920
+ _rootState().myRoomMeta = v;
1531
921
  },
1532
922
  get peerShort() {
1533
923
  return _peerShort;
@@ -1536,10 +926,10 @@ const deps = {
1536
926
  _peerShort = v;
1537
927
  },
1538
928
  get sessionStartedAt() {
1539
- return _sessionStartedAt;
929
+ return _rootState().sessionStartedAt;
1540
930
  },
1541
931
  set sessionStartedAt(v) {
1542
- _sessionStartedAt = v;
932
+ _rootState().sessionStartedAt = v;
1543
933
  },
1544
934
  get cwdLock() {
1545
935
  return _cwdLock;
@@ -1614,7 +1004,7 @@ const deps = {
1614
1004
  return _disposed;
1615
1005
  },
1616
1006
  get lastEventCtx() {
1617
- return _lastEventCtx;
1007
+ return _rootState().baseCtx;
1618
1008
  },
1619
1009
  get pi() {
1620
1010
  return _pi;
@@ -1642,7 +1032,7 @@ const deps = {
1642
1032
  handleControl: _handleControl,
1643
1033
  relayStatus: () => _relayStatus(relayDeps),
1644
1034
  getState: _getState,
1645
- runTestScenario: _runTestScenario,
1035
+ runTestScenario: (scenario) => runTestScenario({ broadcast: (env) => _broadcastEnvelope(relayDeps, env), emitBus: makeTestBusEmitter(() => _pi) }, scenario),
1646
1036
  safeNotify: _safeNotify,
1647
1037
  renameAgent: _renameAgent,
1648
1038
  };
@@ -1703,10 +1093,10 @@ const _testHooks = createTestHooks({
1703
1093
  _lockedName = v;
1704
1094
  },
1705
1095
  set sessionStartedAt(v) {
1706
- _sessionStartedAt = v;
1096
+ _rootState().sessionStartedAt = v;
1707
1097
  },
1708
1098
  set currentModel(v) {
1709
- _currentModel = v;
1099
+ _rootState().currentModel = v ?? null;
1710
1100
  },
1711
1101
  set pi(v) {
1712
1102
  _pi = v;
@@ -1799,7 +1189,7 @@ const extension = (pi) => {
1799
1189
  });
1800
1190
  _subagentRooms?.dispose();
1801
1191
  _subagentRooms = initSubagentRooms(pi, {
1802
- getParentRoomId: () => _myRoomId,
1192
+ getParentRoomId: () => _rootState().myRoomId,
1803
1193
  getParentSessionId: () => _rootSessionId,
1804
1194
  broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
1805
1195
  });
@@ -1882,19 +1272,19 @@ const extension = (pi) => {
1882
1272
  const level = event?.level;
1883
1273
  if (!level)
1884
1274
  return;
1885
- // Cache per-sid; only the ROOT projects to _currentThinking + room_meta.
1275
+ // Cache per-sid; only the ROOT projects to room_meta.
1886
1276
  const sid = _sidOf(ctx);
1887
- _stateFor(sid).thinking = level;
1277
+ const st = _stateFor(sid);
1278
+ st.thinking = level;
1888
1279
  if (_isNonRootSid(sid))
1889
1280
  return;
1890
- _currentThinking = level;
1891
- if (_myRoomMeta)
1892
- _myRoomMeta = { ..._myRoomMeta, thinking: level };
1893
- if (!_relay || !_myRoomId)
1281
+ if (st.myRoomMeta)
1282
+ st.myRoomMeta = { ...st.myRoomMeta, thinking: level };
1283
+ if (!_relay || !st.myRoomId)
1894
1284
  return;
1895
1285
  _relay.sendControl({
1896
1286
  type: "room_meta_update",
1897
- room_id: _myRoomId,
1287
+ room_id: st.myRoomId,
1898
1288
  meta: { thinking: level },
1899
1289
  });
1900
1290
  });
@@ -1970,35 +1360,36 @@ const extension = (pi) => {
1970
1360
  }
1971
1361
  if (_isNonRootSid(sid))
1972
1362
  return; // room_meta projection is root-only
1973
- // Root projection: seed the global model + room_meta hello from the root's
1363
+ // Root projection: seed the room_meta model + hello from the root's
1974
1364
  // cached model, once.
1975
- if (!_currentModel && st.model)
1365
+ if (!st.currentModel && st.model)
1976
1366
  _setCurrentModel(st.model);
1977
1367
  // Plan/32 Part B: publish working=true as room_meta (raw, no debounce —
1978
1368
  // the debounce lives in the app). Same shape as the model/thinking updates.
1979
- // _myRoomMeta is the ROOM projection (driven only by the root session).
1980
- if (_myRoomMeta)
1981
- _myRoomMeta = { ..._myRoomMeta, working: true };
1982
- if (_relay && _myRoomId) {
1369
+ // myRoomMeta is the ROOM projection (driven only by the root session).
1370
+ if (st.myRoomMeta)
1371
+ st.myRoomMeta = { ...st.myRoomMeta, working: true };
1372
+ if (_relay && st.myRoomId) {
1983
1373
  _relay.sendControl({
1984
1374
  type: "room_meta_update",
1985
- room_id: _myRoomId,
1375
+ room_id: st.myRoomId,
1986
1376
  meta: { working: true },
1987
1377
  });
1988
1378
  }
1989
1379
  });
1990
1380
  pi.on("turn_end", (_event, ctx) => {
1991
1381
  const sid = _sidOf(ctx);
1992
- _stateFor(sid).working = false;
1382
+ const st = _stateFor(sid);
1383
+ st.working = false;
1993
1384
  if (_isNonRootSid(sid))
1994
1385
  return; // room_meta is root-only
1995
1386
  // Plan/32 Part B: publish working=false as room_meta (raw, no debounce).
1996
- if (_myRoomMeta)
1997
- _myRoomMeta = { ..._myRoomMeta, working: false };
1998
- if (_relay && _myRoomId) {
1387
+ if (st.myRoomMeta)
1388
+ st.myRoomMeta = { ...st.myRoomMeta, working: false };
1389
+ if (_relay && st.myRoomId) {
1999
1390
  _relay.sendControl({
2000
1391
  type: "room_meta_update",
2001
- room_id: _myRoomId,
1392
+ room_id: st.myRoomId,
2002
1393
  meta: { working: false },
2003
1394
  });
2004
1395
  }
@@ -2043,8 +1434,9 @@ const extension = (pi) => {
2043
1434
  if (_isNonRootSid(_sidOf(ctx)))
2044
1435
  return;
2045
1436
  _sessionName = name;
2046
- if (_myRoomMeta)
2047
- _myRoomMeta = { ..._myRoomMeta, name };
1437
+ const st = _rootState();
1438
+ if (st.myRoomMeta)
1439
+ st.myRoomMeta = { ...st.myRoomMeta, name };
2048
1440
  _broadcastEnvelope(relayDeps, {
2049
1441
  evt: { channel: "session_info", data: { name } },
2050
1442
  });
@@ -2053,10 +1445,10 @@ const extension = (pi) => {
2053
1445
  // reaches attached peers, so pre-attach the Home tile went stale until open
2054
1446
  // or reconnect. Relay >=0.6.0 merges + fans `name`; older relays drop the
2055
1447
  // unknown field (harmless — attached peers still get the evt). Design 01M1SPN7.
2056
- if (_relay && _myRoomId) {
1448
+ if (_relay && st.myRoomId) {
2057
1449
  _relay.sendControl({
2058
1450
  type: "room_meta_update",
2059
- room_id: _myRoomId,
1451
+ room_id: st.myRoomId,
2060
1452
  meta: { name },
2061
1453
  });
2062
1454
  }
@@ -2090,12 +1482,11 @@ const extension = (pi) => {
2090
1482
  // session_start with their own ctx). Each records its OWN sessionManager —
2091
1483
  // no cross-session clobber (was the unguarded `_sessionManager = ...` bug).
2092
1484
  const sid = _sidOf(ctx);
2093
- // The module BASE ctx (compact/notify fallback when no fresh ctx is passed)
2094
- // is the ROOT's — a subagent child's ctx must NOT clobber it, same
2095
- // no-cross-session-clobber rule as the per-sid sessionManager. Otherwise a
2096
- // subagent steals the base ctx and root-scoped notifies silently drop.
2097
- if (!_isNonRootSid(sid))
2098
- _lastEventCtx = ctx;
1485
+ // Base ctx (compact/notify fallback when no fresh ctx is passed) is
1486
+ // PER-SESSION (plan 01M18RNH) — each session_start records its OWN; the
1487
+ // base-ctx resolution reads the ROOT's, so a child's ctx never leaks
1488
+ // (structurally replaces the old `!_isNonRootSid` guard + global).
1489
+ _stateFor(sid).baseCtx = ctx ?? null;
2099
1490
  // EVENT-CTX TARGETED PROBE (run 2026-09-18, round 3): the ctx objects
2100
1491
  // are guarded (throwing getters — proxies), so full reflection
2101
1492
  // under-reports or throws. Per-property access, each caught, is the
@@ -2131,7 +1522,7 @@ const extension = (pi) => {
2131
1522
  });
2132
1523
  if (!_subagentRooms)
2133
1524
  _subagentRooms = initSubagentRooms(pi, {
2134
- getParentRoomId: () => _myRoomId,
1525
+ getParentRoomId: () => _rootState().myRoomId,
2135
1526
  getParentSessionId: () => _rootSessionId,
2136
1527
  broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
2137
1528
  });
@@ -2295,8 +1686,8 @@ const extension = (pi) => {
2295
1686
  }
2296
1687
  // Drop the captured event ctx immediately. On module-reuse hosts the same
2297
1688
  // instance survives session replacement; the next session_start re-binds
2298
- // `_lastEventCtx` for the new session.
2299
- _lastEventCtx = null;
1689
+ // the root record's baseCtx for the new session.
1690
+ _rootState().baseCtx = null;
2300
1691
  // No bye reason: the process keeps running and the fresh instance re-joins
2301
1692
  // the SAME relay room, so an explicit offline→online flap would be wrong.
2302
1693
  // Revoke producer/Relay/bridge authority while the global node is still
@@ -2463,7 +1854,7 @@ function _deliverMeshMessageToAgent(env) {
2463
1854
  * handles incoming app→pi requests.
2464
1855
  */
2465
1856
  function _abortCurrentTurn(fallbackCtx) {
2466
- const candidates = [_lastEventCtx, fallbackCtx];
1857
+ const candidates = [_rootState().baseCtx, fallbackCtx];
2467
1858
  for (const candidate of candidates) {
2468
1859
  if (!candidate || candidate === _noopCtx)
2469
1860
  continue;