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
@@ -20,7 +20,7 @@
20
20
  * must be `run-run:<n>.sh` specifically, so the setup wrapper (`run-setup:<n>.sh`) that
21
21
  * runs beside it is not read as an active Run.
22
22
  *
23
- * Like src/background-tasks.ts, only `ps` **args** are read, never the environment. The
23
+ * Like src/reads/background-tasks.ts, only `ps` **args** are read, never the environment. The
24
24
  * args snapshot can itself hold sensitive command-line data, so it is never logged.
25
25
  */
26
26
  import { execFile } from 'node:child_process';
@@ -70,7 +70,7 @@ let refreshing = null;
70
70
  /**
71
71
  * The project keys with a live Run wrapper, stale-while-revalidate.
72
72
  *
73
- * Synchronous on purpose, exactly like src/background-tasks.ts ▸ `agentProcessStarts`:
73
+ * Synchronous on purpose, exactly like src/reads/background-tasks.ts ▸ `agentProcessStarts`:
74
74
  * `/api/state` is assembled from plain reads and must not fork a `ps` on every 2.5s
75
75
  * poll. The first call after start answers empty — no badge for one poll — and every
76
76
  * call after that answers from the last listing while a fresh one lands.
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,6 @@
1
1
  import os from 'node:os';
2
2
  import path from 'node:path';
3
- import { isPreviewableImage, isPreviewableSource } from "./shared.js";
3
+ import { isPreviewableImage, isPreviewableSource } from "../shared.js";
4
4
  /** True only for a descendant. A prefix test would let `/workspaces-old` escape `/workspaces`. */
