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
@@ -1,3930 +1,14 @@
1
- import crypto from 'node:crypto';
2
- import fs from 'node:fs';
3
- import http from 'node:http';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import zlib from 'node:zlib';
7
- import { applyAgentConfig } from "./agent-config.js";
8
- import { ATTACHMENT_DIR, attachmentName, attachmentPrompt, attachmentToken, writeAttachment } from "./attachments.js";
9
- import { startAutoUpdate, updateStatus } from "./autoupdate.js";
10
- import { attachChangeStats } from "./change-stats.js";
11
- import { isDefaultEffortLevel, readDefaultEfforts, writeDefaultEfforts } from "./conductor-settings.js";
12
- import { loadConfig, stateDir } from "./config.js";
13
- import { ConductorDb } from "./db.js";
14
- import { acceptDelegation, delegationHttpStatus } from "./delegation-intake.js";
15
- import { delegatedPrompt } from "./delegation-prompt.js";
16
- import { DelegationQueue, DelegationStore } from "./delegations.js";
17
- import { DevServerController } from "./dev-server.js";
18
- import { isAllowedPreviewPath, parseFileReference, parseImageReference } from "./file-preview.js";
19
- import { FirstPromptQueue } from "./firstprompt.js";
20
- import { captureForkWorkspace, materializeForkWorkspace, releaseForkWorkspace } from "./fork-workspace.js";
21
- import { startFunnelWatchdog } from "./funnel-watchdog.js";
22
- import { listSourceFiles, workspaceDiff, workspaceFileDiff } from "./git.js";
23
- import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
24
- import { createTools, handleRpc, READ_TIMEOUT_MS } from "./mcp-tools.js";
25
- import { mergePr } from "./merge.js";
26
- import { ModelCache } from "./model-cache.js";
27
- import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
28
- import { chatRoute, noteViewing, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
29
- import { IdempotencyConflictError, ORCHESTRATION_PROTOCOL_VERSION, OrchestrationDb, UiLeaseUnavailableError, WorkflowTransitionError } from "./orchestration-db.js";
30
- import { ParkedPromptQueue } from "./parked.js";
31
- import { PlanUsageService } from "./plan-usage.js";
32
- import { attachPrStatus } from "./pr.js";
33
- import { readPrefs, writePrefs } from "./prefs.js";
34
- import { Reads } from "./reads.js";
35
- import { currentProcessStartIdentity, incompatibleRelayProcesses, listUiCapableRelayProcesses, processIdentityAlive } from "./relay-processes.js";
36
- import { decodeRoles, RoleStore, roleModelIssues } from "./roles.js";
37
- import { isRoute, routeParam, routes } from "./routes.js";
38
- import { attachRunActivity } from "./run-activity.js";
39
- import { foldHits, queryTokens, SearchIndex } from "./search.js";
40
- import { SendOnce } from "./sendonce.js";
41
- import { SessionPoller } from "./session-poller.js";
42
- import { readSettings, writeSettings } from "./settings.js";
43
- import { attachmentTokens, isOpenAIRealtimeVoice, isVoiceLanguage, modelLabel, OPENAI_REALTIME_VOICES, responseErrorMessage, scrubWorkflowSecrets, timestampMs, VIEWING_HEADER, withoutClientWindowEvidence, withoutWindowEvidence, workspaceTitle } from "./shared.js";
44
- import { discardStagedAttachment, materializeStagedAttachments, pruneStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
45
- import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
46
- import { isToolUsageRange } from "./tool-usage.js";
47
- import { ToolUsageService } from "./tool-usage-service.js";
48
- import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
49
- import { recoverExpiredUiLease } from "./ui-lease-watchdog.js";
50
- import { VoiceBriefBoard } from "./voice/brief.js";
51
- import { VoiceBroker } from "./voice/broker.js";
52
- import { openAIOriginForSipHost, readVoiceConfig, voicePort } from "./voice/config.js";
53
- import { parseVoiceCallTarget, readVoiceChatContext, VoiceContextError } from "./voice/context.js";
54
- import { createVoiceGateway } from "./voice/gateway.js";
55
- import { MAX_VOICE_SEARCH_CHARS, VoiceHistory } from "./voice/history.js";
56
- import { PreviewStore } from "./voice/preview.js";
57
- import { createVoiceServer } from "./voice/server.js";
58
- import { mintSipTicket, missingTicketConfig } from "./voice/ticket.js";
59
- import { createVoiceTools } from "./voice/tools.js";
60
- import { createWebRtcCall, MAX_SDP_CHARS } from "./voice/webrtc.js";
61
- import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
62
- import { WorkflowCoordinator, WorkflowCoordinatorError } from "./workflow-coordinator.js";
63
- import { parseConfirmUiStableRequest, parseStartWorkflowRequest, parseWorkflowAdoptRequest, parseWorkflowCompleteRequest, parseWorkflowDelegateRequest, parseWorkflowReplayRequest, parseWorkflowRetryRequest, WorkflowRequestError, workflowClientIsMcp } from "./workflow-http.js";
64
- import { WorkflowGuardError } from "./workflow-machine.js";
65
- import { archiveWorkspace, closeChat, configureSharedUiLeaseProvider, continueWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, restoreChat, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiBusy, uiQueueDepth, uiTurn, WORKSPACE_STATUS_LABELS, withGatedUiCommand, withUiPriority } from "./writes.js";
66
- // Before anything that logs: from here on every console line is also kept in memory for
67
- // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
68
- installLogCapture();
69
- const cfg = loadConfig();
70
- const db = new ConductorDb(cfg.dbPath);
71
- const reads = new Reads(db, cfg.workspacesRoot);
72
- const sessionPoller = new SessionPoller(() => reads.listSessionStates());
73
- const actuator = pickActuator(cfg.writeStrategy);
74
- const devServers = new DevServerController();
75
- if (cfg.devWebPort !== undefined && process.env.CONDUCTOR_WORKSPACE_ID) {
76
- const preview = new URL(`http://localhost:${cfg.devWebPort}/`);
77
- preview.hash = new URLSearchParams({ token: cfg.token }).toString();
78
- // `yarn dev` is itself one application behind Conductor's Run button. Publish
79
- // the same canonical URL printed below. The private live advertisement crosses
80
- // into the installed relay that normally serves the phone; its forwarding code
81
- // still treats this exactly like any full URL and knows nothing about the token.
82
- devServers.advertisePreviewUrls(process.env.CONDUCTOR_WORKSPACE_ID, [{ name: 'Conductor Remote', url: preview.href }]);
83
- }
84
- const STAGED_ATTACHMENTS_DIR = path.join(stateDir(), 'attachment-staging');
85
- /** Attachment IDs are already encoded in the immutable objective's Conductor tokens. */
86
- function stagedAttachmentIdsInObjective(objective) {
87
- return [
88
- ...new Set(attachmentTokens(objective).flatMap(token => {
89
- const match = /^\.context\/attachments\/([A-Za-z0-9]{6})\//.exec(token.path);
90
- return match ? [match[1]] : [];
91
- }))
92
- ];
93
- }
94
- // Picker labels cannot be reconstructed from `sessions.model`, so they belong to
95
- // relay state alongside the prompt queues. This lets a brand-new workspace choose
96
- // from a list before Conductor has created its first chat.
97
- const modelCache = new ModelCache(path.join(stateDir(), 'model-cache.json'));
98
- const planUsage = new PlanUsageService();
99
- const toolUsage = new ToolUsageService(cfg.dbPath);
100
- const roleStore = new RoleStore(path.join(stateDir(), 'roles.json'));
101
- const orchestration = new OrchestrationDb(path.join(stateDir(), 'orchestration.db'), {
102
- processProbe: processIdentityAlive
103
- });
104
- const relayIdentity = {
105
- instanceId: crypto.randomUUID(),
106
- pid: process.pid,
107
- processStartedAt: currentProcessStartIdentity(),
108
- protocolVersion: ORCHESTRATION_PROTOCOL_VERSION
109
- };
110
- function orchestrationUnavailableReason() {
111
- return orchestration.schemaWarning ?? `orchestration schema ${orchestration.schemaVersion} is unsupported`;
112
- }
113
- if (orchestration.writable) {
114
- orchestration.registerRelayInstance({ ...relayIdentity, canDriveUi: true });
115
- const sharedUiLeases = orchestration.createSharedUiLeaseProvider(relayIdentity, { leaseMs: 2 * 60_000 });
116
- configureSharedUiLeaseProvider({
117
- acquire: async (request) => {
118
- try {
119
- return await sharedUiLeases.acquire(request);
120
- }
121
- catch (error) {
122
- if (error instanceof UiLeaseUnavailableError)
123
- throw new UiBusyError(0);
124
- throw error;
125
- }
126
- }
127
- });
128
- }
129
- else {
130
- console.warn(`[relay] ${orchestrationUnavailableReason()}; Workflow is disabled until this relay is upgraded`);
131
- }
132
- /** One store object per live worktree, so the queue never registers a path twice. */
133
- const delegationStores = new Map();
134
- function delegationStore(ws) {
135
- if (!ws.worktree)
136
- return null;
137
- const cached = delegationStores.get(ws.id);
138
- if (cached?.worktree === ws.worktree)
139
- return cached.store;
140
- const store = new DelegationStore(ws.worktree);
141
- delegationStores.set(ws.id, { worktree: ws.worktree, store });
142
- return store;
143
- }
144
- function liveDelegationStores() {
145
- return reads.listWorkspaces().flatMap(ws => {
146
- const store = delegationStore(ws);
147
- return store ? [store] : [];
148
- });
149
- }
150
- /** Preserve role identity for already-persisted pre-coordinator Workflow prompts. */
151
- function assignWorkflowRoot(ws, sessionId, role, assignedAt) {
152
- const store = delegationStore(ws);
153
- if (!store)
154
- return { ok: false, error: 'the workflow worktree is unavailable' };
155
- if (reads.sessionWorkspaceId(sessionId) !== ws.id) {
156
- return { ok: false, error: 'the workflow root chat is not in that workspace' };
157
- }
158
- const session = reads.getSession(sessionId);
159
- if (!session)
160
- return { ok: false, error: 'the workflow root chat is unavailable' };
161
- const assignments = store.sessionRoles();
162
- if (assignments.warning)
163
- return { ok: false, error: `cannot assign the workflow root: ${assignments.warning}` };
164
- const existing = assignments.sessions[sessionId];
165
- if (existing && existing.role !== role) {
166
- return { ok: false, error: `the new chat is already assigned to role ${existing.role}` };
167
- }
168
- if (!existing)
169
- store.assign(sessionId, { role, assignedAt });
170
- return { ok: true };
171
- }
172
- // Full-text index over the chat prose, in the relay's own sidecar DB — never in
173
- // Conductor's (see src/search.ts). It backfills in the background and is disposable:
174
- // deleting the file rebuilds it on the next start.
175
- const search = new SearchIndex(cfg.dbPath, path.join(stateDir(), 'search.db'));
176
- search.start();
177
- /**
178
- * The MCP tools, bound to this relay over loopback.
179
- *
180
- * A self-request looks odd and is deliberate: the alternative is carving every route
181
- * handler out of the router below so the tools could call them directly, which buys
182
- * a sub-millisecond hop and costs the guarantee that matters — that a tool behaves
183
- * identically over `POST /mcp` and over `conductor-remote mcp`'s stdio. One code
184
- * path, one set of budgets, one place a route's semantics live.
185
- */
186
- const mcpTools = createTools(async (route, opts = {}) => {
187
- // 0.0.0.0 binds every interface, so loopback still reaches us; a pinned RELAY_HOST
188
- // is the address we actually answer on.
189
- const host = !cfg.host || cfg.host === '0.0.0.0' || cfg.host === '::' ? '127.0.0.1' : cfg.host;
190
- const timeoutMs = opts.timeoutMs ?? READ_TIMEOUT_MS;
191
- const res = await fetch(`http://${host}:${cfg.port}${route}`, {
192
- method: opts.method ?? 'GET',
193
- signal: AbortSignal.timeout(timeoutMs),
194
- headers: {
195
- authorization: `Bearer ${cfg.token}`,
196
- 'content-type': 'application/json',
197
- 'x-relay-client': 'mcp',
198
- 'x-client-timeout-ms': String(timeoutMs)
199
- },
200
- body: opts.body === undefined ? undefined : JSON.stringify(opts.body)
201
- });
202
- const payload = (await res.json().catch(() => ({})));
203
- if (!res.ok) {
204
- const busy = res.status === 503 ? ' (Conductor’s UI is busy — retry shortly)' : '';
205
- throw new Error(`${responseErrorMessage(payload.error, `HTTP ${res.status}`)}${busy}`);
206
- }
207
- return payload;
208
- });
209
- // The voice process surface shares this process (and therefore the one UI lock) but not
210
- // this server's port. Only its three scoped routes are mounted through Funnel.
211
- const voiceConfig = readVoiceConfig();
212
- // Stable but useless outside this OpenAI abuse-control header. Hashing the relay
213
- // bearer means neither that bearer nor a device identifier leaves the Mac.
214
- const voiceSafetyIdentifier = crypto.createHash('sha256').update(`conductor-remote:${cfg.token}`).digest('hex');
215
- const voiceBoards = new Map();
216
- const voicePreviews = new PreviewStore(path.join(stateDir(), 'voice-previews.json'));
217
- const voiceHistory = new VoiceHistory(path.join(stateDir(), 'voice-history.db'));
218
- process.on('exit', () => voiceHistory.close());
219
- const voiceBroker = voiceConfig.openaiKey
220
- ? new VoiceBroker({
221
- apiKey: voiceConfig.openaiKey,
222
- apiOrigin: openAIOriginForSipHost(voiceConfig.sipHost),
223
- model: voiceConfig.model,
224
- voice: voiceConfig.voice,
225
- mcpUrl: voiceConfig.publicBaseUrl ? `${voiceConfig.publicBaseUrl}/mcp` : null,
226
- mcpToken: voiceConfig.mcpToken,
227
- stateFile: path.join(stateDir(), 'voice-calls.json'),
228
- history: voiceHistory,
229
- tools: callId => voiceToolsForCall(callId),
230
- onClose: callId => voiceBoards.delete(callId)
231
- })
232
- : null;
233
- function voiceBoard(callId) {
234
- let board = voiceBoards.get(callId);
235
- if (board)
236
- return board;
237
- board = new VoiceBriefBoard({
238
- reads: {
239
- listWorkspaces: () => {
240
- const workspaces = reads.listWorkspaces();
241
- attachPrStatus(workspaces);
242
- return workspaces;
243
- },
244
- listSessionStates: () => reads.listSessionStates(),
245
- lastAssistantText: sessionId => reads.lastAssistantText(sessionId),
246
- lastQuestionInput: sessionId => reads.lastQuestionInput(sessionId)
247
- },
248
- locked: async () => (await screenLocked()) === true,
249
- readPrefs,
250
- writePrefs
251
- });
252
- voiceBoards.set(callId, board);
253
- return board;
254
- }
255
- function voiceRelayOrigin() {
256
- const host = !cfg.host || cfg.host === '0.0.0.0' || cfg.host === '::' ? '127.0.0.1' : cfg.host;
257
- return `http://${host}:${cfg.port}`;
258
- }
259
- async function dispatchVoicePreview(preview) {
260
- const timeoutMs = 75_000;
261
- const res = await fetch(`${voiceRelayOrigin()}${routes.sendPrompt.path(preview.sessionId)}`, {
262
- method: routes.sendPrompt.method,
263
- signal: AbortSignal.timeout(timeoutMs),
264
- headers: {
265
- authorization: `Bearer ${cfg.token}`,
266
- 'content-type': 'application/json',
267
- 'x-relay-client': 'voice',
268
- 'x-client-timeout-ms': String(timeoutMs)
269
- },
270
- body: JSON.stringify({
271
- workspaceId: preview.workspaceId,
272
- text: preview.text,
273
- clientId: preview.token
274
- })
275
- });
276
- const payload = (await res.json().catch(() => ({})));
277
- return {
278
- ok: payload.ok === true,
279
- parked: payload.parked === true,
280
- error: payload.error ?? (!res.ok ? `HTTP ${res.status}` : undefined)
281
- };
282
- }
283
- async function createVoiceWorkspace(preview) {
284
- // Creation runs behind the shared Conductor UI lease. The call already returned
285
- // "queued", so keep enough room for one in-flight interactive action to finish
286
- // before the deep link and its workspace-row receipt run.
287
- const timeoutMs = 75_000;
288
- const res = await fetch(`${voiceRelayOrigin()}${routes.createWorkspace.path()}`, {
289
- method: routes.createWorkspace.method,
290
- signal: AbortSignal.timeout(timeoutMs),
291
- headers: {
292
- authorization: `Bearer ${cfg.token}`,
293
- 'content-type': 'application/json',
294
- 'x-relay-client': 'voice',
295
- 'x-client-timeout-ms': String(timeoutMs)
296
- },
297
- body: JSON.stringify({ repo: preview.repo, prompt: preview.prompt, sendImmediately: true })
298
- });
299
- const payload = (await res.json().catch(() => ({})));
300
- return {
301
- ok: res.ok && payload.ok === true,
302
- workspaceId: payload.workspaceId,
303
- warning: payload.warning,
304
- error: payload.error ?? (!res.ok ? `HTTP ${res.status}` : undefined)
305
- };
306
- }
307
- function voiceToolsForCall(callId) {
308
- return createVoiceTools({
309
- callId,
310
- board: voiceBoard(callId),
311
- previews: voicePreviews,
312
- findSession: sessionId => reads.listSessionStates().find(state => state.sessionId === sessionId) ?? null,
313
- listRepos: () => reads.listRepos().map(repo => ({ name: repo.name, defaultBranch: repo.default_branch })),
314
- createWorkspace: createVoiceWorkspace,
315
- readChatContext: target => readVoiceChatContext(reads, target),
316
- dispatch: dispatchVoicePreview,
317
- announce: spoken => {
318
- if (!voiceBroker?.inject(callId, spoken))
319
- console.warn(`[voice] ${callId} could not receive a delivery nudge`);
320
- }
321
- });
322
- }
323
- const voiceGateway = createVoiceGateway({
324
- config: () => voiceConfig,
325
- broker: () => voiceBroker,
326
- rpc: (callId, request) => handleRpc(voiceToolsForCall(callId), request)
327
- });
328
- const voiceServer = createVoiceServer({
329
- routes: voiceGateway,
330
- mcpToken: () => voiceConfig.mcpToken,
331
- log: line => console.warn(line)
332
- });
333
- // A windowless Conductor that ignores reopen *and* a Dock click can only be fixed
334
- // by restarting it — and quitting takes any agent mid-turn down with it. So the
335
- // write path may only do that while nothing is working, which is a DB fact, not
336
- // something AppleScript can see. Read fresh each time: a session can start between
337
- // the phone opening the app and the send landing.
338
- setRestartGuard(() => !reads.listWorkspaces().some(w => w.session_status === 'working'));
339
- const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
340
- let uiLeaseRecoveryRunning = false;
341
- /**
342
- * A dead relay can leave its detached, already-authorized GUI helper behind. Keep
343
- * the old mutex until that exact process group is gone, allow its external event
344
- * to settle, then let the database CAS create the persistent safety hold.
345
- */
346
- async function recoverUiLease() {
347
- if (!orchestration.writable || uiLeaseRecoveryRunning)
348
- return;
349
- uiLeaseRecoveryRunning = true;
350
- try {
351
- const result = await recoverExpiredUiLease(orchestration, relayIdentity);
352
- if (result.status === 'external_process_alive') {
353
- console.warn(`[workflow] expired UI helper ${result.owner.externalProcess?.pid ?? 'unknown'} could not be proven dead; retaining its mutex`);
354
- }
355
- else if (result.status === 'owned_external_terminated') {
356
- console.warn(`[workflow] terminated overdue UI helper ${result.owner.externalProcess?.pid ?? 'unknown'}`);
357
- }
358
- else if (result.status === 'reclaimed') {
359
- console.warn(`[workflow] reclaimed expired UI action ${result.owner.actionId}${result.quarantined ? ' into persistent quarantine' : ''}`);
360
- }
361
- }
362
- catch (error) {
363
- console.error('[workflow] expired UI lease recovery failed:', error);
364
- }
365
- finally {
366
- uiLeaseRecoveryRunning = false;
367
- }
368
- }
369
- /** Fail closed if another UI-capable relay cannot prove it speaks this protocol. */
370
- async function workflowCompatibilityError() {
371
- if (!orchestration.writable) {
372
- return `Workflow is disabled because ${orchestrationUnavailableReason()}.`;
373
- }
374
- try {
375
- const processes = await listUiCapableRelayProcesses();
376
- const incompatible = incompatibleRelayProcesses(processes, orchestration.listRelayInstances(), ORCHESTRATION_PROTOCOL_VERSION);
377
- if (!incompatible.length)
378
- return null;
379
- return `Workflow needs every live conductor-remote UI relay on protocol ${ORCHESTRATION_PROTOCOL_VERSION}. Stop incompatible PID${incompatible.length === 1 ? '' : 's'} ${incompatible.map(process => process.pid).join(', ')} and try again.`;
380
- }
381
- catch (error) {
382
- return `Workflow could not verify the live relay processes: ${error instanceof Error ? error.message : String(error)}`;
383
- }
384
- }
385
- /**
386
- * A deep link has no request id to correlate with the workspace row it creates. Keep
387
- * relay-originated creations single-flight until that row appears, or two simultaneous
388
- * requests can each claim the other's workspace. Manual desktop creation can still
389
- * happen in the gap, so callers also narrow the fresh row to the requested repo.
390
- */
391
- let workspaceCreationTail = Promise.resolve();
392
- async function createWorkspaceAndRead(prompt, repoPath, repoName, strictUnique = false) {
393
- const previous = workspaceCreationTail;
394
- let release = () => { };
395
- workspaceCreationTail = new Promise(resolve => {
396
- release = resolve;
397
- });
398
- await previous;
399
- try {
400
- const before = new Set(reads.listWorkspaces().map(w => w.id));
401
- const result = await createWorkspace(prompt, repoPath);
402
- if (!result.ok)
403
- return { result };
404
- // The deep link is fire-and-forget, so the new row is the only proof it worked.
405
- // Creating a worktree takes a beat longer than opening a chat does.
406
- for (let attempt = 0; attempt < 40; attempt++) {
407
- await sleep(500);
408
- const candidates = reads
409
- .listWorkspaces()
410
- .filter(workspace => !before.has(workspace.id) && (!repoName || workspace.repo_name === repoName));
411
- if (strictUnique && candidates.length > 1)
412
- return { result };
413
- if (candidates[0])
414
- return { result, created: candidates[0] };
415
- }
416
- return { result };
417
- }
418
- finally {
419
- release();
420
- }
421
- }
422
- /**
423
- * Has Conductor taken ownership of the prompt yet? The receipt everything below is
424
- * built on. The AppleScript actuator reports `ok` on `osascript` exit 0 — which only
425
- * means the script *ran*, not that Conductor accepted the keystrokes — so without
426
- * this a dropped send (asleep/unfocused Mac) looks delivered. A prompt accepted into
427
- * Conductor's durable outbox also counts, before it becomes a transcript row.
428
- */
429
- function deliveredSince(sessionId, text, since) {
430
- return reads.promptDeliveredSince(sessionId, text, since);
431
- }
432
- function deliveredRowSince(sessionId, text, sinceRowid) {
433
- const target = text.trim();
434
- const { entries } = reads.getMessages(sessionId, sinceRowid);
435
- return entries.find(e => e.role === 'user' && e.text.trim() === target)?.rowid ?? null;
436
- }
437
- /**
438
- * Watch for that row, ending on a check rather than a sleep, and never past
439
- * `budgetDeadline`. Conductor records the row or outbox item right after the send
440
- * presses Enter, so a real send is confirmed in a tick and only the failure path
441
- * waits the window out.
442
- *
443
- * The window is *also* what makes a retry safe — it is deliberately longer than the
444
- * row takes to appear, because everything past it is allowed to type into the
445
- * composer again — so note which end of the budget gets clipped when the two
446
- * compete: a retry only happens with `MIN_ATTEMPT_MS + CONFIRM_WINDOW_MS` left, so
447
- * a confirm *followed by another attempt* always gets its full window. Only the
448
- * last confirm of all can be cut short, and nothing follows it to duplicate a row.
449
- */
450
- async function confirmDeliveryRow(sessionId, text, sinceRowid, budgetDeadline) {
451
- const stopAt = Math.min(Date.now() + CONFIRM_WINDOW_MS, budgetDeadline);
452
- for (;;) {
453
- const rowid = deliveredRowSince(sessionId, text, sinceRowid);
454
- if (rowid !== null)
455
- return rowid;
456
- if (Date.now() >= stopAt)
457
- return null;
458
- await sleep(300);
459
- }
460
- }
461
- async function confirmDelivery(sessionId, text, since, budgetDeadline) {
462
- const stopAt = Math.min(Date.now() + CONFIRM_WINDOW_MS, budgetDeadline);
463
- for (;;) {
464
- if (deliveredSince(sessionId, text, since))
465
- return true;
466
- if (Date.now() >= stopAt)
467
- return false;
468
- await sleep(300);
469
- }
470
- }
471
- /** How long we watch the transcript after a run before deciding it didn't land. */
472
- const CONFIRM_WINDOW_MS = 6_000;
473
- /** Ceiling on a whole send, retries included — no phone should hold a request open longer. */
474
- const SEND_BUDGET_MS = 55_000;
475
- /** Below this there isn't room for a run that could plausibly succeed, so don't start one. */
476
- const MIN_ATTEMPT_MS = 12_000;
477
- /**
478
- * The least a confirm is worth doing at all. Held back from every run so a send that
479
- * lands can be *seen* to have landed — an unconfirmed send is indistinguishable from
480
- * a lost one, which is the failure this whole path exists to avoid.
481
- */
482
- const MIN_CONFIRM_MS = 2_000;
483
- /** Leaves the response itself time to get home before the caller's own timer fires. */
484
- const RESPONSE_MARGIN_MS = 5_000;
485
- /**
486
- * Budget for a caller that didn't say how long it would wait — a PWA build from
487
- * before `x-client-timeout-ms`, which aborted a send at a flat 25s. Sized so that
488
- * such a phone is no worse off than it was: one run with a ceiling like the old one,
489
- * and no retry (there was never room for a retry inside 25s).
490
- */
491
- const LEGACY_SEND_BUDGET_MS = 20_000;
492
- /**
493
- * Never outlast the caller. The relay giving up *after* the phone has is the worst
494
- * available outcome: the phone shows a failure while the send goes on to land, and
495
- * the user can't tell that from a send that really didn't. Pairing our budget to
496
- * the PWA's by hand wouldn't hold — the relay updates itself (autoupdate.ts) while
497
- * the app sits in a service-worker cache — so the caller states its own deadline
498
- * and we retry inside it.
499
- */
500
- function sendBudget(req) {
501
- const asked = Number(req.headers['x-client-timeout-ms']);
502
- if (!Number.isFinite(asked) || asked <= 0)
503
- return LEGACY_SEND_BUDGET_MS;
504
- // Floor at one confirmable attempt: a caller in a hurry still gets a real try.
505
- return Math.max(MIN_ATTEMPT_MS + CONFIRM_WINDOW_MS, Math.min(SEND_BUDGET_MS, asked - RESPONSE_MARGIN_MS));
506
- }
507
- /**
508
- * Where a chat sits in Conductor's tab strip. Both write paths need it: the
509
- * actuator selects that tab before touching anything, otherwise it acts on
510
- * whichever tab happens to be active.
511
- */
512
- function locateChat(ws, sessionId) {
513
- const sessions = reads.listSessions(ws.id);
514
- const index = sessions.findIndex(s => s.id === sessionId);
515
- if (index < 0 && sessions.length > 1)
516
- return { error: 'chat is no longer one of the workspace’s tabs' };
517
- if (index < 0)
518
- return { tab: undefined, session: undefined };
519
- return {
520
- tab: { index: index + 1, count: sessions.length, title: sessions[index].title ?? '' },
521
- session: sessions[index]
522
- };
523
- }
524
- /**
525
- * Open a chat tab in a workspace and come back with its id.
526
- *
527
- * ⌘T is fire-and-forget like every other keystroke here, so the id is not something
528
- * the write can return — the DB is the receipt. Which row is the new one is decided by
529
- * diffing the tab list against the one taken *before* the keystroke, not by taking the
530
- * newest: a sibling tab or another agent may have opened one in between, and picking by
531
- * `created_at` would hand back theirs.
532
- */
533
- async function openChat(ws) {
534
- const before = new Set(reads.listSessions(ws.id).map(s => s.id));
535
- const result = await newChat(ws);
536
- if (!result.ok)
537
- return { error: true, result };
538
- // The new session lands in the DB a beat after Cmd+T — poll for the fresh id.
539
- for (let i = 0; i < 12; i++) {
540
- await sleep(500);
541
- const fresh = reads.listSessions(ws.id).filter(s => !before.has(s.id));
542
- if (fresh.length > 1) {
543
- return {
544
- error: true,
545
- retryable: false,
546
- result: {
547
- ok: false,
548
- strategy: actuator.name,
549
- error: 'more than one new chat appeared; refusing to guess which one this request opened'
550
- }
551
- };
552
- }
553
- if (fresh[0])
554
- return { sessionId: fresh[0].id };
555
- }
556
- // The tab is almost certainly on screen; only its id is missing. Say so rather than
557
- // failing the call, so a caller can still tell the user where the work went.
558
- return { sessionId: null };
559
- }
560
- /**
561
- * Deliver a prompt to one chat and confirm it landed, retrying until the caller's
562
- * budget runs out. The single write path: the phone's own sends go through it, and so
563
- * does the first-prompt queue, so both get the same targeting, the same read-back,
564
- * the same retries and the same errors.
565
- *
566
- * Retrying here rather than handing the phone a Retry button is the point: the
567
- * failures this path hits are overwhelmingly warm-up costs — a cold or busy Conductor
568
- * makes the first AppleScript run slow enough to be killed, and the second finds an
569
- * activated app and lands — which is exactly why tapping Retry always worked. Two
570
- * things make doing it automatically safe rather than a way to send a prompt twice:
571
- * - **The transcript is the receipt.** Every run is followed by a full
572
- * `CONFIRM_WINDOW_MS` of watching for the matching user row, *including* runs that
573
- * reported an error, and the last of those checks is the moment before we type
574
- * again. A run that actually landed — even one killed just after pressing Enter,
575
- * or one whose row appeared after we'd stopped looking — is reported as delivered.
576
- * - **The composer is written, not appended to** (`fillComposer` sets AXValue), so a
577
- * retry replaces a half-finished attempt's text instead of doubling it.
578
- *
579
- * Bounded by a wall clock rather than an attempt count, because someone is holding
580
- * this request open: runs are bounded by the caller's deadline, and we stop rather
581
- * than start one the budget could not also confirm. The queue's own 3-sends-over-15-
582
- * minutes schedule sits *outside* this and is unaffected — it retries a delivery that
583
- * never got off the ground (worktree still setting up), not one Conductor fumbled.
584
- */
585
- async function deliverPrompt(ws, sessionId, text, budgetMs = SEND_BUDGET_MS, queue = false) {
586
- const located = locateChat(ws, sessionId);
587
- if ('error' in located)
588
- return { ok: false, strategy: actuator.name, attempts: 0, error: located.error };
589
- // Snapshot the transcript cursor and outbox ids once: every check below asks "did
590
- // *this* prompt arrive since we started", so a retry can't be fooled by an older
591
- // identical prompt moving from the outbox into a new transcript row.
592
- const before = reads.deliveryCursor(sessionId);
593
- const label = ws.branch ?? ws.id;
594
- const deadline = Date.now() + budgetMs;
595
- let attempts = 0;
596
- let last = { ok: false, strategy: actuator.name };
597
- for (;;) {
598
- attempts++;
599
- // The run gets the deadline, not a duration: `uiTurn` may hold it behind another
600
- // write, and only the run knows what was left of the budget when it started. Minus
601
- // the confirm, so a caller on a tight budget spends it on the run rather than on
602
- // watching — a 25s-era phone gets one full-length attempt, not two too short to finish.
603
- last = await actuator.send({ workspace: ws, sessionId, tab: located.tab }, text, {
604
- deadline: deadline - MIN_CONFIRM_MS,
605
- queue
606
- });
607
- // A run that left the prompt in the composer proved it wrote no row, so the
608
- // window would be six seconds of watching for nothing. One check still happens:
609
- // an *earlier* attempt's row can be arriving, and typing again over that is the
610
- // duplicate this whole path exists to avoid.
611
- const landed = sendNeverStarted(last.error)
612
- ? deliveredSince(sessionId, text, before)
613
- : await confirmDelivery(sessionId, text, before, deadline);
614
- if (landed) {
615
- if (attempts > 1)
616
- console.info(`[relay] send to ${label} landed on attempt ${attempts}`);
617
- return { ok: true, strategy: last.strategy, attempts };
618
- }
619
- if (retryWontHelp(last.error))
620
- break;
621
- // A locked screen isn't worth the rest of the budget either — but for the
622
- // opposite reason: the parked-prompt queue (src/parked.ts) waits it out far
623
- // past any deadline a phone could hold open, so hand it over at once.
624
- if (lockBlocked(last.error))
625
- break;
626
- if (deadline - Date.now() < MIN_ATTEMPT_MS + CONFIRM_WINDOW_MS)
627
- break;
628
- // The phone only ever sees the outcome; why a send goes missing lives on this
629
- // side, so leave the trail in relay.log rather than nothing at all.
630
- console.warn(`[relay] send to ${label} attempt ${attempts} didn’t land (${last.error ?? 'no user row appeared'}) — retrying`);
631
- }
632
- const tried = attempts > 1 ? ` (tried ${attempts}×)` : '';
633
- const error = last.ok
634
- ? `Send didn’t land in the chat — Conductor may have been asleep or unfocused${tried}. Try again.`
635
- : `${last.error}${tried}`;
636
- console.warn(`[relay] send to ${label} failed after ${attempts} attempt(s): ${error}`);
637
- return { ok: false, strategy: last.strategy, attempts, error };
638
- }
639
- /**
640
- * Undelivered first prompts, owned by this process rather than by the phone (see
641
- * firstprompt.ts for why). Everything Conductor-side it needs is a plain DB read.
642
- */
643
- const firstPrompts = new FirstPromptQueue(path.join(stateDir(), 'first-prompts.json'), {
644
- inspect: workspaceId => {
645
- const ws = reads.getWorkspace(workspaceId);
646
- if (!ws)
647
- return null;
648
- // 'setting_up' is the worktree (and the setup script), not the window: Conductor
649
- // draws the workspace and its chat the moment the row exists, so the queue tries
650
- // the send then and treats only a post-'ready' failure as one worth counting.
651
- // `getWorkspace` already limits itself to 'ready'/'setting_up', so an archived
652
- // workspace reads as no row at all and ages out rather than being typed into.
653
- const sessions = reads.listSessions(workspaceId);
654
- const session = sessions.find(s => s.id === ws.active_session_id) ?? sessions[0];
655
- return {
656
- phase: ws.state === 'ready' ? 'ready' : 'setting_up',
657
- sessionId: session?.id ?? null,
658
- alreadySent: !!session?.last_user_message_at,
659
- worktree: ws.worktree
660
- };
661
- },
662
- // The queue fires on its own schedule, so it must never make a human tap wait.
663
- send: (workspaceId, sessionId, text) => withUiPriority('background', async () => {
664
- const ws = reads.getWorkspace(workspaceId);
665
- if (!ws)
666
- return { ok: false, error: 'the workspace is gone' };
667
- const result = await deliverPrompt(ws, sessionId, text);
668
- return { ok: result.ok, error: result.error, blocked: lockBlocked(result.error) };
669
- }),
670
- setAgent: (workspaceId, sessionId, agent) => withUiPriority('background', async () => {
671
- const ws = reads.getWorkspace(workspaceId);
672
- if (!ws)
673
- return { ok: false, error: 'the workspace is gone' };
674
- const result = await applyAgentPatch(ws, sessionId, agent);
675
- return { ok: result.ok, error: result.error, blocked: lockBlocked(result.error) };
676
- }),
677
- materialize: async (_workspaceId, worktree, attachmentIds) => {
678
- try {
679
- materializeStagedAttachments(STAGED_ATTACHMENTS_DIR, worktree, attachmentIds);
680
- return { ok: true };
681
- }
682
- catch (err) {
683
- return { ok: false, error: err instanceof Error ? err.message : 'the attached files could not be copied' };
684
- }
685
- },
686
- assignRole: async (workspaceId, sessionId, role, assignedAt) => {
687
- try {
688
- const ws = reads.getWorkspace(workspaceId);
689
- return ws ? assignWorkflowRoot(ws, sessionId, role, assignedAt) : { ok: false, error: 'the workspace is gone' };
690
- }
691
- catch (err) {
692
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
693
- }
694
- },
695
- discard: attachmentIds => {
696
- for (const id of attachmentIds)
697
- discardStagedAttachment(STAGED_ATTACHMENTS_DIR, id);
698
- },
699
- // A locked Mac holds first prompts whole — no attempts spent, no aging — instead
700
- // of burning all three sends into a lock screen nobody is there to see.
701
- gate: async () => (await screenLocked()) !== true
702
- });
703
- /** Unreferenced pre-workspace uploads get one week for an offline device to reconnect. */
704
- const STAGED_ATTACHMENT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
705
- const STAGED_ATTACHMENT_SWEEP_MS = 6 * 60 * 60 * 1000;
706
- function referencedStagedAttachments() {
707
- const referenced = new Set();
708
- for (const draft of Object.values(readPrefs().drafts)) {
709
- if (draft.deleted)
710
- continue;
711
- for (const attachment of draft.attachments) {
712
- if (attachment.stageId)
713
- referenced.add(attachment.stageId);
714
- }
715
- }
716
- for (const prompt of firstPrompts.list()) {
717
- for (const stageId of prompt.attachmentIds ?? [])
718
- referenced.add(stageId);
719
- }
720
- if (orchestration.writable) {
721
- for (const projection of orchestration.listWorkflowProjections()) {
722
- const run = orchestration.getWorkflowRun(projection.id);
723
- if (run?.target.kind !== 'new_workspace')
724
- continue;
725
- for (const stageId of stagedAttachmentIdsInObjective(run.objective))
726
- referenced.add(stageId);
727
- }
728
- }
729
- return referenced;
730
- }
731
- function sweepStagedAttachments() {
732
- const removed = pruneStagedAttachments(STAGED_ATTACHMENTS_DIR, referencedStagedAttachments(), STAGED_ATTACHMENT_MAX_AGE_MS);
733
- if (removed)
734
- console.info(`[relay] removed ${removed} abandoned staged attachment(s)`);
735
- }
736
- /**
737
- * Apply staged agent settings to a chat — the shared half of `POST …/agent` and
738
- * of a send that carries a patch. The `fast` translation lives here because the
739
- * UI button only *toggles*: the DB says whether the press is needed at all.
740
- */
741
- async function applyAgentPatch(ws, sessionId, patch) {
742
- const located = locateChat(ws, sessionId);
743
- if ('error' in located)
744
- return { ok: false, error: located.error };
745
- const target = { workspace: ws, sessionId, tab: located.tab };
746
- const result = await applyAgentConfig(patch, {
747
- read: () => {
748
- const session = reads.listSessions(ws.id).find(row => row.id === sessionId);
749
- if (!session)
750
- return undefined;
751
- return {
752
- agentType: session.agent_type,
753
- model: session.model,
754
- effort: session.claude_effort_level,
755
- plan: session.permission_mode === 'plan',
756
- fast: Boolean(session.fast_mode)
757
- };
758
- },
759
- write: options => setAgentOptions(target, options),
760
- wait: () => sleep(300)
761
- });
762
- if (!result.ok)
763
- return result;
764
- if (patch.model) {
765
- const session = reads.listSessions(ws.id).find(row => row.id === sessionId);
766
- modelCache.rememberModel(session?.agent_type, patch.model);
767
- }
768
- return { ok: true };
769
- }
770
- function delegationError(code, error, retryable = true) {
771
- const clean = withoutWindowEvidence(error);
772
- if (clean !== error)
773
- console.warn(`[relay] ${error}`);
774
- return { ok: false, code, error: clean, retryable, blocked: lockBlocked(error) || uiBusy(error) };
775
- }
776
- function wireAttachment(written) {
777
- return {
778
- name: written.name,
779
- path: written.relPath,
780
- bytes: written.bytes,
781
- token: written.token
782
- };
783
- }
784
- /** Write the frozen parent transcript cut before opening its child tab. */
785
- function delegationHandoff(job, ws) {
786
- if (!ws.worktree)
787
- throw new Error('worktree path unresolved');
788
- const source = reads.getSession(job.parentSessionId);
789
- if (!source)
790
- throw new Error('parent chat not found in that workspace');
791
- const { entries } = reads.getMessages(job.parentSessionId);
792
- const cut = job.throughRowid === undefined ? { entries, later: 0 } : transcriptThrough(entries, job.throughRowid);
793
- if (!cut)
794
- throw new Error('the requested handoff message is not in the parent chat');
795
- const rendered = renderTranscript(cut.entries, { thinking: job.includeThinking, tools: false });
796
- if (!rendered.kept)
797
- throw new Error('the parent chat has nothing to hand off yet');
798
- const title = source.title?.trim() || 'chat';
799
- const header = [
800
- `# Transcript of ${title}`,
801
- '',
802
- [ws.repo_name, ws.branch].filter(Boolean).join(' · '),
803
- `Delegation ${job.id} copied this chat through ${job.throughRowid ?? 'its latest row'}.`,
804
- cut.later ? `${cut.later} later ${cut.later === 1 ? 'entry is' : 'entries are'} intentionally omitted.` : '',
805
- '',
806
- ''
807
- ]
808
- .filter((line, index) => line || index < 2 || index >= 5)
809
- .join('\n');
810
- return wireAttachment(writeAttachment(ws.worktree, `Transcript of ${title}.md`, header + rendered.text));
811
- }
812
- async function openDelegation(job) {
813
- const ws = reads.getWorkspace(job.workspaceId);
814
- if (!ws)
815
- return delegationError('workspace_not_found', 'the delegated workspace is gone', false);
816
- if (!ws.worktree)
817
- return delegationError('worktree_unavailable', 'worktree path unresolved', false);
818
- if ((await screenLocked()) === true) {
819
- return delegationError('opening_failed', 'The Mac is locked — unlock it and try again.');
820
- }
821
- let handoff;
822
- try {
823
- handoff = delegationHandoff(job, ws);
824
- }
825
- catch (err) {
826
- return delegationError('opening_failed', err instanceof Error ? err.message : String(err), false);
827
- }
828
- const opened = await withUiPriority('background', () => openChat(ws));
829
- if ('error' in opened) {
830
- return delegationError('opening_failed', opened.result.error ?? 'Conductor did not open a child chat', opened.retryable !== false);
831
- }
832
- if (!opened.sessionId)
833
- return delegationError('opening_failed', 'Conductor opened a tab but did not record its chat id', false);
834
- return { ok: true, childSessionId: opened.sessionId, handoff };
835
- }
836
- async function configureDelegation(job) {
837
- const ws = reads.getWorkspace(job.workspaceId);
838
- if (!ws)
839
- return delegationError('workspace_not_found', 'the delegated workspace is gone', false);
840
- if (!job.childSessionId)
841
- return delegationError('state_invalid', 'the delegated child id is missing', false);
842
- const before = reads.getSession(job.childSessionId);
843
- if (!before)
844
- return delegationError('session_not_found', 'the delegated child chat is gone', false);
845
- const applied = await withUiPriority('background', () => applyAgentPatch(ws, job.childSessionId, {
846
- model: job.resolvedRole.model,
847
- effort: job.resolvedRole.effort,
848
- fast: job.resolvedRole.fast
849
- }));
850
- if (!applied.ok)
851
- return delegationError('configuration_failed', applied.error ?? 'agent configuration did not stick');
852
- const after = reads.getSession(job.childSessionId);
853
- if (!after)
854
- return delegationError('session_not_found', 'the configured child chat disappeared', false);
855
- if (after.agent_type !== job.resolvedRole.agentType) {
856
- return delegationError('configuration_failed', `Conductor recorded provider ${after.agent_type ?? 'unknown'}, not ${job.resolvedRole.agentType}`);
857
- }
858
- return { ok: true };
859
- }
860
- async function sendDelegation(job) {
861
- const ws = reads.getWorkspace(job.workspaceId);
862
- if (!ws)
863
- return delegationError('workspace_not_found', 'the delegated workspace is gone', false);
864
- if (!job.childSessionId)
865
- return delegationError('state_invalid', 'the delegated child id is missing', false);
866
- let text;
867
- try {
868
- text = delegatedPrompt(job);
869
- }
870
- catch (err) {
871
- return delegationError('state_invalid', err instanceof Error ? err.message : String(err), false);
872
- }
873
- const cursor = reads.getMessages(job.childSessionId).cursor;
874
- const result = await withUiPriority('background', () => deliverPrompt(ws, job.childSessionId, text));
875
- if (!result.ok)
876
- return delegationError('send_failed', result.error ?? 'the delegated prompt did not land');
877
- const sentRowid = deliveredRowSince(job.childSessionId, text, cursor);
878
- if (sentRowid === null)
879
- return delegationError('send_failed', 'the delegated prompt has no transcript receipt');
880
- return { ok: true, sentRowid };
881
- }
882
- function delegationCompletion(job) {
883
- if (!job.childSessionId || job.sentRowid === undefined)
884
- return null;
885
- const child = reads.getSession(job.childSessionId);
886
- if (!child) {
887
- return {
888
- outcome: { kind: 'error', error: 'the delegated child chat disappeared' }
889
- };
890
- }
891
- const assistants = reads
892
- .getMessages(job.childSessionId, job.sentRowid)
893
- .entries.filter(entry => entry.role === 'assistant' && entry.text.trim());
894
- const last = assistants.at(-1);
895
- if (child.status === 'error') {
896
- return {
897
- outcome: {
898
- kind: 'error',
899
- error: 'the delegated agent stopped with an error',
900
- ...(last ? { assistantRowid: last.rowid, text: last.text.trim() } : {})
901
- },
902
- ...(last ? { completionRowid: last.rowid } : {})
903
- };
904
- }
905
- if (child.status !== 'idle' || child.background_tasks.length || !last)
906
- return null;
907
- return {
908
- outcome: { kind: 'success', assistantRowid: last.rowid, text: last.text.trim() },
909
- completionRowid: last.rowid
910
- };
911
- }
912
- /** Keep the structured Baton tail when present; otherwise the complete answer is the Baton. */
913
- function batonText(text) {
914
- const match = /^## Baton\b/im.exec(text);
915
- return match ? text.slice(match.index).trim() : text.trim();
916
- }
917
- function delegationReturnAttachment(job, ws) {
918
- if (!ws.worktree || !job.childSessionId || job.sentRowid === undefined)
919
- throw new Error('return state is incomplete');
920
- const rendered = renderTranscript(reads.getMessages(job.childSessionId, job.sentRowid).entries, {
921
- thinking: true,
922
- tools: false
923
- });
924
- const outcomeText = job.outcome
925
- ? job.outcome.kind === 'success'
926
- ? job.outcome.text
927
- : (job.outcome.text ?? job.outcome.error)
928
- : '(no transcript prose)';
929
- const body = [
930
- `# Delegated ${job.role} result`,
931
- '',
932
- `Delegation: ${job.id}`,
933
- `Child chat: ${job.childSessionId}`,
934
- '',
935
- rendered.text || outcomeText
936
- ].join('\n');
937
- return wireAttachment(writeAttachment(ws.worktree, `Delegated ${job.role} result.md`, body));
938
- }
939
- function delegationReturnText(job, attachment) {
940
- if (!job.outcome)
941
- throw new Error('the delegated outcome is missing');
942
- const result = job.outcome.kind === 'success' ? batonText(job.outcome.text) : batonText(job.outcome.text ?? job.outcome.error);
943
- const verb = job.outcome.kind === 'success' ? 'completed' : 'failed';
944
- return [`Delegated ${job.role} task ${job.id} ${verb}.`, '', result, '', attachment.token].join('\n');
945
- }
946
- async function returnDelegation(job) {
947
- const ws = reads.getWorkspace(job.workspaceId);
948
- if (!ws)
949
- return delegationError('workspace_not_found', 'the delegated workspace is gone', false);
950
- if (!reads.getSession(job.parentSessionId)) {
951
- return delegationError('session_not_found', 'the parent chat is gone', false);
952
- }
953
- if (job.returnCursor !== undefined) {
954
- if (!job.returnAttachment || !job.returnText) {
955
- return delegationError('state_invalid', 'the queued return receipt state is incomplete', false);
956
- }
957
- const rowid = deliveredRowSince(job.parentSessionId, job.returnText, job.returnCursor);
958
- return rowid === null
959
- ? {
960
- ok: true,
961
- pending: true,
962
- returnCursor: job.returnCursor,
963
- returnAttachment: job.returnAttachment,
964
- returnText: job.returnText
965
- }
966
- : { ok: true, returnRowid: rowid };
967
- }
968
- let attachment;
969
- let text;
970
- try {
971
- attachment = delegationReturnAttachment(job, ws);
972
- text = delegationReturnText(job, attachment);
973
- }
974
- catch (err) {
975
- return delegationError('return_failed', err instanceof Error ? err.message : String(err), false);
976
- }
977
- const cursor = reads.getMessages(job.parentSessionId).cursor;
978
- if (job.returnMode === 'steer') {
979
- const result = await withUiPriority('background', () => deliverPrompt(ws, job.parentSessionId, text, SEND_BUDGET_MS, false));
980
- if (!result.ok)
981
- return delegationError('return_failed', result.error ?? 'the delegated result did not return');
982
- const rowid = deliveredRowSince(job.parentSessionId, text, cursor);
983
- return rowid === null
984
- ? delegationError('return_failed', 'the delegated result has no transcript receipt')
985
- : { ok: true, returnRowid: rowid };
986
- }
987
- const located = locateChat(ws, job.parentSessionId);
988
- if ('error' in located)
989
- return delegationError('return_failed', located.error, false);
990
- const result = await withUiPriority('background', () => actuator.send({ workspace: ws, sessionId: job.parentSessionId, tab: located.tab }, text, {
991
- deadline: Date.now() + SEND_BUDGET_MS,
992
- queue: true
993
- }));
994
- const immediate = deliveredRowSince(job.parentSessionId, text, cursor);
995
- if (immediate !== null)
996
- return { ok: true, returnRowid: immediate };
997
- if (!result.ok) {
998
- const late = await confirmDeliveryRow(job.parentSessionId, text, cursor, Date.now() + CONFIRM_WINDOW_MS);
999
- if (late !== null)
1000
- return { ok: true, returnRowid: late };
1001
- return delegationError('return_failed', result.error ?? 'Conductor did not accept the queued result');
1002
- }
1003
- return {
1004
- ok: true,
1005
- pending: true,
1006
- returnCursor: cursor,
1007
- returnAttachment: attachment,
1008
- returnText: text
1009
- };
1010
- }
1011
- const delegationQueue = new DelegationQueue({
1012
- open: openDelegation,
1013
- configure: configureDelegation,
1014
- send: sendDelegation,
1015
- completion: delegationCompletion,
1016
- returnResult: returnDelegation
1017
- }, {
1018
- blockedError: error => error instanceof UiBusyError
1019
- });
1020
- sessionPoller.subscribe(() => {
1021
- void delegationQueue.wake();
1022
- });
1023
- function projectDelegation(job) {
1024
- return {
1025
- id: job.id,
1026
- workspaceId: job.workspaceId,
1027
- parentSessionId: job.parentSessionId,
1028
- ...(job.childSessionId ? { childSessionId: job.childSessionId } : {}),
1029
- role: job.role,
1030
- resolvedRole: job.resolvedRole,
1031
- prompt: job.prompt,
1032
- returnMode: job.returnMode,
1033
- status: job.status,
1034
- attempts: job.attempts,
1035
- createdAt: job.createdAt,
1036
- updatedAt: job.updatedAt,
1037
- ...(job.outcome ? { outcome: job.outcome } : {}),
1038
- ...(job.failure ? { failure: job.failure } : {})
1039
- };
1040
- }
1041
- function attachDelegationState(workspaces) {
1042
- for (const ws of workspaces) {
1043
- const store = delegationStore(ws);
1044
- if (!store)
1045
- continue;
1046
- const listed = store.list();
1047
- const jobs = listed.jobs.filter(job => job.status !== 'returned').map(projectDelegation);
1048
- const roles = store.sessionRoles();
1049
- if (jobs.length)
1050
- Object.assign(ws, { delegations: jobs });
1051
- if (Object.keys(roles.sessions).length)
1052
- Object.assign(ws, { session_roles: roles.sessions });
1053
- const warnings = [...listed.warnings.map(warning => `${warning.file}: ${warning.message}`)];
1054
- if (roles.warning)
1055
- warnings.push(`sessions.json: ${roles.warning}`);
1056
- if (warnings.length)
1057
- Object.assign(ws, { delegation_warning: warnings.join('; ') });
1058
- }
1059
- }
1060
- /** Keep process identity and raw recovery evidence private; the phone only needs the hold and its cause. */
1061
- function wireUiQuarantine() {
1062
- if (!orchestration.writable)
1063
- return undefined;
1064
- const quarantine = orchestration.getUiQuarantine();
1065
- if (!quarantine.active)
1066
- return undefined;
1067
- const bounded = (value, maximum) => withoutWindowEvidence(scrubWorkflowSecrets(value)).slice(0, maximum);
1068
- return {
1069
- active: true,
1070
- reason: bounded(quarantine.reason ??
1071
- 'A previous automated Conductor UI action may have completed without a confirmed receipt. Inspect Conductor before continuing.', 500),
1072
- createdAt: quarantine.createdAt ?? 0,
1073
- ...(quarantine.actionId ? { actionId: bounded(quarantine.actionId, 256) } : {}),
1074
- ...(quarantine.effectId ? { effectId: bounded(quarantine.effectId, 256) } : {})
1075
- };
1076
- }
1077
- function workflowJobStatus(state) {
1078
- if (state === 'owned')
1079
- return 'opening';
1080
- if (state === 'dormant')
1081
- return 'queued';
1082
- if (state === 'cancelled')
1083
- return 'failed';
1084
- return state;
1085
- }
1086
- function projectWorkflowDelegation(workflow, job) {
1087
- if (!workflow.workspaceId || job.state === 'cancelled')
1088
- return null;
1089
- return {
1090
- id: job.id,
1091
- workflowId: workflow.id,
1092
- logicalKey: job.logicalKey,
1093
- bootstrap: job.logicalKey === 'explore:0',
1094
- workspaceId: workflow.workspaceId,
1095
- parentSessionId: workflow.rootSessionId ?? '',
1096
- ...(job.childSessionId ? { childSessionId: job.childSessionId } : {}),
1097
- role: job.role,
1098
- // The immutable preamble remains coordinator-private. Only the public picker
1099
- // settings are useful for role chips and list_delegations.
1100
- resolvedRole: {
1101
- agentType: job.resolvedRole.agentType,
1102
- model: job.resolvedRole.model,
1103
- ...(job.resolvedRole.effort ? { effort: job.resolvedRole.effort } : {}),
1104
- ...(job.resolvedRole.fast === undefined ? {} : { fast: job.resolvedRole.fast })
1105
- },
1106
- prompt: scrubWorkflowSecrets(job.prompt).slice(0, 500),
1107
- returnMode: 'queue',
1108
- status: workflowJobStatus(job.state),
1109
- attempts: job.attemptCount,
1110
- createdAt: job.createdAt,
1111
- updatedAt: job.updatedAt,
1112
- ...(job.state === 'failed'
1113
- ? {
1114
- failure: {
1115
- code: 'workflow_blocked',
1116
- message: workflow.error?.message ?? 'Workflow job failed.',
1117
- retryable: workflow.actions.canRetry
1118
- }
1119
- }
1120
- : {})
1121
- };
1122
- }
1123
- function attachWorkflowState(workspaces) {
1124
- if (!orchestration.writable)
1125
- return [];
1126
- const projections = orchestration.listWorkflowProjections();
1127
- const byWorkspace = new Map(workspaces.map(workspace => [workspace.id, workspace]));
1128
- for (const workflow of projections) {
1129
- if (!workflow.workspaceId)
1130
- continue;
1131
- const workspace = byWorkspace.get(workflow.workspaceId);
1132
- if (!workspace)
1133
- continue;
1134
- const run = orchestration.getWorkflowRun(workflow.id);
1135
- if (!run)
1136
- continue;
1137
- const jobs = orchestration
1138
- .listWorkflowJobs(workflow.id)
1139
- .flatMap(job => projectWorkflowDelegation(workflow, job) ?? []);
1140
- workspace.delegations = [...(workspace.delegations ?? []), ...jobs];
1141
- workspace.session_roles = { ...(workspace.session_roles ?? {}) };
1142
- if (workflow.rootSessionId) {
1143
- workspace.session_roles[workflow.rootSessionId] = {
1144
- role: 'planning',
1145
- workflowId: workflow.id,
1146
- assignedAt: workflow.createdAt
1147
- };
1148
- }
1149
- for (const job of orchestration.listWorkflowJobs(workflow.id)) {
1150
- if (!job.childSessionId)
1151
- continue;
1152
- workspace.session_roles[job.childSessionId] = {
1153
- role: job.role,
1154
- delegationId: job.id,
1155
- workflowId: workflow.id,
1156
- assignedAt: job.createdAt
1157
- };
1158
- }
1159
- // Compatibility for cached clients that only understand one workspace-level run.
1160
- if (!workspace.workflow || workflow.rootSessionId === workspace.active_session_id)
1161
- workspace.workflow = workflow;
1162
- }
1163
- // A terminal run leaves the active Workflow list but not the workspace's identity.
1164
- // Attach only the newest historical projection when no live run already won above;
1165
- // its frozen public roles let the sidebar remain truthful after role settings change.
1166
- const historicalWorkspaceIds = [...byWorkspace.values()]
1167
- .filter(workspace => !workspace.workflow)
1168
- .map(workspace => workspace.id);
1169
- for (const workflow of orchestration.listLatestWorkflowProjectionsForWorkspaces(historicalWorkspaceIds)) {
1170
- if (!workflow.workspaceId)
1171
- continue;
1172
- const workspace = byWorkspace.get(workflow.workspaceId);
1173
- if (workspace && !workspace.workflow) {
1174
- workspace.workflow_identity = {
1175
- id: workflow.id,
1176
- phase: workflow.phase,
1177
- roles: workflow.roles
1178
- };
1179
- }
1180
- }
1181
- return projections;
1182
- }
1183
- function workflowOwningSession(sessionId) {
1184
- if (!orchestration.writable)
1185
- return null;
1186
- for (const projection of orchestration.listWorkflowProjections()) {
1187
- if (projection.rootSessionId === sessionId)
1188
- return projection;
1189
- if (orchestration.listWorkflowJobs(projection.id).some(job => job.childSessionId === sessionId))
1190
- return projection;
1191
- }
1192
- return null;
1193
- }
1194
- function workflowFrozenError(sessionId) {
1195
- const workflow = workflowOwningSession(sessionId);
1196
- if (!workflow)
1197
- return null;
1198
- return {
1199
- error: {
1200
- code: 'workflow_role_frozen',
1201
- message: `Workflow ${workflow.id} froze this chat's model, effort, and Fast setting at Start.`,
1202
- retryable: false
1203
- }
1204
- };
1205
- }
1206
- function workflowHttpError(error) {
1207
- if (error instanceof WorkflowRequestError || error instanceof WorkflowGuardError) {
1208
- return { status: error.status, error: { code: error.code, message: error.message, retryable: false } };
1209
- }
1210
- if (error instanceof WorkflowCoordinatorError) {
1211
- return {
1212
- status: error.status,
1213
- error: { code: error.code, message: error.message, retryable: error.retryable }
1214
- };
1215
- }
1216
- if (error instanceof IdempotencyConflictError) {
1217
- return {
1218
- status: 409,
1219
- error: { code: 'idempotency_conflict', message: error.message, retryable: false }
1220
- };
1221
- }
1222
- if (error instanceof WorkflowTransitionError) {
1223
- return {
1224
- status: 409,
1225
- error: { code: 'workflow_phase_invalid', message: error.message, retryable: false }
1226
- };
1227
- }
1228
- return null;
1229
- }
1230
- async function workflowRequestBody(req) {
1231
- try {
1232
- return JSON.parse((await readBody(req)) || '{}');
1233
- }
1234
- catch {
1235
- throw new WorkflowRequestError('Workflow request body must be valid JSON.');
1236
- }
1237
- }
1238
- /**
1239
- * One prompt per tap, however many requests carry it (src/sendonce.ts). Keyed on the
1240
- * phone's own `PendingMessage.id`, which Retry reuses and a fresh send re-rolls, so a
1241
- * repeat someone meant still goes twice. Only an answer the phone would treat as final
1242
- * is remembered: a real failure has to stay retryable, or Retry does nothing for ten
1243
- * minutes and the prompt is lost for good rather than merely doubled.
1244
- */
1245
- const sendOnce = new SendOnce({
1246
- keep: answer => answer.status === 200 || answer.status === 202
1247
- });
1248
- /** What the phone is told when its prompt is parked instead of failed. */
1249
- const PARKED_ERROR = 'The Mac is locked — the relay parked the prompt and will send it when the Mac is unlocked.';
1250
- /**
1251
- * Prompts that hit the lock screen, owned by this process until the Mac unlocks
1252
- * (see parked.ts for why the phone can't wait this out itself).
1253
- */
1254
- const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompts.json'), {
1255
- locked: screenLocked,
1256
- // Delivers on unlock, on its own schedule — background, like the first-prompt queue.
1257
- deliver: entry => withUiPriority('background', async () => {
1258
- const ws = reads.getWorkspace(entry.workspaceId);
1259
- if (!ws)
1260
- return { ok: false, error: 'the workspace is gone' };
1261
- // Settings first, prompt only if they stuck — the same order and the same
1262
- // fail-closed rule as the phone's own send (running the prompt on the model
1263
- // the user moved away from is the mistake this exists to prevent). A re-run
1264
- // after a failed prompt re-applies harmlessly: every control is read before
1265
- // it is pressed, so an already-correct value presses nothing.
1266
- if (entry.agent) {
1267
- const applied = await applyAgentPatch(ws, entry.sessionId, entry.agent);
1268
- if (!applied.ok)
1269
- return { ok: false, error: applied.error, blocked: lockBlocked(applied.error) };
1270
- }
1271
- const result = await deliverPrompt(ws, entry.sessionId, entry.text, SEND_BUDGET_MS, entry.queue);
1272
- return { ok: result.ok, error: result.error, blocked: lockBlocked(result.error) };
1273
- }),
1274
- notify: (entry, error) => {
1275
- const ws = reads.getWorkspace(entry.workspaceId);
1276
- const title = ws?.workspace_name ?? ws?.pr_title ?? ws?.branch ?? 'Conductor';
1277
- const preview = entry.text.length > 140 ? `${entry.text.slice(0, 140).trimEnd()}…` : entry.text;
1278
- void notifyAll({
1279
- title,
1280
- body: error ? `Parked prompt failed: ${error}` : `Sent after unlock: ${preview}`,
1281
- // Per chat, so a second parked prompt replaces the first's notification.
1282
- tag: `parked-${entry.sessionId}`,
1283
- url: chatRoute(entry.workspaceId, entry.sessionId),
1284
- kind: error ? 'error' : 'done',
1285
- ts: Date.now()
1286
- });
1287
- }
1288
- });
1289
- function workflowDeliveryCursor(sessionId) {
1290
- const cursor = reads.deliveryCursor(sessionId);
1291
- return { rowid: cursor.rowid, outboxIds: [...cursor.outboxIds].sort() };
1292
- }
1293
- function readsDeliveryCursor(value) {
1294
- if (!value || typeof value !== 'object')
1295
- return null;
1296
- const cursor = value;
1297
- if (!Number.isSafeInteger(cursor.rowid) || !Array.isArray(cursor.outboxIds))
1298
- return null;
1299
- if (cursor.outboxIds.some(id => typeof id !== 'string'))
1300
- return null;
1301
- return { rowid: cursor.rowid, outboxIds: new Set(cursor.outboxIds) };
1302
- }
1303
- function workspaceBaseline(value) {
1304
- if (!value || typeof value !== 'object')
1305
- return null;
1306
- const baseline = value;
1307
- return baseline.kind === 'workspace_ids' &&
1308
- typeof baseline.repo === 'string' &&
1309
- Array.isArray(baseline.workspaceIds) &&
1310
- baseline.workspaceIds.every(id => typeof id === 'string')
1311
- ? baseline
1312
- : null;
1313
- }
1314
- function sessionBaseline(value) {
1315
- if (!value || typeof value !== 'object')
1316
- return null;
1317
- const baseline = value;
1318
- return baseline.kind === 'session_ids' &&
1319
- typeof baseline.workspaceId === 'string' &&
1320
- Array.isArray(baseline.sessionIds) &&
1321
- baseline.sessionIds.every(id => typeof id === 'string')
1322
- ? baseline
1323
- : null;
1324
- }
1325
- function workflowRootInspection(ws, session) {
1326
- const cursor = workflowDeliveryCursor(session.id);
1327
- const userRows = reads.getMessages(session.id).entries.filter(entry => entry.role === 'user');
1328
- const firstPrompt = firstPrompts.list().some(entry => entry.workspaceId === ws.id);
1329
- const parked = parkedPrompts.list().some(entry => entry.sessionId === session.id);
1330
- const reasons = [
1331
- session.status !== 'idle' ? `the chat status is ${session.status ?? 'unknown'}, not idle` : '',
1332
- session.background_tasks.length ? 'the chat is waiting on a background task' : '',
1333
- session.last_user_message_at ? 'the chat already has a user message timestamp' : '',
1334
- userRows.length ? 'the chat already has a user message' : '',
1335
- cursor.outboxIds.length ? 'the chat already has a queued prompt' : '',
1336
- firstPrompt ? 'the workspace already has a pending first prompt' : '',
1337
- parked ? 'the chat already has a parked prompt' : ''
1338
- ].filter(Boolean);
1339
- return {
1340
- workspaceId: ws.id,
1341
- rootSessionId: session.id,
1342
- pristine: reasons.length === 0,
1343
- pristineEvidence: {
1344
- status: session.status,
1345
- backgroundTasks: session.background_tasks.length,
1346
- lastUserMessageAt: session.last_user_message_at,
1347
- userRows: userRows.length,
1348
- outboxRows: cursor.outboxIds.length,
1349
- firstPrompt,
1350
- parked
1351
- },
1352
- deliveryCursor: cursor,
1353
- ...(reasons.length ? { reason: `Workflow requires a pristine root: ${reasons.join('; ')}.` } : {})
1354
- };
1355
- }
1356
- function assertWorkflowRootStillPristine(run, expectedSessionId) {
1357
- const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
1358
- const session = reads.getSession(expectedSessionId);
1359
- if (!ws ||
1360
- !run.rootSessionId ||
1361
- run.rootSessionId !== expectedSessionId ||
1362
- !session ||
1363
- reads.sessionWorkspaceId(expectedSessionId) !== ws.id) {
1364
- throw new WorkflowCoordinatorError('workflow_root_not_pristine', 'The exact Workflow root binding changed before its first prompt could be dispatched.');
1365
- }
1366
- const inspection = workflowRootInspection(ws, session);
1367
- if (!inspection.pristine) {
1368
- throw new WorkflowCoordinatorError('workflow_root_not_pristine', inspection.reason ?? 'Workflow requires a pristine root chat.');
1369
- }
1370
- }
1371
- function sessionMatchesWorkflowRole(session, role) {
1372
- const selected = modelLabel(session.model, [role.model]).toLowerCase();
1373
- const wanted = role.model.toLowerCase();
1374
- return (session.agent_type === role.agentType &&
1375
- (selected === wanted || selected.startsWith(wanted)) &&
1376
- (role.effort === undefined || session.claude_effort_level === role.effort) &&
1377
- (role.fast === undefined || Boolean(session.fast_mode) === role.fast));
1378
- }
1379
- function stableWorkflowAttachment(worktree, jobId, name, body) {
1380
- // Conductor requires six alphanumerics. Preserve all six characters' entropy
1381
- // instead of truncating a hex digest to only 24 bits for a long-lived stable path.
1382
- const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
1383
- const digest = crypto.createHash('sha256').update(`workflow-handoff:${jobId}`).digest();
1384
- const id = Array.from(digest.subarray(0, 6), byte => alphabet[byte % alphabet.length]).join('');
1385
- const safeName = attachmentName(name);
1386
- const directory = path.join(worktree, ATTACHMENT_DIR, id);
1387
- const destination = path.join(directory, safeName);
1388
- fs.mkdirSync(directory, { recursive: true });
1389
- try {
1390
- fs.writeFileSync(destination, body, { flag: 'wx' });
1391
- }
1392
- catch (error) {
1393
- if (error.code !== 'EEXIST')
1394
- throw error;
1395
- if (fs.readFileSync(destination, 'utf8') !== body) {
1396
- throw new Error(`the stable Workflow handoff path ${path.join(ATTACHMENT_DIR, id, safeName)} is occupied`);
1397
- }
1398
- }
1399
- return attachmentToken(safeName, path.join(ATTACHMENT_DIR, id, safeName));
1400
- }
1401
- function workflowWorkspaceCandidate(ws) {
1402
- return {
1403
- id: ws.id,
1404
- title: workspaceTitle(ws),
1405
- repo: ws.repo_name ?? '',
1406
- createdAt: timestampMs(ws.created_at),
1407
- kind: 'workspace'
1408
- };
1409
- }
1410
- function workflowSessionCandidate(ws, session) {
1411
- return {
1412
- id: session.id,
1413
- title: session.title?.trim() || '(untitled chat)',
1414
- repo: ws.repo_name ?? '',
1415
- createdAt: timestampMs(session.created_at),
1416
- kind: 'session'
1417
- };
1418
- }
1419
- function workflowSessionId(effect) {
1420
- if (!effect.target || typeof effect.target !== 'object')
1421
- return null;
1422
- const id = effect.target.sessionId;
1423
- return typeof id === 'string' ? id : null;
1424
- }
1425
- function workflowEffectPrompt(effect) {
1426
- if (!effect.inputs || typeof effect.inputs !== 'object')
1427
- return null;
1428
- const prompt = effect.inputs.prompt;
1429
- return typeof prompt === 'string' ? prompt : null;
1430
- }
1431
- function workflowEffectMarker(effect) {
1432
- if (!effect.inputs || typeof effect.inputs !== 'object')
1433
- return null;
1434
- const marker = effect.inputs.correlationMarker;
1435
- return typeof marker === 'string' ? marker : null;
1436
- }
1437
- function withWorkflowEffectGate(call, operation) {
1438
- return call.dispatch.mode === 'gated_child'
1439
- ? withGatedUiCommand(call.dispatch.gatedProcessReady, () => uiTurn(operation))
1440
- : uiTurn(operation);
1441
- }
1442
- async function sendWorkflowPrompt(call, queue) {
1443
- const ws = call.run.workspaceId ? reads.getWorkspace(call.run.workspaceId) : null;
1444
- if (!ws || reads.sessionWorkspaceId(call.sessionId) !== ws.id) {
1445
- throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow destination chat is unavailable.', {
1446
- status: 404
1447
- });
1448
- }
1449
- const before = readsDeliveryCursor(call.effect.cursor) ?? reads.deliveryCursor(call.sessionId);
1450
- const result = await withWorkflowEffectGate(call, async () => {
1451
- if (actuator.name !== 'sidecar')
1452
- return deliverPrompt(ws, call.sessionId, call.text, SEND_BUDGET_MS, queue);
1453
- const located = locateChat(ws, call.sessionId);
1454
- if ('error' in located)
1455
- return { ok: false, strategy: actuator.name, attempts: 0, error: located.error };
1456
- const sent = await actuator.send({ workspace: ws, sessionId: call.sessionId, tab: located.tab }, call.text, {
1457
- queue
1458
- });
1459
- if (!sent.ok)
1460
- return { ...sent, attempts: 1 };
1461
- const landed = await confirmDelivery(call.sessionId, call.text, before, Date.now() + CONFIRM_WINDOW_MS);
1462
- return landed
1463
- ? { ...sent, attempts: 1 }
1464
- : {
1465
- ok: false,
1466
- strategy: sent.strategy,
1467
- attempts: 1,
1468
- error: 'Conductor did not record the sidecar Workflow prompt; automatic replay is disabled.'
1469
- };
1470
- });
1471
- const receipt = reads.deliveryReceiptSince(call.sessionId, call.text, before);
1472
- if (receipt)
1473
- return receipt;
1474
- throw new WorkflowCoordinatorError('workflow_effect_failed', result.error ?? 'Conductor did not record an accepted Workflow prompt.', { retryable: !retryWontHelp(result.error) });
1475
- }
1476
- const workflowCoordinator = orchestration.writable
1477
- ? new WorkflowCoordinator(orchestration, relayIdentity, {
1478
- captureWorkspaceBaseline: async (repoName) => {
1479
- const repo = reads.listRepos().find(candidate => candidate.name === repoName);
1480
- if (!repo) {
1481
- throw new WorkflowCoordinatorError('workflow_not_found', `Unknown repo ${repoName}.`, { status: 404 });
1482
- }
1483
- if (!repo.root_path) {
1484
- throw new WorkflowCoordinatorError('invalid_request', `${repo.name} has no checkout path.`);
1485
- }
1486
- return {
1487
- kind: 'workspace_ids',
1488
- repo: repo.name,
1489
- workspaceIds: reads
1490
- .listWorkspaces()
1491
- .filter(workspace => workspace.repo_name === repo.name)
1492
- .map(workspace => workspace.id)
1493
- .sort()
1494
- };
1495
- },
1496
- inspectExistingRoot: async (target) => {
1497
- const ws = reads.getWorkspace(target.workspaceId);
1498
- if (!ws || reads.sessionWorkspaceId(target.sessionId) !== ws.id)
1499
- return null;
1500
- const session = reads.getSession(target.sessionId);
1501
- return session ? workflowRootInspection(ws, session) : null;
1502
- },
1503
- bindCreatedRoot: async ({ run, workspaceId }) => {
1504
- const ws = reads.getWorkspace(workspaceId);
1505
- if (!ws)
1506
- return null;
1507
- if (run.target.kind !== 'new_workspace')
1508
- return null;
1509
- if (!run.target.sendImmediately && ws.state !== 'ready')
1510
- return null;
1511
- const sessions = reads.listSessions(workspaceId);
1512
- if (!sessions.length)
1513
- return null;
1514
- const session = sessions.find(candidate => candidate.id === ws.active_session_id) ?? sessions[0];
1515
- if (sessions.length !== 1) {
1516
- const inspection = workflowRootInspection(ws, session);
1517
- return { ...inspection, pristine: false, reason: 'The created workspace has more than one root candidate.' };
1518
- }
1519
- const stageIds = stagedAttachmentIdsInObjective(run.objective);
1520
- if (stageIds.length) {
1521
- if (!ws.worktree)
1522
- return null;
1523
- try {
1524
- materializeStagedAttachments(STAGED_ATTACHMENTS_DIR, ws.worktree, stageIds);
1525
- }
1526
- catch (error) {
1527
- const inspection = workflowRootInspection(ws, session);
1528
- return {
1529
- ...inspection,
1530
- pristine: false,
1531
- reason: error instanceof Error ? error.message : 'The Workflow attachments could not be materialized.'
1532
- };
1533
- }
1534
- }
1535
- return workflowRootInspection(ws, session);
1536
- },
1537
- createWorkspace: call => withWorkflowEffectGate(call, async () => {
1538
- const { target } = call;
1539
- const repo = reads.listRepos().find(candidate => candidate.name === target.repo);
1540
- if (!repo) {
1541
- throw new WorkflowCoordinatorError('workflow_not_found', `Unknown repo ${target.repo}.`, { status: 404 });
1542
- }
1543
- if (!repo.root_path)
1544
- throw new WorkflowCoordinatorError('invalid_request', `${repo.name} has no checkout path.`);
1545
- const { result, created } = await createWorkspaceAndRead('', repo.root_path, repo.name, true);
1546
- if (!result.ok) {
1547
- throw new WorkflowCoordinatorError('workflow_effect_failed', result.error ?? 'Workspace creation failed.', {
1548
- retryable: !retryWontHelp(result.error)
1549
- });
1550
- }
1551
- if (!created) {
1552
- throw new WorkflowCoordinatorError('workflow_effect_failed', 'Conductor accepted the workspace link but no exact workspace row appeared.', { retryable: true });
1553
- }
1554
- return { workspaceId: created.id };
1555
- }),
1556
- configureSession: call => withWorkflowEffectGate(call, async () => {
1557
- const { run, sessionId, role } = call;
1558
- const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
1559
- if (!ws || reads.sessionWorkspaceId(sessionId) !== ws.id) {
1560
- throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow chat is unavailable.', {
1561
- status: 404
1562
- });
1563
- }
1564
- const applied = await applyAgentPatch(ws, sessionId, {
1565
- model: role.model,
1566
- ...(role.effort === undefined ? {} : { effort: role.effort }),
1567
- ...(role.fast === undefined ? {} : { fast: role.fast })
1568
- });
1569
- if (!applied.ok) {
1570
- throw new WorkflowCoordinatorError('workflow_effect_failed', applied.error ?? 'Conductor rejected the frozen role settings.');
1571
- }
1572
- const session = reads.getSession(sessionId);
1573
- if (!session || !sessionMatchesWorkflowRole(session, role)) {
1574
- throw new WorkflowCoordinatorError('workflow_effect_failed', 'Conductor no longer matches every frozen role setting; no fallback was selected.');
1575
- }
1576
- return {
1577
- sessionId,
1578
- agentType: session.agent_type,
1579
- model: role.model,
1580
- effort: session.claude_effort_level,
1581
- fast: Boolean(session.fast_mode)
1582
- };
1583
- }),
1584
- openChild: async (call) => {
1585
- const { run } = call;
1586
- const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
1587
- if (!ws)
1588
- throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow workspace is unavailable.', {
1589
- status: 404
1590
- });
1591
- // A lock probe is read-only and happens before the durable dispatch boundary.
1592
- // Otherwise a locked Mac would quarantine an effect whose child tab was never attempted.
1593
- if ((await screenLocked()) === true) {
1594
- throw new WorkflowCoordinatorError('workflow_effect_failed', 'The Mac is locked — unlock it and retry Workflow.');
1595
- }
1596
- return withWorkflowEffectGate(call, async () => {
1597
- const opened = await openChat(ws);
1598
- if ('error' in opened) {
1599
- throw new WorkflowCoordinatorError('workflow_effect_failed', opened.result.error ?? 'Conductor did not open a tracked child chat.', { retryable: opened.retryable !== false });
1600
- }
1601
- if (!opened.sessionId) {
1602
- throw new WorkflowCoordinatorError('workflow_effect_failed', 'Conductor opened a child tab but did not record its exact session ID.');
1603
- }
1604
- return { sessionId: opened.sessionId };
1605
- });
1606
- },
1607
- captureSessionBaseline: async (workspaceId) => {
1608
- const ws = reads.getWorkspace(workspaceId);
1609
- if (!ws) {
1610
- throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow workspace is unavailable.', {
1611
- status: 404
1612
- });
1613
- }
1614
- return {
1615
- kind: 'session_ids',
1616
- workspaceId,
1617
- sessionIds: reads
1618
- .listSessions(workspaceId)
1619
- .map(session => session.id)
1620
- .sort()
1621
- };
1622
- },
1623
- captureDeliveryCursor: async (sessionId) => workflowDeliveryCursor(sessionId),
1624
- captureTranscriptCursor: async (sessionId) => ({ rowid: reads.getMessages(sessionId).cursor }),
1625
- materializeHandoff: async ({ run, job }) => {
1626
- const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
1627
- if (!ws?.worktree || !run.rootSessionId)
1628
- return undefined;
1629
- const cursor = job.transcriptCursor && typeof job.transcriptCursor === 'object'
1630
- ? job.transcriptCursor.rowid
1631
- : undefined;
1632
- const entries = reads
1633
- .getMessages(run.rootSessionId)
1634
- .entries.filter(entry => typeof cursor !== 'number' || entry.rowid <= cursor);
1635
- const rendered = renderTranscript(entries, { thinking: true, tools: false });
1636
- if (!rendered.kept)
1637
- return undefined;
1638
- const body = [
1639
- `# Workflow handoff for ${job.logicalKey}`,
1640
- '',
1641
- `Workflow: ${run.id}`,
1642
- `Workflow job: ${job.id}`,
1643
- `Root chat: ${run.rootSessionId}`,
1644
- '',
1645
- rendered.text
1646
- ].join('\n');
1647
- return stableWorkflowAttachment(ws.worktree, job.id, `Workflow ${job.role} handoff.md`, body);
1648
- },
1649
- sendPrompt: call => sendWorkflowPrompt(call, false),
1650
- returnBaton: call => sendWorkflowPrompt(call, true),
1651
- resolveDeliveryReceipt: async ({ sessionId, receipt }) => {
1652
- const current = reads.deliveryReceiptForId(sessionId, receipt.id);
1653
- if (current?.kind === 'message')
1654
- return { status: 'delivered', receipt: current };
1655
- if (current?.kind === 'outbox')
1656
- return { status: 'pending' };
1657
- return { status: 'lost', evidence: { receiptId: receipt.id, priorKind: receipt.kind } };
1658
- },
1659
- readChildOutcome: async ({ job }) => {
1660
- if (!job.childSessionId || !job.taskReceipt || typeof job.taskReceipt !== 'object')
1661
- return null;
1662
- const receipt = job.taskReceipt;
1663
- if (receipt.kind !== 'message' || !Number.isSafeInteger(receipt.rowid))
1664
- return null;
1665
- const child = reads.getSession(job.childSessionId);
1666
- if (!child) {
1667
- return {
1668
- kind: 'failure',
1669
- code: 'session_not_found',
1670
- message: 'The tracked child chat disappeared.',
1671
- retryClass: 'deterministic'
1672
- };
1673
- }
1674
- const messages = typeof receipt.turnId === 'string'
1675
- ? reads.getMessagesForTurn(job.childSessionId, receipt.turnId, receipt.rowid)
1676
- : reads.getMessages(job.childSessionId, receipt.rowid);
1677
- const assistants = messages.entries.filter(entry => entry.role === 'assistant' && entry.text.trim());
1678
- const last = assistants.at(-1);
1679
- if (child.status === 'error') {
1680
- return {
1681
- kind: 'failure',
1682
- code: 'completion_failed',
1683
- message: last?.text.trim() || 'The tracked child agent stopped with an error.',
1684
- retryClass: 'deterministic',
1685
- evidence: { assistantRowid: last?.rowid }
1686
- };
1687
- }
1688
- if (child.status !== 'idle' || child.background_tasks.length || !last)
1689
- return null;
1690
- return {
1691
- kind: 'success',
1692
- baton: batonText(last.text),
1693
- evidence: { assistantRowid: last.rowid }
1694
- };
1695
- },
1696
- validateBeforeDispatch: async ({ run, effect }) => {
1697
- if (effect.kind !== 'configure_root' && effect.kind !== 'send_root')
1698
- return;
1699
- const sessionId = workflowSessionId(effect);
1700
- if (!sessionId) {
1701
- throw new WorkflowCoordinatorError('workflow_root_not_pristine', 'The Workflow root effect lost its exact session binding before dispatch.');
1702
- }
1703
- assertWorkflowRootStillPristine(run, sessionId);
1704
- },
1705
- reconcileEffect: async ({ run, effect }) => {
1706
- const sessionId = workflowSessionId(effect);
1707
- if (effect.receipt && sessionId && typeof effect.receipt === 'object') {
1708
- const receipt = effect.receipt;
1709
- if (typeof receipt.id === 'string') {
1710
- const current = reads.deliveryReceiptForId(sessionId, receipt.id);
1711
- if (current)
1712
- return { status: 'committed', receipt: current };
1713
- return { status: 'ambiguous', evidence: { receiptId: receipt.id, state: 'missing' } };
1714
- }
1715
- }
1716
- if (effect.kind === 'configure_root' || effect.kind === 'configure_child') {
1717
- const role = effect.inputs && typeof effect.inputs === 'object'
1718
- ? effect.inputs.role
1719
- : undefined;
1720
- const session = sessionId ? reads.getSession(sessionId) : null;
1721
- if (effect.kind === 'configure_root') {
1722
- const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
1723
- if (!ws ||
1724
- !session ||
1725
- run.rootSessionId !== sessionId ||
1726
- reads.sessionWorkspaceId(session.id) !== ws.id ||
1727
- !workflowRootInspection(ws, session).pristine) {
1728
- return { status: 'pending' };
1729
- }
1730
- }
1731
- if (role && session && sessionMatchesWorkflowRole(session, role)) {
1732
- return { status: 'committed', receipt: { sessionId, matched: true } };
1733
- }
1734
- }
1735
- if (effect.kind === 'create_workspace') {
1736
- const baseline = workspaceBaseline(effect.baseline);
1737
- if (!baseline)
1738
- return { status: 'ambiguous', evidence: { baseline: 'invalid' } };
1739
- const prior = new Set(baseline.workspaceIds);
1740
- const candidates = reads
1741
- .listWorkspaces()
1742
- .filter(workspace => workspace.repo_name === baseline.repo && !prior.has(workspace.id))
1743
- .map(workflowWorkspaceCandidate);
1744
- return { status: 'ambiguous', candidates, evidence: { candidateCount: candidates.length } };
1745
- }
1746
- if (effect.kind === 'open_child') {
1747
- const baseline = sessionBaseline(effect.baseline);
1748
- const ws = baseline ? reads.getWorkspace(baseline.workspaceId) : null;
1749
- if (!baseline || !ws)
1750
- return { status: 'ambiguous', evidence: { baseline: 'invalid' } };
1751
- const prior = new Set(baseline.sessionIds);
1752
- const candidates = reads
1753
- .listSessions(ws.id)
1754
- .filter(session => !prior.has(session.id) && workflowRootInspection(ws, session).pristine)
1755
- .map(session => workflowSessionCandidate(ws, session));
1756
- return { status: 'ambiguous', candidates, evidence: { candidateCount: candidates.length } };
1757
- }
1758
- const prompt = workflowEffectPrompt(effect);
1759
- const cursor = readsDeliveryCursor(effect.cursor);
1760
- if (sessionId && cursor) {
1761
- const marker = workflowEffectMarker(effect);
1762
- const receipt = marker
1763
- ? reads.deliveryReceiptContainingSince(sessionId, marker, cursor)
1764
- : prompt
1765
- ? reads.deliveryReceiptSince(sessionId, prompt, cursor)
1766
- : null;
1767
- if (receipt)
1768
- return { status: 'committed', receipt };
1769
- }
1770
- return { status: 'pending' };
1771
- },
1772
- validateAdoption: async ({ effect, candidate }) => {
1773
- if (candidate.kind === 'workspace' && effect.kind === 'create_workspace') {
1774
- const baseline = workspaceBaseline(effect.baseline);
1775
- const ws = reads.getWorkspace(candidate.id);
1776
- if (!baseline || !ws || baseline.workspaceIds.includes(ws.id) || ws.repo_name !== baseline.repo)
1777
- return null;
1778
- return { workspaceId: ws.id };
1779
- }
1780
- if (candidate.kind === 'session' && effect.kind === 'open_child') {
1781
- const baseline = sessionBaseline(effect.baseline);
1782
- const ws = baseline ? reads.getWorkspace(baseline.workspaceId) : null;
1783
- const session = reads.getSession(candidate.id);
1784
- if (!baseline ||
1785
- !ws ||
1786
- !session ||
1787
- baseline.sessionIds.includes(session.id) ||
1788
- reads.sessionWorkspaceId(session.id) !== ws.id ||
1789
- !workflowRootInspection(ws, session).pristine) {
1790
- return null;
1791
- }
1792
- return { sessionId: session.id };
1793
- }
1794
- return null;
1795
- },
1796
- assertCompatibleRelays: async () => {
1797
- const error = await workflowCompatibilityError();
1798
- if (error) {
1799
- throw new WorkflowCoordinatorError('workflow_incompatible_relay', error, {
1800
- status: 409,
1801
- retryable: true
1802
- });
1803
- }
1804
- },
1805
- dispatchMode: effect => actuator.name === 'sidecar' &&
1806
- (effect.kind === 'send_root' ||
1807
- effect.kind === 'send_task' ||
1808
- effect.kind === 'return_baton' ||
1809
- effect.kind === 'authorize_phase')
1810
- ? 'in_process'
1811
- : 'gated_child'
1812
- })
1813
- : null;
1814
- const WORKFLOW_RECOVERY_PHONE_ONLY = 'Workflow recovery is available only from the phone UI.';
1815
- function requirePhoneWorkflowCoordinator(req, forbiddenMessage) {
1816
- if (workflowClientIsMcp(req.headers))
1817
- throw new WorkflowRequestError(forbiddenMessage, 403);
1818
- if (!workflowCoordinator)
1819
- throw new WorkflowCoordinatorError('workflow_incompatible_relay', 'Workflow is unavailable.');
1820
- return workflowCoordinator;
1821
- }
1822
- function wakeWorkflows() {
1823
- if (!workflowCoordinator)
1824
- return;
1825
- for (const workflowId of workflowCoordinator.runIdsNeedingWake()) {
1826
- void workflowCoordinator.wake(workflowId).catch(error => {
1827
- console.error(`[workflow ${workflowId}] wake failed:`, error);
1828
- });
1829
- }
1830
- }
1831
- sessionPoller.subscribe(wakeWorkflows);
1832
- const MIME = {
1833
- '.html': 'text/html; charset=utf-8',
1834
- '.js': 'text/javascript; charset=utf-8',
1835
- '.css': 'text/css; charset=utf-8',
1836
- '.json': 'application/json; charset=utf-8',
1837
- '.webmanifest': 'application/manifest+json; charset=utf-8',
1838
- '.svg': 'image/svg+xml',
1839
- '.png': 'image/png'
1840
- };
1841
- const LOCAL_IMAGE_TYPES = {
1842
- '.avif': 'image/avif',
1843
- '.gif': 'image/gif',
1844
- '.jpeg': 'image/jpeg',
1845
- '.jpg': 'image/jpeg',
1846
- '.png': 'image/png',
1847
- '.webp': 'image/webp'
1848
- };
1849
- const LOCAL_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
1850
- // `/tmp` is a symlink to `/private/tmp` on macOS. os.tmpdir() also covers tools that use the user's
1851
- // per-login temporary directory instead. Resolve both before checking a requested file's real path.
1852
- const LOCAL_IMAGE_ROOTS = [...new Set([os.tmpdir(), '/tmp'].map(root => fs.realpathSync(root)))];
1853
- function insideRoot(filePath, root) {
1854
- const rel = path.relative(root, filePath);
1855
- return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
1856
- }
1857
- async function serveLocalImage(req, res, requestedPath) {
1858
- const target = parseImageReference(requestedPath);
1859
- const contentType = target ? LOCAL_IMAGE_TYPES[path.extname(target).toLowerCase()] : null;
1860
- if (!target || !contentType)
1861
- return json(req, res, 404, { error: 'image not found' });
1862
- let filePath;
1863
- let size;
1864
- try {
1865
- filePath = await fs.promises.realpath(target);
1866
- if (!LOCAL_IMAGE_ROOTS.some(root => insideRoot(filePath, root))) {
1867
- const [workspaceRoot, homeRoot, bundledSkillsRoot] = await Promise.all([
1868
- fs.promises.realpath(cfg.workspacesRoot),
1869
- fs.promises.realpath(os.homedir()),
1870
- fs.promises.realpath(BUNDLED_SKILLS_ROOT).catch(() => null)
1871
- ]);
1872
- if (!isAllowedPreviewPath(filePath, workspaceRoot, homeRoot, readExposeMode(), bundledSkillsRoot)) {
1873
- return json(req, res, 404, { error: 'image not found' });
1874
- }
1875
- }
1876
- const info = await fs.promises.stat(filePath);
1877
- if (!info.isFile())
1878
- return json(req, res, 404, { error: 'image not found' });
1879
- size = info.size;
1880
- }
1881
- catch {
1882
- return json(req, res, 404, { error: 'image not found' });
1883
- }
1884
- if (size > LOCAL_IMAGE_MAX_BYTES)
1885
- return json(req, res, 413, { error: 'image is too large' });
1886
- res.writeHead(200, { 'content-type': contentType, 'cache-control': 'no-store' });
1887
- fs.createReadStream(filePath)
1888
- .once('error', () => res.destroy())
1889
- .pipe(res);
1890
- }
1891
- /** A preview stays small enough to render smoothly in the phone's source sheet. */
1892
- const FILE_PREVIEW_MAX_BYTES = 512 * 1024;
1893
- const FILE_PREVIEW_CONTEXT_LINES = 100;
1894
- const FILE_PREVIEW_FIRST_LINES = 500;
1895
- const BUNDLED_SKILLS_ROOT = '/Applications/Conductor.app/Contents/Resources/conductor-skill/skills';
1896
- /**
1897
- * Serve source that an agent linked in its Markdown. The link format comes from
1898
- * coding-agent file references, but its path still arrives from a remote client.
1899
- * Public Funnel clients stay within Conductor workspaces. Tailnet-only relays may
1900
- * also read supporting source files from the signed-in user's home directory.
1901
- */
1902
- async function serveFilePreview(req, res, reference) {
1903
- const target = parseFileReference(reference);
1904
- if (!target)
1905
- return json(req, res, 404, { error: 'source file not found' });
1906
- const refused = (filePath) => {
1907
- const answer = previewRefusal(filePath);
1908
- return json(req, res, answer.status, { error: answer.error });
1909
- };
1910
- let filePath;
1911
- let workspaceRoot;
1912
- let homeRoot;
1913
- let bundledSkillsRoot;
1914
- let size;
1915
- try {
1916
- ;
1917
- [filePath, workspaceRoot, homeRoot, bundledSkillsRoot] = await Promise.all([
1918
- fs.promises.realpath(target.path),
1919
- fs.promises.realpath(cfg.workspacesRoot),
1920
- fs.promises.realpath(os.homedir()),
1921
- fs.promises.realpath(BUNDLED_SKILLS_ROOT).catch(() => null)
1922
- ]);
1923
- if (!isAllowedPreviewPath(filePath, workspaceRoot, homeRoot, readExposeMode(), bundledSkillsRoot)) {
1924
- return refused(filePath);
1925
- }
1926
- const info = await fs.promises.stat(filePath);
1927
- if (!info.isFile())
1928
- return json(req, res, 404, { error: 'source file not found' });
1929
- size = info.size;
1930
- }
1931
- catch {
1932
- // A path this relay would refuse must answer the same whether or not it is there, or
1933
- // a public client learns which home files exist by watching 404 turn into 403.
1934
- return refused(target.path);
1935
- }
1936
- if (size > FILE_PREVIEW_MAX_BYTES)
1937
- return json(req, res, 413, { error: 'source file is too large to preview' });
1938
- let content;
1939
- try {
1940
- const raw = await fs.promises.readFile(filePath);
1941
- if (raw.includes(0))
1942
- return json(req, res, 415, { error: 'source file is not text' });
1943
- content = new TextDecoder('utf-8', { fatal: true }).decode(raw);
1944
- }
1945
- catch {
1946
- return json(req, res, 415, { error: 'source file is not text' });
1947
- }
1948
- const lines = content.split('\n');
1949
- const focus = target.line === null ? null : Math.min(target.line, lines.length);
1950
- const start = focus === null ? 0 : Math.max(0, focus - FILE_PREVIEW_CONTEXT_LINES - 1);
1951
- const end = focus === null
1952
- ? Math.min(lines.length, FILE_PREVIEW_FIRST_LINES)
1953
- : Math.min(lines.length, focus + FILE_PREVIEW_CONTEXT_LINES);
1954
- return json(req, res, 200, {
1955
- path: target.path,
1956
- line: focus,
1957
- lineStart: start + 1,
1958
- lineEnd: end,
1959
- totalLines: lines.length,
1960
- content: lines.slice(start, end).join('\n'),
1961
- truncated: start > 0 || end < lines.length
1962
- });
1963
- }
1964
- /**
1965
- * How to answer for a file the preview will not serve, from the path alone.
1966
- *
1967
- * Chat mentions made the home-directory case ordinary — agents write "plan written to
1968
- * `~/.gstack/plan.md`" constantly — and on a public funnel every one of those is refused
1969
- * by policy. Answering "source file not found" then sends someone hunting for a file that
1970
- * is sitting right there, so a refusal says it is a refusal. It discloses nothing: the
1971
- * verdict comes from the path and the funnel's posture, never from the disk, and it is
1972
- * the answer for an out-of-bounds path whether or not that path exists.
1973
- */
1974
- function previewRefusal(filePath) {
1975
- const mode = readExposeMode();
1976
- if (isAllowedPreviewPath(path.resolve(filePath), cfg.workspacesRoot, os.homedir(), mode, BUNDLED_SKILLS_ROOT)) {
1977
- return { status: 404, error: 'source file not found' };
1978
- }
1979
- return {
1980
- status: 403,
1981
- error: mode === 'public'
1982
- ? 'this relay is reachable from the internet, so it previews files inside Conductor workspaces only'
1983
- : 'outside the files this relay may read'
1984
- };
1985
- }
1986
- /** Per-file ceiling for a relay exposed through Tailscale Funnel. Large media belongs in a link. */
1987
- const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
1988
- class PayloadTooLargeError extends Error {
1989
- }
1990
- /**
1991
- * The last thing that happens to an error before it leaves the relay: the diagnostic
1992
- * tail `windowEvidence()` appends comes off, and the full text goes to the log instead.
1993
- *
1994
- * One place rather than the dozen routes that hand back a UI-write failure, for the
1995
- * same reason `redactSecrets` sits in front of every served log line: a rule applied at
1996
- * the boundary cannot be forgotten by the next route someone adds. What it prevents is
1997
- * what a tap on Fork against a locked Mac used to answer with — the sentence, then
1998
- * "[window server: 6; screen: locked] [processes: conductor=0] [menus: Apple, Conductor,
1999
- * File, ...]", in 11px red, on a phone that can do nothing with any of it. The evidence
2000
- * is still the fastest way to tell a wedged Conductor from a hidden window, so it lands
2001
- * in relay.log, which `/api/logs` serves to the same phone on request.
2002
- */
2003
- function forTheClient(body) {
2004
- const scrub = (value, key) => {
2005
- if (typeof value === 'string') {
2006
- const withoutEvidence = withoutClientWindowEvidence(value, key);
2007
- if (withoutEvidence !== value)
2008
- console.warn(`[relay] ${scrubWorkflowSecrets(value)}`);
2009
- return scrubWorkflowSecrets(withoutEvidence);
2010
- }
2011
- if (Array.isArray(value))
2012
- return value.map(item => scrub(item));
2013
- if (!value || typeof value !== 'object')
2014
- return value;
2015
- return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, scrub(child, childKey)]));
2016
- };
2017
- return scrub(body);
2018
- }
2019
- /**
2020
- * Successful GETs are conditional + compressed to keep the phone's polling cheap.
2021
- * `no-cache` (not `no-store`) means the browser must revalidate on every tick —
2022
- * the relay still runs the handler and auth each time, so data is never stale;
2023
- * a matching ETag just elides the redundant body (304), and changed bodies over
2024
- * ~1 KB go out gzipped. Errors and non-GETs stay unconditional `no-store`.
2025
- */
2026
- function json(req, res, status, body) {
2027
- const payload = Buffer.from(JSON.stringify(forTheClient(body)));
2028
- if (status !== 200 || req.method !== 'GET') {
2029
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
2030
- return void res.end(payload);
2031
- }
2032
- // Weak: the same entity may be delivered gzipped or plain.
2033
- const etag = `W/"${crypto.createHash('sha1').update(payload).digest('base64url')}"`;
2034
- const headers = {
2035
- 'content-type': 'application/json; charset=utf-8',
2036
- 'cache-control': 'no-cache',
2037
- etag,
2038
- vary: 'accept-encoding'
2039
- };
2040
- if (req.headers['if-none-match'] === etag)
2041
- return void res.writeHead(304, headers).end();
2042
- if (payload.length > 1024 && /\bgzip\b/.test(String(req.headers['accept-encoding'] ?? ''))) {
2043
- headers['content-encoding'] = 'gzip';
2044
- return void res.writeHead(200, headers).end(zlib.gzipSync(payload));
2045
- }
2046
- res.writeHead(200, headers).end(payload);
2047
- }
2048
- /** Constant-time string compare — the token is the sole internet-facing gate when exposed via Funnel. */
2049
- function tokenEq(candidate) {
2050
- if (candidate == null)
2051
- return false;
2052
- const a = Buffer.from(candidate);
2053
- const b = Buffer.from(cfg.token);
2054
- return a.length === b.length && crypto.timingSafeEqual(a, b);
2055
- }
2056
- function authed(req) {
2057
- const auth = req.headers.authorization;
2058
- if (auth?.startsWith('Bearer '))
2059
- return tokenEq(auth.slice('Bearer '.length));
2060
- const url = new URL(req.url ?? '/', 'http://x');
2061
- return tokenEq(url.searchParams.get('token'));
2062
- }
2063
- async function readBody(req) {
2064
- const chunks = [];
2065
- for await (const c of req)
2066
- chunks.push(c);
2067
- return Buffer.concat(chunks).toString('utf8');
2068
- }
2069
- /** Read one uploaded file without allowing a token holder to fill the relay's memory. */
2070
- async function readAttachmentBody(req) {
2071
- const declared = Number(req.headers['content-length']);
2072
- if (Number.isFinite(declared) && declared > MAX_ATTACHMENT_BYTES) {
2073
- throw new PayloadTooLargeError(`attachments are limited to ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`);
2074
- }
2075
- const chunks = [];
2076
- let bytes = 0;
2077
- for await (const chunk of req) {
2078
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
2079
- bytes += buffer.length;
2080
- if (bytes > MAX_ATTACHMENT_BYTES) {
2081
- req.destroy();
2082
- throw new PayloadTooLargeError(`attachments are limited to ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB`);
2083
- }
2084
- chunks.push(buffer);
2085
- }
2086
- return Buffer.concat(chunks, bytes);
2087
- }
2088
- function attachmentHeaderName(req) {
2089
- const encoded = req.headers['x-attachment-name'];
2090
- if (typeof encoded !== 'string' || !encoded)
2091
- return null;
2092
- try {
2093
- return decodeURIComponent(encoded);
2094
- }
2095
- catch {
2096
- return null;
2097
- }
2098
- }
2099
- /** Hashed Vite assets are immutable and cache-forever; the shell/SW must never go stale. */
2100
- function cacheControl(rel) {
2101
- if (rel.startsWith('assets/'))
2102
- return 'public, max-age=31536000, immutable';
2103
- return 'no-cache';
2104
- }
2105
- function serveStatic(_req, res, pathname) {
2106
- const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
2107
- const filePath = path.resolve(cfg.publicDir, rel);
2108
- // Contain to publicDir. The URL parser already collapses `..`/`%2e%2e` dot-segments, but don't lean on
2109
- // that: reject anything that resolves outside the dir (a bare `startsWith` would also admit a sibling
2110
- // like `dist-node/`). An empty relative (filePath === publicDir) falls through to the SPA shell below.
2111
- const within = path.relative(cfg.publicDir, filePath);
2112
- if (within.startsWith('..') || path.isAbsolute(within)) {
2113
- res.writeHead(403).end();
2114
- return;
2115
- }
2116
- fs.readFile(filePath, (err, data) => {
2117
- if (err) {
2118
- // SPA fallback to shell.
2119
- fs.readFile(path.join(cfg.publicDir, 'index.html'), (e2, shell) => {
2120
- if (e2)
2121
- return void res.writeHead(404).end('not found');
2122
- res.writeHead(200, { 'content-type': MIME['.html'], 'cache-control': 'no-cache' });
2123
- res.end(shell);
2124
- });
2125
- return;
2126
- }
2127
- const ext = path.extname(filePath);
2128
- res.writeHead(200, { 'content-type': MIME[ext] ?? 'application/octet-stream', 'cache-control': cacheControl(rel) });
2129
- res.end(data);
2130
- });
2131
- }
2132
- /**
2133
- * MCP over HTTP.
2134
- *
2135
- * Two guards beyond the token. **Origin is rejected when present and foreign**: a real
2136
- * MCP client sends none, and a browser cannot omit it — so this closes the DNS-rebinding
2137
- * hole the spec warns about without needing to know our own hostname behind Tailscale's
2138
- * TLS. And **the body is capped**, because this endpoint is reachable from the internet
2139
- * whenever EXPOSE=public and an unbounded JSON parse is the cheapest thing to abuse.
2140
- */
2141
- async function handleMcpHttp(req, res) {
2142
- if (req.method === 'GET' || req.method === 'DELETE') {
2143
- // No server-initiated messages and no session to end. 405 is the spec's own answer
2144
- // for a server that doesn't offer the stream.
2145
- res.writeHead(405, { allow: 'POST' }).end();
2146
- return;
2147
- }
2148
- if (req.method !== 'POST')
2149
- return void res.writeHead(405, { allow: 'POST' }).end();
2150
- const origin = req.headers.origin;
2151
- if (origin)
2152
- return void json(req, res, 403, { error: 'cross-origin requests are not accepted here' });
2153
- if (!authed(req))
2154
- return void json(req, res, 401, { error: 'unauthorized' });
2155
- let body;
2156
- try {
2157
- body = await readBody(req);
2158
- }
2159
- catch {
2160
- return void json(req, res, 400, { error: 'could not read request body' });
2161
- }
2162
- if (body.length > 1_000_000)
2163
- return void json(req, res, 413, { error: 'request too large' });
2164
- let parsed;
2165
- try {
2166
- parsed = JSON.parse(body || 'null');
2167
- }
2168
- catch {
2169
- res.writeHead(400, { 'content-type': 'application/json' });
2170
- return void res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }));
2171
- }
2172
- // Agents yield the UI lock to whoever is holding the phone, exactly as the stdio
2173
- // transport does via its `x-relay-client` header.
2174
- const answers = await withUiPriority('background', async () => {
2175
- const batch = Array.isArray(parsed) ? parsed : [parsed];
2176
- const settled = await Promise.all(batch.map(m => handleRpc(mcpTools, m)));
2177
- return settled.filter(m => m !== null);
2178
- });
2179
- // A payload of nothing but notifications takes no reply at all.
2180
- if (!answers.length)
2181
- return void res.writeHead(202).end();
2182
- res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
2183
- res.end(JSON.stringify(forTheClient(Array.isArray(parsed) ? answers : answers[0])));
2184
- }
2185
- const server = http.createServer(async (req, res) => {
2186
- const url = new URL(req.url ?? '/', 'http://x');
2187
- const { pathname } = url;
2188
- // POST /mcp — the MCP Streamable HTTP transport, for a client that can only reach a
2189
- // URL (an agent on another machine, or a hosted one). Same tools as
2190
- // `conductor-remote mcp`'s stdio, same token gate as /api/*, and — because this runs
2191
- // *inside* the relay — the same UI lock, with no second process to sit outside it.
2192
- //
2193
- // Deliberately minimal: this server never initiates a message, so there is no SSE
2194
- // stream to open and GET is answered 405, which the spec allows. It keeps no session
2195
- // either, so no `Mcp-Session-Id` is issued and every request stands alone.
2196
- if (pathname === '/mcp')
2197
- return handleMcpHttp(req, res);
2198
- if (!pathname.startsWith('/api/'))
2199
- return serveStatic(req, res, pathname);
2200
- // Everything under /api requires the shared secret.
2201
- if (!authed(req))
2202
- return json(req, res, 401, { error: 'unauthorized' });
2203
- // Who is asking decides who waits for Conductor's window. An agent (src/mcp.ts sets
2204
- // this header) yields the UI lock to the person holding the phone — see writes.ts ▸
2205
- // uiTurn. Anything unlabelled is treated as the person, because the phone is the
2206
- // only caller that predates the header and mislabelling it would be the bad way round.
2207
- const priority = req.headers['x-relay-client'] === 'mcp' ? 'background' : 'interactive';
2208
- return withUiPriority(priority, async () => {
2209
- try {
2210
- // GET /api/state — workspace list with active-session status
2211
- if (isRoute(routes.state, req.method, pathname)) {
2212
- const update = updateStatus();
2213
- const workspaces = reads.listWorkspaces();
2214
- attachChangeStats(workspaces); // serves the cache now; refreshes stale git stats in the background
2215
- attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
2216
- attachRunActivity(workspaces); // flags a live Run wrapper from a cached ps snapshot
2217
- attachDelegationState(workspaces);
2218
- const workflows = attachWorkflowState(workspaces);
2219
- const uiQuarantine = wireUiQuarantine();
2220
- // An undelivered first prompt rides along with its workspace: the phone renders it
2221
- // in that chat rather than tracking delivery itself (see src/firstprompt.ts).
2222
- // Prompts parked for the lock screen ride the same way, one list per workspace,
2223
- // each entry naming its chat (src/parked.ts).
2224
- const parked = parkedPrompts.list();
2225
- for (const ws of workspaces) {
2226
- ws.pending_prompt = firstPrompts.get(ws.id);
2227
- const mine = parked.filter(p => p.workspaceId === ws.id);
2228
- if (mine.length)
2229
- ws.parked_prompts = mine;
2230
- }
2231
- return json(req, res, 200, {
2232
- workspaces,
2233
- workflows,
2234
- ...(uiQuarantine ? { uiQuarantine } : {}),
2235
- ...(orchestration.writable
2236
- ? {}
2237
- : { workflowWarning: scrubWorkflowSecrets(orchestrationUnavailableReason()).slice(0, 500) }),
2238
- actuator: await describeActuator(actuator),
2239
- version: update.current,
2240
- update
2241
- });
2242
- }
2243
- // GET /api/search?q= — find a workspace by its name or by what was said in its chats.
2244
- //
2245
- // Two sources, merged. `findWorkspacesByName` matches the workspace's own identity
2246
- // and wins ties, because someone who types a name wants that workspace and not the
2247
- // twelve chats that mention it. The transcript index answers the harder question —
2248
- // "which workspace did I do this in" — and is the only one that can, since the
2249
- // words you remember are usually the agent's, not the branch's.
2250
- //
2251
- // Both reach archived workspaces. That is the point: 1,846 of the 1,886 here are
2252
- // archived, so a search limited to the live sidebar would miss almost everything.
2253
- //
2254
- // `repo=` (repeatable) and `archived=0` scope both halves. They are resolved to
2255
- // chat ids and pushed *into* the FTS query rather than applied to its top 300
2256
- // chunks, or excluded work would fill every slot (search.ts ▸ search).
2257
- if (isRoute(routes.search, req.method, pathname)) {
2258
- const q = url.searchParams.get('q') ?? '';
2259
- const repos = [...new Set(url.searchParams.getAll('repo').filter(Boolean))];
2260
- // Archived search predates the toggle and stays the default for cached PWAs and MCP.
2261
- const includeArchived = url.searchParams.get('archived') !== '0';
2262
- // 12, not 50: an OR query over common words ("add", "remove") has a long weak tail,
2263
- // and past the first screenful nobody scrolls — they retype instead.
2264
- const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 12) || 12));
2265
- const index = search.status();
2266
- const tokens = queryTokens(q);
2267
- if (!tokens.length)
2268
- return json(req, res, 200, { query: q, repos, results: [], index });
2269
- const scoped = repos.length > 0 || !includeArchived;
2270
- const scope = scoped
2271
- ? { sessionIds: reads.searchSessionIds(repos.length ? repos : undefined, includeArchived) }
2272
- : {};
2273
- const hits = await search.search(q, scope);
2274
- const targets = reads.searchTargets([...new Set(hits.map(h => h.sessionId))]);
2275
- const fromChats = foldHits(hits, sid => {
2276
- const workspace = targets.get(sid)?.workspace ?? null;
2277
- return !includeArchived && workspace?.archived ? null : workspace;
2278
- });
2279
- const remaining = new Map(fromChats.map(r => [r.workspace.id, r]));
2280
- const merged = [];
2281
- for (const workspace of reads.findWorkspacesByName(tokens, limit, repos.length ? repos : undefined, includeArchived)) {
2282
- const evidence = remaining.get(workspace.id);
2283
- remaining.delete(workspace.id);
2284
- // Keep the chat evidence when there is any: the snippet is what tells you this
2285
- // is the right "fix-lamp-thing" out of three with similar names.
2286
- merged.push(evidence
2287
- ? { ...evidence, byName: true }
2288
- : { workspace, sessionId: null, hits: 0, score: 0, at: null, snippets: [], byName: true });
2289
- }
2290
- merged.push(...remaining.values());
2291
- return json(req, res, 200, {
2292
- query: q,
2293
- repos,
2294
- index,
2295
- results: merged.slice(0, limit).map(r => ({
2296
- ...r,
2297
- sessionTitle: r.sessionId ? (targets.get(r.sessionId)?.sessionTitle ?? null) : null
2298
- }))
2299
- });
2300
- }
2301
- // GET /api/repos — repos a new workspace can be created in
2302
- if (isRoute(routes.repos, req.method, pathname)) {
2303
- return json(req, res, 200, { repos: reads.listRepos() });
2304
- }
2305
- // GET /api/models — prior live picker reads, without activating Conductor. A
2306
- // new workspace has no chat yet, so this is its only safe source of choices.
2307
- if (isRoute(routes.modelCatalog, req.method, pathname)) {
2308
- return json(req, res, 200, { groups: modelCache.list(), defaultModel: modelCache.defaultModel() });
2309
- }
2310
- // GET/PATCH /api/models/defaults — the live user-wide effort defaults.
2311
- // These are file-backed settings, not the stale rows conductor.db still carries.
2312
- if (isRoute(routes.modelDefaults, req.method, pathname)) {
2313
- return json(req, res, 200, { defaultEfforts: readDefaultEfforts() });
2314
- }
2315
- if (isRoute(routes.updateModelDefaults, req.method, pathname)) {
2316
- const body = JSON.parse((await readBody(req)) || '{}');
2317
- const patch = {};
2318
- if (body.claude !== undefined) {
2319
- if (!isDefaultEffortLevel(body.claude))
2320
- return json(req, res, 400, { error: 'unknown Claude effort level' });
2321
- patch.claude = body.claude;
2322
- }
2323
- if (body.codex !== undefined) {
2324
- if (!isDefaultEffortLevel(body.codex))
2325
- return json(req, res, 400, { error: 'unknown Codex effort level' });
2326
- patch.codex = body.codex;
2327
- }
2328
- if (Object.keys(patch).length === 0)
2329
- return json(req, res, 400, { error: 'nothing to change' });
2330
- return json(req, res, 200, { defaultEfforts: writeDefaultEfforts(patch) });
2331
- }
2332
- // GET /api/usage — structured subscription limits from the CLIs Conductor
2333
- // itself bundles. Both reads are prompt-free and cached; `refresh=1` is the
2334
- // explicit user action in the sheet, never a background poll.
2335
- if (isRoute(routes.planUsage, req.method, pathname)) {
2336
- return json(req, res, 200, await planUsage.read(url.searchParams.get('refresh') === '1'));
2337
- }
2338
- if (isRoute(routes.toolUsage, req.method, pathname)) {
2339
- const range = url.searchParams.get('range') ?? '24h';
2340
- if (!isToolUsageRange(range))
2341
- return json(req, res, 400, { error: 'Choose 24h, 7d, or 30d for tool usage.' });
2342
- return json(req, res, 200, await toolUsage.read(range, url.searchParams.get('refresh') === '1'));
2343
- }
2344
- if (isRoute(routes.roles, req.method, pathname)) {
2345
- const stored = roleStore.read();
2346
- return json(req, res, 200, {
2347
- ...stored.config,
2348
- issues: roleModelIssues(stored.config, modelCache.list()),
2349
- ...(stored.warning ? { warning: stored.warning } : {})
2350
- });
2351
- }
2352
- if (isRoute(routes.updateRoles, req.method, pathname)) {
2353
- let config;
2354
- try {
2355
- config = decodeRoles(JSON.parse((await readBody(req)) || '{}'));
2356
- }
2357
- catch (err) {
2358
- const error = {
2359
- code: 'invalid_request',
2360
- message: err instanceof Error ? err.message : String(err),
2361
- retryable: false
2362
- };
2363
- return json(req, res, 400, { ok: false, error });
2364
- }
2365
- const issues = roleModelIssues(config, modelCache.list());
2366
- if (issues.length)
2367
- return json(req, res, 409, { ok: false, error: issues[0].error, issues });
2368
- const written = roleStore.write(config);
2369
- if (!written.ok) {
2370
- return json(req, res, 500, {
2371
- ok: false,
2372
- error: { code: 'state_invalid', message: written.error, retryable: true }
2373
- });
2374
- }
2375
- return json(req, res, 200, { ok: true, config: written.config });
2376
- }
2377
- // This is deliberately independent of a Workflow run: cancellation cannot
2378
- // make an ambiguous shared-window effect safe. Only a phone acknowledgement
2379
- // after inspecting Conductor clears the relay-wide hold.
2380
- if (isRoute(routes.confirmUiStable, req.method, pathname)) {
2381
- if (workflowClientIsMcp(req.headers)) {
2382
- throw new WorkflowRequestError('Only the phone UI can confirm that Conductor is stable.', 403);
2383
- }
2384
- if (!orchestration.writable) {
2385
- throw new WorkflowCoordinatorError('workflow_incompatible_relay', `UI stability confirmation is disabled because ${orchestrationUnavailableReason()}.`, { status: 409 });
2386
- }
2387
- const request = parseConfirmUiStableRequest(await workflowRequestBody(req));
2388
- const confirmed = orchestration.idempotentMutation('confirm_ui_stable', request.clientId, {
2389
- confirmStable: true,
2390
- createdAt: request.createdAt,
2391
- ...(request.actionId ? { actionId: request.actionId } : {}),
2392
- ...(request.effectId ? { effectId: request.effectId } : {})
2393
- }, () => {
2394
- const current = orchestration.getUiQuarantine();
2395
- if (current.active &&
2396
- (current.createdAt !== request.createdAt ||
2397
- current.actionId !== request.actionId ||
2398
- current.effectId !== request.effectId)) {
2399
- throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'The Conductor UI safety hold changed; inspect the current hold before confirming it.', { status: 409 });
2400
- }
2401
- orchestration.clearUiQuarantine(`phone:${request.clientId}`);
2402
- return { ok: true };
2403
- });
2404
- return json(req, res, 200, confirmed.result);
2405
- }
2406
- // POST /api/workflows — the only operation that authorizes a managed
2407
- // Workflow. Acceptance is durable and intentionally precedes every UI effect.
2408
- if (isRoute(routes.workflows, req.method, pathname)) {
2409
- if (workflowClientIsMcp(req.headers)) {
2410
- throw new WorkflowRequestError('MCP cannot start a Workflow; start it from the Conductor Remote UI.', 403);
2411
- }
2412
- if (!workflowCoordinator) {
2413
- throw new WorkflowCoordinatorError('workflow_incompatible_relay', `Workflow is disabled because ${orchestrationUnavailableReason()}.`, { status: 409 });
2414
- }
2415
- const request = parseStartWorkflowRequest(await workflowRequestBody(req));
2416
- const replay = orchestration.getIdempotentMutation('start_workflow', request.clientId, {
2417
- objective: request.objective,
2418
- target: request.target
2419
- });
2420
- if (!replay && request.target.kind === 'new_workspace') {
2421
- const stageIds = stagedAttachmentIdsInObjective(request.objective);
2422
- if (stageIds.length && !stagedAttachments(STAGED_ATTACHMENTS_DIR, stageIds)) {
2423
- throw new WorkflowCoordinatorError('invalid_request', 'One or more Workflow attachments are no longer staged; add them again.', { status: 409 });
2424
- }
2425
- }
2426
- const accepted = await workflowCoordinator.start({
2427
- clientId: request.clientId,
2428
- objective: request.objective,
2429
- target: request.target,
2430
- roles: roleStore.read(),
2431
- modelGroups: modelCache.list()
2432
- });
2433
- queueMicrotask(() => {
2434
- void workflowCoordinator.wake(accepted.workflow.id).catch(error => {
2435
- console.error(`[workflow ${accepted.workflow.id}] initial wake failed:`, error);
2436
- });
2437
- });
2438
- return json(req, res, 202, { workflow: accepted.workflow });
2439
- }
2440
- // POST /api/workflows/:id/delegations — managed delegation. The
2441
- // capability, exact root, frozen role, and phase barrier are checked together.
2442
- const workflowDelegation = routeParam(routes.workflowDelegation, req.method, pathname);
2443
- if (workflowDelegation) {
2444
- if (!workflowCoordinator) {
2445
- throw new WorkflowCoordinatorError('workflow_incompatible_relay', 'Workflow is unavailable.', {
2446
- status: 409
2447
- });
2448
- }
2449
- const request = parseWorkflowDelegateRequest(await workflowRequestBody(req), workflowDelegation);
2450
- // The capability rotates after every accepted choice. Its hash therefore
2451
- // doubles as a stable retry identity without adding a field to the exact tool schema.
2452
- const clientId = crypto
2453
- .createHash('sha256')
2454
- .update(JSON.stringify([
2455
- request.workflow_id,
2456
- request.phase_capability,
2457
- request.session_id,
2458
- request.role,
2459
- request.prompt
2460
- ]))
2461
- .digest('hex');
2462
- const accepted = await workflowCoordinator.delegate({
2463
- clientId,
2464
- workflowId: request.workflow_id,
2465
- sessionId: request.session_id,
2466
- phaseCapability: request.phase_capability,
2467
- role: request.role,
2468
- task: request.prompt,
2469
- ...(request.role === 'implementation' ? { planningInterpretation: request.prompt } : {})
2470
- });
2471
- queueMicrotask(() => {
2472
- void workflowCoordinator.wake(accepted.workflow.id).catch(error => {
2473
- console.error(`[workflow ${accepted.workflow.id}] delegated wake failed:`, error);
2474
- });
2475
- });
2476
- return json(req, res, 202, {
2477
- ok: true,
2478
- workflowId: accepted.workflow.id,
2479
- delegationId: accepted.job.id,
2480
- role: accepted.job.role,
2481
- model: accepted.job.resolvedRole.model
2482
- });
2483
- }
2484
- const retryWorkflow = routeParam(routes.workflowRetry, req.method, pathname);
2485
- if (retryWorkflow) {
2486
- const coordinator = requirePhoneWorkflowCoordinator(req, WORKFLOW_RECOVERY_PHONE_ONLY);
2487
- const request = parseWorkflowRetryRequest(await workflowRequestBody(req));
2488
- const result = await coordinator.retry({
2489
- clientId: request.clientId,
2490
- workflowId: retryWorkflow
2491
- });
2492
- queueMicrotask(() => void coordinator.wake(result.workflow.id).catch(console.error));
2493
- return json(req, res, 200, { workflow: result.workflow });
2494
- }
2495
- const adoptWorkflow = routeParam(routes.workflowAdopt, req.method, pathname);
2496
- if (adoptWorkflow) {
2497
- const coordinator = requirePhoneWorkflowCoordinator(req, WORKFLOW_RECOVERY_PHONE_ONLY);
2498
- const request = parseWorkflowAdoptRequest(await workflowRequestBody(req));
2499
- const result = await coordinator.adopt({
2500
- clientId: request.clientId,
2501
- workflowId: adoptWorkflow,
2502
- actionId: request.actionId,
2503
- candidateId: request.workspaceId ?? request.sessionId
2504
- });
2505
- queueMicrotask(() => void coordinator.wake(result.workflow.id).catch(console.error));
2506
- return json(req, res, 200, { workflow: result.workflow });
2507
- }
2508
- const replayWorkflow = routeParam(routes.workflowReplay, req.method, pathname);
2509
- if (replayWorkflow) {
2510
- const coordinator = requirePhoneWorkflowCoordinator(req, WORKFLOW_RECOVERY_PHONE_ONLY);
2511
- const request = parseWorkflowReplayRequest(await workflowRequestBody(req));
2512
- const result = await coordinator.replay({
2513
- clientId: request.clientId,
2514
- workflowId: replayWorkflow,
2515
- actionId: request.actionId,
2516
- confirmDuplicateRisk: request.confirmDuplicateRisk
2517
- });
2518
- queueMicrotask(() => void coordinator.wake(result.workflow.id).catch(console.error));
2519
- return json(req, res, 200, { workflow: result.workflow });
2520
- }
2521
- const completeWorkflow = routeParam(routes.workflowComplete, req.method, pathname);
2522
- if (completeWorkflow) {
2523
- const coordinator = requirePhoneWorkflowCoordinator(req, 'Only the phone UI can mark a Workflow complete.');
2524
- const request = parseWorkflowCompleteRequest(await workflowRequestBody(req));
2525
- const result = await coordinator.complete({
2526
- clientId: request.clientId,
2527
- workflowId: completeWorkflow
2528
- });
2529
- return json(req, res, 200, { workflow: result.workflow });
2530
- }
2531
- const cancelWorkflow = routeParam(routes.workflow, req.method, pathname);
2532
- if (cancelWorkflow) {
2533
- const coordinator = requirePhoneWorkflowCoordinator(req, 'Only the phone UI can cancel a Workflow.');
2534
- const clientId = url.searchParams.get('clientId');
2535
- if (!clientId?.trim())
2536
- throw new WorkflowRequestError('clientId is required.');
2537
- const result = await coordinator.cancel({ clientId: clientId.trim(), workflowId: cancelWorkflow });
2538
- return json(req, res, 200, { workflow: result.workflow });
2539
- }
2540
- if (isRoute(routes.delegations, req.method, pathname)) {
2541
- const workspaceId = url.searchParams.get('workspaceId');
2542
- const workspaces = reads.listWorkspaces().filter(ws => !workspaceId || ws.id === workspaceId);
2543
- if (workspaceId && !workspaces.length)
2544
- return json(req, res, 404, { error: 'workspace not found' });
2545
- const legacy = workspaces.flatMap(ws => {
2546
- const store = delegationStore(ws);
2547
- return store
2548
- ? store
2549
- .list()
2550
- .jobs.filter(job => job.status !== 'returned')
2551
- .map(projectDelegation)
2552
- : [];
2553
- });
2554
- const allowedWorkspaces = new Set(workspaces.map(workspace => workspace.id));
2555
- const workflows = orchestration.writable
2556
- ? orchestration.listWorkflowProjections().flatMap(workflow => {
2557
- if (!workflow.workspaceId || !allowedWorkspaces.has(workflow.workspaceId))
2558
- return [];
2559
- return orchestration
2560
- .listWorkflowJobs(workflow.id)
2561
- .filter(job => job.state !== 'returned')
2562
- .flatMap(job => projectWorkflowDelegation(workflow, job) ?? []);
2563
- })
2564
- : [];
2565
- return json(req, res, 200, { delegations: [...legacy, ...workflows] });
2566
- }
2567
- const dismissDelegation = routeParam(routes.dismissDelegation, req.method, pathname);
2568
- if (dismissDelegation) {
2569
- for (const ws of reads.listWorkspaces()) {
2570
- const store = delegationStore(ws);
2571
- if (!store)
2572
- continue;
2573
- let job;
2574
- try {
2575
- job = store.get(dismissDelegation);
2576
- }
2577
- catch (err) {
2578
- const error = {
2579
- code: 'state_invalid',
2580
- message: `Cannot dismiss unreadable delegation: ${err instanceof Error ? err.message : err}`,
2581
- retryable: false
2582
- };
2583
- return json(req, res, 409, {
2584
- ok: false,
2585
- error
2586
- });
2587
- }
2588
- if (!job)
2589
- continue;
2590
- if (job.status !== 'failed') {
2591
- return json(req, res, 409, {
2592
- ok: false,
2593
- error: {
2594
- code: 'invalid_request',
2595
- message: 'Only a failed delegation can be dismissed.',
2596
- retryable: false
2597
- }
2598
- });
2599
- }
2600
- store.remove(job.id);
2601
- return json(req, res, 200, { ok: true, delegationId: job.id });
2602
- }
2603
- return json(req, res, 404, {
2604
- ok: false,
2605
- error: {
2606
- code: 'delegation_not_found',
2607
- message: 'Delegation not found.',
2608
- retryable: false
2609
- }
2610
- });
2611
- }
2612
- const voiceTranscript = routeParam(routes.voiceTranscript, req.method, pathname);
2613
- if (isRoute(routes.voiceHistory, req.method, pathname) ||
2614
- isRoute(routes.voiceSearch, req.method, pathname) ||
2615
- voiceTranscript) {
2616
- try {
2617
- if (isRoute(routes.voiceSearch, req.method, pathname)) {
2618
- const query = url.searchParams.get('q')?.trim() ?? '';
2619
- if (!query || query.length > MAX_VOICE_SEARCH_CHARS)
2620
- return json(req, res, 400, { error: `q must contain 1–${MAX_VOICE_SEARCH_CHARS} characters` });
2621
- return json(req, res, 200, voiceHistory.search(query, {
2622
- limit: Math.max(1, Math.min(50, Number(url.searchParams.get('limit')) || 12)),
2623
- offset: Math.max(0, Math.min(1_000_000, Number(url.searchParams.get('offset')) || 0)),
2624
- callId: url.searchParams.get('callId') || undefined
2625
- }));
2626
- }
2627
- if (!voiceTranscript) {
2628
- const limit = Math.max(1, Math.min(100, Number(url.searchParams.get('limit')) || 30));
2629
- const offset = Math.max(0, Math.min(1_000_000, Number(url.searchParams.get('offset')) || 0));
2630
- return json(req, res, 200, voiceHistory.list(Math.floor(limit), Math.floor(offset)));
2631
- }
2632
- const call = url.searchParams.get('summary') === '1'
2633
- ? voiceHistory.status(voiceTranscript)
2634
- : voiceHistory.read(voiceTranscript);
2635
- return call ? json(req, res, 200, call) : json(req, res, 404, { error: 'Saved voice call not found' });
2636
- }
2637
- catch (error) {
2638
- console.warn('[voice] could not read call history:', error);
2639
- return json(req, res, 503, { error: 'Call history is unavailable. Check the relay logs.' });
2640
- }
2641
- }
2642
- // POST /api/voice/ticket — the native app presents the same relay bearer as
2643
- // the PWA, and receives only a two-minute SIP URI. The OpenAI key, webhook
2644
- // secret and marker key never leave this Mac.
2645
- if (isRoute(routes.voiceTicket, req.method, pathname)) {
2646
- const missing = missingTicketConfig(voiceConfig);
2647
- if (missing.length || !voiceBroker) {
2648
- return json(req, res, 503, {
2649
- error: 'voice calls are not fully configured on this relay',
2650
- missing
2651
- });
2652
- }
2653
- return json(req, res, 200, mintSipTicket(voiceConfig));
2654
- }
2655
- // POST /api/voice/calls — the PWA sends its SDP offer to this authenticated
2656
- // relay. The relay loads the selected chat context or the fleet session and
2657
- // keeps OpenAI's permanent key and every function tool on the Mac.
2658
- if (isRoute(routes.voiceCall, req.method, pathname)) {
2659
- if (!voiceConfig.openaiKey || !voiceBroker)
2660
- return json(req, res, 503, { error: 'voice needs an OpenAI API key on this relay' });
2661
- const raw = await readBody(req);
2662
- if (raw.length > MAX_SDP_CHARS * 2)
2663
- return json(req, res, 413, { error: 'WebRTC offer is too large' });
2664
- const body = JSON.parse(raw || '{}');
2665
- if (typeof body.sdp !== 'string' || !body.sdp.trim())
2666
- return json(req, res, 400, { error: 'WebRTC offer is required' });
2667
- if (body.sdp.length > MAX_SDP_CHARS)
2668
- return json(req, res, 413, { error: 'WebRTC offer is too large' });
2669
- if (!isOpenAIRealtimeVoice(body.voice))
2670
- return json(req, res, 400, { error: `voice must be one of ${OPENAI_REALTIME_VOICES.join(', ')}` });
2671
- if (!isVoiceLanguage(body.language))
2672
- return json(req, res, 400, { error: 'unsupported voice language' });
2673
- try {
2674
- const target = parseVoiceCallTarget(body.target);
2675
- const context = target ? readVoiceChatContext(reads, target) : undefined;
2676
- const call = await createWebRtcCall(voiceConfig.openaiKey, openAIOriginForSipHost(voiceConfig.sipHost), body.sdp, {
2677
- model: voiceConfig.model,
2678
- voice: body.voice,
2679
- language: body.language,
2680
- context
2681
- }, voiceSafetyIdentifier);
2682
- voiceBroker.registerWebRtc(call.callId, { voice: body.voice, language: body.language });
2683
- return json(req, res, 200, call);
2684
- }
2685
- catch (err) {
2686
- if (err instanceof VoiceContextError)
2687
- return json(req, res, err.status, { error: err.message });
2688
- console.warn('[voice] could not create WebRTC orchestrator call:', err);
2689
- return json(req, res, 502, { error: err instanceof Error ? err.message : 'voice call failed' });
2690
- }
2691
- }
2692
- const readyVoiceCall = routeParam(routes.voiceCallReady, req.method, pathname);
2693
- if (readyVoiceCall) {
2694
- if (!voiceBroker)
2695
- return json(req, res, 503, { error: 'voice is not configured on this relay' });
2696
- if (!voiceBroker.beginWebRtc(readyVoiceCall))
2697
- return json(req, res, 404, { error: 'voice call not found' });
2698
- return json(req, res, 200, { ok: true });
2699
- }
2700
- const endedVoiceCall = routeParam(routes.voiceCallEnd, req.method, pathname);
2701
- if (endedVoiceCall) {
2702
- if (!voiceBroker)
2703
- return json(req, res, 503, { error: 'voice is not configured on this relay' });
2704
- try {
2705
- if (!(await voiceBroker.hangupWebRtc(endedVoiceCall)))
2706
- return json(req, res, 404, { error: 'voice call not found' });
2707
- return json(req, res, 200, { ok: true });
2708
- }
2709
- catch (err) {
2710
- console.warn('[voice] could not hang up WebRTC orchestrator call:', err);
2711
- return json(req, res, 502, { error: err instanceof Error ? err.message : 'voice hangup failed' });
2712
- }
2713
- }
2714
- // GET /api/settings — relay preferences plus what the phone needs to edit them:
2715
- // the SSIDs this Mac already holds credentials for, so the picker offers a choice
2716
- // instead of asking someone to type a network name from memory on a phone keyboard.
2717
- // `ssid` is best-effort and often null (macOS gates it behind Location Services).
2718
- if (isRoute(routes.settings, req.method, pathname)) {
2719
- // Five subprocesses, all concurrent: this is the one route that shells out more
2720
- // than once, and serialising them would put the phone's polls behind the sum.
2721
- const [known, current, autoJoinHotspot, nosleep, locked] = await Promise.all([
2722
- preferredNetworks(),
2723
- currentSsid(),
2724
- // macOS's own Auto-join Hotspot setting. On "Never" the Mac won't reach for
2725
- // your phone unprompted, which no amount of relay code can substitute for.
2726
- autoJoinHotspotMode(),
2727
- nosleepState(),
2728
- // Read here rather than polled: this sheet is where someone goes when the Mac
2729
- // stopped answering, and a keep-awake window that says "automatic screen lock
2730
- // is off" beside a Mac that is locked right now reads as the relay lying. The
2731
- // assertion blocks the *idle* lock; it cannot lift one already up, and a lid
2732
- // close or a manual lock puts one up whatever it holds.
2733
- screenLocked()
2734
- ]);
2735
- return json(req, res, 200, {
2736
- settings: readSettings(),
2737
- wifi: {
2738
- current,
2739
- known,
2740
- // A guess from the name, never a fact — see wifi.ts. It only sorts the picker.
2741
- likelyHotspots: known.filter(looksLikeHotspot),
2742
- autoJoinHotspot
2743
- },
2744
- nosleep: { ...nosleep, maxSeconds: NOSLEEP_MAX_SECONDS },
2745
- screenLocked: locked
2746
- });
2747
- }
2748
- // PATCH /api/settings { fallbackSsids?, autoRejoin? } — merge and persist.
2749
- if (isRoute(routes.updateSettings, req.method, pathname)) {
2750
- const body = JSON.parse((await readBody(req)) || '{}');
2751
- const patch = {};
2752
- if (Array.isArray(body.fallbackSsids))
2753
- patch.fallbackSsids = body.fallbackSsids;
2754
- if (typeof body.autoRejoin === 'boolean')
2755
- patch.autoRejoin = body.autoRejoin;
2756
- if (Object.keys(patch).length === 0)
2757
- return json(req, res, 400, { error: 'nothing to change' });
2758
- return json(req, res, 200, { settings: writeSettings(patch) });
2759
- }
2760
- // PWA state remains local-first; this host copy survives origin changes and
2761
- // reconciles phones. PATCH accepts a full client snapshot and merges per key.
2762
- if (isRoute(routes.prefs, req.method, pathname)) {
2763
- return json(req, res, 200, { prefs: readPrefs() });
2764
- }
2765
- if (isRoute(routes.updatePrefs, req.method, pathname)) {
2766
- const raw = JSON.parse((await readBody(req)) || '{}');
2767
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
2768
- return json(req, res, 400, { error: 'preferences must be an object' });
2769
- }
2770
- const body = raw;
2771
- if (!Object.hasOwn(body, 'readMarks') && !Object.hasOwn(body, 'drafts')) {
2772
- return json(req, res, 400, { error: 'nothing to sync' });
2773
- }
2774
- return json(req, res, 200, { prefs: writePrefs(body) });
2775
- }
2776
- // GET /api/nosleep — is the Mac being held awake, and can this relay do it at all
2777
- if (isRoute(routes.nosleep, req.method, pathname)) {
2778
- return json(req, res, 200, { ...(await nosleepState()), maxSeconds: NOSLEEP_MAX_SECONDS });
2779
- }
2780
- // POST /api/nosleep { seconds } — hold this Mac awake, lid closed, for a bounded window.
2781
- // Only works once `conductor-remote nosleep setup` has installed the scoped sudoers
2782
- // rule; without it there is no way for a TTY-less daemon to reach root, and the
2783
- // response says so rather than failing vaguely.
2784
- if (isRoute(routes.armNoSleep, req.method, pathname)) {
2785
- const body = JSON.parse((await readBody(req)) || '{}');
2786
- const seconds = Number(body.seconds);
2787
- // Whole seconds, not just "> 0": the helper reads 0 as "until killed", and 0.4
2788
- // truncates to 0 — an unbounded window from a request that looked bounded.
2789
- if (!Number.isInteger(seconds) || seconds < 1)
2790
- return json(req, res, 400, { error: 'need a whole number of seconds >= 1' });
2791
- const result = await armNoSleep(seconds, cfg.preventScreenLock);
2792
- return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
2793
- }
2794
- // DELETE /api/nosleep — let it sleep again now, rather than at the window's end
2795
- if (isRoute(routes.disarmNoSleep, req.method, pathname)) {
2796
- const result = await disarmNoSleep();
2797
- return json(req, res, result.ok ? 200 : result.state.available ? 502 : 409, result);
2798
- }
2799
- // POST /api/conductor/restart { stopAgents? } — quit Conductor and start it again.
2800
- //
2801
- // The lever exists in the actuator already, but only as activateConductor's last
2802
- // resort, which fires exclusively for a *windowless* Conductor. This is for the
2803
- // other shape: window up, prompts landing as rows, and no agent output behind any
2804
- // of it (measured 2026-09-02 — 2h35m of user rows after the last agent frame).
2805
- // The running agents are counted from the DB before the UI is touched and refused
2806
- // unless the caller meant it, the same way archiving is: quitting ends every turn
2807
- // in flight. The lock screen is the actuator's own gate, since only it can ask.
2808
- if (isRoute(routes.restartConductor, req.method, pathname)) {
2809
- const body = JSON.parse((await readBody(req)) || '{}');
2810
- const working = reads.listSessionStates().filter(state => state.status === 'working').length;
2811
- if (working > 0 && body.stopAgents !== true) {
2812
- return json(req, res, 409, {
2813
- ok: false,
2814
- agentsRunning: true,
2815
- working,
2816
- error: `${working} chat${working === 1 ? ' is' : 's are'} mid-turn. Restarting Conductor ends ${working === 1 ? 'it' : 'them'}.`
2817
- });
2818
- }
2819
- const startedAt = Date.now();
2820
- const result = await restartConductorApp();
2821
- const ms = Date.now() - startedAt;
2822
- if (!result.ok) {
2823
- console.warn(`[restart] Conductor restart failed after ${(ms / 1000).toFixed(1)}s: ${result.error}`);
2824
- return json(req, res, 502, { ok: false, ms, error: result.error });
2825
- }
2826
- console.log(`[restart] quit Conductor and relaunched it in ${(ms / 1000).toFixed(1)}s`);
2827
- return json(req, res, 200, { ok: true, ms });
2828
- }
2829
- // GET /api/logs?file=&limit= — the relay's own log, so a phone can diagnose a failed send
2830
- // without reaching the Mac. Default is this process's captured console (ordered, timestamped);
2831
- // `file` tails the daemon's stdout/stderr on disk, which is the only place the *previous*
2832
- // process's crash survives. Everything is redacted: the startup banner prints the token.
2833
- if (isRoute(routes.logs, req.method, pathname)) {
2834
- const file = url.searchParams.get('file');
2835
- if (file && !LOG_FILE_NAMES.includes(file)) {
2836
- return json(req, res, 404, { error: `unknown log file ${file}`, files: LOG_FILE_NAMES });
2837
- }
2838
- const asked = Number(url.searchParams.get('limit') ?? 300);
2839
- const limit = Number.isFinite(asked) ? Math.min(2000, Math.max(1, Math.trunc(asked))) : 300;
2840
- let entries;
2841
- try {
2842
- entries = file ? tailLogFile(file, limit) : recentLogs(limit);
2843
- }
2844
- catch (err) {
2845
- // The file only exists once the LaunchAgent has run; say so instead of a bare 500.
2846
- return json(req, res, 404, { error: `can’t read ${file}: ${err instanceof Error ? err.message : err}` });
2847
- }
2848
- return json(req, res, 200, {
2849
- source: file ?? 'live',
2850
- // False → the files below are some *other* (daemon) process's output, not this relay's.
2851
- managed: isManaged(),
2852
- startedAt: processStartedAt(),
2853
- now: Date.now(),
2854
- files: logFiles(),
2855
- entries: entries.map(e => ({ ...e, text: redactSecrets(e.text, cfg.token) }))
2856
- });
2857
- }
2858
- // GET /api/push — the VAPID public key the phone subscribes with, plus who's already subscribed
2859
- if (isRoute(routes.push, req.method, pathname)) {
2860
- return json(req, res, 200, pushConfig());
2861
- }
2862
- // POST /api/push/subscribe { subscription, label? } — register (or refresh) this device.
2863
- // Idempotent by endpoint: the app re-sends on every load, which is what heals a relay that
2864
- // lost its store, or a subscription the browser silently renewed.
2865
- if (isRoute(routes.pushSubscribe, req.method, pathname)) {
2866
- const body = JSON.parse((await readBody(req)) || '{}');
2867
- const sub = body.subscription;
2868
- if (!sub?.endpoint || !sub.keys?.p256dh || !sub.keys.auth) {
2869
- return json(req, res, 400, { error: 'need a subscription with endpoint and keys' });
2870
- }
2871
- // An endpoint is a URL we will POST to — never accept a non-HTTPS one.
2872
- if (!/^https:\/\//i.test(sub.endpoint))
2873
- return json(req, res, 400, { error: 'endpoint must be https' });
2874
- const registered = subscribeDevice({ endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth } }, (body.label ?? '').slice(0, 64));
2875
- return json(req, res, 200, { ok: true, ...registered });
2876
- }
2877
- // POST /api/push/unsubscribe { endpoint } — the phone turned notifications off
2878
- if (isRoute(routes.pushUnsubscribe, req.method, pathname)) {
2879
- const body = JSON.parse((await readBody(req)) || '{}');
2880
- if (!body.endpoint)
2881
- return json(req, res, 400, { error: 'need the endpoint' });
2882
- return json(req, res, 200, { ok: unsubscribeDevice(body.endpoint), devices: pushConfig().devices });
2883
- }
2884
- // POST /api/push/test { id } — push to one device, so "is this actually wired up?" has an answer
2885
- if (isRoute(routes.pushTest, req.method, pathname)) {
2886
- const body = JSON.parse((await readBody(req)) || '{}');
2887
- if (!body.id)
2888
- return json(req, res, 400, { error: 'need the device id' });
2889
- const result = await notifyDevice(body.id, {
2890
- title: 'Conductor Remote',
2891
- body: 'Notifications are working. You’ll get one when an agent finishes.',
2892
- tag: 'test',
2893
- url: '/',
2894
- kind: 'test',
2895
- ts: Date.now()
2896
- });
2897
- return json(req, res, result.ok ? 200 : 502, result);
2898
- }
2899
- // POST /api/attachments — hold one phone file until its new workspace has a worktree.
2900
- if (isRoute(routes.stageAttachment, req.method, pathname)) {
2901
- const name = attachmentHeaderName(req);
2902
- if (!name)
2903
- return json(req, res, 400, { error: 'missing attachment name' });
2904
- const bytes = await readAttachmentBody(req);
2905
- if (!bytes.length)
2906
- return json(req, res, 400, { error: 'empty attachment' });
2907
- const attachment = stageAttachment(STAGED_ATTACHMENTS_DIR, name, bytes);
2908
- return json(req, res, 201, { ok: true, attachment });
2909
- }
2910
- // DELETE /api/attachments/:id — an upload cancelled before it became a synced draft attachment.
2911
- const stagedAttachment = routeParam(routes.discardStagedAttachment, req.method, pathname);
2912
- if (stagedAttachment)
2913
- return json(req, res, discardStagedAttachment(STAGED_ATTACHMENTS_DIR, stagedAttachment) ? 200 : 404, {
2914
- ok: true
2915
- });
2916
- // POST /api/workspaces { repo, prompt, model?/effort?/plan?/fast?, send? }
2917
- // — create a workspace via Conductor's deep link, then configure its first chat.
2918
- if (isRoute(routes.createWorkspace, req.method, pathname)) {
2919
- const body = JSON.parse((await readBody(req)) || '{}');
2920
- const attachmentIds = body.attachmentIds ?? [];
2921
- if ('workflow' in body)
2922
- return json(req, res, 400, { error: 'Workflow starts through POST /api/workflows.' });
2923
- if (body.model !== undefined && typeof body.model !== 'string')
2924
- return json(req, res, 400, { error: 'model must be a picker label' });
2925
- if (body.effort !== undefined && typeof body.effort !== 'string')
2926
- return json(req, res, 400, { error: 'effort must be a string' });
2927
- const effort = body.effort?.trim() || undefined;
2928
- if (effort && !EFFORT_LABELS[effort])
2929
- return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
2930
- if (body.plan !== undefined && typeof body.plan !== 'boolean')
2931
- return json(req, res, 400, { error: 'plan must be a boolean' });
2932
- if (body.fast !== undefined && typeof body.fast !== 'boolean')
2933
- return json(req, res, 400, { error: 'fast must be a boolean' });
2934
- const requestedAgent = {
2935
- model: body.model?.trim() || undefined,
2936
- effort,
2937
- plan: body.plan,
2938
- fast: body.fast
2939
- };
2940
- if (!Array.isArray(attachmentIds) || attachmentIds.some(id => typeof id !== 'string'))
2941
- return json(req, res, 400, { error: 'attachment ids must be a list of strings' });
2942
- const attachments = stagedAttachments(STAGED_ATTACHMENTS_DIR, attachmentIds);
2943
- if (!attachments)
2944
- return json(req, res, 409, { error: 'an attached file is no longer available; add it again' });
2945
- // The prompt is optional — a bare `path=` opens an empty workspace, like
2946
- // Conductor's own New workspace — but *something* has to say where it goes.
2947
- const objective = [...attachments.map(attachment => attachment.token), (body.prompt ?? '').trim()]
2948
- .filter(Boolean)
2949
- .join('\n');
2950
- if (!objective && !body.repo)
2951
- return json(req, res, 400, { error: 'need a repo or a prompt' });
2952
- const prompt = objective;
2953
- const agent = requestedAgent;
2954
- const configureAgent = Object.values(agent).some(value => value !== undefined);
2955
- // Resolve the repo to a real path: an unmatched `path` would silently land
2956
- // the workspace in whichever repo Conductor happens to list first.
2957
- const repo = body.repo ? reads.listRepos().find(r => r.name === body.repo) : undefined;
2958
- if (body.repo && !repo)
2959
- return json(req, res, 404, { error: `unknown repo ${body.repo}` });
2960
- if (repo && !repo.root_path)
2961
- return json(req, res, 409, { error: `${repo.name} has no checkout path` });
2962
- const { result, created } = await createWorkspaceAndRead(prompt, repo?.root_path ?? null, repo?.name);
2963
- if (!result.ok)
2964
- return json(req, res, 502, result);
2965
- if (!created) {
2966
- return json(req, res, 502, {
2967
- ok: false,
2968
- strategy: result.strategy,
2969
- error: 'Conductor didn’t create a workspace — check it’s running and not showing a dialog.'
2970
- });
2971
- }
2972
- // Return as soon as the row exists (~2s) — waiting for delivery would block the
2973
- // request through Conductor's whole setup, measured at 30s+ on a real repo and
2974
- // past any budget a phone should hold a request open for. The queue delivers on
2975
- // its own schedule and the phone watches it in /api/state; `send:true` opts API
2976
- // callers into waiting.
2977
- // Whatever happens, the prompt is already pre-filled in Conductor's composer.
2978
- const settled = prompt || configureAgent
2979
- ? firstPrompts.enqueue(created.id, prompt, body.sendImmediately !== false, attachmentIds, configureAgent ? agent : undefined)
2980
- : null;
2981
- const failed = settled && body.send === true ? await settled : null;
2982
- settled?.catch(() => undefined); // fire-and-forget: it reports failure, it never rejects
2983
- return json(req, res, 200, {
2984
- ok: true,
2985
- workspaceId: created.id,
2986
- workspace: reads.getWorkspace(created.id) ?? created,
2987
- pendingPrompt: prompt || undefined,
2988
- model: agent.model,
2989
- sent: body.send === true && !!prompt && !failed,
2990
- configured: body.send === true && configureAgent && !failed,
2991
- warning: failed?.error &&
2992
- `Workspace created; the initial ${configureAgent ? 'agent settings and prompt' : 'prompt'} didn’t finish (${failed.error}).`
2993
- });
2994
- }
2995
- // GET /api/repos/:name/icon — the repo's resolved sidebar icon (see src/icons.ts)
2996
- const repo = routeParam(routes.repoIcon, req.method, pathname);
2997
- if (repo) {
2998
- const icon = reads.resolveRepoIcon(repo);
2999
- if (!icon)
3000
- return json(req, res, 404, { error: 'no icon' });
3001
- return void fs.readFile(icon.path, (err, data) => {
3002
- if (err)
3003
- return void json(req, res, 404, { error: 'no icon' });
3004
- // Cache briefly on the phone; the resolver itself refreshes within ~30s of an icon change.
3005
- res.writeHead(200, { 'content-type': icon.contentType, 'cache-control': 'public, max-age=300' });
3006
- res.end(data);
3007
- });
3008
- }
3009
- // GET /api/local-images/:path — local images linked from agent Markdown. The browser fetches this
3010
- // with its Authorization header and turns the reply into an object URL (Markdown.tsx), so the secret
3011
- // stays out of the image URL. `serveLocalImage` limits reads to temp files and permitted workspace paths.
3012
- const localImage = routeParam(routes.localImage, req.method, pathname);
3013
- if (localImage)
3014
- return serveLocalImage(req, res, localImage);
3015
- // GET /api/tool-images/:reference — a screenshot or other image a tool returned. Held
3016
- // back from the transcript itself (~100 kB of base64 each) and fetched only for a step
3017
- // the reader opened, with the phone's auth header, like every other image route here.
3018
- const toolImageRef = routeParam(routes.toolImage, req.method, pathname);
3019
- if (toolImageRef) {
3020
- const image = reads.toolImage(toolImageRef);
3021
- if (!image)
3022
- return json(req, res, 404, { error: 'image not found' });
3023
- const bytes = Buffer.from(image.data, 'base64');
3024
- // Immutable: a transcript row is written once, so the reference names one picture
3025
- // forever and re-opening the step costs nothing.
3026
- res.writeHead(200, {
3027
- 'content-type': image.mediaType,
3028
- 'content-length': String(bytes.length),
3029
- 'cache-control': 'private, max-age=86400, immutable'
3030
- });
3031
- return void res.end(bytes);
3032
- }
3033
- // GET /api/files/:reference — source linked from an agent reply. The Markdown component
3034
- // intercepts the browser navigation and fetches this endpoint with its auth header.
3035
- const fileReference = routeParam(routes.filePreview, req.method, pathname);
3036
- if (fileReference)
3037
- return serveFilePreview(req, res, fileReference);
3038
- // GET /api/workspaces/:id — one workspace by id, archived included. `/api/state` lists
3039
- // only the live ones, so this is what lets the phone open a chat search found in work
3040
- // that has been put away: the worktree is gone, the transcript is not.
3041
- const workspaceById = routeParam(routes.workspace, req.method, pathname);
3042
- if (workspaceById) {
3043
- const found = reads.getAnyWorkspace(workspaceById);
3044
- if (!found)
3045
- return json(req, res, 404, { error: 'workspace not found' });
3046
- return json(req, res, 200, { workspace: found });
3047
- }
3048
- // GET /api/workspaces/:id/sessions
3049
- const listSessionsIn = routeParam(routes.sessions, req.method, pathname);
3050
- if (listSessionsIn) {
3051
- const ws = reads.getWorkspace(listSessionsIn);
3052
- const store = ws ? delegationStore(ws) : null;
3053
- const roles = store?.sessionRoles();
3054
- const enriched = ws;
3055
- if (enriched)
3056
- attachWorkflowState([enriched]);
3057
- const sessionRoles = { ...(roles?.sessions ?? {}), ...(enriched?.session_roles ?? {}) };
3058
- return json(req, res, 200, {
3059
- sessions: reads.listSessions(listSessionsIn),
3060
- ...(Object.keys(sessionRoles).length ? { session_roles: sessionRoles } : {})
3061
- });
3062
- }
3063
- const closedSessionsIn = routeParam(routes.closedSessions, req.method, pathname);
3064
- if (closedSessionsIn) {
3065
- if (!reads.getAnyWorkspace(closedSessionsIn))
3066
- return json(req, res, 404, { error: 'workspace not found' });
3067
- return json(req, res, 200, { sessions: reads.listClosedSessions(closedSessionsIn) });
3068
- }
3069
- // POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
3070
- const newChatIn = routeParam(routes.newChat, req.method, pathname);
3071
- if (newChatIn) {
3072
- const workspaceId = newChatIn;
3073
- const ws = reads.getWorkspace(workspaceId);
3074
- if (!ws)
3075
- return json(req, res, 404, { error: 'workspace not found' });
3076
- const opened = await openChat(ws);
3077
- if ('error' in opened)
3078
- return json(req, res, 502, opened.result);
3079
- return json(req, res, 200, { ok: true, sessionId: opened.sessionId });
3080
- }
3081
- // GET /api/workspaces/:id/diff
3082
- const diffOf = routeParam(routes.diff, req.method, pathname);
3083
- if (diffOf) {
3084
- const ws = reads.getWorkspace(diffOf);
3085
- if (!ws)
3086
- return json(req, res, 404, { error: 'workspace not found' });
3087
- if (!ws.worktree)
3088
- return json(req, res, 409, { error: 'worktree path unresolved' });
3089
- const diff = await workspaceDiff(ws.worktree, ws.baseBranch);
3090
- return json(req, res, 200, diff);
3091
- }
3092
- // GET /api/workspaces/:id/diff/file?path=… — the complete patch for the file
3093
- // currently on screen. The aggregate endpoint stays bounded for phone-sized
3094
- // responses, while a late file no longer disappears behind that bound.
3095
- const fileDiffOf = routeParam(routes.fileDiff, req.method, pathname);
3096
- if (fileDiffOf) {
3097
- const ws = reads.getWorkspace(fileDiffOf);
3098
- if (!ws)
3099
- return json(req, res, 404, { error: 'workspace not found' });
3100
- if (!ws.worktree)
3101
- return json(req, res, 409, { error: 'worktree path unresolved' });
3102
- const file = url.searchParams.get('path');
3103
- if (!file)
3104
- return json(req, res, 400, { error: 'file path is required' });
3105
- const diff = await workspaceFileDiff(ws.worktree, ws.baseBranch, file);
3106
- if (!diff)
3107
- return json(req, res, 404, { error: 'changed file not found' });
3108
- return json(req, res, 200, diff);
3109
- }
3110
- // GET /api/workspaces/:id/files — previewable worktree files for the diff window's
3111
- // All-files rail and for linking `tests/foo.ts` in a message only when it really exists.
3112
- // A workspace with no worktree has no list for either caller.
3113
- const filesOf = routeParam(routes.workspaceFiles, req.method, pathname);
3114
- if (filesOf) {
3115
- const ws = reads.getWorkspace(filesOf);
3116
- if (!ws)
3117
- return json(req, res, 404, { error: 'workspace not found' });
3118
- if (!ws.worktree)
3119
- return json(req, res, 200, { files: [], truncated: false });
3120
- return json(req, res, 200, await listSourceFiles(ws.worktree));
3121
- }
3122
- // POST /api/workspaces/:id/merge — merge the workspace's open PR (mirrors Conductor's merge button)
3123
- const mergeOf = routeParam(routes.merge, req.method, pathname);
3124
- if (mergeOf) {
3125
- const ws = reads.getWorkspace(mergeOf);
3126
- if (!ws)
3127
- return json(req, res, 404, { error: 'workspace not found' });
3128
- const result = await mergePr(ws);
3129
- return json(req, res, result.ok ? 200 : 409, result);
3130
- }
3131
- // POST /api/workspaces/:id/continue { sessionId? } — press the Continue action
3132
- // Conductor draws for a merged PR. The native handler checks out a fresh branch,
3133
- // updates its own workspace record and stages Branch continued.md in the selected
3134
- // chat. Only it can do all three consistently; this relay keeps its DB read-only.
3135
- const continueOf = routeParam(routes.continueWorkspace, req.method, pathname);
3136
- if (continueOf) {
3137
- const workspaceId = continueOf;
3138
- const ws = reads.getWorkspace(workspaceId);
3139
- if (!ws) {
3140
- const known = reads.getAnyWorkspace(workspaceId);
3141
- if (known?.archived) {
3142
- return json(req, res, 409, { ok: false, error: 'Archived workspaces cannot be continued.' });
3143
- }
3144
- return json(req, res, 404, { error: 'workspace not found' });
3145
- }
3146
- const body = JSON.parse((await readBody(req)) || '{}');
3147
- const requestedSession = body.sessionId || ws.active_session_id;
3148
- let tab;
3149
- if (requestedSession) {
3150
- const located = locateChat(ws, requestedSession);
3151
- if ('error' in located)
3152
- return json(req, res, 409, { ok: false, error: located.error });
3153
- if (body.sessionId && !located.session) {
3154
- return json(req, res, 409, { ok: false, error: 'chat is no longer one of the workspace’s tabs' });
3155
- }
3156
- tab = located.tab;
3157
- }
3158
- const previousBranch = ws.branch;
3159
- if (!previousBranch)
3160
- return json(req, res, 409, { ok: false, error: 'workspace has no branch to continue' });
3161
- const result = await continueWorkspace({ workspace: ws, sessionId: requestedSession, tab });
3162
- if (!result.ok)
3163
- return json(req, res, 502, result);
3164
- // AXPress only proves the button accepted a click. Conductor then fetches the
3165
- // target and changes the worktree asynchronously, so the branch column is the
3166
- // receipt. A generous wait covers the fetch without ever writing that column.
3167
- let continued = reads.getWorkspace(workspaceId);
3168
- for (let i = 0; i < 60 && (!continued?.branch || continued.branch === previousBranch); i++) {
3169
- await sleep(500);
3170
- continued = reads.getWorkspace(workspaceId);
3171
- }
3172
- if (!continued?.branch || continued.branch === previousBranch) {
3173
- return json(req, res, 502, {
3174
- ok: false,
3175
- strategy: result.strategy,
3176
- error: 'Conductor did not record a new branch within 30 seconds. Check it on your Mac before retrying.'
3177
- });
3178
- }
3179
- return json(req, res, 200, { ok: true, previousBranch, workspace: continued });
3180
- }
3181
- // POST /api/workspaces/:id/status { status } — move it between the sidebar's status groups.
3182
- // Conductor derives that status from a PR it sometimes never links (a PR merged inside its
3183
- // poll window is invisible to it afterwards), which strands finished work in "In progress"
3184
- // with no way to correct it from a phone. This is that way.
3185
- const statusOf = routeParam(routes.workspaceStatus, req.method, pathname);
3186
- if (statusOf) {
3187
- const workspaceId = statusOf;
3188
- const body = JSON.parse((await readBody(req)) || '{}');
3189
- const status = body.status ?? '';
3190
- if (!WORKSPACE_STATUS_LABELS[status]) {
3191
- const allowed = Object.keys(WORKSPACE_STATUS_LABELS).join(', ');
3192
- return json(req, res, 400, { error: `status must be one of ${allowed}` });
3193
- }
3194
- const ws = reads.getWorkspace(workspaceId);
3195
- if (!ws)
3196
- return json(req, res, 404, { error: 'workspace not found' });
3197
- const result = await setWorkspaceStatus(ws, status);
3198
- if (!result.ok)
3199
- return json(req, res, 502, result);
3200
- // The menu press lands in the DB a beat later. Confirm rather than assume —
3201
- // and if Conductor wrote something else, say what, instead of "didn't work".
3202
- let observed = ws.manual_status ?? '';
3203
- for (let i = 0; i < 10 && observed !== status; i++) {
3204
- await new Promise(r => setTimeout(r, 300));
3205
- observed = reads.getWorkspace(workspaceId)?.manual_status ?? '';
3206
- }
3207
- if (observed !== status) {
3208
- return json(req, res, 502, {
3209
- ok: false,
3210
- strategy: result.strategy,
3211
- error: observed
3212
- ? `Conductor recorded the status as “${observed}”, not “${status}”.`
3213
- : 'Conductor didn’t record the change — it may have been asleep. Try again.'
3214
- });
3215
- }
3216
- return json(req, res, 200, { ok: true, workspace: reads.getWorkspace(workspaceId) });
3217
- }
3218
- // POST /api/workspaces/:id/archive { stopAgents? } — put the workspace away, the way
3219
- // Conductor's own ⌘⇧A does. The one write here that destroys something: the worktree
3220
- // goes, and any agent still working goes with it. So the running agents are counted
3221
- // from the DB *before* the UI is touched and refused unless the caller has said it
3222
- // meant that — the phone's own dialog then says so in the same words Conductor's does.
3223
- const archiveOf = routeParam(routes.archiveWorkspace, req.method, pathname);
3224
- if (archiveOf) {
3225
- const workspaceId = archiveOf;
3226
- const body = JSON.parse((await readBody(req)) || '{}');
3227
- const ws = reads.getWorkspace(workspaceId);
3228
- if (!ws) {
3229
- // Already archived is the answer the caller asked for, not a 404. A phone whose
3230
- // answer went missing retries, and `getWorkspace` only sees the live sidebar.
3231
- const known = reads.getAnyWorkspace(workspaceId);
3232
- if (known?.archived)
3233
- return json(req, res, 200, { ok: true, alreadyArchived: true, workspace: known });
3234
- return json(req, res, 404, { error: 'workspace not found' });
3235
- }
3236
- const working = reads.listSessions(ws.id).filter(s => s.status === 'working').length;
3237
- if (working > 0 && body.stopAgents !== true) {
3238
- return json(req, res, 409, {
3239
- ok: false,
3240
- agentsRunning: true,
3241
- error: `${working} agent${working === 1 ? ' is' : 's are'} still working here. Archiving stops them.`
3242
- });
3243
- }
3244
- const result = await archiveWorkspace(ws, body.stopAgents === true);
3245
- if (!result.ok)
3246
- return json(req, res, 502, result);
3247
- // `state` becoming 'archived' is the receipt, like the status change above: the
3248
- // keystroke is fire-and-forget and Conductor writes the row a beat later.
3249
- let archived = reads.getAnyWorkspace(workspaceId);
3250
- for (let i = 0; i < 20 && !archived?.archived; i++) {
3251
- await sleep(300);
3252
- archived = reads.getAnyWorkspace(workspaceId);
3253
- }
3254
- if (!archived?.archived) {
3255
- return json(req, res, 502, {
3256
- ok: false,
3257
- strategy: result.strategy,
3258
- error: 'Conductor took the archive but the workspace is still in the sidebar. Try again, or archive it on your Mac.'
3259
- });
3260
- }
3261
- return json(req, res, 200, { ok: true, strategy: result.strategy, workspace: archived });
3262
- }
3263
- // Conductor's Run configs plus tailnet-only HTTPS forwards for the active
3264
- // one's ports. Reads never touch Conductor's UI; start/stop use the same
3265
- // Accessibility lock and target assertion as every other UI write.
3266
- const devServerOf = routeParam(routes.devServer, req.method, pathname);
3267
- if (devServerOf) {
3268
- const ws = reads.getWorkspace(devServerOf);
3269
- if (!ws)
3270
- return json(req, res, 404, { error: 'workspace not found' });
3271
- return json(req, res, 200, await devServers.state(ws));
3272
- }
3273
- const startDevServerIn = routeParam(routes.startDevServer, req.method, pathname);
3274
- if (startDevServerIn) {
3275
- const ws = reads.getWorkspace(startDevServerIn);
3276
- if (!ws)
3277
- return json(req, res, 404, { error: 'workspace not found' });
3278
- const body = JSON.parse((await readBody(req)) || '{}');
3279
- if (body.runConfigId !== undefined && (typeof body.runConfigId !== 'string' || !body.runConfigId.trim())) {
3280
- return json(req, res, 400, { error: 'runConfigId must be a non-empty string' });
3281
- }
3282
- const result = await devServers.start(ws, body.runConfigId);
3283
- return json(req, res, result.ok ? 200 : result.available ? 502 : 409, result);
3284
- }
3285
- const stopDevServerIn = routeParam(routes.stopDevServer, req.method, pathname);
3286
- if (stopDevServerIn) {
3287
- const ws = reads.getWorkspace(stopDevServerIn);
3288
- if (!ws)
3289
- return json(req, res, 404, { error: 'workspace not found' });
3290
- const result = await devServers.stop(ws);
3291
- return json(req, res, result.ok ? 200 : 502, result);
3292
- }
3293
- // GET /api/sessions/:id/messages?after=<rowid>
3294
- const messagesOf = routeParam(routes.messages, req.method, pathname);
3295
- if (messagesOf) {
3296
- // The phone's 1s transcript poll doubles as its "I am reading this chat" heartbeat,
3297
- // which is what keeps a turn ending on screen from also buzzing the lock screen
3298
- // (src/notify.ts). Only this route is a claim: it is the one read that runs for the
3299
- // chat on screen and for no other.
3300
- const device = req.headers[VIEWING_HEADER];
3301
- if (typeof device === 'string' && device)
3302
- noteViewing(device, messagesOf);
3303
- const after = Number(url.searchParams.get('after') ?? 0);
3304
- return json(req, res, 200, reads.getMessages(messagesOf, Number.isFinite(after) ? after : 0));
3305
- }
3306
- // GET /api/sessions/:id/context — expensive enough to stay off the session poll.
3307
- const contextOf = routeParam(routes.context, req.method, pathname);
3308
- if (contextOf) {
3309
- const breakdown = reads.getContextBreakdown(contextOf);
3310
- if (!breakdown)
3311
- return json(req, res, 404, { error: 'chat not found' });
3312
- return json(req, res, 200, breakdown);
3313
- }
3314
- // GET /api/sessions/:id/models?workspaceId= — labels from Conductor's live picker
3315
- const modelsOf = routeParam(routes.models, req.method, pathname);
3316
- if (modelsOf) {
3317
- const sessionId = modelsOf;
3318
- const frozen = workflowFrozenError(sessionId);
3319
- if (frozen)
3320
- return json(req, res, 409, frozen);
3321
- const ws = reads.getWorkspace(url.searchParams.get('workspaceId') ?? '');
3322
- if (!ws)
3323
- return json(req, res, 404, { error: 'workspace for session not found' });
3324
- const located = locateChat(ws, sessionId);
3325
- if ('error' in located)
3326
- return json(req, res, 409, { error: located.error });
3327
- const result = await listAgentModels({ workspace: ws, sessionId, tab: located.tab });
3328
- if (result.ok && result.models)
3329
- modelCache.remember(located.session?.agent_type, result.models, result.defaultModel);
3330
- return json(req, res, result.ok ? 200 : 502, result);
3331
- }
3332
- // POST /api/sessions/:id/default-model { model, workspaceId? }
3333
- // The picker star is a combined "set default and select" action, so this
3334
- // changes both the user-wide default and this chat's model exactly as the
3335
- // desktop control does.
3336
- const defaultModelOf = routeParam(routes.defaultModel, req.method, pathname);
3337
- if (defaultModelOf) {
3338
- const sessionId = defaultModelOf;
3339
- const frozen = workflowFrozenError(sessionId);
3340
- if (frozen)
3341
- return json(req, res, 409, frozen);
3342
- const body = JSON.parse((await readBody(req)) || '{}');
3343
- if (typeof body.model !== 'string' || !body.model.trim()) {
3344
- return json(req, res, 400, { error: 'model must be a picker label' });
3345
- }
3346
- const ws = typeof body.workspaceId === 'string'
3347
- ? reads.getWorkspace(body.workspaceId)
3348
- : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
3349
- if (!ws)
3350
- return json(req, res, 404, { error: 'workspace for session not found' });
3351
- const located = locateChat(ws, sessionId);
3352
- if ('error' in located)
3353
- return json(req, res, 409, { error: located.error });
3354
- const result = await setDefaultModel({ workspace: ws, sessionId, tab: located.tab }, body.model.trim());
3355
- if (!result.ok || !result.model) {
3356
- return json(req, res, 502, { ok: false, error: result.error ?? 'the default model did not change' });
3357
- }
3358
- const session = reads.listSessions(ws.id).find(row => row.id === sessionId);
3359
- modelCache.rememberModel(session?.agent_type, result.model);
3360
- modelCache.rememberDefault(result.model);
3361
- return json(req, res, 200, { ok: true, defaultModel: result.model, session });
3362
- }
3363
- // POST /api/sessions/:id/agent { effort?, plan?, fast?, model? }
3364
- // Drives the composer's own model/effort/plan/fast controls for one chat.
3365
- const agentOf = routeParam(routes.agent, req.method, pathname);
3366
- if (agentOf) {
3367
- const sessionId = agentOf;
3368
- const body = JSON.parse((await readBody(req)) || '{}');
3369
- if (body.effort && !EFFORT_LABELS[body.effort]) {
3370
- return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
3371
- }
3372
- const frozen = workflowFrozenError(sessionId);
3373
- if (frozen && (body.model !== undefined || body.effort !== undefined || body.fast !== undefined)) {
3374
- return json(req, res, 409, frozen);
3375
- }
3376
- const ws = body.workspaceId
3377
- ? reads.getWorkspace(body.workspaceId)
3378
- : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
3379
- if (!ws)
3380
- return json(req, res, 404, { error: 'workspace for session not found' });
3381
- const applied = await applyAgentPatch(ws, sessionId, body);
3382
- if (!applied.ok)
3383
- return json(req, res, 502, { ok: false, strategy: actuator.name, error: applied.error });
3384
- return json(req, res, 200, { ok: true, session: reads.listSessions(ws.id).find(s => s.id === sessionId) });
3385
- }
3386
- // POST /api/sessions/:id/stop — the desktop app's stop button, for one chat.
3387
- const stopOf = routeParam(routes.stop, req.method, pathname);
3388
- if (stopOf) {
3389
- const sessionId = stopOf;
3390
- const body = JSON.parse((await readBody(req)) || '{}');
3391
- const ws = body.workspaceId
3392
- ? reads.getWorkspace(body.workspaceId)
3393
- : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
3394
- if (!ws)
3395
- return json(req, res, 404, { error: 'workspace for session not found' });
3396
- const located = locateChat(ws, sessionId);
3397
- if ('error' in located)
3398
- return json(req, res, 409, { error: located.error });
3399
- // Nothing running is a success, not an error: the phone shows Stop the moment it
3400
- // sends (the optimistic hint) and a turn that ends on its own a beat before the tap
3401
- // is the common case, not a mistake worth a red banner. It also keeps the one
3402
- // keystroke this route presses off an idle chat entirely — Conductor's own
3403
- // composer has no stop button to mis-tap there either.
3404
- const before = reads.listSessions(ws.id).find(s => s.id === sessionId);
3405
- if (before?.status !== 'working') {
3406
- return json(req, res, 200, { ok: true, alreadyIdle: true, session: before });
3407
- }
3408
- const result = await stopTurn({ workspace: ws, sessionId, tab: located.tab });
3409
- if (!result.ok)
3410
- return json(req, res, 502, result);
3411
- // The DB is the receipt, exactly as it is for agent settings: the keystroke is
3412
- // fire-and-forget, so what counts is `status` leaving `working`. Conductor writes
3413
- // that a beat after it tears the turn down.
3414
- let observed = before.status;
3415
- for (let i = 0; i < 20 && observed === 'working'; i++) {
3416
- await sleep(300);
3417
- observed = reads.listSessions(ws.id).find(s => s.id === sessionId)?.status ?? observed;
3418
- }
3419
- if (observed === 'working') {
3420
- return json(req, res, 502, {
3421
- ok: false,
3422
- strategy: result.strategy,
3423
- error: 'Conductor took the stop but the agent is still working. Try again, or stop it on your Mac.'
3424
- });
3425
- }
3426
- return json(req, res, 200, {
3427
- ok: true,
3428
- strategy: result.strategy,
3429
- session: reads.listSessions(ws.id).find(s => s.id === sessionId)
3430
- });
3431
- }
3432
- const restoreChatId = routeParam(routes.restoreChat, req.method, pathname);
3433
- if (restoreChatId) {
3434
- const body = JSON.parse((await readBody(req)) || '{}');
3435
- const ownerId = reads.sessionWorkspaceId(restoreChatId);
3436
- if (!ownerId)
3437
- return json(req, res, 404, { error: 'chat not found' });
3438
- if (body.workspaceId && body.workspaceId !== ownerId) {
3439
- return json(req, res, 409, { error: 'chat is not in that workspace' });
3440
- }
3441
- if (!reads.getWorkspace(ownerId)) {
3442
- return json(req, res, 409, { error: 'Restore this workspace in Conductor before restoring its tabs.' });
3443
- }
3444
- const result = await restoreChat(ownerId, restoreChatId, () => reads.getSession(restoreChatId) !== null);
3445
- if (!result.ok)
3446
- return json(req, res, 502, result);
3447
- return json(req, res, 200, { ...result, session: reads.getSession(restoreChatId) });
3448
- }
3449
- // DELETE /api/sessions/:id { workspaceId?, closeRunning? } — Conductor's
3450
- // reversible Close tab action (Command-W). A running chat gets the same
3451
- // explicit "Close anyway" gate as the desktop app.
3452
- const closeChatId = routeParam(routes.closeChat, req.method, pathname);
3453
- if (closeChatId) {
3454
- const sessionId = closeChatId;
3455
- const body = JSON.parse((await readBody(req)) || '{}');
3456
- const ownerId = reads.sessionWorkspaceId(sessionId);
3457
- if (!ownerId)
3458
- return json(req, res, 404, { error: 'chat not found' });
3459
- if (body.workspaceId && body.workspaceId !== ownerId) {
3460
- return json(req, res, 409, { error: 'chat is not in that workspace' });
3461
- }
3462
- const ws = reads.getWorkspace(ownerId);
3463
- if (!ws)
3464
- return json(req, res, 404, { error: 'workspace for session not found' });
3465
- const before = reads.listSessions(ws.id);
3466
- const session = before.find(s => s.id === sessionId);
3467
- const visibleActiveSession = () => {
3468
- const visible = reads.listSessions(ws.id);
3469
- const activeId = reads.getWorkspace(ws.id)?.active_session_id;
3470
- return visible.some(s => s.id === activeId) ? (activeId ?? null) : (visible[0]?.id ?? null);
3471
- };
3472
- // Closing is a soft delete. A repeat whose first response was lost is already
3473
- // in the requested state, so it is success rather than a stale-link error.
3474
- if (!session) {
3475
- return json(req, res, 200, {
3476
- ok: true,
3477
- alreadyClosed: true,
3478
- activeSessionId: visibleActiveSession()
3479
- });
3480
- }
3481
- if ((session.status === 'working' || session.background_tasks.length > 0) && body.closeRunning !== true) {
3482
- return json(req, res, 409, {
3483
- ok: false,
3484
- agentRunning: true,
3485
- error: 'The agent is still working in this chat. Confirm closing it anyway.'
3486
- });
3487
- }
3488
- const located = locateChat(ws, sessionId);
3489
- if ('error' in located)
3490
- return json(req, res, 409, { error: located.error });
3491
- const result = await closeChat({ workspace: ws, sessionId, tab: located.tab }, body.closeRunning === true);
3492
- if (!result.ok) {
3493
- // A turn can start after the status read above. The script dismisses
3494
- // Conductor's surprise dialog instead of accepting it, and this sends the
3495
- // caller back through the same explicit confirmation path.
3496
- if (body.closeRunning !== true && result.error?.includes('needs confirmation')) {
3497
- return json(req, res, 409, {
3498
- ok: false,
3499
- agentRunning: true,
3500
- error: 'The agent is still working in this chat. Confirm closing it anyway.'
3501
- });
3502
- }
3503
- return json(req, res, 502, result);
3504
- }
3505
- // Command-W is fire-and-forget. The durable receipt is the same flag all tab
3506
- // reads filter on: this id disappearing from listSessions means Conductor set
3507
- // sessions.is_hidden, not merely that a keystroke happened.
3508
- let visible = true;
3509
- for (let i = 0; i < 20 && visible; i++) {
3510
- await sleep(300);
3511
- visible = reads.listSessions(ws.id).some(s => s.id === sessionId);
3512
- }
3513
- if (visible) {
3514
- return json(req, res, 502, {
3515
- ok: false,
3516
- strategy: result.strategy,
3517
- error: 'Conductor took the close but the chat tab is still open. Try again, or close it on your Mac.'
3518
- });
3519
- }
3520
- return json(req, res, 200, {
3521
- ok: true,
3522
- strategy: result.strategy,
3523
- activeSessionId: visibleActiveSession()
3524
- });
3525
- }
3526
- // POST /api/sessions/:id/attachments?workspaceId= — raw bytes from the phone.
3527
- // Conductor derives an attachment from this on-disk layout plus the composer
3528
- // token, so its own database stays read-only from this relay's point of view.
3529
- const uploadTo = routeParam(routes.uploadAttachment, req.method, pathname);
3530
- if (uploadTo) {
3531
- const sessionId = uploadTo;
3532
- const ownerId = reads.sessionWorkspaceId(sessionId);
3533
- const workspaceId = url.searchParams.get('workspaceId') ?? ownerId;
3534
- const ws = workspaceId ? reads.getWorkspace(workspaceId) : null;
3535
- if (!ws)
3536
- return json(req, res, 404, { error: 'workspace for session not found' });
3537
- if (!ownerId || ownerId !== ws.id)
3538
- return json(req, res, 409, { error: 'chat is not in that workspace' });
3539
- if (!ws.worktree)
3540
- return json(req, res, 409, { error: 'worktree path unresolved' });
3541
- const name = attachmentHeaderName(req);
3542
- if (!name)
3543
- return json(req, res, 400, { error: 'missing attachment name' });
3544
- const bytes = await readAttachmentBody(req);
3545
- if (!bytes.length)
3546
- return json(req, res, 400, { error: 'empty attachment' });
3547
- const attachment = writeAttachment(ws.worktree, name, bytes);
3548
- return json(req, res, 200, {
3549
- ok: true,
3550
- attachment: {
3551
- name: attachment.name,
3552
- path: attachment.relPath,
3553
- bytes: attachment.bytes,
3554
- token: attachment.token
3555
- }
3556
- });
3557
- }
3558
- const delegateFrom = routeParam(routes.delegateTask, req.method, pathname);
3559
- if (delegateFrom) {
3560
- if (!orchestration.writable) {
3561
- throw new WorkflowCoordinatorError('workflow_incompatible_relay', orchestrationUnavailableReason(), {
3562
- status: 409
3563
- });
3564
- }
3565
- let body;
3566
- try {
3567
- body = JSON.parse((await readBody(req)) || '{}');
3568
- }
3569
- catch {
3570
- return json(req, res, 400, {
3571
- ok: false,
3572
- error: { code: 'invalid_request', message: 'delegation must be valid JSON', retryable: false }
3573
- });
3574
- }
3575
- const result = acceptDelegation(delegateFrom, body, {
3576
- ownsSession: sessionId => workflowOwningSession(sessionId) !== null,
3577
- sessionWorkspaceId: sessionId => reads.sessionWorkspaceId(sessionId),
3578
- getSession: sessionId => reads.getSession(sessionId),
3579
- getWorkspace: workspaceId => reads.getWorkspace(workspaceId),
3580
- getMessages: sessionId => reads.getMessages(sessionId).entries,
3581
- readRoles: () => roleStore.read(),
3582
- models: () => modelCache.list(),
3583
- enqueue: (workspace, job) => {
3584
- const store = delegationStore(workspace);
3585
- if (!store)
3586
- throw new Error('worktree path unresolved');
3587
- delegationQueue.enqueue(store, job);
3588
- }
3589
- });
3590
- return json(req, res, result.ok ? 202 : delegationHttpStatus(result.error), result);
3591
- }
3592
- // POST /api/sessions/:id/prompt { text, agent? } — ordinary staged settings
3593
- // are applied before the prompt so the two cannot come apart.
3594
- const promptTo = routeParam(routes.sendPrompt, req.method, pathname);
3595
- if (promptTo) {
3596
- const sessionId = promptTo;
3597
- const body = JSON.parse((await readBody(req)) || '{}');
3598
- if ('workflow' in body) {
3599
- return json(req, res, 400, { error: 'Workflow starts through POST /api/workflows.' });
3600
- }
3601
- if (body.text !== undefined && typeof body.text !== 'string') {
3602
- return json(req, res, 400, { error: 'prompt must be a string' });
3603
- }
3604
- const rawText = (body.text ?? '').trim();
3605
- if (!rawText)
3606
- return json(req, res, 400, { error: 'empty prompt' });
3607
- if (body.agent !== undefined && (!body.agent || typeof body.agent !== 'object' || Array.isArray(body.agent))) {
3608
- return json(req, res, 400, { error: 'agent must be a settings object' });
3609
- }
3610
- const requestedAgent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
3611
- const frozen = workflowFrozenError(sessionId);
3612
- if (frozen &&
3613
- requestedAgent &&
3614
- (requestedAgent.model !== undefined ||
3615
- requestedAgent.effort !== undefined ||
3616
- requestedAgent.fast !== undefined)) {
3617
- return json(req, res, 409, frozen);
3618
- }
3619
- const ws = body.workspaceId
3620
- ? reads.getWorkspace(body.workspaceId)
3621
- : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
3622
- if (!ws)
3623
- return json(req, res, 404, { error: 'workspace for session not found' });
3624
- // One deadline for the whole request: settings eat into the send's budget
3625
- // rather than extending it past what the phone said it would wait.
3626
- const deadline = Date.now() + sendBudget(req);
3627
- const queue = body.queue === true;
3628
- if (requestedAgent?.effort && !EFFORT_LABELS[requestedAgent.effort]) {
3629
- return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
3630
- }
3631
- // One prompt per intent (src/sendonce.ts). Everything that can *say something
3632
- // to Conductor* sits inside, so an answer the phone never heard is replayed
3633
- // rather than re-performed — including the parked branches, since parking the
3634
- // same intent twice queues the same prompt twice for the unlock.
3635
- if (body.clientId && sendOnce.recall(body.clientId)) {
3636
- console.info(`[relay] send to ${ws.branch ?? ws.id} already delivered for this tap — answering, not resending`);
3637
- }
3638
- const answer = await sendOnce.run(body.clientId, async () => {
3639
- const text = rawText;
3640
- const agent = requestedAgent;
3641
- // A failed first-prompt entry offers the same Retry button as an ordinary
3642
- // prompt. If staging had been the failure, put its files in place before
3643
- // that retry reaches the attachment tokens.
3644
- const first = firstPrompts.get(ws.id);
3645
- if (first?.attachmentIds?.length && first.text === text) {
3646
- if (!ws.worktree)
3647
- return {
3648
- status: 409,
3649
- body: { ok: false, strategy: actuator.name, error: 'worktree path unresolved' }
3650
- };
3651
- try {
3652
- materializeStagedAttachments(STAGED_ATTACHMENTS_DIR, ws.worktree, first.attachmentIds);
3653
- }
3654
- catch (err) {
3655
- return {
3656
- status: 409,
3657
- body: {
3658
- ok: false,
3659
- strategy: actuator.name,
3660
- error: err instanceof Error ? err.message : 'the attached files could not be copied'
3661
- }
3662
- };
3663
- }
3664
- }
3665
- if (agent) {
3666
- const applied = await applyAgentPatch(ws, sessionId, agent);
3667
- if (!applied.ok) {
3668
- if (lockBlocked(applied.error)) {
3669
- const queued = parkedPrompts.park(ws.id, sessionId, text, agent, queue);
3670
- return {
3671
- status: 202,
3672
- body: { ok: false, parked: true, queued, strategy: actuator.name, error: PARKED_ERROR }
3673
- };
3674
- }
3675
- return { status: 502, body: { ok: false, strategy: actuator.name, error: applied.error } };
3676
- }
3677
- }
3678
- // Retries live inside deliverPrompt, confirmed against the transcript each time,
3679
- // and inside the deadline this phone told us it would wait.
3680
- const result = await deliverPrompt(ws, sessionId, text, deadline - Date.now(), queue);
3681
- if (result.ok) {
3682
- // Whatever a queue was still holding has now been said by hand — the first
3683
- // prompt (including a failed entry retried from the chat), and any parked
3684
- // copy of this exact text, which delivering again would double.
3685
- firstPrompts.forget(ws.id);
3686
- parkedPrompts.forgetDelivered(sessionId, text);
3687
- return { status: 200, body: result };
3688
- }
3689
- if (lockBlocked(result.error)) {
3690
- // Settings (if any) already stuck, so the entry parks without them.
3691
- const queued = parkedPrompts.park(ws.id, sessionId, text, undefined, queue);
3692
- return {
3693
- status: 202,
3694
- body: { ok: false, parked: true, queued, strategy: result.strategy, error: PARKED_ERROR }
3695
- };
3696
- }
3697
- return { status: 502, body: result };
3698
- });
3699
- return json(req, res, answer.status, answer.body);
3700
- }
3701
- // POST /api/sessions/:id/split
3702
- // { prompt?, includeThinking?, includeTools?, throughRowid?, onlyRowid?, destination? }
3703
- //
3704
- // Conductor's own tab fork resumes the agent's real session. This copies the
3705
- // conversation instead, as a Conductor attachment, which is the cut that survives
3706
- // being read by a *different* agent: prose and reasoning, no tool churn. Its
3707
- // destination can be another tab over the same files, or a new workspace whose
3708
- // Git layers are restored from the source's current worktree snapshot.
3709
- // Two reasons it exists at all. A tangent asked inside a running chat leaves three
3710
- // conversations interleaved in one tab, which reads badly for everyone afterwards;
3711
- // and Conductor's fork lives on a hover menu over one message, which an agent
3712
- // cannot reach and which the relay would have to find by walking a transcript that
3713
- // gets more expensive the longer the chat is.
3714
- //
3715
- // It stops before sending. The composed prompt goes out through the ordinary send
3716
- // route so it inherits the retry loop, the transcript confirm and the parked queue.
3717
- // For a tab, that also keeps ⌘T plus a send from becoming two UI turns inside one
3718
- // request (28s + 55s against the MCP client's 75s); for a workspace it leaves the
3719
- // staged handoff as the same editable draft the phone already presents for a tab.
3720
- const splitFrom = routeParam(routes.splitChat, req.method, pathname);
3721
- if (splitFrom) {
3722
- const sessionId = splitFrom;
3723
- const body = JSON.parse((await readBody(req)) || '{}');
3724
- // `active_session_id` is how every other route resolves this, and it would only
3725
- // ever find the tab on screen. Splitting a chat you are not looking at is the
3726
- // normal case here, so the session's own column decides.
3727
- const workspaceId = body.workspaceId ?? reads.sessionWorkspaceId(sessionId);
3728
- const ws = workspaceId ? reads.getWorkspace(workspaceId) : null;
3729
- if (!ws)
3730
- return json(req, res, 404, { error: 'workspace for session not found' });
3731
- if (!ws.worktree)
3732
- return json(req, res, 409, { error: 'worktree path unresolved' });
3733
- const source = reads.listSessions(ws.id).find(s => s.id === sessionId);
3734
- if (!source)
3735
- return json(req, res, 404, { error: 'chat not found in that workspace' });
3736
- const destination = body.destination ?? 'chat';
3737
- if (destination !== 'chat' && destination !== 'workspace') {
3738
- return json(req, res, 400, { error: 'destination must be chat or workspace' });
3739
- }
3740
- const format = { thinking: body.includeThinking !== false, tools: body.includeTools === true };
3741
- const { entries } = reads.getMessages(sessionId);
3742
- const through = body.throughRowid;
3743
- const only = body.onlyRowid;
3744
- if (through !== undefined && (!Number.isSafeInteger(through) || through < 1)) {
3745
- return json(req, res, 400, { error: 'throughRowid must be a positive integer' });
3746
- }
3747
- if (only !== undefined && (!Number.isSafeInteger(only) || only < 1)) {
3748
- return json(req, res, 400, { error: 'onlyRowid must be a positive integer' });
3749
- }
3750
- if (through !== undefined && only !== undefined) {
3751
- return json(req, res, 400, { error: 'throughRowid and onlyRowid cannot be combined' });
3752
- }
3753
- const cut = only !== undefined
3754
- ? transcriptMessage(entries, only)
3755
- : through === undefined
3756
- ? { entries, earlier: 0, later: 0 }
3757
- : transcriptThrough(entries, through);
3758
- if (!cut)
3759
- return json(req, res, 409, { error: 'that message is not in this chat' });
3760
- const rendered = renderTranscript(cut.entries, format);
3761
- const elided = { ...rendered.elided, earlier: 'earlier' in cut ? cut.earlier : 0, later: cut.later };
3762
- if (!rendered.kept)
3763
- return json(req, res, 409, { error: 'that chat has nothing to copy yet' });
3764
- // Conductor's own name for a copied transcript, so the chip reads the same as one
3765
- // saved by hand. The header states the cut, because a transcript that silently
3766
- // drops half a chat is worse than one that admits to it.
3767
- const title = source.title?.trim() || 'chat';
3768
- const carried = [`thinking ${format.thinking ? 'included' : 'omitted'}`];
3769
- carried.push(`tool calls ${format.tools ? 'included' : 'omitted'}`);
3770
- const stops = only !== undefined
3771
- ? ['The copy contains only the selected source message; all earlier and later messages are omitted.']
3772
- : cut.later
3773
- ? [
3774
- `The copy stops partway through: ${cut.later} later ${cut.later === 1 ? 'entry is' : 'entries are'} not in it.`
3775
- ]
3776
- : [];
3777
- const header = [
3778
- `# Transcript of ${title}`,
3779
- '',
3780
- `${[ws.repo_name, ws.branch].filter(Boolean).join(' · ')}`,
3781
- `Copied from the Conductor chat \`${sessionId}\` by conductor-remote. ${carried.join(', ')}.`,
3782
- ...stops,
3783
- '',
3784
- ''
3785
- ].join('\n');
3786
- const transcript = header + rendered.text;
3787
- if (destination === 'workspace') {
3788
- if (!(ws.repo_name && ws.repo_root)) {
3789
- return json(req, res, 409, { error: 'the source workspace has no repository checkout to fork' });
3790
- }
3791
- let snapshot;
3792
- try {
3793
- snapshot = await captureForkWorkspace(ws.worktree);
3794
- }
3795
- catch (err) {
3796
- const reason = err instanceof Error ? err.message : 'Git could not capture the worktree';
3797
- return json(req, res, 502, { error: `Could not snapshot the source workspace: ${reason}` });
3798
- }
3799
- let staged;
3800
- let materialized = false;
3801
- let created;
3802
- try {
3803
- staged = stageAttachment(STAGED_ATTACHMENTS_DIR, `Transcript of ${title}.md`, Buffer.from(transcript));
3804
- const creation = await createWorkspaceAndRead('', ws.repo_root, ws.repo_name);
3805
- if (!creation.result.ok)
3806
- return json(req, res, 502, creation.result);
3807
- created = creation.created;
3808
- if (!created) {
3809
- return json(req, res, 502, {
3810
- error: 'Conductor didn’t create the fork workspace — check it’s running and not showing a dialog.'
3811
- });
3812
- }
3813
- // The DB row can precede `.git` by a tick. Install the snapshot at the
3814
- // first verified worktree path, before Conductor starts the new agent.
3815
- let target = reads.getWorkspace(created.id) ?? created;
3816
- for (let attempt = 0; attempt < 20 && !target.worktree; attempt++) {
3817
- await sleep(250);
3818
- target = reads.getWorkspace(created.id) ?? target;
3819
- }
3820
- if (!target.worktree)
3821
- throw new Error('the new workspace worktree path never became available');
3822
- await materializeForkWorkspace(snapshot, target.worktree);
3823
- materializeStagedAttachments(STAGED_ATTACHMENTS_DIR, target.worktree, [staged.stageId]);
3824
- materialized = true;
3825
- discardStagedAttachment(STAGED_ATTACHMENTS_DIR, staged.stageId);
3826
- let destinationSession = reads.listSessions(created.id)[0];
3827
- for (let attempt = 0; attempt < 12 && !destinationSession; attempt++) {
3828
- await sleep(250);
3829
- destinationSession = reads.listSessions(created.id)[0];
3830
- }
3831
- return json(req, res, 200, {
3832
- ok: true,
3833
- destination,
3834
- sessionId: destinationSession?.id ?? null,
3835
- workspaceId: created.id,
3836
- text: attachmentPrompt(staged.token, body.prompt),
3837
- attachment: {
3838
- name: staged.name,
3839
- path: staged.path,
3840
- bytes: staged.bytes,
3841
- kept: rendered.kept,
3842
- elided
3843
- }
3844
- });
3845
- }
3846
- catch (err) {
3847
- const reason = err instanceof Error ? err.message : 'the current files could not be copied';
3848
- return json(req, res, 502, {
3849
- error: created
3850
- ? `Workspace ${created.id} was created, but its code fork failed: ${reason}`
3851
- : `Could not create the code fork: ${reason}`
3852
- });
3853
- }
3854
- finally {
3855
- if (staged && !materialized)
3856
- discardStagedAttachment(STAGED_ATTACHMENTS_DIR, staged.stageId);
3857
- await releaseForkWorkspace(snapshot).catch(err => {
3858
- console.warn(`[relay] could not release fork snapshot ${snapshot.ref}: ${err instanceof Error ? err.message : err}`);
3859
- });
3860
- }
3861
- }
3862
- const attachment = writeAttachment(ws.worktree, `Transcript of ${title}.md`, transcript);
3863
- const opened = await openChat(ws);
3864
- if ('error' in opened) {
3865
- return json(req, res, 502, {
3866
- ...opened.result,
3867
- destination,
3868
- attachment: { ...attachment, ...rendered, elided }
3869
- });
3870
- }
3871
- // The token is what Conductor turns into the attachment chip and supplies to the
3872
- // receiving agent. Do not repeat `attachment.relPath` in prose: that renders a
3873
- // second link to the same transcript in the new chat.
3874
- const text = attachmentPrompt(attachment.token, body.prompt);
3875
- return json(req, res, 200, {
3876
- ok: true,
3877
- destination,
3878
- sessionId: opened.sessionId,
3879
- workspaceId: ws.id,
3880
- text,
3881
- attachment: {
3882
- name: attachment.name,
3883
- path: attachment.relPath,
3884
- bytes: attachment.bytes,
3885
- kept: rendered.kept,
3886
- elided
3887
- }
3888
- });
3889
- }
3890
- // DELETE /api/workspaces/:id/prompt — dismiss an undelivered first prompt
3891
- const forgetFirst = routeParam(routes.dismissFirstPrompt, req.method, pathname);
3892
- if (forgetFirst) {
3893
- const workspaceId = forgetFirst;
3894
- if (!firstPrompts.forget(workspaceId))
3895
- return json(req, res, 404, { error: 'no pending prompt' });
3896
- return json(req, res, 200, { ok: true });
3897
- }
3898
- // DELETE /api/sessions/:id/prompt — dismiss whatever is parked for this chat
3899
- const forgetParked = routeParam(routes.dismissParkedPrompt, req.method, pathname);
3900
- if (forgetParked) {
3901
- const sessionId = forgetParked;
3902
- if (!parkedPrompts.forgetSession(sessionId))
3903
- return json(req, res, 404, { error: 'no parked prompt' });
3904
- return json(req, res, 200, { ok: true });
3905
- }
3906
- return json(req, res, 404, { error: 'no route', pathname });
3907
- }
3908
- catch (err) {
3909
- if (err instanceof PayloadTooLargeError)
3910
- return json(req, res, 413, { error: err.message });
3911
- const workflowError = workflowHttpError(err);
3912
- if (workflowError) {
3913
- console.warn(`[workflow] ${req.method} ${pathname}: ${workflowError.error.code}: ${workflowError.error.message}`);
3914
- return json(req, res, workflowError.status, { ok: false, error: workflowError.error });
3915
- }
3916
- // A refused UI turn is not a server fault and a retry is the right move, so it gets
3917
- // 503 + Retry-After rather than a 500 that reads as "the relay is broken".
3918
- if (err instanceof UiBusyError) {
3919
- res.setHeader('retry-after', '15');
3920
- return json(req, res, 503, { error: err.message, busy: true, queue: uiQueueDepth() });
3921
- }
3922
- // Log the detail locally; don't reflect internals (paths, stack strings) back over the wire.
3923
- console.error(`[relay] ${req.method} ${pathname} failed:`, err);
3924
- return json(req, res, 500, { error: 'internal error' });
3925
- }
3926
- });
3927
- });
1
+ import { startAutoUpdate } from "./host/autoupdate.js";
2
+ import { startFunnelWatchdog } from "./host/funnel-watchdog.js";
3
+ import { watchNoSleepExpiry } from "./host/nosleep.js";
4
+ import { driftWarningLines, tailscaleBin } from "./host/tailscale.js";
5
+ import { createRelayServer } from "./http/router.js";
6
+ import { createRelayServices } from "./http/services.js";
7
+ import { startNotifier } from "./notifications/notify.js";
8
+ import { voicePort } from "./voice/config.js";
9
+ const services = createRelayServices();
10
+ const { cfg, voiceServer, voiceBroker, voiceHistory, orchestration, relayIdentity, recoverUiLease, wakeWorkflows, actuator, firstPrompts, sweepStagedAttachments, STAGED_ATTACHMENT_SWEEP_MS, parkedPrompts, delegationQueue, liveDelegationStores, devServers, reads, sessionPoller } = services;
11
+ const server = createRelayServer(services);
3928
12
  server.listen(cfg.port, cfg.host, () => {
3929
13
  voiceServer.listen(voicePort(), '127.0.0.1', () => {
3930
14
  console.info(` voice: 127.0.0.1:${voicePort()}${voiceBroker ? '' : ' (waiting for OpenAI config)'}`);
@@ -3980,10 +64,10 @@ server.listen(cfg.port, cfg.host, () => {
3980
64
  // persisted Serve mapping. Rebuild bridges for dev servers that are still up,
3981
65
  // and remove this relay's stale mappings for ones that are not.
3982
66
  void devServers.restore();
3983
- // Keep the managed global daemon current — no-ops for dev checkouts / unmanaged runs (see autoupdate.ts).
67
+ // Keep the managed global daemon current — no-ops for dev checkouts / unmanaged runs (see src/host/autoupdate.ts).
3984
68
  startAutoUpdate();
3985
69
  // Keep the phone's public URL reachable — re-registers Funnel when its ingress goes stale after a
3986
- // network change. No-ops unless managed + public (Funnel) posture (see funnel-watchdog.ts).
70
+ // network change. No-ops unless managed + public (Funnel) posture (see src/host/funnel-watchdog.ts).
3987
71
  startFunnelWatchdog();
3988
72
  // One base DB read fans out to notification and orchestration listeners. Push can
3989
73
  // be disabled or have zero devices without stopping the clock delegated jobs need.
@@ -3991,6 +75,6 @@ server.listen(cfg.port, cfg.host, () => {
3991
75
  sessionPoller.start();
3992
76
  // Watch armed keep-awake windows for their recorded expiry: the helper's restore only
3993
77
  // re-allows sleep, so a lid still shut at expiry needs the relay's `pmset sleepnow`
3994
- // (see nosleep.ts). Also picks a window back up after the relay's own restarts.
78
+ // (see src/host/nosleep.ts). Also picks a window back up after the relay's own restarts.
3995
79
  watchNoSleepExpiry();
3996
80
  });