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/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-CatXxUrf.js"></script>
29
- <link rel="stylesheet" crossorigin href="/assets/index-0UqQA-X1.css">
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 l={};const t=e=>i(e,o),c={module:{uri:o},exports:l,require:t};s[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}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:"b64c27d0b8b9afd274befe2a3aa880db"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-CatXxUrf.js",revision:null},{url:"assets/index-0UqQA-X1.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\//]}))});
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({}, RELAY_PORT);
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:${RELAY_PORT}/${frag}`);
499
- console.info(`\n ⚠ Not reachable from your phone yet. Run \`yarn deploy\` (it picks a free port), or by hand \`tailscale funnel --bg ${RELAY_PORT}\` (public) / \`tailscale serve --bg ${RELAY_PORT}\` (tailnet)${dns ? ` → https://${dns}/` : ''}, then \`yarn service status\`.`);
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
- process.env.EXPOSE = resolveExposeMode();
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' || changedSetting === 'port' || changedSetting === 'hostname')
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
- printUrl();
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
- printUrl();
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
@@ -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.replace(/(token=)[^\s&"'`]+/gi, '$1<token>');
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
  }
@@ -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: clip(message.body, room) }));
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,
@@ -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
@@ -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
+ }