@geohar/un-bien 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +237 -268
  2. package/dist/bin/launcher.js +38 -0
  3. package/dist/bin/launcher.js.map +1 -0
  4. package/dist/config.d.ts +41 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/daemon/install.d.ts +33 -35
  7. package/dist/daemon/install.js +43 -73
  8. package/dist/daemon/install.js.map +1 -1
  9. package/dist/enrich_tool_args.d.ts +9 -0
  10. package/dist/enrich_tool_args.js +171 -0
  11. package/dist/enrich_tool_args.js.map +1 -0
  12. package/dist/image_codec.d.ts +22 -0
  13. package/dist/image_codec.js +149 -0
  14. package/dist/image_codec.js.map +1 -0
  15. package/dist/index.d.ts +0 -26
  16. package/dist/index.js +235 -1237
  17. package/dist/index.js.map +1 -1
  18. package/dist/launch.d.ts +34 -0
  19. package/dist/launch.js +171 -0
  20. package/dist/launch.js.map +1 -0
  21. package/dist/launcher/launcher.d.ts +6 -0
  22. package/dist/launcher/launcher.js +188 -0
  23. package/dist/launcher/launcher.js.map +1 -0
  24. package/dist/pairing/crypto.d.ts +4 -0
  25. package/dist/pairing/crypto.js +10 -0
  26. package/dist/pairing/crypto.js.map +1 -1
  27. package/dist/pairing/peer_trust.d.ts +20 -0
  28. package/dist/pairing/peer_trust.js +82 -0
  29. package/dist/pairing/peer_trust.js.map +1 -0
  30. package/dist/pairing/storage.d.ts +51 -26
  31. package/dist/pairing/storage.js +346 -136
  32. package/dist/pairing/storage.js.map +1 -1
  33. package/dist/panel_bridge.d.ts +3 -1
  34. package/dist/panel_bridge.js +10 -10
  35. package/dist/panel_bridge.js.map +1 -1
  36. package/dist/rooms.d.ts +16 -0
  37. package/dist/rooms.js +22 -0
  38. package/dist/rooms.js.map +1 -1
  39. package/dist/session/debug_log.js +2 -2
  40. package/dist/session/debug_log.js.map +1 -1
  41. package/dist/session/local_config.js +2 -0
  42. package/dist/session/local_config.js.map +1 -1
  43. package/dist/session/rpc_envelope.d.ts +18 -0
  44. package/dist/session/rpc_envelope.js +1 -1
  45. package/dist/session/rpc_envelope.js.map +1 -1
  46. package/dist/session/rpc_inbound.d.ts +2 -2
  47. package/dist/session/rpc_inbound.js +1 -1
  48. package/dist/session/rpc_inbound.js.map +1 -1
  49. package/dist/subagent_rooms.d.ts +49 -0
  50. package/dist/subagent_rooms.js +787 -0
  51. package/dist/subagent_rooms.js.map +1 -0
  52. package/dist/transport/peer_channel.d.ts +7 -1
  53. package/dist/transport/peer_channel.js +8 -1
  54. package/dist/transport/peer_channel.js.map +1 -1
  55. package/dist/transport/relay_client.d.ts +19 -0
  56. package/dist/transport/relay_client.js.map +1 -1
  57. package/docs/daemon.md +65 -64
  58. package/package.json +13 -11
  59. package/service-templates/launchd.plist.template +5 -5
  60. package/service-templates/systemd.service.template +2 -2
  61. package/service-templates/task-launcher.vbs.template +5 -5
  62. package/service-templates/task-scheduler.xml.template +1 -1
  63. package/dist/bin/supervisord.js +0 -86
  64. package/dist/bin/supervisord.js.map +0 -1
  65. package/dist/daemon/client.d.ts +0 -20
  66. package/dist/daemon/client.js +0 -131
  67. package/dist/daemon/client.js.map +0 -1
  68. package/dist/daemon/control_protocol.d.ts +0 -176
  69. package/dist/daemon/control_protocol.js +0 -63
  70. package/dist/daemon/control_protocol.js.map +0 -1
  71. package/dist/daemon/cron_log.d.ts +0 -45
  72. package/dist/daemon/cron_log.js +0 -71
  73. package/dist/daemon/cron_log.js.map +0 -1
  74. package/dist/daemon/cron_registry.d.ts +0 -80
  75. package/dist/daemon/cron_registry.js +0 -198
  76. package/dist/daemon/cron_registry.js.map +0 -1
  77. package/dist/daemon/id.d.ts +0 -24
  78. package/dist/daemon/id.js +0 -36
  79. package/dist/daemon/id.js.map +0 -1
  80. package/dist/daemon/registry.d.ts +0 -63
  81. package/dist/daemon/registry.js +0 -153
  82. package/dist/daemon/registry.js.map +0 -1
  83. package/dist/daemon/rpc_child.d.ts +0 -194
  84. package/dist/daemon/rpc_child.js +0 -419
  85. package/dist/daemon/rpc_child.js.map +0 -1
  86. package/dist/daemon/supervisor.d.ts +0 -93
  87. package/dist/daemon/supervisor.js +0 -666
  88. package/dist/daemon/supervisor.js.map +0 -1
  89. /package/dist/bin/{supervisord.d.ts → launcher.d.ts} +0 -0
package/dist/index.js CHANGED
@@ -29,17 +29,17 @@
29
29
  * AgentBridge (src/session/agent_bridge.ts) remains the tested, mockable unit
30
30
  * for integration tests.
31
31
  */
32
- import { createHash, randomUUID } from "node:crypto";
33
- import { SettingsManager, convertToPng } from "@earendil-works/pi-coding-agent";
32
+ import { randomUUID } from "node:crypto";
33
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
34
34
  import { buildQRUri, qrSession, renderQRAscii, clampPairTtlMs, TOKEN_TTL_MS, } from "./pairing/qr.js";
35
- import { addPeer, getOrCreateEd25519Keypair, KeyringUnavailableError, PairedIdentityMissingError, listPeers, removePeer, snapshotOwnerPubkeys, conditionalRemovePeer, } from "./pairing/storage.js";
35
+ import { addPeer, getOrCreateEd25519Keypair, describeIdentity, KeyringUnavailableError, PairedIdentityMissingError, listPeers, removePeer, snapshotOwnerPubkeys, conditionalRemovePeer, } from "./pairing/storage.js";
36
36
  import { MeshClient } from "./mesh/client.js";
37
- import { canonicalizeEd25519PublicKey, decodeEd25519PublicKey, publicKeyFingerprint, } from "./mesh/encoding.js";
38
37
  import { SelfRevoke } from "./mesh/self_revoke.js";
39
38
  import { RelayClient, RoomAlreadyOpenError } from "./transport/relay_client.js";
40
39
  import { PlainPeerChannel } from "./transport/peer_channel.js";
41
40
  import { createExtensionUiBridge, } from "./extension_ui_bridge.js";
42
41
  import { createPanelBridge } from "./panel_bridge.js";
42
+ import { initSubagentRooms, subagentRoomsEnabled, } from "./subagent_rooms.js";
43
43
  import { createRpcEnvelope, isEnvelopeFrame, helloEnvelope, } from "./session/rpc_envelope.js";
44
44
  import { dispatchRpcCommand, } from "./session/rpc_inbound.js";
45
45
  import { envLog } from "./session/debug_log.js";
@@ -51,20 +51,21 @@ import { wireFromModel, } from "./actions/handlers.js";
51
51
  import { ensureModelRegistry } from "./actions/registry.js";
52
52
  import { ensureGlobalDirs, LOCAL_SESSION_NAME, sessionAuditPath, sessionSockPath, skillsDir, } from "./session/global_config.js";
53
53
  import { acquireCwdLock } from "./session/cwd_lock.js";
54
- import { addDaemon, listDaemons, removeDaemon } from "./daemon/registry.js";
55
- import { EXIT_DAEMON_FRESH_SESSION } from "./daemon/rpc_child.js";
56
- import { callSupervisor, supervisorOnline, SupervisorOfflineError, } from "./daemon/client.js";
57
- import { installService, uninstallService, linkCliBinaries, unlinkCliBinaries, LAUNCHD_LABEL, SYSTEMD_UNIT, WINDOWS_TASK_NAME, } from "./daemon/install.js";
54
+ import { installService, uninstallService, linkCliBinaries, unlinkCliBinaries, } from "./daemon/install.js";
58
55
  import { defaultAgentName, effectiveAutoStartRelay, effectiveAllowRemoteLaunch, loadLocalConfig, localConfigExists, saveLocalConfig, } from "./session/local_config.js";
59
56
  import { runSetupWizard } from "./session/setup_wizard.js";
60
57
  import { updateFooter } from "./ui/footer.js";
61
- import { join, dirname, resolve, basename } from "node:path";
58
+ import { join, dirname, resolve } from "node:path";
62
59
  import { fileURLToPath } from "node:url";
63
- import { chmodSync, mkdtempSync, mkdirSync, copyFileSync, existsSync, statSync, unlinkSync, readFileSync, writeFileSync, realpathSync, } from "node:fs";
60
+ import { chmodSync, mkdirSync, copyFileSync, existsSync, unlinkSync, readFileSync, writeFileSync, realpathSync, } from "node:fs";
64
61
  import { createInterface } from "node:readline";
65
- import { spawn, spawnSync } from "node:child_process";
62
+ import { spawnSync } from "node:child_process";
66
63
  import { hostname, tmpdir } from "node:os";
67
- import { resolveRelayUrl, saveConfig, isValidRelayUrl, isWebSocketScheme, toWebSocketUrl, } from "./config.js";
64
+ import { resolveRelayUrl, loadConfig, saveConfig, isValidRelayUrl, isWebSocketScheme, toWebSocketUrl, } from "./config.js";
65
+ import { _expandTilde, _launchSession } from "./launch.js";
66
+ import { _enrichToolArgs } from "./enrich_tool_args.js";
67
+ import { IMAGE_PREVIEW_MIME, _imageCacheRootDir, _imageExtension, _safeFilenameToken, _safePreviewPath, _renderablePngPathFromImage, _decodeImagePayload, } from "./image_codec.js";
68
+ import { _findKnownPeer, _inspectPeerRecord, _runtimeOwnerFingerprint, } from "./pairing/peer_trust.js";
68
69
  import { Box, Container, Image, Text } from "@earendil-works/pi-tui";
69
70
  let _state = "idle";
70
71
  let _relay = null;
@@ -93,36 +94,7 @@ let _relayUrl = null; // URL used by current _relay connection
93
94
  const _activePeers = new Map();
94
95
  let _peerShort = ""; // shortid of the most recently attached peer (UX hint only)
95
96
  const UNBIEN_RECEIVED_IMAGE_TYPE = "un-bien:received-image";
96
- const RECEIVED_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
97
- const IMAGE_PREVIEW_MIME = "image/png";
98
- const IMAGE_CACHE_PREFIX = "pi-app-";
99
- let _imageCacheDir;
100
97
  const _pendingReceivedImagePreviews = [];
101
- function _isBase64Char(code) {
102
- return ((code >= 48 && code <= 57) || // 0-9
103
- (code >= 65 && code <= 90) || // A-Z
104
- (code >= 97 && code <= 122) || // a-z
105
- code === 43 || // +
106
- code === 47); // /
107
- }
108
- function _isStrictBase64(data) {
109
- if (data.length === 0 || data.length % 4 !== 0)
110
- return false;
111
- if (data.startsWith("="))
112
- return false;
113
- const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
114
- for (let i = 0; i < data.length; i += 1) {
115
- const code = data.charCodeAt(i);
116
- if (i >= data.length - padding) {
117
- if (code !== 61)
118
- return false;
119
- continue;
120
- }
121
- if (!_isBase64Char(code))
122
- return false;
123
- }
124
- return true;
125
- }
126
98
  let _myRoomId = null; // this Pi's room id (derived from the session id)
