@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.
- package/README.md +223 -254
- package/dist/bin/launcher.js +38 -0
- package/dist/bin/launcher.js.map +1 -0
- package/dist/config.d.ts +28 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon/install.d.ts +33 -35
- package/dist/daemon/install.js +43 -73
- package/dist/daemon/install.js.map +1 -1
- package/dist/enrich_tool_args.d.ts +9 -0
- package/dist/enrich_tool_args.js +171 -0
- package/dist/enrich_tool_args.js.map +1 -0
- package/dist/image_codec.d.ts +22 -0
- package/dist/image_codec.js +149 -0
- package/dist/image_codec.js.map +1 -0
- package/dist/index.d.ts +0 -26
- package/dist/index.js +200 -1236
- package/dist/index.js.map +1 -1
- package/dist/launch.d.ts +34 -0
- package/dist/launch.js +171 -0
- package/dist/launch.js.map +1 -0
- package/dist/launcher/launcher.d.ts +6 -0
- package/dist/launcher/launcher.js +188 -0
- package/dist/launcher/launcher.js.map +1 -0
- package/dist/pairing/peer_trust.d.ts +20 -0
- package/dist/pairing/peer_trust.js +82 -0
- package/dist/pairing/peer_trust.js.map +1 -0
- package/dist/panel_bridge.d.ts +3 -1
- package/dist/panel_bridge.js +10 -10
- package/dist/panel_bridge.js.map +1 -1
- package/dist/rooms.d.ts +16 -0
- package/dist/rooms.js +22 -0
- package/dist/rooms.js.map +1 -1
- package/dist/session/debug_log.js +2 -2
- package/dist/session/debug_log.js.map +1 -1
- package/dist/session/local_config.js +2 -0
- package/dist/session/local_config.js.map +1 -1
- package/dist/session/rpc_envelope.d.ts +18 -0
- package/dist/session/rpc_envelope.js +1 -1
- package/dist/session/rpc_envelope.js.map +1 -1
- package/dist/session/rpc_inbound.d.ts +2 -2
- package/dist/session/rpc_inbound.js +1 -1
- package/dist/session/rpc_inbound.js.map +1 -1
- package/dist/subagent_rooms.d.ts +49 -0
- package/dist/subagent_rooms.js +787 -0
- package/dist/subagent_rooms.js.map +1 -0
- package/dist/transport/peer_channel.d.ts +7 -1
- package/dist/transport/peer_channel.js +8 -1
- package/dist/transport/peer_channel.js.map +1 -1
- package/dist/transport/relay_client.d.ts +19 -0
- package/dist/transport/relay_client.js.map +1 -1
- package/docs/daemon.md +65 -64
- package/package.json +13 -11
- package/service-templates/launchd.plist.template +5 -5
- package/service-templates/systemd.service.template +2 -2
- package/service-templates/task-launcher.vbs.template +5 -5
- package/service-templates/task-scheduler.xml.template +1 -1
- package/dist/bin/supervisord.js +0 -86
- package/dist/bin/supervisord.js.map +0 -1
- package/dist/daemon/client.d.ts +0 -20
- package/dist/daemon/client.js +0 -131
- package/dist/daemon/client.js.map +0 -1
- package/dist/daemon/control_protocol.d.ts +0 -176
- package/dist/daemon/control_protocol.js +0 -63
- package/dist/daemon/control_protocol.js.map +0 -1
- package/dist/daemon/cron_log.d.ts +0 -45
- package/dist/daemon/cron_log.js +0 -71
- package/dist/daemon/cron_log.js.map +0 -1
- package/dist/daemon/cron_registry.d.ts +0 -80
- package/dist/daemon/cron_registry.js +0 -198
- package/dist/daemon/cron_registry.js.map +0 -1
- package/dist/daemon/id.d.ts +0 -24
- package/dist/daemon/id.js +0 -36
- package/dist/daemon/id.js.map +0 -1
- package/dist/daemon/registry.d.ts +0 -63
- package/dist/daemon/registry.js +0 -153
- package/dist/daemon/registry.js.map +0 -1
- package/dist/daemon/rpc_child.d.ts +0 -194
- package/dist/daemon/rpc_child.js +0 -419
- package/dist/daemon/rpc_child.js.map +0 -1
- package/dist/daemon/supervisor.d.ts +0 -93
- package/dist/daemon/supervisor.js +0 -666
- package/dist/daemon/supervisor.js.map +0 -1
- /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
|