conductor-remote 1.131.4 → 1.131.5
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/dist/assets/{AutoModelSettings-SBdRr_mv.js → AutoModelSettings-CFlOKb1W.js} +1 -1
- package/dist/assets/{PierrePatch-BmY2b7Ei.js → PierrePatch-BExA4mpZ.js} +1 -1
- package/dist/assets/{index-BAEVNqQy.js → index-D7ZV7qS_.js} +4 -4
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/dist-node/scripts/service/commands.js +2 -2
- package/dist-node/scripts/service/flags.js +8 -0
- package/dist-node/scripts/service/installation.js +53 -3
- package/dist-node/scripts/service/launch-agent.js +14 -0
- package/dist-node/scripts/service.js +1 -1
- package/dist-node/src/host/ports.js +82 -0
- package/dist-node/src/server.js +16 -0
- package/package.json +1 -1
package/dist/index.html
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
<title>Conductor Remote</title>
|
|
26
26
|
<!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
|
|
27
27
|
<script src="/self-heal.js"></script>
|
|
28
|
-
<script type="module" crossorigin src="/assets/index-
|
|
28
|
+
<script type="module" crossorigin src="/assets/index-D7ZV7qS_.js"></script>
|
|
29
29
|
<link rel="stylesheet" crossorigin href="/assets/index-Dbu5DOed.css">
|
|
30
30
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
|
31
31
|
<body>
|
package/dist/sw.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const t=e=>i(e,l),a={module:{uri:l},exports:o,require:t};s[l]=Promise.all(n.map(e=>a[e]||t(e))).then(e=>(r(...e),o))}}define(["./workbox-dcde9eb3"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"4e0b714c0ea53e85c167e6d8f5b767aa"},{url:"index.html",revision:"
|
|
1
|
+
if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const t=e=>i(e,l),a={module:{uri:l},exports:o,require:t};s[l]=Promise.all(n.map(e=>a[e]||t(e))).then(e=>(r(...e),o))}}define(["./workbox-dcde9eb3"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"4e0b714c0ea53e85c167e6d8f5b767aa"},{url:"index.html",revision:"2f9082c33526aefc0ab3185c27066c56"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/pierre-shiki-wasm-C8ETeZem.js",revision:null},{url:"assets/pierre-light-480U9XYS.js",revision:null},{url:"assets/pierre-dark-CyvmCCZW.js",revision:null},{url:"assets/index-Dbu5DOed.css",revision:null},{url:"assets/index-D7ZV7qS_.js",revision:null},{url:"assets/PierrePatch-BExA4mpZ.js",revision:null},{url:"assets/AutoModelSettings-CFlOKb1W.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]})),e.registerRoute(({url:e})=>e.pathname.startsWith("/assets/diff-syntax/"),new e.CacheFirst({cacheName:"diff-syntax",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:31536e3})]}),"GET")});
|
|
@@ -2,11 +2,11 @@ import { config } from "./configuration.js";
|
|
|
2
2
|
import { FLAG_ENV } from "./flags.js";
|
|
3
3
|
import { install, restart, uninstall } from "./installation.js";
|
|
4
4
|
import { logs, status } from "./presentation.js";
|
|
5
|
-
export function runServiceCommand() {
|
|
5
|
+
export async function runServiceCommand() {
|
|
6
6
|
const cmd = process.argv[2] ?? 'status';
|
|
7
7
|
switch (cmd) {
|
|
8
8
|
case 'install':
|
|
9
|
-
install();
|
|
9
|
+
await install();
|
|
10
10
|
break;
|
|
11
11
|
case 'uninstall':
|
|
12
12
|
uninstall();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parsePortValue } from "../../src/host/ports.js";
|
|
1
2
|
/**
|
|
2
3
|
* Install-time knobs are accepted as documented CLI flags OR the matching env var — a flag wins over the
|
|
3
4
|
* ambient env. Parsed flags are folded back into process.env so everything downstream (and the plist we
|
|
@@ -40,6 +41,13 @@ export function applyFlags(argv) {
|
|
|
40
41
|
console.error(`flag ${name} must be on or off`);
|
|
41
42
|
process.exit(1);
|
|
42
43
|
}
|
|
44
|
+
// Named here as well as in the install preflight because a typo is worth catching against the
|
|
45
|
+
// argument that carries it. The preflight still runs: an ambient RELAY_PORT reaches install
|
|
46
|
+
// without passing through any flag, and only the preflight sees both ports at once.
|
|
47
|
+
if ((name === '--port' || name === '--voice-port') && parsePortValue(value) === null) {
|
|
48
|
+
console.error(`flag ${name} must be a whole number from 1 to 65535, not "${value}"`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
43
51
|
process.env[envKey] = value;
|
|
44
52
|
}
|
|
45
53
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { bindFailureLines, listenersOn, parsePortValue, probeListen } from "../../src/host/ports.js";
|
|
3
4
|
import { exposeStorePath } from "../../src/host/tailscale.js";
|
|
4
5
|
import { readVoiceConfig } from "../../src/voice/config.js";
|
|
5
|
-
import { distBuilt, domain, LABEL, logDir, persistPinnedToken, plistPath, projectDir, readPlistEnv } from "./environment.js";
|
|
6
|
-
import { buildPlist, launchctl, reloadAgent } from "./launch-agent.js";
|
|
6
|
+
import { distBuilt, domain, LABEL, logDir, persistPinnedToken, plistPath, projectDir, RELAY_PORT, readPlistEnv, VOICE_PORT } from "./environment.js";
|
|
7
|
+
import { agentPid, buildPlist, launchctl, reloadAgent } from "./launch-agent.js";
|
|
7
8
|
import { ensureTailscale, resolveExposeMode } from "./network.js";
|
|
8
9
|
import { printUrl, printVoiceRoute } from "./presentation.js";
|
|
9
10
|
import { ensureVoiceFunnel } from "./voice.js";
|
|
@@ -11,7 +12,55 @@ import { ensureVoiceFunnel } from "./voice.js";
|
|
|
11
12
|
function isEphemeralInstall(dir) {
|
|
12
13
|
return /[\\/]_npx[\\/]|[\\/]\.npm[\\/]_npx[\\/]/.test(dir);
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Refuse a port the relay cannot have, before the plist is written and launchd is reloaded.
|
|
17
|
+
*
|
|
18
|
+
* The alternative is the daemon's own crash: `server.listen` reaches launchd as an unhandled error
|
|
19
|
+
* event, KeepAlive respawns it, and the loop's only trace is a stack trace in relay.err.log — while
|
|
20
|
+
* install itself prints a success line, a phone URL and a QR code for a relay that is not running.
|
|
21
|
+
* A port our own daemon holds is not a conflict; a re-install boots it out moments later.
|
|
22
|
+
*/
|
|
23
|
+
async function assertPortFree(what, host, port, setting) {
|
|
24
|
+
const code = await probeListen(host, port);
|
|
25
|
+
if (code === null)
|
|
26
|
+
return;
|
|
27
|
+
if (code === 'EADDRINUSE') {
|
|
28
|
+
const ours = agentPid();
|
|
29
|
+
const holders = listenersOn(port);
|
|
30
|
+
if (ours !== null && holders.length > 0 && holders.every(holder => holder.pid === ours))
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
for (const line of bindFailureLines(what, host, port, code, setting))
|
|
34
|
+
console.error(line);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
/** Both ports, resolved from flags or the ambient environment, checked together and against the machine. */
|
|
38
|
+
async function assertPortsUsable() {
|
|
39
|
+
const relay = parsePortValue(RELAY_PORT);
|
|
40
|
+
if (relay === null) {
|
|
41
|
+
console.error(`✗ the relay port must be a whole number from 1 to 65535, not "${RELAY_PORT}".`);
|
|
42
|
+
console.error(' Set it with `--port <n>` (or `conductor-remote config set port <n>`).');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
const voice = parsePortValue(VOICE_PORT);
|
|
46
|
+
if (voice === null) {
|
|
47
|
+
console.error(`✗ the voice port must be a whole number from 1 to 65535, not "${VOICE_PORT}".`);
|
|
48
|
+
console.error(' Set it with `--voice-port <n>` (or `conductor-remote config set voice-port <n>`).');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
// The two servers live in one process, so equal ports are a collision the relay finds at boot rather
|
|
52
|
+
// than a configuration the machine can report as busy. 8788 is the voice default and a habitual
|
|
53
|
+
// second relay port, which is how this pair meets by accident.
|
|
54
|
+
if (relay === voice) {
|
|
55
|
+
console.error(`✗ the relay and the voice listener would both bind :${relay}.`);
|
|
56
|
+
console.error(' They are two servers in one process; give them different ports.');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
await assertPortFree('the relay', process.env.RELAY_HOST ?? '127.0.0.1', relay, 'port');
|
|
60
|
+
// The voice listener is loopback-only by construction (src/server.ts); Funnel is what publishes it.
|
|
61
|
+
await assertPortFree('the voice listener', '127.0.0.1', voice, 'voice-port');
|
|
62
|
+
}
|
|
63
|
+
export async function install() {
|
|
15
64
|
if (isEphemeralInstall(projectDir)) {
|
|
16
65
|
console.error(`✗ refusing to install from an npx cache path:\n ${projectDir}\n` +
|
|
17
66
|
' That directory is temporary and gets purged, which would break the LaunchAgent.\n' +
|
|
@@ -22,6 +71,7 @@ export function install() {
|
|
|
22
71
|
console.error('✗ dist/ not built. Run `yarn build` first (or use `yarn deploy`, which builds).');
|
|
23
72
|
process.exit(1);
|
|
24
73
|
}
|
|
74
|
+
await assertPortsUsable();
|
|
25
75
|
persistPinnedToken();
|
|
26
76
|
// Resolve (and persist) the expose posture *before* the plist is written and launchd is reloaded, so
|
|
27
77
|
// the daemon starts with an explicit EXPOSE in its own environment rather than racing the file
|
|
@@ -27,6 +27,20 @@ function serviceLoaded() {
|
|
|
27
27
|
return false;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* The pid of the running LaunchAgent, or null when it is not loaded or not running. Used by the install
|
|
32
|
+
* preflight to tell "the port is taken" from "the port is taken by the very relay we are about to replace".
|
|
33
|
+
*/
|
|
34
|
+
export function agentPid() {
|
|
35
|
+
try {
|
|
36
|
+
const out = execFileSync('launchctl', ['print', `${domain}/${LABEL}`], { encoding: 'utf8', stdio: 'pipe' });
|
|
37
|
+
const pid = Number(out.match(/pid = (\d+)/)?.[1]);
|
|
38
|
+
return Number.isInteger(pid) ? pid : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
30
44
|
/**
|
|
31
45
|
* Reload the agent from the freshly written plist. `bootout` of a *running* instance is asynchronous,
|
|
32
46
|
* so we wait for it to fully unload before `bootstrap` — otherwise bootstrap races the teardown and
|
|
@@ -14,4 +14,4 @@ if ((process.argv[2] ?? 'status') !== 'logs')
|
|
|
14
14
|
applyFlags(process.argv.slice(3));
|
|
15
15
|
// Import only after parsing flags: environment.ts freezes the relay/voice ports.
|
|
16
16
|
const { runServiceCommand } = await import("./service/commands.js");
|
|
17
|
-
runServiceCommand();
|
|
17
|
+
await runServiceCommand();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
/**
|
|
4
|
+
* Port knobs arrive as free text — a `--port` flag, a plist entry, an ambient shell variable — and every
|
|
5
|
+
* one of them is handed straight to `server.listen`, which rejects a non-number with a bare
|
|
6
|
+
* ERR_SOCKET_BAD_PORT inside the daemon. Reject it here instead, where a person is reading the output.
|
|
7
|
+
*/
|
|
8
|
+
export function parsePortValue(raw) {
|
|
9
|
+
if (raw === undefined || raw === null)
|
|
10
|
+
return null;
|
|
11
|
+
const text = raw.trim();
|
|
12
|
+
if (!/^\d+$/.test(text))
|
|
13
|
+
return null;
|
|
14
|
+
const port = Number(text);
|
|
15
|
+
return port >= 1 && port <= 65535 ? port : null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parse `lsof -Fpcn` field output into one entry per listening process. The field form is used rather
|
|
19
|
+
* than the default table because lsof's COMMAND column carries spaces ("Google Chrome H"), so a column
|
|
20
|
+
* split names the wrong holder. Process fields are printed once per process, ahead of its file blocks,
|
|
21
|
+
* which is what keeps a process holding several matching descriptors from being reported several times.
|
|
22
|
+
*/
|
|
23
|
+
export function parseLsofListeners(out) {
|
|
24
|
+
const holders = [];
|
|
25
|
+
let pid = null;
|
|
26
|
+
for (const line of out.split('\n')) {
|
|
27
|
+
if (line.startsWith('p')) {
|
|
28
|
+
const parsed = Number(line.slice(1));
|
|
29
|
+
pid = Number.isInteger(parsed) ? parsed : null;
|
|
30
|
+
}
|
|
31
|
+
else if (line.startsWith('c') && pid !== null) {
|
|
32
|
+
holders.push({ command: line.slice(1), pid });
|
|
33
|
+
pid = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return holders;
|
|
37
|
+
}
|
|
38
|
+
/** Who is listening on `port`, best-effort — this only ever decorates a message that has already failed. */
|
|
39
|
+
export function listenersOn(port) {
|
|
40
|
+
try {
|
|
41
|
+
const out = execFileSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpcn'], {
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
stdio: 'pipe'
|
|
44
|
+
});
|
|
45
|
+
return parseLsofListeners(out);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// lsof exits non-zero when nothing matches, and it may not be installed at all.
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Can this process bind `host:port`? Answered by binding it, because that is the only test that agrees
|
|
54
|
+
* with the real listen in every case that matters: a wildcard listener blocks a loopback bind, a
|
|
55
|
+
* privileged port is refused rather than busy, and a host the machine does not hold fails on its own.
|
|
56
|
+
* Resolves the error code, or null when the port is free. The socket never accepts, so it leaves no
|
|
57
|
+
* TIME_WAIT behind and the port is free again the moment this resolves.
|
|
58
|
+
*/
|
|
59
|
+
export function probeListen(host, port) {
|
|
60
|
+
return new Promise(resolve => {
|
|
61
|
+
const probe = net.createServer();
|
|
62
|
+
probe.once('error', (err) => resolve(err.code ?? 'EUNKNOWN'));
|
|
63
|
+
probe.listen(port, host, () => probe.close(() => resolve(null)));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export function describeHolders(holders) {
|
|
67
|
+
if (!holders.length)
|
|
68
|
+
return 'an unidentified process';
|
|
69
|
+
return holders.map(holder => `${holder.command} (pid ${holder.pid})`).join(', ');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The two lines a failed bind prints, shared by the install preflight and the running relay so both name
|
|
73
|
+
* the same port and the same knob. `config set` is the hint in both because it is the one lever that
|
|
74
|
+
* works against an installed daemon, which is where this failure is otherwise invisible.
|
|
75
|
+
*/
|
|
76
|
+
export function bindFailureLines(what, host, port, code, setting) {
|
|
77
|
+
const cause = code === 'EADDRINUSE' ? `held by ${describeHolders(listenersOn(port))}` : (code ?? 'unknown error');
|
|
78
|
+
return [
|
|
79
|
+
`✗ ${what} cannot bind ${host}:${port} — ${cause}.`,
|
|
80
|
+
` Free that port, or move it: conductor-remote config set ${setting} <port>`
|
|
81
|
+
];
|
|
82
|
+
}
|
package/dist-node/src/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { startAutoUpdate } from "./host/autoupdate.js";
|
|
2
2
|
import { startFunnelWatchdog } from "./host/funnel-watchdog.js";
|
|
3
3
|
import { watchNoSleepExpiry } from "./host/nosleep.js";
|
|
4
|
+
import { bindFailureLines } from "./host/ports.js";
|
|
4
5
|
import { driftWarningLines, tailscaleBin } from "./host/tailscale.js";
|
|
5
6
|
import { createRelayServer } from "./http/router.js";
|
|
6
7
|
import { createRelayServices } from "./http/services.js";
|
|
@@ -9,6 +10,21 @@ import { voicePort } from "./voice/config.js";
|
|
|
9
10
|
const services = createRelayServices();
|
|
10
11
|
const { cfg, voiceServer, voiceBroker, voiceHistory, orchestration, relayIdentity, recoverUiLease, wakeWorkflows, actuator, firstPrompts, autoModels, sweepStagedAttachments, STAGED_ATTACHMENT_SWEEP_MS, parkedPrompts, delegationQueue, liveDelegationStores, devServers, reads, sessionPoller } = services;
|
|
11
12
|
const server = createRelayServer(services);
|
|
13
|
+
// A `listen` failure arrives as an 'error' event, and with nobody listening for it the process throws.
|
|
14
|
+
// Under launchd that is a KeepAlive restart loop whose only trace is a stack trace in relay.err.log with
|
|
15
|
+
// the port nowhere in it, while `service status` says only "loaded but not running". Name the port.
|
|
16
|
+
server.on('error', (err) => {
|
|
17
|
+
for (const line of bindFailureLines('the relay', cfg.host, cfg.port, err.code ?? null, 'port'))
|
|
18
|
+
console.error(line);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
|
21
|
+
// The voice listener is opt-in and the control panel is the product, so its own bind failure is loud
|
|
22
|
+
// rather than fatal: taking the phone's relay down over an unconfigured second server is the worse trade.
|
|
23
|
+
// `service status` reports the voice route separately, so a listener that never came up still shows there.
|
|
24
|
+
voiceServer.on('error', (err) => {
|
|
25
|
+
for (const line of bindFailureLines('the voice listener', '127.0.0.1', voicePort(), err.code ?? null, 'voice-port'))
|
|
26
|
+
console.error(line);
|
|
27
|
+
});
|
|
12
28
|
server.listen(cfg.port, cfg.host, () => {
|
|
13
29
|
voiceServer.listen(voicePort(), '127.0.0.1', () => {
|
|
14
30
|
console.info(` voice: 127.0.0.1:${voicePort()}${voiceBroker ? '' : ' (waiting for OpenAI config)'}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conductor-remote",
|
|
3
|
-
"version": "1.131.
|
|
3
|
+
"version": "1.131.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "yarn@4.15.0",
|
|
6
6
|
"description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
|