conductor-remote 1.116.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-BPp5Cf-p.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 +130 -9
  132. package/dist-node/src/{transcript.js → transcript/parser.js} +4 -124
  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-DNLdIfZ2.css +0 -1
  169. package/dist/assets/index-QJR7tn2I.js +0 -61
  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
@@ -0,0 +1,244 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { exposeStorePath, FUNNEL_PORTS, freeServePort, magicDnsName, normalizeExposeMode, parseServeStatus, relayServeState, serveUrl, tailscaleBin, writeUrlHost } from "../../src/host/tailscale.js";
5
+ import { RELAY_PORT } from "./environment.js";
6
+ import { sleepSync } from "./launch-agent.js";
7
+ /**
8
+ * Resolve the expose mode. Precedence: `EXPOSE` env (public|funnel / tailnet|serve|private) > persisted
9
+ * choice > 'public' default. An explicit env value is persisted so re-deploys don't silently flip posture.
10
+ * (The read-only counterpart used by the runtime watchdog is readExposeMode() in src/host/tailscale.ts.)
11
+ */
12
+ export function resolveExposeMode() {
13
+ const fromEnv = normalizeExposeMode(process.env.EXPOSE);
14
+ if (fromEnv) {
15
+ try {
16
+ const file = exposeStorePath();
17
+ fs.mkdirSync(path.dirname(file), { recursive: true });
18
+ fs.writeFileSync(file, fromEnv);
19
+ }
20
+ catch {
21
+ // persistence is a convenience; ignore failures
22
+ }
23
+ return fromEnv;
24
+ }
25
+ if (process.env.EXPOSE)
26
+ console.info(` ⚠ unrecognized EXPOSE=${process.env.EXPOSE} — expected public|tailnet.`);
27
+ try {
28
+ const saved = normalizeExposeMode(fs.readFileSync(exposeStorePath(), 'utf8'));
29
+ if (saved)
30
+ return saved;
31
+ }
32
+ catch {
33
+ // no saved choice yet
34
+ }
35
+ return 'public';
36
+ }
37
+ /**
38
+ * Live serve/funnel state for this node as it concerns the relay: the HTTPS port fronting it (any port, not
39
+ * only :443), whether that port is public, and which ports carry mounts that are not the relay's. A status
40
+ * that cannot be read counts as nothing fronting — the same answer the old reader gave, and one that only
41
+ * ever leads to a mount on a port nobody holds.
42
+ */
43
+ export function tailscaleState(bin, port = RELAY_PORT) {
44
+ try {
45
+ const out = execFileSync(bin, ['serve', 'status', '--json'], { encoding: 'utf8', stdio: 'pipe' });
46
+ return relayServeState(parseServeStatus(out), port);
47
+ }
48
+ catch {
49
+ return relayServeState({}, port);
50
+ }
51
+ }
52
+ export function rawTailscaleStatus(bin) {
53
+ try {
54
+ return parseServeStatus(execFileSync(bin, ['serve', 'status', '--json'], { encoding: 'utf8', stdio: 'pipe' }));
55
+ }
56
+ catch {
57
+ return {};
58
+ }
59
+ }
60
+ /**
61
+ * The one Tailscale write here: `tailscale <serve|funnel> --bg --https=<port> <relay port>`. Always scoped
62
+ * to a port the relay owns outright or nobody holds — the callers check, this does not. Serving a port
63
+ * Funnel currently exposes drops that port's Funnel flag and touches no other port, which is what made
64
+ * the old `funnel reset` (every mount on the node, gone) unnecessary. Returns the CLI's error, or null.
65
+ */
66
+ export function mountRelay(bin, kind, port) {
67
+ try {
68
+ execFileSync(bin, [kind, '--bg', '--yes', `--https=${port}`, RELAY_PORT], { stdio: 'pipe' });
69
+ return null;
70
+ }
71
+ catch (err) {
72
+ return err instanceof Error ? err.message.trim() : String(err);
73
+ }
74
+ }
75
+ function byHand(kind, port) {
76
+ return `tailscale ${kind} --bg --yes --https=${port} ${RELAY_PORT}`;
77
+ }
78
+ function describeTaken(state, port) {
79
+ return (state.taken.get(port) ?? []).join(', ');
80
+ }
81
+ /** A port the relay shares is left exactly as found: Funnel is per port, so a change would drag the rest along. */
82
+ function leaveShared(url, state, want) {
83
+ console.info(`\n ⚠ ${url} fronts the relay, but :${state.port} also carries ${state.shared.join(', ')}.`);
84
+ console.info(` Funnel is per port, so making the relay ${want} would take those mounts along. Left as is.`);
85
+ console.info(` To change it, move the relay to a port of its own: ${byHand(want === 'public' ? 'funnel' : 'serve', '<port>')}`);
86
+ }
87
+ function refuseNoPort(state, candidates) {
88
+ const held = candidates.map(p => `:${p} → ${describeTaken(state, p)}`).join('; ');
89
+ console.info(`\n ⚠ every port the relay could take already carries another service (${held}). Not touching them.`);
90
+ console.info(` Free one, then run \`yarn deploy\` again — or mount by hand: ${byHand('serve', '<port>')}`);
91
+ }
92
+ /**
93
+ * Assert a tailnet-only `serve` proxy in front of the relay — tailnet mode, and the fallback when Funnel is
94
+ * refused. Non-destructive by construction: a mapping is kept wherever it already lives (:443 or not), a
95
+ * shared port is reported rather than rewritten, and a fresh mount steps around any port someone else holds.
96
+ * The order of candidates is 443 (the default, unchanged), then the relay's own port number — the memorable
97
+ * choice, and it leaves 8443/10000 for services that need Funnel — then the remaining Funnel ports.
98
+ */
99
+ function ensureServeOnly(bin, dns, state) {
100
+ const host = dns ?? '<node>';
101
+ if (state.port !== null) {
102
+ const url = serveUrl(host, state.port);
103
+ if (!state.funnelOn) {
104
+ console.info(`✓ tailscale serve fronts ${url} → 127.0.0.1:${RELAY_PORT} (tailnet-only)`);
105
+ return;
106
+ }
107
+ if (state.shared.length) {
108
+ leaveShared(url, state, 'tailnet-only');
109
+ return;
110
+ }
111
+ const failed = mountRelay(bin, 'serve', state.port);
112
+ if (failed) {
113
+ console.info(`\n ⚠ could not take Funnel off ${url} (${failed}). Run by hand:\n ${byHand('serve', state.port)}`);
114
+ return;
115
+ }
116
+ console.info(`✓ tailscale serve → ${url} is tailnet-only again → 127.0.0.1:${RELAY_PORT}`);
117
+ return;
118
+ }
119
+ const candidates = [443, Number(RELAY_PORT), ...FUNNEL_PORTS.filter(p => p !== 443)];
120
+ const port = freeServePort(state, candidates);
121
+ if (port === null) {
122
+ refuseNoPort(state, candidates);
123
+ return;
124
+ }
125
+ if (port !== 443)
126
+ console.info(` :443 carries ${describeTaken(state, 443)}, so the relay goes on :${port} instead.`);
127
+ const url = serveUrl(host, port);
128
+ const failed = mountRelay(bin, 'serve', port);
129
+ if (failed) {
130
+ console.info(`\n ⚠ could not configure tailscale serve (${failed}). Run by hand:\n ${byHand('serve', port)}`);
131
+ return;
132
+ }
133
+ console.info(`✓ tailscale serve → ${url} proxies 127.0.0.1:${RELAY_PORT} (tailnet-only)`);
134
+ }
135
+ /**
136
+ * Persist a stable Tailscale device name so this node's MagicDNS URL — the origin the installed PWA is
137
+ * bolted to — can't drift out from under it. Tailscale otherwise *derives* the name from the Mac's
138
+ * LocalHostName, which a macOS update or a network/settings reset can silently clear; the name then moves
139
+ * (e.g. `mac` → `macbook-pro`), the URL changes, and every home-screen PWA pinned to the old origin dies
140
+ * with an unexplained "failed to fetch". We pin `--hostname`/`RELAY_HOSTNAME` when given (also the way to
141
+ * *rename* — `--hostname mac` restores a drifted node), else re-pin whatever name the node already has: a
142
+ * no-op on today's URL, but it turns an auto-derived name into an explicit one Tailscale won't re-derive.
143
+ * Best-effort — a failure just leaves the name auto-derived, exactly as before this ran.
144
+ */
145
+ function pinHostname(bin) {
146
+ const label = (dns) => (dns ?? '').split('.')[0];
147
+ const current = label(magicDnsName(bin));
148
+ const desired = (process.env.RELAY_HOSTNAME ?? '').trim() || current;
149
+ if (!desired)
150
+ return; // node name unknown and none requested — nothing to pin
151
+ try {
152
+ execFileSync(bin, ['set', `--hostname=${desired}`], { stdio: 'pipe' });
153
+ }
154
+ catch (err) {
155
+ console.info(` ⚠ could not pin tailscale hostname to "${desired}" (${err instanceof Error ? err.message.trim() : err}) — name stays auto-derived.`);
156
+ return;
157
+ }
158
+ if (desired === current) {
159
+ console.info(`✓ tailscale device name pinned to "${desired}" (won't drift on an OS hostname change)`);
160
+ return;
161
+ }
162
+ // A rename: wait briefly for MagicDNS to reflect it, so the URL and Funnel cert below use the new name.
163
+ for (let i = 0; i < 12 && label(magicDnsName(bin)) !== desired; i++)
164
+ sleepSync(500);
165
+ const actual = label(magicDnsName(bin));
166
+ if (actual === desired)
167
+ console.info(`✓ tailscale device renamed "${current || '<derived>'}" → "${desired}" and pinned`);
168
+ else
169
+ console.info(` ⚠ requested hostname "${desired}" but the tailnet assigned "${actual}" (name likely already taken) — your URL is https://${actual}.…`);
170
+ }
171
+ /**
172
+ * Front the loopback relay with a stable HTTPS URL, either publicly (`tailscale funnel`, the default) or
173
+ * tailnet-only (`tailscale serve`), per resolveExposeMode(). Idempotent — flips Funnel off when switching
174
+ * back to tailnet — and non-fatal: the relay binds loopback regardless, so a failure here just means the
175
+ * phone URL isn't wired yet and we print how to do it by hand. Real TLS also satisfies the PWA's
176
+ * secure-context requirement (a service worker won't register over plain http on a 100.x IP).
177
+ *
178
+ * PUBLIC IS INTERNET-FACING: the 128-bit token on every /api/* request is the only gate. Funnel must be
179
+ * enabled for the tailnet (Admin console) or the funnel command fails — we then fall back to tailnet-only.
180
+ */
181
+ export function ensureTailscale() {
182
+ const bin = tailscaleBin();
183
+ if (!bin) {
184
+ console.info('\n ⚠ tailscale CLI not found — skipped URL setup. Once Tailscale is installed, run:');
185
+ console.info(` tailscale funnel --bg ${RELAY_PORT} # public, or \`serve\` for tailnet-only`);
186
+ return;
187
+ }
188
+ pinHostname(bin);
189
+ const dns = magicDnsName(bin);
190
+ if (dns)
191
+ writeUrlHost(dns); // baseline for drift detection (server startup + `service status` compare against this)
192
+ const host = dns ?? '<node>';
193
+ const mode = resolveExposeMode();
194
+ const state = tailscaleState(bin);
195
+ if (mode === 'tailnet') {
196
+ ensureServeOnly(bin, dns, state);
197
+ return;
198
+ }
199
+ // public (Funnel)
200
+ if (state.port !== null) {
201
+ const url = serveUrl(host, state.port);
202
+ if (state.funnelOn) {
203
+ console.info(`✓ tailscale funnel already exposes ${url} → 127.0.0.1:${RELAY_PORT} (public, token-gated)`);
204
+ return;
205
+ }
206
+ if (state.shared.length) {
207
+ leaveShared(url, state, 'public');
208
+ return;
209
+ }
210
+ if (!FUNNEL_PORTS.includes(state.port)) {
211
+ console.info(`\n ⚠ the relay is fronted tailnet-only at ${url}, and Funnel cannot listen on :${state.port}`);
212
+ console.info(` (only ${FUNNEL_PORTS.join(', ')}). Left as is — \`conductor-remote config\` will show the mismatch.`);
213
+ console.info(` To go public, move it to a Funnel port by hand: ${byHand('funnel', '<443|8443|10000>')}`);
214
+ return;
215
+ }
216
+ const failed = mountRelay(bin, 'funnel', state.port);
217
+ if (failed) {
218
+ funnelRefused(failed);
219
+ ensureServeOnly(bin, dns, state);
220
+ return;
221
+ }
222
+ console.info(`✓ tailscale funnel → ${url} now public over the internet (token-gated) → 127.0.0.1:${RELAY_PORT}`);
223
+ return;
224
+ }
225
+ const port = freeServePort(state, FUNNEL_PORTS);
226
+ if (port === null) {
227
+ refuseNoPort(state, FUNNEL_PORTS);
228
+ return;
229
+ }
230
+ if (port !== 443)
231
+ console.info(` :443 carries ${describeTaken(state, 443)}, so the relay goes on :${port} instead.`);
232
+ const failed = mountRelay(bin, 'funnel', port);
233
+ if (failed) {
234
+ funnelRefused(failed);
235
+ ensureServeOnly(bin, dns, state);
236
+ return;
237
+ }
238
+ console.info(`✓ tailscale funnel → ${serveUrl(host, port)} now public over the internet (token-gated) → 127.0.0.1:${RELAY_PORT}`);
239
+ }
240
+ function funnelRefused(error) {
241
+ console.info(`\n ⚠ could not enable Funnel (${error}).`);
242
+ console.info(' Funnel must be enabled for this tailnet: open the URL Tailscale printed above, or add the');
243
+ console.info(' "funnel" nodeAttr in Admin console ▸ Access controls. Falling back to tailnet-only for now.');
244
+ }
@@ -0,0 +1,101 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { driftWarningLines, magicDnsName, relayServeState, serveUrl, tailscaleBin } from "../../src/host/tailscale.js";
5
+ import { readVoiceConfig } from "../../src/voice/config.js";
6
+ import { inspectVoiceFunnel, readVoiceFunnelReceipt } from "../../src/voice/funnel.js";
7
+ import { qrLines } from "../qr.js";
8
+ import { currentToken, domain, LABEL, logDir, plistPath, RELAY_PORT, readPlistEnv } from "./environment.js";
9
+ import { rawTailscaleStatus, tailscaleState } from "./network.js";
10
+ /** Print a scannable QR of `url` (theme-independent black-on-white). Never fatal — QR is a convenience. */
11
+ function printQr(url) {
12
+ try {
13
+ console.info(`\n${qrLines(url).join('\n')}`);
14
+ }
15
+ catch (err) {
16
+ console.info(` (QR skipped: ${err instanceof Error ? err.message : err})`);
17
+ }
18
+ }
19
+ export function printUrl(loopbackPort = RELAY_PORT) {
20
+ const token = currentToken();
21
+ const frag = `#token=${token ?? '<starts on first run>'}`;
22
+ const bin = tailscaleBin();
23
+ if (bin) {
24
+ const drift = driftWarningLines(bin);
25
+ if (drift.length)
26
+ console.info(`\n${drift.join('\n')}`);
27
+ }
28
+ const dns = bin ? magicDnsName(bin) : null;
29
+ const state = bin ? tailscaleState(bin, loopbackPort) : relayServeState({}, loopbackPort);
30
+ if (dns && state.port !== null) {
31
+ const scope = state.funnelOn ? 'public — any browser, token-gated' : 'same Tailnet only';
32
+ const url = `${serveUrl(dns, state.port)}${frag}`;
33
+ console.info(`\n Phone URL (HTTPS, ${scope}):\n ${url}`);
34
+ if (token) {
35
+ console.info('\n Scan to open on your phone:');
36
+ printQr(url);
37
+ }
38
+ return;
39
+ }
40
+ // Nothing fronting yet — the relay is only on loopback.
41
+ console.info(`\n Local URL:\n http://127.0.0.1:${loopbackPort}/${frag}`);
42
+ 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\`.`);
43
+ }
44
+ /** Report the public voice path independently of the relay's tailnet-only phone URL. */
45
+ export function printVoiceRoute(loopbackPort, listenerPort) {
46
+ const voice = readVoiceConfig();
47
+ if (!voice.publicBaseUrl) {
48
+ console.info('voice: disabled (set voice.public-url to enable)');
49
+ return;
50
+ }
51
+ const bin = tailscaleBin();
52
+ if (!bin) {
53
+ console.info(`voice: configured at ${voice.publicBaseUrl}, but Tailscale is unavailable`);
54
+ return;
55
+ }
56
+ const inspected = inspectVoiceFunnel(rawTailscaleStatus(bin), listenerPort, loopbackPort, readVoiceFunnelReceipt());
57
+ if (inspected.owned && inspected.targetMatches && inspected.funnelOn && !inspected.relayAtRoot) {
58
+ console.info(`voice: ${voice.publicBaseUrl} (public) → 127.0.0.1:${listenerPort}`);
59
+ return;
60
+ }
61
+ console.info(`voice: ${voice.publicBaseUrl} configured, but its isolated Funnel mount is not live`);
62
+ }
63
+ export function status() {
64
+ const installed = fs.existsSync(plistPath);
65
+ console.info(`plist: ${installed ? plistPath : '(not installed)'}`);
66
+ if (!installed)
67
+ return;
68
+ try {
69
+ const out = execFileSync('launchctl', ['print', `${domain}/${LABEL}`], { encoding: 'utf8', stdio: 'pipe' });
70
+ const state = out.match(/state = (\S+)/)?.[1] ?? 'unknown';
71
+ const pid = out.match(/pid = (\d+)/)?.[1] ?? '—';
72
+ console.info(`state: ${state} (pid ${pid})`);
73
+ }
74
+ catch {
75
+ console.info('state: loaded but not running (check logs)');
76
+ }
77
+ const env = readPlistEnv();
78
+ const relayPort = env.RELAY_PORT ?? '8787';
79
+ printUrl(relayPort);
80
+ printVoiceRoute(relayPort, env.VOICE_PORT ?? '8788');
81
+ }
82
+ /**
83
+ * Stream the LaunchAgent's stdout+stderr logs (the same files the plist points at). Follows both by default
84
+ * — `--no-follow` for a one-shot tail, `-n N` for depth. Pure `tail` passthrough so Ctrl-C just detaches.
85
+ */
86
+ export function logs() {
87
+ const files = ['relay.log', 'relay.err.log'].map(f => path.join(logDir, f)).filter(f => fs.existsSync(f));
88
+ if (files.length === 0) {
89
+ console.info(`no logs yet in ${logDir}/ — the relay writes there once the LaunchAgent is running (\`conductor-remote service install\`).`);
90
+ return;
91
+ }
92
+ const argv = process.argv.slice(3);
93
+ const follow = !argv.includes('--no-follow');
94
+ const nAt = argv.indexOf('-n');
95
+ const count = nAt !== -1 && argv[nAt + 1] ? argv[nAt + 1] : '200';
96
+ const args = ['-n', count, ...(follow ? ['-F'] : []), ...files];
97
+ const res = spawnSync('tail', args, { stdio: 'inherit' });
98
+ // tail exits 0 normally; Ctrl-C kills it via SIGINT (null status). Only surface a genuine non-zero exit.
99
+ if (typeof res.status === 'number' && res.status !== 0)
100
+ process.exit(res.status);
101
+ }
@@ -0,0 +1,113 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import { freeServePort, magicDnsName, relayServeState, tailscaleBin } from "../../src/host/tailscale.js";
4
+ import { readVoiceConfig } from "../../src/voice/config.js";
5
+ import { inspectVoiceFunnel, readVoiceFunnelReceipt, voiceFunnelReceiptPath, writeVoiceFunnelReceipt } from "../../src/voice/funnel.js";
6
+ import { RELAY_PORT, VOICE_PORT } from "./environment.js";
7
+ import { mountRelay, rawTailscaleStatus } from "./network.js";
8
+ /**
9
+ * Give OpenAI the only public route it needs while keeping the control panel tailnet-only.
10
+ * Port-wide changes happen only when every live handler is either the relay root or the exact
11
+ * voice target named by our receipt; a foreign or merely manual mount is left untouched.
12
+ */
13
+ export function ensureVoiceFunnel() {
14
+ const voice = readVoiceConfig();
15
+ const bin = tailscaleBin();
16
+ if (!voice.publicBaseUrl) {
17
+ const receipt = readVoiceFunnelReceipt();
18
+ if (!bin || !receipt)
19
+ return;
20
+ const inspected = inspectVoiceFunnel(rawTailscaleStatus(bin), VOICE_PORT, RELAY_PORT, receipt);
21
+ if (!inspected.owned || inspected.relayAtRoot || inspected.conflicts.length)
22
+ return;
23
+ try {
24
+ execFileSync(bin, ['serve', '--yes', '--https=443', 'off'], { stdio: 'pipe' });
25
+ fs.rmSync(voiceFunnelReceiptPath());
26
+ console.info('✓ removed the receipt-owned voice Funnel mount');
27
+ }
28
+ catch (error) {
29
+ console.info(` ⚠ could not remove the receipt-owned voice Funnel (${error instanceof Error ? error.message : error})`);
30
+ }
31
+ return;
32
+ }
33
+ if (!bin) {
34
+ console.info(`\n ⚠ voice is configured but Tailscale is unavailable. Mount by hand:`);
35
+ console.info(` tailscale funnel --bg --yes --https=443 --set-path=/voice ${VOICE_PORT}`);
36
+ return;
37
+ }
38
+ const dns = magicDnsName(bin);
39
+ if (!dns) {
40
+ console.info('\n ⚠ voice is configured but this node has no MagicDNS name; Funnel was not changed.');
41
+ return;
42
+ }
43
+ let configuredHost;
44
+ try {
45
+ configuredHost = new URL(voice.publicBaseUrl).hostname;
46
+ }
47
+ catch {
48
+ console.info('\n ⚠ voice.public-url is invalid; Funnel was not changed.');
49
+ return;
50
+ }
51
+ if (configuredHost !== dns) {
52
+ console.info(`\n ⚠ voice.public-url names ${configuredHost}, but this node is ${dns}; Funnel was not changed.`);
53
+ console.info(` Fix with: conductor-remote config set voice.public-url https://${dns}/voice`);
54
+ return;
55
+ }
56
+ let status = rawTailscaleStatus(bin);
57
+ const oldReceipt = readVoiceFunnelReceipt();
58
+ let inspected = inspectVoiceFunnel(status, VOICE_PORT, RELAY_PORT, oldReceipt);
59
+ if (inspected.conflicts.length) {
60
+ console.info(`\n ⚠ :443 carries ${inspected.conflicts.join(', ')}. It is not owned by this receipt; left as is.`);
61
+ return;
62
+ }
63
+ if (inspected.owned && inspected.targetMatches && inspected.funnelOn && !inspected.relayAtRoot) {
64
+ console.info(`✓ voice Funnel already exposes https://${dns}/voice → 127.0.0.1:${VOICE_PORT}`);
65
+ return;
66
+ }
67
+ if (inspected.relayAtRoot) {
68
+ const state = relayServeState(status, RELAY_PORT);
69
+ const candidates = [Number(RELAY_PORT), 8443, 10000].filter((port, index, all) => Number.isInteger(port) && port !== 443 && all.indexOf(port) === index);
70
+ const destination = freeServePort(state, candidates);
71
+ if (destination === null) {
72
+ console.info(`\n ⚠ the relay must leave :443 for voice, but ${candidates.map(p => `:${p}`).join(', ')} are occupied.`);
73
+ return;
74
+ }
75
+ const failed = mountRelay(bin, 'serve', destination);
76
+ if (failed) {
77
+ console.info(`\n ⚠ could not move the relay to tailnet-only :${destination} (${failed}). Voice was not changed.`);
78
+ return;
79
+ }
80
+ try {
81
+ execFileSync(bin, ['serve', '--yes', '--https=443', 'off'], { stdio: 'pipe' });
82
+ }
83
+ catch (error) {
84
+ console.info(`\n ⚠ relay moved, but :443 could not be cleared (${error instanceof Error ? error.message : error}).`);
85
+ return;
86
+ }
87
+ console.info(`✓ relay moved to https://${dns}:${destination}/ (tailnet-only) so voice can own :443`);
88
+ }
89
+ try {
90
+ execFileSync(bin, ['funnel', '--bg', '--yes', '--https=443', '--set-path=/voice', VOICE_PORT], {
91
+ stdio: 'pipe'
92
+ });
93
+ }
94
+ catch (error) {
95
+ console.info(`\n ⚠ could not mount voice Funnel (${error instanceof Error ? error.message : error}).`);
96
+ console.info(` Run by hand: tailscale funnel --bg --yes --https=443 --set-path=/voice ${VOICE_PORT}`);
97
+ return;
98
+ }
99
+ const receipt = {
100
+ version: 1,
101
+ host: dns,
102
+ path: '/voice',
103
+ target: `http://127.0.0.1:${VOICE_PORT}`
104
+ };
105
+ status = rawTailscaleStatus(bin);
106
+ inspected = inspectVoiceFunnel(status, VOICE_PORT, RELAY_PORT, receipt);
107
+ if (!inspected.owned || !inspected.targetMatches || !inspected.funnelOn || inspected.relayAtRoot) {
108
+ console.info('\n ⚠ Tailscale accepted the voice command, but the isolated public mount could not be verified.');
109
+ return;
110
+ }
111
+ writeVoiceFunnelReceipt(receipt);
112
+ console.info(`✓ voice Funnel exposes https://${dns}/voice → 127.0.0.1:${VOICE_PORT} (receipt recorded)`);
113
+ }