@geohar/un-bien 0.9.0 → 0.16.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 (140) hide show
  1. package/README.md +100 -131
  2. package/dist/actions/handlers.js.map +1 -1
  3. package/dist/actions/registry.js.map +1 -1
  4. package/dist/bin/launcher.js.map +1 -1
  5. package/dist/client.d.ts +15 -0
  6. package/dist/client.js +15 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/commands/deps.d.ts +156 -0
  9. package/dist/commands/deps.js +2 -0
  10. package/dist/commands/deps.js.map +1 -0
  11. package/dist/commands/fork_link.d.ts +4 -0
  12. package/dist/commands/fork_link.js +45 -0
  13. package/dist/commands/fork_link.js.map +1 -0
  14. package/dist/commands/housekeeping.d.ts +34 -0
  15. package/dist/commands/housekeeping.js +275 -0
  16. package/dist/commands/housekeeping.js.map +1 -0
  17. package/dist/commands/info.d.ts +43 -0
  18. package/dist/commands/info.js +127 -0
  19. package/dist/commands/info.js.map +1 -0
  20. package/dist/commands/lifecycle.d.ts +45 -0
  21. package/dist/commands/lifecycle.js +708 -0
  22. package/dist/commands/lifecycle.js.map +1 -0
  23. package/dist/commands/pairing.d.ts +26 -0
  24. package/dist/commands/pairing.js +167 -0
  25. package/dist/commands/pairing.js.map +1 -0
  26. package/dist/commands/register.d.ts +12 -0
  27. package/dist/commands/register.js +255 -0
  28. package/dist/commands/register.js.map +1 -0
  29. package/dist/commands/relay.d.ts +28 -0
  30. package/dist/commands/relay.js +81 -0
  31. package/dist/commands/relay.js.map +1 -0
  32. package/dist/commands/session_ops.d.ts +52 -0
  33. package/dist/commands/session_ops.js +75 -0
  34. package/dist/commands/session_ops.js.map +1 -0
  35. package/dist/config.d.ts +11 -1
  36. package/dist/config.js.map +1 -1
  37. package/dist/daemon/install.d.ts +6 -5
  38. package/dist/daemon/install.js +15 -5
  39. package/dist/daemon/install.js.map +1 -1
  40. package/dist/enrich_tool_args.js.map +1 -1
  41. package/dist/extension_ui_bridge.js.map +1 -1
  42. package/dist/image_codec.js.map +1 -1
  43. package/dist/index.d.ts +32 -101
  44. package/dist/index.js +852 -2872
  45. package/dist/index.js.map +1 -1
  46. package/dist/launch.d.ts +14 -1
  47. package/dist/launch.js +55 -5
  48. package/dist/launch.js.map +1 -1
  49. package/dist/launcher/launcher.js +48 -6
  50. package/dist/launcher/launcher.js.map +1 -1
  51. package/dist/mcp/mesh_result.js.map +1 -1
  52. package/dist/mcp/mesh_server.js.map +1 -1
  53. package/dist/mesh/canonical.js +1 -1
  54. package/dist/mesh/canonical.js.map +1 -1
  55. package/dist/mesh/client.js.map +1 -1
  56. package/dist/mesh/encoding.js.map +1 -1
  57. package/dist/mesh/self_revoke.js.map +1 -1
  58. package/dist/mesh/siblings.js.map +1 -1
  59. package/dist/mesh/verify.js +1 -1
  60. package/dist/mesh/verify.js.map +1 -1
  61. package/dist/pairing/crypto.js +0 -1
  62. package/dist/pairing/crypto.js.map +1 -1
  63. package/dist/pairing/peer_trust.js.map +1 -1
  64. package/dist/pairing/qr.js.map +1 -1
  65. package/dist/pairing/storage.d.ts +7 -0
  66. package/dist/pairing/storage.js +22 -3
  67. package/dist/pairing/storage.js.map +1 -1
  68. package/dist/panel_bridge.js +1 -0
  69. package/dist/panel_bridge.js.map +1 -1
  70. package/dist/paths.d.ts +22 -8
  71. package/dist/paths.js +37 -10
  72. package/dist/paths.js.map +1 -1
  73. package/dist/protocol/codec.js.map +1 -1
  74. package/dist/rooms.js +1 -4
  75. package/dist/rooms.js.map +1 -1
  76. package/dist/session/bridge.js.map +1 -1
  77. package/dist/session/broker.js.map +1 -1
  78. package/dist/session/broker_remote.js.map +1 -1
  79. package/dist/session/capabilities.d.ts +11 -0
  80. package/dist/session/capabilities.js +35 -0
  81. package/dist/session/capabilities.js.map +1 -0
  82. package/dist/session/cwd_lock.js +3 -3
  83. package/dist/session/cwd_lock.js.map +1 -1
  84. package/dist/session/debug_log.js +13 -3
  85. package/dist/session/debug_log.js.map +1 -1
  86. package/dist/session/envelope.js.map +1 -1
  87. package/dist/session/global_config.d.ts +1 -1
  88. package/dist/session/global_config.js +1 -1
  89. package/dist/session/global_config.js.map +1 -1
  90. package/dist/session/ipc.d.ts +2 -2
  91. package/dist/session/ipc.js.map +1 -1
  92. package/dist/session/leader_election.js.map +1 -1
  93. package/dist/session/local_config.d.ts +37 -0
  94. package/dist/session/local_config.js +45 -1
  95. package/dist/session/local_config.js.map +1 -1
  96. package/dist/session/mesh_node.d.ts +1 -1
  97. package/dist/session/mesh_node.js +1 -1
  98. package/dist/session/mesh_node.js.map +1 -1
  99. package/dist/session/peer.js.map +1 -1
  100. package/dist/session/peer_inventory.js.map +1 -1
  101. package/dist/session/peer_limits.js.map +1 -1
  102. package/dist/session/received_images.d.ts +63 -0
  103. package/dist/session/received_images.js +273 -0
  104. package/dist/session/received_images.js.map +1 -0
  105. package/dist/session/relay_lifecycle.d.ts +223 -0
  106. package/dist/session/relay_lifecycle.js +802 -0
  107. package/dist/session/relay_lifecycle.js.map +1 -0
  108. package/dist/session/rpc_envelope.d.ts +10 -0
  109. package/dist/session/rpc_envelope.js +9 -0
  110. package/dist/session/rpc_envelope.js.map +1 -1
  111. package/dist/session/rpc_handlers.d.ts +53 -0
  112. package/dist/session/rpc_handlers.js +268 -0
  113. package/dist/session/rpc_handlers.js.map +1 -0
  114. package/dist/session/rpc_inbound.d.ts +41 -6
  115. package/dist/session/rpc_inbound.js +55 -0
  116. package/dist/session/rpc_inbound.js.map +1 -1
  117. package/dist/session/setup_wizard.js.map +1 -1
  118. package/dist/session/tools.js.map +1 -1
  119. package/dist/session/wizard.js.map +1 -1
  120. package/dist/state_migration.d.ts +45 -0
  121. package/dist/state_migration.js +104 -0
  122. package/dist/state_migration.js.map +1 -0
  123. package/dist/subagent_rooms.d.ts +19 -0
  124. package/dist/subagent_rooms.js +146 -14
  125. package/dist/subagent_rooms.js.map +1 -1
  126. package/dist/test_hooks.d.ts +180 -0
  127. package/dist/test_hooks.js +98 -0
  128. package/dist/test_hooks.js.map +1 -0
  129. package/dist/transport/peer_channel.d.ts +11 -0
  130. package/dist/transport/peer_channel.js +34 -11
  131. package/dist/transport/peer_channel.js.map +1 -1
  132. package/dist/transport/pi_forward_client.js.map +1 -1
  133. package/dist/transport/relay_client.d.ts +14 -3
  134. package/dist/transport/relay_client.js +6 -3
  135. package/dist/transport/relay_client.js.map +1 -1
  136. package/dist/ui/footer.js.map +1 -1
  137. package/docs/daemon.md +166 -176
  138. package/package.json +6 -2
  139. package/service-templates/launchd.plist.template +5 -4
  140. package/service-templates/systemd.service.template +3 -2
