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