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.
- package/README.md +10 -8
- package/dist/assets/{PierrePatch-BPp5Cf-p.js → PierrePatch-D4YbKD8Q.js} +1 -1
- package/dist/assets/index-BRzsSW8g.css +1 -0
- package/dist/assets/index-BaS6qXUW.js +65 -0
- package/dist/index.html +2 -2
- package/dist/push-sw.js +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/scripts/nosleep-setup.js +2 -2
- package/dist-node/scripts/nosleep.js +3 -3
- package/dist-node/scripts/service/commands.js +32 -0
- package/dist-node/scripts/service/configuration.js +205 -0
- package/dist-node/scripts/service/environment.js +59 -0
- package/dist-node/scripts/service/flags.js +45 -0
- package/dist-node/scripts/service/installation.js +91 -0
- package/dist-node/scripts/service/launch-agent.js +134 -0
- package/dist-node/scripts/service/network.js +244 -0
- package/dist-node/scripts/service/presentation.js +101 -0
- package/dist-node/scripts/service/voice.js +113 -0
- package/dist-node/scripts/service.js +5 -980
- package/dist-node/src/{agent-config.js → agents/agent-config.js} +1 -1
- package/dist-node/src/{model-cache.js → agents/model-cache.js} +1 -1
- package/dist-node/src/{roles.js → agents/roles.js} +3 -3
- package/dist-node/src/db.js +1 -1
- package/dist-node/src/{parked.js → delivery/parked.js} +1 -1
- package/dist-node/src/{dev-server.js → dev-server/controller.js} +8 -291
- package/dist-node/src/dev-server/ports.js +63 -0
- package/dist-node/src/dev-server/processes.js +73 -0
- package/dist-node/src/dev-server/proxy.js +158 -0
- package/dist-node/src/{run-activity.js → dev-server/run-activity.js} +2 -2
- package/dist-node/src/dev-server/types.js +1 -0
- package/dist-node/src/{file-preview.js → files/file-preview.js} +1 -1
- package/dist-node/src/{change-stats.js → git/change-stats.js} +1 -1
- package/dist-node/src/{git.js → git/diff.js} +1 -1
- package/dist-node/src/{autoupdate.js → host/autoupdate.js} +1 -1
- package/dist-node/src/{funnel-watchdog.js → host/funnel-watchdog.js} +4 -4
- package/dist-node/src/{logbuf.js → host/logbuf.js} +1 -1
- package/dist-node/src/{nosleep-helper.js → host/nosleep-helper.js} +1 -1
- package/dist-node/src/{tailscale.js → host/tailscale.js} +1 -1
- package/dist-node/src/http/router-types.js +2 -0
- package/dist-node/src/http/router.js +78 -0
- package/dist-node/src/http/routes/create-workspace.js +90 -0
- package/dist-node/src/http/routes/files.js +53 -0
- package/dist-node/src/http/routes/prompts.js +363 -0
- package/dist-node/src/http/routes/sessions.js +279 -0
- package/dist-node/src/http/routes/state.js +186 -0
- package/dist-node/src/http/routes/system.js +220 -0
- package/dist-node/src/http/routes/voice.js +117 -0
- package/dist-node/src/http/routes/workflows.js +248 -0
- package/dist-node/src/http/routes/workspaces.js +267 -0
- package/dist-node/src/http/services/base.js +252 -0
- package/dist-node/src/http/services/delegations.js +301 -0
- package/dist-node/src/http/services/delivery.js +433 -0
- package/dist-node/src/http/services/files.js +199 -0
- package/dist-node/src/http/services/mcp.js +94 -0
- package/dist-node/src/http/services/responses.js +120 -0
- package/dist-node/src/http/services/voice.js +147 -0
- package/dist-node/src/http/services/workflow-probes.js +215 -0
- package/dist-node/src/http/services/workflow-state.js +195 -0
- package/dist-node/src/http/services/workflow.js +366 -0
- package/dist-node/src/http/services.js +35 -0
- package/dist-node/src/mcp/arguments.js +14 -0
- package/dist-node/src/mcp/dispatcher.js +56 -0
- package/dist-node/src/mcp/formatters.js +110 -0
- package/dist-node/src/mcp/protocol.js +7 -0
- package/dist-node/src/mcp/registry.js +66 -0
- package/dist-node/src/mcp/tools/agent-options.js +122 -0
- package/dist-node/src/mcp/tools/chat-actions.js +194 -0
- package/dist-node/src/mcp/tools/chats.js +194 -0
- package/dist-node/src/mcp/tools/delegation.js +135 -0
- package/dist-node/src/mcp/tools/system.js +135 -0
- package/dist-node/src/mcp/tools/voice.js +136 -0
- package/dist-node/src/mcp/tools/workspaces.js +259 -0
- package/dist-node/src/mcp/types.js +1 -0
- package/dist-node/src/mcp.js +6 -4
- package/dist-node/src/{notify.js → notifications/notify.js} +5 -5
- package/dist-node/src/{webpush.js → notifications/webpush.js} +1 -1
- package/dist-node/src/orchestration/delegation/codec.js +201 -0
- package/dist-node/src/{delegation-intake.js → orchestration/delegation/intake.js} +2 -2
- package/dist-node/src/{delegation-prompt.js → orchestration/delegation/prompt.js} +1 -1
- package/dist-node/src/orchestration/delegation/queue.js +255 -0
- package/dist-node/src/orchestration/delegation/store.js +111 -0
- package/dist-node/src/orchestration/delegation/types.js +2 -0
- package/dist-node/src/orchestration/persistence/capabilities.js +109 -0
- package/dist-node/src/orchestration/persistence/codecs.js +148 -0
- package/dist-node/src/orchestration/persistence/connection.js +205 -0
- package/dist-node/src/orchestration/persistence/db.js +101 -0
- package/dist-node/src/orchestration/persistence/effect-recovery.js +227 -0
- package/dist-node/src/orchestration/persistence/effect-transitions.js +157 -0
- package/dist-node/src/orchestration/persistence/effects.js +109 -0
- package/dist-node/src/orchestration/persistence/errors.js +39 -0
- package/dist-node/src/orchestration/persistence/events.js +29 -0
- package/dist-node/src/orchestration/persistence/idempotency.js +82 -0
- package/dist-node/src/orchestration/persistence/job-attempts.js +114 -0
- package/dist-node/src/orchestration/persistence/jobs.js +159 -0
- package/dist-node/src/orchestration/persistence/projections.js +190 -0
- package/dist-node/src/orchestration/persistence/quarantine.js +46 -0
- package/dist-node/src/orchestration/persistence/records.js +115 -0
- package/dist-node/src/orchestration/persistence/relays.js +78 -0
- package/dist-node/src/orchestration/persistence/runs.js +156 -0
- package/dist-node/src/{orchestration-schema.js → orchestration/persistence/schema.js} +1 -1
- package/dist-node/src/orchestration/persistence/types.js +1 -0
- package/dist-node/src/orchestration/persistence/ui-lease.js +197 -0
- package/dist-node/src/orchestration/persistence/values.js +114 -0
- package/dist-node/src/orchestration/workflow/commands.js +309 -0
- package/dist-node/src/orchestration/workflow/coordinator.js +28 -0
- package/dist-node/src/orchestration/workflow/effect-recovery.js +125 -0
- package/dist-node/src/orchestration/workflow/effects.js +307 -0
- package/dist-node/src/orchestration/workflow/errors.js +13 -0
- package/dist-node/src/orchestration/workflow/helpers.js +154 -0
- package/dist-node/src/orchestration/workflow/job-dispatch.js +312 -0
- package/dist-node/src/orchestration/workflow/job-results.js +280 -0
- package/dist-node/src/{workflow.js → orchestration/workflow/prompts.js} +2 -2
- package/dist-node/src/orchestration/workflow/root.js +293 -0
- package/dist-node/src/orchestration/workflow/start.js +99 -0
- package/dist-node/src/orchestration/workflow/state.js +105 -0
- package/dist-node/src/orchestration/workflow/types.js +1 -0
- package/dist-node/src/orchestration/workflow/wake.js +164 -0
- package/dist-node/src/prefs.js +1 -1
- package/dist-node/src/{background-tasks.js → reads/background-tasks.js} +1 -1
- package/dist-node/src/reads/messages.js +362 -0
- package/dist-node/src/reads/repository.js +87 -0
- package/dist-node/src/reads/sessions.js +165 -0
- package/dist-node/src/reads/types.js +1 -0
- package/dist-node/src/reads/workspaces.js +230 -0
- package/dist-node/src/reads/worktrees.js +45 -0
- package/dist-node/src/routes.js +5 -5
- package/dist-node/src/{search.js → search/coordinator.js} +4 -4
- package/dist-node/src/{search-worker.js → search/worker.js} +3 -3
- package/dist-node/src/server.js +14 -3930
- package/dist-node/src/settings.js +1 -1
- package/dist-node/src/shared.js +130 -9
- package/dist-node/src/{transcript.js → transcript/parser.js} +4 -124
- package/dist-node/src/{tool-usage-worker.js → usage/tool-usage-worker.js} +1 -1
- package/dist-node/src/{tool-usage.js → usage/tool-usage.js} +1 -1
- package/dist-node/src/voice/brief.js +22 -50
- package/dist-node/src/voice/context.js +1 -1
- package/dist-node/src/voice/dates.js +38 -0
- package/dist-node/src/voice/history.js +14 -5
- package/dist-node/src/voice/prompt.js +10 -9
- package/dist-node/src/voice/recall.js +115 -0
- package/dist-node/src/{speech.js → voice/speech.js} +1 -1
- package/dist-node/src/voice/tools.js +92 -3
- package/dist-node/src/voice/webrtc.js +1 -1
- package/dist-node/src/wire.js +2 -2
- package/dist-node/src/writes/actuator.js +124 -0
- package/dist-node/src/writes/agent-options.js +151 -0
- package/dist-node/src/writes/applescript/agent-options.applescript +334 -0
- package/dist-node/src/writes/applescript/chat-targeting.applescript +149 -0
- package/dist-node/src/writes/applescript/composer.applescript +195 -0
- package/dist-node/src/writes/applescript/hotspot.applescript +155 -0
- package/dist-node/src/writes/applescript/lifecycle.applescript +266 -0
- package/dist-node/src/writes/applescript/run-tasks.applescript +208 -0
- package/dist-node/src/writes/applescript/source.js +52 -0
- package/dist-node/src/writes/applescript/window.applescript +436 -0
- package/dist-node/src/writes/applescript/workspace-status.applescript +177 -0
- package/dist-node/src/writes/applescript/workspace-targeting.applescript +359 -0
- package/dist-node/src/writes/chats.js +160 -0
- package/dist-node/src/writes/guards.js +77 -0
- package/dist-node/src/writes/runner.js +67 -0
- package/dist-node/src/{sidecar.js → writes/sidecar.js} +1 -1
- package/dist-node/src/writes/system.js +81 -0
- package/dist-node/src/writes/targeting.js +121 -0
- package/dist-node/src/writes/types.js +1 -0
- package/dist-node/src/writes/ui-lock.js +229 -0
- package/dist-node/src/writes/workspaces.js +233 -0
- package/docs/voice-setup.md +34 -12
- package/package.json +2 -2
- package/dist/assets/index-DNLdIfZ2.css +0 -1
- package/dist/assets/index-QJR7tn2I.js +0 -61
- package/dist-node/src/conductor.applescript +0 -2279
- package/dist-node/src/delegations.js +0 -557
- package/dist-node/src/mcp-tools.js +0 -1319
- package/dist-node/src/orchestration-db.js +0 -2186
- package/dist-node/src/reads.js +0 -787
- package/dist-node/src/workflow-coordinator.js +0 -2119
- package/dist-node/src/writes.js +0 -1223
- /package/dist-node/src/{conductor-settings.js → agents/conductor-settings.js} +0 -0
- /package/dist-node/src/{firstprompt.js → delivery/firstprompt.js} +0 -0
- /package/dist-node/src/{sendonce.js → delivery/sendonce.js} +0 -0
- /package/dist-node/src/{preview-urls.js → dev-server/preview-urls.js} +0 -0
- /package/dist-node/src/{run-configs.js → dev-server/run-configs.js} +0 -0
- /package/dist-node/src/{attachments.js → files/attachments.js} +0 -0
- /package/dist-node/src/{icons.js → files/icons.js} +0 -0
- /package/dist-node/src/{staged-attachments.js → files/staged-attachments.js} +0 -0
- /package/dist-node/src/{fork-workspace.js → git/fork-workspace.js} +0 -0
- /package/dist-node/src/{merge.js → git/merge.js} +0 -0
- /package/dist-node/src/{pr.js → git/pr.js} +0 -0
- /package/dist-node/src/{nosleep.js → host/nosleep.js} +0 -0
- /package/dist-node/src/{relay-processes.js → host/relay-processes.js} +0 -0
- /package/dist-node/src/{ui-lease-watchdog.js → host/ui-lease-watchdog.js} +0 -0
- /package/dist-node/src/{wifi.js → host/wifi.js} +0 -0
- /package/dist-node/src/{orchestration-schema.generated.js → orchestration/persistence/schema.generated.js} +0 -0
- /package/dist-node/src/{workflow-effect-runner.js → orchestration/workflow/effect-runner.js} +0 -0
- /package/dist-node/src/{workflow-http.js → orchestration/workflow/http.js} +0 -0
- /package/dist-node/src/{workflow-machine.js → orchestration/workflow/machine.js} +0 -0
- /package/dist-node/src/{session-poller.js → reads/session-poller.js} +0 -0
- /package/dist-node/src/{context-breakdown.js → transcript/context-breakdown.js} +0 -0
- /package/dist-node/src/{chat-cursor.js → transcript/cursor.js} +0 -0
- /package/dist-node/src/{plan-usage.js → usage/plan-usage.js} +0 -0
- /package/dist-node/src/{tool-usage-service.js → usage/tool-usage-service.js} +0 -0
package/dist-node/src/server.js
CHANGED
|
@@ -1,3930 +1,14 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
});
|