conductor-remote 1.99.0 → 1.101.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 +11 -0
- package/bin/cli.js +1 -0
- package/dist/assets/index-AS43kfu8.css +1 -0
- package/dist/assets/index-DH64usTv.js +52 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/scripts/service.js +202 -9
- package/dist-node/src/logbuf.js +4 -1
- package/dist-node/src/notify.js +4 -11
- package/dist-node/src/reads.js +29 -1
- package/dist-node/src/routes.js +8 -0
- package/dist-node/src/run-activity.js +103 -0
- package/dist-node/src/server.js +160 -1
- package/dist-node/src/shared.js +26 -0
- package/dist-node/src/speech.js +68 -0
- package/dist-node/src/voice/brief.js +260 -0
- package/dist-node/src/voice/broker.js +447 -0
- package/dist-node/src/voice/config.js +171 -0
- package/dist-node/src/voice/funnel.js +75 -0
- package/dist-node/src/voice/gateway.js +86 -0
- package/dist-node/src/voice/preview.js +67 -0
- package/dist-node/src/voice/prompt.js +12 -0
- package/dist-node/src/voice/server.js +137 -0
- package/dist-node/src/voice/ticket.js +23 -0
- package/dist-node/src/voice/tools.js +198 -0
- package/dist-node/src/voice/twiml.js +124 -0
- package/dist-node/src/voice/webhook.js +113 -0
- package/dist-node/src/voice/webrtc.js +107 -0
- package/docs/voice-setup.md +223 -0
- package/package.json +3 -2
- package/dist/assets/index-0UqQA-X1.css +0 -1
- package/dist/assets/index-CatXxUrf.js +0 -52
package/dist/index.html
CHANGED
|
@@ -25,8 +25,8 @@
|
|
|
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-
|
|
29
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
28
|
+
<script type="module" crossorigin src="/assets/index-DH64usTv.js"></script>
|
|
29
|
+
<link rel="stylesheet" crossorigin href="/assets/index-AS43kfu8.css">
|
|
30
30
|
<link rel="manifest" href="/manifest.webmanifest"></head>
|
|
31
31
|
<body>
|
|
32
32
|
<div id="root"></div>
|
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 o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let
|
|
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 o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let c={};const l=e=>i(e,o),t={module:{uri:o},exports:c,require:l};s[o]=Promise.all(n.map(e=>t[e]||l(e))).then(e=>(r(...e),c))}}define(["./workbox-9c191d2f"],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:"e7ef44deca46c0539e6ff7bba5eb815e"},{url:"index.html",revision:"ffb0fcb42cc33dd8b3b4fd1d62d5c8de"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DH64usTv.js",revision:null},{url:"assets/index-AS43kfu8.css",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\//]}))});
|
|
@@ -15,6 +15,8 @@ import path from 'node:path';
|
|
|
15
15
|
import { installedServiceEnvironment, serviceEnvironmentWithSetting } from "../src/config.js";
|
|
16
16
|
import { packageRoot } from "../src/pkg-root.js";
|
|
17
17
|
import { driftWarningLines, exposeStorePath, FUNNEL_PORTS, freeServePort, magicDnsName, normalizeExposeMode, parseServeStatus, relayPort, relayServeState, serveUrl, tailscaleBin, writeUrlHost } from "../src/tailscale.js";
|
|
18
|
+
import { readVoiceConfig, setVoiceSetting, VOICE_SETTING_NAMES } from "../src/voice/config.js";
|
|
19
|
+
import { inspectVoiceFunnel, readVoiceFunnelReceipt, voiceFunnelReceiptPath, writeVoiceFunnelReceipt } from "../src/voice/funnel.js";
|
|
18
20
|
import { qrLines } from "./qr.js";
|
|
19
21
|
const LABEL = 'no.adluna.conductor-remote';
|
|
20
22
|
const projectDir = packageRoot(import.meta.dirname);
|
|
@@ -30,6 +32,7 @@ const domain = `gui/${uid}`;
|
|
|
30
32
|
const FLAG_ENV = {
|
|
31
33
|
'--expose': 'EXPOSE',
|
|
32
34
|
'--port': 'RELAY_PORT',
|
|
35
|
+
'--voice-port': 'VOICE_PORT',
|
|
33
36
|
'--host': 'RELAY_HOST',
|
|
34
37
|
'--hostname': 'RELAY_HOSTNAME',
|
|
35
38
|
'--token': 'RELAY_TOKEN',
|
|
@@ -155,6 +158,8 @@ function buildPlist() {
|
|
|
155
158
|
envEntries.push(['RELAY_HOST', process.env.RELAY_HOST]);
|
|
156
159
|
if (process.env.RELAY_PORT)
|
|
157
160
|
envEntries.push(['RELAY_PORT', process.env.RELAY_PORT]);
|
|
161
|
+
if (process.env.VOICE_PORT)
|
|
162
|
+
envEntries.push(['VOICE_PORT', process.env.VOICE_PORT]);
|
|
158
163
|
if (process.env.AUTO_UPDATE)
|
|
159
164
|
envEntries.push(['AUTO_UPDATE', process.env.AUTO_UPDATE]);
|
|
160
165
|
if (process.env.AUTO_UPDATE_INTERVAL_MINUTES)
|
|
@@ -234,6 +239,7 @@ function persistPinnedToken() {
|
|
|
234
239
|
}
|
|
235
240
|
}
|
|
236
241
|
const RELAY_PORT = relayPort();
|
|
242
|
+
const VOICE_PORT = String(process.env.VOICE_PORT ?? 8788);
|
|
237
243
|
/**
|
|
238
244
|
* Resolve the expose mode. Precedence: `EXPOSE` env (public|funnel / tailnet|serve|private) > persisted
|
|
239
245
|
* choice > 'public' default. An explicit env value is persisted so re-deploys don't silently flip posture.
|
|
@@ -279,6 +285,14 @@ function tailscaleState(bin, port = RELAY_PORT) {
|
|
|
279
285
|
return relayServeState({}, port);
|
|
280
286
|
}
|
|
281
287
|
}
|
|
288
|
+
function rawTailscaleStatus(bin) {
|
|
289
|
+
try {
|
|
290
|
+
return parseServeStatus(execFileSync(bin, ['serve', 'status', '--json'], { encoding: 'utf8', stdio: 'pipe' }));
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return {};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
282
296
|
/**
|
|
283
297
|
* The one Tailscale write here: `tailscale <serve|funnel> --bg --https=<port> <relay port>`. Always scoped
|
|
284
298
|
* to a port the relay owns outright or nobody holds — the callers check, this does not. Serving a port
|
|
@@ -459,6 +473,112 @@ function ensureTailscale() {
|
|
|
459
473
|
}
|
|
460
474
|
console.info(`✓ tailscale funnel → ${serveUrl(host, port)} now public over the internet (token-gated) → 127.0.0.1:${RELAY_PORT}`);
|
|
461
475
|
}
|
|
476
|
+
/**
|
|
477
|
+
* Give OpenAI the only public route it needs while keeping the control panel tailnet-only.
|
|
478
|
+
* Port-wide changes happen only when every live handler is either the relay root or the exact
|
|
479
|
+
* voice target named by our receipt; a foreign or merely manual mount is left untouched.
|
|
480
|
+
*/
|
|
481
|
+
function ensureVoiceFunnel() {
|
|
482
|
+
const voice = readVoiceConfig();
|
|
483
|
+
const bin = tailscaleBin();
|
|
484
|
+
if (!voice.publicBaseUrl) {
|
|
485
|
+
const receipt = readVoiceFunnelReceipt();
|
|
486
|
+
if (!bin || !receipt)
|
|
487
|
+
return;
|
|
488
|
+
const inspected = inspectVoiceFunnel(rawTailscaleStatus(bin), VOICE_PORT, RELAY_PORT, receipt);
|
|
489
|
+
if (!inspected.owned || inspected.relayAtRoot || inspected.conflicts.length)
|
|
490
|
+
return;
|
|
491
|
+
try {
|
|
492
|
+
execFileSync(bin, ['serve', '--yes', '--https=443', 'off'], { stdio: 'pipe' });
|
|
493
|
+
fs.rmSync(voiceFunnelReceiptPath());
|
|
494
|
+
console.info('✓ removed the receipt-owned voice Funnel mount');
|
|
495
|
+
}
|
|
496
|
+
catch (error) {
|
|
497
|
+
console.info(` ⚠ could not remove the receipt-owned voice Funnel (${error instanceof Error ? error.message : error})`);
|
|
498
|
+
}
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (!bin) {
|
|
502
|
+
console.info(`\n ⚠ voice is configured but Tailscale is unavailable. Mount by hand:`);
|
|
503
|
+
console.info(` tailscale funnel --bg --yes --https=443 --set-path=/voice ${VOICE_PORT}`);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const dns = magicDnsName(bin);
|
|
507
|
+
if (!dns) {
|
|
508
|
+
console.info('\n ⚠ voice is configured but this node has no MagicDNS name; Funnel was not changed.');
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
let configuredHost;
|
|
512
|
+
try {
|
|
513
|
+
configuredHost = new URL(voice.publicBaseUrl).hostname;
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
console.info('\n ⚠ voice.public-url is invalid; Funnel was not changed.');
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (configuredHost !== dns) {
|
|
520
|
+
console.info(`\n ⚠ voice.public-url names ${configuredHost}, but this node is ${dns}; Funnel was not changed.`);
|
|
521
|
+
console.info(` Fix with: conductor-remote config set voice.public-url https://${dns}/voice`);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
let status = rawTailscaleStatus(bin);
|
|
525
|
+
const oldReceipt = readVoiceFunnelReceipt();
|
|
526
|
+
let inspected = inspectVoiceFunnel(status, VOICE_PORT, RELAY_PORT, oldReceipt);
|
|
527
|
+
if (inspected.conflicts.length) {
|
|
528
|
+
console.info(`\n ⚠ :443 carries ${inspected.conflicts.join(', ')}. It is not owned by this receipt; left as is.`);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (inspected.owned && inspected.targetMatches && inspected.funnelOn && !inspected.relayAtRoot) {
|
|
532
|
+
console.info(`✓ voice Funnel already exposes https://${dns}/voice → 127.0.0.1:${VOICE_PORT}`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (inspected.relayAtRoot) {
|
|
536
|
+
const state = relayServeState(status, RELAY_PORT);
|
|
537
|
+
const candidates = [Number(RELAY_PORT), 8443, 10000].filter((port, index, all) => Number.isInteger(port) && port !== 443 && all.indexOf(port) === index);
|
|
538
|
+
const destination = freeServePort(state, candidates);
|
|
539
|
+
if (destination === null) {
|
|
540
|
+
console.info(`\n ⚠ the relay must leave :443 for voice, but ${candidates.map(p => `:${p}`).join(', ')} are occupied.`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const failed = mountRelay(bin, 'serve', destination);
|
|
544
|
+
if (failed) {
|
|
545
|
+
console.info(`\n ⚠ could not move the relay to tailnet-only :${destination} (${failed}). Voice was not changed.`);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
execFileSync(bin, ['serve', '--yes', '--https=443', 'off'], { stdio: 'pipe' });
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
console.info(`\n ⚠ relay moved, but :443 could not be cleared (${error instanceof Error ? error.message : error}).`);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
console.info(`✓ relay moved to https://${dns}:${destination}/ (tailnet-only) so voice can own :443`);
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
execFileSync(bin, ['funnel', '--bg', '--yes', '--https=443', '--set-path=/voice', VOICE_PORT], {
|
|
559
|
+
stdio: 'pipe'
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
console.info(`\n ⚠ could not mount voice Funnel (${error instanceof Error ? error.message : error}).`);
|
|
564
|
+
console.info(` Run by hand: tailscale funnel --bg --yes --https=443 --set-path=/voice ${VOICE_PORT}`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const receipt = {
|
|
568
|
+
version: 1,
|
|
569
|
+
host: dns,
|
|
570
|
+
path: '/voice',
|
|
571
|
+
target: `http://127.0.0.1:${VOICE_PORT}`
|
|
572
|
+
};
|
|
573
|
+
status = rawTailscaleStatus(bin);
|
|
574
|
+
inspected = inspectVoiceFunnel(status, VOICE_PORT, RELAY_PORT, receipt);
|
|
575
|
+
if (!inspected.owned || !inspected.targetMatches || !inspected.funnelOn || inspected.relayAtRoot) {
|
|
576
|
+
console.info('\n ⚠ Tailscale accepted the voice command, but the isolated public mount could not be verified.');
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
writeVoiceFunnelReceipt(receipt);
|
|
580
|
+
console.info(`✓ voice Funnel exposes https://${dns}/voice → 127.0.0.1:${VOICE_PORT} (receipt recorded)`);
|
|
581
|
+
}
|
|
462
582
|
function funnelRefused(error) {
|
|
463
583
|
console.info(`\n ⚠ could not enable Funnel (${error}).`);
|
|
464
584
|
console.info(' Funnel must be enabled for this tailnet: open the URL Tailscale printed above, or add the');
|
|
@@ -473,7 +593,7 @@ function printQr(url) {
|
|
|
473
593
|
console.info(` (QR skipped: ${err instanceof Error ? err.message : err})`);
|
|
474
594
|
}
|
|
475
595
|
}
|
|
476
|
-
function printUrl() {
|
|
596
|
+
function printUrl(loopbackPort = RELAY_PORT) {
|
|
477
597
|
const token = currentToken();
|
|
478
598
|
const frag = `#token=${token ?? '<starts on first run>'}`;
|
|
479
599
|
const bin = tailscaleBin();
|
|
@@ -483,7 +603,7 @@ function printUrl() {
|
|
|
483
603
|
console.info(`\n${drift.join('\n')}`);
|
|
484
604
|
}
|
|
485
605
|
const dns = bin ? magicDnsName(bin) : null;
|
|
486
|
-
const state = bin ? tailscaleState(bin) : relayServeState({},
|
|
606
|
+
const state = bin ? tailscaleState(bin, loopbackPort) : relayServeState({}, loopbackPort);
|
|
487
607
|
if (dns && state.port !== null) {
|
|
488
608
|
const scope = state.funnelOn ? 'public — any browser, token-gated' : 'same Tailnet only';
|
|
489
609
|
const url = `${serveUrl(dns, state.port)}${frag}`;
|
|
@@ -495,8 +615,27 @@ function printUrl() {
|
|
|
495
615
|
return;
|
|
496
616
|
}
|
|
497
617
|
// Nothing fronting yet — the relay is only on loopback.
|
|
498
|
-
console.info(`\n Local URL:\n http://127.0.0.1:${
|
|
499
|
-
console.info(`\n ⚠ Not reachable from your phone yet. Run \`yarn deploy\` (it picks a free port), or by hand \`tailscale funnel --bg ${
|
|
618
|
+
console.info(`\n Local URL:\n http://127.0.0.1:${loopbackPort}/${frag}`);
|
|
619
|
+
console.info(`\n ⚠ Not reachable from your phone yet. Run \`yarn deploy\` (it picks a free port), or by hand \`tailscale funnel --bg ${loopbackPort}\` (public) / \`tailscale serve --bg ${loopbackPort}\` (tailnet)${dns ? ` → https://${dns}/` : ''}, then \`yarn service status\`.`);
|
|
620
|
+
}
|
|
621
|
+
/** Report the public voice path independently of the relay's tailnet-only phone URL. */
|
|
622
|
+
function printVoiceRoute(loopbackPort, listenerPort) {
|
|
623
|
+
const voice = readVoiceConfig();
|
|
624
|
+
if (!voice.publicBaseUrl) {
|
|
625
|
+
console.info('voice: disabled (set voice.public-url to enable)');
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const bin = tailscaleBin();
|
|
629
|
+
if (!bin) {
|
|
630
|
+
console.info(`voice: configured at ${voice.publicBaseUrl}, but Tailscale is unavailable`);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const inspected = inspectVoiceFunnel(rawTailscaleStatus(bin), listenerPort, loopbackPort, readVoiceFunnelReceipt());
|
|
634
|
+
if (inspected.owned && inspected.targetMatches && inspected.funnelOn && !inspected.relayAtRoot) {
|
|
635
|
+
console.info(`voice: ${voice.publicBaseUrl} (public) → 127.0.0.1:${listenerPort}`);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
console.info(`voice: ${voice.publicBaseUrl} configured, but its isolated Funnel mount is not live`);
|
|
500
639
|
}
|
|
501
640
|
/** npx unpacks into a throwaway cache that gets purged; a LaunchAgent baked against it would rot. */
|
|
502
641
|
function isEphemeralInstall(dir) {
|
|
@@ -518,15 +657,36 @@ function install() {
|
|
|
518
657
|
// the daemon starts with an explicit EXPOSE in its own environment rather than racing the file
|
|
519
658
|
// ensureTailscale() writes further down. Folding it back into process.env is how every other knob
|
|
520
659
|
// travels here (see applyFlags), and it keeps ensureTailscale()'s own resolve a no-op re-read.
|
|
521
|
-
|
|
660
|
+
const requestedExpose = resolveExposeMode();
|
|
661
|
+
if (readVoiceConfig().publicBaseUrl) {
|
|
662
|
+
if (requestedExpose !== 'tailnet')
|
|
663
|
+
console.info(' voice requires the relay itself to stay tailnet-only; forcing EXPOSE=tailnet.');
|
|
664
|
+
process.env.EXPOSE = 'tailnet';
|
|
665
|
+
try {
|
|
666
|
+
fs.mkdirSync(path.dirname(exposeStorePath()), { recursive: true });
|
|
667
|
+
fs.writeFileSync(exposeStorePath(), 'tailnet');
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
// The plist still carries the safe posture; this file is only the next install's default.
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
process.env.EXPOSE = requestedExpose;
|
|
675
|
+
}
|
|
522
676
|
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
523
677
|
fs.mkdirSync(logDir, { recursive: true });
|
|
524
678
|
fs.writeFileSync(plistPath, buildPlist());
|
|
525
679
|
reloadAgent();
|
|
526
680
|
const changedSetting = process.env.CONDUCTOR_REMOTE_CONFIG_SET;
|
|
527
681
|
if (changedSetting) {
|
|
528
|
-
if (changedSetting === 'expose' ||
|
|
682
|
+
if (changedSetting === 'expose' ||
|
|
683
|
+
changedSetting === 'port' ||
|
|
684
|
+
changedSetting === 'voice-port' ||
|
|
685
|
+
changedSetting === 'hostname' ||
|
|
686
|
+
changedSetting.startsWith('voice.')) {
|
|
529
687
|
ensureTailscale();
|
|
688
|
+
ensureVoiceFunnel();
|
|
689
|
+
}
|
|
530
690
|
console.info(`✓ set ${changedSetting}; the relay restarted with the new value.`);
|
|
531
691
|
console.info(' Check it with: conductor-remote config');
|
|
532
692
|
return;
|
|
@@ -536,6 +696,7 @@ function install() {
|
|
|
536
696
|
console.info(` logs: ${logDir}/relay.log`);
|
|
537
697
|
console.info(` node: ${process.execPath}`);
|
|
538
698
|
ensureTailscale();
|
|
699
|
+
ensureVoiceFunnel();
|
|
539
700
|
printUrl();
|
|
540
701
|
console.info('\n Note: a node version change (nvm) invalidates the baked path — re-run `yarn deploy` after upgrading node.');
|
|
541
702
|
console.info(' Note: the AppleScript write path needs Accessibility permission granted to this node binary (System Settings ▸ Privacy).');
|
|
@@ -553,7 +714,10 @@ function uninstall() {
|
|
|
553
714
|
function restart() {
|
|
554
715
|
launchctl('kickstart', '-k', `${domain}/${LABEL}`);
|
|
555
716
|
console.info(`✓ restarted ${LABEL}`);
|
|
556
|
-
|
|
717
|
+
const env = readPlistEnv();
|
|
718
|
+
const relayPort = env.RELAY_PORT ?? '8787';
|
|
719
|
+
printUrl(relayPort);
|
|
720
|
+
printVoiceRoute(relayPort, env.VOICE_PORT ?? '8788');
|
|
557
721
|
}
|
|
558
722
|
/**
|
|
559
723
|
* The daemon's environment, read back out of the plist it was installed with.
|
|
@@ -587,6 +751,7 @@ const KNOBS = [
|
|
|
587
751
|
fallbackSource: () => (fs.existsSync(exposeStorePath()) ? 'expose file' : 'default')
|
|
588
752
|
},
|
|
589
753
|
{ name: 'port', env: 'RELAY_PORT', fallback: () => '8787' },
|
|
754
|
+
{ name: 'voice-port', env: 'VOICE_PORT', fallback: () => '8788' },
|
|
590
755
|
{ name: 'host', env: 'RELAY_HOST', fallback: () => '127.0.0.1' },
|
|
591
756
|
{ name: 'write-strategy', env: 'WRITE_STRATEGY', fallback: () => 'applescript' },
|
|
592
757
|
{ name: 'prevent-screen-lock', env: 'PREVENT_SCREEN_LOCK', fallback: () => 'on' },
|
|
@@ -619,8 +784,27 @@ function setConfig(args) {
|
|
|
619
784
|
console.error('usage: conductor-remote config set <setting> <value>');
|
|
620
785
|
process.exit(1);
|
|
621
786
|
}
|
|
787
|
+
if (name && VOICE_SETTING_NAMES.includes(name)) {
|
|
788
|
+
try {
|
|
789
|
+
setVoiceSetting(name, value);
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
console.error(`config set: ${error instanceof Error ? error.message : error}`);
|
|
793
|
+
process.exit(1);
|
|
794
|
+
}
|
|
795
|
+
if (!fs.existsSync(plistPath)) {
|
|
796
|
+
console.info(`✓ set ${name}. Install the service to start the voice listener.`);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
const childEnv = { ...process.env, ...readPlistEnv(), CONDUCTOR_REMOTE_CONFIG_SET: name };
|
|
800
|
+
const cli = path.join(projectDir, 'bin', 'cli.js');
|
|
801
|
+
const result = spawnSync(process.execPath, [cli, 'service', 'install'], { env: childEnv, stdio: 'inherit' });
|
|
802
|
+
if (result.error)
|
|
803
|
+
console.error(`config set: could not restart the service (${result.error.message})`);
|
|
804
|
+
process.exit(result.status ?? 1);
|
|
805
|
+
}
|
|
622
806
|
if (!envKey) {
|
|
623
|
-
console.error(`config set: unknown setting "${name}"\n known: ${[...CONFIG_KEYS.keys()].join(', ')}`);
|
|
807
|
+
console.error(`config set: unknown setting "${name}"\n known: ${[...CONFIG_KEYS.keys(), ...VOICE_SETTING_NAMES].join(', ')}`);
|
|
624
808
|
process.exit(1);
|
|
625
809
|
}
|
|
626
810
|
if (name === 'prevent-screen-lock' && value !== 'on' && value !== 'off') {
|
|
@@ -686,6 +870,12 @@ function config() {
|
|
|
686
870
|
// no token file yet
|
|
687
871
|
}
|
|
688
872
|
rows.push({ name: 'token', value: token, source: 'token file' });
|
|
873
|
+
const voice = readVoiceConfig();
|
|
874
|
+
rows.push({ name: 'voice.openai-key', value: voice.openaiKey ? '(set)' : '(unset)', source: 'voice file' }, { name: 'voice.webhook-secret', value: voice.webhookSecret ? '(set)' : '(unset)', source: 'voice file' }, { name: 'voice.twilio-auth-token', value: voice.twilioAuthToken ? '(set)' : '(unset)', source: 'voice file' }, {
|
|
875
|
+
name: 'voice.allowed-callers',
|
|
876
|
+
value: voice.allowedCallers.length ? `${voice.allowedCallers.length} set` : '(unset)',
|
|
877
|
+
source: 'voice file'
|
|
878
|
+
}, { name: 'voice.pin', value: voice.pin ? '(set)' : '(unset)', source: 'voice file' }, { name: 'voice.project-id', value: voice.projectId ? '(set)' : '(unset)', source: 'voice file' }, { name: 'voice.public-url', value: voice.publicBaseUrl ?? '(unset)', source: 'voice file' }, { name: 'voice.model', value: voice.model, source: 'voice file' }, { name: 'voice.voice', value: voice.voice, source: 'voice file' }, { name: 'voice.sip-host', value: voice.sipHost, source: 'voice file' });
|
|
689
879
|
// The HTTPS port is a live Tailscale fact, not a plist knob: :443 by default, elsewhere once another
|
|
690
880
|
// service holds :443 (see ensureServeOnly), and the phone URL carries whichever it is. Read against the
|
|
691
881
|
// daemon's own port, since this shell's RELAY_PORT is not the one the relay listens on.
|
|
@@ -749,7 +939,10 @@ function status() {
|
|
|
749
939
|
catch {
|
|
750
940
|
console.info('state: loaded but not running (check logs)');
|
|
751
941
|
}
|
|
752
|
-
|
|
942
|
+
const env = readPlistEnv();
|
|
943
|
+
const relayPort = env.RELAY_PORT ?? '8787';
|
|
944
|
+
printUrl(relayPort);
|
|
945
|
+
printVoiceRoute(relayPort, env.VOICE_PORT ?? '8788');
|
|
753
946
|
}
|
|
754
947
|
/**
|
|
755
948
|
* Stream the LaunchAgent's stdout+stderr logs (the same files the plist points at). Follows both by default
|
package/dist-node/src/logbuf.js
CHANGED
|
@@ -142,5 +142,8 @@ export function tailLogFile(name, limit) {
|
|
|
142
142
|
*/
|
|
143
143
|
export function redactSecrets(text, token) {
|
|
144
144
|
const masked = token ? text.split(token).join('<token>') : text;
|
|
145
|
-
return masked
|
|
145
|
+
return masked
|
|
146
|
+
.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, '<openai-key>')
|
|
147
|
+
.replace(/\bwhsec_[A-Za-z0-9+/=_-]{8,}/g, '<webhook-secret>')
|
|
148
|
+
.replace(/(token=)[^\s&"'`]+/gi, '$1<token>');
|
|
146
149
|
}
|
package/dist-node/src/notify.js
CHANGED
|
@@ -29,6 +29,7 @@ import crypto from 'node:crypto';
|
|
|
29
29
|
import fs from 'node:fs';
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
import { stateDir } from "./config.js";
|
|
32
|
+
import { clipExact, oneLine } from "./speech.js";
|
|
32
33
|
import { generateVapidKeys, MAX_PAYLOAD_BYTES, sendPush } from "./webpush.js";
|
|
33
34
|
const STORE_VERSION = 1;
|
|
34
35
|
/**
|
|
@@ -49,7 +50,6 @@ const MAX_FAILURES = 20;
|
|
|
49
50
|
* long enough to survive a slow tunnel dropping a handful of ticks.
|
|
50
51
|
*/
|
|
51
52
|
const VIEWING_FRESH_MS = 10_000;
|
|
52
|
-
const clip = (s, n) => (s.length > n ? `${s.slice(0, n).trimEnd()}…` : s);
|
|
53
53
|
/** Alongside the token and the first-prompt queue — one dir holding everything this relay persists. */
|
|
54
54
|
function storePath() {
|
|
55
55
|
return path.join(stateDir(), 'push.json');
|
|
@@ -211,7 +211,7 @@ async function deliver(device, message) {
|
|
|
211
211
|
// Unreachable at the current BODY_CHARS, but an over-long title (a workspace name
|
|
212
212
|
// is user text) must clip rather than turn into a failed send.
|
|
213
213
|
const room = Math.max(0, message.body.length - (payload.length - MAX_PAYLOAD_BYTES) - 8);
|
|
214
|
-
payload = Buffer.from(JSON.stringify({ ...message, body:
|
|
214
|
+
payload = Buffer.from(JSON.stringify({ ...message, body: clipExact(message.body, room) }));
|
|
215
215
|
}
|
|
216
216
|
const result = await sendPush(device, s.vapid, subject(), payload, TTL_SECONDS);
|
|
217
217
|
if (result.ok) {
|
|
@@ -281,13 +281,6 @@ export function deviceCount() {
|
|
|
281
281
|
export function chatRoute(workspaceId, sessionId) {
|
|
282
282
|
return `/w/${workspaceId}?session=${encodeURIComponent(sessionId)}`;
|
|
283
283
|
}
|
|
284
|
-
/** Collapse a transcript entry to one lock-screen line: no code fences, no blank runs. */
|
|
285
|
-
function oneLine(text) {
|
|
286
|
-
return clip(text
|
|
287
|
-
.replace(/```[\s\S]*?```/g, '…')
|
|
288
|
-
.replace(/\s+/g, ' ')
|
|
289
|
-
.trim(), BODY_CHARS);
|
|
290
|
-
}
|
|
291
284
|
// --- the watcher ---
|
|
292
285
|
/**
|
|
293
286
|
* Everything this chat records about a person having asked for something, as one
|
|
@@ -426,10 +419,10 @@ async function fire(reads, sessionId, kind, state) {
|
|
|
426
419
|
const where = state.sessionTitle ? `${state.workspaceTitle} · ${state.sessionTitle}` : state.workspaceTitle;
|
|
427
420
|
const body = kind === 'error'
|
|
428
421
|
? said
|
|
429
|
-
? `Stopped with an error. ${oneLine(said)}`
|
|
422
|
+
? `Stopped with an error. ${oneLine(said, BODY_CHARS)}`
|
|
430
423
|
: 'The agent stopped with an error.'
|
|
431
424
|
: said
|
|
432
|
-
? oneLine(said)
|
|
425
|
+
? oneLine(said, BODY_CHARS)
|
|
433
426
|
: 'Finished its turn.';
|
|
434
427
|
const sent = await notifyAll({
|
|
435
428
|
title: state.repoName ? `${where} — ${state.repoName}` : where,
|
package/dist-node/src/reads.js
CHANGED
|
@@ -393,7 +393,7 @@ export class Reads {
|
|
|
393
393
|
// `status` cycles working → idle on every lap. Both are needed: `turn_started_at`
|
|
394
394
|
// stays at the first message when a person steers the running turn, while
|
|
395
395
|
// `last_user_message_at` moves. See src/notify.ts.
|
|
396
|
-
`SELECT s.id, s.status, s.title, s.workspace_id, s.last_user_message_at,
|
|
396
|
+
`SELECT s.id, s.status, s.title, s.workspace_id, s.last_user_message_at, s.updated_at,
|
|
397
397
|
w.workspace_name, w.pr_title, w.branch, w.directory_name,
|
|
398
398
|
r.name AS repo_name,
|
|
399
399
|
(SELECT COUNT(*) FROM sessions t WHERE t.workspace_id = w.id AND COALESCE(t.is_hidden, 0) = 0) AS tab_count,
|
|
@@ -406,6 +406,7 @@ export class Reads {
|
|
|
406
406
|
sessionId: r.id,
|
|
407
407
|
workspaceId: r.workspace_id,
|
|
408
408
|
status: r.status,
|
|
409
|
+
updatedAt: r.updated_at,
|
|
409
410
|
turnStartedAt: r.turn_started_at,
|
|
410
411
|
lastUserMessageAt: r.last_user_message_at,
|
|
411
412
|
workspaceTitle: workspaceTitle({ ...r, id: r.workspace_id }),
|
|
@@ -414,6 +415,33 @@ export class Reads {
|
|
|
414
415
|
sessionTitle: r.tab_count > 1 ? r.title : null
|
|
415
416
|
}));
|
|
416
417
|
}
|
|
418
|
+
/**
|
|
419
|
+
* The newest structured AskUserQuestion input, when a harness emitted one. Conductor's
|
|
420
|
+
* normal gstack path writes the same decision as prose, so this is deliberately a fallback
|
|
421
|
+
* behind `lastAssistantText`, not a second primary transcript parser.
|
|
422
|
+
*/
|
|
423
|
+
lastQuestionInput(sessionId) {
|
|
424
|
+
const rows = this.db.query(`SELECT content FROM session_messages
|
|
425
|
+
WHERE session_id = ? AND content LIKE '%AskUserQuestion%'
|
|
426
|
+
ORDER BY rowid DESC LIMIT 40`, [sessionId]);
|
|
427
|
+
for (const row of rows) {
|
|
428
|
+
if (!row.content)
|
|
429
|
+
continue;
|
|
430
|
+
try {
|
|
431
|
+
const parsed = JSON.parse(row.content);
|
|
432
|
+
for (const block of parsed.message?.content ?? []) {
|
|
433
|
+
if (block.type === 'tool_use' &&
|
|
434
|
+
typeof block.name === 'string' &&
|
|
435
|
+
block.name.toLowerCase().endsWith('askuserquestion'))
|
|
436
|
+
return block.input ?? null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// The LIKE is only a cheap prefilter; malformed history is skipped like parseMessage does.
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
417
445
|
/**
|
|
418
446
|
* The last thing the agent actually said in a chat — the body of a "finished"
|
|
419
447
|
* notification. Reads the tail rather than the whole transcript, and runs the
|
package/dist-node/src/routes.js
CHANGED
|
@@ -52,6 +52,14 @@ export const routes = {
|
|
|
52
52
|
updateModelDefaults: flat('PATCH', '/api/models/defaults'),
|
|
53
53
|
/** Rolling subscription limits, read without a model request from each provider CLI. */
|
|
54
54
|
planUsage: flat('GET', '/api/usage'),
|
|
55
|
+
/** Mint a short-lived OpenAI SIP URI for an authenticated native client. */
|
|
56
|
+
voiceTicket: flat('POST', '/api/voice/ticket'),
|
|
57
|
+
/** Negotiate one app-wide PWA orchestrator call through the relay. */
|
|
58
|
+
voiceCall: flat('POST', '/api/voice/calls'),
|
|
59
|
+
/** Start the greeting only after the browser has installed OpenAI's SDP answer. */
|
|
60
|
+
voiceCallReady: param('POST', '/api/voice/calls/:callId/ready'),
|
|
61
|
+
/** End one PWA-owned call. SIP calls remain owned by their phone leg. */
|
|
62
|
+
voiceCallEnd: param('DELETE', '/api/voice/calls/:callId'),
|
|
55
63
|
repoIcon: param('GET', '/api/repos/:repo/icon'),
|
|
56
64
|
/** A local image linked in chat Markdown, fetched with the phone's auth header. */
|
|
57
65
|
localImage: param('GET', '/api/local-images/:path'),
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar's "this workspace has a Run going" badge, re-derived from `ps`.
|
|
3
|
+
*
|
|
4
|
+
* Conductor's Run task is a long-lived shell wrapper. Starting Run in a workspace
|
|
5
|
+
* spawns `/bin/zsh <worktree-key>/run-run:<n>.sh`, where the wrapper lives under
|
|
6
|
+
* `~/.conductor/projects/<worktree-key>/` and `<worktree-key>` is that workspace's
|
|
7
|
+
* own worktree path with every `/` turned into `--` (a leading slash becomes a
|
|
8
|
+
* leading `--`). The wrapper *file* is written once and stays on disk after the task
|
|
9
|
+
* stops, so its presence proves nothing — the live **process** is the fact. While a
|
|
10
|
+
* Run is active that zsh process is in the table; when Run stops, it leaves.
|
|
11
|
+
*
|
|
12
|
+
* This is a better signal than "the dev-server port is open": a Run config can be a
|
|
13
|
+
* non-server command that never listens, and an unrelated process can hold the port a
|
|
14
|
+
* server-style Run would use. Matching the wrapper process ties the badge to the exact
|
|
15
|
+
* task Conductor started, and to nothing else.
|
|
16
|
+
*
|
|
17
|
+
* Two things keep it from false-firing. The wrapper path must be zsh's **immediate
|
|
18
|
+
* script argument** — not merely somewhere on the command line — so `zsh -c 'echo
|
|
19
|
+
* …/run-run:1.sh'` and a non-zsh command that names the file both miss, and the script
|
|
20
|
+
* must be `run-run:<n>.sh` specifically, so the setup wrapper (`run-setup:<n>.sh`) that
|
|
21
|
+
* runs beside it is not read as an active Run.
|
|
22
|
+
*
|
|
23
|
+
* Like src/background-tasks.ts, only `ps` **args** are read, never the environment. The
|
|
24
|
+
* args snapshot can itself hold sensitive command-line data, so it is never logged.
|
|
25
|
+
*/
|
|
26
|
+
import { execFile } from 'node:child_process';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { promisify } from 'node:util';
|
|
30
|
+
const exec = promisify(execFile);
|
|
31
|
+
const RUN_PROJECTS_DIR = path.join(homedir(), '.conductor', 'projects');
|
|
32
|
+
/**
|
|
33
|
+
* The `~/.conductor/projects` key for a worktree: its absolute path with every `/`
|
|
34
|
+
* replaced by `--`, so `/Users/me/conductor/workspaces/repo/praia` becomes
|
|
35
|
+
* `--Users--me--conductor--workspaces--repo--praia`.
|
|
36
|
+
*/
|
|
37
|
+
export function runWrapperKey(worktree) {
|
|
38
|
+
return worktree.replace(/\//g, '--');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The set of project keys with a live Run wrapper process, from one `ps -axww -o args=`
|
|
42
|
+
* listing.
|
|
43
|
+
*
|
|
44
|
+
* The line must be `zsh <home>/.conductor/projects/<key>/run-run:<n>.sh`, with that
|
|
45
|
+
* exact projects directory as zsh's **immediate** first argument. So a `-c` form
|
|
46
|
+
* (whether it echoes the file or names it directly), another script argument, and any
|
|
47
|
+
* non-zsh process naming it all miss. Escaping the known directory rather than parsing
|
|
48
|
+
* shell words also preserves spaces and punctuation in both the home path and project
|
|
49
|
+
* key. Two wrappers for one workspace — a restart, or two run ids — fold to one key.
|
|
50
|
+
*/
|
|
51
|
+
export function parseRunWrappers(ps, projectsDir = RUN_PROJECTS_DIR) {
|
|
52
|
+
const escapedDir = projectsDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
53
|
+
const runWrapper = new RegExp(`^(?:\\S*\\/)?zsh\\s+${escapedDir}\\/([^/\\r\\n]+?)\\/run-run:\\d+\\.sh(?:\\s|$)`);
|
|
54
|
+
const keys = new Set();
|
|
55
|
+
for (const raw of ps.split('\n')) {
|
|
56
|
+
const m = runWrapper.exec(raw.trim());
|
|
57
|
+
if (m)
|
|
58
|
+
keys.add(m[1]);
|
|
59
|
+
}
|
|
60
|
+
return keys;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* How long one `ps` listing is trusted. The sidebar polls `/api/state` every 2.5s, the
|
|
64
|
+
* listing costs tens of milliseconds, and a Run that starts or stops is reflected
|
|
65
|
+
* within this window — one poll late, against a badge nobody is timing to the second.
|
|
66
|
+
*/
|
|
67
|
+
const PS_FRESH_MS = 5000;
|
|
68
|
+
let snapshot = { at: 0, keys: new Set() };
|
|
69
|
+
let refreshing = null;
|
|
70
|
+
/**
|
|
71
|
+
* The project keys with a live Run wrapper, stale-while-revalidate.
|
|
72
|
+
*
|
|
73
|
+
* Synchronous on purpose, exactly like src/background-tasks.ts ▸ `agentProcessStarts`:
|
|
74
|
+
* `/api/state` is assembled from plain reads and must not fork a `ps` on every 2.5s
|
|
75
|
+
* poll. The first call after start answers empty — no badge for one poll — and every
|
|
76
|
+
* call after that answers from the last listing while a fresh one lands.
|
|
77
|
+
*/
|
|
78
|
+
export function activeRunProjects() {
|
|
79
|
+
if (Date.now() - snapshot.at > PS_FRESH_MS && !refreshing) {
|
|
80
|
+
refreshing = exec('ps', ['-axww', '-o', 'args='], { maxBuffer: 16 * 1024 * 1024 })
|
|
81
|
+
.then(({ stdout }) => {
|
|
82
|
+
snapshot = { at: Date.now(), keys: parseRunWrappers(stdout) };
|
|
83
|
+
})
|
|
84
|
+
.catch(() => {
|
|
85
|
+
// `ps` failing is not a fact about any workspace: keep what we had, retry next window.
|
|
86
|
+
snapshot = { ...snapshot, at: Date.now() };
|
|
87
|
+
})
|
|
88
|
+
.finally(() => {
|
|
89
|
+
refreshing = null;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return snapshot.keys;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Flag each workspace whose worktree has a live Run wrapper process. Reads the cached
|
|
96
|
+
* snapshot and refreshes it in the background, so `/api/state` stays synchronous.
|
|
97
|
+
*/
|
|
98
|
+
export function attachRunActivity(workspaces, liveRuns = activeRunProjects) {
|
|
99
|
+
const keys = liveRuns();
|
|
100
|
+
for (const workspace of workspaces) {
|
|
101
|
+
workspace.run_active = workspace.worktree ? keys.has(runWrapperKey(workspace.worktree)) : false;
|
|
102
|
+
}
|
|
103
|
+
}
|