@geohar/un-bien 0.20.16 → 0.20.18

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,14 +342,41 @@ 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.
361
348
  let _meshDrainScheduled = false;
349
+ /** A freshly-zeroed session record (root or subagent child). */
350
+ function _freshSessionState() {
351
+ return {
352
+ turnId: null,
353
+ working: false,
354
+ agentRun: { active: false, generation: 0 },
355
+ sessionManager: null,
356
+ model: null,
357
+ thinking: null,
358
+ currentModel: null,
359
+ myRoomId: null,
360
+ myRoomMeta: null,
361
+ sessionStartedAt: null,
362
+ baseCtx: null,
363
+ };
364
+ }
365
+ // The ROOT session's state — a SINGLE stable record for the process lifetime,
366
+ // NOT an entry keyed by the (rotating) session id. The relay-room projection
367
+ // fields (myRoomId/myRoomMeta/sessionStartedAt) outlive a session replacement
368
+ // by contract: _goIdle preserves them across stop/start, and session_shutdown
369
+ // does not clear them — the relay room survives a New/Fork/Reload within the
370
+ // same process. Keying them on _rootSessionId (as the prior refactor did) reset
371
+ // them to null on every sid rotation, opening a window between session_start
372
+ // and the async _cmdStart where a reconnect/sync announced a NAMELESS room —
373
+ // the relay filled the absent room_meta.name with its default placeholder.
374
+ // session_start now POPULATES the per-session fields (sessionManager/baseCtx/
375
+ // model/thinking/…) in place; session_shutdown clears exactly those. The record
376
+ // itself is never recreated, so the projection rides through a replacement.
377
+ let _rootRecord = _freshSessionState();
378
+ // Subagent (non-root) child records, keyed by their own session id. The root
379
+ // is never an entry here — root access routes through _rootRecord.
362
380
  const _sessions = new Map();
363
381
  // The session bound to the app room. null until the ROOT session_start fires;
364
382
  // while null, everything is treated as root (single-session / test harness).
@@ -368,23 +386,24 @@ function _rootKey() {
368
386
  return _rootSessionId ?? "__root__";
369
387
  }
370
388
  function _stateFor(sid) {
389
+ if (sid === _rootKey())
390
+ return _rootRecord; // root → stable singleton
371
391
  let st = _sessions.get(sid);
372
392
  if (!st) {
373
- st = {
374
- turnId: null,
375
- working: false,
376
- agentRun: { active: false, generation: 0 },
377
- sessionManager: null,
378
- model: null,
379
- thinking: null,
380
- };
393
+ st = _freshSessionState();
381
394
  _sessions.set(sid, st);
382
395
  }
383
396
  return st;
384
397
  }
385
- /** The root session's record (always defined; lazily created). */
398
+ /** The root session's record (stable for the process lifetime). */
386
399
  function _rootState() {
387
- return _stateFor(_rootKey());
400
+ return _rootRecord;
401
+ }
402
+ /** Test-only: replace the root record with a fresh slate (projection fields
403
+ * included) so tests don't leak state across boundaries. Production never
404
+ * calls this — session_shutdown clears per-session fields in place instead. */
405
+ function _resetRootRecordForTest() {
406
+ _rootRecord = _freshSessionState();
388
407
  }
389
408
  /** sessionId of the firing handler's ctx, defaulting to the root key. */
390
409
  function _sidOf(ctx) {
@@ -460,19 +479,6 @@ export function _getState() {
460
479
  // underlying BUS events and lets the REAL bridges produce the frames (faithful);
461
480
  // the simple ExtensionUIPromptView methods (select/confirm/input/editor) + media
462
481
  // 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
482
  // Owner-notify bridge (transient toasts on the app): /unbien command handlers
477
483
  // report through notifyOwners() - this broadcasts the notice to every attached
478
484
  // owner as extension_ui_request {method:"notify"}, the same shape the ask-flow
@@ -488,628 +494,34 @@ setOwnerNotifyHook((message, level) => {
488
494
  },
489
495
  });
490
496
  });
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
497
  /**
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.
498
+ * Plane routers live in ./routing.ts (carved, slice 4). These thin wrappers
499
+ * preserve the call sites + the CommandDeps surface; the deps accessors
500
+ * keep module state read LIVE at each dispatch.
884
501
  */
885
502
  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
- });
503
+ routeRpcCommand(planeDeps, sender, env);
913
504
  }
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
505
  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
- }
506
+ routeUnBienPlane(planeDeps, sender, env);
1112
507
  }
