conductor-remote 1.117.0 → 1.118.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.
Files changed (200) hide show
  1. package/README.md +10 -8
  2. package/dist/assets/{PierrePatch-D3_YBIAr.js → PierrePatch-D4YbKD8Q.js} +1 -1
  3. package/dist/assets/index-BRzsSW8g.css +1 -0
  4. package/dist/assets/index-BaS6qXUW.js +65 -0
  5. package/dist/index.html +2 -2
  6. package/dist/push-sw.js +2 -2
  7. package/dist/sw.js +1 -1
  8. package/dist-node/scripts/nosleep-setup.js +2 -2
  9. package/dist-node/scripts/nosleep.js +3 -3
  10. package/dist-node/scripts/service/commands.js +32 -0
  11. package/dist-node/scripts/service/configuration.js +205 -0
  12. package/dist-node/scripts/service/environment.js +59 -0
  13. package/dist-node/scripts/service/flags.js +45 -0
  14. package/dist-node/scripts/service/installation.js +91 -0
  15. package/dist-node/scripts/service/launch-agent.js +134 -0
  16. package/dist-node/scripts/service/network.js +244 -0
  17. package/dist-node/scripts/service/presentation.js +101 -0
  18. package/dist-node/scripts/service/voice.js +113 -0
  19. package/dist-node/scripts/service.js +5 -980
  20. package/dist-node/src/{agent-config.js → agents/agent-config.js} +1 -1
  21. package/dist-node/src/{model-cache.js → agents/model-cache.js} +1 -1
  22. package/dist-node/src/{roles.js → agents/roles.js} +3 -3
  23. package/dist-node/src/db.js +1 -1
  24. package/dist-node/src/{parked.js → delivery/parked.js} +1 -1
  25. package/dist-node/src/{dev-server.js → dev-server/controller.js} +8 -291
  26. package/dist-node/src/dev-server/ports.js +63 -0
  27. package/dist-node/src/dev-server/processes.js +73 -0
  28. package/dist-node/src/dev-server/proxy.js +158 -0
  29. package/dist-node/src/{run-activity.js → dev-server/run-activity.js} +2 -2
  30. package/dist-node/src/dev-server/types.js +1 -0
  31. package/dist-node/src/{file-preview.js → files/file-preview.js} +1 -1
  32. package/dist-node/src/{change-stats.js → git/change-stats.js} +1 -1
  33. package/dist-node/src/{git.js → git/diff.js} +1 -1
  34. package/dist-node/src/{autoupdate.js → host/autoupdate.js} +1 -1
  35. package/dist-node/src/{funnel-watchdog.js → host/funnel-watchdog.js} +4 -4
  36. package/dist-node/src/{logbuf.js → host/logbuf.js} +1 -1
  37. package/dist-node/src/{nosleep-helper.js → host/nosleep-helper.js} +1 -1
  38. package/dist-node/src/{tailscale.js → host/tailscale.js} +1 -1
  39. package/dist-node/src/http/router-types.js +2 -0
  40. package/dist-node/src/http/router.js +78 -0
  41. package/dist-node/src/http/routes/create-workspace.js +90 -0
  42. package/dist-node/src/http/routes/files.js +53 -0
  43. package/dist-node/src/http/routes/prompts.js +363 -0
  44. package/dist-node/src/http/routes/sessions.js +279 -0
  45. package/dist-node/src/http/routes/state.js +186 -0
  46. package/dist-node/src/http/routes/system.js +220 -0
  47. package/dist-node/src/http/routes/voice.js +117 -0
  48. package/dist-node/src/http/routes/workflows.js +248 -0
  49. package/dist-node/src/http/routes/workspaces.js +267 -0
  50. package/dist-node/src/http/services/base.js +252 -0
  51. package/dist-node/src/http/services/delegations.js +301 -0
  52. package/dist-node/src/http/services/delivery.js +433 -0
  53. package/dist-node/src/http/services/files.js +199 -0
  54. package/dist-node/src/http/services/mcp.js +94 -0
  55. package/dist-node/src/http/services/responses.js +120 -0
  56. package/dist-node/src/http/services/voice.js +147 -0
  57. package/dist-node/src/http/services/workflow-probes.js +215 -0
  58. package/dist-node/src/http/services/workflow-state.js +195 -0
  59. package/dist-node/src/http/services/workflow.js +366 -0
  60. package/dist-node/src/http/services.js +35 -0
  61. package/dist-node/src/mcp/arguments.js +14 -0
  62. package/dist-node/src/mcp/dispatcher.js +56 -0
  63. package/dist-node/src/mcp/formatters.js +110 -0
  64. package/dist-node/src/mcp/protocol.js +7 -0
  65. package/dist-node/src/mcp/registry.js +66 -0
  66. package/dist-node/src/mcp/tools/agent-options.js +122 -0
  67. package/dist-node/src/mcp/tools/chat-actions.js +194 -0
  68. package/dist-node/src/mcp/tools/chats.js +194 -0
  69. package/dist-node/src/mcp/tools/delegation.js +135 -0
  70. package/dist-node/src/mcp/tools/system.js +135 -0
  71. package/dist-node/src/mcp/tools/voice.js +136 -0
  72. package/dist-node/src/mcp/tools/workspaces.js +259 -0
  73. package/dist-node/src/mcp/types.js +1 -0
  74. package/dist-node/src/mcp.js +6 -4
  75. package/dist-node/src/{notify.js → notifications/notify.js} +5 -5
  76. package/dist-node/src/{webpush.js → notifications/webpush.js} +1 -1
  77. package/dist-node/src/orchestration/delegation/codec.js +201 -0
  78. package/dist-node/src/{delegation-intake.js → orchestration/delegation/intake.js} +2 -2
  79. package/dist-node/src/{delegation-prompt.js → orchestration/delegation/prompt.js} +1 -1
  80. package/dist-node/src/orchestration/delegation/queue.js +255 -0
  81. package/dist-node/src/orchestration/delegation/store.js +111 -0
  82. package/dist-node/src/orchestration/delegation/types.js +2 -0
  83. package/dist-node/src/orchestration/persistence/capabilities.js +109 -0
  84. package/dist-node/src/orchestration/persistence/codecs.js +148 -0
  85. package/dist-node/src/orchestration/persistence/connection.js +205 -0
  86. package/dist-node/src/orchestration/persistence/db.js +101 -0
  87. package/dist-node/src/orchestration/persistence/effect-recovery.js +227 -0
  88. package/dist-node/src/orchestration/persistence/effect-transitions.js +157 -0
  89. package/dist-node/src/orchestration/persistence/effects.js +109 -0
  90. package/dist-node/src/orchestration/persistence/errors.js +39 -0
  91. package/dist-node/src/orchestration/persistence/events.js +29 -0
  92. package/dist-node/src/orchestration/persistence/idempotency.js +82 -0
  93. package/dist-node/src/orchestration/persistence/job-attempts.js +114 -0
  94. package/dist-node/src/orchestration/persistence/jobs.js +159 -0
  95. package/dist-node/src/orchestration/persistence/projections.js +190 -0
  96. package/dist-node/src/orchestration/persistence/quarantine.js +46 -0
  97. package/dist-node/src/orchestration/persistence/records.js +115 -0
  98. package/dist-node/src/orchestration/persistence/relays.js +78 -0
  99. package/dist-node/src/orchestration/persistence/runs.js +156 -0
  100. package/dist-node/src/{orchestration-schema.js → orchestration/persistence/schema.js} +1 -1
  101. package/dist-node/src/orchestration/persistence/types.js +1 -0
  102. package/dist-node/src/orchestration/persistence/ui-lease.js +197 -0
  103. package/dist-node/src/orchestration/persistence/values.js +114 -0
  104. package/dist-node/src/orchestration/workflow/commands.js +309 -0
  105. package/dist-node/src/orchestration/workflow/coordinator.js +28 -0
  106. package/dist-node/src/orchestration/workflow/effect-recovery.js +125 -0
  107. package/dist-node/src/orchestration/workflow/effects.js +307 -0
  108. package/dist-node/src/orchestration/workflow/errors.js +13 -0
  109. package/dist-node/src/orchestration/workflow/helpers.js +154 -0
  110. package/dist-node/src/orchestration/workflow/job-dispatch.js +312 -0
  111. package/dist-node/src/orchestration/workflow/job-results.js +280 -0
  112. package/dist-node/src/{workflow.js → orchestration/workflow/prompts.js} +2 -2
  113. package/dist-node/src/orchestration/workflow/root.js +293 -0
  114. package/dist-node/src/orchestration/workflow/start.js +99 -0
  115. package/dist-node/src/orchestration/workflow/state.js +105 -0
  116. package/dist-node/src/orchestration/workflow/types.js +1 -0
  117. package/dist-node/src/orchestration/workflow/wake.js +164 -0
  118. package/dist-node/src/prefs.js +1 -1
  119. package/dist-node/src/{background-tasks.js → reads/background-tasks.js} +1 -1
  120. package/dist-node/src/reads/messages.js +362 -0
  121. package/dist-node/src/reads/repository.js +87 -0
  122. package/dist-node/src/reads/sessions.js +165 -0
  123. package/dist-node/src/reads/types.js +1 -0
  124. package/dist-node/src/reads/workspaces.js +230 -0
  125. package/dist-node/src/reads/worktrees.js +45 -0
  126. package/dist-node/src/routes.js +5 -5
  127. package/dist-node/src/{search.js → search/coordinator.js} +4 -4
  128. package/dist-node/src/{search-worker.js → search/worker.js} +3 -3
  129. package/dist-node/src/server.js +14 -3930
  130. package/dist-node/src/settings.js +1 -1
  131. package/dist-node/src/shared.js +10 -10
  132. package/dist-node/src/{transcript.js → transcript/parser.js} +4 -4
  133. package/dist-node/src/{tool-usage-worker.js → usage/tool-usage-worker.js} +1 -1
  134. package/dist-node/src/{tool-usage.js → usage/tool-usage.js} +1 -1
  135. package/dist-node/src/voice/brief.js +22 -50
  136. package/dist-node/src/voice/context.js +1 -1
  137. package/dist-node/src/voice/dates.js +38 -0
  138. package/dist-node/src/voice/history.js +14 -5
  139. package/dist-node/src/voice/prompt.js +10 -9
  140. package/dist-node/src/voice/recall.js +115 -0
  141. package/dist-node/src/{speech.js → voice/speech.js} +1 -1
  142. package/dist-node/src/voice/tools.js +92 -3
  143. package/dist-node/src/voice/webrtc.js +1 -1
  144. package/dist-node/src/wire.js +2 -2
  145. package/dist-node/src/writes/actuator.js +124 -0
  146. package/dist-node/src/writes/agent-options.js +151 -0
  147. package/dist-node/src/writes/applescript/agent-options.applescript +334 -0
  148. package/dist-node/src/writes/applescript/chat-targeting.applescript +149 -0
  149. package/dist-node/src/writes/applescript/composer.applescript +195 -0
  150. package/dist-node/src/writes/applescript/hotspot.applescript +155 -0
  151. package/dist-node/src/writes/applescript/lifecycle.applescript +266 -0
  152. package/dist-node/src/writes/applescript/run-tasks.applescript +208 -0
  153. package/dist-node/src/writes/applescript/source.js +52 -0
  154. package/dist-node/src/writes/applescript/window.applescript +436 -0
  155. package/dist-node/src/writes/applescript/workspace-status.applescript +177 -0
  156. package/dist-node/src/writes/applescript/workspace-targeting.applescript +359 -0
  157. package/dist-node/src/writes/chats.js +160 -0
  158. package/dist-node/src/writes/guards.js +77 -0
  159. package/dist-node/src/writes/runner.js +67 -0
  160. package/dist-node/src/{sidecar.js → writes/sidecar.js} +1 -1
  161. package/dist-node/src/writes/system.js +81 -0
  162. package/dist-node/src/writes/targeting.js +121 -0
  163. package/dist-node/src/writes/types.js +1 -0
  164. package/dist-node/src/writes/ui-lock.js +229 -0
  165. package/dist-node/src/writes/workspaces.js +233 -0
  166. package/docs/voice-setup.md +34 -12
  167. package/package.json +2 -2
  168. package/dist/assets/index-ByTwVInL.css +0 -1
  169. package/dist/assets/index-LAm9X7wv.js +0 -65
  170. package/dist-node/src/conductor.applescript +0 -2279
  171. package/dist-node/src/delegations.js +0 -557
  172. package/dist-node/src/mcp-tools.js +0 -1319
  173. package/dist-node/src/orchestration-db.js +0 -2186
  174. package/dist-node/src/reads.js +0 -787
  175. package/dist-node/src/workflow-coordinator.js +0 -2119
  176. package/dist-node/src/writes.js +0 -1223
  177. /package/dist-node/src/{conductor-settings.js → agents/conductor-settings.js} +0 -0
  178. /package/dist-node/src/{firstprompt.js → delivery/firstprompt.js} +0 -0
  179. /package/dist-node/src/{sendonce.js → delivery/sendonce.js} +0 -0
  180. /package/dist-node/src/{preview-urls.js → dev-server/preview-urls.js} +0 -0
  181. /package/dist-node/src/{run-configs.js → dev-server/run-configs.js} +0 -0
  182. /package/dist-node/src/{attachments.js → files/attachments.js} +0 -0
  183. /package/dist-node/src/{icons.js → files/icons.js} +0 -0
  184. /package/dist-node/src/{staged-attachments.js → files/staged-attachments.js} +0 -0
  185. /package/dist-node/src/{fork-workspace.js → git/fork-workspace.js} +0 -0
  186. /package/dist-node/src/{merge.js → git/merge.js} +0 -0
  187. /package/dist-node/src/{pr.js → git/pr.js} +0 -0
  188. /package/dist-node/src/{nosleep.js → host/nosleep.js} +0 -0
  189. /package/dist-node/src/{relay-processes.js → host/relay-processes.js} +0 -0
  190. /package/dist-node/src/{ui-lease-watchdog.js → host/ui-lease-watchdog.js} +0 -0
  191. /package/dist-node/src/{wifi.js → host/wifi.js} +0 -0
  192. /package/dist-node/src/{orchestration-schema.generated.js → orchestration/persistence/schema.generated.js} +0 -0
  193. /package/dist-node/src/{workflow-effect-runner.js → orchestration/workflow/effect-runner.js} +0 -0
  194. /package/dist-node/src/{workflow-http.js → orchestration/workflow/http.js} +0 -0
  195. /package/dist-node/src/{workflow-machine.js → orchestration/workflow/machine.js} +0 -0
  196. /package/dist-node/src/{session-poller.js → reads/session-poller.js} +0 -0
  197. /package/dist-node/src/{context-breakdown.js → transcript/context-breakdown.js} +0 -0
  198. /package/dist-node/src/{chat-cursor.js → transcript/cursor.js} +0 -0
  199. /package/dist-node/src/{plan-usage.js → usage/plan-usage.js} +0 -0
  200. /package/dist-node/src/{tool-usage-service.js → usage/tool-usage-service.js} +0 -0
