@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.
- package/README.md +100 -131
- package/dist/actions/handlers.js.map +1 -1
- package/dist/actions/registry.js.map +1 -1
- package/dist/bin/launcher.js.map +1 -1
- package/dist/client.d.ts +15 -0
- package/dist/client.js +15 -0
- package/dist/client.js.map +1 -0
- package/dist/commands/deps.d.ts +156 -0
- package/dist/commands/deps.js +2 -0
- package/dist/commands/deps.js.map +1 -0
- package/dist/commands/fork_link.d.ts +4 -0
- package/dist/commands/fork_link.js +45 -0
- package/dist/commands/fork_link.js.map +1 -0
- package/dist/commands/housekeeping.d.ts +34 -0
- package/dist/commands/housekeeping.js +275 -0
- package/dist/commands/housekeeping.js.map +1 -0
- package/dist/commands/info.d.ts +43 -0
- package/dist/commands/info.js +127 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/lifecycle.d.ts +45 -0
- package/dist/commands/lifecycle.js +708 -0
- package/dist/commands/lifecycle.js.map +1 -0
- package/dist/commands/pairing.d.ts +26 -0
- package/dist/commands/pairing.js +167 -0
- package/dist/commands/pairing.js.map +1 -0
- package/dist/commands/register.d.ts +12 -0
- package/dist/commands/register.js +255 -0
- package/dist/commands/register.js.map +1 -0
- package/dist/commands/relay.d.ts +28 -0
- package/dist/commands/relay.js +81 -0
- package/dist/commands/relay.js.map +1 -0
- package/dist/commands/session_ops.d.ts +52 -0
- package/dist/commands/session_ops.js +75 -0
- package/dist/commands/session_ops.js.map +1 -0
- package/dist/config.d.ts +11 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon/install.d.ts +6 -5
- package/dist/daemon/install.js +15 -5
- package/dist/daemon/install.js.map +1 -1
- package/dist/enrich_tool_args.js.map +1 -1
- package/dist/extension_ui_bridge.js.map +1 -1
- package/dist/image_codec.js.map +1 -1
- package/dist/index.d.ts +32 -101
- package/dist/index.js +852 -2872
- package/dist/index.js.map +1 -1
- package/dist/launch.d.ts +14 -1
- package/dist/launch.js +55 -5
- package/dist/launch.js.map +1 -1
- package/dist/launcher/launcher.js +48 -6
- package/dist/launcher/launcher.js.map +1 -1
- package/dist/mcp/mesh_result.js.map +1 -1
- package/dist/mcp/mesh_server.js.map +1 -1
- package/dist/mesh/canonical.js +1 -1
- package/dist/mesh/canonical.js.map +1 -1
- package/dist/mesh/client.js.map +1 -1
- package/dist/mesh/encoding.js.map +1 -1
- package/dist/mesh/self_revoke.js.map +1 -1
- package/dist/mesh/siblings.js.map +1 -1
- package/dist/mesh/verify.js +1 -1
- package/dist/mesh/verify.js.map +1 -1
- package/dist/pairing/crypto.js +0 -1
- package/dist/pairing/crypto.js.map +1 -1
- package/dist/pairing/peer_trust.js.map +1 -1
- package/dist/pairing/qr.js.map +1 -1
- package/dist/pairing/storage.d.ts +7 -0
- package/dist/pairing/storage.js +22 -3
- package/dist/pairing/storage.js.map +1 -1
- package/dist/panel_bridge.js +1 -0
- package/dist/panel_bridge.js.map +1 -1
- package/dist/paths.d.ts +22 -8
- package/dist/paths.js +37 -10
- package/dist/paths.js.map +1 -1
- package/dist/protocol/codec.js.map +1 -1
- package/dist/rooms.js +1 -4
- package/dist/rooms.js.map +1 -1
- package/dist/session/bridge.js.map +1 -1
- package/dist/session/broker.js.map +1 -1
- package/dist/session/broker_remote.js.map +1 -1
- package/dist/session/capabilities.d.ts +11 -0
- package/dist/session/capabilities.js +35 -0
- package/dist/session/capabilities.js.map +1 -0
- package/dist/session/cwd_lock.js +3 -3
- package/dist/session/cwd_lock.js.map +1 -1
- package/dist/session/debug_log.js +13 -3
- package/dist/session/debug_log.js.map +1 -1
- package/dist/session/envelope.js.map +1 -1
- package/dist/session/global_config.d.ts +1 -1
- package/dist/session/global_config.js +1 -1
- package/dist/session/global_config.js.map +1 -1
- package/dist/session/ipc.d.ts +2 -2
- package/dist/session/ipc.js.map +1 -1
- package/dist/session/leader_election.js.map +1 -1
- package/dist/session/local_config.d.ts +37 -0
- package/dist/session/local_config.js +45 -1
- package/dist/session/local_config.js.map +1 -1
- package/dist/session/mesh_node.d.ts +1 -1
- package/dist/session/mesh_node.js +1 -1
- package/dist/session/mesh_node.js.map +1 -1
- package/dist/session/peer.js.map +1 -1
- package/dist/session/peer_inventory.js.map +1 -1
- package/dist/session/peer_limits.js.map +1 -1
- package/dist/session/received_images.d.ts +63 -0
- package/dist/session/received_images.js +273 -0
- package/dist/session/received_images.js.map +1 -0
- package/dist/session/relay_lifecycle.d.ts +223 -0
- package/dist/session/relay_lifecycle.js +802 -0
- package/dist/session/relay_lifecycle.js.map +1 -0
- package/dist/session/rpc_envelope.d.ts +10 -0
- package/dist/session/rpc_envelope.js +9 -0
- package/dist/session/rpc_envelope.js.map +1 -1
- package/dist/session/rpc_handlers.d.ts +53 -0
- package/dist/session/rpc_handlers.js +268 -0
- package/dist/session/rpc_handlers.js.map +1 -0
- package/dist/session/rpc_inbound.d.ts +41 -6
- package/dist/session/rpc_inbound.js +55 -0
- package/dist/session/rpc_inbound.js.map +1 -1
- package/dist/session/setup_wizard.js.map +1 -1
- package/dist/session/tools.js.map +1 -1
- package/dist/session/wizard.js.map +1 -1
- package/dist/state_migration.d.ts +45 -0
- package/dist/state_migration.js +104 -0
- package/dist/state_migration.js.map +1 -0
- package/dist/subagent_rooms.d.ts +19 -0
- package/dist/subagent_rooms.js +146 -14
- package/dist/subagent_rooms.js.map +1 -1
- package/dist/test_hooks.d.ts +180 -0
- package/dist/test_hooks.js +98 -0
- package/dist/test_hooks.js.map +1 -0
- package/dist/transport/peer_channel.d.ts +11 -0
- package/dist/transport/peer_channel.js +34 -11
- package/dist/transport/peer_channel.js.map +1 -1
- package/dist/transport/pi_forward_client.js.map +1 -1
- package/dist/transport/relay_client.d.ts +14 -3
- package/dist/transport/relay_client.js +6 -3
- package/dist/transport/relay_client.js.map +1 -1
- package/dist/ui/footer.js.map +1 -1
- package/docs/daemon.md +166 -176
- package/package.json +6 -2
- package/service-templates/launchd.plist.template +5 -4
- 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
|