@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.
- package/README.md +237 -268
- package/dist/bin/launcher.js +38 -0
- package/dist/bin/launcher.js.map +1 -0
- package/dist/config.d.ts +41 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon/install.d.ts +33 -35
- package/dist/daemon/install.js +43 -73
- package/dist/daemon/install.js.map +1 -1
- package/dist/enrich_tool_args.d.ts +9 -0
- package/dist/enrich_tool_args.js +171 -0
- package/dist/enrich_tool_args.js.map +1 -0
- package/dist/image_codec.d.ts +22 -0
- package/dist/image_codec.js +149 -0
- package/dist/image_codec.js.map +1 -0
- package/dist/index.d.ts +0 -26
- package/dist/index.js +235 -1237
- package/dist/index.js.map +1 -1
- package/dist/launch.d.ts +34 -0
- package/dist/launch.js +171 -0
- package/dist/launch.js.map +1 -0
- package/dist/launcher/launcher.d.ts +6 -0
- package/dist/launcher/launcher.js +188 -0
- package/dist/launcher/launcher.js.map +1 -0
- package/dist/pairing/crypto.d.ts +4 -0
- package/dist/pairing/crypto.js +10 -0
- package/dist/pairing/crypto.js.map +1 -1
- package/dist/pairing/peer_trust.d.ts +20 -0
- package/dist/pairing/peer_trust.js +82 -0
- package/dist/pairing/peer_trust.js.map +1 -0
- package/dist/pairing/storage.d.ts +51 -26
- package/dist/pairing/storage.js +346 -136
- package/dist/pairing/storage.js.map +1 -1
- package/dist/panel_bridge.d.ts +3 -1
- package/dist/panel_bridge.js +10 -10
- package/dist/panel_bridge.js.map +1 -1
- package/dist/rooms.d.ts +16 -0
- package/dist/rooms.js +22 -0
- package/dist/rooms.js.map +1 -1
- package/dist/session/debug_log.js +2 -2
- package/dist/session/debug_log.js.map +1 -1
- package/dist/session/local_config.js +2 -0
- package/dist/session/local_config.js.map +1 -1
- package/dist/session/rpc_envelope.d.ts +18 -0
- package/dist/session/rpc_envelope.js +1 -1
- package/dist/session/rpc_envelope.js.map +1 -1
- package/dist/session/rpc_inbound.d.ts +2 -2
- package/dist/session/rpc_inbound.js +1 -1
- package/dist/session/rpc_inbound.js.map +1 -1
- package/dist/subagent_rooms.d.ts +49 -0
- package/dist/subagent_rooms.js +787 -0
- package/dist/subagent_rooms.js.map +1 -0
- package/dist/transport/peer_channel.d.ts +7 -1
- package/dist/transport/peer_channel.js +8 -1
- package/dist/transport/peer_channel.js.map +1 -1
- package/dist/transport/relay_client.d.ts +19 -0
- package/dist/transport/relay_client.js.map +1 -1
- package/docs/daemon.md +65 -64
- package/package.json +13 -11
- package/service-templates/launchd.plist.template +5 -5
- package/service-templates/systemd.service.template +2 -2
- package/service-templates/task-launcher.vbs.template +5 -5
- package/service-templates/task-scheduler.xml.template +1 -1
- package/dist/bin/supervisord.js +0 -86
- package/dist/bin/supervisord.js.map +0 -1
- package/dist/daemon/client.d.ts +0 -20
- package/dist/daemon/client.js +0 -131
- package/dist/daemon/client.js.map +0 -1
- package/dist/daemon/control_protocol.d.ts +0 -176
- package/dist/daemon/control_protocol.js +0 -63
- package/dist/daemon/control_protocol.js.map +0 -1
- package/dist/daemon/cron_log.d.ts +0 -45
- package/dist/daemon/cron_log.js +0 -71
- package/dist/daemon/cron_log.js.map +0 -1
- package/dist/daemon/cron_registry.d.ts +0 -80
- package/dist/daemon/cron_registry.js +0 -198
- package/dist/daemon/cron_registry.js.map +0 -1
- package/dist/daemon/id.d.ts +0 -24
- package/dist/daemon/id.js +0 -36
- package/dist/daemon/id.js.map +0 -1
- package/dist/daemon/registry.d.ts +0 -63
- package/dist/daemon/registry.js +0 -153
- package/dist/daemon/registry.js.map +0 -1
- package/dist/daemon/rpc_child.d.ts +0 -194
- package/dist/daemon/rpc_child.js +0 -419
- package/dist/daemon/rpc_child.js.map +0 -1
- package/dist/daemon/supervisor.d.ts +0 -93
- package/dist/daemon/supervisor.js +0 -666
- package/dist/daemon/supervisor.js.map +0 -1
- /package/dist/bin/{supervisord.d.ts → launcher.d.ts} +0 -0
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 {
|
|
33
|
-
import { SettingsManager
|
|
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 {
|
|
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
|
|
58
|
+
import { join, dirname, resolve } from "node:path";
|
|
62
59
|
import { fileURLToPath } from "node:url";
|
|
63
|
-
import { chmodSync,
|
|
60
|
+
import { chmodSync, mkdirSync, copyFileSync, existsSync, unlinkSync, readFileSync, writeFileSync, realpathSync, } from "node:fs";
|
|
64
61
|
import { createInterface } from "node:readline";
|
|
65
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
2537
|
-
//
|
|
2538
|
-
//
|
|
2539
|
-
//
|
|
2540
|
-
//
|
|
2541
|
-
// duplicate-registration conflict that crashes the
|
|
2542
|
-
//
|
|
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
|
|
2689
|
-
//
|
|
2690
|
-
//
|
|
2691
|
-
|
|
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
|
|
2758
|
-
|
|
2759
|
-
//
|
|
2760
|
-
//
|
|
2761
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3221
|
-
description: "
|
|
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
|
|
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
|
-
|
|
3252
|
-
|
|
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
|
|
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
|
|
3268
|
-
description: "
|
|
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
|
|
3025
|
+
await _cmdRelay(args.trim(), ctx);
|
|
3279
3026
|
},
|
|
3280
3027
|
});
|
|
3281
|
-
|
|
3282
|
-
|
|
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
|
|
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
|
|
3036
|
+
// Service install / uninstall — the launcher daemon as a system service.
|
|
3303
3037
|
pi.registerCommand("unbien install", {
|
|
3304
|
-
description: "Install
|
|
3038
|
+
description: "Install the un-bien launcher daemon as a system service + link the un-bien CLI (systemd/launchd/Task Scheduler; Windows prompts for admin)",
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
// ──
|
|
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
|
-
//
|
|
4250
|
-
//
|
|
4251
|
-
//
|
|
4252
|
-
|
|
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
|
-
*
|
|
4376
|
-
*
|
|
4377
|
-
*
|
|
4378
|
-
*
|
|
4379
|
-
*
|
|
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]
|
|
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
|
|
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
|
|
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]
|
|
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
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
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`
|
|
5538
|
-
//
|
|
5539
|
-
//
|
|
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
|
|
5559
|
-
//
|
|
5560
|
-
//
|
|
5561
|
-
//
|
|
5562
|
-
//
|
|
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
|
|
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)",
|