@geohar/un-bien 0.20.16 → 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,628 +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
- // ENVELOPE the replay: the app's handleRouted only dispatches
937
- // {rpc|evt|ub} frames — a bare extension_ui_request fell through both
938
- // branches and was silently dropped, so every session_sync replay was
939
- // a no-op and the terminator then retired the still-pending prompt as
940
- // "stale" (the "ask not displayed on phone" bug, 2026-09-25). The live
941
- // ask path sends the same shape enveloped ({rpc}); mirror it here.
942
- sender.sendEnvelope({ rpc: req });
943
- const panels = _panelBridge?.pendingPanels() ?? [];
944
- for (const panel of panels)
945
- sender.sendEnvelope({ evt: { channel: "panel", data: panel } });
946
- envLog(`session_sync(ub): panels=${panels.length} + ui (transcript is the app's get_entries rpc)`);
947
- // Terminator/ack on the ub plane; carries the session clock so the app can
948
- // detect a pi restart. `truncated`/`limit` are gone (a replay concern;
949
- // get_entries is unbounded / since-delta). `session_name` (pre-release
950
- // 2026-09-18): the NAME PULL — heals a missed session_info_changed push
951
- // (an iOS backgrounded socket misses live frames; the relay's stored meta
952
- // is stale until our next hello, so reconnects can't carry it either).
953
- // session_sync is issued on EVERY open + reconnect, so the name heals on
954
- // every natural cycle. Old apps ignore the field; old extensions omit it.
955
- //
956
- // SOURCE = the room's DISPLAY name (myRoomMeta.name), NOT deps.sessionName:
957
- // the latter is `_sessionName`, which `_cmdJoin` sets to the mesh session
958
- // id LOCAL_SESSION_NAME ("local"). Sending that here clobbered the tile
959
- // label with "local" on every reconnect (the flaky-name bug) — the
960
- // non-live pull disagreed with the live session_info push. myRoomMeta.name
961
- // is the same value pair_ok sends (deps.displayName) and tracks live
962
- // renames (session_info_changed updates it), so pull == push now.
963
- const displayName = deps.myRoomMeta?.name;
964
- // Fork auto-nav: a fork-born session's FIRST sync echoes the app's
965
- // originating fork request id (design: fork switch). take = read+unlink,
966
- // so it fires once (the app navigates on first receipt; later reconnects
967
- // must not re-jump). Keyed by the session id, so it survives the extension
968
- // module re-eval that ctx.fork triggers.
969
- const forkSm = _rootState().sessionManager;
970
- const forkedFromReq = forkSm && typeof forkSm.getSessionDir === "function"
971
- ? takeForkLink(forkSm.getSessionDir(), forkSm.getSessionId())
972
- : undefined;
973
- sender.sendEnvelope({
974
- ub: {
975
- type: "session_sync_end",
976
- ...(typeof f.id === "string" ? { in_reply_to: f.id } : {}),
977
- session_started_at: _sessionStartedAt ?? 0,
978
- ...(displayName ? { session_name: displayName } : {}),
979
- ...(forkedFromReq ? { forked_from_req: forkedFromReq } : {}),
980
- },
981
- });
982
- return;
983
- }
984
- if (type === "terminate") {
985
- // app->ext: kill THIS session. Root room only — a child room's terminate
986
- // is handled by the child-room router in subagent_rooms (never reaches
987
- // here; that room's conn dispatches its own ub frames). Gate on local
988
- // config; the app confirms + shows red before sending.
989
- if (!effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()))) {
990
- envLog("terminate(ub): remote terminate disabled on this machine");
991
- return;
992
- }
993
- envLog(`terminate(ub): graceful host shutdown requested${typeof frame.reason === "string" ? ` (${String(frame.reason)})` : ""}`);
994
- _safeNotify("Terminated from the app", "warning");
995
- try {
996
- // ctx.shutdown() IS the /quit equivalent — pi's own source lists
997
- // "extension shutdown()" alongside Ctrl+D / /quit (interactive-mode.js
998
- // 3229: same graceful this.shutdown(): drain input, ui.stop() restores
999
- // cooked mode + cursor, extension disposal, resume hint, exit). It sits
1000
- // DIRECTLY on the event ctx (ExtensionContext.shutdown — there is no
1001
- // ctx.actions sub-object; the first two attempts failed exactly there).
1002
- //
1003
- // MID-TURN DEFERRAL: the interactive handler fires immediately only when
1004
- // idle; otherwise it sets `shutdownRequested` and waits for
1005
- // `agent_settled` — which never arrives when nothing is running. So
1006
- // abort() FIRST: aborting a running turn forces the settle that
1007
- // re-checks the flag; on an idle session abort is a no-op and the
1008
- // idle fast-path fires directly.
1009
- //
1010
- // NO process.exit fallback, ever: a raw exit leaves the TTY in raw
1011
- // mode (keypresses echo escape codes until `stty sane`). No live ctx =>
1012
- // log loudly and stay up; the user quits on the machine.
1013
- const ctx = _liveCtx();
1014
- if (ctx?.shutdown) {
1015
- ctx.abort?.();
1016
- ctx.shutdown();
1017
- }
1018
- else {
1019
- envLog("terminate(ub): no live ctx — refusing raw exit (would leave the terminal in raw mode); staying up");
1020
- }
1021
- }
1022
- catch (err) {
1023
- envLog(`terminate(ub) error: ${err instanceof Error ? err.message : String(err)}`);
1024
- }
1025
- return;
1026
- }
1027
- if (type === "close_child_room") {
1028
- // app->ext(PARENT): permanently close one of MY child rooms (done-subagent
1029
- // removal — the room lingers by design otherwise). Tombstone + dispose in
1030
- // subagent_rooms; the relay fires room_ended when the last conn drops.
1031
- if (!effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()))) {
1032
- envLog("close_child_room(ub): remote terminate disabled on this machine");
1033
- return;
1034
- }
1035
- const roomId = frame.room_id;
1036
- if (typeof roomId !== "string" || roomId.length === 0)
1037
- return;
1038
- const closed = _subagentRooms?.closeChildRoom(roomId) ?? false;
1039
- envLog(`close_child_room(ub): room=${roomId.slice(0, 8)} closed=${closed}`);
1040
- return;
1041
- }
1042
- if (type === "session_launch") {
1043
- const f = frame;
1044
- const cwd = _expandTilde(typeof f.cwd === "string" && f.cwd.length > 0 ? f.cwd : process.cwd());
1045
- if (!effectiveAllowRemoteLaunch(loadLocalConfig(cwd))) {
1046
- envLog("session_launch(ub): remote launch disabled on this machine");
1047
- return;
1048
- }
1049
- // Directory allow-list (design 01M211VW9), read FRESH each request.
1050
- if (!launchDirAllowed(cwd, loadConfig().launch?.dirs)) {
1051
- envLog("session_launch(ub): cwd not in launch.dirs allow-list");
1052
- sender.send({
1053
- type: "error",
1054
- code: "permission_denied",
1055
- message: "Remote launch not permitted for this directory",
1056
- });
1057
- return;
1058
- }
1059
- // Backend is a MACHINE config choice (pick-one via launch.backend), not
1060
- // app-chosen; rpc is a fast-follow so only tmux|herdr resolve here.
1061
- const backend = loadConfig().launch?.backend === "herdr" ? "herdr" : "tmux";
1062
- const launchError = _launchSession(backend, cwd, typeof f.name === "string" ? f.name : undefined,
1063
- // Launch correlation: pi is spawned with UNBIEN_LAUNCH_REQ=<id> and
1064
- // echoes it in room_meta, so the launching app can match the announcing
1065
- // room to THIS request and auto-open the chat (resume flow).
1066
- typeof f.resume === "string" && f.resume.trim().length > 0
1067
- ? f.resume.trim()
1068
- : undefined, typeof f.id === "string" && f.id.trim().length > 0
1069
- ? f.id.trim()
1070
- : undefined);
1071
- if (launchError)
1072
- envLog(`session_launch(ub) error: ${launchError}`);
1073
- return;
1074
- }
1075
- if (type === "session_fork" || type === "session_navigate") {
1076
- // app->ext: fork a NEW session / branch IN PLACE from a conversation entry.
1077
- // ctx.fork / ctx.navigateTree live ONLY on the command ctx, so we can't act
1078
- // here (this is the peer-channel dispatch, not a command). Self-dispatch the
1079
- // registered `/unbien fork|branch` command via sendUserMessage — pi runs it
1080
- // with a real ExtensionCommandContext. No stashed ctx, no startup bootstrap:
1081
- // the command ctx lives exactly as long as the op that needs it. Not gated
1082
- // beyond being paired — these are no more privileged than a prompt on this
1083
- // same room (which the app can already send).
1084
- const f = frame;
1085
- const entryId = typeof f.entry_id === "string" ? f.entry_id : "";
1086
- if (!entryId) {
1087
- envLog(`${String(type)}(ub): missing entry_id — ignored`);
1088
- return;
1089
- }
1090
- if (type === "session_fork") {
1091
- // Thread the request id (reqId) so the new session can echo it back as
1092
- // forked_from_req on its first sync — the app's auto-nav correlation.
1093
- // Order: `<entryId> <reqId> [pos]` (see _cmdFork's parse).
1094
- const reqId = typeof f.id === "string" ? f.id : "";
1095
- const posArg = f.position === "at" || f.position === "before" ? ` ${f.position}` : "";
1096
- const reqArg = reqId ? ` ${reqId}` : "";
1097
- envLog(`session_fork(ub): entry=${entryId.slice(0, 8)}${posArg}`);
1098
- _pi?.sendUserMessage(`/unbien fork ${entryId}${reqArg}${posArg}`, {
1099
- deliverAs: "followUp",
1100
- expandPromptTemplates: true,
1101
- });
1102
- }
1103
- else {
1104
- envLog(`session_navigate(ub): entry=${entryId.slice(0, 8)}`);
1105
- _pi?.sendUserMessage(`/unbien branch ${entryId}`, {
1106
- deliverAs: "followUp",
1107
- expandPromptTemplates: true,
1108
- });
1109
- }
1110
- return;
1111
- }
479
+ routeUnBienPlane(planeDeps, sender, env);
1112
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
+ };
1113
498
  // ── Display-name helpers ──────────────────────────────────────────────────────
