@geohar/un-bien 0.8.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +258 -320
  2. package/dist/actions/handlers.js.map +1 -1
  3. package/dist/actions/registry.js.map +1 -1
  4. package/dist/bin/launcher.js +38 -0
  5. package/dist/bin/launcher.js.map +1 -0
  6. package/dist/commands/deps.d.ts +156 -0
  7. package/dist/commands/deps.js +2 -0
  8. package/dist/commands/deps.js.map +1 -0
  9. package/dist/commands/fork_link.d.ts +4 -0
  10. package/dist/commands/fork_link.js +45 -0
  11. package/dist/commands/fork_link.js.map +1 -0
  12. package/dist/commands/housekeeping.d.ts +34 -0
  13. package/dist/commands/housekeeping.js +275 -0
  14. package/dist/commands/housekeeping.js.map +1 -0
  15. package/dist/commands/info.d.ts +43 -0
  16. package/dist/commands/info.js +127 -0
  17. package/dist/commands/info.js.map +1 -0
  18. package/dist/commands/lifecycle.d.ts +45 -0
  19. package/dist/commands/lifecycle.js +708 -0
  20. package/dist/commands/lifecycle.js.map +1 -0
  21. package/dist/commands/pairing.d.ts +26 -0
  22. package/dist/commands/pairing.js +167 -0
  23. package/dist/commands/pairing.js.map +1 -0
  24. package/dist/commands/register.d.ts +12 -0
  25. package/dist/commands/register.js +255 -0
  26. package/dist/commands/register.js.map +1 -0
  27. package/dist/commands/relay.d.ts +28 -0
  28. package/dist/commands/relay.js +81 -0
  29. package/dist/commands/relay.js.map +1 -0
  30. package/dist/commands/session_ops.d.ts +52 -0
  31. package/dist/commands/session_ops.js +75 -0
  32. package/dist/commands/session_ops.js.map +1 -0
  33. package/dist/config.d.ts +30 -2
  34. package/dist/config.js.map +1 -1
  35. package/dist/daemon/install.d.ts +36 -37
  36. package/dist/daemon/install.js +49 -73
  37. package/dist/daemon/install.js.map +1 -1
  38. package/dist/enrich_tool_args.d.ts +9 -0
  39. package/dist/enrich_tool_args.js +171 -0
  40. package/dist/enrich_tool_args.js.map +1 -0
  41. package/dist/extension_ui_bridge.js.map +1 -1
  42. package/dist/image_codec.d.ts +22 -0
  43. package/dist/image_codec.js +149 -0
  44. package/dist/image_codec.js.map +1 -0
  45. package/dist/index.d.ts +32 -127
  46. package/dist/index.js +957 -4042
  47. package/dist/index.js.map +1 -1
  48. package/dist/launch.d.ts +42 -0
  49. package/dist/launch.js +206 -0
  50. package/dist/launch.js.map +1 -0
  51. package/dist/launcher/launcher.d.ts +6 -0
  52. package/dist/launcher/launcher.js +207 -0
  53. package/dist/launcher/launcher.js.map +1 -0
  54. package/dist/mcp/mesh_result.js.map +1 -1
  55. package/dist/mcp/mesh_server.js.map +1 -1
  56. package/dist/mesh/canonical.js +1 -1
  57. package/dist/mesh/canonical.js.map +1 -1
  58. package/dist/mesh/client.js.map +1 -1
  59. package/dist/mesh/encoding.js.map +1 -1
  60. package/dist/mesh/self_revoke.js.map +1 -1
  61. package/dist/mesh/siblings.js.map +1 -1
  62. package/dist/mesh/verify.js +1 -1
  63. package/dist/mesh/verify.js.map +1 -1
  64. package/dist/pairing/crypto.js +0 -1
  65. package/dist/pairing/crypto.js.map +1 -1
  66. package/dist/pairing/peer_trust.d.ts +20 -0
  67. package/dist/pairing/peer_trust.js +82 -0
  68. package/dist/pairing/peer_trust.js.map +1 -0
  69. package/dist/pairing/qr.js.map +1 -1
  70. package/dist/pairing/storage.js +4 -3
  71. package/dist/pairing/storage.js.map +1 -1
  72. package/dist/panel_bridge.d.ts +3 -1
  73. package/dist/panel_bridge.js +11 -10
  74. package/dist/panel_bridge.js.map +1 -1
  75. package/dist/paths.d.ts +22 -8
  76. package/dist/paths.js +37 -10
  77. package/dist/paths.js.map +1 -1
  78. package/dist/protocol/codec.js.map +1 -1
  79. package/dist/rooms.d.ts +16 -0
  80. package/dist/rooms.js +23 -4
  81. package/dist/rooms.js.map +1 -1
  82. package/dist/session/bridge.js.map +1 -1
  83. package/dist/session/broker.js.map +1 -1
  84. package/dist/session/broker_remote.js.map +1 -1
  85. package/dist/session/capabilities.d.ts +11 -0
  86. package/dist/session/capabilities.js +35 -0
  87. package/dist/session/capabilities.js.map +1 -0
  88. package/dist/session/cwd_lock.js +3 -3
  89. package/dist/session/cwd_lock.js.map +1 -1
  90. package/dist/session/debug_log.js +15 -5
  91. package/dist/session/debug_log.js.map +1 -1
  92. package/dist/session/envelope.js.map +1 -1
  93. package/dist/session/global_config.d.ts +1 -1
  94. package/dist/session/global_config.js +1 -1
  95. package/dist/session/global_config.js.map +1 -1
  96. package/dist/session/ipc.d.ts +2 -2
  97. package/dist/session/ipc.js.map +1 -1
  98. package/dist/session/leader_election.js.map +1 -1
  99. package/dist/session/local_config.d.ts +37 -0
  100. package/dist/session/local_config.js +47 -1
  101. package/dist/session/local_config.js.map +1 -1
  102. package/dist/session/mesh_node.d.ts +1 -1
  103. package/dist/session/mesh_node.js +1 -1
  104. package/dist/session/mesh_node.js.map +1 -1
  105. package/dist/session/peer.js.map +1 -1
  106. package/dist/session/peer_inventory.js.map +1 -1
  107. package/dist/session/peer_limits.js.map +1 -1
  108. package/dist/session/received_images.d.ts +63 -0
  109. package/dist/session/received_images.js +273 -0
  110. package/dist/session/received_images.js.map +1 -0
  111. package/dist/session/relay_lifecycle.d.ts +219 -0
  112. package/dist/session/relay_lifecycle.js +764 -0
  113. package/dist/session/relay_lifecycle.js.map +1 -0
  114. package/dist/session/rpc_envelope.d.ts +28 -0
  115. package/dist/session/rpc_envelope.js +10 -1
  116. package/dist/session/rpc_envelope.js.map +1 -1
  117. package/dist/session/rpc_handlers.d.ts +53 -0
  118. package/dist/session/rpc_handlers.js +268 -0
  119. package/dist/session/rpc_handlers.js.map +1 -0
  120. package/dist/session/rpc_inbound.d.ts +42 -7
  121. package/dist/session/rpc_inbound.js +56 -1
  122. package/dist/session/rpc_inbound.js.map +1 -1
  123. package/dist/session/setup_wizard.js.map +1 -1
  124. package/dist/session/tools.js.map +1 -1
  125. package/dist/session/wizard.js.map +1 -1
  126. package/dist/state_migration.d.ts +45 -0
  127. package/dist/state_migration.js +104 -0
  128. package/dist/state_migration.js.map +1 -0
  129. package/dist/subagent_rooms.d.ts +68 -0
  130. package/dist/subagent_rooms.js +919 -0
  131. package/dist/subagent_rooms.js.map +1 -0
  132. package/dist/test_hooks.d.ts +180 -0
  133. package/dist/test_hooks.js +98 -0
  134. package/dist/test_hooks.js.map +1 -0
  135. package/dist/transport/peer_channel.d.ts +18 -1
  136. package/dist/transport/peer_channel.js +42 -12
  137. package/dist/transport/peer_channel.js.map +1 -1
  138. package/dist/transport/pi_forward_client.js.map +1 -1
  139. package/dist/transport/relay_client.d.ts +24 -2
  140. package/dist/transport/relay_client.js +6 -3
  141. package/dist/transport/relay_client.js.map +1 -1
  142. package/dist/ui/footer.js.map +1 -1
  143. package/docs/daemon.md +170 -179
  144. package/package.json +13 -11
  145. package/service-templates/launchd.plist.template +8 -7
  146. package/service-templates/systemd.service.template +5 -4
  147. package/service-templates/task-launcher.vbs.template +5 -5
  148. package/service-templates/task-scheduler.xml.template +1 -1
  149. package/dist/bin/supervisord.js +0 -86
  150. package/dist/bin/supervisord.js.map +0 -1
  151. package/dist/daemon/client.d.ts +0 -20
  152. package/dist/daemon/client.js +0 -131
  153. package/dist/daemon/client.js.map +0 -1
  154. package/dist/daemon/control_protocol.d.ts +0 -176
  155. package/dist/daemon/control_protocol.js +0 -63
  156. package/dist/daemon/control_protocol.js.map +0 -1
  157. package/dist/daemon/cron_log.d.ts +0 -45
  158. package/dist/daemon/cron_log.js +0 -71
  159. package/dist/daemon/cron_log.js.map +0 -1
  160. package/dist/daemon/cron_registry.d.ts +0 -80
  161. package/dist/daemon/cron_registry.js +0 -198
  162. package/dist/daemon/cron_registry.js.map +0 -1
  163. package/dist/daemon/id.d.ts +0 -24
  164. package/dist/daemon/id.js +0 -36
  165. package/dist/daemon/id.js.map +0 -1
  166. package/dist/daemon/registry.d.ts +0 -63
  167. package/dist/daemon/registry.js +0 -153
  168. package/dist/daemon/registry.js.map +0 -1
  169. package/dist/daemon/rpc_child.d.ts +0 -194
  170. package/dist/daemon/rpc_child.js +0 -419
  171. package/dist/daemon/rpc_child.js.map +0 -1
  172. package/dist/daemon/supervisor.d.ts +0 -93
  173. package/dist/daemon/supervisor.js +0 -666
  174. package/dist/daemon/supervisor.js.map +0 -1
  175. /package/dist/bin/{supervisord.d.ts → launcher.d.ts} +0 -0
