@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
package/dist/index.js
CHANGED
|
@@ -30,54 +30,39 @@
|
|
|
30
30
|
* for integration tests.
|
|
31
31
|
*/
|
|
32
32
|
import { randomUUID } from "node:crypto";
|
|
33
|
-
import {
|
|
34
|
-
import { buildQRUri, qrSession, renderQRAscii, clampPairTtlMs, TOKEN_TTL_MS, } from "./pairing/qr.js";
|
|
35
|
-
import { addPeer, getOrCreateEd25519Keypair, describeIdentity, KeyringUnavailableError, PairedIdentityMissingError, listPeers, removePeer, snapshotOwnerPubkeys, conditionalRemovePeer, } from "./pairing/storage.js";
|
|
36
|
-
import { MeshClient } from "./mesh/client.js";
|
|
37
|
-
import { SelfRevoke } from "./mesh/self_revoke.js";
|
|
38
|
-
import { RelayClient, RoomAlreadyOpenError } from "./transport/relay_client.js";
|
|
39
|
-
import { PlainPeerChannel } from "./transport/peer_channel.js";
|
|
33
|
+
import { listPeers, pairingAllowList } from "./pairing/storage.js";
|
|
40
34
|
import { createExtensionUiBridge, } from "./extension_ui_bridge.js";
|
|
41
35
|
import { createPanelBridge } from "./panel_bridge.js";
|
|
42
36
|
import { initSubagentRooms, subagentRoomsEnabled, } from "./subagent_rooms.js";
|
|
43
|
-
import { createRpcEnvelope,
|
|
44
|
-
import { dispatchRpcCommand
|
|
37
|
+
import { createRpcEnvelope, } from "./session/rpc_envelope.js";
|
|
38
|
+
import { dispatchRpcCommand } from "./session/rpc_inbound.js";
|
|
45
39
|
import { envLog } from "./session/debug_log.js";
|
|
46
|
-
import {
|
|
40
|
+
import { roomIdForSession } from "./rooms.js";
|
|
47
41
|
import { registerAgentTools } from "./session/tools.js";
|
|
48
42
|
import { formatPeerInventory } from "./session/peer_inventory.js";
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
import { ensureModelRegistry } from "./actions/registry.js";
|
|
52
|
-
import { ensureGlobalDirs, LOCAL_SESSION_NAME, sessionAuditPath, sessionSockPath, skillsDir, } from "./session/global_config.js";
|
|
53
|
-
import { acquireCwdLock } from "./session/cwd_lock.js";
|
|
54
|
-
import { installService, uninstallService, linkCliBinaries, unlinkCliBinaries, } from "./daemon/install.js";
|
|
55
|
-
import { defaultAgentName, effectiveAutoStartRelay, effectiveAllowRemoteLaunch, loadLocalConfig, localConfigExists, saveLocalConfig, } from "./session/local_config.js";
|
|
56
|
-
import { runSetupWizard } from "./session/setup_wizard.js";
|
|
43
|
+
import { ensureGlobalDirs, LOCAL_SESSION_NAME, sessionSockPath, skillsDir, } from "./session/global_config.js";
|
|
44
|
+
import { effectiveAutoStartRelay, effectiveAllowRemoteLaunch, effectiveAllowRemoteTerminate, loadLocalConfig, localConfigExists, piSessionName, resolveAgentName, saveLocalConfig, } from "./session/local_config.js";
|
|
57
45
|
import { updateFooter } from "./ui/footer.js";
|
|
58
|
-
import { join, dirname, resolve } from "node:path";
|
|
59
46
|
import { fileURLToPath } from "node:url";
|
|
60
|
-
import {
|
|
61
|
-
import {
|
|
62
|
-
import {
|
|
63
|
-
import { hostname, tmpdir } from "node:os";
|
|
64
|
-
import { resolveRelayUrl, loadConfig, saveConfig, isValidRelayUrl, isWebSocketScheme, toWebSocketUrl, } from "./config.js";
|
|
65
|
-
import { _expandTilde, _launchSession } from "./launch.js";
|
|
47
|
+
import { realpathSync } from "node:fs";
|
|
48
|
+
import { loadConfig, saveConfig, isValidRelayUrl, isWebSocketScheme, } from "./config.js";
|
|
49
|
+
import { _expandTilde, _launchSession, launchDirAllowed } from "./launch.js";
|
|
66
50
|
import { _enrichToolArgs } from "./enrich_tool_args.js";
|
|
67
|
-
import {
|
|
68
|
-
import {
|
|
69
|
-
import {
|
|
51
|
+
import { _inspectPeerRecord, _runtimeOwnerFingerprint, } from "./pairing/peer_trust.js";
|
|
52
|
+
import { _flushPendingReceivedImagePreviews, _isReceivedImageContextMessage, _registerReceivedImageRenderer, } from "./session/received_images.js";
|
|
53
|
+
import { _cmdRoot, _cmdStart, } from "./commands/lifecycle.js";
|
|
54
|
+
import { _cmdClaudeCli, _cmdInstall, _cmdUninstall, _deployAgentNetworkSkill, } from "./commands/housekeeping.js";
|
|
55
|
+
import { registerUnbienCommands } from "./commands/register.js";
|
|
56
|
+
import { takeForkLink } from "./commands/fork_link.js";
|
|
57
|
+
import { createTestHooks } from "./test_hooks.js";
|
|
58
|
+
import { createRpcHandlers, } from "./session/rpc_handlers.js";
|
|
59
|
+
import { CTRL_PREFIX, _anyPeerActive, _broadcastEnvelope, _broadcastEnvelopeFlushed, _controlCtx, _detachPeerChannel, _emitRelayState, _getReconnectTimer, _getRelayLifecycleGeneration, _goIdle, _handleControl as _handleControlImpl, _headlessUi, _installAutoListener, _onPeerDisconnect as _onPeerDisconnectImpl, _onRelayClose, _panelBroadcast, _relayStatus, _setRelayLifecycleGeneration, _uiBroadcast, } from "./session/relay_lifecycle.js";
|
|
60
|
+
// Relay-lifecycle seam re-exports: the control-channel sentinel + the
|
|
61
|
+
// connectivity type moved to ./session/relay_lifecycle.ts; these keep the
|
|
62
|
+
// exact export surface (tests import CTRL_PREFIX from here).
|
|
63
|
+
export { CTRL_PREFIX };
|
|
70
64
|
let _state = "idle";
|
|
71
65
|
let _relay = null;
|
|
72
|
-
/** Last `RelayConnectivity` emitted, for change-dedup. Starts "disconnected"
|
|
73
|
-
* (the process boots with the relay down). */
|
|
74
|
-
let _lastRelayStatus = null;
|
|
75
|
-
/** Sentinel prefix for a transparent control message an RPC client sends on the
|
|
76
|
-
* `prompt` channel (stdin). The `input` hook intercepts it, runs the action,
|
|
77
|
-
* and swallows it (`action:"handled"`) so it never becomes an LLM turn or a
|
|
78
|
-
* transcript entry. Starts with NUL so it can't collide with real user input
|
|
79
|
-
* and doesn't begin with "/" (which would route to the command parser). */
|
|
80
|
-
export const CTRL_PREFIX = "\x00un-bien-ctrl:";
|
|
81
66
|
let _relayUrl = null; // URL used by current _relay connection
|
|
82
67
|
/**
|
|
83
68
|
* Owners currently connected via the relay. Key = app peer pubkey (Ed25519,
|
|
@@ -93,16 +78,24 @@ let _relayUrl = null; // URL used by current _relay connection
|
|
|
93
78
|
*/
|
|
94
79
|
const _activePeers = new Map();
|
|
95
80
|
let _peerShort = ""; // shortid of the most recently attached peer (UX hint only)
|
|
96
|
-
const UNBIEN_RECEIVED_IMAGE_TYPE = "un-bien:received-image";
|
|
97
|
-
const _pendingReceivedImagePreviews = [];
|
|
98
81
|
let _myRoomId = null; // this Pi's room id (derived from the session id)
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
82
|
+
/** True when a relay start was DEFERRED because no session id existed yet
|
|
83
|
+
* (design 01M1CAW0). The root session_start handler re-runs the start once
|
|
84
|
+
* the session id becomes available. */
|
|
85
|
+
let _relayStartDeferred = false;
|
|
86
|
+
/** THE App<->Pi room id for the current chat session: ALWAYS derived from the
|
|
87
|
+
* STABLE pi session id (durable across resume) so the room can't drift when
|
|
88
|
+
* the assigned name changes on reconnect. Returns null when no session id
|
|
89
|
+
* exists yet — the legacy (cwd, name) fallback is RETIRED (design 01M1CAW0):
|
|
90
|
+
* it was identical for same-cwd processes, so a pre-session subprocess could
|
|
91
|
+
* announce the SAME room an earlier session occupies (and a parent's keeper
|
|
92
|
+
* then stamped set-once parentage onto that shared room). Callers must SKIP
|
|
93
|
+
* the room announce/join on null — never guess a cwd-derived room id.
|
|
94
|
+
* `cwd`/`name` are still accepted because callers derive them for room_meta
|
|
95
|
+
* labels, but they no longer key the room. */
|
|
96
|
+
function _deriveRoomId(_cwd, _name) {
|
|
104
97
|
const sid = _rootState().sessionManager?.getSessionId();
|
|
105
|
-
return sid ? roomIdForSession(sid) :
|
|
98
|
+
return sid ? roomIdForSession(sid) : null;
|
|
106
99
|
}
|
|
107
100
|
// Plan/28 Wave D.1: `thinking` published alongside `model` so the app's
|
|
108
101
|
// Quick Actions sheet hydrates the thinking segmented control on first
|
|
@@ -144,6 +137,12 @@ function _refreshPairingsCache() {
|
|
|
144
137
|
void listPeers()
|
|
145
138
|
.then((peers) => {
|
|
146
139
|
_hasGlobalPairings = peers.length > 0;
|
|
140
|
+
// Keep the relay's ROOMS-gate allow-list in sync on every pairing change
|
|
141
|
+
// (pair / unpair / revoke). Design 01M1ZE43; no-ops when the relay is down.
|
|
142
|
+
_relay?.sendControl({
|
|
143
|
+
type: "pairing_set",
|
|
144
|
+
owners: pairingAllowList(peers),
|
|
145
|
+
});
|
|
147
146
|
_refreshFooter();
|
|
148
147
|
})
|
|
149
148
|
.catch(() => {
|
|
@@ -211,193 +210,6 @@ function _publishWorking(working) {
|
|
|
211
210
|
});
|
|
212
211
|
}
|
|
213
212
|
}
|
|
214
|
-
function _sendReceivedImagePreviewNow(details) {
|
|
215
|
-
if (!_pi)
|
|
216
|
-
return;
|
|
217
|
-
try {
|
|
218
|
-
_pi.sendMessage({
|
|
219
|
-
customType: UNBIEN_RECEIVED_IMAGE_TYPE,
|
|
220
|
-
content: "",
|
|
221
|
-
display: true,
|
|
222
|
-
details,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
catch {
|
|
226
|
-
// TUI preview is best-effort; skip on failure.
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
function _shouldDeferReceivedImagePreview() {
|
|
230
|
-
return _rootState().turnId !== null || _myRoomMeta?.working === true;
|
|
231
|
-
}
|
|
232
|
-
function _sendReceivedImagePreview(details, delivery = "immediate") {
|
|
233
|
-
if (delivery === "defer" || _shouldDeferReceivedImagePreview()) {
|
|
234
|
-
_pendingReceivedImagePreviews.push(details);
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
_sendReceivedImagePreviewNow(details);
|
|
238
|
-
}
|
|
239
|
-
function _flushPendingReceivedImagePreviews() {
|
|
240
|
-
if (_pendingReceivedImagePreviews.length === 0)
|
|
241
|
-
return;
|
|
242
|
-
const pending = _pendingReceivedImagePreviews.splice(0);
|
|
243
|
-
for (const details of pending)
|
|
244
|
-
_sendReceivedImagePreviewNow(details);
|
|
245
|
-
}
|
|
246
|
-
async function _collectReceivedImagePreviews(msg) {
|
|
247
|
-
if (!msg.images || msg.images.length === 0)
|
|
248
|
-
return [];
|
|
249
|
-
const previews = [];
|
|
250
|
-
const text = typeof msg.text === "string" ? msg.text : "";
|
|
251
|
-
const dir = _imageCacheRootDir();
|
|
252
|
-
for (let i = 0; i < msg.images.length; i += 1) {
|
|
253
|
-
const image = msg.images[i];
|
|
254
|
-
const mime = typeof image?.mime === "string" ? image.mime : "unknown";
|
|
255
|
-
if (!image || typeof image.data !== "string") {
|
|
256
|
-
console.error(`[un-bien] malformed image in message ${msg.id} index=${i}`);
|
|
257
|
-
previews.push({
|
|
258
|
-
messageId: msg.id,
|
|
259
|
-
index: i,
|
|
260
|
-
mime,
|
|
261
|
-
...(text ? { text } : {}),
|
|
262
|
-
error: "malformed image payload",
|
|
263
|
-
reason: "missing mime/data payload fields",
|
|
264
|
-
});
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
const decoded = _decodeImagePayload(image.data, image.mime);
|
|
268
|
-
if (!decoded.ok) {
|
|
269
|
-
console.error(`[un-bien] skipped image id=${msg.id} index=${i}: ${decoded.reason}`);
|
|
270
|
-
previews.push({
|
|
271
|
-
messageId: msg.id,
|
|
272
|
-
index: i,
|
|
273
|
-
mime: image.mime,
|
|
274
|
-
...(text ? { text } : {}),
|
|
275
|
-
error: "invalid image payload",
|
|
276
|
-
reason: decoded.reason,
|
|
277
|
-
});
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
const ext = _imageExtension(image.mime);
|
|
281
|
-
if (!ext) {
|
|
282
|
-
console.error(`[un-bien] unsupported image mime in message ${msg.id} index=${i}: ${image.mime}`);
|
|
283
|
-
previews.push({
|
|
284
|
-
messageId: msg.id,
|
|
285
|
-
index: i,
|
|
286
|
-
mime: image.mime,
|
|
287
|
-
...(text ? { text } : {}),
|
|
288
|
-
error: "invalid image payload",
|
|
289
|
-
reason: `unsupported mime: ${image.mime}`,
|
|
290
|
-
});
|
|
291
|
-
continue;
|
|
292
|
-
}
|
|
293
|
-
const filename = `${_safeFilenameToken(msg.id)}-${i}.${ext}`;
|
|
294
|
-
const path = join(dir, filename);
|
|
295
|
-
try {
|
|
296
|
-
writeFileSync(path, decoded.decoded, { mode: 0o600 });
|
|
297
|
-
try {
|
|
298
|
-
chmodSync(path, 0o600);
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
/* best-effort permission hardening */
|
|
302
|
-
}
|
|
303
|
-
const previewPath = image.mime === IMAGE_PREVIEW_MIME
|
|
304
|
-
? undefined
|
|
305
|
-
: await _renderablePngPathFromImage(image.data, image.mime, _safePreviewPath(dir, msg.id, i));
|
|
306
|
-
previews.push({
|
|
307
|
-
messageId: msg.id,
|
|
308
|
-
index: i,
|
|
309
|
-
mime: image.mime,
|
|
310
|
-
size: decoded.size,
|
|
311
|
-
path,
|
|
312
|
-
...(previewPath ? { previewPath } : {}),
|
|
313
|
-
...(text ? { text } : {}),
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
catch (error) {
|
|
317
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
318
|
-
console.error(`[un-bien] failed saving image id=${msg.id} index=${i}: ${detail}`);
|
|
319
|
-
previews.push({
|
|
320
|
-
messageId: msg.id,
|
|
321
|
-
index: i,
|
|
322
|
-
mime: image.mime,
|
|
323
|
-
...(text ? { text } : {}),
|
|
324
|
-
path,
|
|
325
|
-
error: "failed to save image",
|
|
326
|
-
reason: detail,
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
return previews;
|
|
331
|
-
}
|
|
332
|
-
async function _emitReceivedImagePreviews(msg, delivery = "immediate") {
|
|
333
|
-
const previews = await _collectReceivedImagePreviews(msg);
|
|
334
|
-
for (const preview of previews)
|
|
335
|
-
_sendReceivedImagePreview(preview, delivery);
|
|
336
|
-
}
|
|
337
|
-
function _registerReceivedImageRenderer(pi) {
|
|
338
|
-
pi.registerMessageRenderer(UNBIEN_RECEIVED_IMAGE_TYPE, (message, _options, theme) => {
|
|
339
|
-
const details = (message.details ?? {});
|
|
340
|
-
const path = typeof details.path === "string" ? details.path : "";
|
|
341
|
-
const previewPath = typeof details.previewPath === "string" ? details.previewPath : "";
|
|
342
|
-
const mime = typeof details.mime === "string"
|
|
343
|
-
? details.mime
|
|
344
|
-
: "application/octet-stream";
|
|
345
|
-
const inlineImagePath = previewPath.length > 0
|
|
346
|
-
? previewPath
|
|
347
|
-
: mime === IMAGE_PREVIEW_MIME
|
|
348
|
-
? path
|
|
349
|
-
: "";
|
|
350
|
-
const size = typeof details.size === "number" ? details.size : undefined;
|
|
351
|
-
const index = typeof details.index === "number" ? details.index : undefined;
|
|
352
|
-
const text = typeof details.text === "string" ? details.text.trim() : "";
|
|
353
|
-
const messageId = typeof details.messageId === "string" ? details.messageId : "unknown";
|
|
354
|
-
const error = typeof details.error === "string" ? details.error : undefined;
|
|
355
|
-
const reason = typeof details.reason === "string" ? details.reason : undefined;
|
|
356
|
-
const label = `📷 Photo from Android (${messageId}${index === undefined ? "" : ` #${index}`})`;
|
|
357
|
-
const lines = [
|
|
358
|
-
theme.fg("customMessageLabel", label),
|
|
359
|
-
theme.fg("customMessageText", `Saved: ${path || "(not saved)"}`),
|
|
360
|
-
];
|
|
361
|
-
if (size !== undefined)
|
|
362
|
-
lines.push(theme.fg("customMessageText", `Size: ${size} bytes`));
|
|
363
|
-
if (mime)
|
|
364
|
-
lines.push(theme.fg("customMessageText", `MIME: ${mime}`));
|
|
365
|
-
if (error)
|
|
366
|
-
lines.push(theme.fg("customMessageText", `Error: ${error}`));
|
|
367
|
-
if (reason)
|
|
368
|
-
lines.push(theme.fg("customMessageText", `Reason: ${reason}`));
|
|
369
|
-
if (text)
|
|
370
|
-
lines.push(theme.fg("customMessageText", `Text: ${text}`));
|
|
371
|
-
const container = new Container();
|
|
372
|
-
const metadata = new Box(1, 1, (line) => theme.bg("customMessageBg", line));
|
|
373
|
-
metadata.addChild(new Text(lines.join("\n")));
|
|
374
|
-
container.addChild(metadata);
|
|
375
|
-
if (inlineImagePath && !error) {
|
|
376
|
-
try {
|
|
377
|
-
const imageData = readFileSync(inlineImagePath).toString("base64");
|
|
378
|
-
if (imageData.length > 0) {
|
|
379
|
-
const image = new Image(imageData, IMAGE_PREVIEW_MIME, {
|
|
380
|
-
fallbackColor: (str) => theme.fg("customMessageText", str),
|
|
381
|
-
});
|
|
382
|
-
// Keep Kitty image rows out of Box padding/background so pi-tui can
|
|
383
|
-
// preserve the empty reserved rows that make inline images visible.
|
|
384
|
-
container.addChild(image);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
catch {
|
|
388
|
-
// Keep the metadata-only fallback on any IO/terminal issue.
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
return container;
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
function _isReceivedImageContextMessage(message) {
|
|
395
|
-
return (typeof message === "object" &&
|
|
396
|
-
message !== null &&
|
|
397
|
-
message.role === "custom" &&
|
|
398
|
-
message.customType ===
|
|
399
|
-
UNBIEN_RECEIVED_IMAGE_TYPE);
|
|
400
|
-
}
|
|
401
213
|
/**
|
|
402
214
|
* Issue #105 — pure-data events must not reach the model.
|
|
403
215
|
*
|
|
@@ -430,57 +242,6 @@ function _filterInternalMessagesFromContext(messages) {
|
|
|
430
242
|
!_isPureDataContextMessage(message))
|
|
431
243
|
: [];
|
|
432
244
|
}
|
|
433
|
-
function _contentFromUserMessage(msg) {
|
|
434
|
-
return msg.images && msg.images.length > 0
|
|
435
|
-
? [
|
|
436
|
-
...msg.images.map((img) => ({
|
|
437
|
-
type: "image",
|
|
438
|
-
data: img.data,
|
|
439
|
-
mimeType: img.mime,
|
|
440
|
-
})),
|
|
441
|
-
{ type: "text", text: msg.text },
|
|
442
|
-
]
|
|
443
|
-
: msg.text;
|
|
444
|
-
}
|
|
445
|
-
async function _deliverImageUserMessage(sender, msg, shouldSteer) {
|
|
446
|
-
const previewDelivery = shouldSteer || _rootState().turnId !== null || _myRoomMeta?.working === true
|
|
447
|
-
? "defer"
|
|
448
|
-
: "immediate";
|
|
449
|
-
const emitPreview = async () => {
|
|
450
|
-
try {
|
|
451
|
-
await _emitReceivedImagePreviews(msg, previewDelivery);
|
|
452
|
-
}
|
|
453
|
-
catch (error) {
|
|
454
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
455
|
-
console.error(`[un-bien] failed emitting image preview id=${msg.id}: ${detail}`);
|
|
456
|
-
}
|
|
457
|
-
};
|
|
458
|
-
if (previewDelivery === "immediate") {
|
|
459
|
-
await emitPreview();
|
|
460
|
-
}
|
|
461
|
-
else {
|
|
462
|
-
void emitPreview().finally(() => {
|
|
463
|
-
if (!_shouldDeferReceivedImagePreview())
|
|
464
|
-
_flushPendingReceivedImagePreviews();
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
const previousTurnId = _rootState().turnId;
|
|
468
|
-
const seededTurnId = !shouldSteer || _rootState().turnId === null;
|
|
469
|
-
if (seededTurnId)
|
|
470
|
-
_rootState().turnId = msg.id;
|
|
471
|
-
const wake = _wakeAgent(_contentFromUserMessage(msg), `app user_message id=${msg.id} (+${msg.images?.length ?? 0} image)`, "steer");
|
|
472
|
-
if (!wake.ok) {
|
|
473
|
-
if (seededTurnId)
|
|
474
|
-
_rootState().turnId = previousTurnId;
|
|
475
|
-
sender.send({
|
|
476
|
-
type: "error",
|
|
477
|
-
code: "internal_error",
|
|
478
|
-
in_reply_to: msg.id,
|
|
479
|
-
message: `Agent rejected incoming message: ${wake.detail}`,
|
|
480
|
-
});
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
245
|
// ── Cross-PC mesh wiring (plan/25 Wave B/C) ───────────────────────────────────
|
|
485
246
|
/**
|
|
486
247
|
* Hand the live relay to MeshNode so it can bring up the cross-PC bridge
|
|
@@ -513,13 +274,14 @@ function _attachBridgeIfReady() {
|
|
|
513
274
|
});
|
|
514
275
|
}
|
|
515
276
|
/**
|
|
516
|
-
* Prefer an explicit ctx,
|
|
517
|
-
*
|
|
518
|
-
*
|
|
277
|
+
* Prefer an explicit ctx, else the always-fresh session_start ctx
|
|
278
|
+
* (`_lastEventCtx`, re-captured on every session_start so it is never a stale
|
|
279
|
+
* capture). Relay/async paths reach the ctx-only surfaces (ui/abort/compact)
|
|
280
|
+
* through this, since they fire outside any live pi event.
|
|
519
281
|
* @see https://github.com/jacobaraujo7/remote_pi/issues/55
|
|
520
282
|
*/
|
|
521
283
|
function _liveCtx(preferred) {
|
|
522
|
-
return preferred ?? _lastEventCtx ??
|
|
284
|
+
return preferred ?? _lastEventCtx ?? null;
|
|
523
285
|
}
|
|
524
286
|
/**
|
|
525
287
|
* Read `ctx.ui` without letting a stale-ctx getter become an uncaughtException.
|
|
@@ -573,7 +335,7 @@ function _refreshFooter(ctx) {
|
|
|
573
335
|
// `devicePaired` now reflects "any owner currently attached" — picks one
|
|
574
336
|
// shortid representatively (multi-owner UX detail surfaces in the
|
|
575
337
|
// `/unbien status` line, not the footer slot).
|
|
576
|
-
devicePaired: _anyPeerActive() ? _peerShort : undefined,
|
|
338
|
+
devicePaired: _anyPeerActive(relayDeps) ? _peerShort : undefined,
|
|
577
339
|
hasPairings: _hasGlobalPairings,
|
|
578
340
|
agentName: _meshNode?.name(),
|
|
579
341
|
};
|
|
@@ -596,155 +358,6 @@ let _pendingMeshMessages = [];
|
|
|
596
358
|
// agent-run active/generation now live PER-SESSION in _stateFor(sid).agentRun;
|
|
597
359
|
// mesh delivery targets the ROOT run, so the drain reads _rootState().agentRun.
|
|
598
360
|
let _meshDrainScheduled = false;
|
|
599
|
-
/** Test-only override of the message buffer. */
|
|
600
|
-
/**
|
|
601
|
-
* Test-only: emulate what `/unbien` does on the returning-user path
|
|
602
|
-
* (join the local mesh, then start the relay) without touching the FS for
|
|
603
|
-
* a `localConfigExists()` lookup. Lets tests bring the relay up without
|
|
604
|
-
* mocking the wizard or the local config storage.
|
|
605
|
-
*
|
|
606
|
-
* Typed loosely to accept any ctx shape with `ui.notify` + `cwd` — the
|
|
607
|
-
* unit tests use minimal mocks that don't satisfy the full
|
|
608
|
-
* `ExtensionContext` interface.
|
|
609
|
-
*/
|
|
610
|
-
export async function _connectForTest(ctx) {
|
|
611
|
-
const real = ctx;
|
|
612
|
-
await _cmdJoin(real);
|
|
613
|
-
await _cmdStart(real);
|
|
614
|
-
}
|
|
615
|
-
/** Test-only: tear everything down (mirrors `/unbien stop`). */
|
|
616
|
-
export async function _stopForTest(ctx) {
|
|
617
|
-
await _cmdStop(ctx);
|
|
618
|
-
}
|
|
619
|
-
/** Test-only: read/reset the `_disposed` flag. Production clears it only when
|
|
620
|
-
* a host reuses this module for a replacement session; tests share one module
|
|
621
|
-
* across cases, so they also reset it to avoid cross-test pollution. */
|
|
622
|
-
export function _getDisposedForTest() {
|
|
623
|
-
return _disposed;
|
|
624
|
-
}
|
|
625
|
-
export function _setDisposedForTest(v) {
|
|
626
|
-
_disposed = v;
|
|
627
|
-
}
|
|
628
|
-
/** Test-only: reset the once-per-session auto-init gate so session_start re-runs it. */
|
|
629
|
-
export function _resetAutoInitedForTest() {
|
|
630
|
-
_autoInited = false;
|
|
631
|
-
}
|
|
632
|
-
/** Test-only: clear the globalThis panel/ui bridge ownership so each fresh
|
|
633
|
-
* `extension(pi)` in a shared test process can (re)claim and rebuild bridges. */
|
|
634
|
-
export function _resetBridgeOwnersForTest() {
|
|
635
|
-
const g = globalThis;
|
|
636
|
-
delete g[_ROOT_SESSION_OWNER_KEY];
|
|
637
|
-
_panelBridge?.dispose();
|
|
638
|
-
_panelBridge = null;
|
|
639
|
-
_rpcEnvelope?.dispose();
|
|
640
|
-
_rpcEnvelope = null;
|
|
641
|
-
_subagentRooms?.dispose();
|
|
642
|
-
_subagentRooms = null;
|
|
643
|
-
_extensionUiBridge?.dispose();
|
|
644
|
-
_extensionUiBridge = null;
|
|
645
|
-
}
|
|
646
|
-
/** Test-only: reset the keyed per-session state at a TEST BOUNDARY (beforeEach).
|
|
647
|
-
* Must NOT be folded into _resetBridgeOwnersForTest — that fires mid-test on
|
|
648
|
-
* every captureEventHandler call and would wipe state a test seeds across two
|
|
649
|
-
* captures (e.g. input seeds turnId, message_update reads it). */
|
|
650
|
-
export function _resetSessionsForTest() {
|
|
651
|
-
_sessions.clear();
|
|
652
|
-
_rootSessionId = null;
|
|
653
|
-
}
|
|
654
|
-
/** Test-only: set the auto-init gate for lifecycle replacement tests. */
|
|
655
|
-
export function _setAutoInitedForTest(value) {
|
|
656
|
-
_autoInited = value;
|
|
657
|
-
}
|
|
658
|
-
/** Test-only: true when this instance holds a live local-mesh node. */
|
|
659
|
-
export function _hasMeshNodeForTest() {
|
|
660
|
-
return _meshNode !== null;
|
|
661
|
-
}
|
|
662
|
-
/** Test-only: drive the current real SelfRevoke producer through one sweep. */
|
|
663
|
-
export async function _checkSelfRevokeForTest() {
|
|
664
|
-
await _selfRevoke?.checkOnce();
|
|
665
|
-
}
|
|
666
|
-
/** Test-only: the effective (possibly `#N`-suffixed) name the cwd-lock reserved. */
|
|
667
|
-
export function _getLockedNameForTest() {
|
|
668
|
-
return _lockedName;
|
|
669
|
-
}
|
|
670
|
-
/** Test-only: release + clear the cwd lock (the lock normally survives stop). */
|
|
671
|
-
export function _resetCwdLockForTest() {
|
|
672
|
-
try {
|
|
673
|
-
_cwdLock?.release();
|
|
674
|
-
}
|
|
675
|
-
catch {
|
|
676
|
-
/* ignored */
|
|
677
|
-
}
|
|
678
|
-
_cwdLock = null;
|
|
679
|
-
_lockedName = null;
|
|
680
|
-
}
|
|
681
|
-
/**
|
|
682
|
-
* Test-only: relay-only startup, no UDS mesh join. Replaces the old
|
|
683
|
-
* `unbien relay start` handler that some tests captured to bring up
|
|
684
|
-
* the relay in isolation (e.g. ping/pong tests that don't care about the
|
|
685
|
-
* agent-network broker).
|
|
686
|
-
*/
|
|
687
|
-
export async function _startRelayForTest(ctx) {
|
|
688
|
-
await _cmdStart(ctx);
|
|
689
|
-
}
|
|
690
|
-
/** Test-only: public marker for canceled-keypair cache regression checks. */
|
|
691
|
-
export function _getCachedPublicKeyForTest() {
|
|
692
|
-
return _cachedEd25519
|
|
693
|
-
? Buffer.from(_cachedEd25519.publicKey).toString("base64")
|
|
694
|
-
: null;
|
|
695
|
-
}
|
|
696
|
-
/** Test-only override of session started timestamp. */
|
|
697
|
-
export function _setSessionStartedAtForTest(ts) {
|
|
698
|
-
_sessionStartedAt = ts;
|
|
699
|
-
}
|
|
700
|
-
/** Test-only: reset the cached model name (between tests). */
|
|
701
|
-
export function _setCurrentModelForTest(name) {
|
|
702
|
-
_currentModel = name;
|
|
703
|
-
}
|
|
704
|
-
/** Test-only: read the active turn id used for plain `cancel` routing. */
|
|
705
|
-
export function _getCurrentTurnIdForTest() {
|
|
706
|
-
return _rootState().turnId;
|
|
707
|
-
}
|
|
708
|
-
/** Test-only: override the bound AgentSession so a spy can capture the
|
|
709
|
-
* content handed to `sendUserMessage` (plan/30 multimodal ingest). */
|
|
710
|
-
export function _setPiForTest(pi) {
|
|
711
|
-
_pi = pi;
|
|
712
|
-
}
|
|
713
|
-
/**
|
|
714
|
-
* Persist a model change to the PROJECT settings (`<cwd>/.pi/settings.json`) so
|
|
715
|
-
* a model picked from the app survives a Pi/daemon restart. `pi.setModel` only
|
|
716
|
-
* sets the LIVE model — on the next restart a fresh session reads the saved
|
|
717
|
-
* default and reverts (the reported bug). We write the PROJECT scope, NOT
|
|
718
|
-
* global, deliberately: the SDK merges global←project with PROJECT winning
|
|
719
|
-
* (`SettingsManager`), so a folder that already has a project default (every
|
|
720
|
-
* created daemon does) would shadow a global write like the TUI's. Project
|
|
721
|
-
* scope is also correct for a fleet — each daemon keeps its own model rather
|
|
722
|
-
* than leaking one default globally.
|
|
723
|
-
*
|
|
724
|
-
* Read-merge-write + best-effort: preserves other keys and never throws (a
|
|
725
|
-
* settings write must not fail the live model change, which already applied).
|
|
726
|
-
*/
|
|
727
|
-
function _persistModelDefault(provider, modelId) {
|
|
728
|
-
try {
|
|
729
|
-
const path = join(process.cwd(), ".pi", "settings.json");
|
|
730
|
-
let obj = {};
|
|
731
|
-
try {
|
|
732
|
-
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
733
|
-
if (parsed && typeof parsed === "object")
|
|
734
|
-
obj = parsed;
|
|
735
|
-
}
|
|
736
|
-
catch {
|
|
737
|
-
/* no existing/parseable file → start fresh */
|
|
738
|
-
}
|
|
739
|
-
obj["defaultProvider"] = provider;
|
|
740
|
-
obj["defaultModel"] = modelId;
|
|
741
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
742
|
-
writeFileSync(path, JSON.stringify(obj, null, 2));
|
|
743
|
-
}
|
|
744
|
-
catch {
|
|
745
|
-
/* best-effort — model change already applied live */
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
361
|
const _sessions = new Map();
|
|
749
362
|
// The session bound to the app room. null until the ROOT session_start fires;
|
|
750
363
|
// while null, everything is treated as root (single-session / test harness).
|
|
@@ -820,29 +433,14 @@ let _cwdLock = null;
|
|
|
820
433
|
// registers under this name; the broker confirms it (and may bump it again under
|
|
821
434
|
// a live race). Null until the lock is acquired.
|
|
822
435
|
let _lockedName = null;
|
|
823
|
-
// ── Relay reconnect state ─────────────────────────────────────────────────────
|
|
824
|
-
// Backoffs in ms: 1s, 2s, 5s, 10s, 30s, then stays at 30s.
|
|
825
|
-
const RECONNECT_BACKOFFS_MS = [1_000, 2_000, 5_000, 10_000, 30_000];
|
|
826
|
-
let _reconnectTimer = null;
|
|
827
|
-
let _reconnectAttempt = 0;
|
|
828
|
-
// Every initial connect/reconnect candidate captures this generation. Stop,
|
|
829
|
-
// relay-off, and an unexpected close invalidate older async continuations.
|
|
830
|
-
let _relayLifecycleGeneration = 0;
|
|
831
436
|
// Root startup has pre-candidate awaits (cwd lock, wizard) that relay/mesh
|
|
832
437
|
// generations cannot safely represent: child startup intentionally advances
|
|
833
438
|
// those generations. Stop/off/session replacement advance this separate epoch
|
|
834
439
|
// so a queued root can never regain authority by creating a newer child.
|
|
835
440
|
let _rootLifecycleGeneration = 0;
|
|
836
|
-
// Coalesces concurrent `/unbien` startup paths inside ONE extension instance.
|
|
837
|
-
// Separate Pi processes still keep the existing #N behavior via the cwd lock.
|
|
838
|
-
let _cmdRootInFlight = null;
|
|
839
441
|
function _isCurrentRootLifecycle(generation) {
|
|
840
442
|
return !_disposed && generation === _rootLifecycleGeneration;
|
|
841
443
|
}
|
|
842
|
-
/** Test-only: exposes pending reconnect timer state. */
|
|
843
|
-
export function _hasPendingReconnect() {
|
|
844
|
-
return _reconnectTimer !== null;
|
|
845
|
-
}
|
|
846
444
|
/**
|
|
847
445
|
* Public state-snapshot helper. Returns the derived UX state, not the raw
|
|
848
446
|
* `_state` enum: the W2D refactor collapsed the internal machine to
|
|
@@ -855,19 +453,6 @@ export function _getState() {
|
|
|
855
453
|
return "idle";
|
|
856
454
|
return _activePeers.size > 0 ? "paired" : "started";
|
|
857
455
|
}
|
|
858
|
-
/** Test-only: number of owners currently attached via PlainPeerChannel. */
|
|
859
|
-
export function _getActivePeerCountForTest() {
|
|
860
|
-
return _activePeers.size;
|
|
861
|
-
}
|
|
862
|
-
/** Test-only: true if a specific peer (base64 std) has an attached channel. */
|
|
863
|
-
export function _hasActivePeerForTest(appPeerIdStd) {
|
|
864
|
-
return _activePeers.has(appPeerIdStd);
|
|
865
|
-
}
|
|
866
|
-
// ── Multi-channel helpers ─────────────────────────────────────────────────────
|
|
867
|
-
/** Returns true when at least one owner is attached. Derived `paired` UX. */
|
|
868
|
-
function _anyPeerActive() {
|
|
869
|
-
return _activePeers.size > 0;
|
|
870
|
-
}
|
|
871
456
|
// ── Hidden e2e UI test harness (dev-only, undocumented) ───────────────────
|
|
872
457
|
// Broadcasts CANNED frames to paired apps so the app UI can be exercised
|
|
873
458
|
// end-to-end without a real agent turn. For plan/subagents/rich-ask it EMITS the
|
|
@@ -880,6 +465,7 @@ const _TEST_SVG_B64 = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" viewB
|
|
|
880
465
|
' font-family="sans-serif">un-bien</text></svg>').toString("base64");
|
|
881
466
|
function _emitTestBus(channel, data) {
|
|
882
467
|
try {
|
|
468
|
+
;
|
|
883
469
|
_pi?.events?.emit(channel, data);
|
|
884
470
|
}
|
|
885
471
|
catch {
|
|
@@ -892,7 +478,7 @@ function _runTestScenario(scenario) {
|
|
|
892
478
|
const id = `test-${Date.now()}`;
|
|
893
479
|
switch (s) {
|
|
894
480
|
case "ask-select":
|
|
895
|
-
_broadcastEnvelope({
|
|
481
|
+
_broadcastEnvelope(relayDeps, {
|
|
896
482
|
rpc: {
|
|
897
483
|
type: "extension_ui_request",
|
|
898
484
|
id,
|
|
@@ -903,7 +489,7 @@ function _runTestScenario(scenario) {
|
|
|
903
489
|
});
|
|
904
490
|
return "sent ask-select";
|
|
905
491
|
case "ask-confirm":
|
|
906
|
-
_broadcastEnvelope({
|
|
492
|
+
_broadcastEnvelope(relayDeps, {
|
|
907
493
|
rpc: {
|
|
908
494
|
type: "extension_ui_request",
|
|
909
495
|
id,
|
|
@@ -914,7 +500,7 @@ function _runTestScenario(scenario) {
|
|
|
914
500
|
});
|
|
915
501
|
return "sent ask-confirm";
|
|
916
502
|
case "ask-input":
|
|
917
|
-
_broadcastEnvelope({
|
|
503
|
+
_broadcastEnvelope(relayDeps, {
|
|
918
504
|
rpc: {
|
|
919
505
|
type: "extension_ui_request",
|
|
920
506
|
id,
|
|
@@ -925,7 +511,7 @@ function _runTestScenario(scenario) {
|
|
|
925
511
|
});
|
|
926
512
|
return "sent ask-input";
|
|
927
513
|
case "ask-editor":
|
|
928
|
-
_broadcastEnvelope({
|
|
514
|
+
_broadcastEnvelope(relayDeps, {
|
|
929
515
|
rpc: {
|
|
930
516
|
type: "extension_ui_request",
|
|
931
517
|
id,
|
|
@@ -936,7 +522,7 @@ function _runTestScenario(scenario) {
|
|
|
936
522
|
});
|
|
937
523
|
return "sent ask-editor";
|
|
938
524
|
case "ask-notify":
|
|
939
|
-
_broadcastEnvelope({
|
|
525
|
+
_broadcastEnvelope(relayDeps, {
|
|
940
526
|
rpc: {
|
|
941
527
|
type: "extension_ui_request",
|
|
942
528
|
id,
|
|
@@ -1020,8 +606,8 @@ function _runTestScenario(scenario) {
|
|
|
1020
606
|
// `{content:[{type:"image",data,mimeType}]}`) and renders them below the
|
|
1021
607
|
// card (WireImageView -> SVGImageView). Deliver the SVG that way.
|
|
1022
608
|
const tc = `tc-svg-${Date.now()}`;
|
|
1023
|
-
_broadcastEnvelope({ rpc: { type: "turn_start" } });
|
|
1024
|
-
_broadcastEnvelope({
|
|
609
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
|
|
610
|
+
_broadcastEnvelope(relayDeps, {
|
|
1025
611
|
rpc: {
|
|
1026
612
|
type: "tool_execution_start",
|
|
1027
613
|
toolCallId: tc,
|
|
@@ -1029,7 +615,7 @@ function _runTestScenario(scenario) {
|
|
|
1029
615
|
args: { note: "test svg" },
|
|
1030
616
|
},
|
|
1031
617
|
});
|
|
1032
|
-
_broadcastEnvelope({
|
|
618
|
+
_broadcastEnvelope(relayDeps, {
|
|
1033
619
|
rpc: {
|
|
1034
620
|
type: "tool_execution_end",
|
|
1035
621
|
toolCallId: tc,
|
|
@@ -1046,13 +632,13 @@ function _runTestScenario(scenario) {
|
|
|
1046
632
|
isError: false,
|
|
1047
633
|
},
|
|
1048
634
|
});
|
|
1049
|
-
_broadcastEnvelope({ rpc: { type: "agent_settled" } });
|
|
635
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
|
|
1050
636
|
return "sent svg (envelope tool_execution_end + image)";
|
|
1051
637
|
}
|
|
1052
638
|
case "tool": {
|
|
1053
639
|
const tc = `tc-${Date.now()}`;
|
|
1054
|
-
_broadcastEnvelope({ rpc: { type: "turn_start" } });
|
|
1055
|
-
_broadcastEnvelope({
|
|
640
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
|
|
641
|
+
_broadcastEnvelope(relayDeps, {
|
|
1056
642
|
rpc: {
|
|
1057
643
|
type: "tool_execution_start",
|
|
1058
644
|
toolCallId: tc,
|
|
@@ -1060,7 +646,7 @@ function _runTestScenario(scenario) {
|
|
|
1060
646
|
args: { command: "echo hello" },
|
|
1061
647
|
},
|
|
1062
648
|
});
|
|
1063
|
-
_broadcastEnvelope({
|
|
649
|
+
_broadcastEnvelope(relayDeps, {
|
|
1064
650
|
rpc: {
|
|
1065
651
|
type: "tool_execution_end",
|
|
1066
652
|
toolCallId: tc,
|
|
@@ -1068,7 +654,7 @@ function _runTestScenario(scenario) {
|
|
|
1068
654
|
isError: false,
|
|
1069
655
|
},
|
|
1070
656
|
});
|
|
1071
|
-
_broadcastEnvelope({ rpc: { type: "agent_settled" } });
|
|
657
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
|
|
1072
658
|
return "sent tool pair (envelope)";
|
|
1073
659
|
}
|
|
1074
660
|
case "diff": {
|
|
@@ -1086,8 +672,8 @@ function _runTestScenario(scenario) {
|
|
|
1086
672
|
],
|
|
1087
673
|
},
|
|
1088
674
|
];
|
|
1089
|
-
_broadcastEnvelope({ rpc: { type: "turn_start" } });
|
|
1090
|
-
_broadcastEnvelope({
|
|
675
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
|
|
676
|
+
_broadcastEnvelope(relayDeps, {
|
|
1091
677
|
rpc: {
|
|
1092
678
|
type: "tool_execution_start",
|
|
1093
679
|
toolCallId: tc,
|
|
@@ -1100,7 +686,7 @@ function _runTestScenario(scenario) {
|
|
|
1100
686
|
},
|
|
1101
687
|
aux: { hunks },
|
|
1102
688
|
});
|
|
1103
|
-
_broadcastEnvelope({
|
|
689
|
+
_broadcastEnvelope(relayDeps, {
|
|
1104
690
|
rpc: {
|
|
1105
691
|
type: "tool_execution_end",
|
|
1106
692
|
toolCallId: tc,
|
|
@@ -1108,15 +694,15 @@ function _runTestScenario(scenario) {
|
|
|
1108
694
|
isError: false,
|
|
1109
695
|
},
|
|
1110
696
|
});
|
|
1111
|
-
_broadcastEnvelope({ rpc: { type: "agent_settled" } });
|
|
697
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
|
|
1112
698
|
return "sent diff (edit + aux hunks — shows the Diff⇄Content toggle)";
|
|
1113
699
|
}
|
|
1114
700
|
case "code-shell": {
|
|
1115
701
|
// bash-family result → the app classifies it into a `code` block (lang
|
|
1116
702
|
// shell), syntax-highlighted. OUTPUT is app-side now — no aux stamped.
|
|
1117
703
|
const tc = `tc-sh-${Date.now()}`;
|
|
1118
|
-
_broadcastEnvelope({ rpc: { type: "turn_start" } });
|
|
1119
|
-
_broadcastEnvelope({
|
|
704
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
|
|
705
|
+
_broadcastEnvelope(relayDeps, {
|
|
1120
706
|
rpc: {
|
|
1121
707
|
type: "tool_execution_start",
|
|
1122
708
|
toolCallId: tc,
|
|
@@ -1124,7 +710,7 @@ function _runTestScenario(scenario) {
|
|
|
1124
710
|
args: { command: "ls -la" },
|
|
1125
711
|
},
|
|
1126
712
|
});
|
|
1127
|
-
_broadcastEnvelope({
|
|
713
|
+
_broadcastEnvelope(relayDeps, {
|
|
1128
714
|
rpc: {
|
|
1129
715
|
type: "tool_execution_end",
|
|
1130
716
|
toolCallId: tc,
|
|
@@ -1132,15 +718,15 @@ function _runTestScenario(scenario) {
|
|
|
1132
718
|
isError: false,
|
|
1133
719
|
},
|
|
1134
720
|
});
|
|
1135
|
-
_broadcastEnvelope({ rpc: { type: "agent_settled" } });
|
|
721
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
|
|
1136
722
|
return "sent code-shell (bash output → code block, lang shell)";
|
|
1137
723
|
}
|
|
1138
724
|
case "code-file": {
|
|
1139
725
|
// read-family with a *.swift path → `code` block, lang inferred from the
|
|
1140
726
|
// extension (swift) and highlighted.
|
|
1141
727
|
const tc = `tc-rd-${Date.now()}`;
|
|
1142
|
-
_broadcastEnvelope({ rpc: { type: "turn_start" } });
|
|
1143
|
-
_broadcastEnvelope({
|
|
728
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
|
|
729
|
+
_broadcastEnvelope(relayDeps, {
|
|
1144
730
|
rpc: {
|
|
1145
731
|
type: "tool_execution_start",
|
|
1146
732
|
toolCallId: tc,
|
|
@@ -1148,7 +734,7 @@ function _runTestScenario(scenario) {
|
|
|
1148
734
|
args: { path: "/src/Greeter.swift" },
|
|
1149
735
|
},
|
|
1150
736
|
});
|
|
1151
|
-
_broadcastEnvelope({
|
|
737
|
+
_broadcastEnvelope(relayDeps, {
|
|
1152
738
|
rpc: {
|
|
1153
739
|
type: "tool_execution_end",
|
|
1154
740
|
toolCallId: tc,
|
|
@@ -1156,15 +742,15 @@ function _runTestScenario(scenario) {
|
|
|
1156
742
|
isError: false,
|
|
1157
743
|
},
|
|
1158
744
|
});
|
|
1159
|
-
_broadcastEnvelope({ rpc: { type: "agent_settled" } });
|
|
745
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
|
|
1160
746
|
return "sent code-file (read .swift → highlighted code block)";
|
|
1161
747
|
}
|
|
1162
748
|
case "diff-output": {
|
|
1163
749
|
// A tool whose RESULT already embeds a unified diff → the app parses it
|
|
1164
750
|
// into a `diff` block (re-reading persisted text; replay-safe).
|
|
1165
751
|
const tc = `tc-do-${Date.now()}`;
|
|
1166
|
-
_broadcastEnvelope({ rpc: { type: "turn_start" } });
|
|
1167
|
-
_broadcastEnvelope({
|
|
752
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
|
|
753
|
+
_broadcastEnvelope(relayDeps, {
|
|
1168
754
|
rpc: {
|
|
1169
755
|
type: "tool_execution_start",
|
|
1170
756
|
toolCallId: tc,
|
|
@@ -1172,7 +758,7 @@ function _runTestScenario(scenario) {
|
|
|
1172
758
|
args: { command: "git diff" },
|
|
1173
759
|
},
|
|
1174
760
|
});
|
|
1175
|
-
_broadcastEnvelope({
|
|
761
|
+
_broadcastEnvelope(relayDeps, {
|
|
1176
762
|
rpc: {
|
|
1177
763
|
type: "tool_execution_end",
|
|
1178
764
|
toolCallId: tc,
|
|
@@ -1180,7 +766,7 @@ function _runTestScenario(scenario) {
|
|
|
1180
766
|
isError: false,
|
|
1181
767
|
},
|
|
1182
768
|
});
|
|
1183
|
-
_broadcastEnvelope({ rpc: { type: "agent_settled" } });
|
|
769
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
|
|
1184
770
|
return "sent diff-output (result embeds a unified diff → diff block)";
|
|
1185
771
|
}
|
|
1186
772
|
case "write": {
|
|
@@ -1188,8 +774,8 @@ function _runTestScenario(scenario) {
|
|
|
1188
774
|
// card shows the Content view (new text as a code block, replay-safe).
|
|
1189
775
|
const tc = `tc-wr-${Date.now()}`;
|
|
1190
776
|
const content = "export function add(a: number, b: number): number {\n return a + b;\n}";
|
|
1191
|
-
_broadcastEnvelope({ rpc: { type: "turn_start" } });
|
|
1192
|
-
_broadcastEnvelope({
|
|
777
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
|
|
778
|
+
_broadcastEnvelope(relayDeps, {
|
|
1193
779
|
rpc: {
|
|
1194
780
|
type: "tool_execution_start",
|
|
1195
781
|
toolCallId: tc,
|
|
@@ -1197,7 +783,7 @@ function _runTestScenario(scenario) {
|
|
|
1197
783
|
args: { path: "/src/math.ts", content },
|
|
1198
784
|
},
|
|
1199
785
|
});
|
|
1200
|
-
_broadcastEnvelope({
|
|
786
|
+
_broadcastEnvelope(relayDeps, {
|
|
1201
787
|
rpc: {
|
|
1202
788
|
type: "tool_execution_end",
|
|
1203
789
|
toolCallId: tc,
|
|
@@ -1205,12 +791,12 @@ function _runTestScenario(scenario) {
|
|
|
1205
791
|
isError: false,
|
|
1206
792
|
},
|
|
1207
793
|
});
|
|
1208
|
-
_broadcastEnvelope({ rpc: { type: "agent_settled" } });
|
|
794
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
|
|
1209
795
|
return "sent write (args.content → content-as-code block)";
|
|
1210
796
|
}
|
|
1211
797
|
case "agent": {
|
|
1212
|
-
_broadcastEnvelope({ rpc: { type: "turn_start" } });
|
|
1213
|
-
_broadcastEnvelope({
|
|
798
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "turn_start" } });
|
|
799
|
+
_broadcastEnvelope(relayDeps, {
|
|
1214
800
|
rpc: {
|
|
1215
801
|
type: "message_update",
|
|
1216
802
|
assistantMessageEvent: {
|
|
@@ -1219,7 +805,7 @@ function _runTestScenario(scenario) {
|
|
|
1219
805
|
},
|
|
1220
806
|
},
|
|
1221
807
|
});
|
|
1222
|
-
_broadcastEnvelope({
|
|
808
|
+
_broadcastEnvelope(relayDeps, {
|
|
1223
809
|
rpc: {
|
|
1224
810
|
type: "message_update",
|
|
1225
811
|
assistantMessageEvent: {
|
|
@@ -1228,7 +814,7 @@ function _runTestScenario(scenario) {
|
|
|
1228
814
|
},
|
|
1229
815
|
},
|
|
1230
816
|
});
|
|
1231
|
-
_broadcastEnvelope({
|
|
817
|
+
_broadcastEnvelope(relayDeps, {
|
|
1232
818
|
rpc: {
|
|
1233
819
|
type: "message_end",
|
|
1234
820
|
message: {
|
|
@@ -1237,11 +823,11 @@ function _runTestScenario(scenario) {
|
|
|
1237
823
|
},
|
|
1238
824
|
},
|
|
1239
825
|
});
|
|
1240
|
-
_broadcastEnvelope({ rpc: { type: "agent_settled" } });
|
|
826
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
|
|
1241
827
|
return "sent agent message (envelope)";
|
|
1242
828
|
}
|
|
1243
829
|
case "error":
|
|
1244
|
-
_broadcastEnvelope({
|
|
830
|
+
_broadcastEnvelope(relayDeps, {
|
|
1245
831
|
rpc: {
|
|
1246
832
|
type: "message_end",
|
|
1247
833
|
message: {
|
|
@@ -1251,7 +837,7 @@ function _runTestScenario(scenario) {
|
|
|
1251
837
|
},
|
|
1252
838
|
},
|
|
1253
839
|
});
|
|
1254
|
-
_broadcastEnvelope({ rpc: { type: "agent_settled" } });
|
|
840
|
+
_broadcastEnvelope(relayDeps, { rpc: { type: "agent_settled" } });
|
|
1255
841
|
return "sent error (envelope message_end/error)";
|
|
1256
842
|
case "all":
|
|
1257
843
|
for (const sc of [
|
|
@@ -1296,150 +882,7 @@ function _routeRpcCommandFrom(sender, env) {
|
|
|
1296
882
|
// session_sync (reconstruction) is un-bien's OWN protocol — dispatched on the
|
|
1297
883
|
// un plane by _routeUnBienPlaneFrom, NOT here. Only byte-faithful pi rpc
|
|
1298
884
|
// commands + extension_ui_response ride this rpc dispatch.
|
|
1299
|
-
const handlers =
|
|
1300
|
-
prompt: async (message, opts) => {
|
|
1301
|
-
// Full parity with the retired stock user_message handler:
|
|
1302
|
-
// - ALWAYS hand off with deliverAs:"steer" — the SDK ignores it while idle
|
|
1303
|
-
// but REQUIRES it when a turn is running or still settling right after
|
|
1304
|
-
// agent return; without it the message is rejected as busy.
|
|
1305
|
-
// - `shouldSteer` (echo label + steer tracking) = requested OR inferred
|
|
1306
|
-
// busy-room send.
|
|
1307
|
-
// - seed `_rootState().turnId` for a fresh (non-steer) turn so the agent's
|
|
1308
|
-
// reply chunks/done have a target; restore it if the handoff fails.
|
|
1309
|
-
// The APP owns the steer-vs-followUp semantic switch (design 01M14T6J5W):
|
|
1310
|
-
// pass its chosen streamingBehavior straight through to pi. The extension does
|
|
1311
|
-
// NOT force steer over a followUp anymore. `shouldSteer` below is now only
|
|
1312
|
-
// BOOKKEEPING (turn-seeding + image-preview defer), not the delivery verb.
|
|
1313
|
-
const requestedSteer = opts.streamingBehavior === "steer";
|
|
1314
|
-
// Authoritative busy signal from pi's OWN state (AgentSession.isStreaming),
|
|
1315
|
-
// correct across subagent lifecycles (turnId/working stick busy after a
|
|
1316
|
-
// subagent run).
|
|
1317
|
-
const streaming = _pi?.isStreaming === true;
|
|
1318
|
-
const shouldSteer = requestedSteer || streaming;
|
|
1319
|
-
const msg = {
|
|
1320
|
-
type: "user_message",
|
|
1321
|
-
id: opts.id ?? _rootState().turnId ?? String(Date.now()),
|
|
1322
|
-
text: message,
|
|
1323
|
-
images: opts.images,
|
|
1324
|
-
};
|
|
1325
|
-
// Image path mirrors the stock handler (SDK handoff WITH images + echo).
|
|
1326
|
-
if (msg.images && msg.images.length > 0) {
|
|
1327
|
-
await _deliverImageUserMessage(sender, msg, shouldSteer);
|
|
1328
|
-
return;
|
|
1329
|
-
}
|
|
1330
|
-
const previousTurnId = _rootState().turnId;
|
|
1331
|
-
const seededTurnId = !shouldSteer || _rootState().turnId === null;
|
|
1332
|
-
if (seededTurnId)
|
|
1333
|
-
_rootState().turnId = msg.id;
|
|
1334
|
-
// PASS-THROUGH the app's verb (design 01M14T6J5W). pi's prompt(): idle
|
|
1335
|
-
// ignores streamingBehavior (fresh run); streaming+"steer" -> _queueSteer;
|
|
1336
|
-
// streaming+"followUp" -> _queueFollowUp; streaming+none -> throws. The
|
|
1337
|
-
// `?? (streaming ? "steer" : undefined)` is a MECHANICAL safety net (not
|
|
1338
|
-
// semantic inference) so a racing/old client's no-behavior busy send
|
|
1339
|
-
// defensively steers instead of throwing (keeps plan/43).
|
|
1340
|
-
const wake = _wakeAgent(message, "app rpc prompt", opts.streamingBehavior === "followUp"
|
|
1341
|
-
? "followUp"
|
|
1342
|
-
: opts.streamingBehavior === "steer" || streaming
|
|
1343
|
-
? "steer"
|
|
1344
|
-
: undefined);
|
|
1345
|
-
if (!wake.ok) {
|
|
1346
|
-
if (seededTurnId)
|
|
1347
|
-
_rootState().turnId = previousTurnId;
|
|
1348
|
-
throw new Error(wake.detail);
|
|
1349
|
-
}
|
|
1350
|
-
},
|
|
1351
|
-
steer: async (message) => {
|
|
1352
|
-
const wake = _wakeAgent(message, "app rpc steer", "steer");
|
|
1353
|
-
if (!wake.ok)
|
|
1354
|
-
throw new Error(wake.detail);
|
|
1355
|
-
},
|
|
1356
|
-
followUp: async (message) => {
|
|
1357
|
-
const wake = _wakeAgent(message, "app rpc follow_up", "followUp");
|
|
1358
|
-
if (!wake.ok)
|
|
1359
|
-
throw new Error(wake.detail);
|
|
1360
|
-
},
|
|
1361
|
-
abort: async () => {
|
|
1362
|
-
if (!_abortCurrentTurn())
|
|
1363
|
-
throw new Error("no active turn to abort");
|
|
1364
|
-
},
|
|
1365
|
-
setModel: async (provider, modelId) => {
|
|
1366
|
-
if (!_pi)
|
|
1367
|
-
throw new Error("agent session not bound");
|
|
1368
|
-
const actionCtx = (_lastEventCtx ?? _lastCtx);
|
|
1369
|
-
const reg = actionCtx?.modelRegistry ?? ensureModelRegistry(actionCtx);
|
|
1370
|
-
reg.refresh();
|
|
1371
|
-
const model = reg.find(provider, modelId);
|
|
1372
|
-
if (!model)
|
|
1373
|
-
throw new Error(`model "${provider}/${modelId}" not in registry`);
|
|
1374
|
-
// Route via the minimal ActionPi view (matches handleModelSet): the
|
|
1375
|
-
// registry's `find` returns the minimal SdkModelLike, structurally fine
|
|
1376
|
-
// for setModel at runtime.
|
|
1377
|
-
// SAFETY: _pi (checked non-null above) is the concrete AgentSession; its
|
|
1378
|
-
// setModel accepts the minimal SdkModelLike the registry's find() returns,
|
|
1379
|
-
// matching handleModelSet's ActionPi view.
|
|
1380
|
-
const ok = await _pi.setModel(model);
|
|
1381
|
-
if (!ok)
|
|
1382
|
-
throw new Error("no auth configured for this model");
|
|
1383
|
-
_persistModelDefault(model.provider, model.id); // survive restart, mirrors stock model_set
|
|
1384
|
-
return wireFromModel(model);
|
|
1385
|
-
},
|
|
1386
|
-
setThinkingLevel: async (level) => {
|
|
1387
|
-
if (!_pi)
|
|
1388
|
-
throw new Error("agent session not bound");
|
|
1389
|
-
_pi.setThinkingLevel(level);
|
|
1390
|
-
},
|
|
1391
|
-
getAvailableModels: async () => {
|
|
1392
|
-
const actionCtx = (_lastEventCtx ?? _lastCtx);
|
|
1393
|
-
const reg = actionCtx?.modelRegistry ?? ensureModelRegistry(actionCtx);
|
|
1394
|
-
reg.refresh();
|
|
1395
|
-
const models = reg.getAvailable().map(wireFromModel);
|
|
1396
|
-
const current = actionCtx?.getModel?.();
|
|
1397
|
-
return { models, current: current ? wireFromModel(current) : undefined };
|
|
1398
|
-
},
|
|
1399
|
-
compact: async (customInstructions) => {
|
|
1400
|
-
const actionCtx = (_lastEventCtx ?? _lastCtx);
|
|
1401
|
-
if (!actionCtx?.compact)
|
|
1402
|
-
throw new Error("compact unavailable (no active session ctx)");
|
|
1403
|
-
actionCtx.compact(customInstructions ? { customInstructions } : undefined);
|
|
1404
|
-
return {};
|
|
1405
|
-
},
|
|
1406
|
-
newSession: async () => {
|
|
1407
|
-
const actionCtx = (_lastEventCtx ?? _lastCtx);
|
|
1408
|
-
if (!actionCtx?.newSession) {
|
|
1409
|
-
throw new Error("new_session unavailable (no command ctx)");
|
|
1410
|
-
}
|
|
1411
|
-
await actionCtx.newSession({ withSession: async () => { } });
|
|
1412
|
-
// Restamp the session clock (parity with the retired stock session_new):
|
|
1413
|
-
// session_sync_end carries it so the app can detect the pi restart.
|
|
1414
|
-
_resetSessionForNew();
|
|
1415
|
-
return { cancelled: false };
|
|
1416
|
-
},
|
|
1417
|
-
clearQueue: async () => {
|
|
1418
|
-
if (!_pi)
|
|
1419
|
-
throw new Error("agent session not bound");
|
|
1420
|
-
// SAFETY: _pi (checked non-null above) is the concrete AgentSession,
|
|
1421
|
-
// which implements clearQueue(); the public ExtensionAPI type omits it.
|
|
1422
|
-
return _pi.clearQueue();
|
|
1423
|
-
},
|
|
1424
|
-
getEntries: async (since) => {
|
|
1425
|
-
// Native pi get_entries: the app reconstructs the transcript itself from
|
|
1426
|
-
// the raw entry log (each message entry carries an AgentMessage). `since`
|
|
1427
|
-
// slices to entries AFTER that id (delta cursor); leafId is the cursor to
|
|
1428
|
-
// resume from next time. The extension does NOT replay these — see the app's
|
|
1429
|
-
// SessionState.applyEntries (design 01M15FMQ).
|
|
1430
|
-
const sm = _rootState().sessionManager;
|
|
1431
|
-
if (!sm)
|
|
1432
|
-
return { entries: [], leafId: null };
|
|
1433
|
-
const all = sm.getEntries();
|
|
1434
|
-
const sliced = typeof since === "string"
|
|
1435
|
-
? (() => {
|
|
1436
|
-
const i = all.findIndex((e) => e.id === since);
|
|
1437
|
-
return i === -1 ? all : all.slice(i + 1);
|
|
1438
|
-
})()
|
|
1439
|
-
: all;
|
|
1440
|
-
return { entries: sliced, leafId: sm.getLeafId() };
|
|
1441
|
-
},
|
|
1442
|
-
};
|
|
885
|
+
const handlers = createRpcHandlers(rpcDeps, sender);
|
|
1443
886
|
void dispatchRpcCommand(frame, handlers)
|
|
1444
887
|
.then((resp) => {
|
|
1445
888
|
// Envelope-native ONLY: no stock fallback. An unhandled rpc type is
|
|
@@ -1481,16 +924,99 @@ function _routeUnBienPlaneFrom(sender, env) {
|
|
|
1481
924
|
envLog(`session_sync(ub): panels=${panels.length} + ui (transcript is the app's get_entries rpc)`);
|
|
1482
925
|
// Terminator/ack on the ub plane; carries the session clock so the app can
|
|
1483
926
|
// detect a pi restart. `truncated`/`limit` are gone (a replay concern;
|
|
1484
|
-
// get_entries is unbounded / since-delta).
|
|
927
|
+
// get_entries is unbounded / since-delta). `session_name` (pre-release
|
|
928
|
+
// 2026-09-18): the NAME PULL — heals a missed session_info_changed push
|
|
929
|
+
// (an iOS backgrounded socket misses live frames; the relay's stored meta
|
|
930
|
+
// is stale until our next hello, so reconnects can't carry it either).
|
|
931
|
+
// session_sync is issued on EVERY open + reconnect, so the name heals on
|
|
932
|
+
// every natural cycle. Old apps ignore the field; old extensions omit it.
|
|
933
|
+
//
|
|
934
|
+
// SOURCE = the room's DISPLAY name (myRoomMeta.name), NOT deps.sessionName:
|
|
935
|
+
// the latter is `_sessionName`, which `_cmdJoin` sets to the mesh session
|
|
936
|
+
// id LOCAL_SESSION_NAME ("local"). Sending that here clobbered the tile
|
|
937
|
+
// label with "local" on every reconnect (the flaky-name bug) — the
|
|
938
|
+
// non-live pull disagreed with the live session_info push. myRoomMeta.name
|
|
939
|
+
// is the same value pair_ok sends (deps.displayName) and tracks live
|
|
940
|
+
// renames (session_info_changed updates it), so pull == push now.
|
|
941
|
+
const displayName = deps.myRoomMeta?.name;
|
|
942
|
+
// Fork auto-nav: a fork-born session's FIRST sync echoes the app's
|
|
943
|
+
// originating fork request id (design: fork switch). take = read+unlink,
|
|
944
|
+
// so it fires once (the app navigates on first receipt; later reconnects
|
|
945
|
+
// must not re-jump). Keyed by the session id, so it survives the extension
|
|
946
|
+
// module re-eval that ctx.fork triggers.
|
|
947
|
+
const forkSm = _rootState().sessionManager;
|
|
948
|
+
const forkedFromReq = forkSm && typeof forkSm.getSessionDir === "function"
|
|
949
|
+
? takeForkLink(forkSm.getSessionDir(), forkSm.getSessionId())
|
|
950
|
+
: undefined;
|
|
1485
951
|
sender.sendEnvelope({
|
|
1486
952
|
ub: {
|
|
1487
953
|
type: "session_sync_end",
|
|
1488
954
|
...(typeof f.id === "string" ? { in_reply_to: f.id } : {}),
|
|
1489
955
|
session_started_at: _sessionStartedAt ?? 0,
|
|
956
|
+
...(displayName ? { session_name: displayName } : {}),
|
|
957
|
+
...(forkedFromReq ? { forked_from_req: forkedFromReq } : {}),
|
|
1490
958
|
},
|
|
1491
959
|
});
|
|
1492
960
|
return;
|
|
1493
961
|
}
|
|
962
|
+
if (type === "terminate") {
|
|
963
|
+
// app->ext: kill THIS session. Root room only — a child room's terminate
|
|
964
|
+
// is handled by the child-room router in subagent_rooms (never reaches
|
|
965
|
+
// here; that room's conn dispatches its own ub frames). Gate on local
|
|
966
|
+
// config; the app confirms + shows red before sending.
|
|
967
|
+
if (!effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()))) {
|
|
968
|
+
envLog("terminate(ub): remote terminate disabled on this machine");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
envLog(`terminate(ub): graceful host shutdown requested${typeof frame.reason === "string" ? ` (${String(frame.reason)})` : ""}`);
|
|
972
|
+
_safeNotify("Terminated from the app", "warning");
|
|
973
|
+
try {
|
|
974
|
+
// ctx.shutdown() IS the /quit equivalent — pi's own source lists
|
|
975
|
+
// "extension shutdown()" alongside Ctrl+D / /quit (interactive-mode.js
|
|
976
|
+
// 3229: same graceful this.shutdown(): drain input, ui.stop() restores
|
|
977
|
+
// cooked mode + cursor, extension disposal, resume hint, exit). It sits
|
|
978
|
+
// DIRECTLY on the event ctx (ExtensionContext.shutdown — there is no
|
|
979
|
+
// ctx.actions sub-object; the first two attempts failed exactly there).
|
|
980
|
+
//
|
|
981
|
+
// MID-TURN DEFERRAL: the interactive handler fires immediately only when
|
|
982
|
+
// idle; otherwise it sets `shutdownRequested` and waits for
|
|
983
|
+
// `agent_settled` — which never arrives when nothing is running. So
|
|
984
|
+
// abort() FIRST: aborting a running turn forces the settle that
|
|
985
|
+
// re-checks the flag; on an idle session abort is a no-op and the
|
|
986
|
+
// idle fast-path fires directly.
|
|
987
|
+
//
|
|
988
|
+
// NO process.exit fallback, ever: a raw exit leaves the TTY in raw
|
|
989
|
+
// mode (keypresses echo escape codes until `stty sane`). No live ctx =>
|
|
990
|
+
// log loudly and stay up; the user quits on the machine.
|
|
991
|
+
const ctx = _liveCtx();
|
|
992
|
+
if (ctx?.shutdown) {
|
|
993
|
+
ctx.abort?.();
|
|
994
|
+
ctx.shutdown();
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
envLog("terminate(ub): no live ctx — refusing raw exit (would leave the terminal in raw mode); staying up");
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
catch (err) {
|
|
1001
|
+
envLog(`terminate(ub) error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1002
|
+
}
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (type === "close_child_room") {
|
|
1006
|
+
// app->ext(PARENT): permanently close one of MY child rooms (done-subagent
|
|
1007
|
+
// removal — the room lingers by design otherwise). Tombstone + dispose in
|
|
1008
|
+
// subagent_rooms; the relay fires room_ended when the last conn drops.
|
|
1009
|
+
if (!effectiveAllowRemoteTerminate(loadLocalConfig(process.cwd()))) {
|
|
1010
|
+
envLog("close_child_room(ub): remote terminate disabled on this machine");
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const roomId = frame.room_id;
|
|
1014
|
+
if (typeof roomId !== "string" || roomId.length === 0)
|
|
1015
|
+
return;
|
|
1016
|
+
const closed = _subagentRooms?.closeChildRoom(roomId) ?? false;
|
|
1017
|
+
envLog(`close_child_room(ub): room=${roomId.slice(0, 8)} closed=${closed}`);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1494
1020
|
if (type === "session_launch") {
|
|
1495
1021
|
const f = frame;
|
|
1496
1022
|
const cwd = _expandTilde(typeof f.cwd === "string" && f.cwd.length > 0 ? f.cwd : process.cwd());
|
|
@@ -1498,6 +1024,16 @@ function _routeUnBienPlaneFrom(sender, env) {
|
|
|
1498
1024
|
envLog("session_launch(ub): remote launch disabled on this machine");
|
|
1499
1025
|
return;
|
|
1500
1026
|
}
|
|
1027
|
+
// Directory allow-list (design 01M211VW9), read FRESH each request.
|
|
1028
|
+
if (!launchDirAllowed(cwd, loadConfig().launch?.dirs)) {
|
|
1029
|
+
envLog("session_launch(ub): cwd not in launch.dirs allow-list");
|
|
1030
|
+
sender.send({
|
|
1031
|
+
type: "error",
|
|
1032
|
+
code: "permission_denied",
|
|
1033
|
+
message: "Remote launch not permitted for this directory",
|
|
1034
|
+
});
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1501
1037
|
// Backend is a MACHINE config choice (pick-one via launch.backend), not
|
|
1502
1038
|
// app-chosen; rpc is a fast-follow so only tmux|herdr resolve here.
|
|
1503
1039
|
const backend = loadConfig().launch?.backend === "herdr" ? "herdr" : "tmux";
|
|
@@ -1506,67 +1042,42 @@ function _routeUnBienPlaneFrom(sender, env) {
|
|
|
1506
1042
|
envLog(`session_launch(ub) error: ${launchError}`);
|
|
1507
1043
|
return;
|
|
1508
1044
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
}
|
|
1524
|
-
/** Fan an rpc-envelope frame out to every attached peer (base64 ct via each
|
|
1525
|
-
* channel) — the single owner-fanout path for `{ rpc | evt }` messages. */
|
|
1526
|
-
function _broadcastEnvelope(env) {
|
|
1527
|
-
{
|
|
1528
|
-
// Observability only (not a route gate): watch the {rpc|evt} wire during
|
|
1529
|
-
// e2e bring-up. Frame type only — payloads can be large / carry images.
|
|
1530
|
-
const kind = env.rpc
|
|
1531
|
-
? `rpc:${env.rpc.type ?? "?"}`
|
|
1532
|
-
: `evt:${env.evt?.channel ?? "?"}`;
|
|
1533
|
-
envLog(`envelope -> ${_activePeers.size} peer(s): ${kind}`);
|
|
1534
|
-
}
|
|
1535
|
-
for (const ch of _activePeers.values()) {
|
|
1536
|
-
try {
|
|
1537
|
-
ch.sendEnvelope(env);
|
|
1045
|
+
if (type === "session_fork" || type === "session_navigate") {
|
|
1046
|
+
// app->ext: fork a NEW session / branch IN PLACE from a conversation entry.
|
|
1047
|
+
// ctx.fork / ctx.navigateTree live ONLY on the command ctx, so we can't act
|
|
1048
|
+
// here (this is the peer-channel dispatch, not a command). Self-dispatch the
|
|
1049
|
+
// registered `/unbien fork|branch` command via sendUserMessage — pi runs it
|
|
1050
|
+
// with a real ExtensionCommandContext. No stashed ctx, no startup bootstrap:
|
|
1051
|
+
// the command ctx lives exactly as long as the op that needs it. Not gated
|
|
1052
|
+
// beyond being paired — these are no more privileged than a prompt on this
|
|
1053
|
+
// same room (which the app can already send).
|
|
1054
|
+
const f = frame;
|
|
1055
|
+
const entryId = typeof f.entry_id === "string" ? f.entry_id : "";
|
|
1056
|
+
if (!entryId) {
|
|
1057
|
+
envLog(`${String(type)}(ub): missing entry_id — ignored`);
|
|
1058
|
+
return;
|
|
1538
1059
|
}
|
|
1539
|
-
|
|
1540
|
-
|
|
1060
|
+
if (type === "session_fork") {
|
|
1061
|
+
// Thread the request id (reqId) so the new session can echo it back as
|
|
1062
|
+
// forked_from_req on its first sync — the app's auto-nav correlation.
|
|
1063
|
+
// Order: `<entryId> <reqId> [pos]` (see _cmdFork's parse).
|
|
1064
|
+
const reqId = typeof f.id === "string" ? f.id : "";
|
|
1065
|
+
const posArg = f.position === "at" || f.position === "before" ? ` ${f.position}` : "";
|
|
1066
|
+
const reqArg = reqId ? ` ${reqId}` : "";
|
|
1067
|
+
envLog(`session_fork(ub): entry=${entryId.slice(0, 8)}${posArg}`);
|
|
1068
|
+
_pi?.sendUserMessage(`/unbien fork ${entryId}${reqArg}${posArg}`, {
|
|
1069
|
+
deliverAs: "followUp",
|
|
1070
|
+
expandPromptTemplates: true,
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
else {
|
|
1074
|
+
envLog(`session_navigate(ub): entry=${entryId.slice(0, 8)}`);
|
|
1075
|
+
_pi?.sendUserMessage(`/unbien branch ${entryId}`, {
|
|
1076
|
+
deliverAs: "followUp",
|
|
1077
|
+
expandPromptTemplates: true,
|
|
1078
|
+
});
|
|
1541
1079
|
}
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1544
|
-
/**
|
|
1545
|
-
* Adds an owner's channel to `_activePeers`. Also updates the UX hint
|
|
1546
|
-
* `_peerShort` (last-attached shortid) so the footer + status can pick
|
|
1547
|
-
* a representative device when only one is connected.
|
|
1548
|
-
*/
|
|
1549
|
-
function _attachPeerChannel(appPeerId, channel) {
|
|
1550
|
-
_activePeers.set(appPeerId, channel);
|
|
1551
|
-
_peerShort = appPeerId.slice(0, 8);
|
|
1552
|
-
}
|
|
1553
|
-
/** Detaches a single owner's channel + removes it from the map. Used by
|
|
1554
|
-
* `_onPeerDisconnect`, `_cmdRevoke`, and the SelfRevoke callback. */
|
|
1555
|
-
function _detachPeerChannel(appPeerId) {
|
|
1556
|
-
const ch = _activePeers.get(appPeerId);
|
|
1557
|
-
if (!ch)
|
|
1558
1080
|
return;
|
|
1559
|
-
try {
|
|
1560
|
-
ch.detach();
|
|
1561
|
-
}
|
|
1562
|
-
catch {
|
|
1563
|
-
/* best-effort */
|
|
1564
|
-
}
|
|
1565
|
-
_activePeers.delete(appPeerId);
|
|
1566
|
-
if (_peerShort === appPeerId.slice(0, 8)) {
|
|
1567
|
-
// Pick a different remaining peer for the UX hint, or clear when none.
|
|
1568
|
-
const next = _activePeers.keys().next().value;
|
|
1569
|
-
_peerShort = next ? next.slice(0, 8) : "";
|
|
1570
1081
|
}
|
|
1571
1082
|
}
|
|
1572
1083
|
// ── Display-name helpers ──────────────────────────────────────────────────────
|
|
@@ -1588,12 +1099,14 @@ function _detachPeerChannel(appPeerId) {
|
|
|
1588
1099
|
* inline at three different sites (pair_ok, room_meta, QR URI); this
|
|
1589
1100
|
* helper consolidates them and lifts the user's configured name above
|
|
1590
1101
|
* the raw cwd path.
|
|
1102
|
+
*
|
|
1103
|
+
* A session-scoped name (pi `-n`, set by the remote launcher) slots in just
|
|
1104
|
+
* below the broker-assigned mesh name — see `resolveAgentName`.
|
|
1591
1105
|
*/
|
|
1592
1106
|
function _displayName(cwd) {
|
|
1593
1107
|
if (_meshNode)
|
|
1594
1108
|
return _meshNode.name();
|
|
1595
|
-
|
|
1596
|
-
return local.agent_name || defaultAgentName(cwd);
|
|
1109
|
+
return resolveAgentName(cwd, piSessionName(_pi));
|
|
1597
1110
|
}
|
|
1598
1111
|
function _reportRevocationByFingerprint(canonicalOwnerPubkey) {
|
|
1599
1112
|
const fingerprint = _runtimeOwnerFingerprint(canonicalOwnerPubkey);
|
|
@@ -1609,299 +1122,10 @@ function _revokeActiveOwnerRuntime(canonicalOwnerPubkey) {
|
|
|
1609
1122
|
if (!_activePeers.has(canonicalOwnerPubkey))
|
|
1610
1123
|
return;
|
|
1611
1124
|
_refreshPairingsCache();
|
|
1612
|
-
_detachPeerChannel(canonicalOwnerPubkey);
|
|
1125
|
+
_detachPeerChannel(relayDeps, canonicalOwnerPubkey);
|
|
1613
1126
|
_refreshFooter();
|
|
1614
1127
|
_reportRevocationByFingerprint(canonicalOwnerPubkey);
|
|
1615
1128
|
}
|
|
1616
|
-
// ── Transition helpers ────────────────────────────────────────────────────────
|
|
1617
|
-
/**
|
|
1618
|
-
* Full teardown: stop listener, detach channel, close relay → idle.
|
|
1619
|
-
*/
|
|
1620
|
-
function _goIdle() {
|
|
1621
|
-
_rootLifecycleGeneration += 1;
|
|
1622
|
-
_relayLifecycleGeneration += 1;
|
|
1623
|
-
// Cancel any pending reconnect attempt. Critical: /unbien stop must
|
|
1624
|
-
// win the race against a scheduled reconnect.
|
|
1625
|
-
if (_reconnectTimer !== null) {
|
|
1626
|
-
clearTimeout(_reconnectTimer);
|
|
1627
|
-
_reconnectTimer = null;
|
|
1628
|
-
}
|
|
1629
|
-
_reconnectAttempt = 0;
|
|
1630
|
-
_stopAutoListener?.();
|
|
1631
|
-
_stopAutoListener = null;
|
|
1632
|
-
// Tear down every per-owner channel and clear the map.
|
|
1633
|
-
for (const ch of _activePeers.values()) {
|
|
1634
|
-
try {
|
|
1635
|
-
ch.detach();
|
|
1636
|
-
}
|
|
1637
|
-
catch {
|
|
1638
|
-
/* best-effort */
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
_activePeers.clear();
|
|
1642
|
-
_peerShort = "";
|
|
1643
|
-
_rootState().turnId = null;
|
|
1644
|
-
_pendingReceivedImagePreviews.length = 0;
|
|
1645
|
-
// Invalidate async producers and bridge ownership before closing the host
|
|
1646
|
-
// Relay. A synchronous/delayed close callback must observe stale identity.
|
|
1647
|
-
const producer = _selfRevoke;
|
|
1648
|
-
_selfRevoke = null;
|
|
1649
|
-
_selfRevokeEpoch += 1;
|
|
1650
|
-
_selfRevokeTopologyReadyEpoch = -1;
|
|
1651
|
-
_selfRevokeTopology = null;
|
|
1652
|
-
producer?.stop();
|
|
1653
|
-
_meshNode?.detachBridge();
|
|
1654
|
-
const relay = _relay;
|
|
1655
|
-
_relay = null;
|
|
1656
|
-
_relayUrl = null;
|
|
1657
|
-
relay?.close();
|
|
1658
|
-
// Preserve _sessionStartedAt + _messageBuffer across stop/start cycles.
|
|
1659
|
-
// The Pi agent session outlives the relay connection — `message_end` keeps
|
|
1660
|
-
// firing for terminal turns even while idle, and the buffer must survive
|
|
1661
|
-
// so those turns appear in the next session_sync. Only a Pi process
|
|
1662
|
-
// restart resets these (init-time values).
|
|
1663
|
-
_state = "idle";
|
|
1664
|
-
_refreshFooter();
|
|
1665
|
-
_emitRelayState(); // → disconnected
|
|
1666
|
-
}
|
|
1667
|
-
/**
|
|
1668
|
-
* Called when the relay WS closes unexpectedly (network drop, relay restart,
|
|
1669
|
-
* etc.). Does a **partial** teardown — keeps `_sessionStartedAt`, `_messageBuffer`,
|
|
1670
|
-
* `_relayUrl`, `_cachedEd25519`, `_peerShort` so the session can resume on
|
|
1671
|
-
* reconnect — and schedules an `_attemptReconnect`.
|
|
1672
|
-
*
|
|
1673
|
-
* Peer (app) reconnect after a successful relay reconnect is handled by the
|
|
1674
|
-
* existing auto-listener via `peers.json` lookup, so we don't need to track
|
|
1675
|
-
* the prior peer here; we just go back to `started` and wait.
|
|
1676
|
-
*/
|
|
1677
|
-
function _onRelayClose(closedRelay) {
|
|
1678
|
-
if (_relay !== closedRelay)
|
|
1679
|
-
return; // delayed close from a replaced Relay
|
|
1680
|
-
if (_state === "idle")
|
|
1681
|
-
return; // already torn down (e.g. /unbien stop)
|
|
1682
|
-
_relayLifecycleGeneration += 1;
|
|
1683
|
-
_stopAutoListener?.();
|
|
1684
|
-
_stopAutoListener = null;
|
|
1685
|
-
// Detach every per-owner channel — relay is gone, none can route. The
|
|
1686
|
-
// auto-listener re-attaches owners after `_attemptReconnect` succeeds
|
|
1687
|
-
// (via the same known-peer + pair_request paths used on first connect).
|
|
1688
|
-
for (const ch of _activePeers.values()) {
|
|
1689
|
-
try {
|
|
1690
|
-
ch.detach();
|
|
1691
|
-
}
|
|
1692
|
-
catch {
|
|
1693
|
-
/* best-effort */
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
|
-
_activePeers.clear();
|
|
1697
|
-
_peerShort = "";
|
|
1698
|
-
_rootState().turnId = null;
|
|
1699
|
-
_relay = null; // _relayUrl preserved for retry
|
|
1700
|
-
// Cross-PC routing relies on _relay; bring it down. Will be re-instated
|
|
1701
|
-
// by _attemptReconnect on success.
|
|
1702
|
-
_meshNode?.detachBridge();
|
|
1703
|
-
_state = "started";
|
|
1704
|
-
_refreshFooter();
|
|
1705
|
-
_emitRelayState(); // → reconnecting
|
|
1706
|
-
const reconnectUrl = _relayUrl;
|
|
1707
|
-
if (reconnectUrl) {
|
|
1708
|
-
_scheduleReconnect(_relayLifecycleGeneration, reconnectUrl);
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
function _isCurrentReconnect(lifecycleGeneration, url) {
|
|
1712
|
-
return (lifecycleGeneration === _relayLifecycleGeneration &&
|
|
1713
|
-
_state === "started" &&
|
|
1714
|
-
_relay === null &&
|
|
1715
|
-
_relayUrl === url);
|
|
1716
|
-
}
|
|
1717
|
-
function _scheduleReconnect(lifecycleGeneration, url) {
|
|
1718
|
-
if (_reconnectTimer !== null)
|
|
1719
|
-
return; // already scheduled
|
|
1720
|
-
if (!_cachedEd25519)
|
|
1721
|
-
return; // can't reconnect without the cached identity
|
|
1722
|
-
if (!_isCurrentReconnect(lifecycleGeneration, url))
|
|
1723
|
-
return;
|
|
1724
|
-
const idx = Math.min(_reconnectAttempt, RECONNECT_BACKOFFS_MS.length - 1);
|
|
1725
|
-
const delay = RECONNECT_BACKOFFS_MS[idx];
|
|
1726
|
-
_reconnectAttempt += 1;
|
|
1727
|
-
// The timer belongs to the lifecycle that scheduled it. Re-check that exact
|
|
1728
|
-
// generation + URL before constructing a candidate so a dequeued old timer
|
|
1729
|
-
// cannot act on a newer stop/start lifecycle.
|
|
1730
|
-
_reconnectTimer = setTimeout(() => {
|
|
1731
|
-
_reconnectTimer = null;
|
|
1732
|
-
if (!_isCurrentReconnect(lifecycleGeneration, url))
|
|
1733
|
-
return;
|
|
1734
|
-
void _attemptReconnect(lifecycleGeneration, url);
|
|
1735
|
-
}, delay);
|
|
1736
|
-
}
|
|
1737
|
-
async function _attemptReconnect(lifecycleGeneration, url) {
|
|
1738
|
-
if (!_cachedEd25519)
|
|
1739
|
-
return;
|
|
1740
|
-
if (!_isCurrentReconnect(lifecycleGeneration, url))
|
|
1741
|
-
return;
|
|
1742
|
-
const edKp = _cachedEd25519;
|
|
1743
|
-
// _relayUrl is stored in canonical http(s):// form — convert at the
|
|
1744
|
-
// WS boundary, same as _cmdStart.
|
|
1745
|
-
const relay = new RelayClient(toWebSocketUrl(url), edKp);
|
|
1746
|
-
try {
|
|
1747
|
-
// Replay the same room identity from _cmdStart. Without this the relay
|
|
1748
|
-
// would log this WS as a default-room peer and the app would see a
|
|
1749
|
-
// phantom "legacy session" appear (regression of plano 17 + 18).
|
|
1750
|
-
await relay.connect({
|
|
1751
|
-
...(_myRoomId ? { roomId: _myRoomId } : {}),
|
|
1752
|
-
...(_myRoomMeta ? { roomMeta: _myRoomMeta } : {}),
|
|
1753
|
-
});
|
|
1754
|
-
}
|
|
1755
|
-
catch {
|
|
1756
|
-
// A reconnect candidate stays local until publication; every rejected
|
|
1757
|
-
// candidate is deterministically closed before stale-return or retry.
|
|
1758
|
-
try {
|
|
1759
|
-
relay.close();
|
|
1760
|
-
}
|
|
1761
|
-
catch {
|
|
1762
|
-
/* best-effort rejected candidate cleanup */
|
|
1763
|
-
}
|
|
1764
|
-
if (!_isCurrentReconnect(lifecycleGeneration, url))
|
|
1765
|
-
return;
|
|
1766
|
-
_scheduleReconnect(lifecycleGeneration, url);
|
|
1767
|
-
return;
|
|
1768
|
-
}
|
|
1769
|
-
if (!_isCurrentReconnect(lifecycleGeneration, url)) {
|
|
1770
|
-
try {
|
|
1771
|
-
relay.close();
|
|
1772
|
-
}
|
|
1773
|
-
catch {
|
|
1774
|
-
/* best-effort stale candidate cleanup */
|
|
1775
|
-
}
|
|
1776
|
-
return;
|
|
1777
|
-
}
|
|
1778
|
-
_relay = relay;
|
|
1779
|
-
_reconnectAttempt = 0;
|
|
1780
|
-
relay.on("close", () => _onRelayClose(relay));
|
|
1781
|
-
_stopAutoListener = _installAutoListener(relay);
|
|
1782
|
-
// Plan/25 Wave B/C: relay is back; bring cross-PC routing back online.
|
|
1783
|
-
_attachBridgeIfReady();
|
|
1784
|
-
// _state stays "started"; peer reconnect (if previously paired) flows
|
|
1785
|
-
// through _installAutoListener → _findKnownPeer → _promoteToPaired
|
|
1786
|
-
// automatically when the app sends any inner.
|
|
1787
|
-
_emitRelayState();
|
|
1788
|
-
}
|
|
1789
|
-
// ── Relay state event + transparent control channel (Cockpit toggle) ─────────
|
|
1790
|
-
/** Current relay connectivity, derived from `_state` + `_relay`. */
|
|
1791
|
-
function _relayStatus() {
|
|
1792
|
-
if (_getState() === "idle")
|
|
1793
|
-
return "disconnected";
|
|
1794
|
-
return _relay ? "connected" : "reconnecting";
|
|
1795
|
-
}
|
|
1796
|
-
/**
|
|
1797
|
-
* Emit the `un-bien:relay-state` custom message so an RPC client (Cockpit)
|
|
1798
|
-
* can render a relay on/off indicator. Pure data (`display:false`) — never
|
|
1799
|
-
* shown in the transcript. De-duped on the connectivity value; pass
|
|
1800
|
-
* `force=true` to answer an explicit `relay:status` query regardless.
|
|
1801
|
-
*/
|
|
1802
|
-
function _emitRelayState(force = false) {
|
|
1803
|
-
const status = _relayStatus();
|
|
1804
|
-
if (!force && status === _lastRelayStatus)
|
|
1805
|
-
return;
|
|
1806
|
-
_lastRelayStatus = status;
|
|
1807
|
-
// This can run inside a WebSocket 'close' callback (via _onRelayClose). After a
|
|
1808
|
-
// session replacement (newSession/fork/switchSession/reload) the module-level
|
|
1809
|
-
// `_pi` is stale, and `assertActive` throws synchronously inside `sendMessage`.
|
|
1810
|
-
// An uncaught throw from a WS event callback becomes a process-level
|
|
1811
|
-
// uncaughtException and exits pi. Swallow it here: the next relay-state
|
|
1812
|
-
// change re-emits, so connectivity is eventually consistent. See issue #55.
|
|
1813
|
-
try {
|
|
1814
|
-
_pi?.sendMessage({
|
|
1815
|
-
customType: "un-bien:relay-state",
|
|
1816
|
-
content: `Relay ${status}`,
|
|
1817
|
-
details: {
|
|
1818
|
-
status,
|
|
1819
|
-
connected: status === "connected",
|
|
1820
|
-
...(_relayUrl ? { relayUrl: _relayUrl } : {}),
|
|
1821
|
-
...(_myRoomId ? { room: _myRoomId } : {}),
|
|
1822
|
-
},
|
|
1823
|
-
display: false,
|
|
1824
|
-
});
|
|
1825
|
-
}
|
|
1826
|
-
catch {
|
|
1827
|
-
// _pi stale (session replaced) or extension runtime not yet bound.
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
/** Minimal ctx for relay start/stop driven by a control message (no command
|
|
1831
|
-
* ctx is available in the `input` hook). cwd matches the daemon's launch dir,
|
|
1832
|
-
* so the derived relay room is identical to the one `_cmdStart` first used. */
|
|
1833
|
-
function _controlCtx() {
|
|
1834
|
-
// SAFETY: _headlessUi() implements every ui method the relay start/stop path
|
|
1835
|
-
// actually calls; the notify-forwarding shim is structurally narrower than the
|
|
1836
|
-
// full ExtensionContext["ui"] but complete for this headless control path.
|
|
1837
|
-
return {
|
|
1838
|
-
ui: _headlessUi(),
|
|
1839
|
-
cwd: process.cwd(),
|
|
1840
|
-
};
|
|
1841
|
-
}
|
|
1842
|
-
/**
|
|
1843
|
-
* `ui.notify` for headless contexts (daemon auto-init + control channel). There
|
|
1844
|
-
* is no TUI, and the RPC client (Cockpit) already gets everything it needs via
|
|
1845
|
-
* structured events (`un-bien:relay-state`, `un-bien:name-assigned`,
|
|
1846
|
-
* room_meta) — so routine INFO chatter would just pollute the client's captured
|
|
1847
|
-
* stderr. We drop info and forward only warnings/errors (kept for the
|
|
1848
|
-
* supervisor's journal / genuine failures). The interactive Pi keeps its normal
|
|
1849
|
-
* footer/notify path — this only affects headless ctxs.
|
|
1850
|
-
*/
|
|
1851
|
-
function _headlessUi() {
|
|
1852
|
-
return {
|
|
1853
|
-
notify: (msg, type) => {
|
|
1854
|
-
if (type === "warning" || type === "error")
|
|
1855
|
-
process.stderr.write(`${msg}\n`);
|
|
1856
|
-
},
|
|
1857
|
-
};
|
|
1858
|
-
}
|
|
1859
|
-
/**
|
|
1860
|
-
* Handle a transparent control command from an RPC client (Cockpit), received
|
|
1861
|
-
* as a `CTRL_PREFIX`-tagged input the `input` hook swallowed. Toggles the relay
|
|
1862
|
-
* WITHOUT leaving the local mesh (relay-only: `_cmdStart` up / `_goIdle` down),
|
|
1863
|
-
* then emits the fresh state. `relay:status` just re-emits (no change) so the
|
|
1864
|
-
* client can sync its button after (re)attaching to the RPC stream.
|
|
1865
|
-
*/
|
|
1866
|
-
export async function _handleControl(cmd) {
|
|
1867
|
-
// `rename:<new-name>` carries an argument, so it's matched before the
|
|
1868
|
-
// fixed-verb switch. Renames the agent live (broker re-register + relay room
|
|
1869
|
-
// swap) WITHOUT restarting the process or losing the SDK session.
|
|
1870
|
-
if (cmd.startsWith("rename:")) {
|
|
1871
|
-
await _renameAgent(cmd.slice("rename:".length).trim());
|
|
1872
|
-
return;
|
|
1873
|
-
}
|
|
1874
|
-
switch (cmd) {
|
|
1875
|
-
case "relay:on":
|
|
1876
|
-
if (_getState() === "idle")
|
|
1877
|
-
await _cmdStart(_controlCtx());
|
|
1878
|
-
_emitRelayState(true);
|
|
1879
|
-
return;
|
|
1880
|
-
case "relay:off":
|
|
1881
|
-
if (_getState() === "idle") {
|
|
1882
|
-
_rootLifecycleGeneration += 1;
|
|
1883
|
-
_relayLifecycleGeneration += 1;
|
|
1884
|
-
}
|
|
1885
|
-
else
|
|
1886
|
-
_goIdle();
|
|
1887
|
-
_emitRelayState(true);
|
|
1888
|
-
return;
|
|
1889
|
-
case "relay:toggle":
|
|
1890
|
-
if (_getState() === "idle")
|
|
1891
|
-
await _cmdStart(_controlCtx());
|
|
1892
|
-
else
|
|
1893
|
-
_goIdle();
|
|
1894
|
-
_emitRelayState(true);
|
|
1895
|
-
return;
|
|
1896
|
-
case "relay:status":
|
|
1897
|
-
_emitRelayState(true);
|
|
1898
|
-
return;
|
|
1899
|
-
default:
|
|
1900
|
-
// Unknown control verb — ignore (forward-compat: a newer client may send
|
|
1901
|
-
// verbs an older extension doesn't know).
|
|
1902
|
-
return;
|
|
1903
|
-
}
|
|
1904
|
-
}
|
|
1905
1129
|
/**
|
|
1906
1130
|
* Rename the agent LIVE (plan/38/41), without restarting the process or losing
|
|
1907
1131
|
* the SDK session/conversation. Touches two layers:
|
|
@@ -1931,7 +1155,7 @@ async function _renameAgent(newName) {
|
|
|
1931
1155
|
// clean; bring it back up after with the new name.
|
|
1932
1156
|
const wasStarted = _getState() !== "idle";
|
|
1933
1157
|
if (wasStarted)
|
|
1934
|
-
_goIdle();
|
|
1158
|
+
_goIdle(relayDeps);
|
|
1935
1159
|
let assigned = newName;
|
|
1936
1160
|
try {
|
|
1937
1161
|
assigned = await _meshNode.rename(newName); // broker soft rejoin
|
|
@@ -1940,7 +1164,7 @@ async function _renameAgent(newName) {
|
|
|
1940
1164
|
ctx.ui.notify(`[un-bien] rename failed: ${String(err)}`, "error");
|
|
1941
1165
|
}
|
|
1942
1166
|
if (wasStarted && !_disposed)
|
|
1943
|
-
await _cmdStart(ctx); // relay back up → roomIdFor(cwd, assigned)
|
|
1167
|
+
await _cmdStart(deps, ctx); // relay back up → roomIdFor(cwd, assigned)
|
|
1944
1168
|
_pi?.sendMessage({
|
|
1945
1169
|
customType: "un-bien:name-assigned",
|
|
1946
1170
|
content: assigned === newName
|
|
@@ -1950,338 +1174,48 @@ async function _renameAgent(newName) {
|
|
|
1950
1174
|
display: false,
|
|
1951
1175
|
});
|
|
1952
1176
|
}
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
1959
|
-
*
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1177
|
+
// ── Extension factory (default export) ───────────────────────────────────────
|
|
1178
|
+
// One-shot flag for the turn-ctx surface probe (see the turn_start handler).
|
|
1179
|
+
let _turnCtxProbed = false;
|
|
1180
|
+
/** TARGETED, PROXY-SAFE ctx probe: the ctx objects carry guarded getters
|
|
1181
|
+
* (throwing on invalid/stale access — see _ctxUi), so full reflection
|
|
1182
|
+
* under-reports or throws. Access each interesting member individually,
|
|
1183
|
+
* catching per property. Reflection-only: never invokes anything. */
|
|
1184
|
+
function _probeCtx(label, ctx) {
|
|
1185
|
+
const names = [
|
|
1186
|
+
"fork",
|
|
1187
|
+
"navigateTree",
|
|
1188
|
+
"newSession",
|
|
1189
|
+
"switchSession",
|
|
1190
|
+
"compact",
|
|
1191
|
+
"shutdown",
|
|
1192
|
+
"sendMessage",
|
|
1193
|
+
"prompt",
|
|
1194
|
+
"clearQueue",
|
|
1195
|
+
];
|
|
1196
|
+
const parts = [];
|
|
1197
|
+
// SAFETY: per-property try/catch — a throwing getter (guarded proxy)
|
|
1198
|
+
// reports "throws" instead of failing the whole probe.
|
|
1199
|
+
const target = ctx;
|
|
1200
|
+
for (const name of names) {
|
|
1201
|
+
try {
|
|
1202
|
+
const v = target[name];
|
|
1203
|
+
parts.push(`${name}=${typeof v === "function" ? "fn" : String(v)}`);
|
|
1204
|
+
}
|
|
1205
|
+
catch {
|
|
1206
|
+
parts.push(`${name}=throws`);
|
|
1207
|
+
}
|
|
1978
1208
|
}
|
|
1979
|
-
|
|
1980
|
-
// starts cleanly.
|
|
1981
|
-
_rootState().turnId = null;
|
|
1982
|
-
_refreshFooter();
|
|
1983
|
-
_safeNotify("[un-bien] All app peers disconnected, listening for reconnect", "info");
|
|
1984
|
-
// Auto-listener stays up — same listener catches the reconnect on any peer.
|
|
1209
|
+
envLog(`${label} targeted: ${parts.join(" ")}`);
|
|
1985
1210
|
}
|
|
1986
|
-
/**
|
|
1987
|
-
* Attaches a new owner channel to the multi-owner set. Replaces the
|
|
1988
|
-
* pre-W2D singleton `_promoteToPaired` which set `_state = "paired"` and
|
|
1989
|
-
* a single `_peerChannel`. The relay state remains `started`; pairing
|
|
1990
|
-
* status is derived from `_activePeers.size`.
|
|
1991
|
-
*
|
|
1992
|
-
* Idempotent for the same `appPeerId` (re-attaching tears down the prior
|
|
1993
|
-
* channel and installs a fresh one — covers reconnect from the same
|
|
1994
|
-
* device without leaking listeners).
|
|
1995
|
-
*/
|
|
1996
|
-
function _attachOwner(relay, appPeerId, peerName, firstInner) {
|
|
1997
|
-
const peerShort = appPeerId.slice(0, 8);
|
|
1998
|
-
// Drop any stale channel for this owner before re-attaching.
|
|
1999
|
-
if (_activePeers.has(appPeerId))
|
|
2000
|
-
_detachPeerChannel(appPeerId);
|
|
2001
|
-
// Prefer always-fresh session_start ctx for async relay routing — `_lastCtx`
|
|
2002
|
-
// is a captured command ctx that goes stale after session replacement (#55).
|
|
2003
|
-
const channel = new PlainPeerChannel(relay, appPeerId, _myRoomId ?? undefined, (msg) => _routeClientMessageFrom(channel, msg, _liveCtx() ?? _noopCtx), () => _onPeerDisconnect(appPeerId), (env) => env.ub === undefined
|
|
2004
|
-
? _routeRpcCommandFrom(channel, env)
|
|
2005
|
-
: _routeUnBienPlaneFrom(channel, env), () => _rootState().sessionManager?.getSessionId() ??
|
|
2006
|
-
_rootSessionId ??
|
|
2007
|
-
undefined);
|
|
2008
|
-
_attachPeerChannel(appPeerId, channel);
|
|
2009
|
-
// Envelope-native capability handshake: advertise caps up front so the app can
|
|
2010
|
-
// enable the {rpc|evt} route + suppress stock before any session content
|
|
2011
|
-
// arrives. Additive to the stock session_history caps (parity transition).
|
|
2012
|
-
const _sid = _rootState().sessionManager?.getSessionId();
|
|
2013
|
-
channel.sendEnvelope(helloEnvelope(_capabilities(), _sid));
|
|
2014
|
-
envLog(`attach: peer=${appPeerId.slice(0, 8)} hello sent (caps + sessionId=${_sid ?? "?"}); active=${_activePeers.size}`);
|
|
2015
|
-
// Reconstruction (transcript + panels + extension_ui) is request-driven: the
|
|
2016
|
-
// app issues session_sync — on fresh open AND on relay reconnect — and the
|
|
2017
|
-
// handler in _routeUnBienPlaneFrom replays all of it. Re-sync is idempotent
|
|
2018
|
-
// (stable identify ids + ns/id panel merge), so nothing is replayed
|
|
2019
|
-
// proactively here.
|
|
2020
|
-
_refreshFooter();
|
|
2021
|
-
_safeNotify(`[un-bien] Owner attached: peer=${peerShort}, name=${peerName} ` +
|
|
2022
|
-
`(${_activePeers.size} active)`, "info");
|
|
2023
|
-
if (firstInner) {
|
|
2024
|
-
// The PlainPeerChannel listener fired on the same line that triggered
|
|
2025
|
-
// attachment in some flows; we route explicitly here too to ensure the
|
|
2026
|
-
// inner reaches the handler exactly once.
|
|
2027
|
-
void firstInner;
|
|
2028
|
-
}
|
|
2029
|
-
return channel;
|
|
2030
|
-
}
|
|
2031
|
-
// ── Auto-listener ─────────────────────────────────────────────────────────────
|
|
2032
|
-
//
|
|
2033
|
-
// Installed while in 'started' state. Decodes the outer envelope as
|
|
2034
|
-
// base64(JSON) and dispatches per sender peer_id:
|
|
2035
|
-
// • Sender already in `_activePeers` → ignored here (the per-owner
|
|
2036
|
-
// PlainPeerChannel listens on the same relay event and handles its own
|
|
2037
|
-
// traffic via its `remotePeerId` filter)
|
|
2038
|
-
// • `pair_request` from a new peer → validate token, persist peer, send
|
|
2039
|
-
// pair_ok/pair_error, attach a new channel
|
|
2040
|
-
// • Non-pair message from a known peer (peers.json) without an active
|
|
2041
|
-
// channel yet → attach + route the inner (reconnect path)
|
|
2042
|
-
// • Anything else (unknown peer + non-pair) → emit `error: unknown_peer`
|
|
2043
|
-
function _installAutoListener(relay) {
|
|
2044
|
-
const listenerGeneration = _relayLifecycleGeneration;
|
|
2045
|
-
const hasListenerAuthority = () => !_disposed &&
|
|
2046
|
-
_state === "started" &&
|
|
2047
|
-
_relay === relay &&
|
|
2048
|
-
_relayLifecycleGeneration === listenerGeneration;
|
|
2049
|
-
const onMsg = async (line) => {
|
|
2050
|
-
let outer;
|
|
2051
|
-
try {
|
|
2052
|
-
outer = JSON.parse(line);
|
|
2053
|
-
}
|
|
2054
|
-
catch {
|
|
2055
|
-
return;
|
|
2056
|
-
}
|
|
2057
|
-
if (!outer.peer || !outer.ct)
|
|
2058
|
-
return;
|
|
2059
|
-
if (!hasListenerAuthority())
|
|
2060
|
-
return;
|
|
2061
|
-
// Already-attached owners: their PlainPeerChannel handles routing.
|
|
2062
|
-
if (_activePeers.has(outer.peer))
|
|
2063
|
-
return;
|
|
2064
|
-
// Decode inner envelope (base64 JSON)
|
|
2065
|
-
let inner;
|
|
2066
|
-
try {
|
|
2067
|
-
const plaintext = Buffer.from(outer.ct, "base64").toString("utf8");
|
|
2068
|
-
const parsed = JSON.parse(plaintext);
|
|
2069
|
-
if (!parsed ||
|
|
2070
|
-
typeof parsed !== "object" ||
|
|
2071
|
-
typeof parsed.type !== "string")
|
|
2072
|
-
return;
|
|
2073
|
-
inner = parsed;
|
|
2074
|
-
}
|
|
2075
|
-
catch {
|
|
2076
|
-
return;
|
|
2077
|
-
}
|
|
2078
|
-
const appPeerId = outer.peer;
|
|
2079
|
-
if (inner.type === "pair_request") {
|
|
2080
|
-
await _handlePairRequest(relay, appPeerId, inner, hasListenerAuthority);
|
|
2081
|
-
return;
|
|
2082
|
-
}
|
|
2083
|
-
// Reconnect path: known peer (peers.json) without an active channel
|
|
2084
|
-
// sends a non-pair message → attach + route through the new channel.
|
|
2085
|
-
// See pairing.md §Reconexão.
|
|
2086
|
-
const known = await _findKnownPeer(appPeerId);
|
|
2087
|
-
if (!hasListenerAuthority())
|
|
2088
|
-
return;
|
|
2089
|
-
if (known) {
|
|
2090
|
-
const channel = _attachOwner(relay, appPeerId, known.name);
|
|
2091
|
-
// The channel listener didn't see the line that triggered the attach, so
|
|
2092
|
-
// route it explicitly — MIRRORING the channel's own dispatch (peer_channel
|
|
2093
|
-
// _onLine): a real-typed envelope ("rpc"/"evt"/"ub", legacy "env") or a
|
|
2094
|
-
// bare rpc/evt/ub body goes to the envelope dispatcher, a stock
|
|
2095
|
-
// ClientMessage to the stock switch. Everything is on the envelope proto
|
|
2096
|
-
// now, so the first message is normally the ub session_sync (or the rpc
|
|
2097
|
-
// get_entries) — routing that through the stock switch dropped it. Use
|
|
2098
|
-
// _liveCtx (session_start-fresh), not #55.
|
|
2099
|
-
const innerObj = inner;
|
|
2100
|
-
if (isEnvelopeFrame(innerObj)) {
|
|
2101
|
-
{
|
|
2102
|
-
// SAFETY: isEnvelopeFrame confirmed rpc/evt/ub envelope keys are
|
|
2103
|
-
// present, so this ClientMessage is byte-compatible with EnvelopeMessage.
|
|
2104
|
-
const innerEnv = inner;
|
|
2105
|
-
if (innerEnv.ub === undefined)
|
|
2106
|
-
_routeRpcCommandFrom(channel, innerEnv);
|
|
2107
|
-
else
|
|
2108
|
-
_routeUnBienPlaneFrom(channel, innerEnv);
|
|
2109
|
-
}
|
|
2110
|
-
}
|
|
2111
|
-
else {
|
|
2112
|
-
_routeClientMessageFrom(channel, inner, _liveCtx() ?? _noopCtx);
|
|
2113
|
-
}
|
|
2114
|
-
return;
|
|
2115
|
-
}
|
|
2116
|
-
// Unknown peer with non-pair_request inner — signal so the app can react
|
|
2117
|
-
// (peer was revoked / never paired). pair_request from unknown peer was
|
|
2118
|
-
// already handled above as a legitimate path. We never log inner contents,
|
|
2119
|
-
// only inner.type.
|
|
2120
|
-
const errReply = {
|
|
2121
|
-
type: "error",
|
|
2122
|
-
code: "unknown_peer",
|
|
2123
|
-
message: "Peer not paired — re-scan QR",
|
|
2124
|
-
};
|
|
2125
|
-
const errCt = Buffer.from(JSON.stringify(errReply)).toString("base64");
|
|
2126
|
-
relay.send(JSON.stringify({ peer: appPeerId, ct: errCt }));
|
|
2127
|
-
};
|
|
2128
|
-
relay.on("message", onMsg);
|
|
2129
|
-
return () => relay.off("message", onMsg);
|
|
2130
|
-
}
|
|
2131
|
-
/**
|
|
2132
|
-
* Plan/27 Wave A: lazily resolve the pi-extension package version from
|
|
2133
|
-
* disk so the `pair_ok.harness.version` field reflects what's actually
|
|
2134
|
-
* shipped. The lookup is best-effort — a parse failure (or running this
|
|
2135
|
-
* file out-of-tree) falls back to "0.0.0" which is still semver-valid
|
|
2136
|
-
* and the app tolerates it. Cached at module load.
|
|
2137
|
-
*/
|
|
2138
|
-
function _readExtensionVersion() {
|
|
2139
|
-
try {
|
|
2140
|
-
const here = fileURLToPath(import.meta.url);
|
|
2141
|
-
// dist/index.js → ../package.json. src/index.ts under tsx → also one level up.
|
|
2142
|
-
const pkgPath = join(here, "..", "..", "package.json");
|
|
2143
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
2144
|
-
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
2145
|
-
}
|
|
2146
|
-
catch {
|
|
2147
|
-
return "0.0.0";
|
|
2148
|
-
}
|
|
2149
|
-
}
|
|
2150
|
-
const _HARNESS = {
|
|
2151
|
-
name: "Pi coding agent",
|
|
2152
|
-
version: _readExtensionVersion(),
|
|
2153
|
-
};
|
|
2154
|
-
const _HOSTNAME = hostname();
|
|
2155
|
-
// un-bien capability handshake. PROTOCOL_VERSION bumps on a HARD (breaking)
|
|
2156
|
-
// wire change; the app gates UI on capability PRESENCE, not this number.
|
|
2157
|
-
const PROTOCOL_VERSION = 1;
|
|
2158
|
-
// Features this extension supports, advertised on attach (session_history) + pair_ok.
|
|
2159
|
-
// `remote_launch` is conditional (added only when local config opts in) — see
|
|
2160
|
-
// `_capabilities()`. Passive server->app extras (images/panels) are listed so
|
|
2161
|
-
// the app can also gate any future *controls* it grows for them.
|
|
2162
|
-
const _BASE_CAPABILITIES = [
|
|
2163
|
-
"thinking",
|
|
2164
|
-
"models",
|
|
2165
|
-
"cancel",
|
|
2166
|
-
"queued_messages",
|
|
2167
|
-
"images",
|
|
2168
|
-
"tool_result_images",
|
|
2169
|
-
"panels",
|
|
2170
|
-
"rpc_envelope",
|
|
2171
|
-
];
|
|
2172
|
-
/** The capability set to advertise right now (config-dependent bits included). */
|
|
2173
|
-
function _capabilities() {
|
|
2174
|
-
const caps = [..._BASE_CAPABILITIES];
|
|
2175
|
-
// `remote_launch` is advertised ONLY when the machine opts in via local
|
|
2176
|
-
// config — single choke point so the advertised set and honored behavior
|
|
2177
|
-
// can't drift. Read the session cwd's config (pi runs in the session cwd).
|
|
2178
|
-
if (effectiveAllowRemoteLaunch(loadLocalConfig(process.cwd()))) {
|
|
2179
|
-
caps.push("remote_launch");
|
|
2180
|
-
}
|
|
2181
|
-
return caps;
|
|
2182
|
-
}
|
|
2183
|
-
async function _handlePairRequest(relay, appPeerId, inner, hasListenerAuthority) {
|
|
2184
|
-
const sendInner = (msg) => {
|
|
2185
|
-
const ct = Buffer.from(JSON.stringify(msg)).toString("base64");
|
|
2186
|
-
relay.send(JSON.stringify({ peer: appPeerId, ct }));
|
|
2187
|
-
};
|
|
2188
|
-
const sendError = (code, message) => {
|
|
2189
|
-
sendInner({ type: "pair_error", in_reply_to: inner.id, code, message });
|
|
2190
|
-
};
|
|
2191
|
-
const status = qrSession.consumeToken(inner.token);
|
|
2192
|
-
if (status !== "ok") {
|
|
2193
|
-
const code = status === "expired"
|
|
2194
|
-
? "token_expired"
|
|
2195
|
-
: status === "consumed"
|
|
2196
|
-
? "token_consumed"
|
|
2197
|
-
: "token_unknown";
|
|
2198
|
-
const msg = code === "token_expired"
|
|
2199
|
-
? "Ephemeral token expired. Generate a new QR with /unbien pair."
|
|
2200
|
-
: code === "token_consumed"
|
|
2201
|
-
? "Token already consumed by another pair_request."
|
|
2202
|
-
: "Token was not issued by this Pi.";
|
|
2203
|
-
sendError(code, msg);
|
|
2204
|
-
return;
|
|
2205
|
-
}
|
|
2206
|
-
// A delayed signed revoke must lose authority before the same-process
|
|
2207
|
-
// re-pair enters storage; the replacement owns a fresh token snapshot.
|
|
2208
|
-
const producer = _selfRevoke;
|
|
2209
|
-
const producerEpoch = _selfRevokeEpoch;
|
|
2210
|
-
producer?.invalidateStorageAuthority();
|
|
2211
|
-
const pairedAt = new Date().toISOString();
|
|
2212
|
-
try {
|
|
2213
|
-
await addPeer({
|
|
2214
|
-
name: inner.device_name,
|
|
2215
|
-
remote_epk: appPeerId,
|
|
2216
|
-
paired_at: pairedAt,
|
|
2217
|
-
});
|
|
2218
|
-
if (!hasListenerAuthority())
|
|
2219
|
-
return;
|
|
2220
|
-
_refreshPairingsCache();
|
|
2221
|
-
if (producer &&
|
|
2222
|
-
_selfRevoke === producer &&
|
|
2223
|
-
_selfRevokeEpoch === producerEpoch) {
|
|
2224
|
-
void producer.requestFreshCheck().catch(() => {
|
|
2225
|
-
// The regular cadence retries; pairing itself already succeeded.
|
|
2226
|
-
});
|
|
2227
|
-
}
|
|
2228
|
-
}
|
|
2229
|
-
catch (err) {
|
|
2230
|
-
if (!hasListenerAuthority())
|
|
2231
|
-
return;
|
|
2232
|
-
sendError("internal_error", `Failed to persist peer: ${String(err)}`);
|
|
2233
|
-
return;
|
|
2234
|
-
}
|
|
2235
|
-
const cwd = _lastCtx && "cwd" in _lastCtx
|
|
2236
|
-
? _lastCtx.cwd
|
|
2237
|
-
: process.cwd();
|
|
2238
|
-
// Prefer the user-configured agent_name (with broker suffix when on the
|
|
2239
|
-
// mesh) over the legacy parent/folder path — matches what the user sees
|
|
2240
|
-
// in the terminal title and in /unbien status.
|
|
2241
|
-
const sessionName = _displayName(cwd);
|
|
2242
|
-
_attachOwner(relay, appPeerId, inner.device_name);
|
|
2243
|
-
sendInner({
|
|
2244
|
-
type: "pair_ok",
|
|
2245
|
-
in_reply_to: inner.id,
|
|
2246
|
-
session_name: sessionName,
|
|
2247
|
-
session_started_at: _sessionStartedAt ?? Date.now(),
|
|
2248
|
-
// App uses this to address subsequent inner messages to the right room
|
|
2249
|
-
// when this Pi runs alongside others with the same epk. Defensive fallback
|
|
2250
|
-
// to roomIdFor(cwd, name) covers the edge case where pair_request lands
|
|
2251
|
-
// before _cmdStart could set _myRoomId (shouldn't happen in practice) —
|
|
2252
|
-
// and stays plan/41-consistent (same (cwd, name) derivation as the announce).
|
|
2253
|
-
room_id: _myRoomId ?? _deriveRoomId(cwd, sessionName),
|
|
2254
|
-
// Plan/27 Wave A — surface the host coding-agent identity + machine
|
|
2255
|
-
// hostname so the app can render a meaningful device row (and tell
|
|
2256
|
-
// two PCs apart even when nicknames collide).
|
|
2257
|
-
harness: _HARNESS,
|
|
2258
|
-
hostname: _HOSTNAME,
|
|
2259
|
-
protocol_version: PROTOCOL_VERSION,
|
|
2260
|
-
capabilities: _capabilities(),
|
|
2261
|
-
});
|
|
2262
|
-
// Notify local RPC clients (e.g. Cockpit) that pairing completed, so they can
|
|
2263
|
-
// close the QR screen and show the new device. Pure data event (display:false)
|
|
2264
|
-
// — still emitted to the RPC stdout via the session stream.
|
|
2265
|
-
_pi?.sendMessage({
|
|
2266
|
-
customType: "un-bien:paired",
|
|
2267
|
-
content: `Paired with ${inner.device_name}`,
|
|
2268
|
-
details: { name: inner.device_name, peerId: appPeerId, pairedAt },
|
|
2269
|
-
display: false,
|
|
2270
|
-
});
|
|
2271
|
-
}
|
|
2272
|
-
// ── Extension factory (default export) ───────────────────────────────────────
|
|
2273
|
-
// Stores most recent command context so the auto-listener can use ui.notify.
|
|
2274
|
-
// NOTE: this is a CAPTURED command ctx — the SDK marks it stale after a
|
|
2275
|
-
// session replacement (newSession/fork/switch/reload). We re-capture it via
|
|
2276
|
-
// `withSession` when WE drive a newSession (see the session_new dispatch).
|
|
2277
|
-
let _lastCtx = null;
|
|
2278
1211
|
// Freshest base ExtensionContext, re-captured on EVERY `session_start`
|
|
2279
1212
|
// (startup/new/fork/reload/resume). The session_start ctx is always bound to
|
|
2280
1213
|
// the CURRENT session, so compact + cancel (base-ctx methods) routed through
|
|
2281
1214
|
// here never hit a stale ctx — regardless of who triggered the replacement
|
|
2282
1215
|
// (an app Quick Action OR a `/new` typed in the Pi TUI). It carries only
|
|
2283
|
-
// base-ctx methods (no newSession — that's command-ctx only)
|
|
2284
|
-
//
|
|
1216
|
+
// base-ctx methods (no newSession — that's command-ctx only); command-only ops
|
|
1217
|
+
// (fork/new/branch) reach a command ctx by self-dispatching a slash command
|
|
1218
|
+
// instead of retaining one.
|
|
2285
1219
|
let _lastEventCtx = null;
|
|
2286
1220
|
const _noopCtx = { ui: { notify: () => undefined }, abort: () => undefined };
|
|
2287
1221
|
// A single Pi process can load this extension TWICE in the SAME session:
|
|
@@ -2341,6 +1275,466 @@ function _releaseRootSession(pi) {
|
|
|
2341
1275
|
if (g[_ROOT_SESSION_OWNER_KEY] === pi)
|
|
2342
1276
|
delete g[_ROOT_SESSION_OWNER_KEY];
|
|
2343
1277
|
}
|
|
1278
|
+
// ── Received-image pipeline seam ──────────────────────────────────────────
|
|
1279
|
+
//
|
|
1280
|
+
// The image-preview domain lives in ./session/received_images.ts and reaches
|
|
1281
|
+
// this module's state + helpers ONLY through this object (that module never
|
|
1282
|
+
// imports ../index.js — no circular imports). Same pattern as CommandDeps.
|
|
1283
|
+
const imageDeps = {
|
|
1284
|
+
get pi() {
|
|
1285
|
+
return _pi;
|
|
1286
|
+
},
|
|
1287
|
+
rootState: _rootState,
|
|
1288
|
+
get myRoomMeta() {
|
|
1289
|
+
return _myRoomMeta;
|
|
1290
|
+
},
|
|
1291
|
+
wakeAgent: _wakeAgent,
|
|
1292
|
+
};
|
|
1293
|
+
// ── Rpc-inbound handler seam ──────────────────────────────────────────────
|
|
1294
|
+
//
|
|
1295
|
+
// The rpc-command handler implementations live in ./session/rpc_handlers.ts
|
|
1296
|
+
// (a factory this module's `_routeRpcCommandFrom` — which owns channel/sender
|
|
1297
|
+
// routing — calls per dispatch). Same pattern as CommandDeps: that module
|
|
1298
|
+
// never imports ../index.js — no circular imports.
|
|
1299
|
+
const rpcDeps = {
|
|
1300
|
+
get pi() {
|
|
1301
|
+
return _pi;
|
|
1302
|
+
},
|
|
1303
|
+
rootState: _rootState,
|
|
1304
|
+
get lastEventCtx() {
|
|
1305
|
+
return _lastEventCtx;
|
|
1306
|
+
},
|
|
1307
|
+
imageDeps,
|
|
1308
|
+
wakeAgent: _wakeAgent,
|
|
1309
|
+
abortCurrentTurn: () => _abortCurrentTurn(),
|
|
1310
|
+
};
|
|
1311
|
+
// ── Relay lifecycle seam ──────────────────────────────────────────────────
|
|
1312
|
+
//
|
|
1313
|
+
// The relay lifecycle + owner-management code lives in
|
|
1314
|
+
// ./session/relay_lifecycle.ts and reaches this module's state and helpers
|
|
1315
|
+
// ONLY through this object (that module never imports ../index.js — no
|
|
1316
|
+
// circular imports). Same pattern as CommandDeps. The two wrappers below
|
|
1317
|
+
// preserve the exact `_handleControl` / `_onPeerDisconnect` export signatures
|
|
1318
|
+
// the integration tests import.
|
|
1319
|
+
const relayDeps = {
|
|
1320
|
+
get state() {
|
|
1321
|
+
return _state;
|
|
1322
|
+
},
|
|
1323
|
+
set state(v) {
|
|
1324
|
+
_state = v;
|
|
1325
|
+
},
|
|
1326
|
+
get relay() {
|
|
1327
|
+
return _relay;
|
|
1328
|
+
},
|
|
1329
|
+
set relay(v) {
|
|
1330
|
+
_relay = v;
|
|
1331
|
+
},
|
|
1332
|
+
get relayUrl() {
|
|
1333
|
+
return _relayUrl;
|
|
1334
|
+
},
|
|
1335
|
+
set relayUrl(v) {
|
|
1336
|
+
_relayUrl = v;
|
|
1337
|
+
},
|
|
1338
|
+
get peerShort() {
|
|
1339
|
+
return _peerShort;
|
|
1340
|
+
},
|
|
1341
|
+
set peerShort(v) {
|
|
1342
|
+
_peerShort = v;
|
|
1343
|
+
},
|
|
1344
|
+
get stopAutoListener() {
|
|
1345
|
+
return _stopAutoListener;
|
|
1346
|
+
},
|
|
1347
|
+
set stopAutoListener(v) {
|
|
1348
|
+
_stopAutoListener = v;
|
|
1349
|
+
},
|
|
1350
|
+
get selfRevoke() {
|
|
1351
|
+
return _selfRevoke;
|
|
1352
|
+
},
|
|
1353
|
+
set selfRevoke(v) {
|
|
1354
|
+
_selfRevoke = v;
|
|
1355
|
+
},
|
|
1356
|
+
get selfRevokeEpoch() {
|
|
1357
|
+
return _selfRevokeEpoch;
|
|
1358
|
+
},
|
|
1359
|
+
set selfRevokeEpoch(v) {
|
|
1360
|
+
_selfRevokeEpoch = v;
|
|
1361
|
+
},
|
|
1362
|
+
get selfRevokeTopologyReadyEpoch() {
|
|
1363
|
+
return _selfRevokeTopologyReadyEpoch;
|
|
1364
|
+
},
|
|
1365
|
+
set selfRevokeTopologyReadyEpoch(v) {
|
|
1366
|
+
_selfRevokeTopologyReadyEpoch = v;
|
|
1367
|
+
},
|
|
1368
|
+
get selfRevokeTopology() {
|
|
1369
|
+
return _selfRevokeTopology;
|
|
1370
|
+
},
|
|
1371
|
+
set selfRevokeTopology(v) {
|
|
1372
|
+
_selfRevokeTopology = v;
|
|
1373
|
+
},
|
|
1374
|
+
get rootLifecycleGeneration() {
|
|
1375
|
+
return _rootLifecycleGeneration;
|
|
1376
|
+
},
|
|
1377
|
+
set rootLifecycleGeneration(v) {
|
|
1378
|
+
_rootLifecycleGeneration = v;
|
|
1379
|
+
},
|
|
1380
|
+
get meshNode() {
|
|
1381
|
+
return _meshNode;
|
|
1382
|
+
},
|
|
1383
|
+
get cachedEd25519() {
|
|
1384
|
+
return _cachedEd25519;
|
|
1385
|
+
},
|
|
1386
|
+
get myRoomId() {
|
|
1387
|
+
return _myRoomId;
|
|
1388
|
+
},
|
|
1389
|
+
get myRoomMeta() {
|
|
1390
|
+
return _myRoomMeta;
|
|
1391
|
+
},
|
|
1392
|
+
get sessionStartedAt() {
|
|
1393
|
+
return _sessionStartedAt;
|
|
1394
|
+
},
|
|
1395
|
+
sessionCwd: () => {
|
|
1396
|
+
const sm = _rootState().sessionManager;
|
|
1397
|
+
return sm && typeof sm.getCwd === "function" ? sm.getCwd() : process.cwd();
|
|
1398
|
+
},
|
|
1399
|
+
get disposed() {
|
|
1400
|
+
return _disposed;
|
|
1401
|
+
},
|
|
1402
|
+
get pi() {
|
|
1403
|
+
return _pi;
|
|
1404
|
+
},
|
|
1405
|
+
get rootSessionId() {
|
|
1406
|
+
return _rootSessionId;
|
|
1407
|
+
},
|
|
1408
|
+
get commandDeps() {
|
|
1409
|
+
return deps;
|
|
1410
|
+
},
|
|
1411
|
+
activePeers: _activePeers,
|
|
1412
|
+
rootState: _rootState,
|
|
1413
|
+
getState: _getState,
|
|
1414
|
+
refreshFooter: _refreshFooter,
|
|
1415
|
+
safeNotify: _safeNotify,
|
|
1416
|
+
refreshPairingsCache: _refreshPairingsCache,
|
|
1417
|
+
displayName: _displayName,
|
|
1418
|
+
attachBridgeIfReady: _attachBridgeIfReady,
|
|
1419
|
+
routeClientMessageFrom: _routeClientMessageFrom,
|
|
1420
|
+
routeRpcCommandFrom: _routeRpcCommandFrom,
|
|
1421
|
+
routeUnBienPlaneFrom: _routeUnBienPlaneFrom,
|
|
1422
|
+
liveCtx: _liveCtx,
|
|
1423
|
+
noopCtx: _noopCtx,
|
|
1424
|
+
renameAgent: _renameAgent,
|
|
1425
|
+
};
|
|
1426
|
+
export async function _handleControl(cmd) {
|
|
1427
|
+
await _handleControlImpl(relayDeps, cmd);
|
|
1428
|
+
}
|
|
1429
|
+
export function _onPeerDisconnect(appPeerId) {
|
|
1430
|
+
_onPeerDisconnectImpl(relayDeps, appPeerId);
|
|
1431
|
+
}
|
|
1432
|
+
// ── Command seam ──────────────────────────────────────────────────────────────
|
|
1433
|
+
//
|
|
1434
|
+
// The /unbien handlers live in ./commands/ and reach this module's state
|
|
1435
|
+
// and helpers ONLY through this object (command modules never import
|
|
1436
|
+
// ../index.js — no circular imports). Mutable module state is exposed as
|
|
1437
|
+
// accessor closures so handlers and this module always see the same
|
|
1438
|
+
// variables.
|
|
1439
|
+
const deps = {
|
|
1440
|
+
get state() {
|
|
1441
|
+
return _state;
|
|
1442
|
+
},
|
|
1443
|
+
set state(v) {
|
|
1444
|
+
_state = v;
|
|
1445
|
+
},
|
|
1446
|
+
get relay() {
|
|
1447
|
+
return _relay;
|
|
1448
|
+
},
|
|
1449
|
+
set relay(v) {
|
|
1450
|
+
_relay = v;
|
|
1451
|
+
},
|
|
1452
|
+
get relayUrl() {
|
|
1453
|
+
return _relayUrl;
|
|
1454
|
+
},
|
|
1455
|
+
set relayUrl(v) {
|
|
1456
|
+
_relayUrl = v;
|
|
1457
|
+
},
|
|
1458
|
+
get meshNode() {
|
|
1459
|
+
return _meshNode;
|
|
1460
|
+
},
|
|
1461
|
+
set meshNode(v) {
|
|
1462
|
+
_meshNode = v;
|
|
1463
|
+
},
|
|
1464
|
+
get sessionName() {
|
|
1465
|
+
return _sessionName;
|
|
1466
|
+
},
|
|
1467
|
+
set sessionName(v) {
|
|
1468
|
+
_sessionName = v;
|
|
1469
|
+
},
|
|
1470
|
+
get sessionPeerCount() {
|
|
1471
|
+
return _sessionPeerCount;
|
|
1472
|
+
},
|
|
1473
|
+
set sessionPeerCount(v) {
|
|
1474
|
+
_sessionPeerCount = v;
|
|
1475
|
+
},
|
|
1476
|
+
get cachedEd25519() {
|
|
1477
|
+
return _cachedEd25519;
|
|
1478
|
+
},
|
|
1479
|
+
set cachedEd25519(v) {
|
|
1480
|
+
_cachedEd25519 = v;
|
|
1481
|
+
},
|
|
1482
|
+
get currentModel() {
|
|
1483
|
+
return _currentModel;
|
|
1484
|
+
},
|
|
1485
|
+
set currentModel(v) {
|
|
1486
|
+
_currentModel = v;
|
|
1487
|
+
},
|
|
1488
|
+
get currentThinking() {
|
|
1489
|
+
return _currentThinking;
|
|
1490
|
+
},
|
|
1491
|
+
set currentThinking(v) {
|
|
1492
|
+
_currentThinking = v;
|
|
1493
|
+
},
|
|
1494
|
+
get myRoomId() {
|
|
1495
|
+
return _myRoomId;
|
|
1496
|
+
},
|
|
1497
|
+
set myRoomId(v) {
|
|
1498
|
+
_myRoomId = v;
|
|
1499
|
+
},
|
|
1500
|
+
get myRoomMeta() {
|
|
1501
|
+
return _myRoomMeta;
|
|
1502
|
+
},
|
|
1503
|
+
set myRoomMeta(v) {
|
|
1504
|
+
_myRoomMeta = v;
|
|
1505
|
+
},
|
|
1506
|
+
get peerShort() {
|
|
1507
|
+
return _peerShort;
|
|
1508
|
+
},
|
|
1509
|
+
set peerShort(v) {
|
|
1510
|
+
_peerShort = v;
|
|
1511
|
+
},
|
|
1512
|
+
get sessionStartedAt() {
|
|
1513
|
+
return _sessionStartedAt;
|
|
1514
|
+
},
|
|
1515
|
+
set sessionStartedAt(v) {
|
|
1516
|
+
_sessionStartedAt = v;
|
|
1517
|
+
},
|
|
1518
|
+
get cwdLock() {
|
|
1519
|
+
return _cwdLock;
|
|
1520
|
+
},
|
|
1521
|
+
set cwdLock(v) {
|
|
1522
|
+
_cwdLock = v;
|
|
1523
|
+
},
|
|
1524
|
+
get lockedName() {
|
|
1525
|
+
return _lockedName;
|
|
1526
|
+
},
|
|
1527
|
+
set lockedName(v) {
|
|
1528
|
+
_lockedName = v;
|
|
1529
|
+
},
|
|
1530
|
+
get relayLifecycleGeneration() {
|
|
1531
|
+
return _getRelayLifecycleGeneration();
|
|
1532
|
+
},
|
|
1533
|
+
set relayLifecycleGeneration(v) {
|
|
1534
|
+
_setRelayLifecycleGeneration(v);
|
|
1535
|
+
},
|
|
1536
|
+
get relayStartDeferred() {
|
|
1537
|
+
return _relayStartDeferred;
|
|
1538
|
+
},
|
|
1539
|
+
set relayStartDeferred(v) {
|
|
1540
|
+
_relayStartDeferred = v;
|
|
1541
|
+
},
|
|
1542
|
+
get rootLifecycleGeneration() {
|
|
1543
|
+
return _rootLifecycleGeneration;
|
|
1544
|
+
},
|
|
1545
|
+
set rootLifecycleGeneration(v) {
|
|
1546
|
+
_rootLifecycleGeneration = v;
|
|
1547
|
+
},
|
|
1548
|
+
get meshJoinGeneration() {
|
|
1549
|
+
return _meshJoinGeneration;
|
|
1550
|
+
},
|
|
1551
|
+
set meshJoinGeneration(v) {
|
|
1552
|
+
_meshJoinGeneration = v;
|
|
1553
|
+
},
|
|
1554
|
+
get selfRevoke() {
|
|
1555
|
+
return _selfRevoke;
|
|
1556
|
+
},
|
|
1557
|
+
set selfRevoke(v) {
|
|
1558
|
+
_selfRevoke = v;
|
|
1559
|
+
},
|
|
1560
|
+
get selfRevokeEpoch() {
|
|
1561
|
+
return _selfRevokeEpoch;
|
|
1562
|
+
},
|
|
1563
|
+
set selfRevokeEpoch(v) {
|
|
1564
|
+
_selfRevokeEpoch = v;
|
|
1565
|
+
},
|
|
1566
|
+
get selfRevokeTopologyReadyEpoch() {
|
|
1567
|
+
return _selfRevokeTopologyReadyEpoch;
|
|
1568
|
+
},
|
|
1569
|
+
set selfRevokeTopologyReadyEpoch(v) {
|
|
1570
|
+
_selfRevokeTopologyReadyEpoch = v;
|
|
1571
|
+
},
|
|
1572
|
+
get selfRevokeTopology() {
|
|
1573
|
+
return _selfRevokeTopology;
|
|
1574
|
+
},
|
|
1575
|
+
set selfRevokeTopology(v) {
|
|
1576
|
+
_selfRevokeTopology = v;
|
|
1577
|
+
},
|
|
1578
|
+
get stopAutoListener() {
|
|
1579
|
+
return _stopAutoListener;
|
|
1580
|
+
},
|
|
1581
|
+
set stopAutoListener(v) {
|
|
1582
|
+
_stopAutoListener = v;
|
|
1583
|
+
},
|
|
1584
|
+
get hasGlobalPairings() {
|
|
1585
|
+
return _hasGlobalPairings;
|
|
1586
|
+
},
|
|
1587
|
+
get disposed() {
|
|
1588
|
+
return _disposed;
|
|
1589
|
+
},
|
|
1590
|
+
get lastEventCtx() {
|
|
1591
|
+
return _lastEventCtx;
|
|
1592
|
+
},
|
|
1593
|
+
get pi() {
|
|
1594
|
+
return _pi;
|
|
1595
|
+
},
|
|
1596
|
+
get rootSessionId() {
|
|
1597
|
+
return _rootSessionId;
|
|
1598
|
+
},
|
|
1599
|
+
activePeers: _activePeers,
|
|
1600
|
+
isCurrentRootLifecycle: _isCurrentRootLifecycle,
|
|
1601
|
+
displayName: _displayName,
|
|
1602
|
+
deriveRoomId: _deriveRoomId,
|
|
1603
|
+
currentModelName: _currentModelName,
|
|
1604
|
+
rootState: _rootState,
|
|
1605
|
+
goIdle: () => _goIdle(relayDeps),
|
|
1606
|
+
onRelayClose: (closedRelay) => _onRelayClose(relayDeps, closedRelay),
|
|
1607
|
+
installAutoListener: (relay) => _installAutoListener(relayDeps, relay),
|
|
1608
|
+
refreshFooter: _refreshFooter,
|
|
1609
|
+
revokeActiveOwnerRuntime: _revokeActiveOwnerRuntime,
|
|
1610
|
+
attachBridgeIfReady: _attachBridgeIfReady,
|
|
1611
|
+
emitRelayState: (force) => _emitRelayState(relayDeps, force),
|
|
1612
|
+
refreshSessionPeerCount: _refreshSessionPeerCount,
|
|
1613
|
+
deliverMeshMessageToAgent: _deliverMeshMessageToAgent,
|
|
1614
|
+
refreshPairingsCache: _refreshPairingsCache,
|
|
1615
|
+
detachPeerChannel: (appPeerId) => _detachPeerChannel(relayDeps, appPeerId),
|
|
1616
|
+
handleControl: _handleControl,
|
|
1617
|
+
relayStatus: () => _relayStatus(relayDeps),
|
|
1618
|
+
getState: _getState,
|
|
1619
|
+
runTestScenario: _runTestScenario,
|
|
1620
|
+
safeNotify: _safeNotify,
|
|
1621
|
+
renameAgent: _renameAgent,
|
|
1622
|
+
};
|
|
1623
|
+
// ── Test-only hooks seam ──────────────────────────────────────────────────────
|
|
1624
|
+
//
|
|
1625
|
+
// The _xForTest surface lives in ./test_hooks.ts (a factory over this module's
|
|
1626
|
+
// state + helpers — that module never imports ../index.js). The one-line
|
|
1627
|
+
// re-exports below preserve the exact export names every existing test
|
|
1628
|
+
// (extension.test.ts + the colocated command tests) imports.
|
|
1629
|
+
const _testHooks = createTestHooks({
|
|
1630
|
+
commandDeps: deps,
|
|
1631
|
+
get disposed() {
|
|
1632
|
+
return _disposed;
|
|
1633
|
+
},
|
|
1634
|
+
set disposed(v) {
|
|
1635
|
+
_disposed = v;
|
|
1636
|
+
},
|
|
1637
|
+
set autoInited(v) {
|
|
1638
|
+
_autoInited = v;
|
|
1639
|
+
},
|
|
1640
|
+
get panelBridge() {
|
|
1641
|
+
return _panelBridge;
|
|
1642
|
+
},
|
|
1643
|
+
set panelBridge(v) {
|
|
1644
|
+
_panelBridge = v;
|
|
1645
|
+
},
|
|
1646
|
+
get rpcEnvelope() {
|
|
1647
|
+
return _rpcEnvelope;
|
|
1648
|
+
},
|
|
1649
|
+
set rpcEnvelope(v) {
|
|
1650
|
+
_rpcEnvelope = v;
|
|
1651
|
+
},
|
|
1652
|
+
get subagentRooms() {
|
|
1653
|
+
return _subagentRooms;
|
|
1654
|
+
},
|
|
1655
|
+
set subagentRooms(v) {
|
|
1656
|
+
_subagentRooms = v;
|
|
1657
|
+
},
|
|
1658
|
+
get extensionUiBridge() {
|
|
1659
|
+
return _extensionUiBridge;
|
|
1660
|
+
},
|
|
1661
|
+
set extensionUiBridge(v) {
|
|
1662
|
+
_extensionUiBridge = v;
|
|
1663
|
+
},
|
|
1664
|
+
set rootSessionId(v) {
|
|
1665
|
+
_rootSessionId = v;
|
|
1666
|
+
},
|
|
1667
|
+
get cwdLock() {
|
|
1668
|
+
return _cwdLock;
|
|
1669
|
+
},
|
|
1670
|
+
set cwdLock(v) {
|
|
1671
|
+
_cwdLock = v;
|
|
1672
|
+
},
|
|
1673
|
+
get lockedName() {
|
|
1674
|
+
return _lockedName;
|
|
1675
|
+
},
|
|
1676
|
+
set lockedName(v) {
|
|
1677
|
+
_lockedName = v;
|
|
1678
|
+
},
|
|
1679
|
+
set sessionStartedAt(v) {
|
|
1680
|
+
_sessionStartedAt = v;
|
|
1681
|
+
},
|
|
1682
|
+
set currentModel(v) {
|
|
1683
|
+
_currentModel = v;
|
|
1684
|
+
},
|
|
1685
|
+
set pi(v) {
|
|
1686
|
+
_pi = v;
|
|
1687
|
+
},
|
|
1688
|
+
set relayStartDeferred(v) {
|
|
1689
|
+
_relayStartDeferred = v;
|
|
1690
|
+
},
|
|
1691
|
+
rootSessionOwnerKey: _ROOT_SESSION_OWNER_KEY,
|
|
1692
|
+
sessions: _sessions,
|
|
1693
|
+
get meshNode() {
|
|
1694
|
+
return _meshNode;
|
|
1695
|
+
},
|
|
1696
|
+
get selfRevoke() {
|
|
1697
|
+
return _selfRevoke;
|
|
1698
|
+
},
|
|
1699
|
+
get cachedEd25519() {
|
|
1700
|
+
return _cachedEd25519;
|
|
1701
|
+
},
|
|
1702
|
+
get reconnectTimer() {
|
|
1703
|
+
return _getReconnectTimer();
|
|
1704
|
+
},
|
|
1705
|
+
activePeers: _activePeers,
|
|
1706
|
+
rootState: _rootState,
|
|
1707
|
+
seedRootSession(sid) {
|
|
1708
|
+
_rootSessionId = sid;
|
|
1709
|
+
_stateFor(sid).sessionManager = {
|
|
1710
|
+
getSessionId: () => sid,
|
|
1711
|
+
};
|
|
1712
|
+
},
|
|
1713
|
+
deliverMeshMessageToAgent: _deliverMeshMessageToAgent,
|
|
1714
|
+
});
|
|
1715
|
+
export const _connectForTest = _testHooks.connectForTest;
|
|
1716
|
+
export const _stopForTest = _testHooks.stopForTest;
|
|
1717
|
+
export const _getDisposedForTest = _testHooks.getDisposedForTest;
|
|
1718
|
+
export const _setDisposedForTest = _testHooks.setDisposedForTest;
|
|
1719
|
+
export const _resetAutoInitedForTest = _testHooks.resetAutoInitedForTest;
|
|
1720
|
+
export const _resetBridgeOwnersForTest = _testHooks.resetBridgeOwnersForTest;
|
|
1721
|
+
export const _resetSessionsForTest = _testHooks.resetSessionsForTest;
|
|
1722
|
+
export const _seedRootSessionForTest = _testHooks.seedRootSessionForTest;
|
|
1723
|
+
export const _setAutoInitedForTest = _testHooks.setAutoInitedForTest;
|
|
1724
|
+
export const _hasMeshNodeForTest = _testHooks.hasMeshNodeForTest;
|
|
1725
|
+
export const _checkSelfRevokeForTest = _testHooks.checkSelfRevokeForTest;
|
|
1726
|
+
export const _getLockedNameForTest = _testHooks.getLockedNameForTest;
|
|
1727
|
+
export const _resetCwdLockForTest = _testHooks.resetCwdLockForTest;
|
|
1728
|
+
export const _startRelayForTest = _testHooks.startRelayForTest;
|
|
1729
|
+
export const _getCachedPublicKeyForTest = _testHooks.getCachedPublicKeyForTest;
|
|
1730
|
+
export const _setSessionStartedAtForTest = _testHooks.setSessionStartedAtForTest;
|
|
1731
|
+
export const _setCurrentModelForTest = _testHooks.setCurrentModelForTest;
|
|
1732
|
+
export const _getCurrentTurnIdForTest = _testHooks.getCurrentTurnIdForTest;
|
|
1733
|
+
export const _setPiForTest = _testHooks.setPiForTest;
|
|
1734
|
+
export const _hasPendingReconnect = _testHooks.hasPendingReconnect;
|
|
1735
|
+
export const _getActivePeerCountForTest = _testHooks.getActivePeerCountForTest;
|
|
1736
|
+
export const _hasActivePeerForTest = _testHooks.hasActivePeerForTest;
|
|
1737
|
+
export const _deliverMeshMessageToAgentForTest = _testHooks.deliverMeshMessageToAgentForTest;
|
|
2344
1738
|
const extension = (pi) => {
|
|
2345
1739
|
const applied = _appliedRegistry();
|
|
2346
1740
|
if (applied.has(pi))
|
|
@@ -2365,13 +1759,13 @@ const extension = (pi) => {
|
|
|
2365
1759
|
if (_claimRootSession(pi)) {
|
|
2366
1760
|
_pi = pi;
|
|
2367
1761
|
_extensionUiBridge?.dispose();
|
|
2368
|
-
_extensionUiBridge = createExtensionUiBridge(pi, _uiBroadcast);
|
|
1762
|
+
_extensionUiBridge = createExtensionUiBridge(pi, (msg) => _uiBroadcast(relayDeps, msg));
|
|
2369
1763
|
_panelBridge?.dispose();
|
|
2370
|
-
_panelBridge = createPanelBridge(pi, _panelBroadcast, {
|
|
1764
|
+
_panelBridge = createPanelBridge(pi, (msg) => _panelBroadcast(relayDeps, msg), {
|
|
2371
1765
|
suppressAgents: subagentRoomsEnabled(),
|
|
2372
1766
|
});
|
|
2373
1767
|
_rpcEnvelope?.dispose();
|
|
2374
|
-
_rpcEnvelope = createRpcEnvelope(pi, _broadcastEnvelope, {
|
|
1768
|
+
_rpcEnvelope = createRpcEnvelope(pi, (env) => _broadcastEnvelope(relayDeps, env), {
|
|
2375
1769
|
enrichArgs: (tool, args) => {
|
|
2376
1770
|
const e = _enrichToolArgs(tool, args, _resolveToolCwd());
|
|
2377
1771
|
return Array.isArray(e.hunks) ? { hunks: e.hunks } : null;
|
|
@@ -2381,11 +1775,11 @@ const extension = (pi) => {
|
|
|
2381
1775
|
_subagentRooms = initSubagentRooms(pi, {
|
|
2382
1776
|
getParentRoomId: () => _myRoomId,
|
|
2383
1777
|
getParentSessionId: () => _rootSessionId,
|
|
2384
|
-
broadcastPanel: _panelBroadcast,
|
|
1778
|
+
broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
|
|
2385
1779
|
});
|
|
2386
1780
|
}
|
|
2387
|
-
// Plano 19: ensure
|
|
2388
|
-
// agent-network skill on first load. resources_discover lets Pi find it.
|
|
1781
|
+
// Plano 19: ensure the state root's `sessions/` + `skills/` dirs exist and
|
|
1782
|
+
// deploy the agent-network skill on first load. resources_discover lets Pi find it.
|
|
2389
1783
|
try {
|
|
2390
1784
|
ensureGlobalDirs();
|
|
2391
1785
|
_deployAgentNetworkSkill();
|
|
@@ -2427,7 +1821,7 @@ const extension = (pi) => {
|
|
|
2427
1821
|
void _handleControl(event.text.slice(CTRL_PREFIX.length).trim());
|
|
2428
1822
|
return { action: "handled" };
|
|
2429
1823
|
}
|
|
2430
|
-
if (!_anyPeerActive())
|
|
1824
|
+
if (!_anyPeerActive(relayDeps))
|
|
2431
1825
|
return;
|
|
2432
1826
|
if (event.source === "extension")
|
|
2433
1827
|
return;
|
|
@@ -2508,7 +1902,7 @@ const extension = (pi) => {
|
|
|
2508
1902
|
// only clear the turn id used for queue/turn correlation.
|
|
2509
1903
|
if (st.turnId)
|
|
2510
1904
|
st.turnId = null;
|
|
2511
|
-
_flushPendingReceivedImagePreviews();
|
|
1905
|
+
_flushPendingReceivedImagePreviews(imageDeps);
|
|
2512
1906
|
settleRun();
|
|
2513
1907
|
});
|
|
2514
1908
|
// plan/34: the broker no longer gates delivery on busy state, so we no
|
|
@@ -2516,6 +1910,17 @@ const extension = (pi) => {
|
|
|
2516
1910
|
// room_meta over the relay (plan/32) below — that's independent of the
|
|
2517
1911
|
// broker and drives the app's working indicator.
|
|
2518
1912
|
pi.on("turn_start", (_event, ctx) => {
|
|
1913
|
+
// One-shot TURN CTX TARGETED PROBE (see the session_start probe note):
|
|
1914
|
+
// per-property, proxy-safe. Turn ctxs fire while the agent RUNS — if
|
|
1915
|
+
// they carry the session-control powers, the always-warm capture
|
|
1916
|
+
// replaces the stash entirely.
|
|
1917
|
+
if (!_turnCtxProbed) {
|
|
1918
|
+
_turnCtxProbed = true;
|
|
1919
|
+
if (ctx)
|
|
1920
|
+
_probeCtx("turn ctx", ctx);
|
|
1921
|
+
else
|
|
1922
|
+
envLog("turn ctx targeted: ctx is undefined at turn_start");
|
|
1923
|
+
}
|
|
2519
1924
|
const sid = _sidOf(ctx);
|
|
2520
1925
|
const st = _stateFor(sid);
|
|
2521
1926
|
// Each session records its OWN sessionManager (no cross-session clobber).
|
|
@@ -2594,6 +1999,61 @@ const extension = (pi) => {
|
|
|
2594
1999
|
if (!_isNonRootSid(_sidOf(ctx)))
|
|
2595
2000
|
_publishWorking(false);
|
|
2596
2001
|
});
|
|
2002
|
+
// RENAME FORWARD (pre-release 2026-09-18): the session display name
|
|
2003
|
+
// changed — via /name in the TUI, RPC, or the app's own set_session_name
|
|
2004
|
+
// command (pi's native verb; the reply IS the standard rpc response). Two
|
|
2005
|
+
// effects: (1) forward to the app — over the relay-OPAQUE envelope plane (any
|
|
2006
|
+
// relay, but only reaches ATTACHED peers) AND, for relay >=0.6.0, a
|
|
2007
|
+
// room_meta_update the relay fans to ROOM SUBSCRIBERS (Home, pre-attach);
|
|
2008
|
+
// (2) refresh the local announce state so the NEXT hello re-announces the
|
|
2009
|
+
// fresh name — the relay's stored meta self-heals at the extension's next
|
|
2010
|
+
// connect.
|
|
2011
|
+
// Root sessions only (v1): a subagent child's rename gets its own forward
|
|
2012
|
+
// when child renames matter.
|
|
2013
|
+
pi.on("session_info_changed", (event, ctx) => {
|
|
2014
|
+
const name = event?.name;
|
|
2015
|
+
if (typeof name !== "string" || name.length === 0)
|
|
2016
|
+
return;
|
|
2017
|
+
if (_isNonRootSid(_sidOf(ctx)))
|
|
2018
|
+
return;
|
|
2019
|
+
_sessionName = name;
|
|
2020
|
+
if (_myRoomMeta)
|
|
2021
|
+
_myRoomMeta = { ..._myRoomMeta, name };
|
|
2022
|
+
_broadcastEnvelope(relayDeps, {
|
|
2023
|
+
evt: { channel: "session_info", data: { name } },
|
|
2024
|
+
});
|
|
2025
|
+
// ALSO fan via room_meta_update so the relay pushes the rename to ROOM
|
|
2026
|
+
// SUBSCRIBERS (Home apps subscribed but NOT attached) — the evt above only
|
|
2027
|
+
// reaches attached peers, so pre-attach the Home tile went stale until open
|
|
2028
|
+
// or reconnect. Relay >=0.6.0 merges + fans `name`; older relays drop the
|
|
2029
|
+
// unknown field (harmless — attached peers still get the evt). Design 01M1SPN7.
|
|
2030
|
+
if (_relay && _myRoomId) {
|
|
2031
|
+
_relay.sendControl({
|
|
2032
|
+
type: "room_meta_update",
|
|
2033
|
+
room_id: _myRoomId,
|
|
2034
|
+
meta: { name },
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
});
|
|
2038
|
+
// LEAF BEACON on tree navigation (branch). pi fires session_tree AFTER a leaf
|
|
2039
|
+
// move for EVERY source — TUI /tree, edit-resubmit, and our own /unbien branch
|
|
2040
|
+
// (event.fromExtension) — carrying the new leaf directly. Push it so the app
|
|
2041
|
+
// re-derives its active path (evt session_info leafId → SessionState.derivePath
|
|
2042
|
+
// walks parentId from the new leaf over its already-cached tree; no prefetch).
|
|
2043
|
+
// THE single source for all leaf moves: without it a TUI /tree only reflected
|
|
2044
|
+
// on the next get_entries refetch (stale path until then). Root room only — a
|
|
2045
|
+
// subagent's tree move must not re-path the app's root session.
|
|
2046
|
+
pi.on("session_tree", (event, ctx) => {
|
|
2047
|
+
if (_isNonRootSid(_sidOf(ctx)))
|
|
2048
|
+
return;
|
|
2049
|
+
const leafId = event
|
|
2050
|
+
?.newLeafId;
|
|
2051
|
+
if (typeof leafId === "string" && leafId.length > 0) {
|
|
2052
|
+
_broadcastEnvelope(relayDeps, {
|
|
2053
|
+
evt: { channel: "session_info", data: { leafId } },
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2597
2057
|
// Re-capture the freshest base ctx on every session replacement so compact
|
|
2598
2058
|
// never operates on a stale captured ctx — this is the fix for the
|
|
2599
2059
|
// "stale after session replacement" crash when the app taps Compact after a
|
|
@@ -2610,6 +2070,13 @@ const extension = (pi) => {
|
|
|
2610
2070
|
// subagent steals the base ctx and root-scoped notifies silently drop.
|
|
2611
2071
|
if (!_isNonRootSid(sid))
|
|
2612
2072
|
_lastEventCtx = ctx;
|
|
2073
|
+
// EVENT-CTX TARGETED PROBE (run 2026-09-18, round 3): the ctx objects
|
|
2074
|
+
// are guarded (throwing getters — proxies), so full reflection
|
|
2075
|
+
// under-reports or throws. Per-property access, each caught, is the
|
|
2076
|
+
// proxy-safe probe.
|
|
2077
|
+
if (!_isNonRootSid(sid) && ctx) {
|
|
2078
|
+
_probeCtx("event ctx", ctx);
|
|
2079
|
+
}
|
|
2613
2080
|
if (ctx?.sessionManager)
|
|
2614
2081
|
_stateFor(sid).sessionManager = ctx.sessionManager;
|
|
2615
2082
|
// session_shutdown disposes per-session pi-ask subscriptions. A host that
|
|
@@ -2624,13 +2091,13 @@ const extension = (pi) => {
|
|
|
2624
2091
|
if (ctx?.sessionManager)
|
|
2625
2092
|
_rootSessionId = sid;
|
|
2626
2093
|
if (!_extensionUiBridge)
|
|
2627
|
-
_extensionUiBridge = createExtensionUiBridge(pi, _uiBroadcast);
|
|
2094
|
+
_extensionUiBridge = createExtensionUiBridge(pi, (msg) => _uiBroadcast(relayDeps, msg));
|
|
2628
2095
|
if (!_panelBridge)
|
|
2629
|
-
_panelBridge = createPanelBridge(pi, _panelBroadcast, {
|
|
2096
|
+
_panelBridge = createPanelBridge(pi, (msg) => _panelBroadcast(relayDeps, msg), {
|
|
2630
2097
|
suppressAgents: subagentRoomsEnabled(),
|
|
2631
2098
|
});
|
|
2632
2099
|
if (!_rpcEnvelope)
|
|
2633
|
-
_rpcEnvelope = createRpcEnvelope(pi, _broadcastEnvelope, {
|
|
2100
|
+
_rpcEnvelope = createRpcEnvelope(pi, (env) => _broadcastEnvelope(relayDeps, env), {
|
|
2634
2101
|
enrichArgs: (tool, args) => {
|
|
2635
2102
|
const e = _enrichToolArgs(tool, args, _resolveToolCwd());
|
|
2636
2103
|
return Array.isArray(e.hunks) ? { hunks: e.hunks } : null;
|
|
@@ -2640,7 +2107,7 @@ const extension = (pi) => {
|
|
|
2640
2107
|
_subagentRooms = initSubagentRooms(pi, {
|
|
2641
2108
|
getParentRoomId: () => _myRoomId,
|
|
2642
2109
|
getParentSessionId: () => _rootSessionId,
|
|
2643
|
-
broadcastPanel: _panelBroadcast,
|
|
2110
|
+
broadcastPanel: (msg) => _panelBroadcast(relayDeps, msg),
|
|
2644
2111
|
});
|
|
2645
2112
|
}
|
|
2646
2113
|
else if (_isNonRootSid(sid)) {
|
|
@@ -2648,6 +2115,17 @@ const extension = (pi) => {
|
|
|
2648
2115
|
// `pi` here is the CHILD's ExtensionAPI (per-activation).
|
|
2649
2116
|
_subagentRooms?.onChildSession(pi, ctx);
|
|
2650
2117
|
}
|
|
2118
|
+
// design 01M1CAW0 (announce waits for the session id): a relay start that
|
|
2119
|
+
// was deferred because no session id existed re-runs NOW — the root
|
|
2120
|
+
// session's sessionManager was captured above, so _deriveRoomId has an id
|
|
2121
|
+
// to announce. Root session_starts only (a child never owned the relay);
|
|
2122
|
+
// the flag stays armed when this session_start still carries no id.
|
|
2123
|
+
if (deps.relayStartDeferred && !_isNonRootSid(sid)) {
|
|
2124
|
+
if (_rootState().sessionManager?.getSessionId()) {
|
|
2125
|
+
deps.relayStartDeferred = false;
|
|
2126
|
+
void _cmdStart(deps, ctx);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2651
2129
|
// Rearm a reused-but-disposed instance. The session_shutdown teardown (below)
|
|
2652
2130
|
// sets _disposed=true assuming the host re-evaluates THIS module fresh for the
|
|
2653
2131
|
// replacement session, yielding a new instance with _disposed=false. Some hosts
|
|
@@ -2661,7 +2139,7 @@ const extension = (pi) => {
|
|
|
2661
2139
|
const restartAuthority = {
|
|
2662
2140
|
rootLifecycleGeneration: _rootLifecycleGeneration,
|
|
2663
2141
|
};
|
|
2664
|
-
void _cmdRoot(ctx, restartAuthority);
|
|
2142
|
+
void _cmdRoot(deps, ctx, restartAuthority);
|
|
2665
2143
|
}
|
|
2666
2144
|
// Auto-start un-bien on a fresh boot when the cwd's local config has
|
|
2667
2145
|
// auto_start_relay enabled (default true). Covers BOTH interactive
|
|
@@ -2702,7 +2180,7 @@ const extension = (pi) => {
|
|
|
2702
2180
|
const initCtx = isDaemon
|
|
2703
2181
|
? { ui: _headlessUi(), cwd: process.cwd() }
|
|
2704
2182
|
: ctx;
|
|
2705
|
-
void _cmdRoot(initCtx);
|
|
2183
|
+
void _cmdRoot(deps, initCtx);
|
|
2706
2184
|
}
|
|
2707
2185
|
}
|
|
2708
2186
|
});
|
|
@@ -2750,7 +2228,7 @@ const extension = (pi) => {
|
|
|
2750
2228
|
// clears `_disposed` before its promises settle.
|
|
2751
2229
|
_disposed = true;
|
|
2752
2230
|
_rootLifecycleGeneration += 1;
|
|
2753
|
-
|
|
2231
|
+
_setRelayLifecycleGeneration(_getRelayLifecycleGeneration() + 1);
|
|
2754
2232
|
_meshJoinGeneration += 1;
|
|
2755
2233
|
// The bridge owns live pi.events subscriptions + flow TTLs. Dispose before
|
|
2756
2234
|
// the outgoing session is replaced so stale listeners cannot leak or
|
|
@@ -2765,8 +2243,18 @@ const extension = (pi) => {
|
|
|
2765
2243
|
// app can mark the session ended. Emit EXPLICITLY here, BEFORE the
|
|
2766
2244
|
// producer dispose below: `_rpcEnvelope` gates on its `disposed` flag,
|
|
2767
2245
|
// so once disposed it can no longer build/broadcast this frame.
|
|
2768
|
-
if (_anyPeerActive()) {
|
|
2769
|
-
|
|
2246
|
+
if (_anyPeerActive(relayDeps)) {
|
|
2247
|
+
// FLUSH-BEFORE-TEARDOWN: await the frame's ws send-completion
|
|
2248
|
+
// (bounded — a wedged socket can't hang the shutdown) BEFORE _goIdle
|
|
2249
|
+
// closes the relay below. Fire-and-forget only beat close() by
|
|
2250
|
+
// incidental FIFO luck; the switch flow (/resume: old instance
|
|
2251
|
+
// broadcasts shutdown, fresh instance takes the new room) NEEDS the
|
|
2252
|
+
// app to reliably mark the old session ended, or it lingers live
|
|
2253
|
+
// forever (the app has no disconnect-implies-ended backstop, by
|
|
2254
|
+
// design).
|
|
2255
|
+
await _broadcastEnvelopeFlushed(relayDeps, {
|
|
2256
|
+
rpc: { type: "session_shutdown" },
|
|
2257
|
+
});
|
|
2770
2258
|
}
|
|
2771
2259
|
_extensionUiBridge?.dispose();
|
|
2772
2260
|
_extensionUiBridge = null;
|
|
@@ -2778,11 +2266,9 @@ const extension = (pi) => {
|
|
|
2778
2266
|
_subagentRooms = null;
|
|
2779
2267
|
_releaseRootSession(pi);
|
|
2780
2268
|
}
|
|
2781
|
-
// Drop captured
|
|
2782
|
-
// survives session replacement;
|
|
2783
|
-
//
|
|
2784
|
-
// (issue #55). session_start re-binds `_lastEventCtx` for the new session.
|
|
2785
|
-
_lastCtx = null;
|
|
2269
|
+
// Drop the captured event ctx immediately. On module-reuse hosts the same
|
|
2270
|
+
// instance survives session replacement; the next session_start re-binds
|
|
2271
|
+
// `_lastEventCtx` for the new session.
|
|
2786
2272
|
_lastEventCtx = null;
|
|
2787
2273
|
// No bye reason: the process keeps running and the fresh instance re-joins
|
|
2788
2274
|
// the SAME relay room, so an explicit offline→online flap would be wrong.
|
|
@@ -2792,7 +2278,7 @@ const extension = (pi) => {
|
|
|
2792
2278
|
_meshNode?.detachBridge();
|
|
2793
2279
|
}
|
|
2794
2280
|
else {
|
|
2795
|
-
_goIdle();
|
|
2281
|
+
_goIdle(relayDeps);
|
|
2796
2282
|
}
|
|
2797
2283
|
const meshNode = _meshNode;
|
|
2798
2284
|
_meshNode = null;
|
|
@@ -2822,232 +2308,7 @@ const extension = (pi) => {
|
|
|
2822
2308
|
/* best-effort */
|
|
2823
2309
|
}
|
|
2824
2310
|
});
|
|
2825
|
-
|
|
2826
|
-
//
|
|
2827
|
-
// Final surface: 8 commands. Pre-2026-05-23 we had 20 commands covering
|
|
2828
|
-
// multi-session UDS + granular relay control; in practice every install
|
|
2829
|
-
// converged on one session and the relay was always either fully on or
|
|
2830
|
-
// fully off. The simplified surface keeps the day-to-day path one-key
|
|
2831
|
-
// (`/unbien`) and exposes only the actions that have distinct user
|
|
2832
|
-
// intent: setup, status, stop, pair, devices, revoke, set-relay.
|
|
2833
|
-
pi.registerCommand("unbien", {
|
|
2834
|
-
description: "Connect (join local mesh + start relay), or run setup on first use",
|
|
2835
|
-
getArgumentCompletions: async (prefix) => {
|
|
2836
|
-
if (prefix.startsWith("revoke ") || prefix === "revoke") {
|
|
2837
|
-
const shortPrefix = prefix === "revoke" ? "" : prefix.slice("revoke ".length);
|
|
2838
|
-
return _shortidCompletions(shortPrefix, "revoke ");
|
|
2839
|
-
}
|
|
2840
|
-
return [
|
|
2841
|
-
"setup",
|
|
2842
|
-
"status",
|
|
2843
|
-
"stop",
|
|
2844
|
-
"pair",
|
|
2845
|
-
"devices",
|
|
2846
|
-
"revoke",
|
|
2847
|
-
"rename",
|
|
2848
|
-
"set-relay",
|
|
2849
|
-
"relay",
|
|
2850
|
-
"relay start",
|
|
2851
|
-
"relay stop",
|
|
2852
|
-
"relay status",
|
|
2853
|
-
"relay url",
|
|
2854
|
-
"config",
|
|
2855
|
-
"identity",
|
|
2856
|
-
"identity show",
|
|
2857
|
-
"test", // hidden e2e UI harness (dev-only)
|
|
2858
|
-
"peers", // plan/25 Wave D — local + cross-PC inventory
|
|
2859
|
-
"create",
|
|
2860
|
-
"remove",
|
|
2861
|
-
"daemons", // daemon registry (plan/26 W1)
|
|
2862
|
-
// Fleet ops use the `daemon` prefix so `/unbien stop` keeps
|
|
2863
|
-
// meaning "stop this local Pi" — the local UX shipped in plan/25.
|
|
2864
|
-
"daemon start",
|
|
2865
|
-
"daemon stop",
|
|
2866
|
-
"daemon restart",
|
|
2867
|
-
"daemon send",
|
|
2868
|
-
"daemon status",
|
|
2869
|
-
"cron",
|
|
2870
|
-
"cron add",
|
|
2871
|
-
"cron list",
|
|
2872
|
-
"cron remove",
|
|
2873
|
-
"cron enable",
|
|
2874
|
-
"cron disable",
|
|
2875
|
-
"cron run",
|
|
2876
|
-
"cron log",
|
|
2877
|
-
"install",
|
|
2878
|
-
"uninstall", // service install (plan/26 W3)
|
|
2879
|
-
]
|
|
2880
|
-
.filter((o) => o.startsWith(prefix))
|
|
2881
|
-
.map((o) => ({ value: o, label: o }));
|
|
2882
|
-
},
|
|
2883
|
-
handler: async (args, ctx) => {
|
|
2884
|
-
_lastCtx = ctx;
|
|
2885
|
-
const sub = args.trim();
|
|
2886
|
-
if (sub === "") {
|
|
2887
|
-
await _cmdRoot(ctx);
|
|
2888
|
-
}
|
|
2889
|
-
else if (sub === "setup") {
|
|
2890
|
-
await _cmdSetup(ctx);
|
|
2891
|
-
}
|
|
2892
|
-
else if (sub === "status") {
|
|
2893
|
-
_cmdStatus(ctx);
|
|
2894
|
-
}
|
|
2895
|
-
else if (sub === "stop") {
|
|
2896
|
-
await _cmdStop(ctx);
|
|
2897
|
-
}
|
|
2898
|
-
else if (sub === "pair" || sub.startsWith("pair ")) {
|
|
2899
|
-
await _cmdPair(ctx, sub.slice("pair".length).trim());
|
|
2900
|
-
}
|
|
2901
|
-
else if (sub === "devices") {
|
|
2902
|
-
await _cmdList(ctx);
|
|
2903
|
-
}
|
|
2904
|
-
else if (sub.startsWith("revoke")) {
|
|
2905
|
-
await _cmdRevoke(sub.slice("revoke".length).trim(), ctx);
|
|
2906
|
-
}
|
|
2907
|
-
else if (sub.startsWith("set-relay")) {
|
|
2908
|
-
_cmdSetRelay(sub.slice("set-relay".length).trim(), ctx);
|
|
2909
|
-
}
|
|
2910
|
-
else if (sub === "relay" || sub.startsWith("relay ")) {
|
|
2911
|
-
await _cmdRelay(sub.slice("relay".length).trim(), ctx);
|
|
2912
|
-
}
|
|
2913
|
-
else if (sub === "config") {
|
|
2914
|
-
_cmdConfig(ctx);
|
|
2915
|
-
}
|
|
2916
|
-
else if (sub === "identity" || sub.startsWith("identity ")) {
|
|
2917
|
-
await _cmdIdentity(ctx);
|
|
2918
|
-
}
|
|
2919
|
-
else if (sub === "test" || sub.startsWith("test ")) {
|
|
2920
|
-
// Hidden dev-only e2e UI harness: broadcast canned frames to paired apps.
|
|
2921
|
-
_safeNotify(`[un-bien test] ${_runTestScenario(sub.slice("test".length).trim())}`, "info", ctx);
|
|
2922
|
-
}
|
|
2923
|
-
else if (sub === "rename" || sub.startsWith("rename ")) {
|
|
2924
|
-
await _renameAgent(sub.slice("rename".length).trim());
|
|
2925
|
-
}
|
|
2926
|
-
else if (sub === "peers") {
|
|
2927
|
-
await _cmdPeers(ctx);
|
|
2928
|
-
}
|
|
2929
|
-
else if (sub === "install") {
|
|
2930
|
-
_cmdInstall(ctx, { linkCli: true });
|
|
2931
|
-
}
|
|
2932
|
-
else if (sub === "uninstall") {
|
|
2933
|
-
_cmdUninstall(ctx, { linkCli: true });
|
|
2934
|
-
}
|
|
2935
|
-
else {
|
|
2936
|
-
await _cmdRoot(ctx);
|
|
2937
|
-
}
|
|
2938
|
-
},
|
|
2939
|
-
});
|
|
2940
|
-
// Nested registrations (one entry per public action). The flat handler
|
|
2941
|
-
// above already routes `/unbien <sub>` — these exist for the SDK's
|
|
2942
|
-
// command palette and slash-autocomplete in some UI modes.
|
|
2943
|
-
pi.registerCommand("unbien setup", {
|
|
2944
|
-
description: "Run the setup wizard and update local config",
|
|
2945
|
-
handler: async (_, ctx) => {
|
|
2946
|
-
_lastCtx = ctx;
|
|
2947
|
-
await _cmdSetup(ctx);
|
|
2948
|
-
},
|
|
2949
|
-
});
|
|
2950
|
-
pi.registerCommand("unbien status", {
|
|
2951
|
-
description: "Show local mesh + relay status",
|
|
2952
|
-
handler: async (_, ctx) => {
|
|
2953
|
-
_lastCtx = ctx;
|
|
2954
|
-
_cmdStatus(ctx);
|
|
2955
|
-
},
|
|
2956
|
-
});
|
|
2957
|
-
pi.registerCommand("unbien stop", {
|
|
2958
|
-
description: "Stop everything (leave local mesh + disconnect relay)",
|
|
2959
|
-
handler: async (_, ctx) => {
|
|
2960
|
-
_lastCtx = ctx;
|
|
2961
|
-
await _cmdStop(ctx);
|
|
2962
|
-
},
|
|
2963
|
-
});
|
|
2964
|
-
pi.registerCommand("unbien pair", {
|
|
2965
|
-
description: "Show a QR code to pair a new mobile device (optional: --ttl <seconds>)",
|
|
2966
|
-
handler: async (args, ctx) => {
|
|
2967
|
-
_lastCtx = ctx;
|
|
2968
|
-
await _cmdPair(ctx, args.trim());
|
|
2969
|
-
},
|
|
2970
|
-
});
|
|
2971
|
-
pi.registerCommand("unbien devices", {
|
|
2972
|
-
description: "List paired mobile devices",
|
|
2973
|
-
handler: async (_, ctx) => {
|
|
2974
|
-
_lastCtx = ctx;
|
|
2975
|
-
await _cmdList(ctx);
|
|
2976
|
-
},
|
|
2977
|
-
});
|
|
2978
|
-
pi.registerCommand("unbien rename", {
|
|
2979
|
-
description: "Rename this agent in the current session (updates mesh + relay room)",
|
|
2980
|
-
handler: async (args, ctx) => {
|
|
2981
|
-
_lastCtx = ctx;
|
|
2982
|
-
await _renameAgent(args.trim());
|
|
2983
|
-
},
|
|
2984
|
-
});
|
|
2985
|
-
pi.registerCommand("unbien revoke", {
|
|
2986
|
-
description: "Revoke a paired device by its shortid",
|
|
2987
|
-
getArgumentCompletions: async (prefix) => _shortidCompletions(prefix),
|
|
2988
|
-
handler: async (args, ctx) => {
|
|
2989
|
-
_lastCtx = ctx;
|
|
2990
|
-
await _cmdRevoke(args.trim(), ctx);
|
|
2991
|
-
},
|
|
2992
|
-
});
|
|
2993
|
-
pi.registerCommand("unbien set-relay", {
|
|
2994
|
-
description: "Persist a new relay URL to user config",
|
|
2995
|
-
handler: async (args, ctx) => {
|
|
2996
|
-
_lastCtx = ctx;
|
|
2997
|
-
_cmdSetRelay(args.trim(), ctx);
|
|
2998
|
-
},
|
|
2999
|
-
});
|
|
3000
|
-
pi.registerCommand("unbien config", {
|
|
3001
|
-
description: "Show the effective relay URL and where it came from",
|
|
3002
|
-
handler: async (_, ctx) => {
|
|
3003
|
-
_lastCtx = ctx;
|
|
3004
|
-
_cmdConfig(ctx);
|
|
3005
|
-
},
|
|
3006
|
-
});
|
|
3007
|
-
pi.registerCommand("unbien identity", {
|
|
3008
|
-
description: "Show this machine's identity: active EPK (public), backend, and source",
|
|
3009
|
-
handler: async (_, ctx) => {
|
|
3010
|
-
_lastCtx = ctx;
|
|
3011
|
-
await _cmdIdentity(ctx);
|
|
3012
|
-
},
|
|
3013
|
-
});
|
|
3014
|
-
pi.registerCommand("unbien identity show", {
|
|
3015
|
-
description: "Show this machine's identity (EPK/backend/source) — alias of `identity`",
|
|
3016
|
-
handler: async (_, ctx) => {
|
|
3017
|
-
_lastCtx = ctx;
|
|
3018
|
-
await _cmdIdentity(ctx);
|
|
3019
|
-
},
|
|
3020
|
-
});
|
|
3021
|
-
pi.registerCommand("unbien relay", {
|
|
3022
|
-
description: "Relay control: start | stop | status | url <http(s) url> (no arg toggles)",
|
|
3023
|
-
handler: async (args, ctx) => {
|
|
3024
|
-
_lastCtx = ctx;
|
|
3025
|
-
await _cmdRelay(args.trim(), ctx);
|
|
3026
|
-
},
|
|
3027
|
-
});
|
|
3028
|
-
// Plan/25 Wave D
|
|
3029
|
-
pi.registerCommand("unbien peers", {
|
|
3030
|
-
description: "List local + cross-PC mesh peers, grouped by PC label",
|
|
3031
|
-
handler: async (_, ctx) => {
|
|
3032
|
-
_lastCtx = ctx;
|
|
3033
|
-
await _cmdPeers(ctx);
|
|
3034
|
-
},
|
|
3035
|
-
});
|
|
3036
|
-
// Service install / uninstall — the launcher daemon as a system service.
|
|
3037
|
-
pi.registerCommand("unbien install", {
|
|
3038
|
-
description: "Install the un-bien launcher daemon as a system service + link the un-bien CLI (systemd/launchd/Task Scheduler; Windows prompts for admin)",
|
|
3039
|
-
handler: async (_, ctx) => {
|
|
3040
|
-
_lastCtx = ctx;
|
|
3041
|
-
_cmdInstall(ctx, { linkCli: true });
|
|
3042
|
-
},
|
|
3043
|
-
});
|
|
3044
|
-
pi.registerCommand("unbien uninstall", {
|
|
3045
|
-
description: "Remove the un-bien launcher daemon system service + the CLI shims (Windows prompts for admin)",
|
|
3046
|
-
handler: async (_, ctx) => {
|
|
3047
|
-
_lastCtx = ctx;
|
|
3048
|
-
_cmdUninstall(ctx, { linkCli: true });
|
|
3049
|
-
},
|
|
3050
|
-
});
|
|
2311
|
+
registerUnbienCommands(pi, deps);
|
|
3051
2312
|
// Auto-init now runs from the session_start handler (above), AFTER the
|
|
3052
2313
|
// SDK calls bindCore(). The original setTimeout(0) here fired before bindCore
|
|
3053
2314
|
// replaced the throwing action-method stubs, so the first pi.sendMessage in
|
|
@@ -3057,960 +2318,6 @@ const extension = (pi) => {
|
|
|
3057
2318
|
// interactive pi sessions are on remote automatically — no /unbien needed.
|
|
3058
2319
|
};
|
|
3059
2320
|
export default extension;
|
|
3060
|
-
// ── Command implementations ───────────────────────────────────────────────────
|
|
3061
|
-
/**
|
|
3062
|
-
* `/unbien status` — full state snapshot. Two lines: local mesh + relay.
|
|
3063
|
-
*
|
|
3064
|
-
* Always callable; safe when nothing is up (renders the off variants).
|
|
3065
|
-
* Reuses the same icons as the footer so terminal + status output stay
|
|
3066
|
-
* visually consistent.
|
|
3067
|
-
*/
|
|
3068
|
-
function _cmdStatus(ctx) {
|
|
3069
|
-
const relayUrl = _relayUrl ?? resolveRelayUrl().url ?? "not configured";
|
|
3070
|
-
// Mesh line
|
|
3071
|
-
let meshLine;
|
|
3072
|
-
if (_meshNode) {
|
|
3073
|
-
const name = _meshNode.name();
|
|
3074
|
-
meshLine = `🟢 Local mesh: connected as "${name}" (${_sessionPeerCount} peer${_sessionPeerCount === 1 ? "" : "s"})`;
|
|
3075
|
-
}
|
|
3076
|
-
else {
|
|
3077
|
-
meshLine = "⚪ Local mesh: not connected";
|
|
3078
|
-
}
|
|
3079
|
-
// Relay line — paired state is derived from _activePeers.size now.
|
|
3080
|
-
let relayLine;
|
|
3081
|
-
if (_state === "idle") {
|
|
3082
|
-
relayLine = `⚪ Relay: off (${relayUrl}) — run /unbien to start`;
|
|
3083
|
-
}
|
|
3084
|
-
else if (_activePeers.size > 0) {
|
|
3085
|
-
const count = _activePeers.size;
|
|
3086
|
-
const shortids = [..._activePeers.keys()]
|
|
3087
|
-
.map((peerId) => peerId.slice(0, 8))
|
|
3088
|
-
.join(", ");
|
|
3089
|
-
relayLine = `🟢 Relay: ${count} owner${count === 1 ? "" : "s"} online (${shortids}) (${relayUrl})`;
|
|
3090
|
-
}
|
|
3091
|
-
else {
|
|
3092
|
-
relayLine = _hasGlobalPairings
|
|
3093
|
-
? `🟢 Relay: on, waiting for an app to connect (${relayUrl})`
|
|
3094
|
-
: `🟡 Relay: on, waiting for first pairing (${relayUrl})`;
|
|
3095
|
-
}
|
|
3096
|
-
ctx.ui.notify(`[un-bien]\n ${meshLine}\n ${relayLine}`, "info");
|
|
3097
|
-
}
|
|
3098
|
-
/**
|
|
3099
|
-
* Plan/25 Wave D: `/unbien peers`.
|
|
3100
|
-
*
|
|
3101
|
-
* Queries the local broker for the aggregated peer inventory (`list_peers`
|
|
3102
|
-
* returns locals + cross-PC entries prefixed with `<pc_label>:`). Formats
|
|
3103
|
-
* the result grouped by source so users can see at a glance who's on
|
|
3104
|
-
* their machine vs. on a paired sibling Pi.
|
|
3105
|
-
*/
|
|
3106
|
-
async function _cmdPeers(ctx) {
|
|
3107
|
-
if (!_meshNode) {
|
|
3108
|
-
ctx.ui.notify("[un-bien] Not on the local mesh. Run /unbien to join.", "warning");
|
|
3109
|
-
return;
|
|
3110
|
-
}
|
|
3111
|
-
let peers;
|
|
3112
|
-
try {
|
|
3113
|
-
const reply = await _meshNode.request("broker", { type: "list_peers" }, 2000);
|
|
3114
|
-
peers = reply.body?.peers ?? [];
|
|
3115
|
-
}
|
|
3116
|
-
catch (err) {
|
|
3117
|
-
ctx.ui.notify(`[un-bien] peers list failed: ${String(err)}`, "error");
|
|
3118
|
-
return;
|
|
3119
|
-
}
|
|
3120
|
-
// Exclude self from the printed list — `list_peers` returns every peer
|
|
3121
|
-
// registered with the broker including the caller, which is noise here.
|
|
3122
|
-
const selfName = _meshNode.name();
|
|
3123
|
-
ctx.ui.notify(`[un-bien] peers:\n${formatPeerInventory(peers, selfName)}`, "info");
|
|
3124
|
-
}
|
|
3125
|
-
/**
|
|
3126
|
-
* Root handler for `/unbien`. On first run (no local config) drops into
|
|
3127
|
-
* the wizard; on subsequent runs auto-joins the local mesh + starts the
|
|
3128
|
-
* relay (if opted in during setup), then prints the status.
|
|
3129
|
-
*
|
|
3130
|
-
* `/unbien` is intentionally the only command users need day-to-day:
|
|
3131
|
-
* idempotent connect + status display.
|
|
3132
|
-
*/
|
|
3133
|
-
async function _cmdRoot(ctx, restartAuthority) {
|
|
3134
|
-
const rootLifecycleGeneration = restartAuthority?.rootLifecycleGeneration ?? _rootLifecycleGeneration;
|
|
3135
|
-
if (_cmdRootInFlight) {
|
|
3136
|
-
try {
|
|
3137
|
-
await _cmdRootInFlight;
|
|
3138
|
-
}
|
|
3139
|
-
catch (err) {
|
|
3140
|
-
// Stale authority stops here. A current normal duplicate preserves the
|
|
3141
|
-
// outgoing error, while a current replacement suppresses that old-session
|
|
3142
|
-
// failure and falls through to start one fresh root below.
|
|
3143
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
|
|
3144
|
-
return;
|
|
3145
|
-
if (!restartAuthority)
|
|
3146
|
-
throw err;
|
|
3147
|
-
}
|
|
3148
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
|
|
3149
|
-
return;
|
|
3150
|
-
if (!restartAuthority) {
|
|
3151
|
-
_cmdStatus(ctx);
|
|
3152
|
-
return;
|
|
3153
|
-
}
|
|
3154
|
-
}
|
|
3155
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
|
|
3156
|
-
return;
|
|
3157
|
-
const run = _cmdRootInner(ctx, rootLifecycleGeneration);
|
|
3158
|
-
_cmdRootInFlight = run;
|
|
3159
|
-
try {
|
|
3160
|
-
await run;
|
|
3161
|
-
}
|
|
3162
|
-
finally {
|
|
3163
|
-
if (_cmdRootInFlight === run)
|
|
3164
|
-
_cmdRootInFlight = null;
|
|
3165
|
-
}
|
|
3166
|
-
}
|
|
3167
|
-
async function _cmdRootInner(ctx, rootLifecycleGeneration) {
|
|
3168
|
-
// A root retains its startup epoch through every pre-candidate await. This is
|
|
3169
|
-
// stronger than `_disposed`, which a same-module session_start intentionally
|
|
3170
|
-
// clears while an outgoing continuation may still be pending.
|
|
3171
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
|
|
3172
|
-
return;
|
|
3173
|
-
const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
|
|
3174
|
-
// Lock identity is (cwd, name). Several agents may run in the SAME folder; the
|
|
3175
|
-
// requested name just has to be made unique. Derive the name the same way
|
|
3176
|
-
// `_cmdJoin` does so the lock and the mesh registration agree on identity.
|
|
3177
|
-
const requestedName = loadLocalConfig(cwd).agent_name || defaultAgentName(cwd);
|
|
3178
|
-
// Per-(cwd,name) lock. Interactive agents may coexist by auto-suffixing
|
|
3179
|
-
// (`name#2`, `name#3`, …), but supervised daemons must be singletons for their
|
|
3180
|
-
// registered cwd/name. If a daemon silently came up as `#2`, the supervisor
|
|
3181
|
-
// would report "running" while the mesh had duplicate peers for one repo.
|
|
3182
|
-
if (_cwdLock === null) {
|
|
3183
|
-
const isDaemon = process.env["UNBIEN_DAEMON"] === "1";
|
|
3184
|
-
const maxAttempts = isDaemon ? 1 : 1000;
|
|
3185
|
-
for (let n = 1; n <= maxAttempts; n++) {
|
|
3186
|
-
const candidate = n === 1 ? requestedName : `${requestedName}#${n}`;
|
|
3187
|
-
const result = await acquireCwdLock(cwd, candidate);
|
|
3188
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration)) {
|
|
3189
|
-
if (result.ok) {
|
|
3190
|
-
try {
|
|
3191
|
-
result.release();
|
|
3192
|
-
}
|
|
3193
|
-
catch {
|
|
3194
|
-
/* best-effort stale lock cleanup */
|
|
3195
|
-
}
|
|
3196
|
-
}
|
|
3197
|
-
return;
|
|
3198
|
-
}
|
|
3199
|
-
if (result.ok) {
|
|
3200
|
-
_cwdLock = result;
|
|
3201
|
-
_lockedName = candidate;
|
|
3202
|
-
break;
|
|
3203
|
-
}
|
|
3204
|
-
}
|
|
3205
|
-
if (_cwdLock === null) {
|
|
3206
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
|
|
3207
|
-
return;
|
|
3208
|
-
ctx.ui.notify(process.env["UNBIEN_DAEMON"] === "1"
|
|
3209
|
-
? `[un-bien] Daemon not started: another live agent already owns "${requestedName}" in this folder. Stop the old Pi process, then restart the daemon.`
|
|
3210
|
-
: `[un-bien] Could not start: too many agents named "${requestedName}" already running in this folder.`, "warning");
|
|
3211
|
-
return;
|
|
3212
|
-
}
|
|
3213
|
-
}
|
|
3214
|
-
// First-time wizard: no local config in this cwd → run interactive setup.
|
|
3215
|
-
if (!localConfigExists(cwd)) {
|
|
3216
|
-
// SAFETY: ctx.ui MIGHT carry the wizard's select/input methods (interactive
|
|
3217
|
-
// Pi) or not (headless); the `typeof ui.select !== "function"` guard on the
|
|
3218
|
-
// next line validates the structural assumption before any method call.
|
|
3219
|
-
const ui = ctx.ui;
|
|
3220
|
-
if (typeof ui.select !== "function") {
|
|
3221
|
-
_cmdStatus(ctx);
|
|
3222
|
-
return;
|
|
3223
|
-
}
|
|
3224
|
-
const baseDefault = defaultAgentName(cwd);
|
|
3225
|
-
const newConfig = await runSetupWizard(ui, {
|
|
3226
|
-
agent_name: baseDefault,
|
|
3227
|
-
use_relay: true,
|
|
3228
|
-
});
|
|
3229
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
|
|
3230
|
-
return;
|
|
3231
|
-
if (!newConfig) {
|
|
3232
|
-
ctx.ui.notify("[un-bien] Setup cancelled.", "info");
|
|
3233
|
-
return;
|
|
3234
|
-
}
|
|
3235
|
-
saveLocalConfig(cwd, newConfig);
|
|
3236
|
-
ctx.ui.notify(`[un-bien] Config saved to ${cwd}/.pi/un-bien/config.json`, "info");
|
|
3237
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
|
|
3238
|
-
return;
|
|
3239
|
-
await _cmdJoin(ctx);
|
|
3240
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration) || !_meshNode)
|
|
3241
|
-
return;
|
|
3242
|
-
if (effectiveAutoStartRelay(newConfig))
|
|
3243
|
-
await _cmdStart(ctx);
|
|
3244
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration) || !_meshNode)
|
|
3245
|
-
return;
|
|
3246
|
-
_cmdStatus(ctx);
|
|
3247
|
-
return;
|
|
3248
|
-
}
|
|
3249
|
-
// Returning user with config: ALWAYS join the local UDS mesh on connect; the
|
|
3250
|
-
// relay is the only thing gated by auto_start_relay. So auto_start_relay:false
|
|
3251
|
-
// now means "local mesh, no relay" (matching the first-time/wizard path and
|
|
3252
|
-
// the field's documented intent) — previously a false flag skipped the mesh
|
|
3253
|
-
// join entirely, leaving the agent (incl. daemons) fully idle.
|
|
3254
|
-
const config = loadLocalConfig(cwd);
|
|
3255
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration))
|
|
3256
|
-
return;
|
|
3257
|
-
if (!_meshNode)
|
|
3258
|
-
await _cmdJoin(ctx);
|
|
3259
|
-
// `_cmdJoin` returns void on a canceled/failed join, so recheck both the
|
|
3260
|
-
// root lifecycle and publication before bringing the Relay up.
|
|
3261
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration) || !_meshNode)
|
|
3262
|
-
return;
|
|
3263
|
-
if (effectiveAutoStartRelay(config) && _state === "idle")
|
|
3264
|
-
await _cmdStart(ctx);
|
|
3265
|
-
if (!_isCurrentRootLifecycle(rootLifecycleGeneration) || !_meshNode)
|
|
3266
|
-
return;
|
|
3267
|
-
_cmdStatus(ctx);
|
|
3268
|
-
}
|
|
3269
|
-
/**
|
|
3270
|
-
* `/unbien setup` — re-run the wizard. Defaults pre-fill from the
|
|
3271
|
-
* existing config so it doubles as an "edit" flow.
|
|
3272
|
-
*/
|
|
3273
|
-
async function _cmdSetup(ctx) {
|
|
3274
|
-
const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
|
|
3275
|
-
// SAFETY: ctx.ui MIGHT carry the wizard's select/input methods (interactive
|
|
3276
|
-
// Pi) or not (headless); the `typeof ui.select !== "function"` guard on the
|
|
3277
|
-
// next line validates the structural assumption before any method call.
|
|
3278
|
-
const ui = ctx.ui;
|
|
3279
|
-
if (typeof ui.select !== "function") {
|
|
3280
|
-
ctx.ui.notify("[un-bien] Setup requires an interactive UI.", "warning");
|
|
3281
|
-
return;
|
|
3282
|
-
}
|
|
3283
|
-
const current = loadLocalConfig(cwd);
|
|
3284
|
-
const baseDefault = defaultAgentName(cwd);
|
|
3285
|
-
const newConfig = await runSetupWizard(ui, {
|
|
3286
|
-
agent_name: current.agent_name ?? baseDefault,
|
|
3287
|
-
use_relay: effectiveAutoStartRelay(current),
|
|
3288
|
-
});
|
|
3289
|
-
if (!newConfig) {
|
|
3290
|
-
ctx.ui.notify("[un-bien] Setup cancelled.", "info");
|
|
3291
|
-
return;
|
|
3292
|
-
}
|
|
3293
|
-
saveLocalConfig(cwd, newConfig);
|
|
3294
|
-
ctx.ui.notify("[un-bien] Config updated. Run /unbien to apply now.", "info");
|
|
3295
|
-
}
|
|
3296
|
-
async function _cmdStart(ctx) {
|
|
3297
|
-
if (_state !== "idle") {
|
|
3298
|
-
ctx.ui.notify("[un-bien] Already started.", "warning");
|
|
3299
|
-
return;
|
|
3300
|
-
}
|
|
3301
|
-
const lifecycleGeneration = ++_relayLifecycleGeneration;
|
|
3302
|
-
const isCurrentCandidate = () => !_disposed &&
|
|
3303
|
-
lifecycleGeneration === _relayLifecycleGeneration &&
|
|
3304
|
-
_state === "idle" &&
|
|
3305
|
-
_relay === null;
|
|
3306
|
-
let edKp;
|
|
3307
|
-
try {
|
|
3308
|
-
edKp = await getOrCreateEd25519Keypair();
|
|
3309
|
-
}
|
|
3310
|
-
catch (err) {
|
|
3311
|
-
// Identity lookup is part of the candidate lifecycle. A later stop/off or
|
|
3312
|
-
// session replacement must silence its stale rejection before any UI or
|
|
3313
|
-
// error propagation touches the superseded context.
|
|
3314
|
-
if (!isCurrentCandidate())
|
|
3315
|
-
return;
|
|
3316
|
-
if (err instanceof KeyringUnavailableError) {
|
|
3317
|
-
// The platform keyring (macOS Keychain / Windows Credential Manager) is
|
|
3318
|
-
// locked/denied and there's no file identity to fall back to. We refuse
|
|
3319
|
-
// to mint a new key (that's what silently broke pairing after idle), so
|
|
3320
|
-
// abort cleanly with an actionable message instead of crashing or
|
|
3321
|
-
// re-pairing. Unlocking the keychain and re-running fixes it.
|
|
3322
|
-
ctx.ui.notify("[un-bien] Could not read this machine's identity: the system " +
|
|
3323
|
-
"keychain is locked or access was denied. Unlock it (open the app / " +
|
|
3324
|
-
"log in) and run /unbien again. Your pairing is NOT lost. " +
|
|
3325
|
-
'(For a headless host, set "identity": { "storage": "file" } in un-bien.json.)', "error");
|
|
3326
|
-
return;
|
|
3327
|
-
}
|
|
3328
|
-
if (err instanceof PairedIdentityMissingError) {
|
|
3329
|
-
// Issues #95/#69: this process can't reach the keyring that holds the
|
|
3330
|
-
// paired identity (classically a `systemd --user` daemon vs. the desktop
|
|
3331
|
-
// session that paired). Minting a fresh key here would make SelfRevoke
|
|
3332
|
-
// wipe peers.json seconds later and take the phone offline, so storage
|
|
3333
|
-
// refuses. Surface the actionable fix instead of failing silently.
|
|
3334
|
-
ctx.ui.notify("[un-bien] Could not read this machine's identity, but devices are " +
|
|
3335
|
-
"already paired — refusing to generate a new one (that would revoke " +
|
|
3336
|
-
"them). This process likely cannot reach the same keyring as the " +
|
|
3337
|
-
"session that paired (e.g. a systemd --user daemon). Give the service " +
|
|
3338
|
-
"keyring access, or copy the paired keypair to ~/.pi/un-bien/identity.json " +
|
|
3339
|
-
"(0600) so both contexts read the same identity.", "error");
|
|
3340
|
-
return;
|
|
3341
|
-
}
|
|
3342
|
-
throw err;
|
|
3343
|
-
}
|
|
3344
|
-
// Re-check immediately after the first await, before cache/config/model/UI
|
|
3345
|
-
// mutation or Relay construction. `_disposed` alone is insufficient because
|
|
3346
|
-
// same-module session_start intentionally clears it for the replacement.
|
|
3347
|
-
if (!isCurrentCandidate())
|
|
3348
|
-
return;
|
|
3349
|
-
_cachedEd25519 = edKp;
|
|
3350
|
-
const { url: relayUrl, source } = resolveRelayUrl();
|
|
3351
|
-
if (relayUrl === null) {
|
|
3352
|
-
ctx.ui.notify("[un-bien] No relay configured — staying on the local mesh only. Set one " +
|
|
3353
|
-
"with `/unbien set-relay <url>` (or the UNBIEN_RELAY env var) to connect " +
|
|
3354
|
-
"the phone app.", "warning");
|
|
3355
|
-
return;
|
|
3356
|
-
}
|
|
3357
|
-
const myShort = Buffer.from(edKp.publicKey).toString("base64").slice(0, 8);
|
|
3358
|
-
const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
|
|
3359
|
-
// Same name we send in pair_ok — keeps room_meta.name and the per-pair
|
|
3360
|
-
// session_name aligned so the app shows consistent labels.
|
|
3361
|
-
const sessionName = _displayName(cwd);
|
|
3362
|
-
// plan/41: derive the App↔Pi room from (cwd, name) so several agents in the
|
|
3363
|
-
// SAME folder get distinct rooms (the app renders one tile per agent). The
|
|
3364
|
-
// default/unnamed case preserves the legacy cwd-only id (no re-keying). Uses
|
|
3365
|
-
// the SAME name as room_meta.name / pair_ok below — the invariant that the
|
|
3366
|
-
// app pairs on the room the Pi actually announces.
|
|
3367
|
-
const roomId = _deriveRoomId(cwd, sessionName);
|
|
3368
|
-
// Seed the current model from the SDK's resolved selection so room_meta
|
|
3369
|
-
// carries it on connect. `model_select` only fires on an explicit set/cycle
|
|
3370
|
-
// (NOT on settings load), so a headless daemon that just runs its default
|
|
3371
|
-
// model never emits it — without this its room_meta would omit the model and
|
|
3372
|
-
// the app shows "unknown". `getModel()` returns the session's resolved model
|
|
3373
|
-
// in every mode (interactive + RPC daemon); turn_start hydrates it later if
|
|
3374
|
-
// the SDK resolves the model lazily.
|
|
3375
|
-
if (!_currentModel) {
|
|
3376
|
-
try {
|
|
3377
|
-
const c = ctx;
|
|
3378
|
-
// Prefer the live getModel() / ctx.model — populated for an interactive
|
|
3379
|
-
// Pi. For a HEADLESS DAEMON both are undefined at connect: the SDK only
|
|
3380
|
-
// resolves `this.model` lazily at the first turn, and `model_select`
|
|
3381
|
-
// never fires for a default-model session. So fall back to the CONFIGURED
|
|
3382
|
-
// default (defaultProvider/defaultModel in <cwd>/.pi/settings.json) — the
|
|
3383
|
-
// model the daemon will actually use. Without this an idle daemon (never
|
|
3384
|
-
// prompted → no turn) would never report its model and the app shows
|
|
3385
|
-
// "unknown". turn_start still hydrates a later override.
|
|
3386
|
-
const live = c.getModel?.() ?? c.model;
|
|
3387
|
-
if (live) {
|
|
3388
|
-
_currentModel = live.name ?? live.id ?? undefined;
|
|
3389
|
-
}
|
|
3390
|
-
else {
|
|
3391
|
-
const sm = SettingsManager.create(cwd);
|
|
3392
|
-
const provider = sm.getDefaultProvider();
|
|
3393
|
-
const modelId = sm.getDefaultModel();
|
|
3394
|
-
if (modelId) {
|
|
3395
|
-
// SAFETY: ensureModelRegistry only reads modelRegistry/getModel off
|
|
3396
|
-
// the ctx; c (and the _lastEventCtx/_lastCtx fallbacks) carry those
|
|
3397
|
-
// when present, and it tolerates null.
|
|
3398
|
-
const regCtx = (c ??
|
|
3399
|
-
_lastEventCtx ??
|
|
3400
|
-
_lastCtx);
|
|
3401
|
-
const found = provider
|
|
3402
|
-
? ensureModelRegistry(regCtx).find(provider, modelId)
|
|
3403
|
-
: undefined;
|
|
3404
|
-
_currentModel = found?.name ?? modelId;
|
|
3405
|
-
}
|
|
3406
|
-
}
|
|
3407
|
-
}
|
|
3408
|
-
catch {
|
|
3409
|
-
/* defensive — never block start on a model lookup */
|
|
3410
|
-
}
|
|
3411
|
-
}
|
|
3412
|
-
// Plan/28 Wave D.1: seed thinking from the SDK's current level so the
|
|
3413
|
-
// first room_meta hello already carries it. `pi.getThinkingLevel()` is
|
|
3414
|
-
// safe at this point — extension factory has been bound by the SDK
|
|
3415
|
-
// before any command handler fires. Future toggles go through the
|
|
3416
|
-
// `thinking_level_select` event handler above.
|
|
3417
|
-
try {
|
|
3418
|
-
_currentThinking = _pi?.getThinkingLevel();
|
|
3419
|
-
}
|
|
3420
|
-
catch {
|
|
3421
|
-
/* defensive — never block /unbien start on this */
|
|
3422
|
-
}
|
|
3423
|
-
const roomMeta = { name: sessionName, cwd };
|
|
3424
|
-
const modelName = _currentModelName();
|
|
3425
|
-
if (modelName)
|
|
3426
|
-
roomMeta.model = modelName;
|
|
3427
|
-
if (_currentThinking)
|
|
3428
|
-
roomMeta.thinking = _currentThinking;
|
|
3429
|
-
// The room's OWN pi sessionId on the announce — so the app keys per-session
|
|
3430
|
-
// state by the pi id (wire identity), not the routing roomId. roomId stays
|
|
3431
|
-
// relay-routing only.
|
|
3432
|
-
const rootSid = _rootState().sessionManager?.getSessionId() ?? _rootSessionId ?? undefined;
|
|
3433
|
-
if (rootSid)
|
|
3434
|
-
roomMeta.sessionId = rootSid;
|
|
3435
|
-
// Persist so _attemptReconnect can replay the same hello payload — without
|
|
3436
|
-
// this, reconnect issues a bare hello and the relay creates a "default room"
|
|
3437
|
-
// entry that surfaces in the app as a phantom legacy session.
|
|
3438
|
-
_myRoomMeta = roomMeta;
|
|
3439
|
-
ctx.ui.notify(`[un-bien] Connecting to relay ${relayUrl} (source: ${source}, room: ${roomId})…`, "info");
|
|
3440
|
-
// Transport opens WebSocket; convert the canonical http(s):// stored
|
|
3441
|
-
// form to ws(s):// at this boundary. The relayUrl variable keeps the
|
|
3442
|
-
// http(s):// form for logging + mesh client construction below.
|
|
3443
|
-
const relay = new RelayClient(toWebSocketUrl(relayUrl), edKp);
|
|
3444
|
-
try {
|
|
3445
|
-
await relay.connect({ roomId, roomMeta });
|
|
3446
|
-
}
|
|
3447
|
-
catch (err) {
|
|
3448
|
-
// A rejected local candidate is never published and must always be closed,
|
|
3449
|
-
// regardless of whether this lifecycle is still authoritative.
|
|
3450
|
-
try {
|
|
3451
|
-
relay.close();
|
|
3452
|
-
}
|
|
3453
|
-
catch {
|
|
3454
|
-
/* best-effort rejected candidate cleanup */
|
|
3455
|
-
}
|
|
3456
|
-
// A stop, shutdown/replacement, relay-off, or newer start may supersede a
|
|
3457
|
-
// candidate before its rejection arrives. Keep the outgoing context silent;
|
|
3458
|
-
// only the authoritative attempt may report an error.
|
|
3459
|
-
if (!isCurrentCandidate())
|
|
3460
|
-
return;
|
|
3461
|
-
if (err instanceof RoomAlreadyOpenError) {
|
|
3462
|
-
ctx.ui.notify("[un-bien] Already running in this cwd. Stop the other terminal first.", "error");
|
|
3463
|
-
return;
|
|
3464
|
-
}
|
|
3465
|
-
ctx.ui.notify(`[un-bien] relay connect failed: ${String(err)}`, "error");
|
|
3466
|
-
return;
|
|
3467
|
-
}
|
|
3468
|
-
// The candidate is local until this publication point. Session shutdown,
|
|
3469
|
-
// stop/relay-off, or a newer start may have invalidated it while connect()
|
|
3470
|
-
// was pending; never let that stale continuation resurrect the Relay.
|
|
3471
|
-
if (!isCurrentCandidate()) {
|
|
3472
|
-
try {
|
|
3473
|
-
relay.close();
|
|
3474
|
-
}
|
|
3475
|
-
catch {
|
|
3476
|
-
/* best-effort stale candidate cleanup */
|
|
3477
|
-
}
|
|
3478
|
-
return;
|
|
3479
|
-
}
|
|
3480
|
-
_relay = relay;
|
|
3481
|
-
_relayUrl = relayUrl;
|
|
3482
|
-
_peerShort = myShort;
|
|
3483
|
-
_myRoomId = roomId;
|
|
3484
|
-
_state = "started";
|
|
3485
|
-
// Set _sessionStartedAt ONLY on first /unbien start since process boot.
|
|
3486
|
-
// Subsequent start cycles (after stop) preserve the original epoch so the
|
|
3487
|
-
// app keeps treating it as the same session (and merges new events from
|
|
3488
|
-
// the terminal turns that happened during the idle window). Pi process
|
|
3489
|
-
// restart is the only thing that produces a fresh session_started_at.
|
|
3490
|
-
if (_sessionStartedAt === null)
|
|
3491
|
-
_sessionStartedAt = Date.now();
|
|
3492
|
-
// _messageBuffer intentionally preserved across stop/start — it accumulates
|
|
3493
|
-
// message_end events for the lifetime of the Pi process, including turns
|
|
3494
|
-
// initiated from the terminal while the relay was disconnected.
|
|
3495
|
-
relay.on("close", () => _onRelayClose(relay));
|
|
3496
|
-
_stopAutoListener = _installAutoListener(relay);
|
|
3497
|
-
_refreshFooter(ctx);
|
|
3498
|
-
// SelfRevoke is the Pi path's single initial topology producer. Its first
|
|
3499
|
-
// coalesced sweep always publishes verified membership or a safe fallback
|
|
3500
|
-
// before the bridge may attach.
|
|
3501
|
-
let createdProducer = false;
|
|
3502
|
-
if (_selfRevoke === null) {
|
|
3503
|
-
createdProducer = true;
|
|
3504
|
-
const producerEpoch = ++_selfRevokeEpoch;
|
|
3505
|
-
_selfRevokeTopologyReadyEpoch = -1;
|
|
3506
|
-
_selfRevokeTopology = null;
|
|
3507
|
-
let producer;
|
|
3508
|
-
producer = new SelfRevoke({
|
|
3509
|
-
client: new MeshClient(relayUrl),
|
|
3510
|
-
storage: { snapshotOwnerPubkeys, conditionalRemovePeer },
|
|
3511
|
-
myPubkey: edKp.publicKey,
|
|
3512
|
-
onRevoke: (rawOwnerPubkey, canonicalOwnerPubkey) => {
|
|
3513
|
-
if (_selfRevoke !== producer || producerEpoch !== _selfRevokeEpoch) {
|
|
3514
|
-
return;
|
|
3515
|
-
}
|
|
3516
|
-
_revokeActiveOwnerRuntime(canonicalOwnerPubkey);
|
|
3517
|
-
void rawOwnerPubkey; // exact storage removal already happened upstream
|
|
3518
|
-
},
|
|
3519
|
-
onAuthoritativeOwners: (canonicalOwnerPubkeys) => {
|
|
3520
|
-
if (_selfRevoke !== producer || producerEpoch !== _selfRevokeEpoch) {
|
|
3521
|
-
return;
|
|
3522
|
-
}
|
|
3523
|
-
const presentOwners = new Set(canonicalOwnerPubkeys);
|
|
3524
|
-
let effectFailed = false;
|
|
3525
|
-
for (const canonicalOwnerPubkey of [..._activePeers.keys()]) {
|
|
3526
|
-
if (_selfRevoke !== producer || producerEpoch !== _selfRevokeEpoch) {
|
|
3527
|
-
return;
|
|
3528
|
-
}
|
|
3529
|
-
if (presentOwners.has(canonicalOwnerPubkey))
|
|
3530
|
-
continue;
|
|
3531
|
-
try {
|
|
3532
|
-
_revokeActiveOwnerRuntime(canonicalOwnerPubkey);
|
|
3533
|
-
}
|
|
3534
|
-
catch {
|
|
3535
|
-
effectFailed = true;
|
|
3536
|
-
}
|
|
3537
|
-
}
|
|
3538
|
-
if (effectFailed)
|
|
3539
|
-
throw new Error("Owner runtime reconciliation failed");
|
|
3540
|
-
},
|
|
3541
|
-
onTopologyChanged: (snapshot) => {
|
|
3542
|
-
if (_selfRevoke !== producer || producerEpoch !== _selfRevokeEpoch) {
|
|
3543
|
-
return;
|
|
3544
|
-
}
|
|
3545
|
-
_selfRevokeTopology = snapshot;
|
|
3546
|
-
_meshNode?.setTopology(snapshot);
|
|
3547
|
-
_selfRevokeTopologyReadyEpoch = producerEpoch;
|
|
3548
|
-
_attachBridgeIfReady();
|
|
3549
|
-
},
|
|
3550
|
-
log: { info: () => { }, warn: () => { }, error: () => { } },
|
|
3551
|
-
});
|
|
3552
|
-
_selfRevoke = producer;
|
|
3553
|
-
producer.start();
|
|
3554
|
-
await producer.checkOnce();
|
|
3555
|
-
if (_disposed ||
|
|
3556
|
-
_selfRevoke !== producer ||
|
|
3557
|
-
producerEpoch !== _selfRevokeEpoch ||
|
|
3558
|
-
_relay !== relay) {
|
|
3559
|
-
return;
|
|
3560
|
-
}
|
|
3561
|
-
}
|
|
3562
|
-
// Relay reconnect reuses the current producer's retained snapshot. Initial
|
|
3563
|
-
// startup is callback-driven above, so it must not issue a second attach.
|
|
3564
|
-
if (!createdProducer)
|
|
3565
|
-
_attachBridgeIfReady();
|
|
3566
|
-
_emitRelayState(); // → connected
|
|
3567
|
-
ctx.ui.notify(`[un-bien] state: started (peer=${myShort}) — Connected to relay ${relayUrl}`, "info");
|
|
3568
|
-
}
|
|
3569
|
-
/**
|
|
3570
|
-
* `/unbien pair` — always generates a fresh QR when the relay is up.
|
|
3571
|
-
*
|
|
3572
|
-
* Pre-W2D this rejected with "Already paired with X" once one owner was
|
|
3573
|
-
* connected, forcing /unbien stop to pair a second device — the
|
|
3574
|
-
* catch-22 the multi-channel refactor was designed to break. Now the new
|
|
3575
|
-
* device is **added** to `_activePeers` after scanning, while existing
|
|
3576
|
-
* owners keep their session.
|
|
3577
|
-
*/
|
|
3578
|
-
async function _cmdPair(ctx, args = "") {
|
|
3579
|
-
const cwd = "cwd" in ctx ? ctx.cwd : "";
|
|
3580
|
-
// Auto-bootstrap when services are down. Before this, `/unbien pair`
|
|
3581
|
-
// on a fresh terminal forced the user to call `/unbien` first — every
|
|
3582
|
-
// session began with the same surprise warning + second command. Now we
|
|
3583
|
-
// do the join + relay-start inline so the common "I just opened a
|
|
3584
|
-
// terminal and want to pair my phone" flow is a single command.
|
|
3585
|
-
//
|
|
3586
|
-
// We don't run the first-time wizard here: pair is a focused operation
|
|
3587
|
-
// and the wizard prompts are wrong UX in that flow. If there's no local
|
|
3588
|
-
// config, the user truly needs to run `/unbien` first to configure.
|
|
3589
|
-
if (_state === "idle") {
|
|
3590
|
-
if (!localConfigExists(cwd)) {
|
|
3591
|
-
ctx.ui.notify("[un-bien] First-time setup needed. Run /unbien to configure, then /unbien pair.", "warning");
|
|
3592
|
-
return;
|
|
3593
|
-
}
|
|
3594
|
-
ctx.ui.notify("[un-bien] Starting mesh + relay before pairing…", "info");
|
|
3595
|
-
if (!_meshNode)
|
|
3596
|
-
await _cmdJoin(ctx);
|
|
3597
|
-
if (_state === "idle")
|
|
3598
|
-
await _cmdStart(ctx);
|
|
3599
|
-
}
|
|
3600
|
-
// Relay must be up — the QR carries a token the app exchanges through
|
|
3601
|
-
// the relay. Without a live WS there's nothing for the scan to land on.
|
|
3602
|
-
if (_state === "idle" || !_relay) {
|
|
3603
|
-
ctx.ui.notify("[un-bien] Pair requires the relay to be connected. " +
|
|
3604
|
-
"Run /unbien to start it (or fix your relay URL via /unbien set-relay).", "warning");
|
|
3605
|
-
return;
|
|
3606
|
-
}
|
|
3607
|
-
const edKp = _cachedEd25519;
|
|
3608
|
-
// Embed the user-configured name in the QR so the app shows it on the
|
|
3609
|
-
// pairing screen before pair_ok lands (better UX than "remote" or a
|
|
3610
|
-
// raw path snippet).
|
|
3611
|
-
const sessionName = _displayName(cwd);
|
|
3612
|
-
// Optional `--ttl <seconds>` — RPC clients (e.g. Cockpit) pass a caller-
|
|
3613
|
-
// defined expiry. Defaults to TOKEN_TTL_MS, clamped to the safe window.
|
|
3614
|
-
const ttlMatch = /--ttl\s+(\d+)/.exec(args);
|
|
3615
|
-
const ttlMs = ttlMatch
|
|
3616
|
-
? clampPairTtlMs(Number(ttlMatch[1]) * 1000)
|
|
3617
|
-
: TOKEN_TTL_MS;
|
|
3618
|
-
const { token, expiresAt } = qrSession.issueToken(ttlMs);
|
|
3619
|
-
// The QR room is the ISSUING session's room (session-id-derived `_myRoomId`).
|
|
3620
|
-
// Pairing is room-scoped to this session (it owns the QR token), so only this
|
|
3621
|
-
// session answers the pair_request — no fan-out race. Trust still lands on the
|
|
3622
|
-
// MACHINE: pair_ok persists a PairedMachine keyed by epk, and the app then
|
|
3623
|
-
// discovers all the machine's sessions via room_announced.
|
|
3624
|
-
const roomId = _myRoomId ?? _deriveRoomId(cwd, sessionName);
|
|
3625
|
-
const qrUri = buildQRUri(token, edKp.publicKey, sessionName, roomId);
|
|
3626
|
-
// Render both the QR ASCII and the copy-paste URI inside the Pi TUI's
|
|
3627
|
-
// chat panel via `pi.sendMessage` — the same channel the SDK uses for
|
|
3628
|
-
// agent responses + tool results. `process.stderr.write` (the old QR
|
|
3629
|
-
// path via `displayQR`) broke the TUI layout because it bypassed the
|
|
3630
|
-
// chat widget and bled into the prompt area. qrcode-terminal v0.12
|
|
3631
|
-
// small mode is pure Unicode (█ ▀ ▄ space, no ANSI escapes — see
|
|
3632
|
-
// `lib/main.js:48-53`), so embedding the ASCII inside a sendMessage
|
|
3633
|
-
// content string renders correctly without raw escape bytes.
|
|
3634
|
-
if (_pi) {
|
|
3635
|
-
const qrAscii = renderQRAscii(qrUri);
|
|
3636
|
-
_pi.sendMessage({
|
|
3637
|
-
customType: "un-bien:pair-code",
|
|
3638
|
-
content: `📱 Scan to pair:\n\n${qrAscii}\n` +
|
|
3639
|
-
`📋 Or copy this pairing code (camera-less devices):\n\n${qrUri}`,
|
|
3640
|
-
// Structured payload for RPC clients (e.g. Cockpit): render their own QR
|
|
3641
|
-
// from `uri` + show the expiry, without scraping the display string.
|
|
3642
|
-
details: { uri: qrUri, token, expiresAt, roomId, name: sessionName },
|
|
3643
|
-
display: true,
|
|
3644
|
-
});
|
|
3645
|
-
}
|
|
3646
|
-
ctx.ui.notify(`[un-bien] QR ready — valid until ${new Date(expiresAt).toLocaleTimeString()}. ` +
|
|
3647
|
-
`Scan with the app, or copy the pairing code printed above.`, "info");
|
|
3648
|
-
// Returns immediately; the auto-listener transitions to 'paired' on pair_request.
|
|
3649
|
-
}
|
|
3650
|
-
/**
|
|
3651
|
-
* `/unbien stop` — full teardown. Leaves the local UDS mesh AND closes
|
|
3652
|
-
* the relay. Safe when one or both are already off. To resume, run
|
|
3653
|
-
* `/unbien` again.
|
|
3654
|
-
*/
|
|
3655
|
-
async function _cmdStop(ctx) {
|
|
3656
|
-
// Invalidate queued root work and local async candidates even when none has
|
|
3657
|
-
// published yet.
|
|
3658
|
-
_rootLifecycleGeneration += 1;
|
|
3659
|
-
_meshJoinGeneration += 1;
|
|
3660
|
-
const meshUp = _meshNode !== null;
|
|
3661
|
-
const relayUp = _state !== "idle";
|
|
3662
|
-
if (!meshUp && !relayUp) {
|
|
3663
|
-
_relayLifecycleGeneration += 1;
|
|
3664
|
-
ctx.ui.notify("[un-bien] Already stopped — nothing to do.", "info");
|
|
3665
|
-
return;
|
|
3666
|
-
}
|
|
3667
|
-
// Revoke Relay/SelfRevoke/bridge authority while the global node is still
|
|
3668
|
-
// visible and before close() begins UDS leave.
|
|
3669
|
-
if (relayUp) {
|
|
3670
|
-
_goIdle();
|
|
3671
|
-
}
|
|
3672
|
-
else {
|
|
3673
|
-
_relayLifecycleGeneration += 1;
|
|
3674
|
-
_meshNode?.detachBridge();
|
|
3675
|
-
}
|
|
3676
|
-
const meshNode = _meshNode;
|
|
3677
|
-
_meshNode = null;
|
|
3678
|
-
_sessionName = null;
|
|
3679
|
-
_sessionPeerCount = 0;
|
|
3680
|
-
let meshClose = null;
|
|
3681
|
-
try {
|
|
3682
|
-
meshClose = meshNode?.close() ?? null;
|
|
3683
|
-
}
|
|
3684
|
-
catch {
|
|
3685
|
-
/* best-effort */
|
|
3686
|
-
}
|
|
3687
|
-
try {
|
|
3688
|
-
await meshClose;
|
|
3689
|
-
}
|
|
3690
|
-
catch {
|
|
3691
|
-
/* best-effort */
|
|
3692
|
-
}
|
|
3693
|
-
ctx.ui.notify("[un-bien] Stopped (mesh + relay disconnected).", "info");
|
|
3694
|
-
_refreshFooter(ctx);
|
|
3695
|
-
}
|
|
3696
|
-
async function _cmdList(ctx) {
|
|
3697
|
-
const peers = await listPeers();
|
|
3698
|
-
if (peers.length === 0) {
|
|
3699
|
-
ctx.ui.notify("[un-bien] No paired devices.", "info");
|
|
3700
|
-
return;
|
|
3701
|
-
}
|
|
3702
|
-
// Multi-channel (W2D): each peer is either `online` (channel attached
|
|
3703
|
-
// right now) or `offline` (in peers.json but not connected). Replaces
|
|
3704
|
-
// the singleton " (active)" marker that only ever marked one peer.
|
|
3705
|
-
const lines = peers
|
|
3706
|
-
.flatMap((record) => {
|
|
3707
|
-
const inspected = _inspectPeerRecord(record);
|
|
3708
|
-
if (!inspected)
|
|
3709
|
-
return [];
|
|
3710
|
-
const tag = inspected.runtimeKey !== null && _activePeers.has(inspected.runtimeKey)
|
|
3711
|
-
? " 🟢 online"
|
|
3712
|
-
: " ⚪ offline";
|
|
3713
|
-
return `• ${inspected.rawHandle.slice(0, 8)} — ${inspected.record.name}${tag}`;
|
|
3714
|
-
})
|
|
3715
|
-
.join("\n");
|
|
3716
|
-
ctx.ui.notify(`[un-bien] Paired devices:\n${lines}`, "info");
|
|
3717
|
-
}
|
|
3718
|
-
async function _cmdRevoke(arg, ctx) {
|
|
3719
|
-
const shortid = arg.trim();
|
|
3720
|
-
if (!shortid) {
|
|
3721
|
-
ctx.ui.notify("[un-bien] Usage: /unbien revoke <shortid>. Run /unbien list to see shortids.", "warning");
|
|
3722
|
-
return;
|
|
3723
|
-
}
|
|
3724
|
-
// Revoke needs the relay so the revoked device's live channel is torn down
|
|
3725
|
-
// — not just a silent peers.json edit. Auto-bootstrap the mesh + relay when
|
|
3726
|
-
// down, mirroring `_cmdPair`.
|
|
3727
|
-
const cwd = "cwd" in ctx ? ctx.cwd : "";
|
|
3728
|
-
if (_state === "idle") {
|
|
3729
|
-
if (!localConfigExists(cwd)) {
|
|
3730
|
-
ctx.ui.notify("[un-bien] First-time setup needed. Run /unbien to configure, then /unbien revoke.", "warning");
|
|
3731
|
-
return;
|
|
3732
|
-
}
|
|
3733
|
-
ctx.ui.notify("[un-bien] Starting mesh + relay before revoking…", "info");
|
|
3734
|
-
if (!_meshNode)
|
|
3735
|
-
await _cmdJoin(ctx);
|
|
3736
|
-
if (_state === "idle")
|
|
3737
|
-
await _cmdStart(ctx);
|
|
3738
|
-
}
|
|
3739
|
-
if (_state === "idle" || !_relay) {
|
|
3740
|
-
ctx.ui.notify("[un-bien] Revoke requires the relay to be connected. " +
|
|
3741
|
-
"Run /unbien to start it (or fix your relay URL via /unbien set-relay).", "warning");
|
|
3742
|
-
return;
|
|
3743
|
-
}
|
|
3744
|
-
const matches = (await listPeers())
|
|
3745
|
-
.map(_inspectPeerRecord)
|
|
3746
|
-
.filter((peer) => peer !== null)
|
|
3747
|
-
.filter((peer) => peer.rawHandle.startsWith(shortid));
|
|
3748
|
-
if (matches.length === 0) {
|
|
3749
|
-
ctx.ui.notify("[un-bien] No peer matching that shortid. Run /unbien devices to see shortids.", "warning");
|
|
3750
|
-
return;
|
|
3751
|
-
}
|
|
3752
|
-
if (matches.length > 1) {
|
|
3753
|
-
const collisions = matches
|
|
3754
|
-
.map((peer) => peer.rawHandle.slice(0, 8))
|
|
3755
|
-
.join(", ");
|
|
3756
|
-
ctx.ui.notify(`[un-bien] Ambiguous shortid — ${matches.length} matches: ${collisions}. Use mais chars.`, "warning");
|
|
3757
|
-
return;
|
|
3758
|
-
}
|
|
3759
|
-
const peer = matches[0];
|
|
3760
|
-
await removePeer(peer.rawHandle);
|
|
3761
|
-
_refreshPairingsCache();
|
|
3762
|
-
// Storage removal uses the exact saved representation; the active channel
|
|
3763
|
-
// is indexed by its canonical identity.
|
|
3764
|
-
if (peer.runtimeKey !== null && _activePeers.has(peer.runtimeKey)) {
|
|
3765
|
-
_detachPeerChannel(peer.runtimeKey);
|
|
3766
|
-
_refreshFooter();
|
|
3767
|
-
}
|
|
3768
|
-
ctx.ui.notify(`[un-bien] Revoked: ${peer.record.name} (${peer.rawHandle.slice(0, 8)}…)`, "info");
|
|
3769
|
-
}
|
|
3770
|
-
async function _shortidCompletions(prefix, valuePrefix = "") {
|
|
3771
|
-
const peers = (await listPeers())
|
|
3772
|
-
.map(_inspectPeerRecord)
|
|
3773
|
-
.filter((peer) => peer !== null);
|
|
3774
|
-
return peers
|
|
3775
|
-
.map((peer) => ({
|
|
3776
|
-
shortid: peer.rawHandle.slice(0, 8),
|
|
3777
|
-
name: peer.record.name,
|
|
3778
|
-
}))
|
|
3779
|
-
.filter((entry) => entry.shortid.startsWith(prefix))
|
|
3780
|
-
.map((entry) => ({
|
|
3781
|
-
value: `${valuePrefix}${entry.shortid}`,
|
|
3782
|
-
label: `${entry.shortid} (${entry.name})`,
|
|
3783
|
-
}));
|
|
3784
|
-
}
|
|
3785
|
-
function _cmdSetRelay(arg, ctx) {
|
|
3786
|
-
const raw = arg.trim();
|
|
3787
|
-
if (!raw) {
|
|
3788
|
-
ctx.ui.notify("[un-bien] Usage: /unbien set-relay <http:// or https:// url>", "warning");
|
|
3789
|
-
return;
|
|
3790
|
-
}
|
|
3791
|
-
if (isWebSocketScheme(raw)) {
|
|
3792
|
-
ctx.ui.notify(`[un-bien] Use http:// or https://. The extension converts to WebSocket automatically.`, "error");
|
|
3793
|
-
return;
|
|
3794
|
-
}
|
|
3795
|
-
if (!isValidRelayUrl(raw)) {
|
|
3796
|
-
ctx.ui.notify(`[un-bien] Invalid URL: ${raw}. Must start with http:// or https://`, "error");
|
|
3797
|
-
return;
|
|
3798
|
-
}
|
|
3799
|
-
saveConfig({ relay: raw });
|
|
3800
|
-
ctx.ui.notify(`[un-bien] Relay set to ${raw}. Run /unbien relay stop then /unbien relay start to apply.`, "info");
|
|
3801
|
-
}
|
|
3802
|
-
/**
|
|
3803
|
-
* `/unbien config` — print the effective relay URL and where it came from.
|
|
3804
|
-
*
|
|
3805
|
-
* Documented in the README ("Verify the active URL and its source") and in
|
|
3806
|
-
* CLAUDE.md, but like the `relay` family (issue #119) it had no handler and
|
|
3807
|
-
* fell through to the status panel, which shows the URL but not the source —
|
|
3808
|
-
* so `env` vs `config` vs `default` was unverifiable without a restart.
|
|
3809
|
-
*/
|
|
3810
|
-
function _cmdConfig(ctx) {
|
|
3811
|
-
const { url, source } = resolveRelayUrl();
|
|
3812
|
-
const origin = source === "env"
|
|
3813
|
-
? "UNBIEN_RELAY environment variable"
|
|
3814
|
-
: source === "config"
|
|
3815
|
-
? "extensions/un-bien.json (set via /unbien set-relay)"
|
|
3816
|
-
: "not set — run /unbien set-relay <url> or set UNBIEN_RELAY";
|
|
3817
|
-
const live = _relayUrl && _relayUrl !== url
|
|
3818
|
-
? `\n ⚠ Live connection still on ${_relayUrl} — run /unbien relay stop then /unbien relay start to apply.`
|
|
3819
|
-
: "";
|
|
3820
|
-
ctx.ui.notify(`[un-bien]\n Relay URL: ${url ?? "(none)"}\n Source: ${source} — ${origin}${live}`, "info");
|
|
3821
|
-
}
|
|
3822
|
-
/**
|
|
3823
|
-
* `/unbien identity` — report NON-SECRET identity state (active EPK, backend,
|
|
3824
|
-
* resolved source). The private seed is NEVER shown: command output is
|
|
3825
|
-
* LLM-visible, so extraction stays a manual `cat`/keychain op. Read-only
|
|
3826
|
-
* (never mints).
|
|
3827
|
-
*/
|
|
3828
|
-
async function _cmdIdentity(ctx) {
|
|
3829
|
-
const info = await describeIdentity();
|
|
3830
|
-
const backendLine = info.backend === "file" ? `file (${info.filePath})` : "keychain";
|
|
3831
|
-
const epkLine = info.epk ?? "(none yet — minted on first use)";
|
|
3832
|
-
const sourceLine = info.detail
|
|
3833
|
-
? `${info.source} — ${info.detail}`
|
|
3834
|
-
: info.source;
|
|
3835
|
-
ctx.ui.notify(`[un-bien] identity\n Backend: ${backendLine}\n EPK (public): ${epkLine}\n Source: ${sourceLine}`, info.source === "error" ? "error" : "info");
|
|
3836
|
-
}
|
|
3837
|
-
/**
|
|
3838
|
-
* `/unbien relay [start|stop|status|url <url>]` — issue #119.
|
|
3839
|
-
*
|
|
3840
|
-
* The README has always documented this family (`relay url` to point at a
|
|
3841
|
-
* self-hosted relay, `relay stop` + `relay start` to apply the change), but no
|
|
3842
|
-
* handler existed: every `relay …` invocation fell through the `else` in the
|
|
3843
|
-
* flat dispatcher and silently reprinted the status panel. Users following the
|
|
3844
|
-
* README believed they had switched relays and stayed on the community relay —
|
|
3845
|
-
* exactly the case where our own docs warn the operator sees routed plaintext.
|
|
3846
|
-
*
|
|
3847
|
-
* Verbs map onto the same primitives the RPC control channel already uses
|
|
3848
|
-
* (`_handleControl`), so the slash command and the Cockpit button can't drift:
|
|
3849
|
-
* relay-only up (`_cmdStart`) / relay-only down (`_goIdle`), never touching
|
|
3850
|
-
* local-mesh membership — that stays `/unbien stop`'s job.
|
|
3851
|
-
*/
|
|
3852
|
-
async function _cmdRelay(arg, ctx) {
|
|
3853
|
-
const raw = arg.trim();
|
|
3854
|
-
const [verb, ...rest] = raw.split(/\s+/);
|
|
3855
|
-
const value = rest.join(" ").trim();
|
|
3856
|
-
switch (verb) {
|
|
3857
|
-
case "":
|
|
3858
|
-
case "toggle":
|
|
3859
|
-
await _handleControl("relay:toggle");
|
|
3860
|
-
ctx.ui.notify(`[un-bien] Relay ${_relayStatus()}.`, "info");
|
|
3861
|
-
_refreshFooter(ctx);
|
|
3862
|
-
return;
|
|
3863
|
-
case "start":
|
|
3864
|
-
case "on":
|
|
3865
|
-
if (_getState() === "idle")
|
|
3866
|
-
await _cmdStart(ctx);
|
|
3867
|
-
else
|
|
3868
|
-
ctx.ui.notify(`[un-bien] Relay already ${_relayStatus()}.`, "info");
|
|
3869
|
-
_emitRelayState(true);
|
|
3870
|
-
return;
|
|
3871
|
-
case "stop":
|
|
3872
|
-
case "off":
|
|
3873
|
-
if (_getState() === "idle") {
|
|
3874
|
-
ctx.ui.notify("[un-bien] Relay already disconnected.", "info");
|
|
3875
|
-
}
|
|
3876
|
-
else {
|
|
3877
|
-
_goIdle();
|
|
3878
|
-
ctx.ui.notify("[un-bien] Relay disconnected (local mesh untouched).", "info");
|
|
3879
|
-
}
|
|
3880
|
-
_emitRelayState(true);
|
|
3881
|
-
_refreshFooter(ctx);
|
|
3882
|
-
return;
|
|
3883
|
-
case "status":
|
|
3884
|
-
_cmdStatus(ctx);
|
|
3885
|
-
_emitRelayState(true);
|
|
3886
|
-
return;
|
|
3887
|
-
case "url":
|
|
3888
|
-
// Same writer as `set-relay` — one code path, so validation and the
|
|
3889
|
-
// "restart to apply" hint can never diverge between the two spellings.
|
|
3890
|
-
_cmdSetRelay(value, ctx);
|
|
3891
|
-
return;
|
|
3892
|
-
default:
|
|
3893
|
-
ctx.ui.notify("[un-bien] Usage: /unbien relay [start|stop|status|url <http(s) url>]", "warning");
|
|
3894
|
-
return;
|
|
3895
|
-
}
|
|
3896
|
-
}
|
|
3897
|
-
// ── Install/uninstall the launcher-daemon service ────────────────────────────
|
|
3898
|
-
//
|
|
3899
|
-
// Installs the un-bien launcher daemon as a user-level system service (systemd
|
|
3900
|
-
// `--user` unit on Linux, launchd LaunchAgent on macOS, Task Scheduler on
|
|
3901
|
-
// Windows). Once installed the launcher daemon starts at login + survives
|
|
3902
|
-
// reboots. Uninstall is the inverse.
|
|
3903
|
-
/**
|
|
3904
|
-
* `linkCli` controls whether we symlink `un-bien` into `~/.local/bin/`. The
|
|
3905
|
-
* slash-command path passes `true` (user is inside Pi's TUI — they installed
|
|
3906
|
-
* via `pi install npm:un-bien` and need us to expose the CLI for them). The
|
|
3907
|
-
* standalone-CLI path passes `false` because the user is already running our
|
|
3908
|
-
* binary from PATH (they did `npm install -g un-bien`), so re-linking would
|
|
3909
|
-
* point their `un-bien` at the Pi-extension copy and diverge on upgrades.
|
|
3910
|
-
*/
|
|
3911
|
-
/** Returns true on success, false when install failed (so the standalone CLI
|
|
3912
|
-
* can exit non-zero — e.g. the Cockpit / CI detect failure by exit code).
|
|
3913
|
-
* We do NOT process.exit here: this also runs inside the Pi TUI, where exiting
|
|
3914
|
-
* would kill the session. */
|
|
3915
|
-
function _cmdInstall(ctx, opts = {}) {
|
|
3916
|
-
const linkCli = opts.linkCli ?? false;
|
|
3917
|
-
try {
|
|
3918
|
-
const result = installService();
|
|
3919
|
-
const sections = [
|
|
3920
|
-
`[un-bien] Launcher daemon service installed (${result.platform}).`,
|
|
3921
|
-
` Unit: ${result.unitPath}`,
|
|
3922
|
-
` Steps:\n${result.log.map((l) => " " + l).join("\n")}`,
|
|
3923
|
-
];
|
|
3924
|
-
if (linkCli) {
|
|
3925
|
-
const link = linkCliBinaries();
|
|
3926
|
-
sections.push(` CLI bins linked into ${link.binDir}:`, link.links.map((l) => ` ${l.name} → ${l.target}`).join("\n"), ` Steps:\n${link.log.map((l) => " " + l).join("\n")}`);
|
|
3927
|
-
if (!link.onPath) {
|
|
3928
|
-
if (process.platform === "win32") {
|
|
3929
|
-
sections.push(` ⚠ ${link.binDir} was just added to your user PATH (it wasn't there yet).`, ` Open a NEW terminal and run \`unbien status\` to verify.`);
|
|
3930
|
-
}
|
|
3931
|
-
else {
|
|
3932
|
-
sections.push(` ⚠ ${link.binDir} is not on $PATH yet. Add this line to ~/.zshrc / ~/.bashrc:`, ` export PATH="$HOME/.local/bin:$PATH"`, ` Then open a new terminal and run \`unbien status\` to verify.`);
|
|
3933
|
-
}
|
|
3934
|
-
}
|
|
3935
|
-
}
|
|
3936
|
-
ctx.ui.notify(sections.join("\n"), "info");
|
|
3937
|
-
return true;
|
|
3938
|
-
}
|
|
3939
|
-
catch (err) {
|
|
3940
|
-
ctx.ui.notify(`[un-bien] install failed: ${String(err)}`, "error");
|
|
3941
|
-
return false;
|
|
3942
|
-
}
|
|
3943
|
-
}
|
|
3944
|
-
function _cmdUninstall(ctx, opts = {}) {
|
|
3945
|
-
const linkCli = opts.linkCli ?? false;
|
|
3946
|
-
try {
|
|
3947
|
-
const result = uninstallService();
|
|
3948
|
-
const sections = [
|
|
3949
|
-
`[un-bien] Launcher daemon service uninstalled (${result.platform}).`,
|
|
3950
|
-
` Unit: ${result.unitPath} (${result.removed ? "removed" : "not present"})`,
|
|
3951
|
-
` Steps:\n${result.log.map((l) => " " + l).join("\n")}`,
|
|
3952
|
-
];
|
|
3953
|
-
if (linkCli) {
|
|
3954
|
-
const unlink = unlinkCliBinaries();
|
|
3955
|
-
sections.push(` CLI bins cleanup (${unlink.binDir}):`, unlink.removed
|
|
3956
|
-
.map((r) => ` ${r.name} (${r.existed ? "removed" : "not present"})`)
|
|
3957
|
-
.join("\n"));
|
|
3958
|
-
}
|
|
3959
|
-
ctx.ui.notify(sections.join("\n"), "info");
|
|
3960
|
-
}
|
|
3961
|
-
catch (err) {
|
|
3962
|
-
ctx.ui.notify(`[un-bien] uninstall failed: ${String(err)}`, "error");
|
|
3963
|
-
}
|
|
3964
|
-
}
|
|
3965
|
-
// ── Agent-network commands (plano 19) ─────────────────────────────────────────
|
|
3966
|
-
function _resolveExtensionDir() {
|
|
3967
|
-
// dist/index.js → dist; skills sit at <extensionRoot>/skills/. When we run
|
|
3968
|
-
// from src/ via tsx (dev), index.ts is in src/ and skills/ is sibling. We
|
|
3969
|
-
// detect by checking both locations.
|
|
3970
|
-
const here = fileURLToPath(import.meta.url);
|
|
3971
|
-
// dist/index.js or src/index.ts → parent = <dist or src>; sibling = ../skills
|
|
3972
|
-
const parent = here.replace(/\/[^/]+$/, "");
|
|
3973
|
-
const candidateA = join(parent, "..", "skills"); // dist → ../skills
|
|
3974
|
-
const candidateB = join(parent, "skills"); // src → skills
|
|
3975
|
-
if (existsSync(candidateA))
|
|
3976
|
-
return parent.replace(/\/dist$/, "");
|
|
3977
|
-
if (existsSync(candidateB))
|
|
3978
|
-
return parent;
|
|
3979
|
-
return parent;
|
|
3980
|
-
}
|
|
3981
|
-
function _deployAgentNetworkSkill() {
|
|
3982
|
-
// Pi SDK spec (core/skills.js): every skill must live at
|
|
3983
|
-
// <skillsRoot>/<skill-name>/SKILL.md
|
|
3984
|
-
// The skill `name:` frontmatter must equal the parent directory name. We
|
|
3985
|
-
// ship the source pre-arranged that way so deploy is a straight copy into
|
|
3986
|
-
// ~/.pi/un-bien/skills/agent-network/SKILL.md.
|
|
3987
|
-
const root = _resolveExtensionDir();
|
|
3988
|
-
const src1 = join(root, "skills", "agent-network", "SKILL.md");
|
|
3989
|
-
const src2 = join(root, "..", "skills", "agent-network", "SKILL.md");
|
|
3990
|
-
const src = existsSync(src1) ? src1 : existsSync(src2) ? src2 : null;
|
|
3991
|
-
if (!src)
|
|
3992
|
-
return;
|
|
3993
|
-
const dstDir = join(skillsDir(), "agent-network");
|
|
3994
|
-
const dst = join(dstDir, "SKILL.md");
|
|
3995
|
-
try {
|
|
3996
|
-
mkdirSync(dstDir, { recursive: true });
|
|
3997
|
-
copyFileSync(src, dst);
|
|
3998
|
-
// Cleanup legacy deploy at ~/.pi/un-bien/skills/agent-network.md (flat
|
|
3999
|
-
// layout, fails the Pi SDK's name-vs-parent-dir validation).
|
|
4000
|
-
const legacy = join(skillsDir(), "agent-network.md");
|
|
4001
|
-
if (existsSync(legacy)) {
|
|
4002
|
-
try {
|
|
4003
|
-
unlinkSync(legacy);
|
|
4004
|
-
}
|
|
4005
|
-
catch {
|
|
4006
|
-
/* ignored */
|
|
4007
|
-
}
|
|
4008
|
-
}
|
|
4009
|
-
}
|
|
4010
|
-
catch {
|
|
4011
|
-
/* best-effort */
|
|
4012
|
-
}
|
|
4013
|
-
}
|
|
4014
2321
|
function _wakeAgent(content, label, steeringBehavior) {
|
|
4015
2322
|
if (!_pi) {
|
|
4016
2323
|
const detail = "agent session not bound yet";
|
|
@@ -4018,9 +2325,17 @@ function _wakeAgent(content, label, steeringBehavior) {
|
|
|
4018
2325
|
return { ok: false, detail };
|
|
4019
2326
|
}
|
|
4020
2327
|
try {
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
2328
|
+
// expandPromptTemplates: true — TUI PARITY for app-sent text. pi's
|
|
2329
|
+
// sendUserMessage defaults it to FALSE, so a "/"-prefixed message typed
|
|
2330
|
+
// in the PHONE composer reached the MODEL as literal text (observed:
|
|
2331
|
+
// "ignoring the two /unbien pair statements prior in the chat") instead
|
|
2332
|
+
// of dispatching as a command like the same text typed in the TUI. pi's
|
|
2333
|
+
// prompt() dispatches extension commands FIRST — even mid-stream — so
|
|
2334
|
+
// steering a running turn with a command now behaves identically too.
|
|
2335
|
+
const options = {
|
|
2336
|
+
...(steeringBehavior ? { deliverAs: steeringBehavior } : {}),
|
|
2337
|
+
expandPromptTemplates: true,
|
|
2338
|
+
};
|
|
4024
2339
|
_pi.sendUserMessage(content, options);
|
|
4025
2340
|
return { ok: true };
|
|
4026
2341
|
}
|
|
@@ -4108,181 +2423,6 @@ function _deliverMeshMessageToAgent(env) {
|
|
|
4108
2423
|
_pendingMeshMessages.push(env);
|
|
4109
2424
|
_scheduleMeshMessageDrain();
|
|
4110
2425
|
}
|
|
4111
|
-
/** Test-only entry point for verifying mesh-to-agent delivery semantics. */
|
|
4112
|
-
export function _deliverMeshMessageToAgentForTest(env) {
|
|
4113
|
-
_deliverMeshMessageToAgent(env);
|
|
4114
|
-
}
|
|
4115
|
-
/**
|
|
4116
|
-
* Joins the fixed local UDS mesh ("local" session — see LOCAL_SESSION_NAME).
|
|
4117
|
-
* Called by `_cmdRoot` on first run and on subsequent runs when the relay
|
|
4118
|
-
* is up and the user hasn't explicitly stopped. The session name is no
|
|
4119
|
-
* longer user-configurable: every Pi on the same machine joins the same
|
|
4120
|
-
* broker.
|
|
4121
|
-
*/
|
|
4122
|
-
async function _cmdJoin(ctx) {
|
|
4123
|
-
const cwd = "cwd" in ctx ? ctx.cwd : process.cwd();
|
|
4124
|
-
const local = loadLocalConfig(cwd);
|
|
4125
|
-
const sessionName = LOCAL_SESSION_NAME;
|
|
4126
|
-
// What the user configured for this agent…
|
|
4127
|
-
const requestedName = local.agent_name || defaultAgentName(cwd);
|
|
4128
|
-
// …and what we actually register: the name the cwd-lock reserved, which is
|
|
4129
|
-
// `requestedName` or a `#N` variant when same-named agents share this folder.
|
|
4130
|
-
// Falls back to requestedName when join runs without a prior `_cmdRoot` lock
|
|
4131
|
-
// (e.g. legacy/test paths).
|
|
4132
|
-
const agentName = _lockedName ?? requestedName;
|
|
4133
|
-
if (_meshNode) {
|
|
4134
|
-
ctx.ui.notify("[un-bien] Already on the local mesh.", "warning");
|
|
4135
|
-
return;
|
|
4136
|
-
}
|
|
4137
|
-
const joinGeneration = ++_meshJoinGeneration;
|
|
4138
|
-
ensureGlobalDirs();
|
|
4139
|
-
mkdirSync(join(skillsDir(), "..", "sessions", sessionName), {
|
|
4140
|
-
recursive: true,
|
|
4141
|
-
});
|
|
4142
|
-
const sock = sessionSockPath(sessionName);
|
|
4143
|
-
const audit = sessionAuditPath(sessionName);
|
|
4144
|
-
// Forward the cwd so the broker keys this peer by (cwd, name): a same-folder
|
|
4145
|
-
// same-name reincarnation (switch_session re-eval, app restart) takes over the
|
|
4146
|
-
// name instead of registering behind a mute `name#N` ghost. Canonicalize via
|
|
4147
|
-
// realpath so symlinked cwds map to one identity (matches roomIdForCwd).
|
|
4148
|
-
let canonCwd = cwd;
|
|
4149
|
-
try {
|
|
4150
|
-
canonCwd = realpathSync(cwd);
|
|
4151
|
-
}
|
|
4152
|
-
catch {
|
|
4153
|
-
/* cwd missing — use raw path */
|
|
4154
|
-
}
|
|
4155
|
-
const peer = new MeshNode({
|
|
4156
|
-
sockPath: sock,
|
|
4157
|
-
name: agentName,
|
|
4158
|
-
cwd: canonCwd,
|
|
4159
|
-
auditPath: audit,
|
|
4160
|
-
takeoverExisting: process.env["UNBIEN_DAEMON"] === "1",
|
|
4161
|
-
});
|
|
4162
|
-
peer.onMessage((env) => {
|
|
4163
|
-
const body = env.body;
|
|
4164
|
-
// Broker system events: re-query broker for authoritative count.
|
|
4165
|
-
// Incremental ±1 drifts when peer_left is missed (leader leaves cleanly,
|
|
4166
|
-
// failover, etc.) — querying list_peers makes the count self-healing.
|
|
4167
|
-
if (body && (body.type === "peer_joined" || body.type === "peer_left")) {
|
|
4168
|
-
_refreshSessionPeerCount(peer, ctx);
|
|
4169
|
-
// Plan/25 Wave B: push fresh peer list to all siblings so their
|
|
4170
|
-
// remotePeers cache stays current without polling.
|
|
4171
|
-
void peer
|
|
4172
|
-
.request("broker", { type: "list_peers" }, 2000)
|
|
4173
|
-
.then((reply) => {
|
|
4174
|
-
const body = reply.body;
|
|
4175
|
-
// onLocalPeersChanged wants LOCAL-only addresses (list_peers returns
|
|
4176
|
-
// the aggregated local + cross-PC roster). Prefer the structured
|
|
4177
|
-
// roster (plan/38): a local peer has no `pc`. This is drive-letter
|
|
4178
|
-
// safe — a Windows local address `C:\…@app` contains ':' but is NOT
|
|
4179
|
-
// remote, so the old naive `!p.includes(":")` misclassified it.
|
|
4180
|
-
let local = null;
|
|
4181
|
-
const detailed = body?.peers_detailed;
|
|
4182
|
-
if (Array.isArray(detailed)) {
|
|
4183
|
-
local = detailed
|
|
4184
|
-
.filter((p) => !p.pc && typeof p.address === "string")
|
|
4185
|
-
.map((p) => p.address);
|
|
4186
|
-
}
|
|
4187
|
-
else if (Array.isArray(body?.peers)) {
|
|
4188
|
-
// Fallback for a legacy broker without `peers_detailed`.
|
|
4189
|
-
local = body.peers.filter((p) => !p.includes(":"));
|
|
4190
|
-
}
|
|
4191
|
-
// No-op when the bridge isn't up (follower / relay down).
|
|
4192
|
-
if (local)
|
|
4193
|
-
peer.onLocalPeersChanged(local);
|
|
4194
|
-
})
|
|
4195
|
-
.catch(() => {
|
|
4196
|
-
/* bridge not bound yet, or list_peers failed */
|
|
4197
|
-
});
|
|
4198
|
-
return;
|
|
4199
|
-
}
|
|
4200
|
-
if (env.from === "broker")
|
|
4201
|
-
return; // other broker control messages — ignore
|
|
4202
|
-
// Real agent-to-agent message (SessionPeer already correlated replies via
|
|
4203
|
-
// env.re before this point). Show it in the app's TOOL timeline and wake
|
|
4204
|
-
// the agent as a CUSTOM message — never as the user's own message.
|
|
4205
|
-
_deliverMeshMessageToAgent(env);
|
|
4206
|
-
});
|
|
4207
|
-
// After failover (leader died, we re-elected): the new broker's peers map
|
|
4208
|
-
// starts fresh, but our cached `_sessionPeerCount` is stale. Re-seed it so
|
|
4209
|
-
// surviving peers don't carry the pre-failover count forever.
|
|
4210
|
-
//
|
|
4211
|
-
// The cross-PC bridge re-attach on failover (drop the stale broker ref,
|
|
4212
|
-
// re-wire against the fresh `localBroker()` if we were promoted to leader)
|
|
4213
|
-
// is handled INSIDE MeshNode — no manual teardown/ensure needed here.
|
|
4214
|
-
peer.onReconnect(() => {
|
|
4215
|
-
_refreshSessionPeerCount(peer, ctx);
|
|
4216
|
-
});
|
|
4217
|
-
const isCurrentCandidate = () => !_disposed && joinGeneration === _meshJoinGeneration && _meshNode === null;
|
|
4218
|
-
try {
|
|
4219
|
-
const assigned = await peer.connect();
|
|
4220
|
-
// The candidate stays local until connect resolves. Shutdown, stop, or a
|
|
4221
|
-
// newer join invalidates its generation; close it instead of publishing a
|
|
4222
|
-
// ghost peer or allowing _cmdRoot to continue into Relay startup.
|
|
4223
|
-
if (!isCurrentCandidate()) {
|
|
4224
|
-
try {
|
|
4225
|
-
await peer.close();
|
|
4226
|
-
}
|
|
4227
|
-
catch {
|
|
4228
|
-
/* best-effort */
|
|
4229
|
-
}
|
|
4230
|
-
return;
|
|
4231
|
-
}
|
|
4232
|
-
_meshNode = peer;
|
|
4233
|
-
_sessionName = sessionName;
|
|
4234
|
-
_sessionPeerCount = 1; // optimistic — overwritten by list_peers below
|
|
4235
|
-
// Broker broadcasts `peer_joined` only to existing peers when a new one
|
|
4236
|
-
// arrives — the newcomer doesn't get retroactive joined events. Ask the
|
|
4237
|
-
// broker for the live peer list to seed the count correctly on join.
|
|
4238
|
-
_refreshSessionPeerCount(peer, ctx);
|
|
4239
|
-
// Tell RPC clients (e.g. Cockpit) the EFFECTIVE mesh name. The broker
|
|
4240
|
-
// appends a `#N` suffix only on a same-(cwd,name) collision, so the name we
|
|
4241
|
-
// requested and the one actually assigned can differ. Emit a pure-data event
|
|
4242
|
-
// (display:false) carrying both + a `changed` flag so the client can rename
|
|
4243
|
-
// the agent in its own UI to match what the mesh/relay will show. Fired on
|
|
4244
|
-
// every join (incl. failover re-elect, which can re-assign the name), so the
|
|
4245
|
-
// client always reflects the live name, not just the first one.
|
|
4246
|
-
//
|
|
4247
|
-
// plan/38 decision E: we deliberately DO NOT persist `assigned`. A `#N` is a
|
|
4248
|
-
// RUNTIME collision resolution; freezing it into `agent_name` fossilizes an
|
|
4249
|
-
// accident and causes cross-folder name ping-pong across restarts. The clean
|
|
4250
|
-
// name (wizard / explicit `agent_name`) already lives in config or re-derives
|
|
4251
|
-
// from `basename(cwd)`; the event above carries the live `#N` for the UI.
|
|
4252
|
-
_pi?.sendMessage({
|
|
4253
|
-
customType: "un-bien:name-assigned",
|
|
4254
|
-
content: assigned === requestedName
|
|
4255
|
-
? `Mesh name: ${assigned}`
|
|
4256
|
-
: `Mesh name reassigned: "${requestedName}" → "${assigned}" (collision)`,
|
|
4257
|
-
details: {
|
|
4258
|
-
requested: requestedName,
|
|
4259
|
-
assigned,
|
|
4260
|
-
changed: assigned !== requestedName,
|
|
4261
|
-
},
|
|
4262
|
-
display: false,
|
|
4263
|
-
});
|
|
4264
|
-
ctx.ui.notify(`[un-bien] Joined local mesh as "${assigned}" (${peer.currentRole()})`, "info");
|
|
4265
|
-
_refreshFooter(ctx);
|
|
4266
|
-
// Plan/25 Wave B/C: try to bring up cross-PC routing now that the
|
|
4267
|
-
// local broker exists. No-op if the relay isn't up yet (will fire
|
|
4268
|
-
// again from `_cmdStart`).
|
|
4269
|
-
_attachBridgeIfReady();
|
|
4270
|
-
}
|
|
4271
|
-
catch (err) {
|
|
4272
|
-
// A replacement/stop/newer join can invalidate this candidate before its
|
|
4273
|
-
// failure arrives. Clean it up and never notify the outgoing session ctx.
|
|
4274
|
-
if (!isCurrentCandidate()) {
|
|
4275
|
-
try {
|
|
4276
|
-
await peer.close();
|
|
4277
|
-
}
|
|
4278
|
-
catch {
|
|
4279
|
-
/* best-effort */
|
|
4280
|
-
}
|
|
4281
|
-
return;
|
|
4282
|
-
}
|
|
4283
|
-
ctx.ui.notify(`[un-bien] join failed: ${String(err)}`, "error");
|
|
4284
|
-
}
|
|
4285
|
-
}
|
|
4286
2426
|
// ── routeClientMessage ────────────────────────────────────────────────────────
|
|
4287
2427
|
/**
|
|
4288
2428
|
* Per-channel router. Replaces the W2D-pre `routeClientMessage` which
|
|
@@ -4296,7 +2436,7 @@ async function _cmdJoin(ctx) {
|
|
|
4296
2436
|
* handles incoming app→pi requests.
|
|
4297
2437
|
*/
|
|
4298
2438
|
function _abortCurrentTurn(fallbackCtx) {
|
|
4299
|
-
const candidates = [_lastEventCtx,
|
|
2439
|
+
const candidates = [_lastEventCtx, fallbackCtx];
|
|
4300
2440
|
for (const candidate of candidates) {
|
|
4301
2441
|
if (!candidate || candidate === _noopCtx)
|
|
4302
2442
|
continue;
|
|
@@ -4380,24 +2520,10 @@ export function routeClientMessage(msg, ctx) {
|
|
|
4380
2520
|
* also dump history to owner B's wire — duplicate traffic + the wrong
|
|
4381
2521
|
* `in_reply_to`.
|
|
4382
2522
|
*/
|
|
4383
|
-
/**
|
|
4384
|
-
* Resets the Pi-side session view after a SUCCESSFUL `session_new`. The app's
|
|
4385
|
-
* New Session clears its local store on `action_ok`, but that alone isn't
|
|
4386
|
-
* durable: `_messageBuffer` (which answers `session_sync`) is append-only and
|
|
4387
|
-
* `_sessionStartedAt` is stamped once, so a later reconnect/restart would
|
|
4388
|
-
* replay the OLD history. We clear the buffer and restamp the clock so the
|
|
4389
|
-
* envelope `session_sync` reconstructs from a clean slate. The app drops the
|
|
4390
|
-
* stale conversation off the new-session `hello` (changed `sessionId`).
|
|
4391
|
-
*/
|
|
4392
|
-
function _resetSessionForNew() {
|
|
4393
|
-
// Restamp the session clock so the app detects the pi restart (session_sync_end
|
|
4394
|
-
// carries it). The transcript resets naturally: the app re-fetches via
|
|
4395
|
-
// get_entries against the fresh session and drops the old one on the new hello.
|
|
4396
|
-
_sessionStartedAt = Date.now();
|
|
4397
|
-
}
|
|
4398
|
-
/** Resolve the base dir for tool-arg file lookups from the last command ctx. */
|
|
2523
|
+
/** Resolve the base dir for tool-arg file lookups from the current session. */
|
|
4399
2524
|
function _resolveToolCwd() {
|
|
4400
|
-
|
|
2525
|
+
const sm = _rootState().sessionManager;
|
|
2526
|
+
return sm && typeof sm.getCwd === "function" ? sm.getCwd() : process.cwd();
|
|
4401
2527
|
}
|
|
4402
2528
|
// ── Standalone CLI ────────────────────────────────────────────────────────────
|
|
4403
2529
|
function _isDirectRun() {
|
|
@@ -4599,150 +2725,4 @@ if (_isDirectRun()) {
|
|
|
4599
2725
|
].join("\n"));
|
|
4600
2726
|
}
|
|
4601
2727
|
}
|
|
4602
|
-
// ── `unbien claude` — launch Claude Code connected to the mesh ─────────────
|
|
4603
|
-
/**
|
|
4604
|
-
* Resolve the packaged agent-network skill path
|
|
4605
|
-
* (`<pkgRoot>/skills/agent-network/SKILL.md`). Single source of truth shared
|
|
4606
|
-
* by both runtimes: Pi discovers it via `resources_discover`, and the Claude
|
|
4607
|
-
* launcher injects it as a system prompt (see `_cmdClaudeCli`). Returns null
|
|
4608
|
-
* if the file is missing (e.g. running before `pnpm build`).
|
|
4609
|
-
*/
|
|
4610
|
-
function _agentNetworkSkillPath() {
|
|
4611
|
-
const here = fileURLToPath(import.meta.url); // dist/index.js (or src/index.ts via tsx)
|
|
4612
|
-
const pkgRoot = dirname(dirname(here)); // package root (dist → ..; src → ..)
|
|
4613
|
-
const skill = join(pkgRoot, "skills", "agent-network", "SKILL.md");
|
|
4614
|
-
return existsSync(skill) ? skill : null;
|
|
4615
|
-
}
|
|
4616
|
-
async function _cmdClaudeCli(args) {
|
|
4617
|
-
// Contract: `unbien claude [cwd] [claude-flags...]`. The optional cwd is
|
|
4618
|
-
// ONLY the leading positional (first token, not a flag); everything after it
|
|
4619
|
-
// is forwarded verbatim to the `claude` binary (e.g. `--resume`, `-c`,
|
|
4620
|
-
// `-p "prompt"`). Restricting cwd to the leading token avoids mistaking a
|
|
4621
|
-
// flag's value (e.g. the id in `--resume <id>`) for the cwd.
|
|
4622
|
-
const hasCwdArg = args.length > 0 && !args[0].startsWith("-");
|
|
4623
|
-
const targetCwd = hasCwdArg ? args[0] : process.cwd();
|
|
4624
|
-
const passthroughArgs = hasCwdArg ? args.slice(1) : args;
|
|
4625
|
-
// Wizard when no local config exists
|
|
4626
|
-
if (!localConfigExists(targetCwd)) {
|
|
4627
|
-
const suggested = defaultAgentName(targetCwd);
|
|
4628
|
-
process.stdout.write(`\n[un-bien] No config found for ${targetCwd}\n`);
|
|
4629
|
-
process.stdout.write("Let's set up this agent.\n\n");
|
|
4630
|
-
const rl = createInterface({
|
|
4631
|
-
input: process.stdin,
|
|
4632
|
-
output: process.stdout,
|
|
4633
|
-
});
|
|
4634
|
-
const agentName = await new Promise((res) => rl.question(`Agent name [${suggested}]: `, (ans) => {
|
|
4635
|
-
rl.close();
|
|
4636
|
-
res(ans.trim() || suggested);
|
|
4637
|
-
}));
|
|
4638
|
-
saveLocalConfig(targetCwd, {
|
|
4639
|
-
agent_name: agentName,
|
|
4640
|
-
auto_start_relay: true,
|
|
4641
|
-
});
|
|
4642
|
-
process.stdout.write(`[un-bien] Config saved: agent="${agentName}"\n\n`);
|
|
4643
|
-
}
|
|
4644
|
-
// Resolve mesh server script path (dist/mcp/mesh_server.js)
|
|
4645
|
-
const here = fileURLToPath(import.meta.url);
|
|
4646
|
-
const distRoot = dirname(here);
|
|
4647
|
-
const meshServerPath = resolve(distRoot, "mcp/mesh_server.js");
|
|
4648
|
-
if (!existsSync(meshServerPath)) {
|
|
4649
|
-
console.log(`[un-bien] mesh server not found at ${meshServerPath}. Run pnpm build first.`);
|
|
4650
|
-
process.exit(1);
|
|
4651
|
-
}
|
|
4652
|
-
const absCwd = resolve(targetCwd);
|
|
4653
|
-
const SERVER_NAME = "un-bien-mesh";
|
|
4654
|
-
// The mesh MCP must be visible ONLY inside a `unbien claude` session — a
|
|
4655
|
-
// plain `claude` in the same repo must NOT inherit it (otherwise every
|
|
4656
|
-
// ordinary session silently joins the mesh as a stray agent).
|
|
4657
|
-
//
|
|
4658
|
-
// Older builds registered the server with `claude mcp add -s local`. That
|
|
4659
|
-
// scope lives in `~/.claude.json` keyed by the **git repo root** and is
|
|
4660
|
-
// inherited by EVERY claude session under that root — which is exactly the
|
|
4661
|
-
// leak we're closing. So we no longer write any persistent scope; we load
|
|
4662
|
-
// the server through an ephemeral `--mcp-config <tmpfile>` passed on the
|
|
4663
|
-
// launch command line (see below). That config is session-only: it is never
|
|
4664
|
-
// recorded in any scope `claude mcp list` enumerates, so a normal `claude`
|
|
4665
|
-
// sees nothing.
|
|
4666
|
-
//
|
|
4667
|
-
// Migration: best-effort scrub of the stale `-s local` entry that prior
|
|
4668
|
-
// versions left behind (and that is the source of the inherited-mesh bug).
|
|
4669
|
-
// Idempotent — a no-op (non-zero, ignored) when the entry is already gone.
|
|
4670
|
-
spawnSync("claude", ["mcp", "remove", SERVER_NAME, "-s", "local"], {
|
|
4671
|
-
cwd: absCwd,
|
|
4672
|
-
stdio: "ignore",
|
|
4673
|
-
shell: false,
|
|
4674
|
-
});
|
|
4675
|
-
// Ephemeral MCP config consumed by `--mcp-config` below. We do NOT bake a
|
|
4676
|
-
// `cwd` into it: the server resolves its folder from its own `process.cwd()`,
|
|
4677
|
-
// which Claude sets to the directory the session was launched in (verified
|
|
4678
|
-
// empirically — NOT the git root, NOT CLAUDE_PROJECT_DIR). We spawn claude
|
|
4679
|
-
// with `cwd: absCwd`, the MCP child inherits it, so the server self-identifies
|
|
4680
|
-
// as the right agent without leaking that path to any other session.
|
|
4681
|
-
// Unique per pid so concurrent `unbien claude` launches don't collide.
|
|
4682
|
-
const mcpConfigPath = join(tmpdir(), `un-bien-mesh-mcp-${process.pid}.json`);
|
|
4683
|
-
writeFileSync(mcpConfigPath, JSON.stringify({
|
|
4684
|
-
mcpServers: {
|
|
4685
|
-
[SERVER_NAME]: { command: process.execPath, args: [meshServerPath] },
|
|
4686
|
-
},
|
|
4687
|
-
}));
|
|
4688
|
-
// Inject the agent-network protocol as a system prompt instead of deploying a
|
|
4689
|
-
// skill file into ~/.claude. Anyone running `unbien claude` is here to use
|
|
4690
|
-
// the mesh, so load the protocol unconditionally — no lazy skill gating, no
|
|
4691
|
-
// global skills-dir pollution, and the packaged file is the single source of
|
|
4692
|
-
// truth shared with the Pi runtime. Skipped only if the file is missing.
|
|
4693
|
-
const skillPath = _agentNetworkSkillPath();
|
|
4694
|
-
// Launch flags:
|
|
4695
|
-
// --mcp-config <tmpfile> — load the mesh server for
|
|
4696
|
-
// THIS session only (never a persistent scope). We intentionally omit
|
|
4697
|
-
// `--strict-mcp-config` so the user's own persistent MCP servers stay
|
|
4698
|
-
// available alongside the mesh.
|
|
4699
|
-
// --dangerously-load-development-channels TAG — enable claude/channel push
|
|
4700
|
-
// for our local (non-allowlisted) server, so incoming mesh messages
|
|
4701
|
-
// wake Claude instead of waiting for a get_messages poll. Entries must
|
|
4702
|
-
// be tagged: `server:<name>` for a manually configured MCP server
|
|
4703
|
-
// (`plugin:<name>@<marketplace>` is the plugin form). Shows a one-time
|
|
4704
|
-
// confirmation dialog at startup. Works against the `--mcp-config`
|
|
4705
|
-
// server in current Claude Code; if a build ever fails to match it, the
|
|
4706
|
-
// per-turn `get_messages` poll (mandated by the mesh protocol) still
|
|
4707
|
-
// delivers — we lose the wake, not the messages.
|
|
4708
|
-
// --dangerously-skip-permissions — auto-approve tool calls
|
|
4709
|
-
// --append-system-prompt-file=<skill> — load the mesh protocol
|
|
4710
|
-
// `--append-system-prompt-file` uses the glued `--flag=value` form (a SINGLE
|
|
4711
|
-
// argv token) on purpose: tools that restore a session by capturing and
|
|
4712
|
-
// replaying the live process's argv (e.g. cmux) drop the TRAILING token,
|
|
4713
|
-
// which here was the skill path — leaving a dangling `--append-system-prompt-file`
|
|
4714
|
-
// → `claude` aborts with "argument missing" and the session never comes back.
|
|
4715
|
-
// As one token, the worst case is the whole flag being dropped: claude still
|
|
4716
|
-
// starts (just without the injected protocol), which is recoverable instead
|
|
4717
|
-
// of fatal. (The other flags stay separate pairs — never last, so unaffected,
|
|
4718
|
-
// and we don't risk a parser that may not accept `=`.)
|
|
4719
|
-
// Any extra args the user passed (e.g. `--resume`, `-c`) are appended last so
|
|
4720
|
-
// they reach the claude binary; ours come first as sensible defaults.
|
|
4721
|
-
try {
|
|
4722
|
-
spawnSync("claude", [
|
|
4723
|
-
"--mcp-config",
|
|
4724
|
-
mcpConfigPath,
|
|
4725
|
-
"--dangerously-load-development-channels",
|
|
4726
|
-
`server:${SERVER_NAME}`,
|
|
4727
|
-
"--dangerously-skip-permissions",
|
|
4728
|
-
...(skillPath ? [`--append-system-prompt-file=${skillPath}`] : []),
|
|
4729
|
-
...passthroughArgs,
|
|
4730
|
-
], {
|
|
4731
|
-
cwd: absCwd,
|
|
4732
|
-
stdio: "inherit",
|
|
4733
|
-
shell: false,
|
|
4734
|
-
});
|
|
4735
|
-
}
|
|
4736
|
-
finally {
|
|
4737
|
-
// Session over — drop the ephemeral config so it never lingers as a stray
|
|
4738
|
-
// file. spawnSync blocks until claude exits, so claude has long since read
|
|
4739
|
-
// it. Best-effort: ignore if already gone.
|
|
4740
|
-
try {
|
|
4741
|
-
unlinkSync(mcpConfigPath);
|
|
4742
|
-
}
|
|
4743
|
-
catch {
|
|
4744
|
-
/* already removed */
|
|
4745
|
-
}
|
|
4746
|
-
}
|
|
4747
|
-
}
|
|
4748
2728
|
//# sourceMappingURL=index.js.map
|