1114
499
  /**
1115
500
  * Resolves the name this Pi shows to the mobile app and the relay's
@@ -1246,7 +631,6 @@ function _probeCtx(label, ctx) {
1246
631
  // base-ctx methods (no newSession — that's command-ctx only); command-only ops
1247
632
  // (fork/new/branch) reach a command ctx by self-dispatching a slash command
1248
633
  // instead of retaining one.
1249
- let _lastEventCtx = null;
1250
634
  const _noopCtx = { ui: { notify: () => undefined }, abort: () => undefined };
1251
635
  // A single Pi process can load this extension TWICE in the SAME session:
1252
636
  // when it is launched as `pi -e <dist>/index.js` AND un-bien is ALSO installed
@@ -1316,7 +700,7 @@ const imageDeps = {
1316
700
  },
1317
701
  rootState: _rootState,
1318
702
  get myRoomMeta() {
1319
- return _myRoomMeta;
703
+ return _rootState().myRoomMeta;
1320
704
  },
1321
705
  wakeAgent: _wakeAgent,
1322
706
  };
@@ -1332,7 +716,7 @@ const rpcDeps = {
1332
716
  },
1333
717
  rootState: _rootState,
1334
718
  get lastEventCtx() {
1335
- return _lastEventCtx;
719
+ return _rootState().baseCtx;
1336
720
  },
1337
721
  imageDeps,
1338
722
  wakeAgent: _wakeAgent,
@@ -1414,13 +798,13 @@ const relayDeps = {
1414
798
  return _cachedEd25519;
1415
799
  },
1416
800
  get myRoomId() {
1417
- return _myRoomId;
801
+ return _rootState().myRoomId;
1418
802
  },
1419
803
  get myRoomMeta() {
1420
- return _myRoomMeta;
804
+ return _rootState().myRoomMeta;
1421
805
  },
1422
806
  get sessionStartedAt() {
1423
- return _sessionStartedAt;
807
+ return _rootState().sessionStartedAt;
1424
808
  },
1425
809
  sessionCwd: () => {
1426
810
  const sm = _rootState().sessionManager;
@@ -1510,30 +894,30 @@ const deps = {
1510
894
  _cachedEd25519 = v;
1511
895
  },
1512
896
  get currentModel() {
1513
- return _currentModel;
897
+ return _rootState().currentModel ?? undefined;
1514
898
  },
1515
899
  set currentModel(v) {
1516
- _currentModel = v;
900
+ _rootState().currentModel = v ?? null;
1517
901
  },
1518
902
  get currentThinking() {
1519
- return _currentThinking;
903
+ return _rootState().thinking ?? undefined;
1520
904
  },
1521
905
  abortCurrentTurn: () => _abortCurrentTurn(),
1522
906
  cancelPendingAsks: () => _extensionUiBridge?.cancelAllPending() ?? 0,
1523
907
  set currentThinking(v) {
1524
- _currentThinking = v;
908
+ _rootState().thinking = v ?? null;
1525
909
  },
1526
910
  get myRoomId() {
1527
- return _myRoomId;
911
+ return _rootState().myRoomId;
1528
912
  },
1529
913
  set myRoomId(v) {
1530
- _myRoomId = v;
914
+ _rootState().myRoomId = v;
1531
915
  },
1532
916
  get myRoomMeta() {
1533
- return _myRoomMeta;
917
+ return _rootState().myRoomMeta;
1534
918
  },
1535
919
  set myRoomMeta(v) {
1536
- _myRoomMeta = v;
920
+ _rootState().myRoomMeta = v;
1537
921
  },
1538
922
  get peerShort() {
1539
923
  return _peerShort;
@@ -1542,10 +926,10 @@ const deps = {
1542
926
  _peerShort = v;
1543
927
  },
1544
928
  get sessionStartedAt() {
1545
- return _sessionStartedAt;
929
+ return _rootState().sessionStartedAt;
1546
930
  },
1547
931
  set sessionStartedAt(v) {
1548
- _sessionStartedAt = v;
932
+ _rootState().sessionStartedAt = v;
1549
933
  },
1550
934
  get cwdLock() {
1551
935
  return _cwdLock;
@@ -1620,7 +1004,7 @@ const deps = {
1620
1004
  return _disposed;
1621
1005
  },
1622
1006
  get lastEventCtx() {
1623
- return _lastEventCtx;
1007
+ return _rootState().baseCtx;
1624
1008
  },
1625
1009
  get pi() {
1626
1010
  return _pi;
@@ -1648,7 +1032,7 @@ const deps = {
1648
1032
  handleControl: _handleControl,
1649
1033
  relayStatus: () => _relayStatus(relayDeps),
1650
1034
  getState: _getState,
1651
- runTestScenario: _runTestScenario,
1035
+ runTestScenario: (scenario) => runTestScenario({ broadcast: (env) => _broadcastEnvelope(relayDeps, env), emitBus: makeTestBusEmitter(() => _pi) }, scenario),
1652
1036
  safeNotify: _safeNotify,
1653
1037
  renameAgent: _renameAgent,
1654
1038
  };
@@ -1709,10 +1093,10 @@ const _testHooks = createTestHooks({
1709
1093
  _lockedName = v;
1710
1094
  },
1711
1095
  set sessionStartedAt(v) {
1712
- _sessionStartedAt = v;
1096
+ _rootState().sessionStartedAt = v;
1713
1097
  },
1714
1098
  set currentModel(v) {
1715
- _currentModel = v;
1099
+ _rootState().currentModel = v ?? null;
1716
1100
  },
1717
1101
  set pi(v) {
1718
1102
  _pi = v;
@@ -1805,7 +1189,7 @@ const extension = (pi) => {
1805
1189
  });
1806
1190
  _subagentRooms?.dispose();
1807
1191
  _subagentRooms = initSubagentRooms(pi, {
1808
- getParentRoomId: () => _myRoomId,
1192
+ getParentRoomId: () => _rootState().myRoomId,
1809
1193
  getParentSessionId: () => _rootSessionId,
1810
1194
  broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
1811
1195
  });
@@ -1888,19 +1272,19 @@ const extension = (pi) => {
1888
1272
  const level = event?.level;
1889
1273
  if (!level)
1890
1274
  return;
1891
- // Cache per-sid; only the ROOT projects to _currentThinking + room_meta.
1275
+ // Cache per-sid; only the ROOT projects to room_meta.
1892
1276
  const sid = _sidOf(ctx);
1893
- _stateFor(sid).thinking = level;
1277
+ const st = _stateFor(sid);
1278
+ st.thinking = level;
1894
1279
  if (_isNonRootSid(sid))
1895
1280
  return;
1896
- _currentThinking = level;
1897
- if (_myRoomMeta)
1898
- _myRoomMeta = { ..._myRoomMeta, thinking: level };
1899
- if (!_relay || !_myRoomId)
1281
+ if (st.myRoomMeta)
1282
+ st.myRoomMeta = { ...st.myRoomMeta, thinking: level };
1283
+ if (!_relay || !st.myRoomId)
1900
1284
  return;
1901
1285
  _relay.sendControl({
1902
1286
  type: "room_meta_update",
1903
- room_id: _myRoomId,
1287
+ room_id: st.myRoomId,
1904
1288
  meta: { thinking: level },
1905
1289
  });
1906
1290
  });
@@ -1976,35 +1360,36 @@ const extension = (pi) => {
1976
1360
  }
1977
1361
  if (_isNonRootSid(sid))
1978
1362
  return; // room_meta projection is root-only
1979
- // 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
1980
1364
  // cached model, once.
1981
- if (!_currentModel && st.model)
1365
+ if (!st.currentModel && st.model)
1982
1366
  _setCurrentModel(st.model);
1983
1367
  // Plan/32 Part B: publish working=true as room_meta (raw, no debounce —
1984
1368
  // the debounce lives in the app). Same shape as the model/thinking updates.
1985
- // _myRoomMeta is the ROOM projection (driven only by the root session).
1986
- if (_myRoomMeta)
1987
- _myRoomMeta = { ..._myRoomMeta, working: true };
1988
- 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) {
1989
1373
  _relay.sendControl({
1990
1374
  type: "room_meta_update",
1991
- room_id: _myRoomId,
1375
+ room_id: st.myRoomId,
1992
1376
  meta: { working: true },
1993
1377
  });
1994
1378
  }
1995
1379
  });
1996
1380
  pi.on("turn_end", (_event, ctx) => {
1997
1381
  const sid = _sidOf(ctx);
1998
- _stateFor(sid).working = false;
1382
+ const st = _stateFor(sid);
1383
+ st.working = false;
1999
1384
  if (_isNonRootSid(sid))
2000
1385
  return; // room_meta is root-only
2001
1386
  // Plan/32 Part B: publish working=false as room_meta (raw, no debounce).
2002
- if (_myRoomMeta)
2003
- _myRoomMeta = { ..._myRoomMeta, working: false };
2004
- if (_relay && _myRoomId) {
1387
+ if (st.myRoomMeta)
1388
+ st.myRoomMeta = { ...st.myRoomMeta, working: false };
1389
+ if (_relay && st.myRoomId) {
2005
1390
  _relay.sendControl({
2006
1391
  type: "room_meta_update",
2007
- room_id: _myRoomId,
1392
+ room_id: st.myRoomId,
2008
1393
  meta: { working: false },
2009
1394
  });
2010
1395
  }
@@ -2049,8 +1434,9 @@ const extension = (pi) => {
2049
1434
  if (_isNonRootSid(_sidOf(ctx)))
2050
1435
  return;
2051
1436
  _sessionName = name;
2052
- if (_myRoomMeta)
2053
- _myRoomMeta = { ..._myRoomMeta, name };
1437
+ const st = _rootState();
1438
+ if (st.myRoomMeta)
1439
+ st.myRoomMeta = { ...st.myRoomMeta, name };
2054
1440
  _broadcastEnvelope(relayDeps, {
2055
1441
  evt: { channel: "session_info", data: { name } },
2056
1442
  });
@@ -2059,10 +1445,10 @@ const extension = (pi) => {
2059
1445
  // reaches attached peers, so pre-attach the Home tile went stale until open
2060
1446
  // or reconnect. Relay >=0.6.0 merges + fans `name`; older relays drop the
2061
1447
  // unknown field (harmless — attached peers still get the evt). Design 01M1SPN7.
2062
- if (_relay && _myRoomId) {
1448
+ if (_relay && st.myRoomId) {
2063
1449
  _relay.sendControl({
2064
1450
  type: "room_meta_update",
2065
- room_id: _myRoomId,
1451
+ room_id: st.myRoomId,
2066
1452
  meta: { name },
2067
1453
  });
2068
1454
  }
@@ -2096,12 +1482,11 @@ const extension = (pi) => {
2096
1482
  // session_start with their own ctx). Each records its OWN sessionManager —
2097
1483
  // no cross-session clobber (was the unguarded `_sessionManager = ...` bug).
2098
1484
  const sid = _sidOf(ctx);
2099
- // The module BASE ctx (compact/notify fallback when no fresh ctx is passed)
2100
- // is the ROOT's — a subagent child's ctx must NOT clobber it, same
2101
- // no-cross-session-clobber rule as the per-sid sessionManager. Otherwise a
2102
- // subagent steals the base ctx and root-scoped notifies silently drop.
2103
- if (!_isNonRootSid(sid))
2104
- _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;
2105
1490
  // EVENT-CTX TARGETED PROBE (run 2026-09-18, round 3): the ctx objects
2106
1491
  // are guarded (throwing getters — proxies), so full reflection
2107
1492
  // under-reports or throws. Per-property access, each caught, is the
@@ -2137,7 +1522,7 @@ const extension = (pi) => {
2137
1522
  });
2138
1523
  if (!_subagentRooms)
2139
1524
  _subagentRooms = initSubagentRooms(pi, {
2140
- getParentRoomId: () => _myRoomId,
1525
+ getParentRoomId: () => _rootState().myRoomId,
2141
1526
  getParentSessionId: () => _rootSessionId,
2142
1527
  broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
2143
1528
  });
@@ -2301,8 +1686,8 @@ const extension = (pi) => {
2301
1686
  }
2302
1687
  // Drop the captured event ctx immediately. On module-reuse hosts the same
2303
1688
  // instance survives session replacement; the next session_start re-binds
2304
- // `_lastEventCtx` for the new session.
2305
- _lastEventCtx = null;
1689
+ // the root record's baseCtx for the new session.
1690
+ _rootState().baseCtx = null;
2306
1691
  // No bye reason: the process keeps running and the fresh instance re-joins
2307
1692
  // the SAME relay room, so an explicit offline→online flap would be wrong.
2308
1693
  // Revoke producer/Relay/bridge authority while the global node is still
@@ -2469,7 +1854,7 @@ function _deliverMeshMessageToAgent(env) {
2469
1854
  * handles incoming app→pi requests.
2470
1855
  */
2471
1856
  function _abortCurrentTurn(fallbackCtx) {
2472
- const candidates = [_lastEventCtx, fallbackCtx];
1857
+ const candidates = [_rootState().baseCtx, fallbackCtx];
2473
1858
  for (const candidate of candidates) {
2474
1859
  if (!candidate || candidate === _noopCtx)
2475
1860
  continue;