@@ -8,985 +8,10 @@
8
8
  *
9
9
  * `yarn deploy` builds dist/ first, then runs `install`.
10
10
  */
11
- import { execFileSync, spawnSync } from 'node:child_process';
12
- import fs from 'node:fs';
13
- import os from 'node:os';
14
- import path from 'node:path';
15
- import { installedServiceEnvironment, serviceEnvironmentWithSetting } from "../src/config.js";
16
- import { packageRoot } from "../src/pkg-root.js";
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";
20
- import { qrLines } from "./qr.js";
21
- const LABEL = 'no.adluna.conductor-remote';
22
- const projectDir = packageRoot(import.meta.dirname);
23
- const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
24
- const logDir = path.join(os.homedir(), 'Library', 'Logs', 'conductor-remote');
25
- const uid = process.getuid?.() ?? 0;
26
- const domain = `gui/${uid}`;
27
- /**
28
- * Install-time knobs are accepted as documented CLI flags OR the matching env var — a flag wins over the
29
- * ambient env. Parsed flags are folded back into process.env so everything downstream (and the plist we
30
- * bake) keeps reading a single source. Runs before any module-level env read below.
31
- */
32
- const FLAG_ENV = {
33
- '--expose': 'EXPOSE',
34
- '--port': 'RELAY_PORT',
35
- '--voice-port': 'VOICE_PORT',
36
- '--host': 'RELAY_HOST',
37
- '--hostname': 'RELAY_HOSTNAME',
38
- '--token': 'RELAY_TOKEN',
39
- '--write-strategy': 'WRITE_STRATEGY',
40
- '--prevent-screen-lock': 'PREVENT_SCREEN_LOCK',
41
- '--auto-update': 'AUTO_UPDATE',
42
- '--auto-update-interval': 'AUTO_UPDATE_INTERVAL_MINUTES',
43
- '--funnel-watchdog': 'FUNNEL_WATCHDOG',
44
- '--funnel-watchdog-interval': 'FUNNEL_WATCHDOG_INTERVAL_SECONDS',
45
- '--db': 'CONDUCTOR_DB',
46
- '--workspaces': 'CONDUCTOR_WORKSPACES'
47
- };
48
- function applyFlags(argv) {
49
- for (let i = 0; i < argv.length; i++) {
50
- const arg = argv[i];
51
- if (!arg.startsWith('--'))
52
- continue;
53
- const eq = arg.indexOf('=');
54
- const name = eq === -1 ? arg : arg.slice(0, eq);
55
- const envKey = FLAG_ENV[name];
56
- if (!envKey) {
57
- console.error(`unknown flag: ${name}\n known: ${Object.keys(FLAG_ENV).join(', ')}`);
58
- process.exit(1);
59
- }
60
- const value = eq === -1 ? argv[++i] : arg.slice(eq + 1);
61
- if (value === undefined) {
62
- console.error(`flag ${name} needs a value (e.g. ${name} <value>)`);
63
- process.exit(1);
64
- }
65
- if (name === '--prevent-screen-lock' && value !== 'on' && value !== 'off') {
66
- console.error(`flag ${name} must be on or off`);
67
- process.exit(1);
68
- }
69
- process.env[envKey] = value;
70
- }
71
- }
72
- // argv[2] is the subcommand (see bottom); flags follow it. `logs` parses its own args (it takes -n /
73
- // --no-follow, which applyFlags would reject as unknown flags), so skip the shared flag pass for it.
11
+ import { applyFlags } from "./service/flags.js";
12
+ // Logs owns its -n/--no-follow arguments. All other flags win over ambient env.
74
13
  if ((process.argv[2] ?? 'status') !== 'logs')