127
99
  /** THE App<->Pi room id for the current chat session. Prefers the STABLE pi
128
100
  * session id (durable across resume) so the room can't drift when the assigned
@@ -239,115 +211,6 @@ function _publishWorking(working) {
239
211
  });
240
212
  }
241
213
  }
242
- function _imageCacheRootDir() {
243
- if (_imageCacheDir) {
244
- try {
245
- mkdirSync(_imageCacheDir, { recursive: true, mode: 0o700 });
246
- }
247
- catch { }
248
- try {
249
- chmodSync(_imageCacheDir, 0o700);
250
- }
251
- catch {
252
- /* best-effort permission hardening */
253
- }
254
- return _imageCacheDir;
255
- }
256
- const dir = mkdtempSync(join(tmpdir(), IMAGE_CACHE_PREFIX));
257
- try {
258
- chmodSync(dir, 0o700);
259
- }
260
- catch {
261
- /* best-effort permission hardening */
262
- }
263
- _imageCacheDir = dir;
264
- return dir;
265
- }
266
- function _imageExtension(mime) {
267
- if (mime === "image/jpeg")
268
- return "jpg";
269
- if (mime === "image/png")
270
- return "png";
271
- if (mime === "image/webp")
272
- return "webp";
273
- if (mime === "image/gif")
274
- return "gif";
275
- return undefined;
276
- }
277
- function _safeFilenameToken(value) {
278
- return (value
279
- .trim()
280
- .replace(/[^A-Za-z0-9._-]+/g, "-")
281
- .replace(/-+/g, "-")
282
- .replace(/^-+|-+$/g, "") || "message");
283
- }
284
- function _safePreviewPath(dir, messageId, index) {
285
- return join(dir, `${_safeFilenameToken(messageId)}-${index}.preview.png`);
286
- }
287
- function _cleanupPreviewFile(previewPath) {
288
- try {
289
- if (existsSync(previewPath))
290
- unlinkSync(previewPath);
291
- }
292
- catch {
293
- // best effort
294
- }
295
- }
296
- async function _renderablePngPathFromImage(imageData, mime, previewPath) {
297
- if (mime === IMAGE_PREVIEW_MIME)
298
- return undefined;
299
- try {
300
- const converted = await convertToPng(imageData, mime);
301
- if (!converted ||
302
- converted.mimeType !== IMAGE_PREVIEW_MIME ||
303
- !converted.data) {
304
- return undefined;
305
- }
306
- const previewBytes = Buffer.from(converted.data, "base64");
307
- if (previewBytes.length === 0 ||
308
- previewBytes.length > RECEIVED_IMAGE_MAX_BYTES) {
309
- return undefined;
310
- }
311
- try {
312
- writeFileSync(previewPath, previewBytes, { mode: 0o600 });
313
- try {
314
- chmodSync(previewPath, 0o600);
315
- }
316
- catch {
317
- /* best-effort permission hardening */
318
- }
319
- return previewPath;
320
- }
321
- catch {
322
- _cleanupPreviewFile(previewPath);
323
- }
324
- }
325
- catch {
326
- _cleanupPreviewFile(previewPath);
327
- }
328
- return undefined;
329
- }
330
- function _decodeImagePayload(data, mime) {
331
- if (!_imageExtension(mime))
332
- return { ok: false, reason: `unsupported mime: ${mime}` };
333
- if (data.startsWith("data:"))
334
- return { ok: false, reason: "data URI payloads are not supported" };
335
- if (!_isStrictBase64(data))
336
- return { ok: false, reason: "invalid base64 payload" };
337
- const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
338
- const estimate = (data.length / 4) * 3 - padding;
339
- if (estimate > RECEIVED_IMAGE_MAX_BYTES) {
340
- return { ok: false, reason: `image too large (${estimate} bytes)` };
341
- }
342
- const decoded = Buffer.from(data, "base64");
343
- if (decoded.length === 0 || decoded.length > RECEIVED_IMAGE_MAX_BYTES) {
344
- return {
345
- ok: false,
346
- reason: `invalid decoded image size (${decoded.length} bytes)`,
347
- };
348
- }
349
- return { ok: true, decoded, size: decoded.length };
350
- }
351
214
  function _sendReceivedImagePreviewNow(details) {
352
215
  if (!_pi)
353
216
  return;
@@ -675,9 +538,12 @@ function _ctxUi(preferred) {
675
538
  }
676
539
  }
677
540
  /** Best-effort TUI notify; never throws (relay reconnect must not crash pi). */
