@polderlabs/bizar-omp 0.7.8 → 0.7.10
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 +35 -4
- package/dist/cli/agents.d.ts.map +1 -1
- package/dist/cli/agents.js +46 -23
- package/dist/cli/agents.js.map +1 -1
- package/dist/cli/doctor.d.ts +4 -4
- package/dist/cli/install.d.ts +126 -9
- package/dist/cli/install.d.ts.map +1 -1
- package/dist/cli/install.js +19 -4
- package/dist/cli/install.js.map +1 -1
- package/dist/cli/mobile-claim.d.ts +143 -0
- package/dist/cli/mobile-claim.d.ts.map +1 -0
- package/dist/cli/mobile-claim.js +399 -0
- package/dist/cli/mobile-claim.js.map +1 -0
- package/dist/cli/omb-commands.d.ts +1 -1
- package/dist/cli/omb-commands.d.ts.map +1 -1
- package/dist/cli/omb-commands.js +22 -1
- package/dist/cli/omb-commands.js.map +1 -1
- package/dist/cli/omb-mobile.d.ts +47 -0
- package/dist/cli/omb-mobile.d.ts.map +1 -0
- package/dist/cli/omb-mobile.js +307 -0
- package/dist/cli/omb-mobile.js.map +1 -0
- package/dist/cli/omb-sessions.d.ts +64 -0
- package/dist/cli/omb-sessions.d.ts.map +1 -0
- package/dist/cli/omb-sessions.js +231 -0
- package/dist/cli/omb-sessions.js.map +1 -0
- package/dist/cli/omb-update.d.ts.map +1 -1
- package/dist/cli/omb-update.js +17 -2
- package/dist/cli/omb-update.js.map +1 -1
- package/dist/cli/omb.js +20 -15
- package/dist/cli/omb.js.map +1 -1
- package/dist/cli/omp-mobile-dispatch.d.ts +10 -0
- package/dist/cli/omp-mobile-dispatch.d.ts.map +1 -0
- package/dist/cli/omp-mobile-dispatch.js +148 -0
- package/dist/cli/omp-mobile-dispatch.js.map +1 -0
- package/dist/core/acceptance.d.ts.map +1 -1
- package/dist/core/acceptance.js +22 -2
- package/dist/core/acceptance.js.map +1 -1
- package/dist/core/transitions.d.ts.map +1 -1
- package/dist/core/transitions.js +17 -1
- package/dist/core/transitions.js.map +1 -1
- package/dist/extension.d.ts.map +1 -1
- package/dist/extension.js +62 -23
- package/dist/extension.js.map +1 -1
- package/dist/omp/compatibility-receipt.d.ts +69 -0
- package/dist/omp/compatibility-receipt.d.ts.map +1 -0
- package/dist/omp/compatibility-receipt.js +60 -0
- package/dist/omp/compatibility-receipt.js.map +1 -0
- package/dist/omp/compatibility.d.ts +5 -5
- package/dist/omp/compatibility.d.ts.map +1 -1
- package/dist/omp/compatibility.js +2 -2
- package/dist/omp/compatibility.js.map +1 -1
- package/dist/omp/daemon-service.d.ts +6 -0
- package/dist/omp/daemon-service.d.ts.map +1 -1
- package/dist/omp/daemon-service.js +452 -14
- package/dist/omp/daemon-service.js.map +1 -1
- package/dist/omp/omb-sessions.d.ts +10 -0
- package/dist/omp/omb-sessions.d.ts.map +1 -1
- package/dist/omp/omb-sessions.js +8 -6
- package/dist/omp/omb-sessions.js.map +1 -1
- package/dist/omp/tmux.d.ts +17 -0
- package/dist/omp/tmux.d.ts.map +1 -1
- package/dist/omp/tmux.js +51 -7
- package/dist/omp/tmux.js.map +1 -1
- package/dist/scripts/emit-compatibility-receipt.d.ts +2 -0
- package/dist/scripts/emit-compatibility-receipt.d.ts.map +1 -0
- package/dist/scripts/emit-compatibility-receipt.js +33 -0
- package/dist/scripts/emit-compatibility-receipt.js.map +1 -0
- package/dist/tools/workflow.d.ts.map +1 -1
- package/dist/tools/workflow.js +11 -1
- package/dist/tools/workflow.js.map +1 -1
- package/docs/07-bizarharness-integration.md +44 -0
- package/docs/compatibility/baseline.json +9 -8
- package/docs/compatibility/supported-surfaces.json +2 -1
- package/docs/development/ci-release-implementation-guide.md +3 -3
- package/docs/releases/0.7.10.md +27 -0
- package/docs/releases/0.7.9.md +18 -0
- package/docs/releases/npm-publishing.md +12 -12
- package/docs/releases/support-matrix.md +8 -1
- package/package.json +6 -4
- package/skills/omp-native-development/SKILL.md +1 -1
- package/skills/omp-native-development/references/accuracy-and-versioning.md +1 -1
- package/skills/omp-native-development/references/source-manifest.json +3 -3
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import { deferred } from "./daemon.js";
|
|
4
5
|
import { randomBytes } from "node:crypto";
|
|
5
|
-
import { mkdirSync, statSync } from "node:fs";
|
|
6
|
-
import {
|
|
6
|
+
import { mkdirSync, realpathSync, statSync } from "node:fs";
|
|
7
|
+
import { readdir } from "node:fs/promises";
|
|
8
|
+
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
7
9
|
import { listOmbSessions, ombSessionDisplayName, ombSessionJournalPath, readOmbSessionMessages } from "./omb-sessions.js";
|
|
8
10
|
import { ombSessionName } from "./tmux.js";
|
|
9
11
|
import { DAEMON_PROTOCOL_VERSION, DaemonError, daemonStateDir, writeSnapshot } from "./daemon.js";
|
|
@@ -11,8 +13,11 @@ import { captureOmbSessionConsole, createOmbSession, deleteOmbSession, forgetOmb
|
|
|
11
13
|
import { DASHBOARD_PATH, dashboardPageHtml } from "./dashboard-html.js";
|
|
12
14
|
import { OMB_APPROVAL_MODES, OMB_THINKING_LEVELS, loadOmbSettings, saveOmbSettings } from "./omb-settings.js";
|
|
13
15
|
import { startTray, stopTray } from "./tray.js";
|
|
16
|
+
import { acquireMobileSessionLease, listClaimsDetailed } from "../cli/mobile-claim.js";
|
|
17
|
+
import { expandDirectoryPath } from "../cli/directory-selector.js";
|
|
14
18
|
import { deleteCustomOmpProvider, listCustomOmpProviders, OMP_PROVIDER_APIS, saveCustomOmpProvider, } from "./omp-provider-config.js";
|
|
15
19
|
const SCAN_INTERVAL_MS = 5_000;
|
|
20
|
+
const MOBILE_HOST_ENTRY = fileURLToPath(new URL("../../mobile/packages/host/dist/cli/main.js", import.meta.url));
|
|
16
21
|
export const DASHBOARD_MAX_BODY_BYTES = 64 * 1024;
|
|
17
22
|
export const DASHBOARD_BODY_TIMEOUT_MS = 10_000;
|
|
18
23
|
/** One-time dashboard access codes live a short while and are never reused. */
|
|
@@ -25,6 +30,86 @@ class DashboardRequestError extends Error {
|
|
|
25
30
|
this.status = status;
|
|
26
31
|
}
|
|
27
32
|
}
|
|
33
|
+
const MOBILE_PROJECT_ID_TTL_MS = 10 * 60_000;
|
|
34
|
+
const MOBILE_PROJECT_LIMIT = 512;
|
|
35
|
+
const MOBILE_PROJECT_CHOICES_LIMIT = MOBILE_PROJECT_LIMIT * 4;
|
|
36
|
+
const MOBILE_PROJECT_DEPTH = 5;
|
|
37
|
+
const IGNORED_PROJECT_DIRECTORIES = new Set([".git", "node_modules", ".cache", ".turbo", ".next", ".venv", "venv"]);
|
|
38
|
+
/** A mobile delete is allowed only after one authoritative fresh scan proves
|
|
39
|
+
* the exact host-owned generation is fully stopped and no longer observable. */
|
|
40
|
+
export function isSafeMobileSessionRemoval(session) {
|
|
41
|
+
return session.authorityKnown && session.hosted && session.state === "stopped" && !session.attached && !session.uncertainLiveness && session.processObserved !== true;
|
|
42
|
+
}
|
|
43
|
+
/** Discover only directories the local OMB Hub already treats as project choices. */
|
|
44
|
+
async function discoverMobileProjectDirectories(cwd, projectsFolder, sessions) {
|
|
45
|
+
const candidates = new Map();
|
|
46
|
+
const add = (path, suggestedLabel) => {
|
|
47
|
+
try {
|
|
48
|
+
if (!isDirectory(path))
|
|
49
|
+
return;
|
|
50
|
+
const canonical = realpathSync(path);
|
|
51
|
+
if (!candidates.has(canonical))
|
|
52
|
+
candidates.set(canonical, suggestedLabel ?? "");
|
|
53
|
+
}
|
|
54
|
+
catch { /* A project removed after the hub scan is omitted. */ }
|
|
55
|
+
};
|
|
56
|
+
const current = (() => { try {
|
|
57
|
+
return realpathSync(cwd);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return resolve(cwd);
|
|
61
|
+
} })();
|
|
62
|
+
add(current);
|
|
63
|
+
const rootPath = projectsFolder.trim() ? expandDirectoryPath(projectsFolder, cwd) : "";
|
|
64
|
+
let canonicalRoot = "";
|
|
65
|
+
if (rootPath) {
|
|
66
|
+
add(rootPath, "Projects folder");
|
|
67
|
+
try {
|
|
68
|
+
canonicalRoot = realpathSync(rootPath);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
canonicalRoot = "";
|
|
72
|
+
}
|
|
73
|
+
if (canonicalRoot) {
|
|
74
|
+
const visit = async (directory, depth) => {
|
|
75
|
+
if (depth >= MOBILE_PROJECT_DEPTH || candidates.size >= MOBILE_PROJECT_LIMIT)
|
|
76
|
+
return;
|
|
77
|
+
let entries;
|
|
78
|
+
try {
|
|
79
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
85
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || IGNORED_PROJECT_DIRECTORIES.has(entry.name))
|
|
86
|
+
continue;
|
|
87
|
+
const path = resolve(directory, entry.name);
|
|
88
|
+
const label = relative(canonicalRoot, path).replaceAll("\\", "/");
|
|
89
|
+
add(path, label);
|
|
90
|
+
if (candidates.size >= MOBILE_PROJECT_LIMIT)
|
|
91
|
+
break;
|
|
92
|
+
await visit(path, depth + 1);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
await visit(canonicalRoot, 0);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const session of sessions) {
|
|
99
|
+
if (session.cwd !== "?")
|
|
100
|
+
add(session.cwd, session.project);
|
|
101
|
+
if (candidates.size >= MOBILE_PROJECT_LIMIT)
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
return [...candidates.entries()].slice(0, MOBILE_PROJECT_LIMIT).map(([path, suggested]) => {
|
|
105
|
+
const currentProject = path === current;
|
|
106
|
+
const fromRoot = canonicalRoot === "" ? "" : relative(canonicalRoot, path);
|
|
107
|
+
const insideRoot = canonicalRoot !== "" && (fromRoot === "" || (fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot)));
|
|
108
|
+
const relativeLabel = insideRoot && path !== canonicalRoot ? relative(canonicalRoot, path).replaceAll("\\", "/") : "";
|
|
109
|
+
const label = (currentProject ? `${basename(path)} (current)` : relativeLabel || suggested || basename(path)).replace(/[\u0000-\u001f\u007f]/g, "�").slice(0, 120);
|
|
110
|
+
return { cwd: path, label, current: currentProject };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
28
113
|
/**
|
|
29
114
|
* Session fields the browser needs. The scan records the pane command, the pid,
|
|
30
115
|
* and the native-session flag for the hub, but none of them reach the page: the
|
|
@@ -309,6 +394,18 @@ function dashboardSession(session) {
|
|
|
309
394
|
displayName: session.displayName ?? ombSessionDisplayName(session),
|
|
310
395
|
};
|
|
311
396
|
}
|
|
397
|
+
function mobileHubSettings(settings) {
|
|
398
|
+
return {
|
|
399
|
+
version: settings.version,
|
|
400
|
+
projectsFolderConfigured: settings.projectsFolder.trim() !== "",
|
|
401
|
+
agentsScope: settings.agentsScope,
|
|
402
|
+
sessionFilter: settings.sessionFilter,
|
|
403
|
+
autoAttachLatest: settings.autoAttachLatest,
|
|
404
|
+
mouseScrolling: settings.mouseScrolling,
|
|
405
|
+
showStoppedSessions: settings.showStoppedSessions,
|
|
406
|
+
trayEnabled: settings.trayEnabled,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
312
409
|
/**
|
|
313
410
|
* Long-running half of the daemon: an authenticated HTTP boundary on
|
|
314
411
|
* 127.0.0.1 plus a scan loop that keeps the on-disk snapshot fresh. Spawned
|
|
@@ -318,6 +415,7 @@ function dashboardSession(session) {
|
|
|
318
415
|
export function startDaemonService(options = {}) {
|
|
319
416
|
const log = options.log ?? (() => { });
|
|
320
417
|
const intervalMs = options.intervalMs ?? SCAN_INTERVAL_MS;
|
|
418
|
+
const bodyTimeoutMs = options.bodyTimeoutMs ?? DASHBOARD_BODY_TIMEOUT_MS;
|
|
321
419
|
const env = options.env ?? process.env;
|
|
322
420
|
const token = randomBytes(24).toString("hex");
|
|
323
421
|
mkdirSync(daemonStateDir(env), { recursive: true, mode: 0o700 });
|
|
@@ -326,6 +424,11 @@ export function startDaemonService(options = {}) {
|
|
|
326
424
|
let scanInFlight;
|
|
327
425
|
let lastScanAt;
|
|
328
426
|
let lastScanError;
|
|
427
|
+
let mobileHost;
|
|
428
|
+
let mobileOutput = "";
|
|
429
|
+
let mobilePair;
|
|
430
|
+
let mobileExit;
|
|
431
|
+
const mobileProjectChoices = new Map();
|
|
329
432
|
const ready = deferred();
|
|
330
433
|
async function scan(force = false) {
|
|
331
434
|
if (scanInFlight) {
|
|
@@ -357,25 +460,46 @@ export function startDaemonService(options = {}) {
|
|
|
357
460
|
}
|
|
358
461
|
}
|
|
359
462
|
async function requireCurrentSession(name, body) {
|
|
360
|
-
if (!sessions.some(candidate => candidate.name === name))
|
|
361
|
-
throw new DashboardRequestError(404, "unknown session");
|
|
362
463
|
const generation = typeof body.generation === "string" ? body.generation : "";
|
|
363
464
|
if (!generation)
|
|
364
465
|
throw new DashboardRequestError(400, "generation is required");
|
|
365
466
|
let fresh;
|
|
366
467
|
try {
|
|
367
|
-
fresh = await listOmbSessions(process.cwd(), env, { includeStopped: true, requireMultiplexer: true });
|
|
468
|
+
fresh = await listOmbSessions(process.cwd(), env, { includeStopped: true, includeAllStopped: true, requireMultiplexer: true });
|
|
368
469
|
}
|
|
369
470
|
catch {
|
|
370
471
|
throw new DashboardRequestError(503, "session authority is unavailable");
|
|
371
472
|
}
|
|
372
|
-
const
|
|
373
|
-
if (
|
|
473
|
+
const matches = fresh.filter(candidate => candidate.name === name);
|
|
474
|
+
if (matches.length === 0)
|
|
475
|
+
throw new DashboardRequestError(404, "unknown session");
|
|
476
|
+
if (matches.length !== 1 || matches[0]?.generation !== generation)
|
|
374
477
|
throw new DashboardRequestError(409, "session changed externally");
|
|
478
|
+
const current = matches[0];
|
|
375
479
|
if (!current.authorityKnown)
|
|
376
480
|
throw new DashboardRequestError(503, "session authority is unavailable");
|
|
377
481
|
return current;
|
|
378
482
|
}
|
|
483
|
+
async function withUnclaimedSession(name, generation, action) {
|
|
484
|
+
let lease;
|
|
485
|
+
try {
|
|
486
|
+
lease = acquireMobileSessionLease(name, env);
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
throw new DashboardRequestError(409, "session ownership is changing");
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
const claims = listClaimsDetailed(env);
|
|
493
|
+
if (claims.unreadable.length > 0 || claims.claims.some(claim => claim.session === name)) {
|
|
494
|
+
throw new DashboardRequestError(409, "session is claimed for mobile ownership");
|
|
495
|
+
}
|
|
496
|
+
const current = await requireCurrentSession(name, { generation });
|
|
497
|
+
return await action(current);
|
|
498
|
+
}
|
|
499
|
+
finally {
|
|
500
|
+
lease.release();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
379
503
|
// Dashboard access codes: the bearer token must never land in browser
|
|
380
504
|
// history, so `omb dashboard` mints a code, the page trades that code for the
|
|
381
505
|
// token once, and the token is kept in sessionStorage only.
|
|
@@ -416,12 +540,39 @@ export function startDaemonService(options = {}) {
|
|
|
416
540
|
throw new DashboardRequestError(413, "request body is too large");
|
|
417
541
|
const chunks = [];
|
|
418
542
|
let size = 0;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
543
|
+
// Size alone is not a bound: a client can hold the connection open and
|
|
544
|
+
// trickle bytes forever. The dashboard session exchange is unauthenticated
|
|
545
|
+
// and reads the body before checking any token, so without a deadline a
|
|
546
|
+
// local caller can pin a socket indefinitely.
|
|
547
|
+
let deadline;
|
|
548
|
+
const expiry = new Promise((_, reject) => {
|
|
549
|
+
deadline = setTimeout(() => reject(new DashboardRequestError(408, "request body timed out")), bodyTimeoutMs);
|
|
550
|
+
deadline.unref?.();
|
|
551
|
+
});
|
|
552
|
+
try {
|
|
553
|
+
// Racing the read against the deadline is what bounds it; the timer alone
|
|
554
|
+
// only fires once the read loop is idle between chunks.
|
|
555
|
+
await Promise.race([
|
|
556
|
+
(async () => {
|
|
557
|
+
for await (const chunk of req) {
|
|
558
|
+
const buffer = Buffer.from(chunk);
|
|
559
|
+
size += buffer.length;
|
|
560
|
+
if (size > DASHBOARD_MAX_BODY_BYTES)
|
|
561
|
+
throw new DashboardRequestError(413, "request body is too large");
|
|
562
|
+
chunks.push(buffer);
|
|
563
|
+
}
|
|
564
|
+
})(),
|
|
565
|
+
expiry,
|
|
566
|
+
]);
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
// The socket is deliberately not destroyed here: the handler still needs
|
|
570
|
+
// to write the status the client is waiting for, and tearing the
|
|
571
|
+
// connection down first would surface as a bare "socket hang up".
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
finally {
|
|
575
|
+
clearTimeout(deadline);
|
|
425
576
|
}
|
|
426
577
|
if (!chunks.length)
|
|
427
578
|
return {};
|
|
@@ -491,6 +642,286 @@ export function startDaemonService(options = {}) {
|
|
|
491
642
|
res.end('{"error":"unauthorized"}\n');
|
|
492
643
|
return;
|
|
493
644
|
}
|
|
645
|
+
if (path === "/mobile/pair" && req.method === "POST") {
|
|
646
|
+
const reuseMobileHost = Boolean(mobileHost && !mobileExit && mobilePair);
|
|
647
|
+
if (!mobileHost || mobileHost.exitCode !== null || mobileHost.signalCode !== null) {
|
|
648
|
+
mobilePair = undefined;
|
|
649
|
+
mobileOutput = "";
|
|
650
|
+
mobileExit = undefined;
|
|
651
|
+
const entry = env.BIZAROMP_MOBILE_ENTRY || MOBILE_HOST_ENTRY;
|
|
652
|
+
mobileHost = spawn(process.execPath, [entry, "pair", "--json"], {
|
|
653
|
+
cwd: process.cwd(),
|
|
654
|
+
env: { ...env, BIZAR_OMB_DAEMON_PORT: String(service.port), BIZAR_OMB_DAEMON_TOKEN: token },
|
|
655
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
656
|
+
});
|
|
657
|
+
mobileHost.on("error", error => { mobileExit = { code: 1, signal: null }; log(`mobile host failed to start: ${error.message}`); });
|
|
658
|
+
mobileHost.stdout?.setEncoding("utf8");
|
|
659
|
+
mobileHost.stderr?.setEncoding("utf8");
|
|
660
|
+
mobileHost.stdout?.on("data", (chunk) => {
|
|
661
|
+
mobileOutput = (mobileOutput + String(chunk)).slice(-64 * 1024);
|
|
662
|
+
const start = Math.max(mobileOutput.lastIndexOf("{\n \"deepLink\""), mobileOutput.lastIndexOf("{\"deepLink\""));
|
|
663
|
+
if (start >= 0) {
|
|
664
|
+
try {
|
|
665
|
+
const prettyEnd = mobileOutput.indexOf("\n}", start);
|
|
666
|
+
const lineEnd = mobileOutput.indexOf("\n", start);
|
|
667
|
+
const end = prettyEnd >= 0 ? prettyEnd + 2 : lineEnd;
|
|
668
|
+
if (end >= 0)
|
|
669
|
+
mobilePair = JSON.parse(mobileOutput.slice(start, end));
|
|
670
|
+
}
|
|
671
|
+
catch { /* wait for complete JSON */ }
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
mobileHost.stderr?.on("data", (chunk) => { log(`mobile host: ${String(chunk).trim().slice(0, 1000)}`); });
|
|
675
|
+
mobileHost.on("exit", (code, signal) => { mobileExit = { code, signal }; });
|
|
676
|
+
}
|
|
677
|
+
else if (reuseMobileHost) {
|
|
678
|
+
// Every explicit `omb mobile pair` gets a fresh one-time token. The
|
|
679
|
+
// host renews only its pairing window over this private pipe; restarting
|
|
680
|
+
// it here would tear down authenticated sockets and re-provision a
|
|
681
|
+
// Quick Tunnel with a different public hostname.
|
|
682
|
+
mobilePair = undefined;
|
|
683
|
+
mobileOutput = "";
|
|
684
|
+
if (!mobileHost.stdin?.write("pair\n")) {
|
|
685
|
+
json(res, 503, { error: "mobile host could not renew its pairing window" });
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const deadline = Date.now() + 60_000;
|
|
690
|
+
while (!mobilePair && !mobileExit && Date.now() < deadline)
|
|
691
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
692
|
+
if (!mobilePair) {
|
|
693
|
+
json(res, 503, { error: mobileExit ? `mobile host exited (${mobileExit.code ?? mobileExit.signal})` : "mobile pairing startup timed out" });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
json(res, 200, mobilePair);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (path === "/mobile/status" && req.method === "GET") {
|
|
700
|
+
json(res, 200, {
|
|
701
|
+
running: Boolean(mobileHost && !mobileExit),
|
|
702
|
+
pid: mobileHost?.pid ?? null,
|
|
703
|
+
hostId: typeof mobilePair?.hostId === "string" ? mobilePair.hostId : null,
|
|
704
|
+
endpoint: typeof mobilePair?.endpoint === "string" ? mobilePair.endpoint : null,
|
|
705
|
+
hostKeyFingerprint: typeof mobilePair?.hostKeyFingerprint === "string" ? mobilePair.hostKeyFingerprint : null,
|
|
706
|
+
pairingExpiresAt: typeof mobilePair?.expiresAt === "number" ? mobilePair.expiresAt : null,
|
|
707
|
+
});
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (path === "/mobile/projects" && req.method === "GET") {
|
|
711
|
+
let knownSessions;
|
|
712
|
+
try {
|
|
713
|
+
knownSessions = await listOmbSessions(process.cwd(), env, { includeStopped: true, includeAllStopped: true, requireMultiplexer: true });
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
json(res, 503, { authorityKnown: false, reason: "session authority is unavailable", projects: [] });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const settings = loadOmbSettings(env);
|
|
720
|
+
const discovered = await discoverMobileProjectDirectories(process.cwd(), settings.projectsFolder, knownSessions);
|
|
721
|
+
const now = Date.now();
|
|
722
|
+
for (const [id, choice] of mobileProjectChoices)
|
|
723
|
+
if (choice.expiresAt <= now)
|
|
724
|
+
mobileProjectChoices.delete(id);
|
|
725
|
+
const projects = discovered.map(project => {
|
|
726
|
+
while (mobileProjectChoices.size >= MOBILE_PROJECT_CHOICES_LIMIT) {
|
|
727
|
+
const oldest = mobileProjectChoices.keys().next().value;
|
|
728
|
+
if (oldest === undefined)
|
|
729
|
+
break;
|
|
730
|
+
mobileProjectChoices.delete(oldest);
|
|
731
|
+
}
|
|
732
|
+
const id = randomBytes(24).toString("base64url");
|
|
733
|
+
mobileProjectChoices.set(id, { ...project, expiresAt: now + MOBILE_PROJECT_ID_TTL_MS });
|
|
734
|
+
return { id, label: project.label, current: project.current };
|
|
735
|
+
});
|
|
736
|
+
json(res, 200, { authorityKnown: true, projectsFolderConfigured: Boolean(settings.projectsFolder.trim()), projects });
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (path === "/mobile/settings" && req.method === "GET") {
|
|
740
|
+
json(res, 200, mobileHubSettings(loadOmbSettings(env)));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (path === "/mobile/settings" && req.method === "PATCH") {
|
|
744
|
+
const body = await readBody(req);
|
|
745
|
+
const version = body.version;
|
|
746
|
+
const allowedKeys = new Set(["version", "agentsScope", "sessionFilter", "autoAttachLatest", "mouseScrolling", "showStoppedSessions", "trayEnabled"]);
|
|
747
|
+
const keys = Object.keys(body);
|
|
748
|
+
if (keys.some(key => !allowedKeys.has(key)) || typeof version !== "number" || !Number.isSafeInteger(version) || version < 0 || keys.length < 2) {
|
|
749
|
+
json(res, 400, { error: "invalid OMB Hub settings update" });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const previous = loadOmbSettings(env);
|
|
753
|
+
const next = { ...previous };
|
|
754
|
+
if (body.agentsScope !== undefined) {
|
|
755
|
+
if (body.agentsScope !== "cwd" && body.agentsScope !== "all") {
|
|
756
|
+
json(res, 400, { error: "agentsScope is invalid" });
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
next.agentsScope = body.agentsScope;
|
|
760
|
+
}
|
|
761
|
+
if (body.sessionFilter !== undefined) {
|
|
762
|
+
if (body.sessionFilter !== "active" && body.sessionFilter !== "stopped" && body.sessionFilter !== "all") {
|
|
763
|
+
json(res, 400, { error: "sessionFilter is invalid" });
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
next.sessionFilter = body.sessionFilter;
|
|
767
|
+
}
|
|
768
|
+
for (const key of ["autoAttachLatest", "mouseScrolling", "showStoppedSessions", "trayEnabled"]) {
|
|
769
|
+
if (body[key] !== undefined && typeof body[key] !== "boolean") {
|
|
770
|
+
json(res, 400, { error: `${key} must be a boolean` });
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (typeof body[key] === "boolean")
|
|
774
|
+
next[key] = body[key];
|
|
775
|
+
}
|
|
776
|
+
if (previous.version !== version) {
|
|
777
|
+
json(res, 409, { error: "settings changed externally", settings: mobileHubSettings(previous) });
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
let saved;
|
|
781
|
+
try {
|
|
782
|
+
saved = saveOmbSettings(next, env);
|
|
783
|
+
}
|
|
784
|
+
catch (error) {
|
|
785
|
+
if (error instanceof Error && error.message === "settings changed externally") {
|
|
786
|
+
json(res, 409, { error: "settings changed externally", settings: mobileHubSettings(loadOmbSettings(env)) });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
throw error;
|
|
790
|
+
}
|
|
791
|
+
if (previous.mouseScrolling !== next.mouseScrolling) {
|
|
792
|
+
await Promise.all(sessions.filter(session => session.hosted !== false && session.state !== "stopped")
|
|
793
|
+
.map(session => setOmbSessionMouse(session.name, next.mouseScrolling, session.cwd === "?" ? process.cwd() : session.cwd, env)));
|
|
794
|
+
}
|
|
795
|
+
if (previous.trayEnabled !== next.trayEnabled) {
|
|
796
|
+
if (next.trayEnabled)
|
|
797
|
+
await startTray(env);
|
|
798
|
+
else
|
|
799
|
+
await stopTray(env);
|
|
800
|
+
}
|
|
801
|
+
await scan(true);
|
|
802
|
+
json(res, 200, { ok: true, settings: mobileHubSettings(saved) });
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (path === "/mobile/sessions/create" && req.method === "POST") {
|
|
806
|
+
const body = await readBody(req);
|
|
807
|
+
const projectId = typeof body.projectId === "string" ? body.projectId : "";
|
|
808
|
+
const task = typeof body.task === "string" ? body.task.trim() : "";
|
|
809
|
+
const requestedId = typeof body.sessionId === "string" ? body.sessionId.trim() : "";
|
|
810
|
+
if (!/^[A-Za-z0-9_-]{32}$/.test(projectId) || ("cwd" in body) || task.length > 20_000 || (body.sessionId !== undefined && (typeof body.sessionId !== "string" || requestedId.length > 120 || !/^[A-Za-z0-9 _.-]*$/.test(requestedId)))) {
|
|
811
|
+
json(res, 400, { error: "invalid OMB session creation request" });
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const choice = mobileProjectChoices.get(projectId);
|
|
815
|
+
mobileProjectChoices.delete(projectId);
|
|
816
|
+
if (!choice || choice.expiresAt <= Date.now()) {
|
|
817
|
+
json(res, 409, { error: "project choice expired; refresh the project list" });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
let currentCwd;
|
|
821
|
+
try {
|
|
822
|
+
currentCwd = realpathSync(choice.cwd);
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
json(res, 409, { error: "selected project is no longer available" });
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (currentCwd !== choice.cwd || !isDirectory(currentCwd)) {
|
|
829
|
+
json(res, 409, { error: "selected project changed; refresh the project list" });
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const sessionId = requestedId || `mobile-${Date.now().toString(36)}-${randomBytes(5).toString("hex")}`;
|
|
833
|
+
const sessionName = ombSessionName(currentCwd, sessionId, env.PI_PROFILE);
|
|
834
|
+
let fresh;
|
|
835
|
+
try {
|
|
836
|
+
fresh = await listOmbSessions(process.cwd(), env, { includeStopped: true, includeAllStopped: true, requireMultiplexer: true });
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
json(res, 503, { error: "session authority is unavailable" });
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (fresh.some(session => session.name === sessionName)) {
|
|
843
|
+
json(res, 409, { error: "a session with that name already exists" });
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const settings = loadOmbSettings(env);
|
|
847
|
+
const args = [];
|
|
848
|
+
if (settings.defaultModel)
|
|
849
|
+
args.push("--model", settings.defaultModel);
|
|
850
|
+
if (settings.defaultThinking)
|
|
851
|
+
args.push("--thinking", settings.defaultThinking);
|
|
852
|
+
if (settings.defaultApprovalMode)
|
|
853
|
+
args.push("--approval-mode", settings.defaultApprovalMode);
|
|
854
|
+
if (task)
|
|
855
|
+
args.push(task);
|
|
856
|
+
const created = await createOmbSession(currentCwd, args, env, sessionId);
|
|
857
|
+
if (created.code !== 0) {
|
|
858
|
+
json(res, 409, { error: "OMP could not start the OMB session" });
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
await scan(true);
|
|
862
|
+
json(res, 200, { ok: true });
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (path === "/mobile/stop" && req.method === "POST") {
|
|
866
|
+
if (mobileHost && !mobileExit)
|
|
867
|
+
mobileHost.kill("SIGTERM");
|
|
868
|
+
json(res, 200, { ok: true });
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (path === "/mobile/sessions/rename" && req.method === "POST") {
|
|
872
|
+
const body = await readBody(req);
|
|
873
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
874
|
+
const generation = typeof body.generation === "string" ? body.generation : "";
|
|
875
|
+
const newId = typeof body.newId === "string" ? body.newId.trim() : "";
|
|
876
|
+
if (!/^[A-Za-z0-9-]{1,64}$/.test(name) || !/^[a-f0-9]{32}$/.test(generation) || !newId || newId.length > 120 || /[\\/\u0000-\u001f\u007f]/.test(newId)) {
|
|
877
|
+
json(res, 400, { error: "invalid OMB session identity or label" });
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
const outcome = await withUnclaimedSession(name, generation, async (current) => {
|
|
881
|
+
if (!current.hosted || current.state === "unknown" || current.uncertainLiveness) {
|
|
882
|
+
throw new DashboardRequestError(409, "session cannot be renamed in its current state");
|
|
883
|
+
}
|
|
884
|
+
const projectCwd = current.cwd === "?" ? process.cwd() : current.cwd;
|
|
885
|
+
const nextName = ombSessionName(projectCwd, newId, env.PI_PROFILE);
|
|
886
|
+
const code = await renameOmbSession(name, nextName, projectCwd, env);
|
|
887
|
+
if (code !== 0)
|
|
888
|
+
throw new DashboardRequestError(409, "session could not be renamed");
|
|
889
|
+
return { ok: true };
|
|
890
|
+
});
|
|
891
|
+
await scan(true);
|
|
892
|
+
json(res, 200, outcome);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (path === "/mobile/sessions/delete" && req.method === "POST") {
|
|
896
|
+
const body = await readBody(req);
|
|
897
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
898
|
+
const generation = typeof body.generation === "string" ? body.generation : "";
|
|
899
|
+
if (!/^[A-Za-z0-9-]{1,64}$/.test(name) || !/^[a-f0-9]{32}$/.test(generation)) {
|
|
900
|
+
json(res, 400, { error: "invalid OMB session identity" });
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const outcome = await withUnclaimedSession(name, generation, async (current) => {
|
|
904
|
+
// Mobile removal is destructive. It is only safe for a fresh,
|
|
905
|
+
// host-owned row whose multiplexer and process observations prove that
|
|
906
|
+
// the exact generation is stopped and unattached. In particular,
|
|
907
|
+
// hosted rows must not be treated as removable merely because their
|
|
908
|
+
// liveness is not uncertain: an active hosted OMP session can still be
|
|
909
|
+
// the one writer for a native session.
|
|
910
|
+
if (!isSafeMobileSessionRemoval(current)) {
|
|
911
|
+
throw new DashboardRequestError(409, "session liveness is not safe to remove");
|
|
912
|
+
}
|
|
913
|
+
const projectCwd = current.cwd === "?" ? process.cwd() : current.cwd;
|
|
914
|
+
const code = current.hosted
|
|
915
|
+
? await deleteOmbSession(name, projectCwd, env)
|
|
916
|
+
: forgetOmbSession(name, env);
|
|
917
|
+
if (code !== 0)
|
|
918
|
+
throw new DashboardRequestError(409, "session removal could not be confirmed");
|
|
919
|
+
return { ok: true };
|
|
920
|
+
});
|
|
921
|
+
await scan(true);
|
|
922
|
+
json(res, 200, outcome);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
494
925
|
if (path === "/dash/code" && req.method === "POST") {
|
|
495
926
|
json(res, 200, { code: mintDashCode(), path: DASHBOARD_PATH, port: service.port, expiresInMs: DASH_CODE_TTL_MS });
|
|
496
927
|
return;
|
|
@@ -905,7 +1336,8 @@ export function startDaemonService(options = {}) {
|
|
|
905
1336
|
const name = decodeURIComponent(deleteMatch[1]);
|
|
906
1337
|
const body = await readBody(req);
|
|
907
1338
|
const current = await requireCurrentSession(name, body);
|
|
908
|
-
const
|
|
1339
|
+
const projectCwd = current.cwd === "?" ? process.cwd() : current.cwd;
|
|
1340
|
+
const code = current.hosted === false ? forgetOmbSession(name, env) : await deleteOmbSession(name, projectCwd, env);
|
|
909
1341
|
await scan(true);
|
|
910
1342
|
json(res, code === 0 ? 200 : 500, { code });
|
|
911
1343
|
return;
|
|
@@ -936,6 +1368,12 @@ export function startDaemonService(options = {}) {
|
|
|
936
1368
|
close: async () => {
|
|
937
1369
|
if (scanTimer)
|
|
938
1370
|
clearInterval(scanTimer);
|
|
1371
|
+
if (mobileHost && !mobileExit) {
|
|
1372
|
+
mobileHost.kill("SIGTERM");
|
|
1373
|
+
await Promise.race([new Promise(resolve => mobileHost?.once("exit", () => resolve())), new Promise(resolve => setTimeout(resolve, 5_000))]);
|
|
1374
|
+
if (!mobileExit)
|
|
1375
|
+
mobileHost.kill("SIGKILL");
|
|
1376
|
+
}
|
|
939
1377
|
// Run every SSE cleanup so poll/keepalive timers drop the response
|
|
940
1378
|
// reference immediately — otherwise the timers themselves keep the
|
|
941
1379
|
// socket (and therefore server.close()) alive past shutdown.
|