@@ -0,0 +1,708 @@
1
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { conditionalRemovePeer, getOrCreateEd25519Keypair, KeyringUnavailableError, PairedIdentityMissingError, snapshotOwnerPubkeys, } from "../pairing/storage.js";
3
+ import { resolveRelayUrl, toWebSocketUrl } from "../config.js";
4
+ import { MeshClient } from "../mesh/client.js";
5
+ import { SelfRevoke } from "../mesh/self_revoke.js";
6
+ import { RelayClient, RoomAlreadyOpenError } from "../transport/relay_client.js";
7
+ import { defaultAgentName, effectiveAutoStartRelay, loadLocalConfig, localConfigExists, piSessionName, resolveAgentName, saveLocalConfig, } from "../session/local_config.js";
8
+ import { runSetupWizard } from "../session/setup_wizard.js";
9
+ import { acquireCwdLock } from "../session/cwd_lock.js";
10
+ import { sessionCapabilities } from "../session/capabilities.js";
11
+ import { ensureGlobalDirs, LOCAL_SESSION_NAME, sessionAuditPath, sessionSockPath, skillsDir, } from "../session/global_config.js";
12
+ import { MeshNode } from "../session/mesh_node.js";
13
+ import { ensureModelRegistry } from "../actions/registry.js";
14
+ import { mkdirSync, realpathSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { _cmdStatus } from "./info.js";
17
+ // Coalesces concurrent `/unbien` startup paths inside ONE extension instance.
18
+ // Separate Pi processes still keep the existing #N behavior via the cwd lock.
19
+ let _cmdRootInFlight = null;
20
+ /**
21
+ * Root handler for `/unbien`. On first run (no local config) drops into
22
+ * the wizard; on subsequent runs auto-joins the local mesh + starts the
23
+ * relay (if opted in during setup), then prints the status.
24
+ *
25
+ * `/unbien` is intentionally the only command users need day-to-day:
26
+ * idempotent connect + status display.
27
+ */
28
+ export async function _cmdRoot(deps, ctx, restartAuthority) {
29
+ const rootLifecycleGeneration = restartAuthority?.rootLifecycleGeneration ?? deps.rootLifecycleGeneration;
30
+ if (_cmdRootInFlight) {
31
+ try {
32
+ await _cmdRootInFlight;
33
+ }
34
+ catch (err) {
35
+ // Stale authority stops here. A current normal duplicate preserves the
36
+ // outgoing error, while a current replacement suppresses that old-session
37
+ // failure and falls through to start one fresh root below.
38
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration))
39
+ return;
40
+ if (!restartAuthority)
41
+ throw err;
42
+ }
43
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration))
44
+ return;
45
+ if (!restartAuthority) {
46
+ _cmdStatus(deps, ctx);
47
+ return;
48
+ }
49
+ }
50
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration))
51
+ return;
52
+ const run = _cmdRootInner(deps, ctx, rootLifecycleGeneration);
53
+ _cmdRootInFlight = run;
54
+ try {
55
+ await run;
56
+ }
57
+ finally {
58
+ if (_cmdRootInFlight === run)
59
+ _cmdRootInFlight = null;
60
+ }
61
+ }
62
+ export async function _cmdRootInner(deps, ctx, rootLifecycleGeneration) {
63
+ // A root retains its startup epoch through every pre-candidate await. This is
64
+ // stronger than `deps.disposed`, which a same-module session_start intentionally
65
+ // clears while an outgoing continuation may still be pending.
66
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration))
67
+ return;
68
+ const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
69
+ // Lock identity is (cwd, name). Several agents may run in the SAME folder; the
70
+ // requested name just has to be made unique. Derive the name the same way
71
+ // `_cmdJoin` does so the lock and the mesh registration agree on identity.
72
+ // A session-scoped name (pi -n, set by the remote launcher) beats the
73
+ // configured agent_name so a launched Pi locks/joins under what the app
74
+ // asked for.
75
+ const requestedName = resolveAgentName(cwd, piSessionName(deps.pi));
76
+ // Per-(cwd,name) lock. Interactive agents may coexist by auto-suffixing
77
+ // (`name#2`, `name#3`, …), but supervised daemons must be singletons for their
78
+ // registered cwd/name. If a daemon silently came up as `#2`, the supervisor
79
+ // would report "running" while the mesh had duplicate peers for one repo.
80
+ if (deps.cwdLock === null) {
81
+ const isDaemon = process.env["UNBIEN_DAEMON"] === "1";
82
+ const maxAttempts = isDaemon ? 1 : 1000;
83
+ for (let n = 1; n <= maxAttempts; n++) {
84
+ const candidate = n === 1 ? requestedName : `${requestedName}#${n}`;
85
+ const result = await acquireCwdLock(cwd, candidate);
86
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration)) {
87
+ if (result.ok) {
88
+ try {
89
+ result.release();
90
+ }
91
+ catch {
92
+ /* best-effort stale lock cleanup */
93
+ }
94
+ }
95
+ return;
96
+ }
97
+ if (result.ok) {
98
+ deps.cwdLock = result;
99
+ deps.lockedName = candidate;
100
+ break;
101
+ }
102
+ }
103
+ if (deps.cwdLock === null) {
104
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration))
105
+ return;
106
+ ctx.ui.notify(process.env["UNBIEN_DAEMON"] === "1"
107
+ ? `[un-bien] Daemon not started: another live agent already owns "${requestedName}" in this folder. Stop the old Pi process, then restart the daemon.`
108
+ : `[un-bien] Could not start: too many agents named "${requestedName}" already running in this folder.`, "warning");
109
+ return;
110
+ }
111
+ }
112
+ // First-time wizard: no local config in this cwd → run interactive setup.
113
+ if (!localConfigExists(cwd)) {
114
+ // SAFETY: ctx.ui MIGHT carry the wizard's select/input methods (interactive
115
+ // Pi) or not (headless); the `typeof ui.select !== "function"` guard on the
116
+ // next line validates the structural assumption before any method call.
117
+ const ui = ctx.ui;
118
+ if (typeof ui.select !== "function") {
119
+ _cmdStatus(deps, ctx);
120
+ return;
121
+ }
122
+ const baseDefault = defaultAgentName(cwd);
123
+ const newConfig = await runSetupWizard(ui, {
124
+ agent_name: baseDefault,
125
+ use_relay: true,
126
+ });
127
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration))
128
+ return;
129
+ if (!newConfig) {
130
+ ctx.ui.notify("[un-bien] Setup cancelled.", "info");
131
+ return;
132
+ }
133
+ saveLocalConfig(cwd, newConfig);
134
+ ctx.ui.notify(`[un-bien] Config saved to ${cwd}/.pi/un-bien/config.json`, "info");
135
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration))
136
+ return;
137
+ await _cmdJoin(deps, ctx);
138
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration) || !deps.meshNode)
139
+ return;
140
+ if (effectiveAutoStartRelay(newConfig))
141
+ await _cmdStart(deps, ctx);
142
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration) || !deps.meshNode)
143
+ return;
144
+ _cmdStatus(deps, ctx);
145
+ return;
146
+ }
147
+ // Returning user with config: ALWAYS join the local UDS mesh on connect; the
148
+ // relay is the only thing gated by auto_start_relay. So auto_start_relay:false
149
+ // now means "local mesh, no relay" (matching the first-time/wizard path and
150
+ // the field's documented intent) — previously a false flag skipped the mesh
151
+ // join entirely, leaving the agent (incl. daemons) fully idle.
152
+ const config = loadLocalConfig(cwd);
153
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration))
154
+ return;
155
+ if (!deps.meshNode)
156
+ await _cmdJoin(deps, ctx);
157
+ // `_cmdJoin` returns void on a canceled/failed join, so recheck both the
158
+ // root lifecycle and publication before bringing the Relay up.
159
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration) || !deps.meshNode)
160
+ return;
161
+ if (effectiveAutoStartRelay(config) && deps.state === "idle")
162
+ await _cmdStart(deps, ctx);
163
+ if (!deps.isCurrentRootLifecycle(rootLifecycleGeneration) || !deps.meshNode)
164
+ return;
165
+ _cmdStatus(deps, ctx);
166
+ }
167
+ /**
168
+ * `/unbien setup` — re-run the wizard. Defaults pre-fill from the
169
+ * existing config so it doubles as an "edit" flow.
170
+ */
171
+ export async function _cmdSetup(ctx) {
172
+ const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
173
+ // SAFETY: ctx.ui MIGHT carry the wizard's select/input methods (interactive
174
+ // Pi) or not (headless); the `typeof ui.select !== "function"` guard on the
175
+ // next line validates the structural assumption before any method call.
176
+ const ui = ctx.ui;
177
+ if (typeof ui.select !== "function") {
178
+ ctx.ui.notify("[un-bien] Setup requires an interactive UI.", "warning");
179
+ return;
180
+ }
181
+ const current = loadLocalConfig(cwd);
182
+ const baseDefault = defaultAgentName(cwd);
183
+ const newConfig = await runSetupWizard(ui, {
184
+ agent_name: current.agent_name ?? baseDefault,
185
+ use_relay: effectiveAutoStartRelay(current),
186
+ });
187
+ if (!newConfig) {
188
+ ctx.ui.notify("[un-bien] Setup cancelled.", "info");
189
+ return;
190
+ }
191
+ saveLocalConfig(cwd, newConfig);
192
+ ctx.ui.notify("[un-bien] Config updated. Run /unbien to apply now.", "info");
193
+ }
194
+ export async function _cmdStart(deps, ctx) {
195
+ if (deps.state !== "idle") {
196
+ ctx.ui.notify("[un-bien] Already started.", "warning");
197
+ return;
198
+ }
199
+ const lifecycleGeneration = ++deps.relayLifecycleGeneration;
200
+ const isCurrentCandidate = () => !deps.disposed &&
201
+ lifecycleGeneration === deps.relayLifecycleGeneration &&
202
+ deps.state === "idle" &&
203
+ deps.relay === null;
204
+ let edKp;
205
+ try {
206
+ edKp = await getOrCreateEd25519Keypair();
207
+ }
208
+ catch (err) {
209
+ // Identity lookup is part of the candidate lifecycle. A later stop/off or
210
+ // session replacement must silence its stale rejection before any UI or
211
+ // error propagation touches the superseded context.
212
+ if (!isCurrentCandidate())
213
+ return;
214
+ if (err instanceof KeyringUnavailableError) {
215
+ // The platform keyring (macOS Keychain / Windows Credential Manager) is
216
+ // locked/denied and there's no file identity to fall back to. We refuse
217
+ // to mint a new key (that's what silently broke pairing after idle), so
218
+ // abort cleanly with an actionable message instead of crashing or
219
+ // re-pairing. Unlocking the keychain and re-running fixes it.
220
+ ctx.ui.notify("[un-bien] Could not read this machine's identity: the system " +
221
+ "keychain is locked or access was denied. Unlock it (open the app / " +
222
+ "log in) and run /unbien again. Your pairing is NOT lost. " +
223
+ '(For a headless host, set "identity": { "storage": "file" } in un-bien.json.)', "error");
224
+ return;
225
+ }
226
+ if (err instanceof PairedIdentityMissingError) {
227
+ // Issues #95/#69: this process can't reach the keyring that holds the
228
+ // paired identity (classically a `systemd --user` daemon vs. the desktop
229
+ // session that paired). Minting a fresh key here would make SelfRevoke
230
+ // wipe peers.json seconds later and take the phone offline, so storage
231
+ // refuses. Surface the actionable fix instead of failing silently.
232
+ ctx.ui.notify("[un-bien] Could not read this machine's identity, but devices are " +
233
+ "already paired — refusing to generate a new one (that would revoke " +
234
+ "them). This process likely cannot reach the same keyring as the " +
235
+ "session that paired (e.g. the unbien-launcher systemd --user service). " +
236
+ "Give the service keyring access, or copy the paired keypair to " +
237
+ "~/.local/state/un-bien/identity.json " +
238
+ "(0600) so both contexts read the same identity.", "error");
239
+ return;
240
+ }
241
+ throw err;
242
+ }
243
+ // Re-check immediately after the first await, before cache/config/model/UI
244
+ // mutation or Relay construction. `deps.disposed` alone is insufficient because
245
+ // same-module session_start intentionally clears it for the replacement.
246
+ if (!isCurrentCandidate())
247
+ return;
248
+ deps.cachedEd25519 = edKp;
249
+ const { url: relayUrl, source } = resolveRelayUrl();
250
+ if (relayUrl === null) {
251
+ ctx.ui.notify("[un-bien] No relay configured — staying on the local mesh only. Set one " +
252
+ "with `/unbien set-relay <url>` (or the UNBIEN_RELAY env var) to connect " +
253
+ "the phone app.", "warning");
254
+ return;
255
+ }
256
+ const myShort = Buffer.from(edKp.publicKey).toString("base64").slice(0, 8);
257
+ const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
258
+ // Same name we send in pair_ok — keeps room_meta.name and the per-pair
259
+ // session_name aligned so the app shows consistent labels.
260
+ const sessionName = deps.displayName(cwd);
261
+ // design 01M1CAW0 (announce waits for the session id): the App↔Pi room is
262
+ // ALWAYS session-id-derived (rooms.ts). With no session id there is NO room
263
+ // to announce — the retired (cwd, name) fallback produced an id IDENTICAL
264
+ // for same-cwd processes, so a pre-session subprocess could announce the
265
+ // room an earlier session already occupies. Connecting room-less is not an
266
+ // option either: the relay registers a room-less hello into the shared
267
+ // "main" room, which surfaces in the app as a phantom session. So the WHOLE
268
+ // connect is deferred — the root session_start handler re-runs this start
269
+ // once the session id exists (see the relayStartDeferred re-arm in
270
+ // index.ts). The cwd lock (lockIdFor) is unaffected: lock ids are NOT room
271
+ // ids and stay (cwd, name)-keyed by design.
272
+ const roomId = deps.deriveRoomId(cwd, sessionName);
273
+ if (roomId === null) {
274
+ deps.relayStartDeferred = true;
275
+ ctx.ui.notify("[un-bien] No session id yet — refusing to announce a cwd-derived room " +
276
+ "(design 01M1CAW0). The relay will connect once this session has started.", "warning");
277
+ return;
278
+ }
279
+ // Seed the current model from the SDK's resolved selection so room_meta
280
+ // carries it on connect. `model_select` only fires on an explicit set/cycle
281
+ // (NOT on settings load), so a headless daemon that just runs its default
282
+ // model never emits it — without this its room_meta would omit the model and
283
+ // the app shows "unknown". `getModel()` returns the session's resolved model
284
+ // in every mode (interactive + RPC daemon); turn_start hydrates it later if
285
+ // the SDK resolves the model lazily.
286
+ if (!deps.currentModel) {
287
+ try {
288
+ const c = ctx;
289
+ // Prefer the live getModel() / ctx.model — populated for an interactive
290
+ // Pi. For a HEADLESS DAEMON both are undefined at connect: the SDK only
291
+ // resolves `this.model` lazily at the first turn, and `model_select`
292
+ // never fires for a default-model session. So fall back to the CONFIGURED
293
+ // default (defaultProvider/defaultModel in <cwd>/.pi/settings.json) — the
294
+ // model the daemon will actually use. Without this an idle daemon (never
295
+ // prompted → no turn) would never report its model and the app shows
296
+ // "unknown". turn_start still hydrates a later override.
297
+ const live = c.getModel?.() ?? c.model;
298
+ if (live) {
299
+ deps.currentModel = live.name ?? live.id ?? undefined;
300
+ }
301
+ else {
302
+ const sm = SettingsManager.create(cwd);
303
+ const provider = sm.getDefaultProvider();
304
+ const modelId = sm.getDefaultModel();
305
+ if (modelId) {
306
+ // SAFETY: ensureModelRegistry only reads modelRegistry/getModel off
307
+ // the ctx; c and the deps.lastEventCtx fallback carry those when
308
+ // present, and it tolerates null.
309
+ const regCtx = (c ?? deps.lastEventCtx);
310
+ const found = provider
311
+ ? ensureModelRegistry(regCtx).find(provider, modelId)
312
+ : undefined;
313
+ deps.currentModel = found?.name ?? modelId;
314
+ }
315
+ }
316
+ }
317
+ catch {
318
+ /* defensive — never block start on a model lookup */
319
+ }
320
+ }
321
+ // Plan/28 Wave D.1: seed thinking from the SDK's current level so the
322
+ // first room_meta hello already carries it. `pi.getThinkingLevel()` is
323
+ // safe at this point — extension factory has been bound by the SDK
324
+ // before any command handler fires. Future toggles go through the
325
+ // `thinking_level_select` event handler above.
326
+ try {
327
+ deps.currentThinking = deps.pi?.getThinkingLevel();
328
+ }
329
+ catch {
330
+ /* defensive — never block /unbien start on this */
331
+ }
332
+ const roomMeta = { name: sessionName, cwd };
333
+ // Advertise session caps on the room announce (design 01M1SJDZ) so the app
334
+ // learns remote_terminate etc. on DISCOVERY — the ub hello only reaches it
335
+ // after attach, so Home's End Chat gating was blind until you opened the
336
+ // chat. Same set as the hello (sessionCapabilities), reused on reconnect via
337
+ // deps.myRoomMeta below.
338
+ roomMeta.caps = sessionCapabilities();
339
+ const modelName = deps.currentModelName();
340
+ if (modelName)
341
+ roomMeta.model = modelName;
342
+ if (deps.currentThinking)
343
+ roomMeta.thinking = deps.currentThinking;
344
+ // The room's OWN pi sessionId on the announce — so the app keys per-session
345
+ // state by the pi id (wire identity), not the routing roomId. roomId stays
346
+ // relay-routing only.
347
+ const rootSid = deps.rootState().sessionManager?.getSessionId() ??
348
+ deps.rootSessionId ??
349
+ undefined;
350
+ if (rootSid)
351
+ roomMeta.sessionId = rootSid;
352
+ // Persist so _attemptReconnect can replay the same hello payload — without
353
+ // this, reconnect issues a bare hello and the relay creates a "default room"
354
+ // entry that surfaces in the app as a phantom legacy session.
355
+ deps.myRoomMeta = roomMeta;
356
+ ctx.ui.notify(`[un-bien] Connecting to relay ${relayUrl} (source: ${source}, room: ${roomId})…`, "info");
357
+ // Transport opens WebSocket; convert the canonical http(s):// stored
358
+ // form to ws(s):// at this boundary. The relayUrl variable keeps the
359
+ // http(s):// form for logging + mesh client construction below.
360
+ const relay = new RelayClient(toWebSocketUrl(relayUrl), edKp);
361
+ try {
362
+ await relay.connect({ roomId, roomMeta });
363
+ }
364
+ catch (err) {
365
+ // A rejected local candidate is never published and must always be closed,
366
+ // regardless of whether this lifecycle is still authoritative.
367
+ try {
368
+ relay.close();
369
+ }
370
+ catch {
371
+ /* best-effort rejected candidate cleanup */
372
+ }
373
+ // A stop, shutdown/replacement, relay-off, or newer start may supersede a
374
+ // candidate before its rejection arrives. Keep the outgoing context silent;
375
+ // only the authoritative attempt may report an error.
376
+ if (!isCurrentCandidate())
377
+ return;
378
+ if (err instanceof RoomAlreadyOpenError) {
379
+ ctx.ui.notify("[un-bien] Already running in this cwd. Stop the other terminal first.", "error");
380
+ return;
381
+ }
382
+ ctx.ui.notify(`[un-bien] relay connect failed: ${String(err)}`, "error");
383
+ return;
384
+ }
385
+ // The candidate is local until this publication point. Session shutdown,
386
+ // stop/relay-off, or a newer start may have invalidated it while connect()
387
+ // was pending; never let that stale continuation resurrect the Relay.
388
+ if (!isCurrentCandidate()) {
389
+ try {
390
+ relay.close();
391
+ }
392
+ catch {
393
+ /* best-effort stale candidate cleanup */
394
+ }
395
+ return;
396
+ }
397
+ deps.relay = relay;
398
+ deps.relayUrl = relayUrl;
399
+ deps.peerShort = myShort;
400
+ deps.myRoomId = roomId;
401
+ deps.state = "started";
402
+ // Set deps.sessionStartedAt ONLY on first /unbien start since process boot.
403
+ // Subsequent start cycles (after stop) preserve the original epoch so the
404
+ // app keeps treating it as the same session (and merges new events from
405
+ // the terminal turns that happened during the idle window). Pi process
406
+ // restart is the only thing that produces a fresh session_started_at.
407
+ if (deps.sessionStartedAt === null)
408
+ deps.sessionStartedAt = Date.now();
409
+ // _messageBuffer intentionally preserved across stop/start — it accumulates
410
+ // message_end events for the lifetime of the Pi process, including turns
411
+ // initiated from the terminal while the relay was disconnected.
412
+ relay.on("close", () => deps.onRelayClose(relay));
413
+ deps.stopAutoListener = deps.installAutoListener(relay);
414
+ deps.refreshFooter(ctx);
415
+ // SelfRevoke is the Pi path's single initial topology producer. Its first
416
+ // coalesced sweep always publishes verified membership or a safe fallback
417
+ // before the bridge may attach.
418
+ let createdProducer = false;
419
+ if (deps.selfRevoke === null) {
420
+ createdProducer = true;
421
+ const producerEpoch = ++deps.selfRevokeEpoch;
422
+ deps.selfRevokeTopologyReadyEpoch = -1;
423
+ deps.selfRevokeTopology = null;
424
+ let producer;
425
+ producer = new SelfRevoke({
426
+ client: new MeshClient(relayUrl),
427
+ storage: { snapshotOwnerPubkeys, conditionalRemovePeer },
428
+ myPubkey: edKp.publicKey,
429
+ onRevoke: (rawOwnerPubkey, canonicalOwnerPubkey) => {
430
+ if (deps.selfRevoke !== producer ||
431
+ producerEpoch !== deps.selfRevokeEpoch) {
432
+ return;
433
+ }
434
+ deps.revokeActiveOwnerRuntime(canonicalOwnerPubkey);
435
+ void rawOwnerPubkey; // exact storage removal already happened upstream
436
+ },
437
+ onAuthoritativeOwners: (canonicalOwnerPubkeys) => {
438
+ if (deps.selfRevoke !== producer ||
439
+ producerEpoch !== deps.selfRevokeEpoch) {
440
+ return;
441
+ }
442
+ const presentOwners = new Set(canonicalOwnerPubkeys);
443
+ let effectFailed = false;
444
+ for (const canonicalOwnerPubkey of [...deps.activePeers.keys()]) {
445
+ if (deps.selfRevoke !== producer ||
446
+ producerEpoch !== deps.selfRevokeEpoch) {
447
+ return;
448
+ }
449
+ if (presentOwners.has(canonicalOwnerPubkey))
450
+ continue;
451
+ try {
452
+ deps.revokeActiveOwnerRuntime(canonicalOwnerPubkey);
453
+ }
454
+ catch {
455
+ effectFailed = true;
456
+ }
457
+ }
458
+ if (effectFailed)
459
+ throw new Error("Owner runtime reconciliation failed");
460
+ },
461
+ onTopologyChanged: (snapshot) => {
462
+ if (deps.selfRevoke !== producer ||
463
+ producerEpoch !== deps.selfRevokeEpoch) {
464
+ return;
465
+ }
466
+ deps.selfRevokeTopology = snapshot;
467
+ deps.meshNode?.setTopology(snapshot);
468
+ deps.selfRevokeTopologyReadyEpoch = producerEpoch;
469
+ deps.attachBridgeIfReady();
470
+ },
471
+ log: { info: () => { }, warn: () => { }, error: () => { } },
472
+ });
473
+ deps.selfRevoke = producer;
474
+ producer.start();
475
+ await producer.checkOnce();
476
+ if (deps.disposed ||
477
+ deps.selfRevoke !== producer ||
478
+ producerEpoch !== deps.selfRevokeEpoch ||
479
+ deps.relay !== relay) {
480
+ return;
481
+ }
482
+ }
483
+ // Relay reconnect reuses the current producer's retained snapshot. Initial
484
+ // startup is callback-driven above, so it must not issue a second attach.
485
+ if (!createdProducer)
486
+ deps.attachBridgeIfReady();
487
+ deps.emitRelayState(); // → connected
488
+ ctx.ui.notify(`[un-bien] state: started (peer=${myShort}) — Connected to relay ${relayUrl}`, "info");
489
+ }
490
+ /**
491
+ * `/unbien stop` — full teardown. Leaves the local UDS mesh AND closes
492
+ * the relay. Safe when one or both are already off. To resume, run
493
+ * `/unbien` again.
494
+ */
495
+ export async function _cmdStop(deps, ctx) {
496
+ // Invalidate queued root work and local async candidates even when none has
497
+ // published yet.
498
+ deps.rootLifecycleGeneration += 1;
499
+ deps.meshJoinGeneration += 1;
500
+ const meshUp = deps.meshNode !== null;
501
+ const relayUp = deps.state !== "idle";
502
+ if (!meshUp && !relayUp) {
503
+ deps.relayLifecycleGeneration += 1;
504
+ ctx.ui.notify("[un-bien] Already stopped — nothing to do.", "info");
505
+ return;
506
+ }
507
+ // Revoke Relay/SelfRevoke/bridge authority while the global node is still
508
+ // visible and before close() begins UDS leave.
509
+ if (relayUp) {
510
+ deps.goIdle();
511
+ }
512
+ else {
513
+ deps.relayLifecycleGeneration += 1;
514
+ deps.meshNode?.detachBridge();
515
+ }
516
+ const meshNode = deps.meshNode;
517
+ deps.meshNode = null;
518
+ deps.sessionName = null;
519
+ deps.sessionPeerCount = 0;
520
+ let meshClose = null;
521
+ try {
522
+ meshClose = meshNode?.close() ?? null;
523
+ }
524
+ catch {
525
+ /* best-effort */
526
+ }
527
+ try {
528
+ await meshClose;
529
+ }
530
+ catch {
531
+ /* best-effort */
532
+ }
533
+ ctx.ui.notify("[un-bien] Stopped (mesh + relay disconnected).", "info");
534
+ deps.refreshFooter(ctx);
535
+ }
536
+ /**
537
+ * Joins the fixed local UDS mesh ("local" session — see LOCAL_SESSION_NAME).
538
+ * Called by `_cmdRoot` on first run and on subsequent runs when the relay
539
+ * is up and the user hasn't explicitly stopped. The session name is no
540
+ * longer user-configurable: every Pi on the same machine joins the same
541
+ * broker.
542
+ */
543
+ export async function _cmdJoin(deps, ctx) {
544
+ const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
545
+ const sessionName = LOCAL_SESSION_NAME;
546
+ // What the user configured for this agent… (a session-scoped `pi -n` name
547
+ // from a remote launch wins — see resolveAgentName)
548
+ const requestedName = resolveAgentName(cwd, piSessionName(deps.pi));
549
+ // `requestedName` or a `#N` variant when same-named agents share this folder.
550
+ // Falls back to requestedName when join runs without a prior `_cmdRoot` lock
551
+ // (e.g. legacy/test paths).
552
+ const agentName = deps.lockedName ?? requestedName;
553
+ if (deps.meshNode) {
554
+ ctx.ui.notify("[un-bien] Already on the local mesh.", "warning");
555
+ return;
556
+ }
557
+ const joinGeneration = ++deps.meshJoinGeneration;
558
+ ensureGlobalDirs();
559
+ mkdirSync(join(skillsDir(), "..", "sessions", sessionName), {
560
+ recursive: true,
561
+ });
562
+ const sock = sessionSockPath(sessionName);
563
+ const audit = sessionAuditPath(sessionName);
564
+ // Forward the cwd so the broker keys this peer by (cwd, name): a same-folder
565
+ // same-name reincarnation (switch_session re-eval, app restart) takes over the
566
+ // name instead of registering behind a mute `name#N` ghost. Canonicalize via
567
+ // realpath so symlinked cwds map to one identity (matches roomIdForCwd).
568
+ let canonCwd = cwd;
569
+ try {
570
+ canonCwd = realpathSync(cwd);
571
+ }
572
+ catch {
573
+ /* cwd missing — use raw path */
574
+ }
575
+ const peer = new MeshNode({
576
+ sockPath: sock,
577
+ name: agentName,
578
+ cwd: canonCwd,
579
+ auditPath: audit,
580
+ takeoverExisting: process.env["UNBIEN_DAEMON"] === "1",
581
+ });
582
+ peer.onMessage((env) => {
583
+ const body = env.body;
584
+ // Broker system events: re-query broker for authoritative count.
585
+ // Incremental ±1 drifts when peer_left is missed (leader leaves cleanly,
586
+ // failover, etc.) — querying list_peers makes the count self-healing.
587
+ if (body && (body.type === "peer_joined" || body.type === "peer_left")) {
588
+ deps.refreshSessionPeerCount(peer, ctx);
589
+ // Plan/25 Wave B: push fresh peer list to all siblings so their
590
+ // remotePeers cache stays current without polling.
591
+ void peer
592
+ .request("broker", { type: "list_peers" }, 2000)
593
+ .then((reply) => {
594
+ const body = reply.body;
595
+ // onLocalPeersChanged wants LOCAL-only addresses (list_peers returns
596
+ // the aggregated local + cross-PC roster). Prefer the structured
597
+ // roster (plan/38): a local peer has no `pc`. This is drive-letter
598
+ // safe — a Windows local address `C:\…@app` contains ':' but is NOT
599
+ // remote, so the old naive `!p.includes(":")` misclassified it.
600
+ let local = null;
601
+ const detailed = body?.peers_detailed;
602
+ if (Array.isArray(detailed)) {
603
+ local = detailed
604
+ .filter((p) => !p.pc && typeof p.address === "string")
605
+ .map((p) => p.address);
606
+ }
607
+ else if (Array.isArray(body?.peers)) {
608
+ // Fallback for a legacy broker without `peers_detailed`.
609
+ local = body.peers.filter((p) => !p.includes(":"));
610
+ }
611
+ // No-op when the bridge isn't up (follower / relay down).
612
+ if (local)
613
+ peer.onLocalPeersChanged(local);
614
+ })
615
+ .catch(() => {
616
+ /* bridge not bound yet, or list_peers failed */
617
+ });
618
+ return;
619
+ }
620
+ if (env.from === "broker")
621
+ return; // other broker control messages — ignore
622
+ // Real agent-to-agent message (SessionPeer already correlated replies via
623
+ // env.re before this point). Show it in the app's TOOL timeline and wake
624
+ // the agent as a CUSTOM message — never as the user's own message.
625
+ deps.deliverMeshMessageToAgent(env);
626
+ });
627
+ // After failover (leader died, we re-elected): the new broker's peers map
628
+ // starts fresh, but our cached `deps.sessionPeerCount` is stale. Re-seed it so
629
+ // surviving peers don't carry the pre-failover count forever.
630
+ //
631
+ // The cross-PC bridge re-attach on failover (drop the stale broker ref,
632
+ // re-wire against the fresh `localBroker()` if we were promoted to leader)
633
+ // is handled INSIDE MeshNode — no manual teardown/ensure needed here.
634
+ peer.onReconnect(() => {
635
+ deps.refreshSessionPeerCount(peer, ctx);
636
+ });
637
+ const isCurrentCandidate = () => !deps.disposed &&
638
+ joinGeneration === deps.meshJoinGeneration &&
639
+ deps.meshNode === null;
640
+ try {
641
+ const assigned = await peer.connect();
642
+ // The candidate stays local until connect resolves. Shutdown, stop, or a
643
+ // newer join invalidates its generation; close it instead of publishing a
644
+ // ghost peer or allowing _cmdRoot to continue into Relay startup.
645
+ if (!isCurrentCandidate()) {
646
+ try {
647
+ await peer.close();
648
+ }
649
+ catch {
650
+ /* best-effort */
651
+ }
652
+ return;
653
+ }
654
+ deps.meshNode = peer;
655
+ deps.sessionName = sessionName;
656
+ deps.sessionPeerCount = 1; // optimistic — overwritten by list_peers below
657
+ // Broker broadcasts `peer_joined` only to existing peers when a new one
658
+ // arrives — the newcomer doesn't get retroactive joined events. Ask the
659
+ // broker for the live peer list to seed the count correctly on join.
660
+ deps.refreshSessionPeerCount(peer, ctx);
661
+ // Tell RPC clients (e.g. Cockpit) the EFFECTIVE mesh name. The broker
662
+ // appends a `#N` suffix only on a same-(cwd,name) collision, so the name we
663
+ // requested and the one actually assigned can differ. Emit a pure-data event
664
+ // (display:false) carrying both + a `changed` flag so the client can rename
665
+ // the agent in its own UI to match what the mesh/relay will show. Fired on
666
+ // every join (incl. failover re-elect, which can re-assign the name), so the
667
+ // client always reflects the live name, not just the first one.
668
+ //
669
+ // plan/38 decision E: we deliberately DO NOT persist `assigned`. A `#N` is a
670
+ // RUNTIME collision resolution; freezing it into `agent_name` fossilizes an
671
+ // accident and causes cross-folder name ping-pong across restarts. The clean
672
+ // name (wizard / explicit `agent_name`) already lives in config or re-derives
673
+ // from `basename(cwd)`; the event above carries the live `#N` for the UI.
674
+ deps.pi?.sendMessage({
675
+ customType: "un-bien:name-assigned",
676
+ content: assigned === requestedName
677
+ ? `Mesh name: ${assigned}`
678
+ : `Mesh name reassigned: "${requestedName}" → "${assigned}" (collision)`,
679
+ details: {
680
+ requested: requestedName,
681
+ assigned,
682
+ changed: assigned !== requestedName,
683
+ },
684
+ display: false,
685
+ });
686
+ ctx.ui.notify(`[un-bien] Joined local mesh as "${assigned}" (${peer.currentRole()})`, "info");
687
+ deps.refreshFooter(ctx);
688
+ // Plan/25 Wave B/C: try to bring up cross-PC routing now that the
689
+ // local broker exists. No-op if the relay isn't up yet (will fire
690
+ // again from `_cmdStart`).
691
+ deps.attachBridgeIfReady();
692
+ }
693
+ catch (err) {
694
+ // A replacement/stop/newer join can invalidate this candidate before its
695
+ // failure arrives. Clean it up and never notify the outgoing session ctx.
696
+ if (!isCurrentCandidate()) {
697
+ try {
698
+ await peer.close();
699
+ }
700
+ catch {
701
+ /* best-effort */
702
+ }
703
+ return;
704
+ }
705
+ ctx.ui.notify(`[un-bien] join failed: ${String(err)}`, "error");
706
+ }
707
+ }
708
+ //# sourceMappingURL=lifecycle.js.map