5
5
  function insideDirectory(filePath, root) {
6
6
  const relative = path.relative(root, filePath);
@@ -1,4 +1,4 @@
1
- import { workspaceDiffStats } from "./git.js";
1
+ import { workspaceDiffStats } from "./diff.js";
2
2
  /**
3
3
  * Git stats are local and cheap, but not cheap enough to run synchronously for every
4
4
  * workspace on the 2.5s state poll. Keep the last answer on the wire while a bounded
@@ -1,7 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import path from 'node:path';
3
3
  import { promisify } from 'node:util';
4
- import { isPreviewableSource } from "./shared.js";
4
+ import { isPreviewableSource } from "../shared.js";
5
5
  const exec = promisify(execFile);
6
6
  const MAX_PATCH_BYTES = 400_000;
7
7
  const MAX_LISTED_FILES = 20_000;
@@ -30,7 +30,7 @@ import { execFile } from 'node:child_process';
30
30
  import fs from 'node:fs';
31
31
  import path from 'node:path';
32
32
  import { promisify } from 'node:util';
33
- import { packageRoot } from "./pkg-root.js";
33
+ import { packageRoot } from "../pkg-root.js";
34
34
  const execFileP = promisify(execFile);
35
35
  const NAME = 'conductor-remote';
36
36
  const projectDir = packageRoot(import.meta.dirname);
@@ -28,10 +28,10 @@ import { Resolver } from 'node:dns/promises';
28
28
  import https from 'node:https';
29
29
  import net from 'node:net';
30
30
  import { promisify } from 'node:util';
31
- import { readSettings } from "./settings.js";
31
+ import { readSettings } from "../settings.js";
32
+ import { joinInstantHotspot } from "../writes/system.js";
32
33
  import { magicDnsName, parseServeStatus, readExposeMode, relayPort, relayServeState, tailscaleBin } from "./tailscale.js";
33
34
  import { hasDefaultRoute, joinNetwork, preferredNetworks } from "./wifi.js";
34
- import { joinInstantHotspot } from "./writes.js";
35
35
  const execFileP = promisify(execFile);
36
36
  const PROBE_PATH = '/health'; // unauthenticated 200 on the relay — no token needed to prove reachability
37
37
  const PROBE_TIMEOUT_MS = 8000;
@@ -205,7 +205,7 @@ async function routeAppeared(waitMs) {
205
205
  *
206
206
  * The guards matter more than the action. Switching Wi-Fi networks can take a working
207
207
  * Mac off a working network, so this borrows the shape of `serverWindowCount()`'s veto in
208
- * writes.ts: **a probe that can't answer must prevent the action, never cause it.**
208
+ * src/writes/applescript/window.applescript: **a probe that can't answer must prevent the action, never cause it.**
209
209
  * - Opt-in only (`autoRejoin`), and only with somewhere to go.
210
210
  * - Only when `hasDefaultRoute()` is definitively false. That probe needs no permission
211
211
  * grant, unlike reading the SSID, which macOS refuses without Location Services and
@@ -216,7 +216,7 @@ async function routeAppeared(waitMs) {
216
216
  *
217
217
  * Two ways in, tried in order. `networksetup` first — cheap, no UI — and when it answers
218
218
  * "Could not find network" (a hotspot doesn't broadcast until asked), the Accessibility
219
- * press on the Wi-Fi menu's own row (`joinInstantHotspot` in writes.ts), which wakes the
219
+ * press on the Wi-Fi menu's own row (`joinInstantHotspot` in src/writes/system.ts), which wakes the
220
220
  * phone's hotspot over Continuity exactly like clicking it. The press needs an unlocked
221
221
  * screen — the lock hides the session from Accessibility, and the failure names that —
222
222
  * so a lid-closed Mac still wants macOS's own Auto-Join Hotspot set to Automatic as the
@@ -21,7 +21,7 @@ import fs from 'node:fs';
21
21
  import os from 'node:os';
22
22
  import path from 'node:path';
23
23
  import util from 'node:util';
24
- import { scrubWorkflowSecrets } from "./shared.js";
24
+ import { scrubWorkflowSecrets } from "../shared.js";
25
25
  /** Kept small enough that a phone on a slow link can pull the whole thing; ~a day of a quiet relay. */
26
26
  const CAPACITY = 600;
27
27
  /** One AppleScript failure can carry a long stack; keep a single entry from crowding out the rest. */
@@ -30,7 +30,7 @@ export const HELPER_PATH = '/usr/local/libexec/conductor-remote-nosleep';
30
30
  * with SIGKILL leaves this file behind, macOS recycles pids freely, and the helper runs
31
31
  * as root — so `kill -0` succeeds against whatever inherited the number, and `--stop`
32
32
  * would then SIGTERM an unrelated root process. Readers that only want the pid and the
33
- * expiry (`src/nosleep.ts` ▸ readPidfile) can ignore it; a two-field file from an older
33
+ * expiry (`src/host/nosleep.ts` ▸ readPidfile) can ignore it; a two-field file from an older
34
34
  * version still parses, and skips the check. The fourth field records whether this
35
35
  * window also holds the display assertion that blocks the automatic screen lock.
36
36
  */
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared Tailscale helpers used by BOTH the deploy script (scripts/service.ts) and the runtime funnel
3
- * watchdog (src/funnel-watchdog.ts). They must agree: the watchdog re-establishes Funnel with the same
3
+ * watchdog (src/host/funnel-watchdog.ts). They must agree: the watchdog re-establishes Funnel with the same
4
4
  * port and posture the deploy configured, so "how do we find tailscale / this node's public name / the
5
5
  * expose mode" lives here once rather than drifting between deploy-time and runtime copies.
6
6
  *
@@ -0,0 +1,2 @@
1
+ /** Distinguishes an unmatched route from a handled streaming or empty response. */
2
+ export const NOT_HANDLED = Symbol('route not handled');
@@ -0,0 +1,78 @@
1
+ import http from 'node:http';
2
+ import { UiBusyError, uiQueueDepth, withUiPriority } from "../writes/ui-lock.js";
3
+ import { NOT_HANDLED } from "./router-types.js";
4
+ import { createCreateWorkspaceRoutes } from "./routes/create-workspace.js";
5
+ import { createFilesRoutes } from "./routes/files.js";
6
+ import { createPromptsRoutes } from "./routes/prompts.js";
7
+ import { createSessionsRoutes } from "./routes/sessions.js";
8
+ import { createStateRoutes } from "./routes/state.js";
9
+ import { createSystemRoutes } from "./routes/system.js";
10
+ import { createVoiceRoutes } from "./routes/voice.js";
11
+ import { createWorkflowsRoutes } from "./routes/workflows.js";
12
+ import { createWorkspacesRoutes } from "./routes/workspaces.js";
13
+ export function createRelayServer(services) {
14
+ const { handleMcpHttp, serveStatic, authed, json, PayloadTooLargeError, workflowHttpError } = services;
15
+ const handlers = [
16
+ createStateRoutes(services),
17
+ createWorkflowsRoutes(services),
18
+ createVoiceRoutes(services),
19
+ createSystemRoutes(services),
20
+ createCreateWorkspaceRoutes(services),
21
+ createFilesRoutes(services),
22
+ createWorkspacesRoutes(services),
23
+ createSessionsRoutes(services),
24
+ createPromptsRoutes(services)
25
+ ];
26
+ const server = http.createServer(async (req, res) => {
27
+ const url = new URL(req.url ?? '/', 'http://x');
28
+ const { pathname } = url;
29
+ // POST /mcp — the MCP Streamable HTTP transport, for a client that can only reach a
30
+ // URL (an agent on another machine, or a hosted one). Same tools as
31
+ // `conductor-remote mcp`'s stdio, same token gate as /api/*, and — because this runs
32
+ // *inside* the relay — the same UI lock, with no second process to sit outside it.
33
+ //
34
+ // Deliberately minimal: this server never initiates a message, so there is no SSE
35
+ // stream to open and GET is answered 405, which the spec allows. It keeps no session
36
+ // either, so no `Mcp-Session-Id` is issued and every request stands alone.
37
+ if (pathname === '/mcp')
38
+ return handleMcpHttp(req, res);
39
+ if (!pathname.startsWith('/api/'))
40
+ return serveStatic(req, res, pathname);
41
+ // Everything under /api requires the shared secret.
42
+ if (!authed(req))
43
+ return json(req, res, 401, { error: 'unauthorized' });
44
+ // Who is asking decides who waits for Conductor's window. An agent (src/mcp.ts sets
45
+ // this header) yields the UI lock to the person holding the phone — see src/writes/ui-lock.ts ▸
46
+ // uiTurn. Anything unlabelled is treated as the person, because the phone is the
47
+ // only caller that predates the header and mislabelling it would be the bad way round.
48
+ const priority = req.headers['x-relay-client'] === 'mcp' ? 'background' : 'interactive';
49
+ return withUiPriority(priority, async () => {
50
+ try {
51
+ for (const handler of handlers) {
52
+ if ((await handler(req, res, url)) !== NOT_HANDLED)
53
+ return;
54
+ }
55
+ return json(req, res, 404, { error: 'no route', pathname });
56
+ }
57
+ catch (err) {
58
+ if (err instanceof PayloadTooLargeError)
59
+ return json(req, res, 413, { error: err.message });
60
+ const workflowError = workflowHttpError(err);
61
+ if (workflowError) {
62
+ console.warn(`[workflow] ${req.method} ${pathname}: ${workflowError.error.code}: ${workflowError.error.message}`);
63
+ return json(req, res, workflowError.status, { ok: false, error: workflowError.error });
64
+ }
65
+ // A refused UI turn is not a server fault and a retry is the right move, so it gets
66
+ // 503 + Retry-After rather than a 500 that reads as "the relay is broken".
67
+ if (err instanceof UiBusyError) {
68
+ res.setHeader('retry-after', '15');
69
+ return json(req, res, 503, { error: err.message, busy: true, queue: uiQueueDepth() });
70
+ }
71
+ // Log the detail locally; don't reflect internals (paths, stack strings) back over the wire.
72
+ console.error(`[relay] ${req.method} ${pathname} failed:`, err);
73
+ return json(req, res, 500, { error: 'internal error' });
74
+ }
75
+ });
76
+ });
77
+ return server;
78
+ }
@@ -0,0 +1,90 @@
1
+ import { stagedAttachments } from "../../files/staged-attachments.js";
2
+ import { isRoute, routes } from "../../routes.js";
3
+ import { EFFORT_LABELS } from "../../writes/agent-options.js";
4
+ import { NOT_HANDLED } from "../router-types.js";
5
+ export function createCreateWorkspaceRoutes(services) {
6
+ const { readBody, json, STAGED_ATTACHMENTS_DIR, reads, createWorkspaceAndRead, firstPrompts } = services;
7
+ return async (req, res, url) => {
8
+ const { pathname } = url;
9
+ // POST /api/workspaces { repo, prompt, model?/effort?/plan?/fast?, send? }
10
+ // — create a workspace via Conductor's deep link, then configure its first chat.
11
+ if (isRoute(routes.createWorkspace, req.method, pathname)) {
12
+ const body = JSON.parse((await readBody(req)) || '{}');
13
+ const attachmentIds = body.attachmentIds ?? [];
14
+ if ('workflow' in body)
15
+ return json(req, res, 400, { error: 'Workflow starts through POST /api/workflows.' });
16
+ if (body.model !== undefined && typeof body.model !== 'string')
17
+ return json(req, res, 400, { error: 'model must be a picker label' });
18
+ if (body.effort !== undefined && typeof body.effort !== 'string')
19
+ return json(req, res, 400, { error: 'effort must be a string' });
20
+ const effort = body.effort?.trim() || undefined;
21
+ if (effort && !EFFORT_LABELS[effort])
22
+ return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
23
+ if (body.plan !== undefined && typeof body.plan !== 'boolean')
24
+ return json(req, res, 400, { error: 'plan must be a boolean' });
25
+ if (body.fast !== undefined && typeof body.fast !== 'boolean')
26
+ return json(req, res, 400, { error: 'fast must be a boolean' });
27
+ const requestedAgent = {
28
+ model: body.model?.trim() || undefined,
29
+ effort,
30
+ plan: body.plan,
31
+ fast: body.fast
32
+ };
33
+ if (!Array.isArray(attachmentIds) || attachmentIds.some(id => typeof id !== 'string'))
34
+ return json(req, res, 400, { error: 'attachment ids must be a list of strings' });
35
+ const attachments = stagedAttachments(STAGED_ATTACHMENTS_DIR, attachmentIds);
36
+ if (!attachments)
37
+ return json(req, res, 409, { error: 'an attached file is no longer available; add it again' });
38
+ // The prompt is optional — a bare `path=` opens an empty workspace, like
39
+ // Conductor's own New workspace — but *something* has to say where it goes.
40
+ const objective = [...attachments.map(attachment => attachment.token), (body.prompt ?? '').trim()]
41
+ .filter(Boolean)
42
+ .join('\n');
43
+ if (!objective && !body.repo)
44
+ return json(req, res, 400, { error: 'need a repo or a prompt' });
45
+ const prompt = objective;
46
+ const agent = requestedAgent;
47
+ const configureAgent = Object.values(agent).some(value => value !== undefined);
48
+ // Resolve the repo to a real path: an unmatched `path` would silently land
49
+ // the workspace in whichever repo Conductor happens to list first.
50
+ const repo = body.repo ? reads.listRepos().find(r => r.name === body.repo) : undefined;
51
+ if (body.repo && !repo)
52
+ return json(req, res, 404, { error: `unknown repo ${body.repo}` });
53
+ if (repo && !repo.root_path)
54
+ return json(req, res, 409, { error: `${repo.name} has no checkout path` });
55
+ const { result, created } = await createWorkspaceAndRead(prompt, repo?.root_path ?? null, repo?.name);
56
+ if (!result.ok)
57
+ return json(req, res, 502, result);
58
+ if (!created) {
59
+ return json(req, res, 502, {
60
+ ok: false,
61
+ strategy: result.strategy,
62
+ error: 'Conductor didn’t create a workspace — check it’s running and not showing a dialog.'
63
+ });
64
+ }
65
+ // Return as soon as the row exists (~2s) — waiting for delivery would block the
66
+ // request through Conductor's whole setup, measured at 30s+ on a real repo and
67
+ // past any budget a phone should hold a request open for. The queue delivers on
68
+ // its own schedule and the phone watches it in /api/state; `send:true` opts API
69
+ // callers into waiting.
70
+ // Whatever happens, the prompt is already pre-filled in Conductor's composer.
71
+ const settled = prompt || configureAgent
72
+ ? firstPrompts.enqueue(created.id, prompt, body.sendImmediately !== false, attachmentIds, configureAgent ? agent : undefined)
73
+ : null;
74
+ const failed = settled && body.send === true ? await settled : null;
75
+ settled?.catch(() => undefined); // fire-and-forget: it reports failure, it never rejects
76
+ return json(req, res, 200, {
77
+ ok: true,
78
+ workspaceId: created.id,
79
+ workspace: reads.getWorkspace(created.id) ?? created,
80
+ pendingPrompt: prompt || undefined,
81
+ model: agent.model,
82
+ sent: body.send === true && !!prompt && !failed,
83
+ configured: body.send === true && configureAgent && !failed,
84
+ warning: failed?.error &&
85
+ `Workspace created; the initial ${configureAgent ? 'agent settings and prompt' : 'prompt'} didn’t finish (${failed.error}).`
86
+ });
87
+ }
88
+ return NOT_HANDLED;
89
+ };
90
+ }
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs';
2
+ import { routeParam, routes } from "../../routes.js";
3
+ import { NOT_HANDLED } from "../router-types.js";
4
+ export function createFilesRoutes(services) {
5
+ const { reads, json, serveLocalImage, serveFilePreview } = services;
6
+ return async (req, res, url) => {
7
+ const { pathname } = url;
8
+ // GET /api/repos/:name/icon — the repo's resolved sidebar icon (see src/files/icons.ts)
9
+ const repo = routeParam(routes.repoIcon, req.method, pathname);
10
+ if (repo) {
11
+ const icon = reads.resolveRepoIcon(repo);
12
+ if (!icon)
13
+ return json(req, res, 404, { error: 'no icon' });
14
+ return void fs.readFile(icon.path, (err, data) => {
15
+ if (err)
16
+ return void json(req, res, 404, { error: 'no icon' });
17
+ // Cache briefly on the phone; the resolver itself refreshes within ~30s of an icon change.
18
+ res.writeHead(200, { 'content-type': icon.contentType, 'cache-control': 'public, max-age=300' });
19
+ res.end(data);
20
+ });
21
+ }
22
+ // GET /api/local-images/:path — local images linked from agent Markdown. The browser fetches this
23
+ // with its Authorization header and turns the reply into an object URL (web/src/components/transcript/Markdown.tsx), so the secret
24
+ // stays out of the image URL. `serveLocalImage` limits reads to temp files and permitted workspace paths.
25
+ const localImage = routeParam(routes.localImage, req.method, pathname);
26
+ if (localImage)
27
+ return serveLocalImage(req, res, localImage);
28
+ // GET /api/tool-images/:reference — a screenshot or other image a tool returned. Held
29
+ // back from the transcript itself (~100 kB of base64 each) and fetched only for a step
30
+ // the reader opened, with the phone's auth header, like every other image route here.
31
+ const toolImageRef = routeParam(routes.toolImage, req.method, pathname);
32
+ if (toolImageRef) {
33
+ const image = reads.toolImage(toolImageRef);
34
+ if (!image)
35
+ return json(req, res, 404, { error: 'image not found' });
36
+ const bytes = Buffer.from(image.data, 'base64');
37
+ // Immutable: a transcript row is written once, so the reference names one picture
38
+ // forever and re-opening the step costs nothing.
39
+ res.writeHead(200, {
40
+ 'content-type': image.mediaType,
41
+ 'content-length': String(bytes.length),
42
+ 'cache-control': 'private, max-age=86400, immutable'
43
+ });
44
+ return void res.end(bytes);
45
+ }
46
+ // GET /api/files/:reference — source linked from an agent reply. The Markdown component
47
+ // intercepts the browser navigation and fetches this endpoint with its auth header.
48
+ const fileReference = routeParam(routes.filePreview, req.method, pathname);
49
+ if (fileReference)
50
+ return serveFilePreview(req, res, fileReference);
51
+ return NOT_HANDLED;
52
+ };
53
+ }