678
- function _safeNotify(message, level = "info") {
541
+ function _safeNotify(message, level = "info", preferred) {
679
542
  try {
680
- const ui = _ctxUi();
543
+ // Prefer the caller's fresh ctx (e.g. a command ctx) over the module's
544
+ // last-event ctx — a session_start ctx can be ui-less/stale and would
545
+ // otherwise shadow it, silently dropping the notify.
546
+ const ui = _ctxUi(preferred);
681
547
  if (ui && typeof ui.notify === "function")
682
548
  ui.notify(message, level);
683
549
  }
@@ -772,6 +638,8 @@ export function _resetBridgeOwnersForTest() {
772
638
  _panelBridge = null;
773
639
  _rpcEnvelope?.dispose();
774
640
  _rpcEnvelope = null;
641
+ _subagentRooms?.dispose();
642
+ _subagentRooms = null;
775
643
  _extensionUiBridge?.dispose();
776
644
  _extensionUiBridge = null;
777
645
  }
@@ -894,6 +762,7 @@ function _stateFor(sid) {
894
762
  agentRun: { active: false, generation: 0 },
895
763
  sessionManager: null,
896
764
  model: null,
765
+ thinking: null,
897
766
  };
898
767
  _sessions.set(sid, st);
899
768
  }
@@ -925,6 +794,11 @@ let _panelBridge = null;
925
794
  // (always on, advertised as the `rpc_envelope` capability); runs alongside the
926
795
  // stock ServerMessage path only until M4 parity retirement.
927
796
  let _rpcEnvelope = null;
797
+ // Per-child subagent relay rooms — each subagent surfaced to the app as its own
798
+ // session, opt-in via the `subagents.rooms` un-bien setting (a no-op controller
799
+ // otherwise). Owned by the ROOT session; a child session_start calls
800
+ // onChildSession on this same instance.
801
+ let _subagentRooms = null;
928
802
  let _stopAutoListener = null;
929
803
  // Cached keypair (loaded once, reused across start/pair cycles)
930
804
  let _cachedEd25519 = null;
@@ -1411,9 +1285,11 @@ function _routeRpcCommandFrom(sender, env) {
1411
1285
  if (!frame || typeof frame !== "object")
1412
1286
  return; // no {evt} inbound today
1413
1287
  envLog(`rpc inbound: ${String(frame.type)}`);
1414
- // extension_ui_response is a reply to a fork-issued dialog, not a command —
1288
+ // extension_ui_response is a reply to an extension-issued dialog, not a command —
1415
1289
  // route it straight to the ui bridge (same target as the stock path).
1416
1290
  if (frame.type === "extension_ui_response") {
1291
+ // SAFETY: the type-discriminator check directly above proves this frame is
1292
+ // an extension_ui_response envelope, which is the ExtensionUiResponseWire shape.
1417
1293
  _extensionUiBridge?.respond(frame);
1418
1294
  return;
1419
1295
  }
@@ -1431,15 +1307,14 @@ function _routeRpcCommandFrom(sender, env) {
1431
1307
  // - seed `_rootState().turnId` for a fresh (non-steer) turn so the agent's
1432
1308
  // reply chunks/done have a target; restore it if the handoff fails.
1433
1309
  // The APP owns the steer-vs-followUp semantic switch (design 01M14T6J5W):
1434
- // pass its chosen streamingBehavior straight through to pi. The fork does
1310
+ // pass its chosen streamingBehavior straight through to pi. The extension does
1435
1311
  // NOT force steer over a followUp anymore. `shouldSteer` below is now only
1436
1312
  // BOOKKEEPING (turn-seeding + image-preview defer), not the delivery verb.
1437
1313
  const requestedSteer = opts.streamingBehavior === "steer";
1438
1314
  // Authoritative busy signal from pi's OWN state (AgentSession.isStreaming),
1439
1315
  // correct across subagent lifecycles (turnId/working stick busy after a
1440
1316
  // subagent run).
1441
- const streaming = _pi?.isStreaming ===
1442
- true;
1317
+ const streaming = _pi?.isStreaming === true;
1443
1318
  const shouldSteer = requestedSteer || streaming;
1444
1319
  const msg = {
1445
1320
  type: "user_message",
@@ -1499,6 +1374,9 @@ function _routeRpcCommandFrom(sender, env) {
1499
1374
  // Route via the minimal ActionPi view (matches handleModelSet): the
1500
1375
  // registry's `find` returns the minimal SdkModelLike, structurally fine
1501
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.
1502
1380
  const ok = await _pi.setModel(model);
1503
1381
  if (!ok)
1504
1382
  throw new Error("no auth configured for this model");
@@ -1527,33 +1405,10 @@ function _routeRpcCommandFrom(sender, env) {
1527
1405
  },
1528
1406
  newSession: async () => {
1529
1407
  const actionCtx = (_lastEventCtx ?? _lastCtx);
1530
- const daemonMode = process.env["UNBIEN_DAEMON"] === "1";
1531
- // Daemon fresh-restart (ported from the retired stock session_new): with no
1532
- // command ctx — or when the drive below fails on a STALE ctx — the supervisor
1533
- // relaunches with a genuinely fresh session via process.exit. Ack first (the
1534
- // response goes out before the 100ms-deferred exit); interactive mode has no
1535
- // supervisor so it still surfaces the error.
1536
- const restartFresh = () => {
1537
- _resetSessionForNew();
1538
- setTimeout(() => process.exit(EXIT_DAEMON_FRESH_SESSION), 100);
1539
- };
1540
1408
  if (!actionCtx?.newSession) {
1541
- if (daemonMode) {
1542
- restartFresh();
1543
- return { cancelled: false };
1544
- }
1545
1409
  throw new Error("new_session unavailable (no command ctx)");
1546
1410
  }
1547
- try {
1548
- await actionCtx.newSession({ withSession: async () => { } });
1549
- }
1550
- catch (err) {
1551
- if (daemonMode) {
1552
- restartFresh();
1553
- return { cancelled: false };
1554
- }
1555
- throw err;
1556
- }
1411
+ await actionCtx.newSession({ withSession: async () => { } });
1557
1412
  // Restamp the session clock (parity with the retired stock session_new):
1558
1413
  // session_sync_end carries it so the app can detect the pi restart.
1559
1414
  _resetSessionForNew();
@@ -1562,13 +1417,15 @@ function _routeRpcCommandFrom(sender, env) {
1562
1417
  clearQueue: async () => {
1563
1418
  if (!_pi)
1564
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.
1565
1422
  return _pi.clearQueue();
1566
1423
  },
1567
1424
  getEntries: async (since) => {
1568
1425
  // Native pi get_entries: the app reconstructs the transcript itself from
1569
1426
  // the raw entry log (each message entry carries an AgentMessage). `since`
1570
1427
  // slices to entries AFTER that id (delta cursor); leafId is the cursor to
1571
- // resume from next time. The fork does NOT replay these — see the app's
1428
+ // resume from next time. The extension does NOT replay these — see the app's
1572
1429
  // SessionState.applyEntries (design 01M15FMQ).
1573
1430
  const sm = _rootState().sessionManager;
1574
1431
  if (!sm)
@@ -1636,12 +1493,15 @@ function _routeUnBienPlaneFrom(sender, env) {
1636
1493
  }
1637
1494
  if (type === "session_launch") {
1638
1495
  const f = frame;
1639
- const cwd = typeof f.cwd === "string" && f.cwd.length > 0 ? f.cwd : process.cwd();
1496
+ const cwd = _expandTilde(typeof f.cwd === "string" && f.cwd.length > 0 ? f.cwd : process.cwd());
1640
1497
  if (!effectiveAllowRemoteLaunch(loadLocalConfig(cwd))) {
1641
1498
  envLog("session_launch(ub): remote launch disabled on this machine");
1642
1499
  return;
1643
1500
  }
1644
- const launchError = _launchSession(f.mode === "rpc" ? "rpc" : "tmux", cwd, typeof f.name === "string" ? f.name : undefined);
1501
+ // Backend is a MACHINE config choice (pick-one via launch.backend), not
1502
+ // app-chosen; rpc is a fast-follow so only tmux|herdr resolve here.
1503
+ const backend = loadConfig().launch?.backend === "herdr" ? "herdr" : "tmux";
1504
+ const launchError = _launchSession(backend, cwd, typeof f.name === "string" ? f.name : undefined);
1645
1505
  if (launchError)
1646
1506
  envLog(`session_launch(ub) error: ${launchError}`);
1647
1507
  return;
@@ -1735,64 +1595,6 @@ function _displayName(cwd) {
1735
1595
  const local = loadLocalConfig(cwd);
1736
1596
  return local.agent_name || defaultAgentName(cwd);
1737
1597
  }
1738
- function _rawOwnerFingerprint(rawValue) {
1739
- let fingerprintInput;
1740
- if (typeof rawValue === "string") {
1741
- fingerprintInput = rawValue;
1742
- }
1743
- else {
1744
- try {
1745
- const serialized = JSON.stringify(rawValue);
1746
- const type = rawValue === null ? "null" : typeof rawValue;
1747
- fingerprintInput = `${type}:${serialized ?? ""}`;
1748
- }
1749
- catch {
1750
- fingerprintInput = `${typeof rawValue}:unserializable`;
1751
- }
1752
- }
1753
- return createHash("sha256")
1754
- .update(fingerprintInput, "utf8")
1755
- .digest("hex")
1756
- .slice(0, 8);
1757
- }
1758
- function _runtimeOwnerFingerprint(runtimeKey) {
1759
- try {
1760
- return publicKeyFingerprint(decodeEd25519PublicKey(runtimeKey, "Owner runtime key"));
1761
- }
1762
- catch {
1763
- // Relay authentication guarantees canonical keys in production. This
1764
- // fallback keeps diagnostics metadata-only at defensive/test boundaries.
1765
- return _rawOwnerFingerprint(runtimeKey);
1766
- }
1767
- }
1768
- function _inspectPeerRecord(record) {
1769
- if (!record || typeof record !== "object") {
1770
- const fingerprint = _rawOwnerFingerprint(record);
1771
- console.warn(`[un-bien] event=invalid_owner_record owner_fp=${fingerprint}`);
1772
- return null;
1773
- }
1774
- const candidate = record;
1775
- const rawHandle = candidate.remote_epk;
1776
- if (typeof rawHandle !== "string") {
1777
- const fingerprint = _rawOwnerFingerprint(rawHandle);
1778
- console.warn(`[un-bien] event=invalid_owner_record owner_fp=${fingerprint}`);
1779
- return null;
1780
- }
1781
- const safeRecord = {
1782
- name: typeof candidate.name === "string" ? candidate.name : "Unknown Owner",
1783
- remote_epk: rawHandle,
1784
- paired_at: typeof candidate.paired_at === "string" ? candidate.paired_at : "",
1785
- };
1786
- try {
1787
- const runtimeKey = canonicalizeEd25519PublicKey(rawHandle, "stored Owner public key");
1788
- return { record: safeRecord, rawHandle, runtimeKey };
1789
- }
1790
- catch {
1791
- const fingerprint = _rawOwnerFingerprint(rawHandle);
1792
- console.warn(`[un-bien] event=invalid_owner_record owner_fp=${fingerprint}`);
1793
- return { record: safeRecord, rawHandle, runtimeKey: null };
1794
- }
1795
- }
1796
1598
  function _reportRevocationByFingerprint(canonicalOwnerPubkey) {
1797
1599
  const fingerprint = _runtimeOwnerFingerprint(canonicalOwnerPubkey);
1798
1600
  _pi?.sendMessage({
@@ -1811,21 +1613,6 @@ function _revokeActiveOwnerRuntime(canonicalOwnerPubkey) {
1811
1613
  _refreshFooter();
1812
1614
  _reportRevocationByFingerprint(canonicalOwnerPubkey);
1813
1615
  }
1814
- async function _findKnownPeer(appPeerIdStd) {
1815
- let runtimeKey;
1816
- try {
1817
- runtimeKey = canonicalizeEd25519PublicKey(appPeerIdStd, "Relay Owner key");
1818
- }
1819
- catch {
1820
- return null;
1821
- }
1822
- for (const record of await listPeers()) {
1823
- const inspected = _inspectPeerRecord(record);
1824
- if (inspected?.runtimeKey === runtimeKey)
1825
- return inspected.record;
1826
- }
1827
- return null;
1828
- }
1829
1616
  // ── Transition helpers ────────────────────────────────────────────────────────
1830
1617
  /**
1831
1618
  * Full teardown: stop listener, detach channel, close relay → idle.
@@ -2044,6 +1831,9 @@ function _emitRelayState(force = false) {
2044
1831
  * ctx is available in the `input` hook). cwd matches the daemon's launch dir,
2045
1832
  * so the derived relay room is identical to the one `_cmdStart` first used. */
2046
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.
2047
1837
  return {
2048
1838
  ui: _headlessUi(),
2049
1839
  cwd: process.cwd(),
@@ -2212,7 +2002,9 @@ function _attachOwner(relay, appPeerId, peerName, firstInner) {
2212
2002
  // is a captured command ctx that goes stale after session replacement (#55).
2213
2003
  const channel = new PlainPeerChannel(relay, appPeerId, _myRoomId ?? undefined, (msg) => _routeClientMessageFrom(channel, msg, _liveCtx() ?? _noopCtx), () => _onPeerDisconnect(appPeerId), (env) => env.ub === undefined
2214
2004
  ? _routeRpcCommandFrom(channel, env)
2215
- : _routeUnBienPlaneFrom(channel, env));
2005
+ : _routeUnBienPlaneFrom(channel, env), () => _rootState().sessionManager?.getSessionId() ??
2006
+ _rootSessionId ??
2007
+ undefined);
2216
2008
  _attachPeerChannel(appPeerId, channel);
2217
2009
  // Envelope-native capability handshake: advertise caps up front so the app can
2218
2010
  // enable the {rpc|evt} route + suppress stock before any session content
@@ -2307,6 +2099,8 @@ function _installAutoListener(relay) {
2307
2099
  const innerObj = inner;
2308
2100
  if (isEnvelopeFrame(innerObj)) {
2309
2101
  {
2102
+ // SAFETY: isEnvelopeFrame confirmed rpc/evt/ub envelope keys are
2103
+ // present, so this ClientMessage is byte-compatible with EnvelopeMessage.
2310
2104
  const innerEnv = inner;
2311
2105
  if (innerEnv.ub === undefined)
2312
2106
  _routeRpcCommandFrom(channel, innerEnv);
@@ -2361,7 +2155,7 @@ const _HOSTNAME = hostname();
2361
2155
  // un-bien capability handshake. PROTOCOL_VERSION bumps on a HARD (breaking)
2362
2156
  // wire change; the app gates UI on capability PRESENCE, not this number.
2363
2157
  const PROTOCOL_VERSION = 1;
2364
- // Features this fork supports, advertised on attach (session_history) + pair_ok.
2158
+ // Features this extension supports, advertised on attach (session_history) + pair_ok.
2365
2159
  // `remote_launch` is conditional (added only when local config opts in) — see
2366
2160
  // `_capabilities()`. Passive server->app extras (images/panels) are listed so
2367
2161
  // the app can also gate any future *controls* it grows for them.
@@ -2386,48 +2180,6 @@ function _capabilities() {
2386
2180
  }
2387
2181
  return caps;
2388
2182
  }
2389
- /** Sanitize a tmux session name: keep it shell-safe and tmux-legal. */
2390
- function _safeTmuxName(name, fallback) {
2391
- const base = (name ?? "").trim() || fallback;
2392
- // tmux disallows `.` and `:` in session names; strip anything risky.
2393
- const clean = base
2394
- .replace(/[^A-Za-z0-9_-]+/g, "-")
2395
- .replace(/-{2,}/g, "-")
2396
- .replace(/^-+|-+$/g, "");
2397
- return clean.slice(0, 40) || "pi";
2398
- }
2399
- /** Build the argv for a detached tmux launch of `pi` in `cwd`. Args are an
2400
- * ARRAY (never a shell string) so cwd/name can't inject. Exported for tests. */
2401
- export function _buildTmuxLaunchArgs(name, cwd) {
2402
- return ["new-session", "-d", "-s", name, "-c", cwd, "pi"];
2403
- }
2404
- /**
2405
- * Honor a `session_launch` request. Gated by the caller: config opt-in already
2406
- * checked. `tmux` mode spawns a detached tmux running `pi` in `cwd` (which
2407
- * joins the relay if that cwd has un-bien auto-start); `rpc` is not wired yet.
2408
- * Returns null on success or an error string.
2409
- */
2410
- function _launchSession(mode, cwd, name) {
2411
- if (mode === "rpc")
2412
- return "launch mode 'rpc' is not supported yet";
2413
- if (mode !== "tmux")
2414
- return `unknown launch mode '${mode}'`;
2415
- if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
2416
- return `cwd does not exist or is not a directory: ${cwd}`;
2417
- }
2418
- const sessionName = _safeTmuxName(name, `pi-${basename(cwd) || "session"}`);
2419
- try {
2420
- const child = spawn("tmux", _buildTmuxLaunchArgs(sessionName, cwd), {
2421
- detached: true,
2422
- stdio: "ignore",
2423
- });
2424
- child.unref();
2425
- return null;
2426
- }
2427
- catch (error) {
2428
- return `tmux launch failed: ${error instanceof Error ? error.message : String(error)}`;
2429
- }
2430
- }
2431
2183
  async function _handlePairRequest(relay, appPeerId, inner, hasListenerAuthority) {
2432
2184
  const sendInner = (msg) => {
2433
2185
  const ct = Buffer.from(JSON.stringify(msg)).toString("base64");
@@ -2533,13 +2285,13 @@ let _lastCtx = null;
2533
2285
  let _lastEventCtx = null;
2534
2286
  const _noopCtx = { ui: { notify: () => undefined }, abort: () => undefined };
2535
2287
  // A single Pi process can load this extension TWICE in the SAME session:
2536
- // pi-supervisord launches each daemon child as `pi -e <dist>/index.js`, but if
2537
- // un-bien is ALSO installed as a pi-package (auto-discovered from
2538
- // ~/.pi/agent/extensions or <cwd>/.pi/extensions), Pi loads it a second time
2539
- // for that same session. Both loads receive the same session-scoped `pi` and
2540
- // would re-run registerTool/registerCommand for identical names — a hard
2541
- // duplicate-registration conflict that crashes the daemon child on boot (see
2542
- // daemon/rpc_child.ts). Idempotent, first-load-wins: whichever load runs first
2288
+ // when it is launched as `pi -e <dist>/index.js` AND un-bien is ALSO installed
2289
+ // as a pi-package (auto-discovered from ~/.pi/agent/extensions or
2290
+ // <cwd>/.pi/extensions), Pi loads it a second time for that same session. Both
2291
+ // loads receive the same session-scoped `pi` and would re-run
2292
+ // registerTool/registerCommand for identical names — a hard
2293
+ // duplicate-registration conflict that crashes the process on boot.
2294
+ // Idempotent, first-load-wins: whichever load runs first
2543
2295
  // does all the wiring; the duplicate is an inert no-op. A genuine session
2544
2296
  // REPLACEMENT gets a FRESH `pi`, so re-registration for the new session still
2545
2297
  // happens.
@@ -2615,14 +2367,22 @@ const extension = (pi) => {
2615
2367
  _extensionUiBridge?.dispose();
2616
2368
  _extensionUiBridge = createExtensionUiBridge(pi, _uiBroadcast);
2617
2369
  _panelBridge?.dispose();
2618
- _panelBridge = createPanelBridge(pi, _panelBroadcast);
2370
+ _panelBridge = createPanelBridge(pi, _panelBroadcast, {
2371
+ suppressAgents: subagentRoomsEnabled(),
2372
+ });
2619
2373
  _rpcEnvelope?.dispose();
2620
2374
  _rpcEnvelope = createRpcEnvelope(pi, _broadcastEnvelope, {
2621
2375
  enrichArgs: (tool, args) => {
2622
- const e = _enrichToolArgs(tool, args);
2376
+ const e = _enrichToolArgs(tool, args, _resolveToolCwd());
2623
2377
  return Array.isArray(e.hunks) ? { hunks: e.hunks } : null;
2624
2378
  },
2625
2379
  });
2380
+ _subagentRooms?.dispose();
2381
+ _subagentRooms = initSubagentRooms(pi, {
2382
+ getParentRoomId: () => _myRoomId,
2383
+ getParentSessionId: () => _rootSessionId,
2384
+ broadcastPanel: _panelBroadcast,
2385
+ });
2626
2386
  }
2627
2387
  // Plano 19: ensure ~/.pi/un-bien/{sessions,skills}/ exist and deploy the
2628
2388
  // agent-network skill on first load. resources_discover lets Pi find it.
@@ -2680,25 +2440,33 @@ const extension = (pi) => {
2680
2440
  // SDK fires model_select on settings load + every user switch. We cache the
2681
2441
  // friendly name and broadcast a room_meta_update so the relay can fan it
2682
2442
  // out to subscribed apps without needing a new pair.
2683
- pi.on("model_select", (event) => {
2443
+ pi.on("model_select", (event, ctx) => {
2684
2444
  const m = event?.model;
2685
2445
  const modelName = m?.name ?? m?.id;
2686
2446
  if (!modelName)
2687
2447
  return;
2688
- // Cache + fan out. Keeps the cached room_meta fresh so a future reconnect
2689
- // carries the current model in its hello, and pushes a room_meta_update to
2690
- // apps already subscribed.
2691
- _setCurrentModel(modelName);
2448
+ // Cache per-sid for THIS session (root + subagent) so a subagent's model is
2449
+ // queryable and never clobbers the root's. Only the ROOT projects to
2450
+ // _currentModel + room_meta (the app-room's model hello/update).
2451
+ const sid = _sidOf(ctx);
2452
+ _stateFor(sid).model = modelName;
2453
+ if (!_isNonRootSid(sid))
2454
+ _setCurrentModel(modelName);
2692
2455
  });
2693
2456
  // Plan/28 Wave D.1: mirror model's room_meta_update path for thinking
2694
2457
  // level so the app hydrates the segmented control on first open instead
2695
2458
  // of starting null. SDK fires `thinking_level_select` on settings load
2696
2459
  // AND on every user toggle (matching `model_select`'s behavior), so
2697
2460
  // late-pairing apps see the current level via `room_meta_updated`.
2698
- pi.on("thinking_level_select", (event) => {
2461
+ pi.on("thinking_level_select", (event, ctx) => {
2699
2462
  const level = event?.level;
2700
2463
  if (!level)
2701
2464
  return;
2465
+ // Cache per-sid; only the ROOT projects to _currentThinking + room_meta.
2466
+ const sid = _sidOf(ctx);
2467
+ _stateFor(sid).thinking = level;
2468
+ if (_isNonRootSid(sid))
2469
+ return;
2702
2470
  _currentThinking = level;
2703
2471
  if (_myRoomMeta)
2704
2472
  _myRoomMeta = { ..._myRoomMeta, thinking: level };
@@ -2754,22 +2522,27 @@ const extension = (pi) => {
2754
2522
  if (ctx?.sessionManager)
2755
2523
  st.sessionManager = ctx.sessionManager;
2756
2524
  st.working = true;
2757
- if (_isNonRootSid(sid))
2758
- return; // model hydration + room_meta are root-only
2759
- // Late model hydration: if the model was still unknown at connect (resolved
2760
- // lazily by the SDK), grab it on the first turn and fan it out — so a daemon
2761
- // whose model only materialises at turn 1 still reports it to the app.
2762
- if (!_currentModel) {
2525
+ // Late model hydration for THIS session: if the model was unknown at
2526
+ // connect (SDK resolves it lazily), grab it on the first turn and cache
2527
+ // per-sid root AND subagent, so a subagent's model is queryable and the
2528
+ // root's is never clobbered by a child.
2529
+ if (!st.model) {
2763
2530
  try {
2764
2531
  const m = ctx.getModel?.();
2765
2532
  const name = m?.name ?? m?.id;
2766
2533
  if (name)
2767
- _setCurrentModel(name);
2534
+ st.model = name;
2768
2535
  }
2769
2536
  catch {
2770
2537
  /* defensive — never block a turn on a model lookup */
2771
2538
  }
2772
2539
  }
2540
+ if (_isNonRootSid(sid))
2541
+ return; // room_meta projection is root-only
2542
+ // Root projection: seed the global model + room_meta hello from the root's
2543
+ // cached model, once.
2544
+ if (!_currentModel && st.model)
2545
+ _setCurrentModel(st.model);
2773
2546
  // Plan/32 Part B: publish working=true as room_meta (raw, no debounce —
2774
2547
  // the debounce lives in the app). Same shape as the model/thinking updates.
2775
2548
  // _myRoomMeta is the ROOM projection (driven only by the root session).
@@ -2802,19 +2575,24 @@ const extension = (pi) => {
2802
2575
  // Plan/32: compaction feedback. compact() doesn't run a turn, so bracket it
2803
2576
  // with working=true/false here. Returning void = no veto → default
2804
2577
  // compaction proceeds.
2805
- pi.on("session_before_compact", (event) => {
2578
+ pi.on("session_before_compact", (event, ctx) => {
2806
2579
  if (event.preparation) {
2807
2580
  event.preparation.messagesToSummarize =
2808
2581
  _filterInternalMessagesFromContext(event.preparation.messagesToSummarize);
2809
2582
  event.preparation.turnPrefixMessages = _filterInternalMessagesFromContext(event.preparation.turnPrefixMessages);
2810
2583
  }
2811
- _publishWorking(true);
2584
+ // working=true brackets the ROOT room's compaction; the per-session message
2585
+ // filtering above always runs, but a subagent that compacts must not
2586
+ // flicker the root's working indicator.
2587
+ if (!_isNonRootSid(_sidOf(ctx)))
2588
+ _publishWorking(true);
2812
2589
  });
2813
- pi.on("session_compact", () => {
2590
+ pi.on("session_compact", (_event, ctx) => {
2814
2591
  // Live compaction result rides the rpc-envelope compaction_end (app applyRPC),
2815
2592
  // and the persisted CompactionEntry surfaces natively via get_entries. Only
2816
- // the working=false bracket remains here.
2817
- _publishWorking(false);
2593
+ // the working=false bracket remains here — the ROOT room's, so guard it.
2594
+ if (!_isNonRootSid(_sidOf(ctx)))
2595
+ _publishWorking(false);
2818
2596
  });
2819
2597
  // Re-capture the freshest base ctx on every session replacement so compact
2820
2598
  // never operates on a stale captured ctx — this is the fix for the
@@ -2822,11 +2600,16 @@ const extension = (pi) => {
2822
2600
  // New session. Fires on startup/new/fork/reload/resume; the ctx is always
2823
2601
  // bound to the current session.
2824
2602
  pi.on("session_start", (_event, ctx) => {
2825
- _lastEventCtx = ctx;
2826
2603
  // Register THIS session's record (root + every subagent get their own
2827
2604
  // session_start with their own ctx). Each records its OWN sessionManager —
2828
2605
  // no cross-session clobber (was the unguarded `_sessionManager = ...` bug).
2829
2606
  const sid = _sidOf(ctx);
2607
+ // The module BASE ctx (compact/notify fallback when no fresh ctx is passed)
2608
+ // is the ROOT's — a subagent child's ctx must NOT clobber it, same
2609
+ // no-cross-session-clobber rule as the per-sid sessionManager. Otherwise a
2610
+ // subagent steals the base ctx and root-scoped notifies silently drop.
2611
+ if (!_isNonRootSid(sid))
2612
+ _lastEventCtx = ctx;
2830
2613
  if (ctx?.sessionManager)
2831
2614
  _stateFor(sid).sessionManager = ctx.sessionManager;
2832
2615
  // session_shutdown disposes per-session pi-ask subscriptions. A host that
@@ -2843,14 +2626,27 @@ const extension = (pi) => {
2843
2626
  if (!_extensionUiBridge)
2844
2627
  _extensionUiBridge = createExtensionUiBridge(pi, _uiBroadcast);
2845
2628
  if (!_panelBridge)
2846
- _panelBridge = createPanelBridge(pi, _panelBroadcast);
2629
+ _panelBridge = createPanelBridge(pi, _panelBroadcast, {
2630
+ suppressAgents: subagentRoomsEnabled(),
2631
+ });
2847
2632
  if (!_rpcEnvelope)
2848
2633
  _rpcEnvelope = createRpcEnvelope(pi, _broadcastEnvelope, {
2849
2634
  enrichArgs: (tool, args) => {
2850
- const e = _enrichToolArgs(tool, args);
2635
+ const e = _enrichToolArgs(tool, args, _resolveToolCwd());
2851
2636
  return Array.isArray(e.hunks) ? { hunks: e.hunks } : null;
2852
2637
  },
2853
2638
  });
2639
+ if (!_subagentRooms)
2640
+ _subagentRooms = initSubagentRooms(pi, {
2641
+ getParentRoomId: () => _myRoomId,
2642
+ getParentSessionId: () => _rootSessionId,
2643
+ broadcastPanel: _panelBroadcast,
2644
+ });
2645
+ }
2646
+ else if (_isNonRootSid(sid)) {
2647
+ // A subagent child session (non-root) — surface it as its own relay room.
2648
+ // `pi` here is the CHILD's ExtensionAPI (per-activation).
2649
+ _subagentRooms?.onChildSession(pi, ctx);
2854
2650
  }
2855
2651
  // Rearm a reused-but-disposed instance. The session_shutdown teardown (below)
2856
2652
  // sets _disposed=true assuming the host re-evaluates THIS module fresh for the
@@ -2936,6 +2732,18 @@ const extension = (pi) => {
2936
2732
  // best-effort: every step is guarded so a partially-initialised instance
2937
2733
  // (e.g. shutdown lands mid-`_cmdRoot`) tears down without throwing.
2938
2734
  pi.on("session_shutdown", async () => {
2735
+ // A subagent child's session_shutdown owns NOTHING at the module level: the
2736
+ // connection, mesh node, cwd lock, lifecycle generations, and base ctx are
2737
+ // the ROOT's, and its child room outlives the turn (reaped by the root's
2738
+ // _subagentRooms.dispose(), not here). So a non-root shutdown is a no-op.
2739
+ // Without this, a subagent ENDING poisoned _disposed + the generations AND
2740
+ // tore down the root's mesh node / cwd lock; the next root session_start's
2741
+ // `if (_disposed)` rearm then re-ran _cmdRoot, dropping and re-announcing
2742
+ // the root room — the "parent disappears while still running" flap. This is
2743
+ // root-lifecycle authority (Tier 2), NOT the per-sid data path, so an
2744
+ // early return is correct here — there is no session-local state to cache.
2745
+ if (!_isRootSession(pi))
2746
+ return;
2939
2747
  // Revoke async authority synchronously, before any teardown await. `_disposed`
2940
2748
  // blocks the outgoing continuation immediately; the root and candidate
2941
2749
  // generations keep queued work stale even if a same-module session_start
@@ -2966,6 +2774,8 @@ const extension = (pi) => {
2966
2774
  _panelBridge = null;
2967
2775
  _rpcEnvelope?.dispose();
2968
2776
  _rpcEnvelope = null;
2777
+ _subagentRooms?.dispose();
2778
+ _subagentRooms = null;
2969
2779
  _releaseRootSession(pi);
2970
2780
  }
2971
2781
  // Drop captured ctxs immediately. On module-reuse hosts the same instance
@@ -3042,6 +2852,8 @@ const extension = (pi) => {
3042
2852
  "relay status",
3043
2853
  "relay url",
3044
2854
  "config",
2855
+ "identity",
2856
+ "identity show",
3045
2857
  "test", // hidden e2e UI harness (dev-only)
3046
2858
  "peers", // plan/25 Wave D — local + cross-PC inventory
3047
2859
  "create",
@@ -3101,9 +2913,12 @@ const extension = (pi) => {
3101
2913
  else if (sub === "config") {
3102
2914
  _cmdConfig(ctx);
3103
2915
  }
2916
+ else if (sub === "identity" || sub.startsWith("identity ")) {
2917
+ await _cmdIdentity(ctx);
2918
+ }
3104
2919
  else if (sub === "test" || sub.startsWith("test ")) {
3105
2920
  // Hidden dev-only e2e UI harness: broadcast canned frames to paired apps.
3106
- _safeNotify(`[un-bien test] ${_runTestScenario(sub.slice("test".length).trim())}`, "info");
2921
+ _safeNotify(`[un-bien test] ${_runTestScenario(sub.slice("test".length).trim())}`, "info", ctx);
3107
2922
  }
3108
2923
  else if (sub === "rename" || sub.startsWith("rename ")) {
3109
2924
  await _renameAgent(sub.slice("rename".length).trim());
@@ -3111,34 +2926,6 @@ const extension = (pi) => {
3111
2926
  else if (sub === "peers") {
3112
2927
  await _cmdPeers(ctx);
3113
2928
  }
3114
- else if (sub.startsWith("create")) {
3115
- await _cmdCreate(sub.slice("create".length).trim(), ctx);
3116
- }
3117
- else if (sub.startsWith("remove")) {
3118
- await _cmdRemove(sub.slice("remove".length).trim(), ctx);
3119
- }
3120
- else if (sub === "daemons") {
3121
- await _cmdDaemonsList(ctx);
3122
- }
3123
- else if (sub === "daemon start" || sub.startsWith("daemon start ")) {
3124
- await _cmdDaemonStart(ctx, sub.slice("daemon start".length).trim() || undefined);
3125
- }
3126
- else if (sub === "daemon stop" || sub.startsWith("daemon stop ")) {
3127
- await _cmdDaemonStop(ctx, sub.slice("daemon stop".length).trim() || undefined);
3128
- }
3129
- else if (sub === "daemon restart" ||
3130
- sub.startsWith("daemon restart ")) {
3131
- await _cmdDaemonRestart(ctx, sub.slice("daemon restart".length).trim() || undefined);
3132
- }
3133
- else if (sub === "daemon status") {
3134
- await _cmdDaemonStatus(ctx);
3135
- }
3136
- else if (sub.startsWith("daemon send")) {
3137
- await _cmdDaemonSend(sub.slice("daemon send".length).trim(), ctx);
3138
- }
3139
- else if (sub === "cron" || sub.startsWith("cron ")) {
3140
- await _cmdCron(sub.slice("cron".length).trim(), ctx);
3141
- }
3142
2929
  else if (sub === "install") {
3143
2930
  _cmdInstall(ctx, { linkCli: true });
3144
2931
  }
@@ -3217,98 +3004,45 @@ const extension = (pi) => {
3217
3004
  _cmdConfig(ctx);
3218
3005
  },
3219
3006
  });
3220
- pi.registerCommand("unbien relay", {
3221
- description: "Relay control: start | stop | status | url <http(s) url> (no arg toggles)",
3222
- handler: async (args, ctx) => {
3223
- _lastCtx = ctx;
3224
- await _cmdRelay(args.trim(), ctx);
3225
- },
3226
- });
3227
- // Plan/25 Wave D
3228
- pi.registerCommand("unbien peers", {
3229
- description: "List local + cross-PC mesh peers, grouped by PC label",
3007
+ pi.registerCommand("unbien identity", {
3008
+ description: "Show this machine's identity: active EPK (public), backend, and source",
3230
3009
  handler: async (_, ctx) => {
3231
3010
  _lastCtx = ctx;
3232
- await _cmdPeers(ctx);
3233
- },
3234
- });
3235
- // Daemon registry (plan/26 Wave 1) — create + remove. start/stop/send/
3236
- // status/install/uninstall come in later waves with the supervisor.
3237
- pi.registerCommand("unbien create", {
3238
- description: "Register a folder as a daemon and start it (when the supervisor is running)",
3239
- handler: async (args, ctx) => {
3240
- _lastCtx = ctx;
3241
- await _cmdCreate(args.trim(), ctx);
3242
- },
3243
- });
3244
- pi.registerCommand("unbien remove", {
3245
- description: "Stop + unregister a daemon by id (local config is preserved)",
3246
- handler: async (args, ctx) => {
3247
- _lastCtx = ctx;
3248
- await _cmdRemove(args.trim(), ctx);
3011
+ await _cmdIdentity(ctx);
3249
3012
  },
3250
3013
  });
3251
- // Fleet ops via the supervisor (plan/26 W2). `/unbien stop` stays as
3252
- // local stop fleet stop is `/unbien daemon stop`.
3253
- pi.registerCommand("unbien daemons", {
3254
- description: "List registered daemons + state",
3014
+ pi.registerCommand("unbien identity show", {
3015
+ description: "Show this machine's identity (EPK/backend/source) alias of `identity`",
3255
3016
  handler: async (_, ctx) => {
3256
3017
  _lastCtx = ctx;
3257
- await _cmdDaemonsList(ctx);
3258
- },
3259
- });
3260
- pi.registerCommand("unbien daemon start", {
3261
- description: "Start daemons: all, or one by id (`daemon start <id>`)",
3262
- handler: async (args, ctx) => {
3263
- _lastCtx = ctx;
3264
- await _cmdDaemonStart(ctx, args.trim() || undefined);
3018
+ await _cmdIdentity(ctx);
3265
3019
  },
3266
3020
  });
3267
- pi.registerCommand("unbien daemon stop", {
3268
- description: "Stop daemons: all, or one by id (`daemon stop <id>`)",
3269
- handler: async (args, ctx) => {
3270
- _lastCtx = ctx;
3271
- await _cmdDaemonStop(ctx, args.trim() || undefined);
3272
- },
3273
- });
3274
- pi.registerCommand("unbien daemon restart", {
3275
- description: "Restart daemons: all, or one by id (`daemon restart <id>`)",
3021
+ pi.registerCommand("unbien relay", {
3022
+ description: "Relay control: start | stop | status | url <http(s) url> (no arg toggles)",
3276
3023
  handler: async (args, ctx) => {
3277
3024
  _lastCtx = ctx;
3278
- await _cmdDaemonRestart(ctx, args.trim() || undefined);
3025
+ await _cmdRelay(args.trim(), ctx);
3279
3026
  },
3280
3027
  });
3281
- pi.registerCommand("unbien daemon status", {
3282
- description: "Show fleet runtime status (pid, uptime, restarts)",
3028
+ // Plan/25 Wave D
3029
+ pi.registerCommand("unbien peers", {
3030
+ description: "List local + cross-PC mesh peers, grouped by PC label",
3283
3031
  handler: async (_, ctx) => {
3284
3032
  _lastCtx = ctx;
3285
- await _cmdDaemonStatus(ctx);
3286
- },
3287
- });
3288
- pi.registerCommand("unbien daemon send", {
3289
- description: 'Send a prompt to a daemon: `daemon send <id> "<text>"`',
3290
- handler: async (args, ctx) => {
3291
- _lastCtx = ctx;
3292
- await _cmdDaemonSend(args.trim(), ctx);
3293
- },
3294
- });
3295
- pi.registerCommand("unbien cron", {
3296
- description: "Schedule recurring prompts to daemons: `cron <add|list|remove|enable|disable|run|log>`",
3297
- handler: async (args, ctx) => {
3298
- _lastCtx = ctx;
3299
- await _cmdCron(args.trim(), ctx);
3033
+ await _cmdPeers(ctx);
3300
3034
  },
3301
3035
  });
3302
- // Service install / uninstall (plan/26 W3)
3036
+ // Service install / uninstall the launcher daemon as a system service.
3303
3037
  pi.registerCommand("unbien install", {
3304
- description: "Install pi-supervisord as a system service + link the un-bien CLI (systemd/launchd/Task Scheduler; Windows prompts for admin)",
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)",
3305
3039
  handler: async (_, ctx) => {
3306
3040
  _lastCtx = ctx;
3307
3041
  _cmdInstall(ctx, { linkCli: true });
3308
3042
  },
3309
3043
  });
3310
3044
  pi.registerCommand("unbien uninstall", {
3311
- description: "Remove the pi-supervisord system service + the CLI shims (daemons registry preserved; Windows prompts for admin)",
3045
+ description: "Remove the un-bien launcher daemon system service + the CLI shims (Windows prompts for admin)",
3312
3046
  handler: async (_, ctx) => {
3313
3047
  _lastCtx = ctx;
3314
3048
  _cmdUninstall(ctx, { linkCli: true });
@@ -3479,6 +3213,9 @@ async function _cmdRootInner(ctx, rootLifecycleGeneration) {
3479
3213
  }
3480
3214
  // First-time wizard: no local config in this cwd → run interactive setup.
3481
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.
3482
3219
  const ui = ctx.ui;
3483
3220
  if (typeof ui.select !== "function") {
3484
3221
  _cmdStatus(ctx);
@@ -3535,6 +3272,9 @@ async function _cmdRootInner(ctx, rootLifecycleGeneration) {
3535
3272
  */
3536
3273
  async function _cmdSetup(ctx) {
3537
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.
3538
3278
  const ui = ctx.ui;
3539
3279
  if (typeof ui.select !== "function") {
3540
3280
  ctx.ui.notify("[un-bien] Setup requires an interactive UI.", "warning");
@@ -3582,7 +3322,7 @@ async function _cmdStart(ctx) {
3582
3322
  ctx.ui.notify("[un-bien] Could not read this machine's identity: the system " +
3583
3323
  "keychain is locked or access was denied. Unlock it (open the app / " +
3584
3324
  "log in) and run /unbien again. Your pairing is NOT lost. " +
3585
- "(Set UNBIEN_ALLOW_FILE_IDENTITY=1 only for headless hosts.)", "error");
3325
+ '(For a headless host, set "identity": { "storage": "file" } in un-bien.json.)', "error");
3586
3326
  return;
3587
3327
  }
3588
3328
  if (err instanceof PairedIdentityMissingError) {
@@ -3652,8 +3392,14 @@ async function _cmdStart(ctx) {
3652
3392
  const provider = sm.getDefaultProvider();
3653
3393
  const modelId = sm.getDefaultModel();
3654
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);
3655
3401
  const found = provider
3656
- ? ensureModelRegistry((c ?? _lastEventCtx ?? _lastCtx)).find(provider, modelId)
3402
+ ? ensureModelRegistry(regCtx).find(provider, modelId)
3657
3403
  : undefined;
3658
3404
  _currentModel = found?.name ?? modelId;
3659
3405
  }
@@ -3680,6 +3426,12 @@ async function _cmdStart(ctx) {
3680
3426
  roomMeta.model = modelName;
3681
3427
  if (_currentThinking)
3682
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;
3683
3435
  // Persist so _attemptReconnect can replay the same hello payload — without
3684
3436
  // this, reconnect issues a bare hello and the relay creates a "default room"
3685
3437
  // entry that surfaces in the app as a phantom legacy session.
@@ -4067,6 +3819,21 @@ function _cmdConfig(ctx) {
4067
3819
  : "";
4068
3820
  ctx.ui.notify(`[un-bien]\n Relay URL: ${url ?? "(none)"}\n Source: ${source} — ${origin}${live}`, "info");
4069
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
+ }
4070
3837
  /**
4071
3838
  * `/unbien relay [start|stop|status|url <url>]` — issue #119.
4072
3839
  *
@@ -4127,465 +3894,19 @@ async function _cmdRelay(arg, ctx) {
4127
3894
  return;
4128
3895
  }
4129
3896
  }
4130
- // ── Daemon registry commands (plan/26 Wave 1) ─────────────────────────────────
4131
- /**
4132
- * `/unbien create [<cwd>] [--name <name>]`
4133
- *
4134
- * Promotes a folder to a daemon entry in `~/.pi/un-bien/daemons.json`. The
4135
- * cwd is **always normalized to an absolute realpath** before storage —
4136
- * `~/Movies`, `./Movies`, `../foo/Movies` all collapse to a single
4137
- * canonical entry. Relative paths resolve against the Pi process's
4138
- * current working directory, not the slash-command's `ctx.cwd`.
4139
- *
4140
- * Side effects on the cwd's local config (`<cwd>/.pi/un-bien/config.json`):
4141
- * - If the config doesn't exist: created with `auto_start_relay=true`
4142
- * (mandatory for daemons) and `agent_name` from `--name` if provided.
4143
- * - If the config already exists: left untouched. Re-running `create`
4144
- * on an existing daemon is idempotent at this layer; the registry
4145
- * itself rejects duplicate cwds.
4146
- */
4147
- async function _cmdCreate(arg, ctx) {
4148
- // Parse `[cwd] [--name "value with spaces" | --name word]` in any order.
4149
- // The first non-flag token is the cwd; the rest of the line after
4150
- // `--name` (quoted or unquoted) is the display name.
4151
- const nameMatch = arg.match(/--name\s+"([^"]+)"|--name\s+(\S+)/);
4152
- const name = nameMatch ? (nameMatch[1] ?? nameMatch[2]) : undefined;
4153
- const cwdRaw = arg.replace(/--name\s+"[^"]+"|--name\s+\S+/, "").trim();
4154
- if (!cwdRaw) {
4155
- ctx.ui.notify('[un-bien] Usage: /unbien create <absolute-or-relative-cwd> [--name "Display name"]', "warning");
4156
- return;
4157
- }
4158
- let result;
4159
- try {
4160
- result = addDaemon(cwdRaw, name);
4161
- }
4162
- catch (err) {
4163
- ctx.ui.notify(`[un-bien] create failed: ${String(err)}`, "error");
4164
- return;
4165
- }
4166
- // No local `.pi/un-bien/config.json` is written anymore — the name lives
4167
- // in the registry and the supervisor injects the full config (agent_name,
4168
- // auto_start_relay true) via UNBIEN_DIRECT_CONFIG when it spawns the
4169
- // daemon. The cwd needs no init folder.
4170
- ctx.ui.notify(`[un-bien] Daemon registered: id=${result.id} name="${result.name}" cwd=${result.cwd}`, "info");
4171
- // Auto-start: register alone used to leave the daemon idle until the next
4172
- // supervisor restart (the reported bug — `create` didn't run anything). Ask
4173
- // the supervisor to spawn THIS daemon now; it reads the name from the
4174
- // registry and injects the config via env. When the supervisor is offline we
4175
- // keep the
4176
- // registration and tell the user it'll boot on the next supervisor start.
4177
- try {
4178
- await callSupervisor({ op: "start", id: result.id });
4179
- ctx.ui.notify(`[un-bien] Daemon started: id=${result.id}`, "info");
4180
- }
4181
- catch (err) {
4182
- if (err instanceof SupervisorOfflineError) {
4183
- ctx.ui.notify(`[un-bien] Registered, but the supervisor is offline — not running yet. ` +
4184
- `Run \`unbien install\` (or start \`pi-supervisord\`); it auto-starts on the next supervisor boot.`, "warning");
4185
- return;
4186
- }
4187
- ctx.ui.notify(`[un-bien] Registered, but auto-start failed: ${String(err)}`, "error");
4188
- }
4189
- }
4190
- /**
4191
- * `/unbien remove <id>`
4192
- *
4193
- * Unregisters a daemon by its 8-hex-char id (the same id printed by
4194
- * `/unbien create` and `/unbien daemons`). The cwd's local config
4195
- * stays on disk — re-creating later with the same cwd is a no-op
4196
- * because the existing config wins.
4197
- */
4198
- async function _cmdRemove(arg, ctx) {
4199
- const id = arg.trim();
4200
- if (!id) {
4201
- ctx.ui.notify("[un-bien] Usage: /unbien remove <id>. Run /unbien daemons to see ids.", "warning");
4202
- return;
4203
- }
4204
- // Prefer the supervisor's `unregister`: it STOPS the running child (SIGTERM →
4205
- // SIGKILL) BEFORE deleting the registry entry. Removing only the registry
4206
- // (the old behaviour) left an orphaned `pi --mode rpc` process running with
4207
- // nothing left to manage it — the reported bug. Fall back to a registry-only
4208
- // removal when the supervisor is offline (no managed process to stop anyway).
4209
- try {
4210
- const data = await callSupervisor({ op: "unregister", id });
4211
- if (!data.removed) {
4212
- const known = listDaemons()
4213
- .map((d) => d.id)
4214
- .join(", ") || "(none)";
4215
- ctx.ui.notify(`[un-bien] No daemon with id "${id}". Known ids: ${known}`, "warning");
4216
- return;
4217
- }
4218
- ctx.ui.notify(`[un-bien] Daemon removed + process stopped: id=${id} cwd=${data.cwd}. ` +
4219
- `Local config at ${data.cwd}/.pi/un-bien/config.json was kept.`, "info");
4220
- return;
4221
- }
4222
- catch (err) {
4223
- if (!(err instanceof SupervisorOfflineError)) {
4224
- ctx.ui.notify(`[un-bien] remove failed: ${String(err)}`, "error");
4225
- return;
4226
- }
4227
- // Supervisor offline — fall through to registry-only removal below.
4228
- }
4229
- let result;
4230
- try {
4231
- result = removeDaemon(id);
4232
- }
4233
- catch (err) {
4234
- ctx.ui.notify(`[un-bien] remove failed: ${String(err)}`, "error");
4235
- return;
4236
- }
4237
- if (!result.removed) {
4238
- const known = listDaemons()
4239
- .map((d) => d.id)
4240
- .join(", ") || "(none)";
4241
- ctx.ui.notify(`[un-bien] No daemon with id "${id}". Known ids: ${known}`, "warning");
4242
- return;
4243
- }
4244
- ctx.ui.notify(`[un-bien] Daemon removed from registry: id=${id} cwd=${result.cwd}. ` +
4245
- `Supervisor was offline, so any running process was NOT stopped. Local config kept.`, "warning");
4246
- }
4247
- // ── Fleet-ops commands (plan/26 W2) — talk to the supervisor over UDS ─────────
3897
+ // ── Install/uninstall the launcher-daemon service ────────────────────────────
4248
3898
  //
4249
- // Every command here is a thin wrapper around `callSupervisor(...)`. When
4250
- // the supervisor isn't running we fall back to a friendly hint instead of
4251
- // the raw error, so the user can't get stuck on "what's wrong?".
4252
- function _notifyOffline(ctx, err) {
4253
- ctx.ui.notify(`[un-bien] ${err.message}`, "warning");
4254
- }
4255
- function _formatDaemonTable(daemons) {
4256
- if (daemons.length === 0)
4257
- return "(no daemons registered)";
4258
- const rows = daemons.map((d) => {
4259
- const uptime = d.uptime_s === undefined ? "—" : `${d.uptime_s}s`;
4260
- const pid = d.pid === undefined ? "—" : String(d.pid);
4261
- const restarts = d.restart_count ?? 0;
4262
- return ` ${d.id} ${d.state.padEnd(8)} pid=${pid} up=${uptime} restarts=${restarts} ${d.name} ${d.cwd}`;
4263
- });
4264
- return rows.join("\n");
4265
- }
4266
- /**
4267
- * `/unbien daemons` — registry + runtime state in one view. When the
4268
- * supervisor is offline we still show registry-only output (state =
4269
- * "stopped" everywhere), so the user can see what's configured even
4270
- * before `install`.
4271
- */
4272
- async function _cmdDaemonsList(ctx) {
4273
- if (!(await supervisorOnline())) {
4274
- const registry = listDaemons();
4275
- if (registry.length === 0) {
4276
- ctx.ui.notify("[un-bien] No daemons registered. Run /unbien create <cwd>.", "info");
4277
- return;
4278
- }
4279
- const rows = registry
4280
- .map((d) => {
4281
- const cfg = loadLocalConfig(d.cwd);
4282
- const name = cfg.agent_name ?? defaultAgentName(d.cwd);
4283
- return ` ${d.id} ${name} ${d.cwd} (supervisor offline)`;
4284
- })
4285
- .join("\n");
4286
- ctx.ui.notify(`[un-bien] Daemons (registry only — run install to bring supervisor up):\n${rows}`, "info");
4287
- return;
4288
- }
4289
- try {
4290
- const data = await callSupervisor({ op: "list" });
4291
- ctx.ui.notify(`[un-bien] Daemons:\n${_formatDaemonTable(data.daemons)}`, "info");
4292
- }
4293
- catch (err) {
4294
- if (err instanceof SupervisorOfflineError) {
4295
- _notifyOffline(ctx, err);
4296
- return;
4297
- }
4298
- ctx.ui.notify(`[un-bien] daemons failed: ${String(err)}`, "error");
4299
- }
4300
- }
4301
- async function _cmdDaemonStatus(ctx) {
4302
- try {
4303
- const data = await callSupervisor({ op: "status" });
4304
- ctx.ui.notify(`[un-bien] Fleet status:\n${_formatDaemonTable(data.daemons)}`, "info");
4305
- }
4306
- catch (err) {
4307
- if (err instanceof SupervisorOfflineError) {
4308
- _notifyOffline(ctx, err);
4309
- return;
4310
- }
4311
- ctx.ui.notify(`[un-bien] status failed: ${String(err)}`, "error");
4312
- }
4313
- }
4314
- async function _cmdDaemonStart(ctx, id) {
4315
- try {
4316
- if (id) {
4317
- const data = await callSupervisor({ op: "start", id });
4318
- ctx.ui.notify(data.started
4319
- ? `[un-bien] Started daemon ${id} (${data.state}).`
4320
- : `[un-bien] Daemon ${id} already ${data.state}.`, "info");
4321
- return;
4322
- }
4323
- const data = await callSupervisor({ op: "start_all" });
4324
- ctx.ui.notify(`[un-bien] Started ${data.started.length} daemon(s), ` +
4325
- `${data.already_running.length} already running.`, "info");
4326
- }
4327
- catch (err) {
4328
- if (err instanceof SupervisorOfflineError) {
4329
- _notifyOffline(ctx, err);
4330
- return;
4331
- }
4332
- ctx.ui.notify(`[un-bien] start failed: ${String(err)}`, "error");
4333
- }
4334
- }
4335
- async function _cmdDaemonStop(ctx, id) {
4336
- try {
4337
- if (id) {
4338
- const data = await callSupervisor({ op: "stop", id });
4339
- ctx.ui.notify(data.stopped
4340
- ? `[un-bien] Stopped daemon ${id}.`
4341
- : `[un-bien] Daemon ${id} already ${data.state}.`, "info");
4342
- return;
4343
- }
4344
- const data = await callSupervisor({ op: "stop_all" });
4345
- ctx.ui.notify(`[un-bien] Stopped ${data.stopped.length} daemon(s), ` +
4346
- `${data.already_stopped.length} already stopped.`, "info");
4347
- }
4348
- catch (err) {
4349
- if (err instanceof SupervisorOfflineError) {
4350
- _notifyOffline(ctx, err);
4351
- return;
4352
- }
4353
- ctx.ui.notify(`[un-bien] stop failed: ${String(err)}`, "error");
4354
- }
4355
- }
4356
- async function _cmdDaemonRestart(ctx, id) {
4357
- try {
4358
- if (id) {
4359
- const data = await callSupervisor({ op: "restart", id });
4360
- ctx.ui.notify(`[un-bien] Restarted daemon ${id} (${data.state}).`, "info");
4361
- return;
4362
- }
4363
- const data = await callSupervisor({ op: "restart_all" });
4364
- ctx.ui.notify(`[un-bien] Restarted ${data.restarted.length} daemon(s).`, "info");
4365
- }
4366
- catch (err) {
4367
- if (err instanceof SupervisorOfflineError) {
4368
- _notifyOffline(ctx, err);
4369
- return;
4370
- }
4371
- ctx.ui.notify(`[un-bien] restart failed: ${String(err)}`, "error");
4372
- }
4373
- }
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.
4374
3903
  /**
4375
- * `/unbien daemon send <id> "<text>"` injects a prompt into a
4376
- * running daemon via its RPC stdin. The agent processes the prompt as
4377
- * if a user typed it; output flows back via the relay/mesh, not here.
4378
- *
4379
- * Fire-and-forget at this layer the CLI just confirms delivery.
4380
- */
4381
- async function _cmdDaemonSend(arg, ctx) {
4382
- // Parse `<id> <text...>` — id is the first token, rest is the prompt.
4383
- // The text may be quoted; if so, strip the outer quotes. Otherwise
4384
- // take the entire remainder verbatim.
4385
- const m = arg.match(/^(\S+)\s+(?:"([^"]*)"|(.*))$/);
4386
- if (!m) {
4387
- ctx.ui.notify('[un-bien] Usage: /unbien daemon send <id> "<prompt text>"', "warning");
4388
- return;
4389
- }
4390
- const id = m[1];
4391
- const text = (m[2] ?? m[3] ?? "").trim();
4392
- if (!text) {
4393
- ctx.ui.notify("[un-bien] daemon send: prompt text is empty.", "warning");
4394
- return;
4395
- }
4396
- try {
4397
- const data = await callSupervisor({ op: "send", id, text });
4398
- if (data.delivered) {
4399
- ctx.ui.notify(`[un-bien] Sent to ${id}: ${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`, "info");
4400
- }
4401
- else {
4402
- ctx.ui.notify(`[un-bien] daemon ${id} did not accept the prompt (not running?)`, "warning");
4403
- }
4404
- }
4405
- catch (err) {
4406
- if (err instanceof SupervisorOfflineError) {
4407
- _notifyOffline(ctx, err);
4408
- return;
4409
- }
4410
- ctx.ui.notify(`[un-bien] daemon send failed: ${String(err)}`, "error");
4411
- }
4412
- }
4413
- // ── Cron — scheduled prompts for daemons (plan/39) ──────────────────────────
4414
- /** Splits an arg string into tokens, honoring double-quoted groups. */
4415
- function _tokenizeArgs(s) {
4416
- const out = [];
4417
- const re = /"([^"]*)"|(\S+)/g;
4418
- let m;
4419
- while ((m = re.exec(s)) !== null)
4420
- out.push(m[1] === undefined ? m[2] : m[1]);
4421
- return out;
4422
- }
4423
- /**
4424
- * `/unbien cron <add|list|remove|enable|disable|run|log>` — schedules
4425
- * recurring prompts to daemons via the supervisor. All subcommands require the
4426
- * supervisor running (offline → friendly notice, not a crash).
4427
- */
4428
- async function _cmdCron(arg, ctx) {
4429
- const trimmed = arg.trim();
4430
- const sp = trimmed.indexOf(" ");
4431
- const sub = (sp === -1 ? trimmed : trimmed.slice(0, sp)).toLowerCase();
4432
- const rest = sp === -1 ? "" : trimmed.slice(sp + 1).trim();
4433
- try {
4434
- switch (sub) {
4435
- case "":
4436
- case "list":
4437
- return await _cronList(ctx);
4438
- case "add":
4439
- return await _cronAdd(rest, ctx);
4440
- case "remove":
4441
- case "rm":
4442
- return await _cronMutate({ op: "cron_remove", job_id: rest.trim() }, rest.trim(), ctx);
4443
- case "enable":
4444
- return await _cronMutate({ op: "cron_enable", job_id: rest.trim(), enabled: true }, rest.trim(), ctx);
4445
- case "disable":
4446
- return await _cronMutate({ op: "cron_enable", job_id: rest.trim(), enabled: false }, rest.trim(), ctx);
4447
- case "run":
4448
- return await _cronRun(rest.trim(), ctx);
4449
- case "log":
4450
- return await _cronLog(rest, ctx);
4451
- default:
4452
- ctx.ui.notify("[un-bien] Usage: /unbien cron <add|list|remove|enable|disable|run|log>", "warning");
4453
- }
4454
- }
4455
- catch (err) {
4456
- if (err instanceof SupervisorOfflineError) {
4457
- ctx.ui.notify("[un-bien] Cron needs the supervisor running. Run `unbien install` " +
4458
- "(or start `pi-supervisord`).", "warning");
4459
- return;
4460
- }
4461
- ctx.ui.notify(`[un-bien] cron ${sub || "list"} failed: ${String(err)}`, "error");
4462
- }
4463
- }
4464
- async function _cronAdd(rest, ctx) {
4465
- const toks = _tokenizeArgs(rest);
4466
- let tz;
4467
- let wake = false;
4468
- let skipBusy = true;
4469
- let catchup = false;
4470
- const pos = [];
4471
- for (let i = 0; i < toks.length; i++) {
4472
- const t = toks[i];
4473
- if (t === "--wake")
4474
- wake = true;
4475
- else if (t === "--no-skip-busy")
4476
- skipBusy = false;
4477
- else if (t === "--catchup")
4478
- catchup = true;
4479
- else if (t === "--tz")
4480
- tz = toks[++i];
4481
- else
4482
- pos.push(t);
4483
- }
4484
- const [daemonId, schedule, prompt] = pos;
4485
- if (!daemonId || !schedule || !prompt) {
4486
- ctx.ui.notify('[un-bien] Usage: /unbien cron add <daemonId> "<cron-expr>" "<prompt>" ' +
4487
- "[--tz Area/City] [--wake] [--no-skip-busy] [--catchup]", "warning");
4488
- return;
4489
- }
4490
- const req = {
4491
- op: "cron_add",
4492
- daemon_id: daemonId,
4493
- schedule,
4494
- prompt,
4495
- };
4496
- if (tz)
4497
- req.tz = tz;
4498
- if (wake)
4499
- req.wake = true;
4500
- if (!skipBusy)
4501
- req.skip_if_busy = false;
4502
- if (catchup)
4503
- req.catchup = true;
4504
- const data = await callSupervisor(req);
4505
- ctx.ui.notify(`[un-bien] Cron ${data.job.id} added → daemon ${daemonId}: "${schedule}"` +
4506
- `${tz ? ` (${tz})` : ""}. Next run: ${data.job.next_run ?? "?"}`, "info");
4507
- }
4508
- async function _cronList(ctx) {
4509
- const data = await callSupervisor({ op: "cron_list" });
4510
- if (data.jobs.length === 0) {
4511
- ctx.ui.notify("[un-bien] No cron jobs.", "info");
4512
- return;
4513
- }
4514
- const lines = data.jobs.map((j) => `${j.enabled ? "✓" : "✗"} ${j.id} "${j.schedule}"${j.tz ? ` (${j.tz})` : ""} → ${j.daemon_id} ` +
4515
- `next:${j.next_run ?? "?"} last:${j.last_status ?? "—"}${j.last_run ? `@${j.last_run}` : ""}`);
4516
- ctx.ui.notify(`[un-bien] Cron jobs (${data.jobs.length}):\n${lines.join("\n")}`, "info");
4517
- }
4518
- async function _cronMutate(req, jobId, ctx) {
4519
- if (!jobId) {
4520
- ctx.ui.notify(`[un-bien] Usage: /unbien cron ${req.op === "cron_remove" ? "remove" : "enable|disable"} <jobId>`, "warning");
4521
- return;
4522
- }
4523
- if (req.op === "cron_remove") {
4524
- const data = await callSupervisor(req);
4525
- ctx.ui.notify(data.removed
4526
- ? `[un-bien] Cron ${jobId} removed.`
4527
- : `[un-bien] No cron job ${jobId}.`, data.removed ? "info" : "warning");
4528
- }
4529
- else {
4530
- const data = await callSupervisor(req);
4531
- ctx.ui.notify(data.updated
4532
- ? `[un-bien] Cron ${jobId} ${data.enabled ? "enabled" : "disabled"}.`
4533
- : `[un-bien] No cron job ${jobId}.`, data.updated ? "info" : "warning");
4534
- }
4535
- }
4536
- async function _cronRun(jobId, ctx) {
4537
- if (!jobId) {
4538
- ctx.ui.notify("[un-bien] Usage: /unbien cron run <jobId>", "warning");
4539
- return;
4540
- }
4541
- const data = await callSupervisor({ op: "cron_run", job_id: jobId });
4542
- ctx.ui.notify(`[un-bien] Cron ${jobId} fired now → ${data.result}`, "info");
4543
- }
4544
- async function _cronLog(rest, ctx) {
4545
- const toks = _tokenizeArgs(rest);
4546
- let jobId;
4547
- let tail = 20;
4548
- for (let i = 0; i < toks.length; i++) {
4549
- const t = toks[i];
4550
- if (t === "--tail") {
4551
- const n = Number(toks[++i]);
4552
- if (Number.isFinite(n))
4553
- tail = n;
4554
- }
4555
- else if (!t.startsWith("--"))
4556
- jobId = t;
4557
- }
4558
- const req = {
4559
- op: "cron_log",
4560
- tail,
4561
- };
4562
- if (jobId)
4563
- req.job_id = jobId;
4564
- const data = await callSupervisor(req);
4565
- if (data.entries.length === 0) {
4566
- ctx.ui.notify("[un-bien] No cron log entries.", "info");
4567
- return;
4568
- }
4569
- const lines = data.entries.map((e) => `${new Date(e.ts).toISOString()} ${e.fired ? "▶" : "∅"} ${e.result} ${e.job_id} → ${e.daemon_id} ${e.prompt_preview}`);
4570
- ctx.ui.notify(`[un-bien] Cron log (last ${data.entries.length}):\n${lines.join("\n")}`, "info");
4571
- }
4572
- // ── Install/uninstall the supervisor service (plan/26 W3) ────────────────────
4573
- //
4574
- // Installs `pi-supervisord` as a user-level system service (systemd
4575
- // `--user` unit on Linux, launchd LaunchAgent on macOS). Once installed:
4576
- // - Supervisor starts at login + survives reboots.
4577
- // - `unbien daemon start/stop/send/...` work without manually
4578
- // spawning the supervisor.
4579
- // Uninstall is the inverse — leaves the registry (`daemons.json`) intact,
4580
- // so re-installing later picks up where you left off.
4581
- /**
4582
- * `linkCli` controls whether we symlink `un-bien` + `pi-supervisord`
4583
- * into `~/.local/bin/`. The slash-command path passes `true` (user is
4584
- * inside Pi's TUI — they installed via `pi install npm:un-bien` and
4585
- * need us to expose the CLI for them). The standalone-CLI path passes
4586
- * `false` because the user is already running our binary from PATH (they
4587
- * did `npm install -g un-bien`), so re-linking would point their
4588
- * `un-bien` at the Pi-extension copy and diverge on upgrades.
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.
4589
3910
  */
4590
3911
  /** Returns true on success, false when install failed (so the standalone CLI
4591
3912
  * can exit non-zero — e.g. the Cockpit / CI detect failure by exit code).
@@ -4596,7 +3917,7 @@ function _cmdInstall(ctx, opts = {}) {
4596
3917
  try {
4597
3918
  const result = installService();
4598
3919
  const sections = [
4599
- `[un-bien] Supervisor service installed (${result.platform}).`,
3920
+ `[un-bien] Launcher daemon service installed (${result.platform}).`,
4600
3921
  ` Unit: ${result.unitPath}`,
4601
3922
  ` Steps:\n${result.log.map((l) => " " + l).join("\n")}`,
4602
3923
  ];
@@ -4605,10 +3926,10 @@ function _cmdInstall(ctx, opts = {}) {
4605
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")}`);
4606
3927
  if (!link.onPath) {
4607
3928
  if (process.platform === "win32") {
4608
- sections.push(` ⚠ ${link.binDir} was just added to your user PATH (it wasn't there yet).`, ` Open a NEW terminal and run \`unbien daemons\` to verify.`);
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.`);
4609
3930
  }
4610
3931
  else {
4611
- 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 daemons\` to verify.`);
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.`);
4612
3933
  }
4613
3934
  }
4614
3935
  }
@@ -4625,10 +3946,9 @@ function _cmdUninstall(ctx, opts = {}) {
4625
3946
  try {
4626
3947
  const result = uninstallService();
4627
3948
  const sections = [
4628
- `[un-bien] Supervisor service uninstalled (${result.platform}).`,
3949
+ `[un-bien] Launcher daemon service uninstalled (${result.platform}).`,
4629
3950
  ` Unit: ${result.unitPath} (${result.removed ? "removed" : "not present"})`,
4630
3951
  ` Steps:\n${result.log.map((l) => " " + l).join("\n")}`,
4631
- ` Note: daemons registry (~/.pi/un-bien/daemons.json) kept — re-install restores everything.`,
4632
3952
  ];
4633
3953
  if (linkCli) {
4634
3954
  const unlink = unlinkCliBinaries();
@@ -5075,240 +4395,11 @@ function _resetSessionForNew() {
5075
4395
  // get_entries against the fresh session and drops the old one on the new hello.
5076
4396
  _sessionStartedAt = Date.now();
5077
4397
  }
5078
- export function _enrichToolArgs(tool, args) {
5079
- if (!args || typeof args !== "object")
5080
- return {};
5081
- const base = args;
5082
- switch (tool.toLowerCase()) {
5083
- case "edit":
5084
- return _enrichEditToolArgs(base);
5085
- default:
5086
- return base;
5087
- }
5088
- }
5089
- function _enrichEditToolArgs(base) {
5090
- const filePath = _stringArg(base, ["path", "file_path"]);
5091
- const rawEdits = base["edits"];
5092
- const edits = Array.isArray(rawEdits) ? rawEdits : [base];
5093
- const text = _readToolFile(filePath);
5094
- const hunks = [];
5095
- let searchFrom = 0;
5096
- for (const rawEdit of edits) {
5097
- if (!rawEdit || typeof rawEdit !== "object")
5098
- continue;
5099
- const edit = rawEdit;
5100
- const oldText = _stringArg(edit, [
5101
- "oldText",
5102
- "old_text",
5103
- "old_string",
5104
- "oldString",
5105
- ]);
5106
- const newText = _stringArg(edit, [
5107
- "newText",
5108
- "new_text",
5109
- "new_string",
5110
- "newString",
5111
- ]);
5112
- if (!oldText && !newText)
5113
- continue;
5114
- const matchAt = oldText && text !== null ? text.indexOf(oldText, searchFrom) : -1;
5115
- const fallbackAt = oldText && matchAt < 0 && text !== null ? text.indexOf(oldText) : matchAt;
5116
- const startOffset = fallbackAt >= 0 ? fallbackAt : searchFrom;
5117
- if (text === null)
5118
- continue;
5119
- const hunk = _buildEditHunk(text, startOffset, oldText, newText);
5120
- if (hunk.length > 0)
5121
- hunks.push({ lines: hunk });
5122
- searchFrom = startOffset + Math.max(oldText.length, 1);
5123
- }
5124
- return hunks.length === 0 ? base : { ...base, hunks };
5125
- }
5126
- function _readToolFile(filePath) {
5127
- if (!filePath)
5128
- return null;
5129
- const cwd = _lastCtx && "cwd" in _lastCtx ? _lastCtx.cwd : process.cwd();
5130
- const homePath = filePath.startsWith("~/") && process.env.HOME
5131
- ? resolve(process.env.HOME, filePath.slice(2))
5132
- : null;
5133
- const candidates = [
5134
- filePath,
5135
- resolve(cwd, filePath),
5136
- resolve(process.cwd(), filePath),
5137
- homePath,
5138
- ].filter((p) => typeof p === "string");
5139
- for (const candidate of candidates) {
5140
- try {
5141
- return readFileSync(candidate, "utf8");
5142
- }
5143
- catch {
5144
- // try next candidate
5145
- }
5146
- }
5147
- return null;
5148
- }
5149
- function _buildEditHunk(fileText, startOffset, oldText, newText) {
5150
- const context = 4;
5151
- const fileLines = fileText.split("\n");
5152
- const oldLines = _splitPreviewLines(oldText);
5153
- const newLines = _splitPreviewLines(newText);
5154
- const oldStart = _lineNumberAt(fileText, startOffset);
5155
- const newStart = oldStart;
5156
- const startIndex = oldStart - 1;
5157
- const beforeStart = Math.max(0, startIndex - context);
5158
- const afterStart = startIndex + oldLines.length;
5159
- const afterEnd = Math.min(fileLines.length, afterStart + context);
5160
- const out = [];
5161
- if (beforeStart > 0)
5162
- out.push({ kind: "ellipsis" });
5163
- for (let i = beforeStart; i < startIndex; i++) {
5164
- out.push({
5165
- kind: "context",
5166
- oldLine: i + 1,
5167
- newLine: i + 1,
5168
- text: fileLines[i] ?? "",
5169
- });
5170
- }
5171
- let commonPrefix = 0;
5172
- while (commonPrefix < oldLines.length &&
5173
- commonPrefix < newLines.length &&
5174
- oldLines[commonPrefix] === newLines[commonPrefix]) {
5175
- commonPrefix++;
5176
- }
5177
- let commonSuffix = 0;
5178
- while (commonSuffix < oldLines.length - commonPrefix &&
5179
- commonSuffix < newLines.length - commonPrefix &&
5180
- oldLines[oldLines.length - 1 - commonSuffix] ===
5181
- newLines[newLines.length - 1 - commonSuffix]) {
5182
- commonSuffix++;
5183
- }
5184
- for (let i = 0; i < commonPrefix; i++) {
5185
- out.push({
5186
- kind: "context",
5187
- oldLine: oldStart + i,
5188
- newLine: newStart + i,
5189
- text: oldLines[i] ?? "",
5190
- });
5191
- }
5192
- for (let i = commonPrefix; i < oldLines.length - commonSuffix; i++) {
5193
- out.push({
5194
- kind: "remove",
5195
- oldLine: oldStart + i,
5196
- text: oldLines[i] ?? "",
5197
- });
5198
- }
5199
- for (let i = commonPrefix; i < newLines.length - commonSuffix; i++) {
5200
- out.push({ kind: "add", newLine: newStart + i, text: newLines[i] ?? "" });
5201
- }
5202
- for (let i = oldLines.length - commonSuffix; i < oldLines.length; i++) {
5203
- const newLine = newStart + newLines.length - (oldLines.length - i);
5204
- out.push({
5205
- kind: "context",
5206
- oldLine: oldStart + i,
5207
- newLine,
5208
- text: oldLines[i] ?? "",
5209
- });
5210
- }
5211
- for (let i = afterStart; i < afterEnd; i++) {
5212
- const newLine = newStart + newLines.length + (i - afterStart);
5213
- out.push({
5214
- kind: "context",
5215
- oldLine: i + 1,
5216
- newLine,
5217
- text: fileLines[i] ?? "",
5218
- });
5219
- }
5220
- if (afterEnd < fileLines.length)
5221
- out.push({ kind: "ellipsis" });
5222
- return out;
5223
- }
5224
- function _lineNumberAt(text, offset) {
5225
- let line = 1;
5226
- for (let i = 0; i < Math.max(0, offset); i++)
5227
- if (text[i] === "\n")
5228
- line++;
5229
- return line;
5230
- }
5231
- function _splitPreviewLines(text) {
5232
- if (!text)
5233
- return [];
5234
- const lines = text.split("\n");
5235
- if (lines.length > 0 && lines[lines.length - 1] === "")
5236
- lines.pop();
5237
- return lines;
5238
- }
5239
- function _stringArg(args, keys) {
5240
- for (const key of keys) {
5241
- const value = args[key];
5242
- if (typeof value === "string")
5243
- return value;
5244
- }
5245
- return "";
5246
- }
5247
- function _stringifyContent(content) {
5248
- if (typeof content === "string")
5249
- return content;
5250
- if (!Array.isArray(content))
5251
- return "";
5252
- return content
5253
- .map((c) => {
5254
- if (!c || typeof c !== "object")
5255
- return "";
5256
- const block = c;
5257
- return block.type === "text" ? String(block.text ?? "") : "";
5258
- })
5259
- .join("");
5260
- }
5261
- /** Pure: the OS command sequence that restarts the supervisor service, or null
5262
- * when the platform isn't supported. Most platforms are 1 step; Windows is 2
5263
- * (`schtasks /End` then `/Run`). Exported for tests. */
5264
- export function _restartSupervisorCommand(platform, uid) {
5265
- if (platform === "darwin")
5266
- return [
5267
- {
5268
- cmd: "launchctl",
5269
- args: ["kickstart", "-k", `gui/${uid}/${LAUNCHD_LABEL}`],
5270
- },
5271
- ];
5272
- if (platform === "linux")
5273
- return [{ cmd: "systemctl", args: ["--user", "restart", SYSTEMD_UNIT] }];
5274
- if (platform === "win32")
5275
- return [
5276
- {
5277
- cmd: "schtasks",
5278
- args: ["/End", "/TN", WINDOWS_TASK_NAME],
5279
- ignoreFailure: true,
5280
- },
5281
- { cmd: "schtasks", args: ["/Run", "/TN", WINDOWS_TASK_NAME] },
5282
- ];
5283
- return null;
5284
- }
5285
- function _restartSupervisor() {
5286
- const uid = process.getuid?.() ?? 0;
5287
- const steps = _restartSupervisorCommand(process.platform, uid);
5288
- if (!steps) {
5289
- console.error(`[un-bien] restart-supervisor is not supported on '${process.platform}' yet. ` +
5290
- "Restart pi-supervisord manually.");
5291
- process.exit(1);
5292
- }
5293
- for (const step of steps) {
5294
- const r = spawnSync(step.cmd, step.args, {
5295
- stdio: ["ignore", "pipe", "pipe"],
5296
- encoding: "utf8",
5297
- });
5298
- if (r.error) {
5299
- if (step.ignoreFailure)
5300
- continue;
5301
- console.error(`[un-bien] restart-supervisor failed: ${step.cmd} not runnable (${r.error.message}). Is the service installed? Run \`unbien install\`.`);
5302
- process.exit(1);
5303
- }
5304
- if (r.status !== 0 && !step.ignoreFailure) {
5305
- const detail = (r.stderr || r.stdout || "").trim();
5306
- console.error(`[un-bien] restart-supervisor failed (${step.cmd} exited ${r.status})${detail ? `: ${detail}` : ""}.`);
5307
- process.exit(r.status === null ? 1 : r.status);
5308
- }
5309
- }
5310
- console.log("[un-bien] Supervisor restarted.");
4398
+ /** Resolve the base dir for tool-arg file lookups from the last command ctx. */
4399
+ function _resolveToolCwd() {
4400
+ return _lastCtx && "cwd" in _lastCtx ? _lastCtx.cwd : process.cwd();
5311
4401
  }
4402
+ // ── Standalone CLI ────────────────────────────────────────────────────────────
5312
4403
  function _isDirectRun() {
5313
4404
  try {
5314
4405
  return (fileURLToPath(import.meta.url) === realpathSync(process.argv[1] ?? ""));
@@ -5387,6 +4478,14 @@ export async function probeListPeers(sockPath, timeoutMs = 2000) {
5387
4478
  sock.on("close", () => done(null));
5388
4479
  });
5389
4480
  }
4481
+ function _cliStubUi() {
4482
+ // SAFETY: the CLI fleet/daemon/setup handlers only ever call ui.notify; the
4483
+ // other ExtensionContext["ui"] methods (select/input/editor/…) are never
4484
+ // reached on the direct-run path, so a notify-only console shim is safe.
4485
+ return {
4486
+ notify: (msg) => console.log(msg),
4487
+ };
4488
+ }
5390
4489
  if (_isDirectRun()) {
5391
4490
  const [, , subcmd, ...cliArgs] = process.argv;
5392
4491
  if (subcmd === "devices" || subcmd === "list") {
@@ -5440,81 +4539,6 @@ if (_isDirectRun()) {
5440
4539
  console.log(`Invalid URL: ${raw}. Must start with http:// or https://`);
5441
4540
  }
5442
4541
  }
5443
- else if (subcmd === "create") {
5444
- // Standalone: `unbien create <cwd> [--name "X"]`. The shell already
5445
- // split the args and stripped the outer quotes, so an arg like
5446
- // `Tmp Agent` arrives as a single element with embedded space. Re-add
5447
- // quotes around any arg containing whitespace so the regex-based
5448
- // parser (shared with the slash-command path) sees the same shape
5449
- // as it would from a Pi interactive prompt.
5450
- const joined = cliArgs.map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
5451
- await _cmdCreate(joined, {
5452
- ui: {
5453
- notify: (msg) => console.log(msg),
5454
- },
5455
- });
5456
- }
5457
- else if (subcmd === "remove") {
5458
- const id = (cliArgs[0] ?? "").trim();
5459
- await _cmdRemove(id, {
5460
- ui: {
5461
- notify: (msg) => console.log(msg),
5462
- },
5463
- });
5464
- }
5465
- else if (subcmd === "daemons") {
5466
- // Mirror the slash handler: ask the supervisor when reachable,
5467
- // fall back to registry-only when not.
5468
- const stubCtx = {
5469
- ui: {
5470
- notify: (msg) => console.log(msg),
5471
- },
5472
- };
5473
- await _cmdDaemonsList(stubCtx);
5474
- }
5475
- else if (subcmd === "daemon") {
5476
- // `unbien daemon <op> [args]`. Reuse the fleet-ops handlers — they
5477
- // already accept a minimal ctx with `notify`.
5478
- const op = cliArgs[0] ?? "";
5479
- const rest = cliArgs
5480
- .slice(1)
5481
- .map((a) => (/\s/.test(a) ? `"${a}"` : a))
5482
- .join(" ");
5483
- const stubCtx = {
5484
- ui: {
5485
- notify: (msg) => console.log(msg),
5486
- },
5487
- };
5488
- if (op === "start") {
5489
- await _cmdDaemonStart(stubCtx, cliArgs[1]);
5490
- }
5491
- else if (op === "stop") {
5492
- await _cmdDaemonStop(stubCtx, cliArgs[1]);
5493
- }
5494
- else if (op === "restart") {
5495
- await _cmdDaemonRestart(stubCtx, cliArgs[1]);
5496
- }
5497
- else if (op === "status") {
5498
- await _cmdDaemonStatus(stubCtx);
5499
- }
5500
- else if (op === "send") {
5501
- await _cmdDaemonSend(rest, stubCtx);
5502
- }
5503
- else {
5504
- console.log('Usage: unbien daemon <start|stop|restart [<id>]|status|send <id> "<text>">');
5505
- }
5506
- }
5507
- else if (subcmd === "cron") {
5508
- // `unbien cron <op> [args]`. Re-quote args with spaces so the shared
5509
- // parser sees the same shape as a Pi slash prompt.
5510
- const joined = cliArgs.map((a) => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
5511
- const stubCtx = {
5512
- ui: {
5513
- notify: (msg) => console.log(msg),
5514
- },
5515
- };
5516
- await _cmdCron(joined, stubCtx);
5517
- }
5518
4542
  else if (subcmd === "peers") {
5519
4543
  // Read-only roster of the local + cross-PC mesh. Unlike `devices` (which
5520
4544
  // reads paired phones from peers.json), the mesh roster lives only in the
@@ -5534,59 +4558,33 @@ if (_isDirectRun()) {
5534
4558
  }
5535
4559
  else if (subcmd === "install") {
5536
4560
  // CLI mode = user installed via `npm install -g un-bien`, so the
5537
- // `un-bien` / `pi-supervisord` bins are already on $PATH via npm's
5538
- // global prefix. Explicit `linkCli: false` so we never stomp those
5539
- // with symlinks pointing at a parallel Pi-extension install.
5540
- const stubCtx = {
5541
- ui: {
5542
- notify: (msg) => console.log(msg),
5543
- },
5544
- };
4561
+ // `un-bien` bin is already on $PATH via npm's global prefix. Explicit
4562
+ // `linkCli: false` so we never stomp those with symlinks pointing at a
4563
+ // parallel Pi-extension install.
4564
+ const stubCtx = { ui: _cliStubUi() };
5545
4565
  // Propagate failure as a non-zero exit so callers (Cockpit / CI) detect it
5546
4566
  // — installService throws on a failed schtasks/launchctl/systemctl step.
5547
4567
  if (!_cmdInstall(stubCtx, { linkCli: false }))
5548
4568
  process.exit(1);
5549
4569
  }
5550
4570
  else if (subcmd === "uninstall") {
5551
- const stubCtx = {
5552
- ui: {
5553
- notify: (msg) => console.log(msg),
5554
- },
5555
- };
4571
+ const stubCtx = { ui: _cliStubUi() };
5556
4572
  // `linkCli: true` even from the CLI: unlinking is ALWAYS safe and must run
5557
4573
  // regardless of how install ran. `unlinkCliBinaries` only removes OUR
5558
- // reserved symlinks (`un-bien` / `pi-supervisord`) under `~/.local/bin`;
5559
- // npm-global bins live in a different prefix and are never touched. So a
5560
- // user who installed via the TUI (`/unbien install`, which links) and
5561
- // uninstalls from a shell still gets the links cleaned up — the asymmetry
5562
- // that left an orphaned `~/.local/bin/unbien` behind.
4574
+ // reserved `un-bien` symlink under `~/.local/bin`; npm-global bins live in
4575
+ // a different prefix and are never touched. So a user who installed via the
4576
+ // TUI (`/unbien install`, which links) and uninstalls from a shell still
4577
+ // gets the link cleaned up — the asymmetry that left an orphaned
4578
+ // `~/.local/bin/unbien` behind.
5563
4579
  _cmdUninstall(stubCtx, { linkCli: true });
5564
4580
  }
5565
- else if (subcmd === "restart-supervisor") {
5566
- _restartSupervisor();
5567
- }
5568
4581
  else {
5569
4582
  console.log([
5570
4583
  "Usage: un-bien <command>",
5571
4584
  "",
5572
- "Daemon registry:",
5573
- ' create <cwd> [--name "Name"] Register a folder as a daemon',
5574
- " remove <id> Unregister a daemon",
5575
- " daemons List registered daemons",
5576
- "",
5577
- "Fleet control:",
5578
- " daemon start [<id>] Start all daemons, or one by id",
5579
- " daemon stop [<id>] Stop all daemons, or one by id",
5580
- " daemon restart [<id>] Restart all daemons, or one by id",
5581
- " daemon status Show pid / uptime / restarts",
5582
- ' daemon send <id> "<text>" Send a prompt to a daemon',
5583
- ' cron add <id> "<expr>" "<txt>" Schedule a recurring prompt (≥60s; --tz, --wake)',
5584
- " cron list|run|remove|log Manage scheduled prompts (needs the supervisor)",
5585
- "",
5586
4585
  "Service:",
5587
- " install Install pi-supervisord as a system service",
4586
+ " install Install the un-bien launcher daemon as a system service",
5588
4587
  " uninstall Remove the system service",
5589
- " restart-supervisor Restart the pi-supervisord process",
5590
4588
  "",
5591
4589
  "Devices:",
5592
4590
  " devices List paired phones (peers.json)",