conductor-remote 1.106.3 → 1.108.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.
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import zlib from 'node:zlib';
7
7
  import { applyAgentConfig } from "./agent-config.js";
8
- import { attachmentPrompt, writeAttachment } from "./attachments.js";
8
+ import { ATTACHMENT_DIR, attachmentName, attachmentPrompt, attachmentToken, writeAttachment } from "./attachments.js";
9
9
  import { startAutoUpdate, updateStatus } from "./autoupdate.js";
10
10
  import { attachChangeStats } from "./change-stats.js";
11
11
  import { isDefaultEffortLevel, readDefaultEfforts, writeDefaultEfforts } from "./conductor-settings.js";
@@ -24,22 +24,25 @@ import { mergePr } from "./merge.js";
24
24
  import { ModelCache } from "./model-cache.js";
25
25
  import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
26
26
  import { chatRoute, noteViewing, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
27
+ import { IdempotencyConflictError, ORCHESTRATION_PROTOCOL_VERSION, OrchestrationDb, UiLeaseUnavailableError, WorkflowTransitionError } from "./orchestration-db.js";
27
28
  import { ParkedPromptQueue } from "./parked.js";
28
29
  import { PlanUsageService } from "./plan-usage.js";
29
30
  import { attachPrStatus } from "./pr.js";
30
31
  import { readPrefs, writePrefs } from "./prefs.js";
31
32
  import { Reads } from "./reads.js";
32
- import { decodeRoles, RoleStore, resolveRole, roleModelIssues } from "./roles.js";
33
+ import { currentProcessStartIdentity, incompatibleRelayProcesses, listUiCapableRelayProcesses, processIdentityAlive } from "./relay-processes.js";
34
+ import { decodeRoles, RoleStore, roleModelIssues } from "./roles.js";
33
35
  import { isRoute, routeParam, routes } from "./routes.js";
34
36
  import { attachRunActivity } from "./run-activity.js";
35
37
  import { foldHits, queryTokens, SearchIndex } from "./search.js";
36
38
  import { SendOnce } from "./sendonce.js";
37
39
  import { SessionPoller } from "./session-poller.js";
38
40
  import { readSettings, writeSettings } from "./settings.js";
39
- import { isOpenAIRealtimeVoice, isVoiceLanguage, OPENAI_REALTIME_VOICES, responseErrorMessage, VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
41
+ import { attachmentTokens, isOpenAIRealtimeVoice, isVoiceLanguage, modelLabel, OPENAI_REALTIME_VOICES, responseErrorMessage, scrubWorkflowSecrets, timestampMs, VIEWING_HEADER, withoutClientWindowEvidence, withoutWindowEvidence, workspaceTitle } from "./shared.js";
40
42
  import { discardStagedAttachment, materializeStagedAttachments, pruneStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
41
43
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
42
44
  import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
45
+ import { recoverExpiredUiLease } from "./ui-lease-watchdog.js";
43
46
  import { VoiceBriefBoard } from "./voice/brief.js";
44
47
  import { VoiceBroker } from "./voice/broker.js";
45
48
  import { openAIOriginForSipHost, readVoiceConfig, voicePort } from "./voice/config.js";
@@ -50,8 +53,10 @@ import { mintSipTicket, missingTicketConfig } from "./voice/ticket.js";
50
53
  import { createVoiceTools } from "./voice/tools.js";
51
54
  import { createWebRtcCall, MAX_SDP_CHARS } from "./voice/webrtc.js";
52
55
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
53
- import { prepareWorkflowRoot, WORKFLOW_ROOT_ROLE } from "./workflow.js";
54
- import { archiveWorkspace, closeChat, continueWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiBusy, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
56
+ import { WorkflowCoordinator, WorkflowCoordinatorError } from "./workflow-coordinator.js";
57
+ import { parseConfirmUiStableRequest, parseStartWorkflowRequest, parseWorkflowAdoptRequest, parseWorkflowCompleteRequest, parseWorkflowDelegateRequest, parseWorkflowReplayRequest, parseWorkflowRetryRequest, WorkflowRequestError, workflowClientIsMcp } from "./workflow-http.js";
58
+ import { WorkflowGuardError } from "./workflow-machine.js";
59
+ import { archiveWorkspace, closeChat, configureSharedUiLeaseProvider, continueWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiBusy, uiQueueDepth, uiTurn, WORKSPACE_STATUS_LABELS, withGatedUiCommand, withUiPriority } from "./writes.js";
55
60
  // Before anything that logs: from here on every console line is also kept in memory for
56
61
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
57
62
  installLogCapture();
@@ -71,12 +76,52 @@ if (cfg.devWebPort !== undefined && process.env.CONDUCTOR_WORKSPACE_ID) {
71
76
  devServers.advertisePreviewUrls(process.env.CONDUCTOR_WORKSPACE_ID, [{ name: 'Conductor Remote', url: preview.href }]);
72
77
  }
73
78
  const STAGED_ATTACHMENTS_DIR = path.join(stateDir(), 'attachment-staging');
79
+ /** Attachment IDs are already encoded in the immutable objective's Conductor tokens. */
80
+ function stagedAttachmentIdsInObjective(objective) {
81
+ return [
82
+ ...new Set(attachmentTokens(objective).flatMap(token => {
83
+ const match = /^\.context\/attachments\/([A-Za-z0-9]{6})\//.exec(token.path);
84
+ return match ? [match[1]] : [];
85
+ }))
86
+ ];
87
+ }
74
88
  // Picker labels cannot be reconstructed from `sessions.model`, so they belong to
75
89
  // relay state alongside the prompt queues. This lets a brand-new workspace choose
76
90
  // from a list before Conductor has created its first chat.
77
91
  const modelCache = new ModelCache(path.join(stateDir(), 'model-cache.json'));
78
92
  const planUsage = new PlanUsageService();
79
93
  const roleStore = new RoleStore(path.join(stateDir(), 'roles.json'));
94
+ const orchestration = new OrchestrationDb(path.join(stateDir(), 'orchestration.db'), {
95
+ processProbe: processIdentityAlive
96
+ });
97
+ const relayIdentity = {
98
+ instanceId: crypto.randomUUID(),
99
+ pid: process.pid,
100
+ processStartedAt: currentProcessStartIdentity(),
101
+ protocolVersion: ORCHESTRATION_PROTOCOL_VERSION
102
+ };
103
+ function orchestrationUnavailableReason() {
104
+ return orchestration.schemaWarning ?? `orchestration schema ${orchestration.schemaVersion} is unsupported`;
105
+ }
106
+ if (orchestration.writable) {
107
+ orchestration.registerRelayInstance({ ...relayIdentity, canDriveUi: true });
108
+ const sharedUiLeases = orchestration.createSharedUiLeaseProvider(relayIdentity, { leaseMs: 2 * 60_000 });
109
+ configureSharedUiLeaseProvider({
110
+ acquire: async (request) => {
111
+ try {
112
+ return await sharedUiLeases.acquire(request);
113
+ }
114
+ catch (error) {
115
+ if (error instanceof UiLeaseUnavailableError)
116
+ throw new UiBusyError(0);
117
+ throw error;
118
+ }
119
+ }
120
+ });
121
+ }
122
+ else {
123
+ console.warn(`[relay] ${orchestrationUnavailableReason()}; Workflow is disabled until this relay is upgraded`);
124
+ }
80
125
  /** One store object per live worktree, so the queue never registers a path twice. */
81
126
  const delegationStores = new Map();
82
127
  function delegationStore(ws) {
@@ -95,7 +140,7 @@ function liveDelegationStores() {
95
140
  return store ? [store] : [];
96
141
  });
97
142
  }
98
- /** Tag a pristine workflow root without ever touching Conductor's Plan control. */
143
+ /** Preserve role identity for already-persisted pre-coordinator Workflow prompts. */
99
144
  function assignWorkflowRoot(ws, sessionId, role, assignedAt) {
100
145
  const store = delegationStore(ws);
101
146
  if (!store)
@@ -106,12 +151,6 @@ function assignWorkflowRoot(ws, sessionId, role, assignedAt) {
106
151
  const session = reads.getSession(sessionId);
107
152
  if (!session)
108
153
  return { ok: false, error: 'the workflow root chat is unavailable' };
109
- if (session.permission_mode === 'plan') {
110
- return {
111
- ok: false,
112
- error: 'the workflow root inherited Plan mode; switch it to ordinary chat mode and retry'
113
- };
114
- }
115
154
  const assignments = store.sessionRoles();
116
155
  if (assignments.warning)
117
156
  return { ok: false, error: `cannot assign the workflow root: ${assignments.warning}` };
@@ -244,6 +283,51 @@ const voiceServer = createVoiceServer({
244
283
  // the phone opening the app and the send landing.
245
284
  setRestartGuard(() => !reads.listWorkspaces().some(w => w.session_status === 'working'));
246
285
  const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
286
+ let uiLeaseRecoveryRunning = false;
287
+ /**
288
+ * A dead relay can leave its detached, already-authorized GUI helper behind. Keep
289
+ * the old mutex until that exact process group is gone, allow its external event
290
+ * to settle, then let the database CAS create the persistent safety hold.
291
+ */
292
+ async function recoverUiLease() {
293
+ if (!orchestration.writable || uiLeaseRecoveryRunning)
294
+ return;
295
+ uiLeaseRecoveryRunning = true;
296
+ try {
297
+ const result = await recoverExpiredUiLease(orchestration, relayIdentity);
298
+ if (result.status === 'external_process_alive') {
299
+ console.warn(`[workflow] expired UI helper ${result.owner.externalProcess?.pid ?? 'unknown'} could not be proven dead; retaining its mutex`);
300
+ }
301
+ else if (result.status === 'owned_external_terminated') {
302
+ console.warn(`[workflow] terminated overdue UI helper ${result.owner.externalProcess?.pid ?? 'unknown'}`);
303
+ }
304
+ else if (result.status === 'reclaimed') {
305
+ console.warn(`[workflow] reclaimed expired UI action ${result.owner.actionId}${result.quarantined ? ' into persistent quarantine' : ''}`);
306
+ }
307
+ }
308
+ catch (error) {
309
+ console.error('[workflow] expired UI lease recovery failed:', error);
310
+ }
311
+ finally {
312
+ uiLeaseRecoveryRunning = false;
313
+ }
314
+ }
315
+ /** Fail closed if another UI-capable relay cannot prove it speaks this protocol. */
316
+ async function workflowCompatibilityError() {
317
+ if (!orchestration.writable) {
318
+ return `Workflow is disabled because ${orchestrationUnavailableReason()}.`;
319
+ }
320
+ try {
321
+ const processes = await listUiCapableRelayProcesses();
322
+ const incompatible = incompatibleRelayProcesses(processes, orchestration.listRelayInstances(), ORCHESTRATION_PROTOCOL_VERSION);
323
+ if (!incompatible.length)
324
+ return null;
325
+ 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.`;
326
+ }
327
+ catch (error) {
328
+ return `Workflow could not verify the live relay processes: ${error instanceof Error ? error.message : String(error)}`;
329
+ }
330
+ }
247
331
  /**
248
332
  * A deep link has no request id to correlate with the workspace row it creates. Keep
249
333
  * relay-originated creations single-flight until that row appears, or two simultaneous
@@ -251,7 +335,7 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
251
335
  * happen in the gap, so callers also narrow the fresh row to the requested repo.
252
336
  */
253
337
  let workspaceCreationTail = Promise.resolve();
254
- async function createWorkspaceAndRead(prompt, repoPath, repoName) {
338
+ async function createWorkspaceAndRead(prompt, repoPath, repoName, strictUnique = false) {
255
339
  const previous = workspaceCreationTail;
256
340
  let release = () => { };
257
341
  workspaceCreationTail = new Promise(resolve => {
@@ -267,11 +351,13 @@ async function createWorkspaceAndRead(prompt, repoPath, repoName) {
267
351
  // Creating a worktree takes a beat longer than opening a chat does.
268
352
  for (let attempt = 0; attempt < 40; attempt++) {
269
353
  await sleep(500);
270
- const created = reads
354
+ const candidates = reads
271
355
  .listWorkspaces()
272
- .find(workspace => !before.has(workspace.id) && (!repoName || workspace.repo_name === repoName));
273
- if (created)
274
- return { result, created };
356
+ .filter(workspace => !before.has(workspace.id) && (!repoName || workspace.repo_name === repoName));
357
+ if (strictUnique && candidates.length > 1)
358
+ return { result };
359
+ if (candidates[0])
360
+ return { result, created: candidates[0] };
275
361
  }
276
362
  return { result };
277
363
  }
@@ -577,6 +663,15 @@ function referencedStagedAttachments() {
577
663
  for (const stageId of prompt.attachmentIds ?? [])
578
664
  referenced.add(stageId);
579
665
  }
666
+ if (orchestration.writable) {
667
+ for (const projection of orchestration.listWorkflowProjections()) {
668
+ const run = orchestration.getWorkflowRun(projection.id);
669
+ if (run?.target.kind !== 'new_workspace')
670
+ continue;
671
+ for (const stageId of stagedAttachmentIdsInObjective(run.objective))
672
+ referenced.add(stageId);
673
+ }
674
+ }
580
675
  return referenced;
581
676
  }
582
677
  function sweepStagedAttachments() {
@@ -693,11 +788,6 @@ async function configureDelegation(job) {
693
788
  const before = reads.getSession(job.childSessionId);
694
789
  if (!before)
695
790
  return delegationError('session_not_found', 'the delegated child chat is gone', false);
696
- // Never touch the buggy Plan control for orchestration. A child that somehow
697
- // inherited Plan is refused before its task is sent instead of relying on it.
698
- if (before.permission_mode === 'plan') {
699
- return delegationError('configuration_failed', 'the child inherited Plan mode; delegated roles require default mode', false);
700
- }
701
791
  const applied = await withUiPriority('background', () => applyAgentPatch(ws, job.childSessionId, {
702
792
  model: job.resolvedRole.model,
703
793
  effort: job.resolvedRole.effort,
@@ -708,9 +798,6 @@ async function configureDelegation(job) {
708
798
  const after = reads.getSession(job.childSessionId);
709
799
  if (!after)
710
800
  return delegationError('session_not_found', 'the configured child chat disappeared', false);
711
- if (after.permission_mode === 'plan') {
712
- return delegationError('configuration_failed', 'the child entered Plan mode; delegated roles require default mode', false);
713
- }
714
801
  if (after.agent_type !== job.resolvedRole.agentType) {
715
802
  return delegationError('configuration_failed', `Conductor recorded provider ${after.agent_type ?? 'unknown'}, not ${job.resolvedRole.agentType}`);
716
803
  }
@@ -923,95 +1010,168 @@ function attachDelegationState(workspaces) {
923
1010
  Object.assign(ws, { delegation_warning: warnings.join('; ') });
924
1011
  }
925
1012
  }
926
- function delegationHttpStatus(error) {
927
- if (error.code === 'workspace_not_found' ||
928
- error.code === 'session_not_found' ||
929
- error.code === 'role_not_found' ||
930
- error.code === 'delegation_not_found') {
931
- return 404;
1013
+ /** Keep process identity and raw recovery evidence private; the phone only needs the hold and its cause. */
1014
+ function wireUiQuarantine() {
1015
+ if (!orchestration.writable)
1016
+ return undefined;
1017
+ const quarantine = orchestration.getUiQuarantine();
1018
+ if (!quarantine.active)
1019
+ return undefined;
1020
+ const bounded = (value, maximum) => withoutWindowEvidence(scrubWorkflowSecrets(value)).slice(0, maximum);
1021
+ return {
1022
+ active: true,
1023
+ reason: bounded(quarantine.reason ??
1024
+ 'A previous automated Conductor UI action may have completed without a confirmed receipt. Inspect Conductor before continuing.', 500),
1025
+ createdAt: quarantine.createdAt ?? 0,
1026
+ ...(quarantine.actionId ? { actionId: bounded(quarantine.actionId, 256) } : {}),
1027
+ ...(quarantine.effectId ? { effectId: bounded(quarantine.effectId, 256) } : {})
1028
+ };
1029
+ }
1030
+ function workflowJobStatus(state) {
1031
+ if (state === 'owned')
1032
+ return 'opening';
1033
+ if (state === 'dormant')
1034
+ return 'queued';
1035
+ if (state === 'cancelled')
1036
+ return 'failed';
1037
+ return state;
1038
+ }
1039
+ function projectWorkflowDelegation(workflow, job) {
1040
+ if (!workflow.workspaceId || job.state === 'cancelled')
1041
+ return null;
1042
+ return {
1043
+ id: job.id,
1044
+ workflowId: workflow.id,
1045
+ logicalKey: job.logicalKey,
1046
+ bootstrap: job.logicalKey === 'explore:0',
1047
+ workspaceId: workflow.workspaceId,
1048
+ parentSessionId: workflow.rootSessionId ?? '',
1049
+ ...(job.childSessionId ? { childSessionId: job.childSessionId } : {}),
1050
+ role: job.role,
1051
+ // The immutable preamble remains coordinator-private. Only the public picker
1052
+ // settings are useful for role chips and list_delegations.
1053
+ resolvedRole: {
1054
+ agentType: job.resolvedRole.agentType,
1055
+ model: job.resolvedRole.model,
1056
+ ...(job.resolvedRole.effort ? { effort: job.resolvedRole.effort } : {}),
1057
+ ...(job.resolvedRole.fast === undefined ? {} : { fast: job.resolvedRole.fast })
1058
+ },
1059
+ prompt: scrubWorkflowSecrets(job.prompt).slice(0, 500),
1060
+ returnMode: 'queue',
1061
+ status: workflowJobStatus(job.state),
1062
+ attempts: job.attemptCount,
1063
+ createdAt: job.createdAt,
1064
+ updatedAt: job.updatedAt,
1065
+ ...(job.state === 'failed'
1066
+ ? {
1067
+ failure: {
1068
+ code: 'workflow_blocked',
1069
+ message: workflow.error?.message ?? 'Workflow job failed.',
1070
+ retryable: workflow.actions.canRetry
1071
+ }
1072
+ }
1073
+ : {})
1074
+ };
1075
+ }
1076
+ function attachWorkflowState(workspaces) {
1077
+ if (!orchestration.writable)
1078
+ return [];
1079
+ const projections = orchestration.listWorkflowProjections();
1080
+ const byWorkspace = new Map(workspaces.map(workspace => [workspace.id, workspace]));
1081
+ for (const workflow of projections) {
1082
+ if (!workflow.workspaceId)
1083
+ continue;
1084
+ const workspace = byWorkspace.get(workflow.workspaceId);
1085
+ if (!workspace)
1086
+ continue;
1087
+ const run = orchestration.getWorkflowRun(workflow.id);
1088
+ if (!run)
1089
+ continue;
1090
+ const jobs = orchestration
1091
+ .listWorkflowJobs(workflow.id)
1092
+ .flatMap(job => projectWorkflowDelegation(workflow, job) ?? []);
1093
+ workspace.delegations = [...(workspace.delegations ?? []), ...jobs];
1094
+ workspace.session_roles = { ...(workspace.session_roles ?? {}) };
1095
+ if (workflow.rootSessionId) {
1096
+ workspace.session_roles[workflow.rootSessionId] = {
1097
+ role: 'planning',
1098
+ workflowId: workflow.id,
1099
+ assignedAt: workflow.createdAt
1100
+ };
1101
+ }
1102
+ for (const job of orchestration.listWorkflowJobs(workflow.id)) {
1103
+ if (!job.childSessionId)
1104
+ continue;
1105
+ workspace.session_roles[job.childSessionId] = {
1106
+ role: job.role,
1107
+ delegationId: job.id,
1108
+ workflowId: workflow.id,
1109
+ assignedAt: job.createdAt
1110
+ };
1111
+ }
1112
+ // Compatibility for cached clients that only understand one workspace-level run.
1113
+ if (!workspace.workflow || workflow.rootSessionId === workspace.active_session_id)
1114
+ workspace.workflow = workflow;
932
1115
  }
933
- if (error.code === 'invalid_request')
934
- return 400;
935
- if (error.code === 'state_invalid')
936
- return 500;
937
- return 409;
1116
+ return projections;
1117
+ }
1118
+ function workflowOwningSession(sessionId) {
1119
+ if (!orchestration.writable)
1120
+ return null;
1121
+ for (const projection of orchestration.listWorkflowProjections()) {
1122
+ if (projection.rootSessionId === sessionId)
1123
+ return projection;
1124
+ if (orchestration.listWorkflowJobs(projection.id).some(job => job.childSessionId === sessionId))
1125
+ return projection;
1126
+ }
1127
+ return null;
1128
+ }
1129
+ function workflowFrozenError(sessionId) {
1130
+ const workflow = workflowOwningSession(sessionId);
1131
+ if (!workflow)
1132
+ return null;
1133
+ return {
1134
+ error: {
1135
+ code: 'workflow_role_frozen',
1136
+ message: `Workflow ${workflow.id} froze this chat's model, effort, and Fast setting at Start.`,
1137
+ retryable: false
1138
+ }
1139
+ };
938
1140
  }
939
1141
  function intakeError(code, message, retryable = false) {
940
1142
  return { ok: false, error: { code, message, retryable } };
941
1143
  }
942
- /** Validate and persist one job; no UI work is awaited by the caller. */
943
- function acceptDelegation(parentSessionId, body) {
944
- const roleName = typeof body.role === 'string' ? body.role.trim() : '';
945
- const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
946
- if (!roleName || !prompt)
947
- return intakeError('invalid_request', 'role and prompt are required');
948
- if (body.returnMode !== undefined && body.returnMode !== 'queue' && body.returnMode !== 'steer') {
949
- return intakeError('invalid_request', 'returnMode must be queue or steer');
1144
+ function workflowHttpError(error) {
1145
+ if (error instanceof WorkflowRequestError || error instanceof WorkflowGuardError) {
1146
+ return { status: error.status, error: { code: error.code, message: error.message, retryable: false } };
950
1147
  }
951
- if (body.throughRowid !== undefined && (!Number.isSafeInteger(body.throughRowid) || body.throughRowid < 1)) {
952
- return intakeError('invalid_request', 'throughRowid must be a positive integer');
953
- }
954
- if (body.includeThinking !== undefined && typeof body.includeThinking !== 'boolean') {
955
- return intakeError('invalid_request', 'includeThinking must be a boolean');
1148
+ if (error instanceof WorkflowCoordinatorError) {
1149
+ return {
1150
+ status: error.status,
1151
+ error: { code: error.code, message: error.message, retryable: error.retryable }
1152
+ };
956
1153
  }
957
- const actualWorkspaceId = reads.sessionWorkspaceId(parentSessionId);
958
- if (!actualWorkspaceId)
959
- return intakeError('session_not_found', 'parent chat not found');
960
- if (body.workspaceId && body.workspaceId !== actualWorkspaceId) {
961
- return intakeError('invalid_request', 'parent chat is not in that workspace');
1154
+ if (error instanceof IdempotencyConflictError) {
1155
+ return {
1156
+ status: 409,
1157
+ error: { code: 'idempotency_conflict', message: error.message, retryable: false }
1158
+ };
962
1159
  }
963
- const ws = reads.getWorkspace(actualWorkspaceId);
964
- if (!ws)
965
- return intakeError('workspace_not_found', 'workspace for parent chat not found');
966
- if (!ws.worktree)
967
- return intakeError('worktree_unavailable', 'worktree path unresolved');
968
- const parent = reads.getSession(parentSessionId);
969
- if (!parent)
970
- return intakeError('session_not_found', 'parent chat not found in that workspace');
971
- if (!parent.agent_type)
972
- return intakeError('provider_unknown', 'the parent chat provider is unknown');
973
- const storedRoles = roleStore.read();
974
- if (storedRoles.warning)
975
- return intakeError('state_invalid', storedRoles.warning);
976
- const resolved = resolveRole(storedRoles.config, roleName, modelCache.list());
977
- if (!resolved.ok)
978
- return { ok: false, error: resolved.error };
979
- if (resolved.role.agentType === parent.agent_type) {
980
- return intakeError('same_provider', `Role ${roleName} uses the parent's ${parent.agent_type} provider.`);
981
- }
982
- if (body.throughRowid !== undefined) {
983
- const { entries } = reads.getMessages(parentSessionId);
984
- if (!transcriptThrough(entries, body.throughRowid)) {
985
- return intakeError('invalid_request', 'throughRowid is not in the parent chat');
986
- }
1160
+ if (error instanceof WorkflowTransitionError) {
1161
+ return {
1162
+ status: 409,
1163
+ error: { code: 'workflow_phase_invalid', message: error.message, retryable: false }
1164
+ };
987
1165
  }
988
- const now = Date.now();
989
- const job = {
990
- version: 1,
991
- id: crypto.randomUUID(),
992
- workspaceId: ws.id,
993
- parentSessionId,
994
- role: roleName,
995
- resolvedRole: resolved.role,
996
- prompt,
997
- returnMode: body.returnMode ?? 'queue',
998
- includeThinking: body.includeThinking !== false,
999
- ...(body.throughRowid === undefined ? {} : { throughRowid: body.throughRowid }),
1000
- status: 'queued',
1001
- attempts: 0,
1002
- createdAt: now,
1003
- updatedAt: now
1004
- };
1166
+ return null;
1167
+ }
1168
+ async function workflowRequestBody(req) {
1005
1169
  try {
1006
- const store = delegationStore(ws);
1007
- if (!store)
1008
- return intakeError('worktree_unavailable', 'worktree path unresolved');
1009
- delegationQueue.enqueue(store, job);
1170
+ return JSON.parse((await readBody(req)) || '{}');
1010
1171
  }
1011
- catch (err) {
1012
- return intakeError('state_invalid', err instanceof Error ? err.message : String(err));
1172
+ catch {
1173
+ throw new WorkflowRequestError('Workflow request body must be valid JSON.');
1013
1174
  }
1014
- return { ok: true, delegationId: job.id, role: roleName, model: resolved.role.model };
1015
1175
  }
1016
1176
  /**
1017
1177
  * One prompt per tap, however many requests carry it (src/sendonce.ts). Keyed on the
@@ -1064,6 +1224,549 @@ const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompt
1064
1224
  });
1065
1225
  }
1066
1226
  });
1227
+ function workflowDeliveryCursor(sessionId) {
1228
+ const cursor = reads.deliveryCursor(sessionId);
1229
+ return { rowid: cursor.rowid, outboxIds: [...cursor.outboxIds].sort() };
1230
+ }
1231
+ function readsDeliveryCursor(value) {
1232
+ if (!value || typeof value !== 'object')
1233
+ return null;
1234
+ const cursor = value;
1235
+ if (!Number.isSafeInteger(cursor.rowid) || !Array.isArray(cursor.outboxIds))
1236
+ return null;
1237
+ if (cursor.outboxIds.some(id => typeof id !== 'string'))
1238
+ return null;
1239
+ return { rowid: cursor.rowid, outboxIds: new Set(cursor.outboxIds) };
1240
+ }
1241
+ function workspaceBaseline(value) {
1242
+ if (!value || typeof value !== 'object')
1243
+ return null;
1244
+ const baseline = value;
1245
+ return baseline.kind === 'workspace_ids' &&
1246
+ typeof baseline.repo === 'string' &&
1247
+ Array.isArray(baseline.workspaceIds) &&
1248
+ baseline.workspaceIds.every(id => typeof id === 'string')
1249
+ ? baseline
1250
+ : null;
1251
+ }
1252
+ function sessionBaseline(value) {
1253
+ if (!value || typeof value !== 'object')
1254
+ return null;
1255
+ const baseline = value;
1256
+ return baseline.kind === 'session_ids' &&
1257
+ typeof baseline.workspaceId === 'string' &&
1258
+ Array.isArray(baseline.sessionIds) &&
1259
+ baseline.sessionIds.every(id => typeof id === 'string')
1260
+ ? baseline
1261
+ : null;
1262
+ }
1263
+ function workflowRootInspection(ws, session) {
1264
+ const cursor = workflowDeliveryCursor(session.id);
1265
+ const userRows = reads.getMessages(session.id).entries.filter(entry => entry.role === 'user');
1266
+ const firstPrompt = firstPrompts.list().some(entry => entry.workspaceId === ws.id);
1267
+ const parked = parkedPrompts.list().some(entry => entry.sessionId === session.id);
1268
+ const reasons = [
1269
+ session.status !== 'idle' ? `the chat status is ${session.status ?? 'unknown'}, not idle` : '',
1270
+ session.background_tasks.length ? 'the chat is waiting on a background task' : '',
1271
+ session.last_user_message_at ? 'the chat already has a user message timestamp' : '',
1272
+ userRows.length ? 'the chat already has a user message' : '',
1273
+ cursor.outboxIds.length ? 'the chat already has a queued prompt' : '',
1274
+ firstPrompt ? 'the workspace already has a pending first prompt' : '',
1275
+ parked ? 'the chat already has a parked prompt' : ''
1276
+ ].filter(Boolean);
1277
+ return {
1278
+ workspaceId: ws.id,
1279
+ rootSessionId: session.id,
1280
+ pristine: reasons.length === 0,
1281
+ pristineEvidence: {
1282
+ status: session.status,
1283
+ backgroundTasks: session.background_tasks.length,
1284
+ lastUserMessageAt: session.last_user_message_at,
1285
+ userRows: userRows.length,
1286
+ outboxRows: cursor.outboxIds.length,
1287
+ firstPrompt,
1288
+ parked
1289
+ },
1290
+ deliveryCursor: cursor,
1291
+ ...(reasons.length ? { reason: `Workflow requires a pristine root: ${reasons.join('; ')}.` } : {})
1292
+ };
1293
+ }
1294
+ function assertWorkflowRootStillPristine(run, expectedSessionId) {
1295
+ const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
1296
+ const session = reads.getSession(expectedSessionId);
1297
+ if (!ws ||
1298
+ !run.rootSessionId ||
1299
+ run.rootSessionId !== expectedSessionId ||
1300
+ !session ||
1301
+ reads.sessionWorkspaceId(expectedSessionId) !== ws.id) {
1302
+ throw new WorkflowCoordinatorError('workflow_root_not_pristine', 'The exact Workflow root binding changed before its first prompt could be dispatched.');
1303
+ }
1304
+ const inspection = workflowRootInspection(ws, session);
1305
+ if (!inspection.pristine) {
1306
+ throw new WorkflowCoordinatorError('workflow_root_not_pristine', inspection.reason ?? 'Workflow requires a pristine root chat.');
1307
+ }
1308
+ }
1309
+ function sessionMatchesWorkflowRole(session, role) {
1310
+ const selected = modelLabel(session.model, [role.model]).toLowerCase();
1311
+ const wanted = role.model.toLowerCase();
1312
+ return (session.agent_type === role.agentType &&
1313
+ (selected === wanted || selected.startsWith(wanted)) &&
1314
+ (role.effort === undefined || session.claude_effort_level === role.effort) &&
1315
+ (role.fast === undefined || Boolean(session.fast_mode) === role.fast));
1316
+ }
1317
+ function stableWorkflowAttachment(worktree, jobId, name, body) {
1318
+ // Conductor requires six alphanumerics. Preserve all six characters' entropy
1319
+ // instead of truncating a hex digest to only 24 bits for a long-lived stable path.
1320
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
1321
+ const digest = crypto.createHash('sha256').update(`workflow-handoff:${jobId}`).digest();
1322
+ const id = Array.from(digest.subarray(0, 6), byte => alphabet[byte % alphabet.length]).join('');
1323
+ const safeName = attachmentName(name);
1324
+ const directory = path.join(worktree, ATTACHMENT_DIR, id);
1325
+ const destination = path.join(directory, safeName);
1326
+ fs.mkdirSync(directory, { recursive: true });
1327
+ try {
1328
+ fs.writeFileSync(destination, body, { flag: 'wx' });
1329
+ }
1330
+ catch (error) {
1331
+ if (error.code !== 'EEXIST')
1332
+ throw error;
1333
+ if (fs.readFileSync(destination, 'utf8') !== body) {
1334
+ throw new Error(`the stable Workflow handoff path ${path.join(ATTACHMENT_DIR, id, safeName)} is occupied`);
1335
+ }
1336
+ }
1337
+ return attachmentToken(safeName, path.join(ATTACHMENT_DIR, id, safeName));
1338
+ }
1339
+ function workflowWorkspaceCandidate(ws) {
1340
+ return {
1341
+ id: ws.id,
1342
+ title: workspaceTitle(ws),
1343
+ repo: ws.repo_name ?? '',
1344
+ createdAt: timestampMs(ws.created_at),
1345
+ kind: 'workspace'
1346
+ };
1347
+ }
1348
+ function workflowSessionCandidate(ws, session) {
1349
+ return {
1350
+ id: session.id,
1351
+ title: session.title?.trim() || '(untitled chat)',
1352
+ repo: ws.repo_name ?? '',
1353
+ createdAt: timestampMs(session.created_at),
1354
+ kind: 'session'
1355
+ };
1356
+ }
1357
+ function workflowSessionId(effect) {
1358
+ if (!effect.target || typeof effect.target !== 'object')
1359
+ return null;
1360
+ const id = effect.target.sessionId;
1361
+ return typeof id === 'string' ? id : null;
1362
+ }
1363
+ function workflowEffectPrompt(effect) {
1364
+ if (!effect.inputs || typeof effect.inputs !== 'object')
1365
+ return null;
1366
+ const prompt = effect.inputs.prompt;
1367
+ return typeof prompt === 'string' ? prompt : null;
1368
+ }
1369
+ function workflowEffectMarker(effect) {
1370
+ if (!effect.inputs || typeof effect.inputs !== 'object')
1371
+ return null;
1372
+ const marker = effect.inputs.correlationMarker;
1373
+ return typeof marker === 'string' ? marker : null;
1374
+ }
1375
+ function withWorkflowEffectGate(call, operation) {
1376
+ return call.dispatch.mode === 'gated_child'
1377
+ ? withGatedUiCommand(call.dispatch.gatedProcessReady, () => uiTurn(operation))
1378
+ : uiTurn(operation);
1379
+ }
1380
+ async function sendWorkflowPrompt(call, queue) {
1381
+ const ws = call.run.workspaceId ? reads.getWorkspace(call.run.workspaceId) : null;
1382
+ if (!ws || reads.sessionWorkspaceId(call.sessionId) !== ws.id) {
1383
+ throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow destination chat is unavailable.', {
1384
+ status: 404
1385
+ });
1386
+ }
1387
+ const before = readsDeliveryCursor(call.effect.cursor) ?? reads.deliveryCursor(call.sessionId);
1388
+ const result = await withWorkflowEffectGate(call, async () => {
1389
+ if (actuator.name !== 'sidecar')
1390
+ return deliverPrompt(ws, call.sessionId, call.text, SEND_BUDGET_MS, queue);
1391
+ const located = locateChat(ws, call.sessionId);
1392
+ if ('error' in located)
1393
+ return { ok: false, strategy: actuator.name, attempts: 0, error: located.error };
1394
+ const sent = await actuator.send({ workspace: ws, sessionId: call.sessionId, tab: located.tab }, call.text, {
1395
+ queue
1396
+ });
1397
+ if (!sent.ok)
1398
+ return { ...sent, attempts: 1 };
1399
+ const landed = await confirmDelivery(call.sessionId, call.text, before, Date.now() + CONFIRM_WINDOW_MS);
1400
+ return landed
1401
+ ? { ...sent, attempts: 1 }
1402
+ : {
1403
+ ok: false,
1404
+ strategy: sent.strategy,
1405
+ attempts: 1,
1406
+ error: 'Conductor did not record the sidecar Workflow prompt; automatic replay is disabled.'
1407
+ };
1408
+ });
1409
+ const receipt = reads.deliveryReceiptSince(call.sessionId, call.text, before);
1410
+ if (receipt)
1411
+ return receipt;
1412
+ throw new WorkflowCoordinatorError('workflow_effect_failed', result.error ?? 'Conductor did not record an accepted Workflow prompt.', { retryable: !retryWontHelp(result.error) });
1413
+ }
1414
+ const workflowCoordinator = orchestration.writable
1415
+ ? new WorkflowCoordinator(orchestration, relayIdentity, {
1416
+ captureWorkspaceBaseline: async (repoName) => {
1417
+ const repo = reads.listRepos().find(candidate => candidate.name === repoName);
1418
+ if (!repo) {
1419
+ throw new WorkflowCoordinatorError('workflow_not_found', `Unknown repo ${repoName}.`, { status: 404 });
1420
+ }
1421
+ if (!repo.root_path) {
1422
+ throw new WorkflowCoordinatorError('invalid_request', `${repo.name} has no checkout path.`);
1423
+ }
1424
+ return {
1425
+ kind: 'workspace_ids',
1426
+ repo: repo.name,
1427
+ workspaceIds: reads
1428
+ .listWorkspaces()
1429
+ .filter(workspace => workspace.repo_name === repo.name)
1430
+ .map(workspace => workspace.id)
1431
+ .sort()
1432
+ };
1433
+ },
1434
+ inspectExistingRoot: async (target) => {
1435
+ const ws = reads.getWorkspace(target.workspaceId);
1436
+ if (!ws || reads.sessionWorkspaceId(target.sessionId) !== ws.id)
1437
+ return null;
1438
+ const session = reads.getSession(target.sessionId);
1439
+ return session ? workflowRootInspection(ws, session) : null;
1440
+ },
1441
+ bindCreatedRoot: async ({ run, workspaceId }) => {
1442
+ const ws = reads.getWorkspace(workspaceId);
1443
+ if (!ws)
1444
+ return null;
1445
+ if (run.target.kind !== 'new_workspace')
1446
+ return null;
1447
+ if (!run.target.sendImmediately && ws.state !== 'ready')
1448
+ return null;
1449
+ const sessions = reads.listSessions(workspaceId);
1450
+ if (!sessions.length)
1451
+ return null;
1452
+ const session = sessions.find(candidate => candidate.id === ws.active_session_id) ?? sessions[0];
1453
+ if (sessions.length !== 1) {
1454
+ const inspection = workflowRootInspection(ws, session);
1455
+ return { ...inspection, pristine: false, reason: 'The created workspace has more than one root candidate.' };
1456
+ }
1457
+ const stageIds = stagedAttachmentIdsInObjective(run.objective);
1458
+ if (stageIds.length) {
1459
+ if (!ws.worktree)
1460
+ return null;
1461
+ try {
1462
+ materializeStagedAttachments(STAGED_ATTACHMENTS_DIR, ws.worktree, stageIds);
1463
+ }
1464
+ catch (error) {
1465
+ const inspection = workflowRootInspection(ws, session);
1466
+ return {
1467
+ ...inspection,
1468
+ pristine: false,
1469
+ reason: error instanceof Error ? error.message : 'The Workflow attachments could not be materialized.'
1470
+ };
1471
+ }
1472
+ }
1473
+ return workflowRootInspection(ws, session);
1474
+ },
1475
+ createWorkspace: call => withWorkflowEffectGate(call, async () => {
1476
+ const { target } = call;
1477
+ const repo = reads.listRepos().find(candidate => candidate.name === target.repo);
1478
+ if (!repo) {
1479
+ throw new WorkflowCoordinatorError('workflow_not_found', `Unknown repo ${target.repo}.`, { status: 404 });
1480
+ }
1481
+ if (!repo.root_path)
1482
+ throw new WorkflowCoordinatorError('invalid_request', `${repo.name} has no checkout path.`);
1483
+ const { result, created } = await createWorkspaceAndRead('', repo.root_path, repo.name, true);
1484
+ if (!result.ok) {
1485
+ throw new WorkflowCoordinatorError('workflow_effect_failed', result.error ?? 'Workspace creation failed.', {
1486
+ retryable: !retryWontHelp(result.error)
1487
+ });
1488
+ }
1489
+ if (!created) {
1490
+ throw new WorkflowCoordinatorError('workflow_effect_failed', 'Conductor accepted the workspace link but no exact workspace row appeared.', { retryable: true });
1491
+ }
1492
+ return { workspaceId: created.id };
1493
+ }),
1494
+ configureSession: call => withWorkflowEffectGate(call, async () => {
1495
+ const { run, sessionId, role } = call;
1496
+ const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
1497
+ if (!ws || reads.sessionWorkspaceId(sessionId) !== ws.id) {
1498
+ throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow chat is unavailable.', {
1499
+ status: 404
1500
+ });
1501
+ }
1502
+ const applied = await applyAgentPatch(ws, sessionId, {
1503
+ model: role.model,
1504
+ ...(role.effort === undefined ? {} : { effort: role.effort }),
1505
+ ...(role.fast === undefined ? {} : { fast: role.fast })
1506
+ });
1507
+ if (!applied.ok) {
1508
+ throw new WorkflowCoordinatorError('workflow_effect_failed', applied.error ?? 'Conductor rejected the frozen role settings.');
1509
+ }
1510
+ const session = reads.getSession(sessionId);
1511
+ if (!session || !sessionMatchesWorkflowRole(session, role)) {
1512
+ throw new WorkflowCoordinatorError('workflow_effect_failed', 'Conductor no longer matches every frozen role setting; no fallback was selected.');
1513
+ }
1514
+ return {
1515
+ sessionId,
1516
+ agentType: session.agent_type,
1517
+ model: role.model,
1518
+ effort: session.claude_effort_level,
1519
+ fast: Boolean(session.fast_mode)
1520
+ };
1521
+ }),
1522
+ openChild: async (call) => {
1523
+ const { run } = call;
1524
+ const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
1525
+ if (!ws)
1526
+ throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow workspace is unavailable.', {
1527
+ status: 404
1528
+ });
1529
+ // A lock probe is read-only and happens before the durable dispatch boundary.
1530
+ // Otherwise a locked Mac would quarantine an effect whose child tab was never attempted.
1531
+ if ((await screenLocked()) === true) {
1532
+ throw new WorkflowCoordinatorError('workflow_effect_failed', 'The Mac is locked — unlock it and retry Workflow.');
1533
+ }
1534
+ return withWorkflowEffectGate(call, async () => {
1535
+ const opened = await openChat(ws);
1536
+ if ('error' in opened) {
1537
+ throw new WorkflowCoordinatorError('workflow_effect_failed', opened.result.error ?? 'Conductor did not open a tracked child chat.', { retryable: opened.retryable !== false });
1538
+ }
1539
+ if (!opened.sessionId) {
1540
+ throw new WorkflowCoordinatorError('workflow_effect_failed', 'Conductor opened a child tab but did not record its exact session ID.');
1541
+ }
1542
+ return { sessionId: opened.sessionId };
1543
+ });
1544
+ },
1545
+ captureSessionBaseline: async (workspaceId) => {
1546
+ const ws = reads.getWorkspace(workspaceId);
1547
+ if (!ws) {
1548
+ throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow workspace is unavailable.', {
1549
+ status: 404
1550
+ });
1551
+ }
1552
+ return {
1553
+ kind: 'session_ids',
1554
+ workspaceId,
1555
+ sessionIds: reads
1556
+ .listSessions(workspaceId)
1557
+ .map(session => session.id)
1558
+ .sort()
1559
+ };
1560
+ },
1561
+ captureDeliveryCursor: async (sessionId) => workflowDeliveryCursor(sessionId),
1562
+ captureTranscriptCursor: async (sessionId) => ({ rowid: reads.getMessages(sessionId).cursor }),
1563
+ materializeHandoff: async ({ run, job }) => {
1564
+ const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
1565
+ if (!ws?.worktree || !run.rootSessionId)
1566
+ return undefined;
1567
+ const cursor = job.transcriptCursor && typeof job.transcriptCursor === 'object'
1568
+ ? job.transcriptCursor.rowid
1569
+ : undefined;
1570
+ const entries = reads
1571
+ .getMessages(run.rootSessionId)
1572
+ .entries.filter(entry => typeof cursor !== 'number' || entry.rowid <= cursor);
1573
+ const rendered = renderTranscript(entries, { thinking: true, tools: false });
1574
+ if (!rendered.kept)
1575
+ return undefined;
1576
+ const body = [
1577
+ `# Workflow handoff for ${job.logicalKey}`,
1578
+ '',
1579
+ `Workflow: ${run.id}`,
1580
+ `Workflow job: ${job.id}`,
1581
+ `Root chat: ${run.rootSessionId}`,
1582
+ '',
1583
+ rendered.text
1584
+ ].join('\n');
1585
+ return stableWorkflowAttachment(ws.worktree, job.id, `Workflow ${job.role} handoff.md`, body);
1586
+ },
1587
+ sendPrompt: call => sendWorkflowPrompt(call, false),
1588
+ returnBaton: call => sendWorkflowPrompt(call, true),
1589
+ resolveDeliveryReceipt: async ({ sessionId, receipt }) => {
1590
+ const current = reads.deliveryReceiptForId(sessionId, receipt.id);
1591
+ if (current?.kind === 'message')
1592
+ return { status: 'delivered', receipt: current };
1593
+ if (current?.kind === 'outbox')
1594
+ return { status: 'pending' };
1595
+ return { status: 'lost', evidence: { receiptId: receipt.id, priorKind: receipt.kind } };
1596
+ },
1597
+ readChildOutcome: async ({ job }) => {
1598
+ if (!job.childSessionId || !job.taskReceipt || typeof job.taskReceipt !== 'object')
1599
+ return null;
1600
+ const receipt = job.taskReceipt;
1601
+ if (receipt.kind !== 'message' || !Number.isSafeInteger(receipt.rowid))
1602
+ return null;
1603
+ const child = reads.getSession(job.childSessionId);
1604
+ if (!child) {
1605
+ return {
1606
+ kind: 'failure',
1607
+ code: 'session_not_found',
1608
+ message: 'The tracked child chat disappeared.',
1609
+ retryClass: 'deterministic'
1610
+ };
1611
+ }
1612
+ const messages = typeof receipt.turnId === 'string'
1613
+ ? reads.getMessagesForTurn(job.childSessionId, receipt.turnId, receipt.rowid)
1614
+ : reads.getMessages(job.childSessionId, receipt.rowid);
1615
+ const assistants = messages.entries.filter(entry => entry.role === 'assistant' && entry.text.trim());
1616
+ const last = assistants.at(-1);
1617
+ if (child.status === 'error') {
1618
+ return {
1619
+ kind: 'failure',
1620
+ code: 'completion_failed',
1621
+ message: last?.text.trim() || 'The tracked child agent stopped with an error.',
1622
+ retryClass: 'deterministic',
1623
+ evidence: { assistantRowid: last?.rowid }
1624
+ };
1625
+ }
1626
+ if (child.status !== 'idle' || child.background_tasks.length || !last)
1627
+ return null;
1628
+ return {
1629
+ kind: 'success',
1630
+ baton: batonText(last.text),
1631
+ evidence: { assistantRowid: last.rowid }
1632
+ };
1633
+ },
1634
+ validateBeforeDispatch: async ({ run, effect }) => {
1635
+ if (effect.kind !== 'configure_root' && effect.kind !== 'send_root')
1636
+ return;
1637
+ const sessionId = workflowSessionId(effect);
1638
+ if (!sessionId) {
1639
+ throw new WorkflowCoordinatorError('workflow_root_not_pristine', 'The Workflow root effect lost its exact session binding before dispatch.');
1640
+ }
1641
+ assertWorkflowRootStillPristine(run, sessionId);
1642
+ },
1643
+ reconcileEffect: async ({ run, effect }) => {
1644
+ const sessionId = workflowSessionId(effect);
1645
+ if (effect.receipt && sessionId && typeof effect.receipt === 'object') {
1646
+ const receipt = effect.receipt;
1647
+ if (typeof receipt.id === 'string') {
1648
+ const current = reads.deliveryReceiptForId(sessionId, receipt.id);
1649
+ if (current)
1650
+ return { status: 'committed', receipt: current };
1651
+ return { status: 'ambiguous', evidence: { receiptId: receipt.id, state: 'missing' } };
1652
+ }
1653
+ }
1654
+ if (effect.kind === 'configure_root' || effect.kind === 'configure_child') {
1655
+ const role = effect.inputs && typeof effect.inputs === 'object'
1656
+ ? effect.inputs.role
1657
+ : undefined;
1658
+ const session = sessionId ? reads.getSession(sessionId) : null;
1659
+ if (effect.kind === 'configure_root') {
1660
+ const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
1661
+ if (!ws ||
1662
+ !session ||
1663
+ run.rootSessionId !== sessionId ||
1664
+ reads.sessionWorkspaceId(session.id) !== ws.id ||
1665
+ !workflowRootInspection(ws, session).pristine) {
1666
+ return { status: 'pending' };
1667
+ }
1668
+ }
1669
+ if (role && session && sessionMatchesWorkflowRole(session, role)) {
1670
+ return { status: 'committed', receipt: { sessionId, matched: true } };
1671
+ }
1672
+ }
1673
+ if (effect.kind === 'create_workspace') {
1674
+ const baseline = workspaceBaseline(effect.baseline);
1675
+ if (!baseline)
1676
+ return { status: 'ambiguous', evidence: { baseline: 'invalid' } };
1677
+ const prior = new Set(baseline.workspaceIds);
1678
+ const candidates = reads
1679
+ .listWorkspaces()
1680
+ .filter(workspace => workspace.repo_name === baseline.repo && !prior.has(workspace.id))
1681
+ .map(workflowWorkspaceCandidate);
1682
+ return { status: 'ambiguous', candidates, evidence: { candidateCount: candidates.length } };
1683
+ }
1684
+ if (effect.kind === 'open_child') {
1685
+ const baseline = sessionBaseline(effect.baseline);
1686
+ const ws = baseline ? reads.getWorkspace(baseline.workspaceId) : null;
1687
+ if (!baseline || !ws)
1688
+ return { status: 'ambiguous', evidence: { baseline: 'invalid' } };
1689
+ const prior = new Set(baseline.sessionIds);
1690
+ const candidates = reads
1691
+ .listSessions(ws.id)
1692
+ .filter(session => !prior.has(session.id) && workflowRootInspection(ws, session).pristine)
1693
+ .map(session => workflowSessionCandidate(ws, session));
1694
+ return { status: 'ambiguous', candidates, evidence: { candidateCount: candidates.length } };
1695
+ }
1696
+ const prompt = workflowEffectPrompt(effect);
1697
+ const cursor = readsDeliveryCursor(effect.cursor);
1698
+ if (sessionId && cursor) {
1699
+ const marker = workflowEffectMarker(effect);
1700
+ const receipt = marker
1701
+ ? reads.deliveryReceiptContainingSince(sessionId, marker, cursor)
1702
+ : prompt
1703
+ ? reads.deliveryReceiptSince(sessionId, prompt, cursor)
1704
+ : null;
1705
+ if (receipt)
1706
+ return { status: 'committed', receipt };
1707
+ }
1708
+ return { status: 'pending' };
1709
+ },
1710
+ validateAdoption: async ({ effect, candidate }) => {
1711
+ if (candidate.kind === 'workspace' && effect.kind === 'create_workspace') {
1712
+ const baseline = workspaceBaseline(effect.baseline);
1713
+ const ws = reads.getWorkspace(candidate.id);
1714
+ if (!baseline || !ws || baseline.workspaceIds.includes(ws.id) || ws.repo_name !== baseline.repo)
1715
+ return null;
1716
+ return { workspaceId: ws.id };
1717
+ }
1718
+ if (candidate.kind === 'session' && effect.kind === 'open_child') {
1719
+ const baseline = sessionBaseline(effect.baseline);
1720
+ const ws = baseline ? reads.getWorkspace(baseline.workspaceId) : null;
1721
+ const session = reads.getSession(candidate.id);
1722
+ if (!baseline ||
1723
+ !ws ||
1724
+ !session ||
1725
+ baseline.sessionIds.includes(session.id) ||
1726
+ reads.sessionWorkspaceId(session.id) !== ws.id ||
1727
+ !workflowRootInspection(ws, session).pristine) {
1728
+ return null;
1729
+ }
1730
+ return { sessionId: session.id };
1731
+ }
1732
+ return null;
1733
+ },
1734
+ assertCompatibleRelays: async () => {
1735
+ const error = await workflowCompatibilityError();
1736
+ if (error) {
1737
+ throw new WorkflowCoordinatorError('workflow_incompatible_relay', error, {
1738
+ status: 409,
1739
+ retryable: true
1740
+ });
1741
+ }
1742
+ },
1743
+ dispatchMode: effect => actuator.name === 'sidecar' &&
1744
+ (effect.kind === 'send_root' ||
1745
+ effect.kind === 'send_task' ||
1746
+ effect.kind === 'return_baton' ||
1747
+ effect.kind === 'authorize_phase')
1748
+ ? 'in_process'
1749
+ : 'gated_child'
1750
+ })
1751
+ : null;
1752
+ const WORKFLOW_RECOVERY_PHONE_ONLY = 'Workflow recovery is available only from the phone UI.';
1753
+ function requirePhoneWorkflowCoordinator(req, forbiddenMessage) {
1754
+ if (workflowClientIsMcp(req.headers))
1755
+ throw new WorkflowRequestError(forbiddenMessage, 403);
1756
+ if (!workflowCoordinator)
1757
+ throw new WorkflowCoordinatorError('workflow_incompatible_relay', 'Workflow is unavailable.');
1758
+ return workflowCoordinator;
1759
+ }
1760
+ function wakeWorkflows() {
1761
+ if (!workflowCoordinator)
1762
+ return;
1763
+ for (const workflowId of workflowCoordinator.runIdsNeedingWake()) {
1764
+ void workflowCoordinator.wake(workflowId).catch(error => {
1765
+ console.error(`[workflow ${workflowId}] wake failed:`, error);
1766
+ });
1767
+ }
1768
+ }
1769
+ sessionPoller.subscribe(wakeWorkflows);
1067
1770
  const MIME = {
1068
1771
  '.html': 'text/html; charset=utf-8',
1069
1772
  '.js': 'text/javascript; charset=utf-8',
@@ -1236,16 +1939,20 @@ class PayloadTooLargeError extends Error {
1236
1939
  * in relay.log, which `/api/logs` serves to the same phone on request.
1237
1940
  */
1238
1941
  function forTheClient(body) {
1239
- if (!body || typeof body !== 'object' || Array.isArray(body))
1240
- return body;
1241
- const error = body.error;
1242
- if (typeof error !== 'string')
1243
- return body;
1244
- const clean = withoutWindowEvidence(error);
1245
- if (clean === error)
1246
- return body;
1247
- console.warn(`[relay] ${error}`);
1248
- return { ...body, error: clean };
1942
+ const scrub = (value, key) => {
1943
+ if (typeof value === 'string') {
1944
+ const withoutEvidence = withoutClientWindowEvidence(value, key);
1945
+ if (withoutEvidence !== value)
1946
+ console.warn(`[relay] ${scrubWorkflowSecrets(value)}`);
1947
+ return scrubWorkflowSecrets(withoutEvidence);
1948
+ }
1949
+ if (Array.isArray(value))
1950
+ return value.map(item => scrub(item));
1951
+ if (!value || typeof value !== 'object')
1952
+ return value;
1953
+ return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, scrub(child, childKey)]));
1954
+ };
1955
+ return scrub(body);
1249
1956
  }
1250
1957
  /**
1251
1958
  * Successful GETs are conditional + compressed to keep the phone's polling cheap.
@@ -1411,7 +2118,7 @@ async function handleMcpHttp(req, res) {
1411
2118
  if (!answers.length)
1412
2119
  return void res.writeHead(202).end();
1413
2120
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
1414
- res.end(JSON.stringify(Array.isArray(parsed) ? answers : answers[0]));
2121
+ res.end(JSON.stringify(forTheClient(Array.isArray(parsed) ? answers : answers[0])));
1415
2122
  }
1416
2123
  const server = http.createServer(async (req, res) => {
1417
2124
  const url = new URL(req.url ?? '/', 'http://x');
@@ -1446,6 +2153,8 @@ const server = http.createServer(async (req, res) => {
1446
2153
  attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
1447
2154
  attachRunActivity(workspaces); // flags a live Run wrapper from a cached ps snapshot
1448
2155
  attachDelegationState(workspaces);
2156
+ const workflows = attachWorkflowState(workspaces);
2157
+ const uiQuarantine = wireUiQuarantine();
1449
2158
  // An undelivered first prompt rides along with its workspace: the phone renders it
1450
2159
  // in that chat rather than tracking delivery itself (see src/firstprompt.ts).
1451
2160
  // Prompts parked for the lock screen ride the same way, one list per workspace,
@@ -1459,6 +2168,11 @@ const server = http.createServer(async (req, res) => {
1459
2168
  }
1460
2169
  return json(req, res, 200, {
1461
2170
  workspaces,
2171
+ workflows,
2172
+ ...(uiQuarantine ? { uiQuarantine } : {}),
2173
+ ...(orchestration.writable
2174
+ ? {}
2175
+ : { workflowWarning: scrubWorkflowSecrets(orchestrationUnavailableReason()).slice(0, 500) }),
1462
2176
  actuator: await describeActuator(actuator),
1463
2177
  version: update.current,
1464
2178
  update
@@ -1592,12 +2306,175 @@ const server = http.createServer(async (req, res) => {
1592
2306
  }
1593
2307
  return json(req, res, 200, { ok: true, config: written.config });
1594
2308
  }
2309
+ // This is deliberately independent of a Workflow run: cancellation cannot
2310
+ // make an ambiguous shared-window effect safe. Only a phone acknowledgement
2311
+ // after inspecting Conductor clears the relay-wide hold.
2312
+ if (isRoute(routes.confirmUiStable, req.method, pathname)) {
2313
+ if (workflowClientIsMcp(req.headers)) {
2314
+ throw new WorkflowRequestError('Only the phone UI can confirm that Conductor is stable.', 403);
2315
+ }
2316
+ if (!orchestration.writable) {
2317
+ throw new WorkflowCoordinatorError('workflow_incompatible_relay', `UI stability confirmation is disabled because ${orchestrationUnavailableReason()}.`, { status: 409 });
2318
+ }
2319
+ const request = parseConfirmUiStableRequest(await workflowRequestBody(req));
2320
+ const confirmed = orchestration.idempotentMutation('confirm_ui_stable', request.clientId, {
2321
+ confirmStable: true,
2322
+ createdAt: request.createdAt,
2323
+ ...(request.actionId ? { actionId: request.actionId } : {}),
2324
+ ...(request.effectId ? { effectId: request.effectId } : {})
2325
+ }, () => {
2326
+ const current = orchestration.getUiQuarantine();
2327
+ if (current.active &&
2328
+ (current.createdAt !== request.createdAt ||
2329
+ current.actionId !== request.actionId ||
2330
+ current.effectId !== request.effectId)) {
2331
+ throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'The Conductor UI safety hold changed; inspect the current hold before confirming it.', { status: 409 });
2332
+ }
2333
+ orchestration.clearUiQuarantine(`phone:${request.clientId}`);
2334
+ return { ok: true };
2335
+ });
2336
+ return json(req, res, 200, confirmed.result);
2337
+ }
2338
+ // POST /api/workflows — the only operation that authorizes a managed
2339
+ // Workflow. Acceptance is durable and intentionally precedes every UI effect.
2340
+ if (isRoute(routes.workflows, req.method, pathname)) {
2341
+ if (workflowClientIsMcp(req.headers)) {
2342
+ throw new WorkflowRequestError('MCP cannot start a Workflow; start it from the Conductor Remote UI.', 403);
2343
+ }
2344
+ if (!workflowCoordinator) {
2345
+ throw new WorkflowCoordinatorError('workflow_incompatible_relay', `Workflow is disabled because ${orchestrationUnavailableReason()}.`, { status: 409 });
2346
+ }
2347
+ const request = parseStartWorkflowRequest(await workflowRequestBody(req));
2348
+ const replay = orchestration.getIdempotentMutation('start_workflow', request.clientId, {
2349
+ objective: request.objective,
2350
+ target: request.target
2351
+ });
2352
+ if (!replay && request.target.kind === 'new_workspace') {
2353
+ const stageIds = stagedAttachmentIdsInObjective(request.objective);
2354
+ if (stageIds.length && !stagedAttachments(STAGED_ATTACHMENTS_DIR, stageIds)) {
2355
+ throw new WorkflowCoordinatorError('invalid_request', 'One or more Workflow attachments are no longer staged; add them again.', { status: 409 });
2356
+ }
2357
+ }
2358
+ const accepted = await workflowCoordinator.start({
2359
+ clientId: request.clientId,
2360
+ objective: request.objective,
2361
+ target: request.target,
2362
+ roles: roleStore.read(),
2363
+ modelGroups: modelCache.list()
2364
+ });
2365
+ queueMicrotask(() => {
2366
+ void workflowCoordinator.wake(accepted.workflow.id).catch(error => {
2367
+ console.error(`[workflow ${accepted.workflow.id}] initial wake failed:`, error);
2368
+ });
2369
+ });
2370
+ return json(req, res, 202, { workflow: accepted.workflow });
2371
+ }
2372
+ // POST /api/workflows/:id/delegations — the sole agent mutation. The
2373
+ // capability, exact root, frozen role, and phase barrier are checked together.
2374
+ const workflowDelegation = routeParam(routes.workflowDelegation, req.method, pathname);
2375
+ if (workflowDelegation) {
2376
+ if (!workflowCoordinator) {
2377
+ throw new WorkflowCoordinatorError('workflow_incompatible_relay', 'Workflow is unavailable.', {
2378
+ status: 409
2379
+ });
2380
+ }
2381
+ const request = parseWorkflowDelegateRequest(await workflowRequestBody(req), workflowDelegation);
2382
+ // The capability rotates after every accepted choice. Its hash therefore
2383
+ // doubles as a stable retry identity without adding a field to the exact tool schema.
2384
+ const clientId = crypto
2385
+ .createHash('sha256')
2386
+ .update(JSON.stringify([
2387
+ request.workflow_id,
2388
+ request.phase_capability,
2389
+ request.session_id,
2390
+ request.role,
2391
+ request.prompt
2392
+ ]))
2393
+ .digest('hex');
2394
+ const accepted = await workflowCoordinator.delegate({
2395
+ clientId,
2396
+ workflowId: request.workflow_id,
2397
+ sessionId: request.session_id,
2398
+ phaseCapability: request.phase_capability,
2399
+ role: request.role,
2400
+ task: request.prompt,
2401
+ ...(request.role === 'implementation' ? { planningInterpretation: request.prompt } : {})
2402
+ });
2403
+ queueMicrotask(() => {
2404
+ void workflowCoordinator.wake(accepted.workflow.id).catch(error => {
2405
+ console.error(`[workflow ${accepted.workflow.id}] delegated wake failed:`, error);
2406
+ });
2407
+ });
2408
+ return json(req, res, 202, {
2409
+ ok: true,
2410
+ workflowId: accepted.workflow.id,
2411
+ delegationId: accepted.job.id,
2412
+ role: accepted.job.role,
2413
+ model: accepted.job.resolvedRole.model
2414
+ });
2415
+ }
2416
+ const retryWorkflow = routeParam(routes.workflowRetry, req.method, pathname);
2417
+ if (retryWorkflow) {
2418
+ const coordinator = requirePhoneWorkflowCoordinator(req, WORKFLOW_RECOVERY_PHONE_ONLY);
2419
+ const request = parseWorkflowRetryRequest(await workflowRequestBody(req));
2420
+ const result = await coordinator.retry({
2421
+ clientId: request.clientId,
2422
+ workflowId: retryWorkflow
2423
+ });
2424
+ queueMicrotask(() => void coordinator.wake(result.workflow.id).catch(console.error));
2425
+ return json(req, res, 200, { workflow: result.workflow });
2426
+ }
2427
+ const adoptWorkflow = routeParam(routes.workflowAdopt, req.method, pathname);
2428
+ if (adoptWorkflow) {
2429
+ const coordinator = requirePhoneWorkflowCoordinator(req, WORKFLOW_RECOVERY_PHONE_ONLY);
2430
+ const request = parseWorkflowAdoptRequest(await workflowRequestBody(req));
2431
+ const result = await coordinator.adopt({
2432
+ clientId: request.clientId,
2433
+ workflowId: adoptWorkflow,
2434
+ actionId: request.actionId,
2435
+ candidateId: request.workspaceId ?? request.sessionId
2436
+ });
2437
+ queueMicrotask(() => void coordinator.wake(result.workflow.id).catch(console.error));
2438
+ return json(req, res, 200, { workflow: result.workflow });
2439
+ }
2440
+ const replayWorkflow = routeParam(routes.workflowReplay, req.method, pathname);
2441
+ if (replayWorkflow) {
2442
+ const coordinator = requirePhoneWorkflowCoordinator(req, WORKFLOW_RECOVERY_PHONE_ONLY);
2443
+ const request = parseWorkflowReplayRequest(await workflowRequestBody(req));
2444
+ const result = await coordinator.replay({
2445
+ clientId: request.clientId,
2446
+ workflowId: replayWorkflow,
2447
+ actionId: request.actionId,
2448
+ confirmDuplicateRisk: request.confirmDuplicateRisk
2449
+ });
2450
+ queueMicrotask(() => void coordinator.wake(result.workflow.id).catch(console.error));
2451
+ return json(req, res, 200, { workflow: result.workflow });
2452
+ }
2453
+ const completeWorkflow = routeParam(routes.workflowComplete, req.method, pathname);
2454
+ if (completeWorkflow) {
2455
+ const coordinator = requirePhoneWorkflowCoordinator(req, 'Only the phone UI can mark a Workflow complete.');
2456
+ const request = parseWorkflowCompleteRequest(await workflowRequestBody(req));
2457
+ const result = await coordinator.complete({
2458
+ clientId: request.clientId,
2459
+ workflowId: completeWorkflow
2460
+ });
2461
+ return json(req, res, 200, { workflow: result.workflow });
2462
+ }
2463
+ const cancelWorkflow = routeParam(routes.workflow, req.method, pathname);
2464
+ if (cancelWorkflow) {
2465
+ const coordinator = requirePhoneWorkflowCoordinator(req, 'Only the phone UI can cancel a Workflow.');
2466
+ const clientId = url.searchParams.get('clientId');
2467
+ if (!clientId?.trim())
2468
+ throw new WorkflowRequestError('clientId is required.');
2469
+ const result = await coordinator.cancel({ clientId: clientId.trim(), workflowId: cancelWorkflow });
2470
+ return json(req, res, 200, { workflow: result.workflow });
2471
+ }
1595
2472
  if (isRoute(routes.delegations, req.method, pathname)) {
1596
2473
  const workspaceId = url.searchParams.get('workspaceId');
1597
2474
  const workspaces = reads.listWorkspaces().filter(ws => !workspaceId || ws.id === workspaceId);
1598
2475
  if (workspaceId && !workspaces.length)
1599
2476
  return json(req, res, 404, { error: 'workspace not found' });
1600
- const delegations = workspaces.flatMap(ws => {
2477
+ const legacy = workspaces.flatMap(ws => {
1601
2478
  const store = delegationStore(ws);
1602
2479
  return store
1603
2480
  ? store
@@ -1606,7 +2483,18 @@ const server = http.createServer(async (req, res) => {
1606
2483
  .map(projectDelegation)
1607
2484
  : [];
1608
2485
  });
1609
- return json(req, res, 200, { delegations });
2486
+ const allowedWorkspaces = new Set(workspaces.map(workspace => workspace.id));
2487
+ const workflows = orchestration.writable
2488
+ ? orchestration.listWorkflowProjections().flatMap(workflow => {
2489
+ if (!workflow.workspaceId || !allowedWorkspaces.has(workflow.workspaceId))
2490
+ return [];
2491
+ return orchestration
2492
+ .listWorkflowJobs(workflow.id)
2493
+ .filter(job => job.state !== 'returned')
2494
+ .flatMap(job => projectWorkflowDelegation(workflow, job) ?? []);
2495
+ })
2496
+ : [];
2497
+ return json(req, res, 200, { delegations: [...legacy, ...workflows] });
1610
2498
  }
1611
2499
  const dismissDelegation = routeParam(routes.dismissDelegation, req.method, pathname);
1612
2500
  if (dismissDelegation) {
@@ -1922,13 +2810,13 @@ const server = http.createServer(async (req, res) => {
1922
2810
  return json(req, res, discardStagedAttachment(STAGED_ATTACHMENTS_DIR, stagedAttachment) ? 200 : 404, {
1923
2811
  ok: true
1924
2812
  });
1925
- // POST /api/workspaces { repo, prompt, workflow? | model?/effort?/plan?/fast?, send? }
2813
+ // POST /api/workspaces { repo, prompt, model?/effort?/plan?/fast?, send? }
1926
2814
  // — create a workspace via Conductor's deep link, then configure its first chat.
1927
2815
  if (isRoute(routes.createWorkspace, req.method, pathname)) {
1928
2816
  const body = JSON.parse((await readBody(req)) || '{}');
1929
2817
  const attachmentIds = body.attachmentIds ?? [];
1930
- if (body.workflow !== undefined && typeof body.workflow !== 'boolean')
1931
- return json(req, res, 400, { error: 'workflow must be a boolean' });
2818
+ if ('workflow' in body)
2819
+ return json(req, res, 400, { error: 'Workflow starts through POST /api/workflows.' });
1932
2820
  if (body.model !== undefined && typeof body.model !== 'string')
1933
2821
  return json(req, res, 400, { error: 'model must be a picker label' });
1934
2822
  if (body.effort !== undefined && typeof body.effort !== 'string')
@@ -1946,10 +2834,6 @@ const server = http.createServer(async (req, res) => {
1946
2834
  plan: body.plan,
1947
2835
  fast: body.fast
1948
2836
  };
1949
- const hasRequestedAgent = Object.values(requestedAgent).some(value => value !== undefined);
1950
- if (body.workflow && hasRequestedAgent) {
1951
- return json(req, res, 400, { error: 'workflow mode owns model settings; omit model, effort, plan, and fast' });
1952
- }
1953
2837
  if (!Array.isArray(attachmentIds) || attachmentIds.some(id => typeof id !== 'string'))
1954
2838
  return json(req, res, 400, { error: 'attachment ids must be a list of strings' });
1955
2839
  const attachments = stagedAttachments(STAGED_ATTACHMENTS_DIR, attachmentIds);
@@ -1962,18 +2846,8 @@ const server = http.createServer(async (req, res) => {
1962
2846
  .join('\n');
1963
2847
  if (!objective && !body.repo)
1964
2848
  return json(req, res, 400, { error: 'need a repo or a prompt' });
1965
- let workflowRoot;
1966
- if (body.workflow) {
1967
- const stored = roleStore.read();
1968
- if (stored.warning)
1969
- return json(req, res, 500, { error: stored.warning });
1970
- const prepared = prepareWorkflowRoot(stored.config, modelCache.list(), objective);
1971
- if (!prepared.ok)
1972
- return json(req, res, delegationHttpStatus(prepared.error), { error: prepared.error.message });
1973
- workflowRoot = prepared;
1974
- }
1975
- const prompt = workflowRoot?.prompt ?? objective;
1976
- const agent = workflowRoot?.agent ?? requestedAgent;
2849
+ const prompt = objective;
2850
+ const agent = requestedAgent;
1977
2851
  const configureAgent = Object.values(agent).some(value => value !== undefined);
1978
2852
  // Resolve the repo to a real path: an unmatched `path` would silently land
1979
2853
  // the workspace in whichever repo Conductor happens to list first.
@@ -1999,7 +2873,7 @@ const server = http.createServer(async (req, res) => {
1999
2873
  // callers into waiting.
2000
2874
  // Whatever happens, the prompt is already pre-filled in Conductor's composer.
2001
2875
  const settled = prompt || configureAgent
2002
- ? firstPrompts.enqueue(created.id, prompt, body.sendImmediately !== false, attachmentIds, configureAgent ? agent : undefined, workflowRoot?.role)
2876
+ ? firstPrompts.enqueue(created.id, prompt, body.sendImmediately !== false, attachmentIds, configureAgent ? agent : undefined)
2003
2877
  : null;
2004
2878
  const failed = settled && body.send === true ? await settled : null;
2005
2879
  settled?.catch(() => undefined); // fire-and-forget: it reports failure, it never rejects
@@ -2009,7 +2883,6 @@ const server = http.createServer(async (req, res) => {
2009
2883
  workspace: reads.getWorkspace(created.id) ?? created,
2010
2884
  pendingPrompt: prompt || undefined,
2011
2885
  model: agent.model,
2012
- workflow: workflowRoot ? { role: WORKFLOW_ROOT_ROLE, model: workflowRoot.resolvedRole.model } : undefined,
2013
2886
  sent: body.send === true && !!prompt && !failed,
2014
2887
  configured: body.send === true && configureAgent && !failed,
2015
2888
  warning: failed?.error &&
@@ -2075,9 +2948,13 @@ const server = http.createServer(async (req, res) => {
2075
2948
  const ws = reads.getWorkspace(listSessionsIn);
2076
2949
  const store = ws ? delegationStore(ws) : null;
2077
2950
  const roles = store?.sessionRoles();
2951
+ const enriched = ws;
2952
+ if (enriched)
2953
+ attachWorkflowState([enriched]);
2954
+ const sessionRoles = { ...(roles?.sessions ?? {}), ...(enriched?.session_roles ?? {}) };
2078
2955
  return json(req, res, 200, {
2079
2956
  sessions: reads.listSessions(listSessionsIn),
2080
- ...(roles && Object.keys(roles.sessions).length ? { session_roles: roles.sessions } : {})
2957
+ ...(Object.keys(sessionRoles).length ? { session_roles: sessionRoles } : {})
2081
2958
  });
2082
2959
  }
2083
2960
  // POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
@@ -2329,6 +3206,9 @@ const server = http.createServer(async (req, res) => {
2329
3206
  const modelsOf = routeParam(routes.models, req.method, pathname);
2330
3207
  if (modelsOf) {
2331
3208
  const sessionId = modelsOf;
3209
+ const frozen = workflowFrozenError(sessionId);
3210
+ if (frozen)
3211
+ return json(req, res, 409, frozen);
2332
3212
  const ws = reads.getWorkspace(url.searchParams.get('workspaceId') ?? '');
2333
3213
  if (!ws)
2334
3214
  return json(req, res, 404, { error: 'workspace for session not found' });
@@ -2347,6 +3227,9 @@ const server = http.createServer(async (req, res) => {
2347
3227
  const defaultModelOf = routeParam(routes.defaultModel, req.method, pathname);
2348
3228
  if (defaultModelOf) {
2349
3229
  const sessionId = defaultModelOf;
3230
+ const frozen = workflowFrozenError(sessionId);
3231
+ if (frozen)
3232
+ return json(req, res, 409, frozen);
2350
3233
  const body = JSON.parse((await readBody(req)) || '{}');
2351
3234
  if (typeof body.model !== 'string' || !body.model.trim()) {
2352
3235
  return json(req, res, 400, { error: 'model must be a picker label' });
@@ -2377,6 +3260,10 @@ const server = http.createServer(async (req, res) => {
2377
3260
  if (body.effort && !EFFORT_LABELS[body.effort]) {
2378
3261
  return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
2379
3262
  }
3263
+ const frozen = workflowFrozenError(sessionId);
3264
+ if (frozen && (body.model !== undefined || body.effort !== undefined || body.fast !== undefined)) {
3265
+ return json(req, res, 409, frozen);
3266
+ }
2380
3267
  const ws = body.workspaceId
2381
3268
  ? reads.getWorkspace(body.workspaceId)
2382
3269
  : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
@@ -2544,28 +3431,16 @@ const server = http.createServer(async (req, res) => {
2544
3431
  }
2545
3432
  const delegateFrom = routeParam(routes.delegateTask, req.method, pathname);
2546
3433
  if (delegateFrom) {
2547
- let body;
2548
- try {
2549
- const parsed = JSON.parse((await readBody(req)) || '{}');
2550
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2551
- throw new Error('not an object');
2552
- body = parsed;
2553
- }
2554
- catch {
2555
- return json(req, res, 400, intakeError('invalid_request', 'could not read delegation request'));
2556
- }
2557
- const result = acceptDelegation(delegateFrom, body);
2558
- return json(req, res, result.ok ? 202 : delegationHttpStatus(result.error), result);
3434
+ return json(req, res, 409, intakeError('workflow_required', 'delegate_task is available only inside a UI-authorized Workflow.'));
2559
3435
  }
2560
- // POST /api/sessions/:id/prompt { text, agent? | workflow? } — ordinary staged
2561
- // settings or the planning-root role are applied before the prompt so the two
2562
- // can't come apart (and so both park together when the Mac is locked).
3436
+ // POST /api/sessions/:id/prompt { text, agent? } — ordinary staged settings
3437
+ // are applied before the prompt so the two cannot come apart.
2563
3438
  const promptTo = routeParam(routes.sendPrompt, req.method, pathname);
2564
3439
  if (promptTo) {
2565
3440
  const sessionId = promptTo;
2566
3441
  const body = JSON.parse((await readBody(req)) || '{}');
2567
- if (body.workflow !== undefined && typeof body.workflow !== 'boolean') {
2568
- return json(req, res, 400, { error: 'workflow must be a boolean' });
3442
+ if ('workflow' in body) {
3443
+ return json(req, res, 400, { error: 'Workflow starts through POST /api/workflows.' });
2569
3444
  }
2570
3445
  if (body.text !== undefined && typeof body.text !== 'string') {
2571
3446
  return json(req, res, 400, { error: 'prompt must be a string' });
@@ -2577,8 +3452,13 @@ const server = http.createServer(async (req, res) => {
2577
3452
  return json(req, res, 400, { error: 'agent must be a settings object' });
2578
3453
  }
2579
3454
  const requestedAgent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
2580
- if (body.workflow && (requestedAgent || body.queue === true)) {
2581
- return json(req, res, 400, { error: 'workflow owns agent settings and cannot be queued behind another turn' });
3455
+ const frozen = workflowFrozenError(sessionId);
3456
+ if (frozen &&
3457
+ requestedAgent &&
3458
+ (requestedAgent.model !== undefined ||
3459
+ requestedAgent.effort !== undefined ||
3460
+ requestedAgent.fast !== undefined)) {
3461
+ return json(req, res, 409, frozen);
2582
3462
  }
2583
3463
  const ws = body.workspaceId
2584
3464
  ? reads.getWorkspace(body.workspaceId)
@@ -2600,61 +3480,8 @@ const server = http.createServer(async (req, res) => {
2600
3480
  console.info(`[relay] send to ${ws.branch ?? ws.id} already delivered for this tap — answering, not resending`);
2601
3481
  }
2602
3482
  const answer = await sendOnce.run(body.clientId, async () => {
2603
- let text = rawText;
2604
- let agent = requestedAgent;
2605
- if (body.workflow) {
2606
- const session = reads.getSession(sessionId);
2607
- const hasUserRow = reads.getMessages(sessionId).entries.some(entry => entry.role === 'user');
2608
- if (!session || session.last_user_message_at || hasUserRow) {
2609
- return {
2610
- status: 409,
2611
- body: {
2612
- ok: false,
2613
- strategy: actuator.name,
2614
- error: 'Workflow mode can only start with a new chat’s first message.'
2615
- }
2616
- };
2617
- }
2618
- if (session.status === 'working' || session.background_tasks.length) {
2619
- return {
2620
- status: 409,
2621
- body: {
2622
- ok: false,
2623
- strategy: actuator.name,
2624
- error: 'Workflow mode needs an idle new chat.'
2625
- }
2626
- };
2627
- }
2628
- const stored = roleStore.read();
2629
- if (stored.warning) {
2630
- return { status: 500, body: { ok: false, strategy: actuator.name, error: stored.warning } };
2631
- }
2632
- const prepared = prepareWorkflowRoot(stored.config, modelCache.list(), rawText);
2633
- if (!prepared.ok) {
2634
- return {
2635
- status: delegationHttpStatus(prepared.error),
2636
- body: { ok: false, strategy: actuator.name, error: prepared.error.message }
2637
- };
2638
- }
2639
- try {
2640
- const assigned = assignWorkflowRoot(ws, sessionId, prepared.role, Date.now());
2641
- if (!assigned.ok) {
2642
- return { status: 409, body: { ok: false, strategy: actuator.name, error: assigned.error } };
2643
- }
2644
- }
2645
- catch (err) {
2646
- return {
2647
- status: 409,
2648
- body: {
2649
- ok: false,
2650
- strategy: actuator.name,
2651
- error: err instanceof Error ? err.message : String(err)
2652
- }
2653
- };
2654
- }
2655
- text = prepared.prompt;
2656
- agent = prepared.agent;
2657
- }
3483
+ const text = rawText;
3484
+ const agent = requestedAgent;
2658
3485
  // A failed first-prompt entry offers the same Retry button as an ordinary
2659
3486
  // prompt. If staging had been the failure, put its files in place before
2660
3487
  // that retry reaches the attachment tokens.
@@ -2925,6 +3752,11 @@ const server = http.createServer(async (req, res) => {
2925
3752
  catch (err) {
2926
3753
  if (err instanceof PayloadTooLargeError)
2927
3754
  return json(req, res, 413, { error: err.message });
3755
+ const workflowError = workflowHttpError(err);
3756
+ if (workflowError) {
3757
+ console.warn(`[workflow] ${req.method} ${pathname}: ${workflowError.error.code}: ${workflowError.error.message}`);
3758
+ return json(req, res, workflowError.status, { ok: false, error: workflowError.error });
3759
+ }
2928
3760
  // A refused UI turn is not a server fault and a retry is the right move, so it gets
2929
3761
  // 503 + Retry-After rather than a 500 that reads as "the relay is broken".
2930
3762
  if (err instanceof UiBusyError) {
@@ -2942,6 +3774,13 @@ server.listen(cfg.port, cfg.host, () => {
2942
3774
  console.info(` voice: 127.0.0.1:${voicePort()}${voiceBroker ? '' : ' (waiting for OpenAI config)'}`);
2943
3775
  void voiceBroker?.restore();
2944
3776
  });
3777
+ if (orchestration.writable) {
3778
+ setInterval(() => orchestration.heartbeatRelayInstance(relayIdentity), 2_000).unref();
3779
+ setInterval(() => void recoverUiLease(), 2_000).unref();
3780
+ setInterval(wakeWorkflows, 2_000).unref();
3781
+ setInterval(() => orchestration.compactTerminalRuns({ olderThan: Date.now() - 30 * 24 * 60 * 60_000 }), 24 * 60 * 60_000).unref();
3782
+ queueMicrotask(() => void recoverUiLease().finally(wakeWorkflows));
3783
+ }
2945
3784
  // Under `yarn dev` the app comes from Vite and only /api comes from here, so the URL worth
2946
3785
  // printing is Vite's — carrying the token, which Vite itself has no way to print.
2947
3786
  const dev = cfg.devWebPort !== undefined;