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
@@ -1,4 +1,4 @@
1
- import { agentTypeCanExposeEffort, agentTypeCanExposeFastMode, modelAgentType, modelLabel, modelPickerLabel, shortModel } from "./shared.js";
1
+ import { agentTypeCanExposeEffort, agentTypeCanExposeFastMode, modelAgentType, modelLabel, modelPickerLabel, shortModel } from "../shared.js";
2
2
  function modelMatches(state, requested, allowPrefix = false) {
3
3
  const label = modelPickerLabel(requested).trim();
4
4
  const expectedAgentType = modelAgentType(label);
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
- import { modelPickerLabel } from "./shared.js";
11
+ import { modelPickerLabel } from "../shared.js";
12
12
  function clean(models) {
13
13
  return [
14
14
  ...new Set(models
@@ -8,9 +8,9 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
- import { stateDir } from "./config.js";
12
- import { currentModelCatalog, modelAgentType, modelPickerLabel } from "./shared.js";
13
- export { newestModelSnapshot } from "./shared.js";
11
+ import { stateDir } from "../config.js";
12
+ import { currentModelCatalog, modelAgentType, modelPickerLabel } from "../shared.js";
13
+ export { newestModelSnapshot } from "../shared.js";
14
14
  const EFFORTS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode']);
15
15
  const ROLE_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
16
16
  const MAX_ROLES = 32;
@@ -6,7 +6,7 @@ const SLOW_QUERY_LOG_INTERVAL_MS = 60_000;
6
6
  *
7
7
  * The desktop app holds the same file open in WAL mode; a second read-only
8
8
  * connection sees every committed write without blocking the app. We never
9
- * write through this handle — writes go through the actuator (see writes.ts).
9
+ * write through this handle — writes go through the actuator (see src/writes/actuator.ts).
10
10
  */
11
11
  export class ConductorDb {
12
12
  dbPath;
@@ -8,7 +8,7 @@
8
8
  * the lock hands its prompt to this queue and returns at once: the phone shows it
9
9
  * as a queued bubble in the chat (`workspace.parked_prompts` on `/api/state`),
10
10
  * the relay polls the lock and delivers when it lifts, and a push notification
11
- * says whether it landed. Same ownership story as `firstprompt.ts`: the relay is
11
+ * says whether it landed. Same ownership story as `src/delivery/firstprompt.ts`: the relay is
12
12
  * the only deliverer, the phone only watches and can dismiss.
13
13
  *
14
14
  * The same three properties hold it up:
@@ -3,7 +3,7 @@
3
3
  * the tailnet-only HTTPS port.
4
4
  *
5
5
  * The relay never spawns a repository command itself. Starting and stopping go
6
- * through Conductor's real Run button (`writes.ts`), which preserves its task
6
+ * through Conductor's real Run button (`src/writes/workspaces.ts`), which preserves its task
7
7
  * selection, environment, run-mode rules and process-group cleanup. This module
8
8
  * discovers that workspace's allocated `CONDUCTOR_PORT`, accepts complete URLs
9
9
  * from Conductor configuration or a running application, and gives each local
@@ -20,309 +20,26 @@
20
20
  import { execFile } from 'node:child_process';
21
21
  import crypto from 'node:crypto';
22
22
  import fs from 'node:fs';
23
- import http, {} from 'node:http';
24
- import net from 'node:net';
25
23
  import path from 'node:path';
26
24
  import { promisify } from 'node:util';
27
- import { stateDir } from "./config.js";
25
+ import { stateDir } from "../config.js";
26
+ import { magicDnsName, tailscaleBin } from "../host/tailscale.js";
27
+ import { setRunTask } from "../writes/workspaces.js";
28
+ import { chooseServePort, forwardUrl, localPreviewUrl, previewPath, serveProxyAt, tcpOpen, validPort, waitForPort } from "./ports.js";
28
29
  import { previewUrlSettings, resolvePreviewTargets } from "./preview-urls.js";
30
+ import { PORT_SNAPSHOT_TTL_MS, processAlive, workspacePort } from "./processes.js";
31
+ import { bridgeMatches, createDevProxy } from "./proxy.js";
29
32
  import { runConfigsFor } from "./run-configs.js";
30
- import { magicDnsName, tailscaleBin } from "./tailscale.js";
31
- import { setRunTask } from "./writes.js";
32
- const exec = promisify(execFile);
33
+ export const exec = promisify(execFile);
33
34
  // Keep the receipt additive and version-1-readable: an installed relay and a
34
35
  // source `yarn dev` relay can briefly share this file during an update.
35
36
  const STORE_VERSION = 1;
36
37
  const PREVIEW_ADVERTISEMENT_VERSION = 1;
37
38
  const PORT_WAIT_MS = 15_000;
38
- const PORT_SNAPSHOT_TTL_MS = 5000;
39
39
  const MAX_PREVIEW_ADVERTISEMENT_BYTES = 64 * 1024;
40
40
  function recordKey(workspaceId, targetPort) {
41
41
  return `${workspaceId}:${targetPort}`;
42
42
  }
43
- function validPort(value) {
44
- return Number.isInteger(value) && value > 0 && value <= 65535;
45
- }
46
- function processAlive(pid) {
47
- if (!pid || !Number.isInteger(pid) || pid <= 0)
48
- return false;
49
- try {
50
- process.kill(pid, 0);
51
- return true;
52
- }
53
- catch {
54
- return false;
55
- }
56
- }
57
- function bridgeMatches(record) {
58
- if (!record.bridgeToken) {
59
- // Compatibility for the first local prototype receipts. PID plus an open
60
- // bridge is enough to avoid stealing one during this upgrade; every newly
61
- // written receipt carries the unambiguous challenge below.
62
- return processAlive(record.ownerPid) ? tcpOpen(record.bridgePort) : Promise.resolve(false);
63
- }
64
- return new Promise(resolve => {
65
- let settled = false;
66
- const finish = (matches) => {
67
- if (settled)
68
- return;
69
- settled = true;
70
- request.destroy();
71
- resolve(matches);
72
- };
73
- const request = http.request({
74
- host: '127.0.0.1',
75
- port: record.bridgePort,
76
- method: 'GET',
77
- path: '/',
78
- headers: { 'x-conductor-remote-bridge': record.bridgeToken }
79
- }, response => {
80
- response.resume();
81
- finish(response.statusCode === 204);
82
- });
83
- request.setTimeout(500, () => finish(false));
84
- request.once('error', () => finish(false));
85
- request.end();
86
- });
87
- }
88
- /**
89
- * Parse a macOS `ps eww -axo command=` snapshot without ever logging it: process
90
- * environments can contain credentials. Multiple processes are expected, but
91
- * conflicting ports for one workspace are not safe to guess through.
92
- */
93
- export function parseWorkspacePort(snapshot, workspaceId) {
94
- const ports = new Set();
95
- for (const line of snapshot.split('\n')) {
96
- const id = line.match(/(?:^|\s)CONDUCTOR_WORKSPACE_ID=([^\s]+)/)?.[1];
97
- if (id !== workspaceId)
98
- continue;
99
- const port = Number(line.match(/(?:^|\s)CONDUCTOR_PORT=(\d+)(?:\s|$)/)?.[1]);
100
- if (validPort(port))
101
- ports.add(port);
102
- }
103
- return ports.size === 1 ? [...ports][0] : null;
104
- }
105
- let portSnapshot = null;
106
- let portSnapshotInFlight = null;
107
- async function readProcesses() {
108
- const { stdout } = await exec('ps', ['eww', '-axo', 'command='], {
109
- encoding: 'utf8',
110
- maxBuffer: 64 * 1024 * 1024,
111
- timeout: 5000
112
- });
113
- portSnapshot = { at: Date.now(), text: stdout };
114
- return stdout;
115
- }
116
- /**
117
- * One `ps` snapshot shared by every workspace. It carries every process
118
- * environment on the Mac (~650 kB here), and the phone polls this state every
119
- * 2.5s per open chat, so a read reuses a recent one and simultaneous readers
120
- * await the same child process. A workspace whose Run task is stopped has no
121
- * port to remember, which is why the miss path needs this and not just the hit
122
- * path. A start asks for `maxAgeMs` 0, because the task it just pressed is
123
- * younger than any cached snapshot.
124
- */
125
- function processSnapshot(maxAgeMs) {
126
- if (maxAgeMs <= 0)
127
- return readProcesses();
128
- if (portSnapshot && Date.now() - portSnapshot.at <= maxAgeMs)
129
- return Promise.resolve(portSnapshot.text);
130
- portSnapshotInFlight ??= readProcesses().finally(() => {
131
- portSnapshotInFlight = null;
132
- });
133
- return portSnapshotInFlight;
134
- }
135
- async function workspacePort(workspaceId, maxAgeMs = 0) {
136
- try {
137
- return parseWorkspacePort(await processSnapshot(maxAgeMs), workspaceId);
138
- }
139
- catch {
140
- // Never reflect the error: `execFile` includes stdout, which is the process
141
- // environment snapshot and may contain secrets.
142
- return null;
143
- }
144
- }
145
- function tcpOpen(port, timeoutMs = 300) {
146
- return new Promise(resolve => {
147
- const socket = net.connect({ host: '127.0.0.1', port });
148
- const finish = (open) => {
149
- socket.destroy();
150
- resolve(open);
151
- };
152
- socket.setTimeout(timeoutMs);
153
- socket.once('connect', () => finish(true));
154
- socket.once('timeout', () => finish(false));
155
- socket.once('error', () => finish(false));
156
- });
157
- }
158
- async function waitForPort(port, open, timeoutMs) {
159
- const deadline = Date.now() + timeoutMs;
160
- do {
161
- if ((await tcpOpen(port)) === open)
162
- return true;
163
- await new Promise(resolve => setTimeout(resolve, 250));
164
- } while (Date.now() < deadline);
165
- return false;
166
- }
167
- function externalOrigin(req) {
168
- const forwardedProto = String(req.headers['x-forwarded-proto'] ?? 'https')
169
- .split(',')[0]
170
- .trim();
171
- const forwardedHost = String(req.headers['x-forwarded-host'] ?? req.headers.host ?? '')
172
- .split(',')[0]
173
- .trim();
174
- return `${forwardedProto || 'https'}://${forwardedHost}`;
175
- }
176
- function localOrigin(targetPort) {
177
- return `http://127.0.0.1:${targetPort}`;
178
- }
179
- function proxyHeaders(req, targetPort) {
180
- const headers = { ...req.headers };
181
- const publicHost = String(req.headers['x-forwarded-host'] ?? req.headers.host ?? '');
182
- headers.host = `127.0.0.1:${targetPort}`;
183
- if (publicHost)
184
- headers['x-forwarded-host'] = publicHost;
185
- headers['x-forwarded-proto'] = String(req.headers['x-forwarded-proto'] ?? 'https');
186
- // Framework dev servers commonly apply the same allowlist to WebSocket Origin
187
- // that they apply to Host. Present the local origin they were configured for.
188
- if (headers.origin)
189
- headers.origin = localOrigin(targetPort);
190
- if (headers.referer) {
191
- try {
192
- const ref = new URL(headers.referer);
193
- headers.referer = `${localOrigin(targetPort)}${ref.pathname}${ref.search}`;
194
- }
195
- catch {
196
- // Preserve an unusual Referer rather than inventing one.
197
- }
198
- }
199
- return headers;
200
- }
201
- function rewriteLocation(location, req, targetPort) {
202
- if (!location)
203
- return undefined;
204
- const local = new RegExp(`^https?://(?:127\\.0\\.0\\.1|localhost)(?::${targetPort})?`, 'i');
205
- return location.replace(local, externalOrigin(req));
206
- }
207
- function writeUpgradeRequest(upstream, req, head, targetPort) {
208
- const headers = proxyHeaders(req, targetPort);
209
- const lines = [`${req.method ?? 'GET'} ${req.url ?? '/'} HTTP/${req.httpVersion}`];
210
- for (const [name, value] of Object.entries(headers)) {
211
- if (value === undefined)
212
- continue;
213
- if (Array.isArray(value)) {
214
- for (const item of value)
215
- lines.push(`${name}: ${item}`);
216
- }
217
- else {
218
- lines.push(`${name}: ${value}`);
219
- }
220
- }
221
- upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
222
- if (head.length)
223
- upstream.write(head);
224
- }
225
- /** Loopback reverse proxy with raw WebSocket tunnelling for Vite-style HMR. */
226
- export function createDevProxy(targetPort) {
227
- return new Promise((resolve, reject) => {
228
- const token = crypto.randomBytes(16).toString('hex');
229
- const sockets = new Set();
230
- const server = http.createServer((req, res) => {
231
- if (req.headers['x-conductor-remote-bridge'] === token) {
232
- res.writeHead(204);
233
- return res.end();
234
- }
235
- const upstream = http.request({
236
- host: '127.0.0.1',
237
- port: targetPort,
238
- method: req.method,
239
- path: req.url,
240
- headers: proxyHeaders(req, targetPort)
241
- }, upstreamRes => {
242
- const headers = { ...upstreamRes.headers };
243
- const location = rewriteLocation(upstreamRes.headers.location, req, targetPort);
244
- if (location)
245
- headers.location = location;
246
- res.writeHead(upstreamRes.statusCode ?? 502, headers);
247
- upstreamRes.pipe(res);
248
- });
249
- upstream.once('error', () => {
250
- if (!res.headersSent)
251
- res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
252
- res.end('Dev server is not reachable');
253
- });
254
- req.pipe(upstream);
255
- });
256
- server.on('connection', socket => {
257
- sockets.add(socket);
258
- socket.once('close', () => sockets.delete(socket));
259
- });
260
- server.on('upgrade', (req, socket, head) => {
261
- const upstream = net.connect({ host: '127.0.0.1', port: targetPort }, () => {
262
- writeUpgradeRequest(upstream, req, head, targetPort);
263
- socket.pipe(upstream).pipe(socket);
264
- });
265
- sockets.add(upstream);
266
- upstream.once('close', () => sockets.delete(upstream));
267
- upstream.once('error', () => socket.destroy());
268
- socket.once('error', () => upstream.destroy());
269
- });
270
- server.once('error', reject);
271
- server.listen(0, '127.0.0.1', () => {
272
- const address = server.address();
273
- if (!address || typeof address === 'string') {
274
- server.close();
275
- return reject(new Error('could not allocate the dev-server bridge port'));
276
- }
277
- resolve({
278
- port: address.port,
279
- token,
280
- close: () => new Promise(done => {
281
- for (const socket of sockets)
282
- socket.destroy();
283
- server.close(() => done());
284
- })
285
- });
286
- });
287
- });
288
- }
289
- export function serveProxyAt(status, port) {
290
- for (const [origin, web] of Object.entries(status.Web ?? {})) {
291
- if (!origin.endsWith(`:${port}`))
292
- continue;
293
- return web.Handlers?.['/']?.Proxy ?? null;
294
- }
295
- return null;
296
- }
297
- function servePorts(status) {
298
- return new Set(Object.keys(status.TCP ?? {})
299
- .map(Number)
300
- .filter(validPort));
301
- }
302
- function chooseServePort(status, targetPort, reserved = new Set()) {
303
- const used = servePorts(status);
304
- for (let offset = 0; offset < 10; offset++) {
305
- const candidate = targetPort + offset;
306
- if (validPort(candidate) && !used.has(candidate) && !reserved.has(candidate))
307
- return candidate;
308
- }
309
- for (let candidate = 49152; candidate <= 65535; candidate++) {
310
- if (!used.has(candidate) && !reserved.has(candidate))
311
- return candidate;
312
- }
313
- return null;
314
- }
315
- function previewPath(previewUrl) {
316
- const url = new URL(previewUrl);
317
- return `${url.pathname || '/'}${url.search}${url.hash}`;
318
- }
319
- function localPreviewUrl(port, suffix = '/') {
320
- return `http://localhost:${port}${suffix}`;
321
- }
322
- function forwardUrl(record, previewUrl) {
323
- const suffix = previewUrl ? previewPath(previewUrl) : record.path || '/';
324
- return `https://${record.host}${record.servePort === 443 ? '' : `:${record.servePort}`}${suffix}`;
325
- }
326
43
  export class DevServerController {
327
44
  storeFile;
328
45
  advertisementDir;
@@ -0,0 +1,63 @@
1
+ import net from 'node:net';
2
+ export function validPort(value) {
3
+ return Number.isInteger(value) && value > 0 && value <= 65535;
4
+ }
5
+ export function tcpOpen(port, timeoutMs = 300) {
6
+ return new Promise(resolve => {
7
+ const socket = net.connect({ host: '127.0.0.1', port });
8
+ const finish = (open) => {
9
+ socket.destroy();
10
+ resolve(open);
11
+ };
12
+ socket.setTimeout(timeoutMs);
13
+ socket.once('connect', () => finish(true));
14
+ socket.once('timeout', () => finish(false));
15
+ socket.once('error', () => finish(false));
16
+ });
17
+ }
18
+ export async function waitForPort(port, open, timeoutMs) {
19
+ const deadline = Date.now() + timeoutMs;
20
+ do {
21
+ if ((await tcpOpen(port)) === open)
22
+ return true;
23
+ await new Promise(resolve => setTimeout(resolve, 250));
24
+ } while (Date.now() < deadline);
25
+ return false;
26
+ }
27
+ export function serveProxyAt(status, port) {
28
+ for (const [origin, web] of Object.entries(status.Web ?? {})) {
29
+ if (!origin.endsWith(`:${port}`))
30
+ continue;
31
+ return web.Handlers?.['/']?.Proxy ?? null;
32
+ }
33
+ return null;
34
+ }
35
+ function servePorts(status) {
36
+ return new Set(Object.keys(status.TCP ?? {})
37
+ .map(Number)
38
+ .filter(validPort));
39
+ }
40
+ export function chooseServePort(status, targetPort, reserved = new Set()) {
41
+ const used = servePorts(status);
42
+ for (let offset = 0; offset < 10; offset++) {
43
+ const candidate = targetPort + offset;
44
+ if (validPort(candidate) && !used.has(candidate) && !reserved.has(candidate))
45
+ return candidate;
46
+ }
47
+ for (let candidate = 49152; candidate <= 65535; candidate++) {
48
+ if (!used.has(candidate) && !reserved.has(candidate))
49
+ return candidate;
50
+ }
51
+ return null;
52
+ }
53
+ export function previewPath(previewUrl) {
54
+ const url = new URL(previewUrl);
55
+ return `${url.pathname || '/'}${url.search}${url.hash}`;
56
+ }
57
+ export function localPreviewUrl(port, suffix = '/') {
58
+ return `http://localhost:${port}${suffix}`;
59
+ }
60
+ export function forwardUrl(record, previewUrl) {
61
+ const suffix = previewUrl ? previewPath(previewUrl) : record.path || '/';
62
+ return `https://${record.host}${record.servePort === 443 ? '' : `:${record.servePort}`}${suffix}`;
63
+ }
@@ -0,0 +1,73 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { validPort } from "./ports.js";
4
+ const exec = promisify(execFile);
5
+ export const PORT_SNAPSHOT_TTL_MS = 5000;
6
+ export function processAlive(pid) {
7
+ if (!pid || !Number.isInteger(pid) || pid <= 0)
8
+ return false;
9
+ try {
10
+ process.kill(pid, 0);
11
+ return true;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ /**
18
+ * Parse a macOS `ps eww -axo command=` snapshot without ever logging it: process
19
+ * environments can contain credentials. Multiple processes are expected, but
20
+ * conflicting ports for one workspace are not safe to guess through.
21
+ */
22
+ export function parseWorkspacePort(snapshot, workspaceId) {
23
+ const ports = new Set();
24
+ for (const line of snapshot.split('\n')) {
25
+ const id = line.match(/(?:^|\s)CONDUCTOR_WORKSPACE_ID=([^\s]+)/)?.[1];
26
+ if (id !== workspaceId)
27
+ continue;
28
+ const port = Number(line.match(/(?:^|\s)CONDUCTOR_PORT=(\d+)(?:\s|$)/)?.[1]);
29
+ if (validPort(port))
30
+ ports.add(port);
31
+ }
32
+ return ports.size === 1 ? [...ports][0] : null;
33
+ }
34
+ let portSnapshot = null;
35
+ let portSnapshotInFlight = null;
36
+ async function readProcesses() {
37
+ const { stdout } = await exec('ps', ['eww', '-axo', 'command='], {
38
+ encoding: 'utf8',
39
+ maxBuffer: 64 * 1024 * 1024,
40
+ timeout: 5000
41
+ });
42
+ portSnapshot = { at: Date.now(), text: stdout };
43
+ return stdout;
44
+ }
45
+ /**
46
+ * One `ps` snapshot shared by every workspace. It carries every process
47
+ * environment on the Mac (~650 kB here), and the phone polls this state every
48
+ * 2.5s per open chat, so a read reuses a recent one and simultaneous readers
49
+ * await the same child process. A workspace whose Run task is stopped has no
50
+ * port to remember, which is why the miss path needs this and not just the hit
51
+ * path. A start asks for `maxAgeMs` 0, because the task it just pressed is
52
+ * younger than any cached snapshot.
53
+ */
54
+ function processSnapshot(maxAgeMs) {
55
+ if (maxAgeMs <= 0)
56
+ return readProcesses();
57
+ if (portSnapshot && Date.now() - portSnapshot.at <= maxAgeMs)
58
+ return Promise.resolve(portSnapshot.text);
59
+ portSnapshotInFlight ??= readProcesses().finally(() => {
60
+ portSnapshotInFlight = null;
61
+ });
62
+ return portSnapshotInFlight;
63
+ }
64
+ export async function workspacePort(workspaceId, maxAgeMs = 0) {
65
+ try {
66
+ return parseWorkspacePort(await processSnapshot(maxAgeMs), workspaceId);
67
+ }
68
+ catch {
69
+ // Never reflect the error: `execFile` includes stdout, which is the process
70
+ // environment snapshot and may contain secrets.
71
+ return null;
72
+ }
73
+ }
@@ -0,0 +1,158 @@
1
+ import crypto from 'node:crypto';
2
+ import http, {} from 'node:http';
3
+ import net from 'node:net';
4
+ import { tcpOpen } from "./ports.js";
5
+ import { processAlive } from "./processes.js";
6
+ export function bridgeMatches(record) {
7
+ if (!record.bridgeToken) {
8
+ // Compatibility for the first local prototype receipts. PID plus an open
9
+ // bridge is enough to avoid stealing one during this upgrade; every newly
10
+ // written receipt carries the unambiguous challenge below.
11
+ return processAlive(record.ownerPid) ? tcpOpen(record.bridgePort) : Promise.resolve(false);
12
+ }
13
+ return new Promise(resolve => {
14
+ let settled = false;
15
+ const finish = (matches) => {
16
+ if (settled)
17
+ return;
18
+ settled = true;
19
+ request.destroy();
20
+ resolve(matches);
21
+ };
22
+ const request = http.request({
23
+ host: '127.0.0.1',
24
+ port: record.bridgePort,
25
+ method: 'GET',
26
+ path: '/',
27
+ headers: { 'x-conductor-remote-bridge': record.bridgeToken }
28
+ }, response => {
29
+ response.resume();
30
+ finish(response.statusCode === 204);
31
+ });
32
+ request.setTimeout(500, () => finish(false));
33
+ request.once('error', () => finish(false));
34
+ request.end();
35
+ });
36
+ }
37
+ function externalOrigin(req) {
38
+ const forwardedProto = String(req.headers['x-forwarded-proto'] ?? 'https')
39
+ .split(',')[0]
40
+ .trim();
41
+ const forwardedHost = String(req.headers['x-forwarded-host'] ?? req.headers.host ?? '')
42
+ .split(',')[0]
43
+ .trim();
44
+ return `${forwardedProto || 'https'}://${forwardedHost}`;
45
+ }
46
+ function localOrigin(targetPort) {
47
+ return `http://127.0.0.1:${targetPort}`;
48
+ }
49
+ function proxyHeaders(req, targetPort) {
50
+ const headers = { ...req.headers };
51
+ const publicHost = String(req.headers['x-forwarded-host'] ?? req.headers.host ?? '');
52
+ headers.host = `127.0.0.1:${targetPort}`;
53
+ if (publicHost)
54
+ headers['x-forwarded-host'] = publicHost;
55
+ headers['x-forwarded-proto'] = String(req.headers['x-forwarded-proto'] ?? 'https');
56
+ // Framework dev servers commonly apply the same allowlist to WebSocket Origin
57
+ // that they apply to Host. Present the local origin they were configured for.
58
+ if (headers.origin)
59
+ headers.origin = localOrigin(targetPort);
60
+ if (headers.referer) {
61
+ try {
62
+ const ref = new URL(headers.referer);
63
+ headers.referer = `${localOrigin(targetPort)}${ref.pathname}${ref.search}`;
64
+ }
65
+ catch {
66
+ // Preserve an unusual Referer rather than inventing one.
67
+ }
68
+ }
69
+ return headers;
70
+ }
71
+ function rewriteLocation(location, req, targetPort) {
72
+ if (!location)
73
+ return undefined;
74
+ const local = new RegExp(`^https?://(?:127\\.0\\.0\\.1|localhost)(?::${targetPort})?`, 'i');
75
+ return location.replace(local, externalOrigin(req));
76
+ }
77
+ function writeUpgradeRequest(upstream, req, head, targetPort) {
78
+ const headers = proxyHeaders(req, targetPort);
79
+ const lines = [`${req.method ?? 'GET'} ${req.url ?? '/'} HTTP/${req.httpVersion}`];
80
+ for (const [name, value] of Object.entries(headers)) {
81
+ if (value === undefined)
82
+ continue;
83
+ if (Array.isArray(value)) {
84
+ for (const item of value)
85
+ lines.push(`${name}: ${item}`);
86
+ }
87
+ else {
88
+ lines.push(`${name}: ${value}`);
89
+ }
90
+ }
91
+ upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
92
+ if (head.length)
93
+ upstream.write(head);
94
+ }
95
+ /** Loopback reverse proxy with raw WebSocket tunnelling for Vite-style HMR. */
96
+ export function createDevProxy(targetPort) {
97
+ return new Promise((resolve, reject) => {
98
+ const token = crypto.randomBytes(16).toString('hex');
99
+ const sockets = new Set();
100
+ const server = http.createServer((req, res) => {
101
+ if (req.headers['x-conductor-remote-bridge'] === token) {
102
+ res.writeHead(204);
103
+ return res.end();
104
+ }
105
+ const upstream = http.request({
106
+ host: '127.0.0.1',
107
+ port: targetPort,
108
+ method: req.method,
109
+ path: req.url,
110
+ headers: proxyHeaders(req, targetPort)
111
+ }, upstreamRes => {
112
+ const headers = { ...upstreamRes.headers };
113
+ const location = rewriteLocation(upstreamRes.headers.location, req, targetPort);
114
+ if (location)
115
+ headers.location = location;
116
+ res.writeHead(upstreamRes.statusCode ?? 502, headers);
117
+ upstreamRes.pipe(res);
118
+ });
119
+ upstream.once('error', () => {
120
+ if (!res.headersSent)
121
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
122
+ res.end('Dev server is not reachable');
123
+ });
124
+ req.pipe(upstream);
125
+ });
126
+ server.on('connection', socket => {
127
+ sockets.add(socket);
128
+ socket.once('close', () => sockets.delete(socket));
129
+ });
130
+ server.on('upgrade', (req, socket, head) => {
131
+ const upstream = net.connect({ host: '127.0.0.1', port: targetPort }, () => {
132
+ writeUpgradeRequest(upstream, req, head, targetPort);
133
+ socket.pipe(upstream).pipe(socket);
134
+ });
135
+ sockets.add(upstream);
136
+ upstream.once('close', () => sockets.delete(upstream));
137
+ upstream.once('error', () => socket.destroy());
138
+ socket.once('error', () => upstream.destroy());
139
+ });
140
+ server.once('error', reject);
141
+ server.listen(0, '127.0.0.1', () => {
142
+ const address = server.address();
143
+ if (!address || typeof address === 'string') {
144
+ server.close();
145
+ return reject(new Error('could not allocate the dev-server bridge port'));
146
+ }
147
+ resolve({
148
+ port: address.port,
149
+ token,
150
+ close: () => new Promise(done => {
151
+ for (const socket of sockets)
152
+ socket.destroy();
153
+ server.close(() => done());
154
+ })
155
+ });
156
+ });
157
+ });
158
+ }