75
14
  applyFlags(process.argv.slice(3));
76
- function xml(s) {
77
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
78
- }
79
- /** Run launchctl, swallowing the exit code so "already-loaded"/"not-loaded" states aren't fatal. */
80
- function launchctl(...args) {
81
- try {
82
- execFileSync('launchctl', args, { stdio: 'pipe' });
83
- }
84
- catch {
85
- // non-zero is expected for bootout-when-absent etc.; state is asserted by the caller's sequence
86
- }
87
- }
88
- /** Block the main thread briefly — used to let launchd settle between bootout and bootstrap. */
89
- function sleepSync(ms) {
90
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
91
- }
92
- /** Is the agent currently bootstrapped into the user domain? */
93
- function serviceLoaded() {
94
- try {
95
- execFileSync('launchctl', ['print', `${domain}/${LABEL}`], { stdio: 'pipe' });
96
- return true;
97
- }
98
- catch {
99
- return false;
100
- }
101
- }
102
- /**
103
- * Reload the agent from the freshly written plist. `bootout` of a *running* instance is asynchronous,
104
- * so we wait for it to fully unload before `bootstrap` — otherwise bootstrap races the teardown and
105
- * fails silently, leaving the relay down after a re-deploy. Bootstrap is retried and its failure is fatal.
106
- */
107
- function reloadAgent() {
108
- launchctl('bootout', `${domain}/${LABEL}`);
109
- for (let i = 0; i < 30 && serviceLoaded(); i++)
110
- sleepSync(100);
111
- let bootstrapped = false;
112
- for (let i = 0; i < 10 && !bootstrapped; i++) {
113
- try {
114
- execFileSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'pipe' });
115
- bootstrapped = true;
116
- }
117
- catch {
118
- sleepSync(150);
119
- }
120
- }
121
- if (!bootstrapped) {
122
- console.error(`✗ launchctl bootstrap failed for ${plistPath}`);
123
- console.error(` Inspect with: launchctl print ${domain}/${LABEL}`);
124
- process.exit(1);
125
- }
126
- launchctl('enable', `${domain}/${LABEL}`);
127
- launchctl('kickstart', '-k', `${domain}/${LABEL}`);
128
- }
129
- /** Node runs the relay via the flag-free CLI shim; the absolute execPath is baked at install time. */
130
- function buildPlist() {
131
- const node = xml(process.execPath);
132
- const proj = xml(projectDir);
133
- const out = xml(path.join(logDir, 'relay.log'));
134
- const err = xml(path.join(logDir, 'relay.err.log'));
135
- // node's own dir leads so the daemon can find `npm` (adjacent to node) for self-update under launchd's
136
- // bare PATH; Homebrew's bin is appended for tailscale/node on Apple Silicon.
137
- const nodeDir = path.dirname(process.execPath);
138
- const daemonPath = `${nodeDir}:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin`;
139
- // MANAGED marks this as the launchd-supervised instance: autoupdate.ts only self-restarts (exit →
140
- // KeepAlive respawn) when it sees this, so a dev `yarn start` or worktree run never auto-updates.
141
- const envEntries = [
142
- ['PATH', daemonPath],
143
- ['CONDUCTOR_REMOTE_MANAGED', '1']
144
- ];
145
- // EXPOSE belongs here with the other runtime knobs, and leaving it out was a real bug. The daemon
146
- // reads its posture on every boot (the funnel watchdog only arms itself for `public`), but install()
147
- // reloads launchd *before* ensureTailscale() persists the choice — so a fresh `--expose tailnet`
148
- // daemon came up reading the old posture, armed the watchdog against a Funnel that was about to be
149
- // switched off, and ~3 failed probes later healed it straight back to public. Baking the resolved
150
- // value makes the daemon's own environment the answer, so nothing depends on write ordering.
151
- if (process.env.EXPOSE)
152
- envEntries.push(['EXPOSE', process.env.EXPOSE]);
153
- if (process.env.WRITE_STRATEGY)
154
- envEntries.push(['WRITE_STRATEGY', process.env.WRITE_STRATEGY]);
155
- if (process.env.PREVENT_SCREEN_LOCK)
156
- envEntries.push(['PREVENT_SCREEN_LOCK', process.env.PREVENT_SCREEN_LOCK]);
157
- if (process.env.RELAY_HOST)
158
- envEntries.push(['RELAY_HOST', process.env.RELAY_HOST]);
159
- if (process.env.RELAY_PORT)
160
- envEntries.push(['RELAY_PORT', process.env.RELAY_PORT]);
161
- if (process.env.VOICE_PORT)
162
- envEntries.push(['VOICE_PORT', process.env.VOICE_PORT]);
163
- if (process.env.AUTO_UPDATE)
164
- envEntries.push(['AUTO_UPDATE', process.env.AUTO_UPDATE]);
165
- if (process.env.AUTO_UPDATE_INTERVAL_MINUTES)
166
- envEntries.push(['AUTO_UPDATE_INTERVAL_MINUTES', process.env.AUTO_UPDATE_INTERVAL_MINUTES]);
167
- if (process.env.FUNNEL_WATCHDOG)
168
- envEntries.push(['FUNNEL_WATCHDOG', process.env.FUNNEL_WATCHDOG]);
169
- if (process.env.FUNNEL_WATCHDOG_INTERVAL_SECONDS)
170
- envEntries.push(['FUNNEL_WATCHDOG_INTERVAL_SECONDS', process.env.FUNNEL_WATCHDOG_INTERVAL_SECONDS]);
171
- if (process.env.CONDUCTOR_DB)
172
- envEntries.push(['CONDUCTOR_DB', process.env.CONDUCTOR_DB]);
173
- if (process.env.CONDUCTOR_WORKSPACES)
174
- envEntries.push(['CONDUCTOR_WORKSPACES', process.env.CONDUCTOR_WORKSPACES]);
175
- const envXml = envEntries.map(([k, v]) => `\t\t<key>${xml(k)}</key>\n\t\t<string>${xml(v)}</string>`).join('\n');
176
- return `<?xml version="1.0" encoding="UTF-8"?>
177
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
178
- <plist version="1.0">
179
- <dict>
180
- <key>Label</key>
181
- <string>${LABEL}</string>
182
- <key>ProgramArguments</key>
183
- <array>
184
- <string>${node}</string>
185
- <string>${proj}/bin/cli.js</string>
186
- </array>
187
- <key>WorkingDirectory</key>
188
- <string>${proj}</string>
189
- <key>EnvironmentVariables</key>
190
- <dict>
191
- ${envXml}
192
- </dict>
193
- <key>RunAtLoad</key>
194
- <true/>
195
- <key>KeepAlive</key>
196
- <true/>
197
- <key>ProcessType</key>
198
- <string>Background</string>
199
- <key>StandardOutPath</key>
200
- <string>${out}</string>
201
- <key>StandardErrorPath</key>
202
- <string>${err}</string>
203
- </dict>
204
- </plist>
205
- `;
206
- }
207
- function distBuilt() {
208
- return fs.existsSync(path.join(projectDir, 'dist', 'index.html'));
209
- }
210
- function tokenStorePath() {
211
- return path.join(os.homedir(), 'Library', 'Application Support', 'conductor-remote', 'token');
212
- }
213
- /** Read the persisted token (or env override) purely to print the phone URL — never mints one. */
214
- function currentToken() {
215
- if (process.env.RELAY_TOKEN)
216
- return process.env.RELAY_TOKEN;
217
- try {
218
- return fs.readFileSync(tokenStorePath(), 'utf8').trim() || null;
219
- }
220
- catch {
221
- return null;
222
- }
223
- }
224
- /**
225
- * A pinned token (`--token` / `RELAY_TOKEN`) is persisted to the token file, not baked into the plist —
226
- * the launchd daemon has no such env, so it resolves the secret from this file (config.ts ▸ resolveToken).
227
- * Writing it here keeps the daemon, the printed URL, and later `status` all in agreement.
228
- */
229
- function persistPinnedToken() {
230
- const token = process.env.RELAY_TOKEN;
231
- if (!token)
232
- return;
233
- try {
234
- fs.mkdirSync(path.dirname(tokenStorePath()), { recursive: true });
235
- fs.writeFileSync(tokenStorePath(), token, { mode: 0o600 });
236
- }
237
- catch (err) {
238
- console.info(` ⚠ could not persist --token (${err instanceof Error ? err.message : err})`);
239
- }
240
- }
241
- const RELAY_PORT = relayPort();
242
- const VOICE_PORT = String(process.env.VOICE_PORT ?? 8788);
243
- /**
244
- * Resolve the expose mode. Precedence: `EXPOSE` env (public|funnel / tailnet|serve|private) > persisted
245
- * choice > 'public' default. An explicit env value is persisted so re-deploys don't silently flip posture.
246
- * (The read-only counterpart used by the runtime watchdog is readExposeMode() in src/tailscale.ts.)
247
- */
248
- function resolveExposeMode() {
249
- const fromEnv = normalizeExposeMode(process.env.EXPOSE);
250
- if (fromEnv) {
251
- try {
252
- const file = exposeStorePath();
253
- fs.mkdirSync(path.dirname(file), { recursive: true });
254
- fs.writeFileSync(file, fromEnv);
255
- }
256
- catch {
257
- // persistence is a convenience; ignore failures
258
- }
259
- return fromEnv;
260
- }
261
- if (process.env.EXPOSE)
262
- console.info(` ⚠ unrecognized EXPOSE=${process.env.EXPOSE} — expected public|tailnet.`);
263
- try {
264
- const saved = normalizeExposeMode(fs.readFileSync(exposeStorePath(), 'utf8'));
265
- if (saved)
266
- return saved;
267
- }
268
- catch {
269
- // no saved choice yet
270
- }
271
- return 'public';
272
- }
273
- /**
274
- * Live serve/funnel state for this node as it concerns the relay: the HTTPS port fronting it (any port, not
275
- * only :443), whether that port is public, and which ports carry mounts that are not the relay's. A status
276
- * that cannot be read counts as nothing fronting — the same answer the old reader gave, and one that only
277
- * ever leads to a mount on a port nobody holds.
278
- */
279
- function tailscaleState(bin, port = RELAY_PORT) {
280
- try {
281
- const out = execFileSync(bin, ['serve', 'status', '--json'], { encoding: 'utf8', stdio: 'pipe' });
282
- return relayServeState(parseServeStatus(out), port);
283
- }
284
- catch {
285
- return relayServeState({}, port);
286
- }
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
- }
296
- /**
297
- * The one Tailscale write here: `tailscale <serve|funnel> --bg --https=<port> <relay port>`. Always scoped
298
- * to a port the relay owns outright or nobody holds — the callers check, this does not. Serving a port
299
- * Funnel currently exposes drops that port's Funnel flag and touches no other port, which is what made
300
- * the old `funnel reset` (every mount on the node, gone) unnecessary. Returns the CLI's error, or null.
301
- */
302
- function mountRelay(bin, kind, port) {
303
- try {
304
- execFileSync(bin, [kind, '--bg', '--yes', `--https=${port}`, RELAY_PORT], { stdio: 'pipe' });
305
- return null;
306
- }
307
- catch (err) {
308
- return err instanceof Error ? err.message.trim() : String(err);
309
- }
310
- }
311
- function byHand(kind, port) {
312
- return `tailscale ${kind} --bg --yes --https=${port} ${RELAY_PORT}`;
313
- }
314
- function describeTaken(state, port) {
315
- return (state.taken.get(port) ?? []).join(', ');
316
- }
317
- /** A port the relay shares is left exactly as found: Funnel is per port, so a change would drag the rest along. */
318
- function leaveShared(url, state, want) {
319
- console.info(`\n ⚠ ${url} fronts the relay, but :${state.port} also carries ${state.shared.join(', ')}.`);
320
- console.info(` Funnel is per port, so making the relay ${want} would take those mounts along. Left as is.`);
321
- console.info(` To change it, move the relay to a port of its own: ${byHand(want === 'public' ? 'funnel' : 'serve', '<port>')}`);
322
- }
323
- function refuseNoPort(state, candidates) {
324
- const held = candidates.map(p => `:${p} → ${describeTaken(state, p)}`).join('; ');
325
- console.info(`\n ⚠ every port the relay could take already carries another service (${held}). Not touching them.`);
326
- console.info(` Free one, then run \`yarn deploy\` again — or mount by hand: ${byHand('serve', '<port>')}`);
327
- }
328
- /**
329
- * Assert a tailnet-only `serve` proxy in front of the relay — tailnet mode, and the fallback when Funnel is
330
- * refused. Non-destructive by construction: a mapping is kept wherever it already lives (:443 or not), a
331
- * shared port is reported rather than rewritten, and a fresh mount steps around any port someone else holds.
332
- * The order of candidates is 443 (the default, unchanged), then the relay's own port number — the memorable
333
- * choice, and it leaves 8443/10000 for services that need Funnel — then the remaining Funnel ports.
334
- */
335
- function ensureServeOnly(bin, dns, state) {
336
- const host = dns ?? '<node>';
337
- if (state.port !== null) {
338
- const url = serveUrl(host, state.port);
339
- if (!state.funnelOn) {
340
- console.info(`✓ tailscale serve fronts ${url} → 127.0.0.1:${RELAY_PORT} (tailnet-only)`);
341
- return;
342
- }
343
- if (state.shared.length) {
344
- leaveShared(url, state, 'tailnet-only');
345
- return;
346
- }
347
- const failed = mountRelay(bin, 'serve', state.port);
348
- if (failed) {
349
- console.info(`\n ⚠ could not take Funnel off ${url} (${failed}). Run by hand:\n ${byHand('serve', state.port)}`);
350
- return;
351
- }
352
- console.info(`✓ tailscale serve → ${url} is tailnet-only again → 127.0.0.1:${RELAY_PORT}`);
353
- return;
354
- }
355
- const candidates = [443, Number(RELAY_PORT), ...FUNNEL_PORTS.filter(p => p !== 443)];
356
- const port = freeServePort(state, candidates);
357
- if (port === null) {
358
- refuseNoPort(state, candidates);
359
- return;
360
- }
361
- if (port !== 443)
362
- console.info(` :443 carries ${describeTaken(state, 443)}, so the relay goes on :${port} instead.`);
363
- const url = serveUrl(host, port);
364
- const failed = mountRelay(bin, 'serve', port);
365
- if (failed) {
366
- console.info(`\n ⚠ could not configure tailscale serve (${failed}). Run by hand:\n ${byHand('serve', port)}`);
367
- return;
368
- }
369
- console.info(`✓ tailscale serve → ${url} proxies 127.0.0.1:${RELAY_PORT} (tailnet-only)`);
370
- }
371
- /**
372
- * Persist a stable Tailscale device name so this node's MagicDNS URL — the origin the installed PWA is
373
- * bolted to — can't drift out from under it. Tailscale otherwise *derives* the name from the Mac's
374
- * LocalHostName, which a macOS update or a network/settings reset can silently clear; the name then moves
375
- * (e.g. `mac` → `macbook-pro`), the URL changes, and every home-screen PWA pinned to the old origin dies
376
- * with an unexplained "failed to fetch". We pin `--hostname`/`RELAY_HOSTNAME` when given (also the way to
377
- * *rename* — `--hostname mac` restores a drifted node), else re-pin whatever name the node already has: a
378
- * no-op on today's URL, but it turns an auto-derived name into an explicit one Tailscale won't re-derive.
379
- * Best-effort — a failure just leaves the name auto-derived, exactly as before this ran.
380
- */
381
- function pinHostname(bin) {
382
- const label = (dns) => (dns ?? '').split('.')[0];
383
- const current = label(magicDnsName(bin));
384
- const desired = (process.env.RELAY_HOSTNAME ?? '').trim() || current;
385
- if (!desired)
386
- return; // node name unknown and none requested — nothing to pin
387
- try {
388
- execFileSync(bin, ['set', `--hostname=${desired}`], { stdio: 'pipe' });
389
- }
390
- catch (err) {
391
- console.info(` ⚠ could not pin tailscale hostname to "${desired}" (${err instanceof Error ? err.message.trim() : err}) — name stays auto-derived.`);
392
- return;
393
- }
394
- if (desired === current) {
395
- console.info(`✓ tailscale device name pinned to "${desired}" (won't drift on an OS hostname change)`);
396
- return;
397
- }
398
- // A rename: wait briefly for MagicDNS to reflect it, so the URL and Funnel cert below use the new name.
399
- for (let i = 0; i < 12 && label(magicDnsName(bin)) !== desired; i++)
400
- sleepSync(500);
401
- const actual = label(magicDnsName(bin));
402
- if (actual === desired)
403
- console.info(`✓ tailscale device renamed "${current || '<derived>'}" → "${desired}" and pinned`);
404
- else
405
- console.info(` ⚠ requested hostname "${desired}" but the tailnet assigned "${actual}" (name likely already taken) — your URL is https://${actual}.…`);
406
- }
407
- /**
408
- * Front the loopback relay with a stable HTTPS URL, either publicly (`tailscale funnel`, the default) or
409
- * tailnet-only (`tailscale serve`), per resolveExposeMode(). Idempotent — flips Funnel off when switching
410
- * back to tailnet — and non-fatal: the relay binds loopback regardless, so a failure here just means the
411
- * phone URL isn't wired yet and we print how to do it by hand. Real TLS also satisfies the PWA's
412
- * secure-context requirement (a service worker won't register over plain http on a 100.x IP).
413
- *
414
- * PUBLIC IS INTERNET-FACING: the 128-bit token on every /api/* request is the only gate. Funnel must be
415
- * enabled for the tailnet (Admin console) or the funnel command fails — we then fall back to tailnet-only.
416
- */
417
- function ensureTailscale() {
418
- const bin = tailscaleBin();
419
- if (!bin) {
420
- console.info('\n ⚠ tailscale CLI not found — skipped URL setup. Once Tailscale is installed, run:');
421
- console.info(` tailscale funnel --bg ${RELAY_PORT} # public, or \`serve\` for tailnet-only`);
422
- return;
423
- }
424
- pinHostname(bin);
425
- const dns = magicDnsName(bin);
426
- if (dns)
427
- writeUrlHost(dns); // baseline for drift detection (server startup + `service status` compare against this)
428
- const host = dns ?? '<node>';
429
- const mode = resolveExposeMode();
430
- const state = tailscaleState(bin);
431
- if (mode === 'tailnet') {
432
- ensureServeOnly(bin, dns, state);
433
- return;
434
- }
435
- // public (Funnel)
436
- if (state.port !== null) {
437
- const url = serveUrl(host, state.port);
438
- if (state.funnelOn) {
439
- console.info(`✓ tailscale funnel already exposes ${url} → 127.0.0.1:${RELAY_PORT} (public, token-gated)`);
440
- return;
441
- }
442
- if (state.shared.length) {
443
- leaveShared(url, state, 'public');
444
- return;
445
- }
446
- if (!FUNNEL_PORTS.includes(state.port)) {
447
- console.info(`\n ⚠ the relay is fronted tailnet-only at ${url}, and Funnel cannot listen on :${state.port}`);
448
- console.info(` (only ${FUNNEL_PORTS.join(', ')}). Left as is — \`conductor-remote config\` will show the mismatch.`);
449
- console.info(` To go public, move it to a Funnel port by hand: ${byHand('funnel', '<443|8443|10000>')}`);
450
- return;
451
- }
452
- const failed = mountRelay(bin, 'funnel', state.port);
453
- if (failed) {
454
- funnelRefused(failed);
455
- ensureServeOnly(bin, dns, state);
456
- return;
457
- }
458
- console.info(`✓ tailscale funnel → ${url} now public over the internet (token-gated) → 127.0.0.1:${RELAY_PORT}`);
459
- return;
460
- }
461
- const port = freeServePort(state, FUNNEL_PORTS);
462
- if (port === null) {
463
- refuseNoPort(state, FUNNEL_PORTS);
464
- return;
465
- }
466
- if (port !== 443)
467
- console.info(` :443 carries ${describeTaken(state, 443)}, so the relay goes on :${port} instead.`);
468
- const failed = mountRelay(bin, 'funnel', port);
469
- if (failed) {
470
- funnelRefused(failed);
471
- ensureServeOnly(bin, dns, state);
472
- return;
473
- }
474
- console.info(`✓ tailscale funnel → ${serveUrl(host, port)} now public over the internet (token-gated) → 127.0.0.1:${RELAY_PORT}`);
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
- }
582
- function funnelRefused(error) {
583
- console.info(`\n ⚠ could not enable Funnel (${error}).`);
584
- console.info(' Funnel must be enabled for this tailnet: open the URL Tailscale printed above, or add the');
585
- console.info(' "funnel" nodeAttr in Admin console ▸ Access controls. Falling back to tailnet-only for now.');
586
- }
587
- /** Print a scannable QR of `url` (theme-independent black-on-white). Never fatal — QR is a convenience. */
588
- function printQr(url) {
589
- try {
590
- console.info(`\n${qrLines(url).join('\n')}`);
591
- }
592
- catch (err) {
593
- console.info(` (QR skipped: ${err instanceof Error ? err.message : err})`);
594
- }
595
- }
596
- function printUrl(loopbackPort = RELAY_PORT) {
597
- const token = currentToken();
598
- const frag = `#token=${token ?? '<starts on first run>'}`;
599
- const bin = tailscaleBin();
600
- if (bin) {
601
- const drift = driftWarningLines(bin);
602
- if (drift.length)
603
- console.info(`\n${drift.join('\n')}`);
604
- }
605
- const dns = bin ? magicDnsName(bin) : null;
606
- const state = bin ? tailscaleState(bin, loopbackPort) : relayServeState({}, loopbackPort);
607
- if (dns && state.port !== null) {
608
- const scope = state.funnelOn ? 'public — any browser, token-gated' : 'same Tailnet only';
609
- const url = `${serveUrl(dns, state.port)}${frag}`;
610
- console.info(`\n Phone URL (HTTPS, ${scope}):\n ${url}`);
611
- if (token) {
612
- console.info('\n Scan to open on your phone:');
613
- printQr(url);
614
- }
615
- return;
616
- }
617
- // Nothing fronting yet — the relay is only on loopback.
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`);
639
- }
640
- /** npx unpacks into a throwaway cache that gets purged; a LaunchAgent baked against it would rot. */
641
- function isEphemeralInstall(dir) {
642
- return /[\\/]_npx[\\/]|[\\/]\.npm[\\/]_npx[\\/]/.test(dir);
643
- }
644
- function install() {
645
- if (isEphemeralInstall(projectDir)) {
646
- console.error(`✗ refusing to install from an npx cache path:\n ${projectDir}\n` +
647
- ' That directory is temporary and gets purged, which would break the LaunchAgent.\n' +
648
- ' Install globally first: `npm i -g conductor-remote`, then `conductor-remote service install`.');
649
- process.exit(1);
650
- }
651
- if (!distBuilt()) {
652
- console.error('✗ dist/ not built. Run `yarn build` first (or use `yarn deploy`, which builds).');
653
- process.exit(1);
654
- }
655
- persistPinnedToken();
656
- // Resolve (and persist) the expose posture *before* the plist is written and launchd is reloaded, so
657
- // the daemon starts with an explicit EXPOSE in its own environment rather than racing the file
658
- // ensureTailscale() writes further down. Folding it back into process.env is how every other knob
659
- // travels here (see applyFlags), and it keeps ensureTailscale()'s own resolve a no-op re-read.
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
- }
676
- fs.mkdirSync(path.dirname(plistPath), { recursive: true });
677
- fs.mkdirSync(logDir, { recursive: true });
678
- fs.writeFileSync(plistPath, buildPlist());
679
- reloadAgent();
680
- const changedSetting = process.env.CONDUCTOR_REMOTE_CONFIG_SET;
681
- if (changedSetting) {
682
- if (changedSetting === 'expose' ||
683
- changedSetting === 'port' ||
684
- changedSetting === 'voice-port' ||
685
- changedSetting === 'hostname' ||
686
- changedSetting.startsWith('voice.')) {
687
- ensureTailscale();
688
- ensureVoiceFunnel();
689
- }
690
- console.info(`✓ set ${changedSetting}; the relay restarted with the new value.`);
691
- console.info(' Check it with: conductor-remote config');
692
- return;
693
- }
694
- console.info(`✓ installed LaunchAgent ${LABEL}`);
695
- console.info(` plist: ${plistPath}`);
696
- console.info(` logs: ${logDir}/relay.log`);
697
- console.info(` node: ${process.execPath}`);
698
- ensureTailscale();
699
- ensureVoiceFunnel();
700
- printUrl();
701
- console.info('\n Note: a node version change (nvm) invalidates the baked path — re-run `yarn deploy` after upgrading node.');
702
- console.info(' Note: the AppleScript write path needs Accessibility permission granted to this node binary (System Settings ▸ Privacy).');
703
- }
704
- function uninstall() {
705
- launchctl('bootout', `${domain}/${LABEL}`);
706
- try {
707
- fs.rmSync(plistPath);
708
- }
709
- catch {
710
- // already gone
711
- }
712
- console.info(`✓ removed LaunchAgent ${LABEL}`);
713
- }
714
- function restart() {
715
- launchctl('kickstart', '-k', `${domain}/${LABEL}`);
716
- console.info(`✓ restarted ${LABEL}`);
717
- const env = readPlistEnv();
718
- const relayPort = env.RELAY_PORT ?? '8787';
719
- printUrl(relayPort);
720
- printVoiceRoute(relayPort, env.VOICE_PORT ?? '8788');
721
- }
722
- /**
723
- * The daemon's environment, read back out of the plist it was installed with.
724
- *
725
- * This is the whole point of `config`: `process.env` here belongs to *your shell*, and the daemon
726
- * runs under launchd with whatever `buildPlist()` baked in. Reading the shell's env to report the
727
- * daemon's configuration is how a posture change can look applied while the running relay still
728
- * believes something else — which is exactly the bug that made EXPOSE a plist entry.
729
- */
730
- function readPlistEnv() {
731
- return installedServiceEnvironment();
732
- }
733
- const CONFIG_KEYS = new Map(Object.entries(FLAG_ENV).map(([flag, env]) => [flag.slice(2), env]));
734
- const HOME = os.homedir();
735
- const tilde = (p) => (p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p);
736
- const KNOBS = [
737
- {
738
- name: 'expose',
739
- env: 'EXPOSE',
740
- // The runtime's own precedence (src/tailscale.ts ▸ readExposeMode): env, then the persisted
741
- // file, then public. Reported the same way so this can never describe a posture the relay
742
- // isn't running.
743
- fallback: () => {
744
- try {
745
- return normalizeExposeMode(fs.readFileSync(exposeStorePath(), 'utf8')) ?? 'public';
746
- }
747
- catch {
748
- return 'public';
749
- }
750
- },
751
- fallbackSource: () => (fs.existsSync(exposeStorePath()) ? 'expose file' : 'default')
752
- },
753
- { name: 'port', env: 'RELAY_PORT', fallback: () => '8787' },
754
- { name: 'voice-port', env: 'VOICE_PORT', fallback: () => '8788' },
755
- { name: 'host', env: 'RELAY_HOST', fallback: () => '127.0.0.1' },
756
- { name: 'write-strategy', env: 'WRITE_STRATEGY', fallback: () => 'applescript' },
757
- { name: 'prevent-screen-lock', env: 'PREVENT_SCREEN_LOCK', fallback: () => 'on' },
758
- { name: 'auto-update', env: 'AUTO_UPDATE', fallback: () => 'auto' },
759
- { name: 'auto-update-interval', env: 'AUTO_UPDATE_INTERVAL_MINUTES', fallback: () => '5 (minutes)' },
760
- {
761
- name: 'funnel-watchdog',
762
- env: 'FUNNEL_WATCHDOG',
763
- // Two gates, not one: it defaults on for the managed daemon and then stands down unless the
764
- // posture is public (src/funnel-watchdog.ts ▸ wantEnabled + startFunnelWatchdog).
765
- fallback: env => (env.CONDUCTOR_REMOTE_MANAGED === '1' ? 'on (managed daemon)' : 'off (not managed)')
766
- },
767
- { name: 'funnel-watchdog-interval', env: 'FUNNEL_WATCHDOG_INTERVAL_SECONDS', fallback: () => '60 (seconds)' },
768
- {
769
- name: 'db',
770
- env: 'CONDUCTOR_DB',
771
- fallback: () => tilde(path.join(HOME, 'Library', 'Application Support', 'com.conductor.app', 'conductor.db'))
772
- },
773
- {
774
- name: 'workspaces',
775
- env: 'CONDUCTOR_WORKSPACES',
776
- fallback: () => tilde(path.join(HOME, 'conductor', 'workspaces'))
777
- }
778
- ];
779
- /** Set one daemon knob while preserving every other value already baked into the plist. */
780
- function setConfig(args) {
781
- const [name, value, ...extra] = args;
782
- const envKey = name ? CONFIG_KEYS.get(name) : undefined;
783
- if (!name || value === undefined || extra.length > 0) {
784
- console.error('usage: conductor-remote config set <setting> <value>');
785
- process.exit(1);
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
- }
806
- if (!envKey) {
807
- console.error(`config set: unknown setting "${name}"\n known: ${[...CONFIG_KEYS.keys(), ...VOICE_SETTING_NAMES].join(', ')}`);
808
- process.exit(1);
809
- }
810
- if (name === 'prevent-screen-lock' && value !== 'on' && value !== 'off') {
811
- console.error('config set: prevent-screen-lock must be on or off');
812
- process.exit(1);
813
- }
814
- if (!fs.existsSync(plistPath)) {
815
- console.error('config set: the service is not installed. Run `conductor-remote service install` first.');
816
- process.exit(1);
817
- }
818
- // Re-run install in a fresh process so module-level values such as RELAY_PORT see
819
- // the new environment. Start from the plist, which protects unrelated settings
820
- // from ambient shell variables and from being reset to defaults.
821
- const childEnv = serviceEnvironmentWithSetting(process.env, readPlistEnv(), CONFIG_KEYS.values(), envKey, value);
822
- childEnv.CONDUCTOR_REMOTE_CONFIG_SET = name;
823
- const cli = path.join(projectDir, 'bin', 'cli.js');
824
- const result = spawnSync(process.execPath, [cli, 'service', 'install'], { env: childEnv, stdio: 'inherit' });
825
- if (result.error)
826
- console.error(`config set: could not restart the service (${result.error.message})`);
827
- process.exit(result.status ?? 1);
828
- }
829
- /** What the daemon is configured with, where values came from, and an optional setter. */
830
- function config() {
831
- if (process.argv[3] === 'set') {
832
- setConfig(process.argv.slice(4));
833
- return;
834
- }
835
- if (process.argv[3] !== undefined) {
836
- console.error('usage: conductor-remote config [set <setting> <value>]');
837
- process.exit(1);
838
- }
839
- if (!fs.existsSync(plistPath)) {
840
- console.info(`plist: (not installed)\n\nRun \`conductor-remote service install\` first.`);
841
- return;
842
- }
843
- const env = readPlistEnv();
844
- console.info(`plist: ${tilde(plistPath)}`);
845
- console.info(`state: ${tilde(path.dirname(exposeStorePath()))}`);
846
- try {
847
- const out = execFileSync('launchctl', ['print', `${domain}/${LABEL}`], { encoding: 'utf8', stdio: 'pipe' });
848
- console.info(`daemon: ${out.match(/state = (\S+)/)?.[1] ?? 'unknown'} (pid ${out.match(/pid = (\d+)/)?.[1] ?? '—'})`);
849
- }
850
- catch {
851
- console.info('daemon: loaded but not running (check `conductor-remote logs`)');
852
- }
853
- const rows = KNOBS.map(k => {
854
- const set = env[k.env];
855
- return {
856
- name: k.name,
857
- value: set ?? k.fallback(env),
858
- source: set ? 'plist' : (k.fallbackSource?.(env) ?? 'default')
859
- };
860
- });
861
- // The token is deliberately not a row above: it never rides in the plist, and this output is the
862
- // kind of thing that gets pasted into an issue. Shown truncated, like every other log surface here.
863
- let token = '(none yet — minted on first start)';
864
- try {
865
- const raw = fs.readFileSync(path.join(path.dirname(exposeStorePath()), 'token'), 'utf8').trim();
866
- if (raw)
867
- token = `${raw.slice(0, 4)}…${raw.slice(-4)}`;
868
- }
869
- catch {
870
- // no token file yet
871
- }
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' });
879
- // The HTTPS port is a live Tailscale fact, not a plist knob: :443 by default, elsewhere once another
880
- // service holds :443 (see ensureServeOnly), and the phone URL carries whichever it is. Read against the
881
- // daemon's own port, since this shell's RELAY_PORT is not the one the relay listens on.
882
- const bin = tailscaleBin();
883
- const livePort = env.RELAY_PORT ?? '8787';
884
- const live = bin ? tailscaleState(bin, livePort) : relayServeState({}, livePort);
885
- rows.push({
886
- name: 'https-port',
887
- value: live.port === null ? '(not fronted)' : String(live.port),
888
- source: 'tailscale serve'
889
- });
890
- const width = Math.max(...rows.map(r => r.name.length));
891
- const valueWidth = Math.max(...rows.map(r => r.value.length));
892
- console.info('');
893
- for (const r of rows) {
894
- console.info(` ${r.name.padEnd(width)} ${r.value.padEnd(valueWidth)} ${r.source}`);
895
- }
896
- console.info('\n (values come from the daemon\u2019s own plist environment, not this shell\u2019s)');
897
- console.info(' change one with: conductor-remote config set <setting> <value>');
898
- // The cross-check worth having. A posture the relay believes and a Tailscale that is doing something
899
- // else is invisible in every other command, and it is self-healing in the wrong direction: the funnel
900
- // watchdog only runs for `public`, so a relay that thinks it is public will re-register Funnel.
901
- const configured = rows.find(r => r.name === 'expose')?.value;
902
- if (!bin || !configured)
903
- return;
904
- const liveMode = live.funnelOn ? 'public' : live.port !== null ? 'tailnet' : null;
905
- const where = live.port !== null && live.port !== 443 ? ` on :${live.port}` : '';
906
- const funnelable = live.port === null || FUNNEL_PORTS.includes(live.port);
907
- console.info('');
908
- if (liveMode === null) {
909
- console.info(` \u26a0 tailscale is not fronting 127.0.0.1:${livePort} at all — the phone URL is not wired.`);
910
- console.info(' Fix with: conductor-remote service install');
911
- }
912
- else if (liveMode === configured) {
913
- console.info(` \u2713 tailscale agrees: ${liveMode === 'public' ? 'Funnel on (internet-facing)' : 'serve only (tailnet)'}${where}`);
914
- }
915
- else {
916
- console.info(` \u26a0 tailscale says ${liveMode}${where}, the daemon is configured ${configured}. They must match:`);
917
- console.info(` the funnel watchdog only runs for \`public\`, so the two can heal each other apart.`);
918
- if (configured === 'public' && !funnelable) {
919
- console.info(` Funnel cannot listen on :${live.port} (only ${FUNNEL_PORTS.join(', ')}), so either move the relay by hand`);
920
- console.info(` (tailscale funnel --bg --yes --https=<443|8443|10000> ${livePort}) or match the daemon to it:`);
921
- console.info(' conductor-remote service install --expose tailnet');
922
- }
923
- else {
924
- console.info(` Fix with: conductor-remote service install --expose ${configured}`);
925
- }
926
- }
927
- }
928
- function status() {
929
- const installed = fs.existsSync(plistPath);
930
- console.info(`plist: ${installed ? plistPath : '(not installed)'}`);
931
- if (!installed)
932
- return;
933
- try {
934
- const out = execFileSync('launchctl', ['print', `${domain}/${LABEL}`], { encoding: 'utf8', stdio: 'pipe' });
935
- const state = out.match(/state = (\S+)/)?.[1] ?? 'unknown';
936
- const pid = out.match(/pid = (\d+)/)?.[1] ?? '—';
937
- console.info(`state: ${state} (pid ${pid})`);
938
- }
939
- catch {
940
- console.info('state: loaded but not running (check logs)');
941
- }
942
- const env = readPlistEnv();
943
- const relayPort = env.RELAY_PORT ?? '8787';
944
- printUrl(relayPort);
945
- printVoiceRoute(relayPort, env.VOICE_PORT ?? '8788');
946
- }
947
- /**
948
- * Stream the LaunchAgent's stdout+stderr logs (the same files the plist points at). Follows both by default
949
- * — `--no-follow` for a one-shot tail, `-n N` for depth. Pure `tail` passthrough so Ctrl-C just detaches.
950
- */
951
- function logs() {
952
- const files = ['relay.log', 'relay.err.log'].map(f => path.join(logDir, f)).filter(f => fs.existsSync(f));
953
- if (files.length === 0) {
954
- console.info(`no logs yet in ${logDir}/ — the relay writes there once the LaunchAgent is running (\`conductor-remote service install\`).`);
955
- return;
956
- }
957
- const argv = process.argv.slice(3);
958
- const follow = !argv.includes('--no-follow');
959
- const nAt = argv.indexOf('-n');
960
- const count = nAt !== -1 && argv[nAt + 1] ? argv[nAt + 1] : '200';
961
- const args = ['-n', count, ...(follow ? ['-F'] : []), ...files];
962
- const res = spawnSync('tail', args, { stdio: 'inherit' });
963
- // tail exits 0 normally; Ctrl-C kills it via SIGINT (null status). Only surface a genuine non-zero exit.
964
- if (typeof res.status === 'number' && res.status !== 0)
965
- process.exit(res.status);
966
- }
967
- const cmd = process.argv[2] ?? 'status';
968
- switch (cmd) {
969
- case 'install':
970
- install();
971
- break;
972
- case 'uninstall':
973
- uninstall();
974
- break;
975
- case 'restart':
976
- restart();
977
- break;
978
- case 'status':
979
- status();
980
- break;
981
- case 'config':
982
- config();
983
- break;
984
- case 'logs':
985
- logs();
986
- break;
987
- default:
988
- console.error(`unknown command: ${cmd}\n` +
989
- 'usage: service.ts <install|uninstall|restart|status|config|logs> [flags]\n' +
990
- ` flags (install): ${Object.keys(FLAG_ENV).join(', ')}`);
991
- process.exit(1);
992
- }
15
+ // Import only after parsing flags: environment.ts freezes the relay/voice ports.
16
+ const { runServiceCommand } = await import("./service/commands.js");
17
+ runServiceCommand();