@@ -0,0 +1,919 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { RelayClient } from "./transport/relay_client.js";
3
+ import { PlainPeerChannel } from "./transport/peer_channel.js";
4
+ import { getOrCreateEd25519Keypair } from "./pairing/storage.js";
5
+ import { _findKnownPeer } from "./pairing/peer_trust.js";
6
+ import { roomIdForSession } from "./rooms.js";
7
+ import { loadConfig, resolveRelayUrl } from "./config.js";
8
+ import { dispatchRpcCommand, pageEntries, } from "./session/rpc_inbound.js";
9
+ import { createRpcEnvelope, helloEnvelope, isEnvelopeFrame, } from "./session/rpc_envelope.js";
10
+ import { envLog } from "./session/debug_log.js";
11
+ import { effectiveAllowRemoteTerminate, loadLocalConfig, } from "./session/local_config.js";
12
+ /**
13
+ * Surface each Pi SUBAGENT as its OWN, separate app-facing session — a distinct
14
+ * relay ROOM under the SAME machine identity.
15
+ *
16
+ * A subagent re-activates this extension IN-PROCESS with its own `pi` (its own
17
+ * sessionId). The ROOT never builds a producer for it, so its transcript frames
18
+ * are never produced. This module builds, per child session:
19
+ * - a per-child RelayClient connected to roomIdForSession(childSessionId) with
20
+ * room_meta.parent = the root room id (so the app NESTS it), reusing the
21
+ * machine keypair (pairing is machine-level — the app reaches it with no new
22
+ * pair, exactly like the launcher control room);
23
+ * - a per-child createRpcEnvelope(childPi, …) that produces the child's
24
+ * transcript on THAT room;
25
+ * - owner attach (same _findKnownPeer gate as the launcher) + a READ-ONLY inbound
26
+ * surface (get_entries from the child's own sessionManager; session_sync
27
+ * terminator). No prompt/steer here — view-only (Phase 1).
28
+ *
29
+ * There is NO shared-stream demux: each child is its own room/connection, so the
30
+ * app treats it as a separate session (its own view). Parent linkage is the
31
+ * explicit room_meta.parent field, not correlation.
32
+ *
33
+ * Enablement is opt-in via the un-bien setting `subagents.rooms` (global config,
34
+ * ~/.pi/extensions/un-bien.json). Absent/false ⇒ disabled (a no-op controller).
35
+ *
36
+ * childSessionId <-> subagent record binding is by ARRIVAL ORDER for now (a
37
+ * created event is followed by the child's session_start); the exact key lands
38
+ * when @tintinweb/pi-subagents adds sessionId to subagents:started.
39
+ */
40
+ export function subagentRoomsEnabled() {
41
+ return loadConfig().subagents?.rooms === true;
42
+ }
43
+ /** No-op controller when the feature is off — keeps the index.ts hooks trivial. */
44
+ const NOOP = {
45
+ onChildSession() { },
46
+ dispose() { },
47
+ closeChildRoom() {
48
+ return false;
49
+ },
50
+ };
51
+ /**
52
+ * Child rooms are ALWAYS `roomIdForSession(sessionId)` (design 01M1CAW0 —
53
+ * announce waits for the session id, so a child's self-announce always
54
+ * registers the sid-hash room). A pre-registered/caller-supplied id that
55
+ * DIFFERS — e.g. residue of the retired cwd-derived announce, which was
56
+ * identical for same-cwd processes — is suspect: refuse it in favor of the
57
+ * sid-hash id. Returns the room id to use. Pure + exported for tests.
58
+ */
59
+ export function normalizeChildRoomId(sessionId, candidate) {
60
+ const sidRoomId = roomIdForSession(sessionId);
61
+ if (candidate && candidate !== sidRoomId) {
62
+ envLog(`subagent room id mismatch — refusing "${candidate}" for sid=${sessionId.slice(0, 8)} (design 01M1CAW0)`);
63
+ console.warn(`[un-bien] subagent room id "${candidate}" differs from the ` +
64
+ `session-derived id — using the sid-hash room instead ` +
65
+ `(design 01M1CAW0)`);
66
+ return sidRoomId;
67
+ }
68
+ return candidate ?? sidRoomId;
69
+ }
70
+ /**
71
+ * Root-side init. Subscribes to the subagents:* bus (record metadata) so a
72
+ * child session_start can bind to the record that spawned it. `getParentRoomId`
73
+ * returns the ROOT room id (roomIdForSession(rootSid)) captured extension-side.
74
+ */
75
+ export function initSubagentRooms(rootPi, opts) {
76
+ if (!subagentRoomsEnabled())
77
+ return NOOP;
78
+ const resolution = resolveRelayUrl();
79
+ if (!resolution.url)
80
+ return NOOP; // no relay → nothing to surface to
81
+ const relayUrl = resolution.url;
82
+ // Correlation queues for record-id <-> child-session binding (arrival order;
83
+ // no event carries both ids — see module header). PARTITIONED BINDING:
84
+ // sessions (marker / session_start) only QUEUE — they never pop records;
85
+ // `created` (background-only in gotgenes) does NOT bind — its id returns at
86
+ // `started`, by which time its own session_start has tagged its entry; only
87
+ // `started` / `resumed` pop, and only IN-PROCESS-tagged entries. This keeps a
88
+ // concurrent foreground+background pair from consuming each other's half
89
+ // (bg created stealing a queued fg session; fg session_start stealing a
90
+ // queued bg record), and an out-of-process marker entry (never tagged) from
91
+ // being stolen by an in-process started. Residual: two same-mode concurrent
92
+ // spawns cross-bind only if started order diverges from session_start order
93
+ // — both derive from the manager's admission sequence, so they align.
94
+ const unboundSessions = [];
95
+ const unboundInProcess = new Set();
96
+ // The child's session JSONL path, captured at its session_start — the join
97
+ // key for the authoritative service-match binding below.
98
+ const sessionFileBySession = new Map();
99
+ const fleet = new Map();
100
+ const children = new Map();
101
+ // In-flight keeper builds (async connect) reserved by child sessionId, so a
102
+ // marker's keeper and an in-process session_start can't race into two rooms.
103
+ const building = new Set();
104
+ const pendingAttach = new Map();
105
+ // Parentage queued while a room is still building (race: the child attaches /
106
+ // parent becomes known before the async connect finishes). Applied on build.
107
+ const pendingParent = new Map();
108
+ // IN-PROCESS children (a session_start arrived for this sid): disposal
109
+ // LINGERS — room + panel row stay until parent teardown, the room keeps
110
+ // serving the FINISHED transcript (§5: terminal status is a STAMP, not a
111
+ // removal; gotgenes fires child:disposed on success AND error alike).
112
+ const inProcess = new Set();
113
+ // KEEPER-ONLY children RELEASED by a disposal marker. Doubles as the tombstone
114
+ // for the disposed-while-building race: a startChildRoom still connecting when
115
+ // the marker lands checks this on completion and disposes instead of
116
+ // registering a room nobody will ever attach to.
117
+ const keeperReleased = new Set();
118
+ let disposed = false;
119
+ // Subagents PANEL state, keyed by CHILD sessionId (pi data). The panel is
120
+ // produced HERE from child detection (identity) and enriched by subagents:*
121
+ // events (labels/status). Each item carries the child roomId so the app maps
122
+ // a panel row -> session exactly, with NO record-id round-trip.
123
+ const panelBySession = new Map();
124
+ const recordToSession = new Map();
125
+ // Normalize a raw subagents:* status to un-bien's stable EXTENDED vocabulary
126
+ // (docs/subagent-events.md §1). We forward the FULL vocab to the app rather
127
+ // than collapsing to 3 states, so the app can render the richer states;
128
+ // unknown values pass through (forward-compatible), empty -> "pending".
129
+ function normalizeStatus(s) {
130
+ const v = String(s ?? "").toLowerCase();
131
+ switch (v) {
132
+ case "completed":
133
+ case "done":
134
+ return "completed";
135
+ case "running":
136
+ case "started":
137
+ case "in_progress":
138
+ case "in-progress":
139
+ return "running";
140
+ case "failed":
141
+ case "error":
142
+ case "aborted":
143
+ case "stopped":
144
+ case "steered":
145
+ case "compacted":
146
+ case "queued":
147
+ case "created":
148
+ return v;
149
+ default:
150
+ return v || "pending";
151
+ }
152
+ }
153
+ // Terminal statuses for disposal stamping (the §1 vocabulary's end states): a
154
+ // disposal marker keeps an existing terminal stamp (usually landed via the
155
+ // correlated subagents:completed/failed record event, fired before the run's
156
+ // finally); anything else (running/queued/…) falls back to "stopped".
157
+ const TERMINAL_STATUSES = new Set([
158
+ "completed",
159
+ "failed",
160
+ "error",
161
+ "aborted",
162
+ "stopped",
163
+ ]);
164
+ function emitPanel() {
165
+ if (disposed)
166
+ return;
167
+ // Keyed by the child SESSIONID (pi data), NOT the roomId (relay value). The
168
+ // app maps a panel row -> session by sessionId via its hello-sessionId index.
169
+ const items = [...panelBySession.entries()].map(([sessionId, s]) => ({
170
+ id: `agent:${sessionId}`,
171
+ kind: "agent",
172
+ title: s.description || s.type || sessionId,
173
+ status: normalizeStatus(s.status),
174
+ deps: [],
175
+ meta: { agentType: s.type, startedAt: s.startedAt, sessionId },
176
+ }));
177
+ // SAFETY: this object literal IS a valid panel_update ServerMessage; the
178
+ // ServerMessage union isn't narrowed to that variant at this call site.
179
+ opts.broadcastPanel({
180
+ type: "panel_update",
181
+ key: "subagents",
182
+ title: "Agents",
183
+ icon: "person.2",
184
+ data: { items },
185
+ });
186
+ }
187
+ // Event-asserted child (docs/subagent-events.md §3B/§4): a child whose id is
188
+ // supplied in an EVENT rather than detected via an in-process session_start.
189
+ // CONTRACT (gotgenes' "subagent adapter convention", decision 0012 / CHANGELOG):
190
+ // `subagents:child:session-created` carries `{ sessionId, parentSessionId? }`,
191
+ // emitted synchronously BEFORE bindExtensions(). un-bien's own
192
+ // `unbien:subagent:child` mirrors that shape (+ optional id/type/description/
193
+ // status/cwd). We read those canonical fields; the extra spellings below are
194
+ // defensive only. The child sessionId is REQUIRED. The expectation is the
195
+ // child is un-bien-aware and joins the mesh ITSELF (owns its own transcript);
196
+ // this folds it into the parent's fleet/panel/status + pre-creates the keeper.
197
+ function onChildMarker(raw) {
198
+ if (disposed)
199
+ return;
200
+ const p = (raw ?? {});
201
+ const sessionId = (typeof p.sessionId === "string" && p.sessionId) ||
202
+ (typeof p.childSessionId === "string" && p.childSessionId) ||
203
+ undefined;
204
+ if (!sessionId)
205
+ return; // no child id -> cannot key/track it
206
+ const id = typeof p.id === "string" ? p.id : undefined;
207
+ const type = typeof p.type === "string" ? p.type : undefined;
208
+ const description = typeof p.description === "string" ? p.description : undefined;
209
+ const status = typeof p.status === "string" ? p.status : undefined;
210
+ // Enrich if an in-process room already owns this child (dedup on sessionId),
211
+ // else register it fleet-side WITHOUT building a room (the child owns its own).
212
+ // The row's roomId is ALWAYS the sid-hash id — a pre-registered foreign id
213
+ // (retired cwd-derived residue) is refused (design 01M1CAW0).
214
+ const existing = panelBySession.get(sessionId);
215
+ panelBySession.set(sessionId, {
216
+ roomId: normalizeChildRoomId(sessionId, existing?.roomId),
217
+ type: type ?? existing?.type,
218
+ description: description ?? existing?.description,
219
+ status: status ?? existing?.status ?? "started",
220
+ startedAt: existing?.startedAt ?? Date.now(),
221
+ });
222
+ if (id) {
223
+ bindRecord(id, sessionId);
224
+ const prev = fleet.get(id);
225
+ fleet.set(id, {
226
+ id,
227
+ type: type ?? prev?.type,
228
+ description: description ?? prev?.description,
229
+ status: status ?? prev?.status ?? "started",
230
+ startedAt: prev?.startedAt ?? Date.now(),
231
+ });
232
+ }
233
+ else if (![...recordToSession.values()].includes(sessionId) &&
234
+ !unboundSessions.includes(sessionId)) {
235
+ // No record id on the marker (gotgenes never carries one): queue the
236
+ // session for the started/created record's symmetric pop.
237
+ unboundSessions.push(sessionId);
238
+ }
239
+ emitPanel();
240
+ envLog(`subagent marker: sid=${sessionId.slice(0, 8)} id=${id ?? "-"} queued=${unboundSessions.length}`);
241
+ // Pre-create the passive KEEPER (holds the room open + room_meta.parent) so
242
+ // an event-asserted / out-of-process child NESTS immediately; an in-process
243
+ // launch (if any) later attaches the producer to THIS same room via
244
+ // ensureChildRoom. Keyed by child sessionId; needs the parent room up.
245
+ const parentRoomId = opts.getParentRoomId();
246
+ // design 01M1CAW0: parentage comes from the MARKER only. The old chain
247
+ // fell back to opts.getParentSessionId() — the RECEIVING process's root —
248
+ // so a marker with no parent of its own got THIS process's root stamped
249
+ // as its parent (set-once on the relay, never correctable). A parent-less
250
+ // marker now builds the room WITHOUT parent (the in-process path still
251
+ // sets it authoritatively later via onChildSession) — never a guess.
252
+ const parentSessionId = (typeof p.parentSessionId === "string" && p.parentSessionId) ||
253
+ (typeof p.parentSession === "string" && p.parentSession) ||
254
+ (typeof p.parent === "string" && p.parent) ||
255
+ undefined;
256
+ if (!parentSessionId) {
257
+ envLog(`subagent marker without parent — not guessing (design 01M1CAW0): sid=${sessionId.slice(0, 8)}`);
258
+ console.warn("[un-bien] subagent marker carries no parentSessionId — creating " +
259
+ "the room WITHOUT parentage rather than guessing the local root " +
260
+ "(design 01M1CAW0)");
261
+ }
262
+ if (parentRoomId) {
263
+ const markerRoomId = normalizeChildRoomId(sessionId, existing?.roomId);
264
+ ensureChildRoom(sessionId, {
265
+ relayUrl,
266
+ sessionId,
267
+ roomId: markerRoomId,
268
+ parentRoomId,
269
+ parentSessionId,
270
+ startedAt: existing?.startedAt ?? Date.now(),
271
+ name: description ?? type ?? "subagent",
272
+ subagentId: id,
273
+ makeHandlers,
274
+ getStatus: () => normalizeStatus(panelBySession.get(sessionId)?.status),
275
+ onTerminate: () => terminateChildRoom(sessionId, markerRoomId),
276
+ });
277
+ }
278
+ }
279
+ // Paired disposal (gotgenes `subagents:child:disposed` = `{ sessionId }`, fired
280
+ // in the run's finally on success AND error; un-bien's own
281
+ // `unbien:subagent:disposed` mirrors it; inert for tintinweb). Disposal is a
282
+ // TERMINAL STATUS STAMP on the panel row (kept — §5 "failure is a status
283
+ // stamp, not a removal"), then splits on whether the child ever attached
284
+ // in-process:
285
+ // - IN-PROCESS (session_start seen): LINGER — keep the room serving the
286
+ // finished transcript until parent teardown. It stays interactive-readable
287
+ // from the app; nothing is torn down here.
288
+ // - KEEPER-ONLY (out-of-process child, or one that died before attaching):
289
+ // release the keeper. An out-of-process child's own conn (if still up)
290
+ // keeps the room; a fast-fail had no transcript to lose. The stamped panel
291
+ // row stays either way — the error surface.
292
+ function onChildDisposed(raw) {
293
+ const p = (raw ?? {});
294
+ const sessionId = (typeof p.sessionId === "string" && p.sessionId) ||
295
+ (typeof p.childSessionId === "string" && p.childSessionId) ||
296
+ undefined;
297
+ if (!sessionId)
298
+ return;
299
+ envLog(`subagent disposed: sid=${sessionId.slice(0, 8)} inProcess=${inProcess.has(sessionId)}`);
300
+ // Stamp the row terminal: the correlated subagents:completed/failed record
301
+ // event usually landed first (fired before the run's finally); "stopped" is
302
+ // the honest fallback when nothing terminal did.
303
+ const row = panelBySession.get(sessionId);
304
+ if (row) {
305
+ const cur = normalizeStatus(row.status);
306
+ row.status = TERMINAL_STATUSES.has(cur) ? cur : "stopped";
307
+ }
308
+ if (!inProcess.has(sessionId)) {
309
+ keeperReleased.add(sessionId);
310
+ children.get(sessionId)?.dispose();
311
+ children.delete(sessionId);
312
+ building.delete(sessionId);
313
+ pendingAttach.delete(sessionId);
314
+ pendingParent.delete(sessionId);
315
+ }
316
+ emitPanel();
317
+ }
318
+ // SAFETY: the pi SDK exposes an `events` bus at runtime that isn't part of
319
+ // its public typings; we read it defensively (optional) and guard below.
320
+ const events = rootPi.events;
321
+ const unsub = [];
322
+ if (events) {
323
+ // Adapter for the subagents:* event format (the de-facto standard, also
324
+ // consumed by @geohar/pi-plan). Records supply LABELS (type/description) +
325
+ // STATUS; identity/room come from child detection. created/started seed a
326
+ // pending record for the next child session_start to bind; terminal events
327
+ // update the bound child's status on the panel.
328
+ const onRecord = (status) => (data) => {
329
+ const p = (data ?? {});
330
+ const id = typeof p.id === "string" ? p.id : undefined;
331
+ if (!id)
332
+ return;
333
+ const type = typeof p.type === "string" ? p.type : undefined;
334
+ const description = typeof p.description === "string" ? p.description : undefined;
335
+ const prev = fleet.get(id);
336
+ // `resumed` (gotgenes detached-resume) fires NO started — it jumps
337
+ // straight to a terminal payload whose `status` discriminates. Use it
338
+ // when it's terminal, else stamp the channel verbatim.
339
+ let stamp = status;
340
+ if (status === "resumed" && typeof p.status === "string") {
341
+ const s = normalizeStatus(p.status);
342
+ if (TERMINAL_STATUSES.has(s))
343
+ stamp = s;
344
+ }
345
+ fleet.set(id, {
346
+ id,
347
+ type: type ?? prev?.type,
348
+ description: description ?? prev?.description,
349
+ status: stamp,
350
+ startedAt: prev?.startedAt ?? Date.now(),
351
+ });
352
+ if (!recordToSession.has(id)) {
353
+ // FUTURE-PROOF direct bind — neither implementation emits this TODAY
354
+ // (tintinweb 0.19.0 / gotgenes 21.x carry only the record id), but the
355
+ // child sessionId on record events is the one-field upstream fix that
356
+ // makes binding exact (our module-header TODO has waited for it). If a
357
+ // release adds it, prefer it over every heuristic.
358
+ const payloadSid = typeof p.sessionId === "string" ? p.sessionId : undefined;
359
+ if (payloadSid && panelBySession.has(payloadSid)) {
360
+ bindRecord(id, payloadSid);
361
+ }
362
+ else {
363
+ // Layer 1 — AUTHORITATIVE: service outputFile match (any event,
364
+ // any time, no ordering assumptions). Falls through to the FIFO
365
+ // when the service is absent or has no file yet.
366
+ bindByServiceMatch(id);
367
+ }
368
+ }
369
+ if (status === "started" || status === "resumed") {
370
+ if (!recordToSession.has(id)) {
371
+ // Layer 2 — BASE (partitioned FIFO): only started/resumed pop — and
372
+ // only IN-PROCESS-tagged entries. `created` (background-only in
373
+ // gotgenes) does NOT bind: its id returns at started, by which time
374
+ // its own session_start has tagged its entry. Sessions never pop
375
+ // records. This partitions the modes so a concurrent fg+bg pair
376
+ // can't consume each other's half, and an out-of-process marker
377
+ // entry (never tagged) can't be stolen by an in-process started.
378
+ const idx = unboundSessions.findIndex((s) => unboundInProcess.has(s));
379
+ if (idx !== -1) {
380
+ const sid = unboundSessions.splice(idx, 1)[0];
381
+ unboundInProcess.delete(sid);
382
+ bindRecord(id, sid);
383
+ }
384
+ }
385
+ }
386
+ // Already bound to a child? reflect the status/labels on its panel row.
387
+ const sid = recordToSession.get(id);
388
+ const st = sid ? panelBySession.get(sid) : undefined;
389
+ if (st) {
390
+ st.status = stamp;
391
+ if (type)
392
+ st.type = type;
393
+ if (description)
394
+ st.description = description;
395
+ emitPanel();
396
+ }
397
+ envLog(`subagents evt: ${status} id=${id} bound=${sid !== undefined} ` +
398
+ `queued=${unboundSessions.length}(${unboundInProcess.size} ip)`);
399
+ };
400
+ unsub.push(events.on("subagents:created", onRecord("created")));
401
+ unsub.push(events.on("subagents:started", onRecord("started")));
402
+ unsub.push(events.on("subagents:resumed", onRecord("resumed")));
403
+ unsub.push(events.on("subagents:completed", onRecord("completed")));
404
+ unsub.push(events.on("subagents:failed", onRecord("failed")));
405
+ unsub.push(events.on("subagents:steered", onRecord("steered")));
406
+ unsub.push(events.on("subagents:compacted", onRecord("compacted")));
407
+ // Event-asserted child markers (§3B): gotgenes' authoritative pre-bind event
408
+ // and un-bien's own implementation-neutral marker. Additive — absent, only
409
+ // in-process session_start detection runs.
410
+ unsub.push(events.on("subagents:child:session-created", onChildMarker));
411
+ unsub.push(events.on("unbien:subagent:child", onChildMarker));
412
+ unsub.push(events.on("subagents:child:disposed", onChildDisposed));
413
+ unsub.push(events.on("unbien:subagent:disposed", onChildDisposed));
414
+ }
415
+ else {
416
+ envLog("subagent bus: pi.events MISSING — no marker/record/disposed events will arrive (rooms still work via session_start)");
417
+ }
418
+ /** Bind record id <-> child session, draining the session from the unbound
419
+ * FIFO. Single binding per record and per session (both sides check first). */
420
+ function bindRecord(recId, sessionId) {
421
+ recordToSession.set(recId, sessionId);
422
+ const i = unboundSessions.indexOf(sessionId);
423
+ if (i !== -1)
424
+ unboundSessions.splice(i, 1);
425
+ }
426
+ function bindByServiceMatch(recId) {
427
+ let file;
428
+ try {
429
+ const svc = globalThis[Symbol.for("@gotgenes/pi-subagents:service")];
430
+ file = svc?.getRecord(recId)?.outputFile;
431
+ }
432
+ catch {
433
+ return false;
434
+ }
435
+ if (typeof file !== "string")
436
+ return false;
437
+ const sid = unboundSessions.find((s) => sessionFileBySession.get(s) === file);
438
+ if (!sid)
439
+ return false;
440
+ const i = unboundSessions.indexOf(sid);
441
+ if (i !== -1)
442
+ unboundSessions.splice(i, 1);
443
+ unboundInProcess.delete(sid);
444
+ bindRecord(recId, sid);
445
+ envLog(`subagent bind: service match id=${recId} sid=${sid.slice(0, 8)}`);
446
+ return true;
447
+ }
448
+ // Idempotent create-or-attach, keyed by child sessionId (the deterministic
449
+ // roomId's key). The FIRST caller builds the room — a passive keeper when no
450
+ // childPi, or serving when a childPi is supplied; a later in-process
451
+ // session_start ATTACHES the producer to the SAME room. `building` is reserved
452
+ // synchronously so a marker + session_start microseconds apart converge on one
453
+ // room instead of racing into two.
454
+ function ensureChildRoom(sessionId, buildArgs) {
455
+ const launch = buildArgs.childPi && buildArgs.ctx
456
+ ? { childPi: buildArgs.childPi, ctx: buildArgs.ctx }
457
+ : undefined;
458
+ // Parentage to advertise. Gate on parentSessionId — the app nests by it;
459
+ // parentRoomId alone can't. gotgenes / out-of-process advertise EARLY (the
460
+ // connect room_meta below already carries it); tintinweb advertises LATE via
461
+ // setParent (re-advertise, relay set-once). Both funnel through here; the
462
+ // room is NEVER rebuilt.
463
+ const parent = buildArgs.parentSessionId && buildArgs.parentRoomId
464
+ ? {
465
+ parentRoomId: buildArgs.parentRoomId,
466
+ parentSessionId: buildArgs.parentSessionId,
467
+ }
468
+ : undefined;
469
+ const existing = children.get(sessionId);
470
+ if (existing) {
471
+ // design 01M1CAW0: a LIVE room keyed by anything but the sid-hash id is
472
+ // residue of the retired cwd-derived announce. It cannot be rebuilt
473
+ // (the room is already open on the relay) — log loudly and keep serving.
474
+ if (existing.roomId !== roomIdForSession(sessionId)) {
475
+ envLog(`subagent live room id mismatch: ${existing.roomId} != sid-hash for sid=${sessionId.slice(0, 8)} (design 01M1CAW0)`);
476
+ console.warn(`[un-bien] subagent room "${existing.roomId}" is not the ` +
477
+ `session-derived room — set-once parentage on it is suspect ` +
478
+ `(design 01M1CAW0)`);
479
+ }
480
+ if (launch)
481
+ existing.attach(launch.childPi, launch.ctx);
482
+ if (parent)
483
+ existing.setParent(parent.parentRoomId, parent.parentSessionId);
484
+ return;
485
+ }
486
+ if (building.has(sessionId)) {
487
+ if (launch)
488
+ pendingAttach.set(sessionId, launch);
489
+ if (parent)
490
+ pendingParent.set(sessionId, parent);
491
+ return;
492
+ }
493
+ building.add(sessionId);
494
+ // design 01M1CAW0: the room this build opens is ALWAYS the sid-hash id —
495
+ // a buildArgs.roomId that differs (cwd-derived residue fed from a stale
496
+ // panel row) is refused here, the single authoritative choke point.
497
+ const roomId = normalizeChildRoomId(sessionId, buildArgs.roomId);
498
+ void startChildRoom({
499
+ ...buildArgs,
500
+ roomId,
501
+ onClosed: () => children.delete(sessionId),
502
+ }).then((room) => {
503
+ building.delete(sessionId);
504
+ // Controller teardown OR a disposal marker that landed while this build
505
+ // was connecting (keeperReleased tombstone) — nobody will ever attach to
506
+ // this room; dispose instead of registering it.
507
+ if (disposed || keeperReleased.has(sessionId)) {
508
+ room.dispose();
509
+ return;
510
+ }
511
+ children.set(sessionId, room);
512
+ const pend = pendingAttach.get(sessionId);
513
+ if (pend) {
514
+ room.attach(pend.childPi, pend.ctx);
515
+ pendingAttach.delete(sessionId);
516
+ }
517
+ // Re-advertise for the just-built room (set-once; harmless when the connect
518
+ // room_meta already carried it) + any parent queued during the build race.
519
+ const pp = pendingParent.get(sessionId);
520
+ if (pp) {
521
+ room.setParent(pp.parentRoomId, pp.parentSessionId);
522
+ pendingParent.delete(sessionId);
523
+ }
524
+ else if (parent) {
525
+ room.setParent(parent.parentRoomId, parent.parentSessionId);
526
+ }
527
+ });
528
+ }
529
+ function makeHandlers(ctx) {
530
+ const ro = async () => {
531
+ throw new Error("subagent session is read-only");
532
+ };
533
+ // Capture the sessionManager ONCE, while the ctx is ACTIVE: the child
534
+ // session's dispose() (fired by the manager when its run ends) invalidates
535
+ // the ctx WRAPPER — a later `ctx.sessionManager` read would throw the
536
+ // staleness error. The SessionManager itself stays readable (in-memory
537
+ // entry log; the SDK even types a ReadonlySessionManager for this), which is
538
+ // what lets a LINGERED room keep serving the finished transcript.
539
+ const sm = ctx.sessionManager;
540
+ return {
541
+ prompt: ro,
542
+ steer: ro,
543
+ followUp: ro,
544
+ abort: ro,
545
+ setModel: ro,
546
+ setThinkingLevel: ro,
547
+ getEntries: async (since) => {
548
+ // PAGED like the root room's handler (design: get_entries backfill
549
+ // paging) — a lingered child's finished transcript can also exceed a
550
+ // transport cap in one frame. pi-faithful `since` semantics: unknown
551
+ // id → `Entry not found` error (dispatch turns a throw into
552
+ // success:false + error on the response envelope).
553
+ const all = sm.getEntries();
554
+ if (typeof since === "string" &&
555
+ all.findIndex((e) => e.id === since) === -1)
556
+ throw new Error(`Entry not found: ${since}`);
557
+ return pageEntries(all, since, sm.getLeafId());
558
+ },
559
+ };
560
+ }
561
+ function onChildSession(childPi, ctx) {
562
+ if (disposed || !ctx?.sessionManager)
563
+ return;
564
+ const sessionId = ctx.sessionManager.getSessionId();
565
+ if (!sessionId)
566
+ return;
567
+ // In-process lifecycle: disposal of THIS sid lingers (see inProcess). A
568
+ // resurrected session id (resume of the same session file) re-opens it.
569
+ inProcess.add(sessionId);
570
+ keeperReleased.delete(sessionId);
571
+ // Capture the session file — the join key for service-match binding.
572
+ const sessionFile = ctx.sessionManager.getSessionFile?.();
573
+ if (typeof sessionFile === "string") {
574
+ sessionFileBySession.set(sessionId, sessionFile);
575
+ }
576
+ const parentRoomId = opts.getParentRoomId();
577
+ if (!parentRoomId)
578
+ return; // root room not up yet — nothing to nest under
579
+ // UPSERT reconcile (deterministic roomId is the meeting point): if a child
580
+ // MARKER (subagents:child:session-created / unbien:subagent:child) already
581
+ // registered this child, that binding is AUTHORITATIVE — enrich it and SKIP
582
+ // the arrival-order record pop for IDENTITY (removes the mis-bind risk).
583
+ // BUT still BIND the next pending record unless this session already owns
584
+ // one: the gotgenes marker carries no record id, so skipping the pop
585
+ // entirely left the later subagents:completed/failed {id} events unbindable
586
+ // — the row's status stuck on started/running forever (both the panel and
587
+ // get_session_info answers, which read this same map). Identity stays
588
+ // marker-authoritative; only the record BINDING falls back to arrival order.
589
+ const existing = panelBySession.get(sessionId);
590
+ const alreadyBound = [...recordToSession.values()].includes(sessionId);
591
+ if (!alreadyBound) {
592
+ // QUEUE + TAG, never pop (partitioned binding): the entry becomes
593
+ // poppable by a later started/resumed record. The marker usually queued
594
+ // it already; session_start is what TAGS it in-process.
595
+ if (!unboundSessions.includes(sessionId))
596
+ unboundSessions.push(sessionId);
597
+ unboundInProcess.add(sessionId);
598
+ }
599
+ const roomId = normalizeChildRoomId(sessionId, existing?.roomId);
600
+ const name = existing?.description ?? existing?.type ?? "subagent";
601
+ // Register/enrich the panel row keyed by the CHILD sessionId (identity from
602
+ // detection); labels enrich from a bound record's events once correlated.
603
+ panelBySession.set(sessionId, {
604
+ roomId,
605
+ type: existing?.type,
606
+ description: existing?.description,
607
+ status: existing?.status ?? "started",
608
+ startedAt: existing?.startedAt ?? Date.now(),
609
+ });
610
+ emitPanel();
611
+ // getParentSessionId() returns the PARENT session's bare sessionId directly —
612
+ // exactly what the parent advertises as room_meta.sessionId, so the child
613
+ // nests. (For the subagents that surface it resolves to the root, since
614
+ // managers suppress nested subagents.) Do NOT use the SDK
615
+ // `header.parentSession`: it is the parent's session FILE PATH
616
+ // (…/<ts>_<id>.jsonl), not an id, and a path never equals the parent's
617
+ // advertised sessionId — so every subagent would orphan (shows flat in the
618
+ // home view). Depth-2 under-real-spawner, if ever needed, wants a
619
+ // sessionFile->sessionId map, not filename parsing.
620
+ const header = ctx.sessionManager.getHeader?.() ?? null;
621
+ const parentSessionId = opts.getParentSessionId() ?? undefined;
622
+ // DEBUG (keep until nesting confirmed): header.parentSession is a FILE PATH,
623
+ // so we ignore it and use the root's bare sessionId directly.
624
+ envLog(`subagent nest: child=${sessionId.slice(0, 8)} ` +
625
+ `header.parentSession=${String(header?.parentSession)} ` +
626
+ `-> parentSessionId=${String(parentSessionId)}`);
627
+ // The child's real start (session-header timestamp) so its room reports its
628
+ // own start instead of 0; fall back to the panel stamp, then now.
629
+ const startedAt = (header?.timestamp ? Date.parse(header.timestamp) : 0) ||
630
+ panelBySession.get(sessionId)?.startedAt ||
631
+ Date.now();
632
+ ensureChildRoom(sessionId, {
633
+ relayUrl,
634
+ childPi,
635
+ ctx,
636
+ sessionId,
637
+ roomId,
638
+ parentRoomId,
639
+ parentSessionId,
640
+ startedAt,
641
+ name,
642
+ makeHandlers,
643
+ getStatus: () => normalizeStatus(panelBySession.get(sessionId)?.status),
644
+ onTerminate: () => terminateChildRoom(sessionId, roomId),
645
+ });
646
+ }
647
+ /**
648
+ * The config gate for app-driven termination, read at call time (config can
649
+ * change mid-run). Shared by the root dispatcher's `terminate` /
650
+ * `close_child_room` gates and the child-room `onTerminate` callback.
651
+ */
652
+ function allowRemoteTerminate() {
653
+ return effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()));
654
+ }
655
+ /**
656
+
657
+ /**
658
+ * app-driven close (`close_child_room` ub frame + child-room `terminate`):
659
+ * permanently close the child room — tombstone the sid FIRST (in-flight
660
+ * builds dispose instead of registering), then dispose the room (drops its
661
+ * conn; last conn at the relay => room_ended, which purges the app row). An
662
+ * in-process SDK child is NOT force-killed (v1: its run finishes headless;
663
+ * the panel row stamps terminal on natural completion) — this kills the
664
+ * CHAT, not the subagent's work. `room_id` must be the sid-hash id of
665
+ * `sid` (normalizeChildRoomId contract); the sid comes from the marker/
666
+ * ensure path that owns the room. Returns whether the gate allowed it.
667
+ * Closure-level so the controller method, the root dispatcher's
668
+ * `close_child_room` (via the controller) and the startChildRoom
669
+ * `onTerminate` callback share ONE teardown path.
670
+ */
671
+ function terminateChildRoom(sessionId, roomId) {
672
+ if (!allowRemoteTerminate())
673
+ return false;
674
+ const room = children.get(sessionId);
675
+ if (room && room.roomId !== roomId) {
676
+ // design 01M1CAW0: never tear down a room keyed by a foreign id.
677
+ envLog(`terminate refused: room ${roomId.slice(0, 8)} != sid-hash for sid=${sessionId.slice(0, 8)}`);
678
+ return false;
679
+ }
680
+ keeperReleased.add(sessionId);
681
+ children.delete(sessionId);
682
+ building.delete(sessionId);
683
+ pendingAttach.delete(sessionId);
684
+ pendingParent.delete(sessionId);
685
+ try {
686
+ room?.dispose();
687
+ }
688
+ catch {
689
+ /* best-effort — the conn drops with the process either way */
690
+ }
691
+ envLog(`close_child_room: room=${roomId.slice(0, 8)} (sid=${sessionId.slice(0, 8)})`);
692
+ return true;
693
+ }
694
+ return {
695
+ onChildSession,
696
+ closeChildRoom(roomId) {
697
+ // Root-dispatcher path (`close_child_room`): close by ROOM id — the app
698
+ // knows only the room. The gate lives in the root dispatcher.
699
+ for (const [sid, room] of children) {
700
+ if (room.roomId !== roomId)
701
+ continue;
702
+ return terminateChildRoom(sid, roomId);
703
+ }
704
+ return false;
705
+ },
706
+ dispose() {
707
+ disposed = true;
708
+ for (const u of unsub) {
709
+ try {
710
+ u();
711
+ }
712
+ catch {
713
+ /* best-effort */
714
+ }
715
+ }
716
+ for (const room of children.values())
717
+ room.dispose();
718
+ children.clear();
719
+ },
720
+ };
721
+ }
722
+ async function startChildRoom(args) {
723
+ const kp = await getOrCreateEd25519Keypair();
724
+ const relay = new RelayClient(args.relayUrl, kp);
725
+ const channels = new Map();
726
+ // Passive KEEPER by default: holds the room open + carries room_meta.parent,
727
+ // subscribes to NO child events and answers NO owner RPCs. attach() is the
728
+ // ONLY thing that reads the child / takes pi callbacks, and it runs only on an
729
+ // ACTUAL in-process launch (immediately when built with a childPi, else
730
+ // deferred to session_start).
731
+ let handlers = null;
732
+ let rpc = { dispose() { } };
733
+ let serving = false;
734
+ function broadcast(env) {
735
+ for (const ch of channels.values()) {
736
+ try {
737
+ ch.sendEnvelope(env);
738
+ }
739
+ catch {
740
+ /* best-effort per channel */
741
+ }
742
+ }
743
+ }
744
+ function handleRpc(env, sender) {
745
+ if (!serving || !handlers)
746
+ return; // passive keeper answers nothing
747
+ if (env.rpc !== undefined) {
748
+ void dispatchRpcCommand(env.rpc, handlers)
749
+ .then((resp) => {
750
+ if (resp)
751
+ sender.sendEnvelope(resp);
752
+ })
753
+ .catch(() => {
754
+ /* read-only rejections already shape success:false; ignore */
755
+ });
756
+ return;
757
+ }
758
+ if (env.ub !== undefined) {
759
+ const f = env.ub;
760
+ if (f.type === "session_sync") {
761
+ // A child room has NO panels/pending-ui of its own; the transcript is
762
+ // the app's native get_entries. Just terminate the sync.
763
+ sender.sendEnvelope({
764
+ ub: {
765
+ type: "session_sync_end",
766
+ ...(typeof f.id === "string" ? { in_reply_to: f.id } : {}),
767
+ session_started_at: args.startedAt,
768
+ },
769
+ });
770
+ }
771
+ else if (f.type === "get_session_info") {
772
+ // Pull: the app asks this subagent for its own info (lifecycle status).
773
+ // Answered from the extension's tracked state, so it survives app relaunch.
774
+ sender.sendEnvelope({
775
+ ub: {
776
+ type: "session_info",
777
+ status: args.getStatus(),
778
+ ...(typeof f.id === "string" ? { in_reply_to: f.id } : {}),
779
+ },
780
+ });
781
+ }
782
+ else if (f.type === "terminate") {
783
+ // app-driven kill of a CHILD chat (plan [lifecycle][send]): stop
784
+ // serving this room + tombstone it. The in-process SDK child is not
785
+ // force-killed (v1) — its run finishes headless and the panel row
786
+ // stamps terminal on natural completion. There is NO re-serve: the
787
+ // tombstone is lifted only by a genuine same-sid session_start
788
+ // (resume of the same session file), never by reconnect. The actual
789
+ // teardown runs through the controller's closeChildRoom (closure
790
+ // state — tombstone + map cleanup + dispose), reached via the
791
+ // onTerminate callback the room was built with.
792
+ if (!args.onTerminate()) {
793
+ envLog("terminate(ub): remote terminate disabled on this machine");
794
+ return;
795
+ }
796
+ }
797
+ }
798
+ }
799
+ async function gateAndAttach(peer, firstInner) {
800
+ if (!serving)
801
+ return; // keeper doesn't attach owners; the child's conn serves
802
+ if (channels.has(peer))
803
+ return;
804
+ const known = await _findKnownPeer(peer);
805
+ if (!known)
806
+ return; // relay-verified but not a paired owner
807
+ const channel = new PlainPeerChannel(relay, peer, args.roomId, () => { }, // stock ClientMessages unused — envelope plane only
808
+ () => channels.delete(peer), (env) => handleRpc(env, channel), () => args.sessionId);
809
+ channels.set(peer, channel);
810
+ // Greet: caps + the child sessionId, so the app turns on the envelope route.
811
+ channel.sendEnvelope(helloEnvelope(["rpc_envelope"], args.sessionId));
812
+ if (isEnvelopeFrame(firstInner)) {
813
+ handleRpc(firstInner, channel);
814
+ }
815
+ }
816
+ function onLine(line) {
817
+ let outer;
818
+ try {
819
+ outer = JSON.parse(line);
820
+ }
821
+ catch {
822
+ return;
823
+ }
824
+ if (!outer.peer || !outer.ct)
825
+ return;
826
+ if (channels.has(outer.peer))
827
+ return; // its channel routes it
828
+ let inner;
829
+ try {
830
+ inner = JSON.parse(Buffer.from(outer.ct, "base64").toString("utf8"));
831
+ }
832
+ catch {
833
+ return;
834
+ }
835
+ if (!inner || typeof inner !== "object")
836
+ return;
837
+ void gateAndAttach(outer.peer, inner);
838
+ }
839
+ relay.on("message", onLine);
840
+ relay.on("close", () => {
841
+ channels.clear();
842
+ args.onClosed();
843
+ });
844
+ await relay.connect({
845
+ roomId: args.roomId,
846
+ roomMeta: {
847
+ name: args.name,
848
+ cwd: args.ctx?.cwd ?? "",
849
+ // Pi ids — the app nests + maps by these; parent (roomId) + subagentId are
850
+ // kept as supplementary (not the logic keys).
851
+ sessionId: args.sessionId,
852
+ // design 01M1CAW0: parentage (parent roomId + parentSessionId) is
853
+ // advertised ONLY when the parent SESSION is known — the relay merges
854
+ // it SET-ONCE, so a parent-less build must never stamp a guessed parent.
855
+ ...(args.parentSessionId
856
+ ? {
857
+ parent: args.parentRoomId,
858
+ parentSessionId: args.parentSessionId,
859
+ }
860
+ : {}),
861
+ ...(args.subagentId ? { subagentId: args.subagentId } : {}),
862
+ },
863
+ });
864
+ envLog(`subagent room ${args.roomId} up (child ${args.sessionId.slice(0, 8)}…, parent ${args.parentRoomId})`);
865
+ // Attach the transcript PRODUCER — the ONLY pi.on subscription (child
866
+ // callbacks). Called immediately when built WITH a child pi (in-process direct
867
+ // launch), else deferred to session_start via the returned attach().
868
+ function attach(childPi, ctx) {
869
+ if (serving)
870
+ return; // idempotent
871
+ handlers = args.makeHandlers(ctx);
872
+ rpc = createRpcEnvelope(childPi, broadcast);
873
+ serving = true;
874
+ }
875
+ if (args.childPi && args.ctx)
876
+ attach(args.childPi, args.ctx);
877
+ // Re-advertise parentage on this already-open room via room_meta_update (the
878
+ // relay merges it SET-ONCE, never overriding an existing parent). Lets a child
879
+ // whose parent is learned LATE (in-process, after session_start) nest without
880
+ // a re-announce or a rebuild — no dispose/teardown.
881
+ function setParent(parentRoomId, parentSessionId) {
882
+ relay.sendControl({
883
+ type: "room_meta_update",
884
+ room_id: args.roomId,
885
+ meta: {
886
+ parent: parentRoomId,
887
+ ...(parentSessionId ? { parentSessionId } : {}),
888
+ },
889
+ });
890
+ }
891
+ return {
892
+ sessionId: args.sessionId,
893
+ roomId: args.roomId,
894
+ relay,
895
+ channels,
896
+ attach,
897
+ setParent,
898
+ rpc: { dispose: () => rpc.dispose() },
899
+ dispose() {
900
+ try {
901
+ rpc.dispose();
902
+ }
903
+ catch {
904
+ /* best-effort */
905
+ }
906
+ for (const ch of channels.values()) {
907
+ try {
908
+ ch.detach();
909
+ }
910
+ catch {
911
+ /* best-effort */
912
+ }
913
+ }
914
+ channels.clear();
915
+ relay.close();
916
+ },
917
+ };
918
+ }
919
+ //# sourceMappingURL=subagent_rooms.js.map