508
+ // ── Plane-router deps (accessors over module state — live at dispatch) ──────
509
+ const planeDeps = {
510
+ extensionUiBridge: () => _extensionUiBridge,
511
+ panelBridge: () => _panelBridge,
512
+ subagentRooms: () => _subagentRooms,
513
+ sessionStartedAt: () => _rootState().sessionStartedAt ?? 0,
514
+ myRoomMeta: () => _rootState().myRoomMeta,
515
+ rootState: () => _rootState(),
516
+ safeNotify: _safeNotify,
517
+ liveCtx: () => _liveCtx(),
518
+ pi: () => _pi,
519
+ // Getter: rpcDeps is declared later in the file; the dispatch reads it
520
+ // live at call time.
521
+ get rpcDeps() {
522
+ return rpcDeps;
523
+ },
524
+ };
1113
525
  // ── Display-name helpers ──────────────────────────────────────────────────────
1114
526
  /**
1115
527
  * Resolves the name this Pi shows to the mobile app and the relay's
@@ -1246,7 +658,6 @@ function _probeCtx(label, ctx) {
1246
658
  // base-ctx methods (no newSession — that's command-ctx only); command-only ops
1247
659
  // (fork/new/branch) reach a command ctx by self-dispatching a slash command
1248
660
  // instead of retaining one.
1249
- let _lastEventCtx = null;
1250
661
  const _noopCtx = { ui: { notify: () => undefined }, abort: () => undefined };
1251
662
  // A single Pi process can load this extension TWICE in the SAME session:
1252
663
  // when it is launched as `pi -e <dist>/index.js` AND un-bien is ALSO installed
@@ -1316,7 +727,7 @@ const imageDeps = {
1316
727
  },
1317
728
  rootState: _rootState,
1318
729
  get myRoomMeta() {
1319
- return _myRoomMeta;
730
+ return _rootState().myRoomMeta;
1320
731
  },
1321
732
  wakeAgent: _wakeAgent,
1322
733
  };
@@ -1332,7 +743,7 @@ const rpcDeps = {
1332
743
  },
1333
744
  rootState: _rootState,
1334
745
  get lastEventCtx() {
1335
- return _lastEventCtx;
746
+ return _rootState().baseCtx;
1336
747
  },
1337
748
  imageDeps,
1338
749
  wakeAgent: _wakeAgent,
@@ -1414,13 +825,13 @@ const relayDeps = {
1414
825
  return _cachedEd25519;
1415
826
  },
1416
827
  get myRoomId() {
1417
- return _myRoomId;
828
+ return _rootState().myRoomId;
1418
829
  },
1419
830
  get myRoomMeta() {
1420
- return _myRoomMeta;
831
+ return _rootState().myRoomMeta;
1421
832
  },
1422
833
  get sessionStartedAt() {
1423
- return _sessionStartedAt;
834
+ return _rootState().sessionStartedAt;
1424
835
  },
1425
836
  sessionCwd: () => {
1426
837
  const sm = _rootState().sessionManager;
@@ -1510,30 +921,30 @@ const deps = {
1510
921
  _cachedEd25519 = v;
1511
922
  },
1512
923
  get currentModel() {
1513
- return _currentModel;
924
+ return _rootState().currentModel ?? undefined;
1514
925
  },
1515
926
  set currentModel(v) {
1516
- _currentModel = v;
927
+ _rootState().currentModel = v ?? null;
1517
928
  },
1518
929
  get currentThinking() {
1519
- return _currentThinking;
930
+ return _rootState().thinking ?? undefined;
1520
931
  },
1521
932
  abortCurrentTurn: () => _abortCurrentTurn(),
1522
933
  cancelPendingAsks: () => _extensionUiBridge?.cancelAllPending() ?? 0,
1523
934
  set currentThinking(v) {
1524
- _currentThinking = v;
935
+ _rootState().thinking = v ?? null;
1525
936
  },
1526
937
  get myRoomId() {
1527
- return _myRoomId;
938
+ return _rootState().myRoomId;
1528
939
  },
1529
940
  set myRoomId(v) {
1530
- _myRoomId = v;
941
+ _rootState().myRoomId = v;
1531
942
  },
1532
943
  get myRoomMeta() {
1533
- return _myRoomMeta;
944
+ return _rootState().myRoomMeta;
1534
945
  },
1535
946
  set myRoomMeta(v) {
1536
- _myRoomMeta = v;
947
+ _rootState().myRoomMeta = v;
1537
948
  },
1538
949
  get peerShort() {
1539
950
  return _peerShort;
@@ -1542,10 +953,10 @@ const deps = {
1542
953
  _peerShort = v;
1543
954
  },
1544
955
  get sessionStartedAt() {
1545
- return _sessionStartedAt;
956
+ return _rootState().sessionStartedAt;
1546
957
  },
1547
958
  set sessionStartedAt(v) {
1548
- _sessionStartedAt = v;
959
+ _rootState().sessionStartedAt = v;
1549
960
  },
1550
961
  get cwdLock() {
1551
962
  return _cwdLock;
@@ -1620,7 +1031,7 @@ const deps = {
1620
1031
  return _disposed;
1621
1032
  },
1622
1033
  get lastEventCtx() {
1623
- return _lastEventCtx;
1034
+ return _rootState().baseCtx;
1624
1035
  },
1625
1036
  get pi() {
1626
1037
  return _pi;
@@ -1648,7 +1059,7 @@ const deps = {
1648
1059
  handleControl: _handleControl,
1649
1060
  relayStatus: () => _relayStatus(relayDeps),
1650
1061
  getState: _getState,
1651
- runTestScenario: _runTestScenario,
1062
+ runTestScenario: (scenario) => runTestScenario({ broadcast: (env) => _broadcastEnvelope(relayDeps, env), emitBus: makeTestBusEmitter(() => _pi) }, scenario),
1652
1063
  safeNotify: _safeNotify,
1653
1064
  renameAgent: _renameAgent,
1654
1065
  };
@@ -1709,10 +1120,10 @@ const _testHooks = createTestHooks({
1709
1120
  _lockedName = v;
1710
1121
  },
1711
1122
  set sessionStartedAt(v) {
1712
- _sessionStartedAt = v;
1123
+ _rootState().sessionStartedAt = v;
1713
1124
  },
1714
1125
  set currentModel(v) {
1715
- _currentModel = v;
1126
+ _rootState().currentModel = v ?? null;
1716
1127
  },
1717
1128
  set pi(v) {
1718
1129
  _pi = v;
@@ -1736,6 +1147,7 @@ const _testHooks = createTestHooks({
1736
1147
  },
1737
1148
  activePeers: _activePeers,
1738
1149
  rootState: _rootState,
1150
+ resetRootRecord: _resetRootRecordForTest,
1739
1151
  seedRootSession(sid) {
1740
1152
  _rootSessionId = sid;
1741
1153
  _stateFor(sid).sessionManager = {
@@ -1762,6 +1174,7 @@ export const _getCachedPublicKeyForTest = _testHooks.getCachedPublicKeyForTest;
1762
1174
  export const _setSessionStartedAtForTest = _testHooks.setSessionStartedAtForTest;
1763
1175
  export const _setCurrentModelForTest = _testHooks.setCurrentModelForTest;
1764
1176
  export const _getCurrentTurnIdForTest = _testHooks.getCurrentTurnIdForTest;
1177
+ export const _getRootProjectionForTest = _testHooks.getRootProjectionForTest;
1765
1178
  export const _setPiForTest = _testHooks.setPiForTest;
1766
1179
  export const _hasPendingReconnect = _testHooks.hasPendingReconnect;
1767
1180
  export const _getActivePeerCountForTest = _testHooks.getActivePeerCountForTest;
@@ -1805,7 +1218,7 @@ const extension = (pi) => {
1805
1218
  });
1806
1219
  _subagentRooms?.dispose();
1807
1220
  _subagentRooms = initSubagentRooms(pi, {
1808
- getParentRoomId: () => _myRoomId,
1221
+ getParentRoomId: () => _rootState().myRoomId,
1809
1222
  getParentSessionId: () => _rootSessionId,
1810
1223
  broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
1811
1224
  });
@@ -1888,19 +1301,19 @@ const extension = (pi) => {
1888
1301
  const level = event?.level;
1889
1302
  if (!level)
1890
1303
  return;
1891
- // Cache per-sid; only the ROOT projects to _currentThinking + room_meta.
1304
+ // Cache per-sid; only the ROOT projects to room_meta.
1892
1305
  const sid = _sidOf(ctx);
1893
- _stateFor(sid).thinking = level;
1306
+ const st = _stateFor(sid);
1307
+ st.thinking = level;
1894
1308
  if (_isNonRootSid(sid))
1895
1309
  return;
1896
- _currentThinking = level;
1897
- if (_myRoomMeta)
1898
- _myRoomMeta = { ..._myRoomMeta, thinking: level };
1899
- if (!_relay || !_myRoomId)
1310
+ if (st.myRoomMeta)
1311
+ st.myRoomMeta = { ...st.myRoomMeta, thinking: level };
1312
+ if (!_relay || !st.myRoomId)
1900
1313
  return;
1901
1314
  _relay.sendControl({
1902
1315
  type: "room_meta_update",
1903
- room_id: _myRoomId,
1316
+ room_id: st.myRoomId,
1904
1317
  meta: { thinking: level },
1905
1318
  });
1906
1319
  });
@@ -1976,35 +1389,36 @@ const extension = (pi) => {
1976
1389
  }
1977
1390
  if (_isNonRootSid(sid))
1978
1391
  return; // room_meta projection is root-only
1979
- // Root projection: seed the global model + room_meta hello from the root's
1392
+ // Root projection: seed the room_meta model + hello from the root's
1980
1393
  // cached model, once.
1981
- if (!_currentModel && st.model)
1394
+ if (!st.currentModel && st.model)
1982
1395
  _setCurrentModel(st.model);
1983
1396
  // Plan/32 Part B: publish working=true as room_meta (raw, no debounce —
1984
1397
  // 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) {
1398
+ // myRoomMeta is the ROOM projection (driven only by the root session).
1399
+ if (st.myRoomMeta)
1400
+ st.myRoomMeta = { ...st.myRoomMeta, working: true };
1401
+ if (_relay && st.myRoomId) {
1989
1402
  _relay.sendControl({
1990
1403
  type: "room_meta_update",
1991
- room_id: _myRoomId,
1404
+ room_id: st.myRoomId,
1992
1405
  meta: { working: true },
1993
1406
  });
1994
1407
  }
1995
1408
  });
1996
1409
  pi.on("turn_end", (_event, ctx) => {
1997
1410
  const sid = _sidOf(ctx);
1998
- _stateFor(sid).working = false;
1411
+ const st = _stateFor(sid);
1412
+ st.working = false;
1999
1413
  if (_isNonRootSid(sid))
2000
1414
  return; // room_meta is root-only
2001
1415
  // 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) {
1416
+ if (st.myRoomMeta)
1417
+ st.myRoomMeta = { ...st.myRoomMeta, working: false };
1418
+ if (_relay && st.myRoomId) {
2005
1419
  _relay.sendControl({
2006
1420
  type: "room_meta_update",
2007
- room_id: _myRoomId,
1421
+ room_id: st.myRoomId,
2008
1422
  meta: { working: false },
2009
1423
  });
2010
1424
  }
@@ -2049,8 +1463,9 @@ const extension = (pi) => {
2049
1463
  if (_isNonRootSid(_sidOf(ctx)))
2050
1464
  return;
2051
1465
  _sessionName = name;
2052
- if (_myRoomMeta)
2053
- _myRoomMeta = { ..._myRoomMeta, name };
1466
+ const st = _rootState();
1467
+ if (st.myRoomMeta)
1468
+ st.myRoomMeta = { ...st.myRoomMeta, name };
2054
1469
  _broadcastEnvelope(relayDeps, {
2055
1470
  evt: { channel: "session_info", data: { name } },
2056
1471
  });
@@ -2059,10 +1474,10 @@ const extension = (pi) => {
2059
1474
  // reaches attached peers, so pre-attach the Home tile went stale until open
2060
1475
  // or reconnect. Relay >=0.6.0 merges + fans `name`; older relays drop the
2061
1476
  // unknown field (harmless — attached peers still get the evt). Design 01M1SPN7.
2062
- if (_relay && _myRoomId) {
1477
+ if (_relay && st.myRoomId) {
2063
1478
  _relay.sendControl({
2064
1479
  type: "room_meta_update",
2065
- room_id: _myRoomId,
1480
+ room_id: st.myRoomId,
2066
1481
  meta: { name },
2067
1482
  });
2068
1483
  }
@@ -2096,12 +1511,22 @@ const extension = (pi) => {
2096
1511
  // session_start with their own ctx). Each records its OWN sessionManager —
2097
1512
  // no cross-session clobber (was the unguarded `_sessionManager = ...` bug).
2098
1513
  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;
1514
+ // Claim root + adopt the root sid BEFORE any _stateFor(sid) write. The root
1515
+ // record is the stable _rootRecord, but _stateFor(sid) only routes there
1516
+ // once sid === _rootKey() — i.e. once _rootSessionId === sid. Writing
1517
+ // baseCtx/sessionManager before the adoption lands them in an orphaned
1518
+ // per-sid map entry (the deferred re-arm then reads _rootState and sees
1519
+ // null). For a subagent child _claimRootSession returns false (root owns
1520
+ // the slot), so _rootSessionId is untouched and _stateFor(sid) correctly
1521
+ // gives the child its own map entry.
1522
+ const isRootClaim = _claimRootSession(pi);
1523
+ if (isRootClaim && ctx?.sessionManager)
1524
+ _rootSessionId = sid;
1525
+ // Base ctx (compact/notify fallback when no fresh ctx is passed) is
1526
+ // PER-SESSION (plan 01M18RNH) — each session_start records its OWN; the
1527
+ // base-ctx resolution reads the ROOT's, so a child's ctx never leaks
1528
+ // (structurally replaces the old `!_isNonRootSid` guard + global).
1529
+ _stateFor(sid).baseCtx = ctx ?? null;
2105
1530
  // EVENT-CTX TARGETED PROBE (run 2026-09-18, round 3): the ctx objects
2106
1531
  // are guarded (throwing getters — proxies), so full reflection
2107
1532
  // under-reports or throws. Per-property access, each caught, is the
@@ -2119,7 +1544,7 @@ const extension = (pi) => {
2119
1544
  // The root claim also fixes _rootSessionId (re-captured across replacement);
2120
1545
  // only when a real sessionManager is present (ctx-less test events stay in
2121
1546
  // null-root mode where every event is treated as root).
2122
- if (_claimRootSession(pi)) {
1547
+ if (isRootClaim) {
2123
1548
  if (ctx?.sessionManager)
2124
1549
  _rootSessionId = sid;
2125
1550
  if (!_extensionUiBridge)
@@ -2137,7 +1562,7 @@ const extension = (pi) => {
2137
1562
  });
2138
1563
  if (!_subagentRooms)
2139
1564
  _subagentRooms = initSubagentRooms(pi, {
2140
- getParentRoomId: () => _myRoomId,
1565
+ getParentRoomId: () => _rootState().myRoomId,
2141
1566
  getParentSessionId: () => _rootSessionId,
2142
1567
  broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
2143
1568
  });
@@ -2299,10 +1724,19 @@ const extension = (pi) => {
2299
1724
  _subagentRooms = null;
2300
1725
  _releaseRootSession(pi);
2301
1726
  }
2302
- // Drop the captured event ctx immediately. On module-reuse hosts the same
2303
- // instance survives session replacement; the next session_start re-binds
2304
- // `_lastEventCtx` for the new session.
2305
- _lastEventCtx = null;
1727
+ // Drop the captured base ctx on the stable root record. On module-reuse
1728
+ // hosts the same instance survives session replacement; the next
1729
+ // session_start re-binds it for the new session. We clear ONLY baseCtx here
1730
+ // (mirroring the pre-01M18RNH global semantics): the relay-room PROJECTION
1731
+ // (myRoomId/myRoomMeta/sessionStartedAt) must survive the replacement by
1732
+ // contract (_goIdle keeps it too; _cmdStart overwrites it on the new
1733
+ // connect), and the other per-session fields (model/thinking/currentModel/
1734
+ // sessionManager/…) are either re-populated by the new session_start + its
1735
+ // events or intentionally fall back to the prior value when the replacement
1736
+ // ctx carries no sessionManager (the ctx-less same-module path). Clearing
1737
+ // sessionManager here, for instance, defers `_cmdStart` (no roomId) and
1738
+ // breaks that path — see the same-module replacement suite.
1739
+ _rootState().baseCtx = null;
2306
1740
  // No bye reason: the process keeps running and the fresh instance re-joins
2307
1741
  // the SAME relay room, so an explicit offline→online flap would be wrong.
2308
1742
  // Revoke producer/Relay/bridge authority while the global node is still
@@ -2469,7 +1903,7 @@ function _deliverMeshMessageToAgent(env) {
2469
1903
  * handles incoming app→pi requests.
2470
1904
  */
2471
1905
  function _abortCurrentTurn(fallbackCtx) {
2472
- const candidates = [_lastEventCtx, fallbackCtx];
1906
+ const candidates = [_rootState().baseCtx, fallbackCtx];
2473
1907
  for (const candidate of candidates) {
2474
1908
  if (!candidate || candidate === _noopCtx)
2475
1909
  continue;