conductor-remote 1.96.2 → 1.96.4

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.
@@ -4,12 +4,14 @@ import http from 'node:http';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import zlib from 'node:zlib';
7
+ import { applyAgentConfig } from "./agent-config.js";
7
8
  import { attachmentPrompt, writeAttachment } from "./attachments.js";
8
9
  import { startAutoUpdate, updateStatus } from "./autoupdate.js";
9
10
  import { attachChangeStats } from "./change-stats.js";
10
11
  import { isDefaultEffortLevel, readDefaultEfforts, writeDefaultEfforts } from "./conductor-settings.js";
11
12
  import { loadConfig, stateDir } from "./config.js";
12
13
  import { ConductorDb } from "./db.js";
14
+ import { DelegationQueue, DelegationStore } from "./delegations.js";
13
15
  import { DevServerController } from "./dev-server.js";
14
16
  import { isAllowedPreviewPath, parseFileReference, parseImageReference } from "./file-preview.js";
15
17
  import { FirstPromptQueue } from "./firstprompt.js";
@@ -27,22 +29,26 @@ import { PlanUsageService } from "./plan-usage.js";
27
29
  import { attachPrStatus } from "./pr.js";
28
30
  import { readPrefs, writePrefs } from "./prefs.js";
29
31
  import { Reads } from "./reads.js";
32
+ import { decodeRoles, RoleStore, resolveRole, roleModelIssues } from "./roles.js";
30
33
  import { isRoute, routeParam, routes } from "./routes.js";
31
34
  import { foldHits, queryTokens, SearchIndex } from "./search.js";
32
35
  import { SendOnce } from "./sendonce.js";
36
+ import { SessionPoller } from "./session-poller.js";
33
37
  import { readSettings, writeSettings } from "./settings.js";
34
- import { VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
38
+ import { responseErrorMessage, VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
35
39
  import { discardStagedAttachment, materializeStagedAttachments, pruneStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
36
40
  import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
37
41
  import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
38
42
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
39
- import { archiveWorkspace, closeChat, continueWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, planSettingForUi, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiQueueDepth, WORKSPACE_STATUS_LABELS, withUiPriority } from "./writes.js";
43
+ import { prepareWorkflowRoot, WORKFLOW_ROOT_ROLE } from "./workflow.js";
44
+ 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";
40
45
  // Before anything that logs: from here on every console line is also kept in memory for
41
46
  // `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
42
47
  installLogCapture();
43
48
  const cfg = loadConfig();
44
49
  const db = new ConductorDb(cfg.dbPath);
45
50
  const reads = new Reads(db, cfg.workspacesRoot);
51
+ const sessionPoller = new SessionPoller(() => reads.listSessionStates());
46
52
  const actuator = pickActuator(cfg.writeStrategy);
47
53
  const devServers = new DevServerController();
48
54
  if (cfg.devWebPort !== undefined && process.env.CONDUCTOR_WORKSPACE_ID) {
@@ -59,6 +65,53 @@ const STAGED_ATTACHMENTS_DIR = path.join(stateDir(), 'attachment-staging');
59
65
  // from a list before Conductor has created its first chat.
60
66
  const modelCache = new ModelCache(path.join(stateDir(), 'model-cache.json'));
61
67
  const planUsage = new PlanUsageService();
68
+ const roleStore = new RoleStore(path.join(stateDir(), 'roles.json'));
69
+ /** One store object per live worktree, so the queue never registers a path twice. */
70
+ const delegationStores = new Map();
71
+ function delegationStore(ws) {
72
+ if (!ws.worktree)
73
+ return null;
74
+ const cached = delegationStores.get(ws.id);
75
+ if (cached?.worktree === ws.worktree)
76
+ return cached.store;
77
+ const store = new DelegationStore(ws.worktree);
78
+ delegationStores.set(ws.id, { worktree: ws.worktree, store });
79
+ return store;
80
+ }
81
+ function liveDelegationStores() {
82
+ return reads.listWorkspaces().flatMap(ws => {
83
+ const store = delegationStore(ws);
84
+ return store ? [store] : [];
85
+ });
86
+ }
87
+ /** Tag a pristine workflow root without ever touching Conductor's Plan control. */
88
+ function assignWorkflowRoot(ws, sessionId, role, assignedAt) {
89
+ const store = delegationStore(ws);
90
+ if (!store)
91
+ return { ok: false, error: 'the workflow worktree is unavailable' };
92
+ if (reads.sessionWorkspaceId(sessionId) !== ws.id) {
93
+ return { ok: false, error: 'the workflow root chat is not in that workspace' };
94
+ }
95
+ const session = reads.getSession(sessionId);
96
+ if (!session)
97
+ return { ok: false, error: 'the workflow root chat is unavailable' };
98
+ if (session.permission_mode === 'plan') {
99
+ return {
100
+ ok: false,
101
+ error: 'the workflow root inherited Plan mode; switch it to ordinary chat mode and retry'
102
+ };
103
+ }
104
+ const assignments = store.sessionRoles();
105
+ if (assignments.warning)
106
+ return { ok: false, error: `cannot assign the workflow root: ${assignments.warning}` };
107
+ const existing = assignments.sessions[sessionId];
108
+ if (existing && existing.role !== role) {
109
+ return { ok: false, error: `the new chat is already assigned to role ${existing.role}` };
110
+ }
111
+ if (!existing)
112
+ store.assign(sessionId, { role, assignedAt });
113
+ return { ok: true };
114
+ }
62
115
  // Full-text index over the chat prose, in the relay's own sidecar DB — never in
63
116
  // Conductor's (see src/search.ts). It backfills in the background and is disposable:
64
117
  // deleting the file rebuilds it on the next start.
@@ -92,7 +145,7 @@ const mcpTools = createTools(async (route, opts = {}) => {
92
145
  const payload = (await res.json().catch(() => ({})));
93
146
  if (!res.ok) {
94
147
  const busy = res.status === 503 ? ' (Conductor’s UI is busy — retry shortly)' : '';
95
- throw new Error(`${payload.error || `HTTP ${res.status}`}${busy}`);
148
+ throw new Error(`${responseErrorMessage(payload.error, `HTTP ${res.status}`)}${busy}`);
96
149
  }
97
150
  return payload;
98
151
  });
@@ -148,6 +201,11 @@ async function createWorkspaceAndRead(prompt, repoPath, repoName) {
148
201
  function deliveredSince(sessionId, text, since) {
149
202
  return reads.promptDeliveredSince(sessionId, text, since);
150
203
  }
204
+ function deliveredRowSince(sessionId, text, sinceRowid) {
205
+ const target = text.trim();
206
+ const { entries } = reads.getMessages(sessionId, sinceRowid);
207
+ return entries.find(e => e.role === 'user' && e.text.trim() === target)?.rowid ?? null;
208
+ }
151
209
  /**
152
210
  * Watch for that row, ending on a check rather than a sleep, and never past
153
211
  * `budgetDeadline`. Conductor records the row or outbox item right after the send
@@ -161,6 +219,17 @@ function deliveredSince(sessionId, text, since) {
161
219
  * a confirm *followed by another attempt* always gets its full window. Only the
162
220
  * last confirm of all can be cut short, and nothing follows it to duplicate a row.
163
221
  */
222
+ async function confirmDeliveryRow(sessionId, text, sinceRowid, budgetDeadline) {
223
+ const stopAt = Math.min(Date.now() + CONFIRM_WINDOW_MS, budgetDeadline);
224
+ for (;;) {
225
+ const rowid = deliveredRowSince(sessionId, text, sinceRowid);
226
+ if (rowid !== null)
227
+ return rowid;
228
+ if (Date.now() >= stopAt)
229
+ return null;
230
+ await sleep(300);
231
+ }
232
+ }
164
233
  async function confirmDelivery(sessionId, text, since, budgetDeadline) {
165
234
  const stopAt = Math.min(Date.now() + CONFIRM_WINDOW_MS, budgetDeadline);
166
235
  for (;;) {
@@ -241,26 +310,25 @@ async function openChat(ws) {
241
310
  // The new session lands in the DB a beat after Cmd+T — poll for the fresh id.
242
311
  for (let i = 0; i < 12; i++) {
243
312
  await sleep(500);
244
- const fresh = reads.listSessions(ws.id).find(s => !before.has(s.id));
245
- if (fresh)
246
- return { sessionId: fresh.id };
313
+ const fresh = reads.listSessions(ws.id).filter(s => !before.has(s.id));
314
+ if (fresh.length > 1) {
315
+ return {
316
+ error: true,
317
+ retryable: false,
318
+ result: {
319
+ ok: false,
320
+ strategy: actuator.name,
321
+ error: 'more than one new chat appeared; refusing to guess which one this request opened'
322
+ }
323
+ };
324
+ }
325
+ if (fresh[0])
326
+ return { sessionId: fresh[0].id };
247
327
  }
248
328
  // The tab is almost certainly on screen; only its id is missing. Say so rather than
249
329
  // failing the call, so a caller can still tell the user where the work went.
250
330
  return { sessionId: null };
251
331
  }
252
- /** Poll the DB until Conductor records the setting we just drove through the UI. */
253
- async function confirmAgentOptions(ws, sessionId, opts) {
254
- for (let attempt = 0; attempt < 10; attempt++) {
255
- const s = reads.listSessions(ws.id).find(row => row.id === sessionId);
256
- const effortOk = !opts.effort || s?.claude_effort_level === opts.effort;
257
- const planOk = opts.plan === undefined || s?.permission_mode === (opts.plan ? 'plan' : 'default');
258
- if (effortOk && planOk)
259
- return true;
260
- await sleep(300);
261
- }
262
- return false;
263
- }
264
332
  /**
265
333
  * Deliver a prompt to one chat and confirm it landed, retrying until the caller's
266
334
  * budget runs out. The single write path: the phone's own sends go through it, and so
@@ -387,6 +455,15 @@ const firstPrompts = new FirstPromptQueue(path.join(stateDir(), 'first-prompts.j
387
455
  return { ok: false, error: err instanceof Error ? err.message : 'the attached files could not be copied' };
388
456
  }
389
457
  },
458
+ assignRole: async (workspaceId, sessionId, role, assignedAt) => {
459
+ try {
460
+ const ws = reads.getWorkspace(workspaceId);
461
+ return ws ? assignWorkflowRoot(ws, sessionId, role, assignedAt) : { ok: false, error: 'the workspace is gone' };
462
+ }
463
+ catch (err) {
464
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
465
+ }
466
+ },
390
467
  discard: attachmentIds => {
391
468
  for (const id of attachmentIds)
392
469
  discardStagedAttachment(STAGED_ATTACHMENTS_DIR, id);
@@ -428,31 +505,426 @@ async function applyAgentPatch(ws, sessionId, patch) {
428
505
  const located = locateChat(ws, sessionId);
429
506
  if ('error' in located)
430
507
  return { ok: false, error: located.error };
431
- const desired = {
432
- effort: patch.effort,
433
- plan: patch.plan,
434
- model: patch.model,
435
- toggleFast: patch.fast === undefined ? false : patch.fast !== Boolean(located.session?.fast_mode)
436
- };
437
- const opts = {
438
- ...desired,
439
- plan: planSettingForUi(patch.plan, located.session?.permission_mode)
440
- };
441
- const result = await setAgentOptions({ workspace: ws, sessionId, tab: located.tab }, opts);
508
+ const target = { workspace: ws, sessionId, tab: located.tab };
509
+ const result = await applyAgentConfig(patch, {
510
+ read: () => {
511
+ const session = reads.listSessions(ws.id).find(row => row.id === sessionId);
512
+ if (!session)
513
+ return undefined;
514
+ return {
515
+ agentType: session.agent_type,
516
+ model: session.model,
517
+ effort: session.claude_effort_level,
518
+ plan: session.permission_mode === 'plan',
519
+ fast: Boolean(session.fast_mode)
520
+ };
521
+ },
522
+ write: options => setAgentOptions(target, options),
523
+ wait: () => sleep(300)
524
+ });
442
525
  if (!result.ok)
443
- return { ok: false, error: result.error };
444
- // Confirm the requested state, including settings that needed no UI action.
445
- // A model change can redraw controls, so the pre-flight DB read is not itself
446
- // enough to call the whole patch successful.
447
- if (!(await confirmAgentOptions(ws, sessionId, desired))) {
448
- return { ok: false, error: 'Conductor didn’t record the change — it may have been asleep. Try again.' };
449
- }
526
+ return result;
450
527
  if (patch.model) {
451
528
  const session = reads.listSessions(ws.id).find(row => row.id === sessionId);
452
529
  modelCache.rememberModel(session?.agent_type, patch.model);
453
530
  }
454
531
  return { ok: true };
455
532
  }
533
+ function delegationError(code, error, retryable = true) {
534
+ const clean = withoutWindowEvidence(error);
535
+ if (clean !== error)
536
+ console.warn(`[relay] ${error}`);
537
+ return { ok: false, code, error: clean, retryable, blocked: lockBlocked(error) || uiBusy(error) };
538
+ }
539
+ function wireAttachment(written) {
540
+ return {
541
+ name: written.name,
542
+ path: written.relPath,
543
+ bytes: written.bytes,
544
+ token: written.token
545
+ };
546
+ }
547
+ /** Write the frozen parent transcript cut before opening its child tab. */
548
+ function delegationHandoff(job, ws) {
549
+ if (!ws.worktree)
550
+ throw new Error('worktree path unresolved');
551
+ const source = reads.getSession(job.parentSessionId);
552
+ if (!source)
553
+ throw new Error('parent chat not found in that workspace');
554
+ const { entries } = reads.getMessages(job.parentSessionId);
555
+ const cut = job.throughRowid === undefined ? { entries, later: 0 } : transcriptThrough(entries, job.throughRowid);
556
+ if (!cut)
557
+ throw new Error('the requested handoff message is not in the parent chat');
558
+ const rendered = renderTranscript(cut.entries, { thinking: job.includeThinking, tools: false });
559
+ if (!rendered.kept)
560
+ throw new Error('the parent chat has nothing to hand off yet');
561
+ const title = source.title?.trim() || 'chat';
562
+ const header = [
563
+ `# Transcript of ${title}`,
564
+ '',
565
+ [ws.repo_name, ws.branch].filter(Boolean).join(' · '),
566
+ `Delegation ${job.id} copied this chat through ${job.throughRowid ?? 'its latest row'}.`,
567
+ cut.later ? `${cut.later} later ${cut.later === 1 ? 'entry is' : 'entries are'} intentionally omitted.` : '',
568
+ '',
569
+ ''
570
+ ]
571
+ .filter((line, index) => line || index < 2 || index >= 5)
572
+ .join('\n');
573
+ return wireAttachment(writeAttachment(ws.worktree, `Transcript of ${title}.md`, header + rendered.text));
574
+ }
575
+ async function openDelegation(job) {
576
+ const ws = reads.getWorkspace(job.workspaceId);
577
+ if (!ws)
578
+ return delegationError('workspace_not_found', 'the delegated workspace is gone', false);
579
+ if (!ws.worktree)
580
+ return delegationError('worktree_unavailable', 'worktree path unresolved', false);
581
+ if ((await screenLocked()) === true) {
582
+ return delegationError('opening_failed', 'The Mac is locked — unlock it and try again.');
583
+ }
584
+ let handoff;
585
+ try {
586
+ handoff = delegationHandoff(job, ws);
587
+ }
588
+ catch (err) {
589
+ return delegationError('opening_failed', err instanceof Error ? err.message : String(err), false);
590
+ }
591
+ const opened = await withUiPriority('background', () => openChat(ws));
592
+ if ('error' in opened) {
593
+ return delegationError('opening_failed', opened.result.error ?? 'Conductor did not open a child chat', opened.retryable !== false);
594
+ }
595
+ if (!opened.sessionId)
596
+ return delegationError('opening_failed', 'Conductor opened a tab but did not record its chat id', false);
597
+ return { ok: true, childSessionId: opened.sessionId, handoff };
598
+ }
599
+ async function configureDelegation(job) {
600
+ const ws = reads.getWorkspace(job.workspaceId);
601
+ if (!ws)
602
+ return delegationError('workspace_not_found', 'the delegated workspace is gone', false);
603
+ if (!job.childSessionId)
604
+ return delegationError('state_invalid', 'the delegated child id is missing', false);
605
+ const before = reads.getSession(job.childSessionId);
606
+ if (!before)
607
+ return delegationError('session_not_found', 'the delegated child chat is gone', false);
608
+ // Never touch the buggy Plan control for orchestration. A child that somehow
609
+ // inherited Plan is refused before its task is sent instead of relying on it.
610
+ if (before.permission_mode === 'plan') {
611
+ return delegationError('configuration_failed', 'the child inherited Plan mode; delegated roles require default mode', false);
612
+ }
613
+ const applied = await withUiPriority('background', () => applyAgentPatch(ws, job.childSessionId, {
614
+ model: job.resolvedRole.model,
615
+ effort: job.resolvedRole.effort,
616
+ fast: job.resolvedRole.fast
617
+ }));
618
+ if (!applied.ok)
619
+ return delegationError('configuration_failed', applied.error ?? 'agent configuration did not stick');
620
+ const after = reads.getSession(job.childSessionId);
621
+ if (!after)
622
+ return delegationError('session_not_found', 'the configured child chat disappeared', false);
623
+ if (after.permission_mode === 'plan') {
624
+ return delegationError('configuration_failed', 'the child entered Plan mode; delegated roles require default mode', false);
625
+ }
626
+ if (after.agent_type !== job.resolvedRole.agentType) {
627
+ return delegationError('configuration_failed', `Conductor recorded provider ${after.agent_type ?? 'unknown'}, not ${job.resolvedRole.agentType}`);
628
+ }
629
+ return { ok: true };
630
+ }
631
+ function delegatedPrompt(job) {
632
+ const handoff = job.handoff;
633
+ if (!handoff)
634
+ throw new Error('the delegated handoff is missing');
635
+ const task = attachmentPrompt(handoff.token, job.prompt);
636
+ return job.resolvedRole.preamble?.trim() ? `${job.resolvedRole.preamble.trim()}\n\n${task}` : task;
637
+ }
638
+ async function sendDelegation(job) {
639
+ const ws = reads.getWorkspace(job.workspaceId);
640
+ if (!ws)
641
+ return delegationError('workspace_not_found', 'the delegated workspace is gone', false);
642
+ if (!job.childSessionId)
643
+ return delegationError('state_invalid', 'the delegated child id is missing', false);
644
+ let text;
645
+ try {
646
+ text = delegatedPrompt(job);
647
+ }
648
+ catch (err) {
649
+ return delegationError('state_invalid', err instanceof Error ? err.message : String(err), false);
650
+ }
651
+ const cursor = reads.getMessages(job.childSessionId).cursor;
652
+ const result = await withUiPriority('background', () => deliverPrompt(ws, job.childSessionId, text));
653
+ if (!result.ok)
654
+ return delegationError('send_failed', result.error ?? 'the delegated prompt did not land');
655
+ const sentRowid = deliveredRowSince(job.childSessionId, text, cursor);
656
+ if (sentRowid === null)
657
+ return delegationError('send_failed', 'the delegated prompt has no transcript receipt');
658
+ return { ok: true, sentRowid };
659
+ }
660
+ function delegationCompletion(job) {
661
+ if (!job.childSessionId || job.sentRowid === undefined)
662
+ return null;
663
+ const child = reads.getSession(job.childSessionId);
664
+ if (!child) {
665
+ return {
666
+ outcome: { kind: 'error', error: 'the delegated child chat disappeared' }
667
+ };
668
+ }
669
+ const assistants = reads
670
+ .getMessages(job.childSessionId, job.sentRowid)
671
+ .entries.filter(entry => entry.role === 'assistant' && entry.text.trim());
672
+ const last = assistants.at(-1);
673
+ if (child.status === 'error') {
674
+ return {
675
+ outcome: {
676
+ kind: 'error',
677
+ error: 'the delegated agent stopped with an error',
678
+ ...(last ? { assistantRowid: last.rowid, text: last.text.trim() } : {})
679
+ },
680
+ ...(last ? { completionRowid: last.rowid } : {})
681
+ };
682
+ }
683
+ if (child.status !== 'idle' || child.background_tasks.length || !last)
684
+ return null;
685
+ return {
686
+ outcome: { kind: 'success', assistantRowid: last.rowid, text: last.text.trim() },
687
+ completionRowid: last.rowid
688
+ };
689
+ }
690
+ /** Keep the structured Baton tail when present; otherwise the complete answer is the Baton. */
691
+ function batonText(text) {
692
+ const match = /^## Baton\b/im.exec(text);
693
+ return match ? text.slice(match.index).trim() : text.trim();
694
+ }
695
+ function delegationReturnAttachment(job, ws) {
696
+ if (!ws.worktree || !job.childSessionId || job.sentRowid === undefined)
697
+ throw new Error('return state is incomplete');
698
+ const rendered = renderTranscript(reads.getMessages(job.childSessionId, job.sentRowid).entries, {
699
+ thinking: true,
700
+ tools: false
701
+ });
702
+ const outcomeText = job.outcome
703
+ ? job.outcome.kind === 'success'
704
+ ? job.outcome.text
705
+ : (job.outcome.text ?? job.outcome.error)
706
+ : '(no transcript prose)';
707
+ const body = [
708
+ `# Delegated ${job.role} result`,
709
+ '',
710
+ `Delegation: ${job.id}`,
711
+ `Child chat: ${job.childSessionId}`,
712
+ '',
713
+ rendered.text || outcomeText
714
+ ].join('\n');
715
+ return wireAttachment(writeAttachment(ws.worktree, `Delegated ${job.role} result.md`, body));
716
+ }
717
+ function delegationReturnText(job, attachment) {
718
+ if (!job.outcome)
719
+ throw new Error('the delegated outcome is missing');
720
+ const result = job.outcome.kind === 'success' ? batonText(job.outcome.text) : batonText(job.outcome.text ?? job.outcome.error);
721
+ const verb = job.outcome.kind === 'success' ? 'completed' : 'failed';
722
+ return [`Delegated ${job.role} task ${job.id} ${verb}.`, '', result, '', attachment.token].join('\n');
723
+ }
724
+ async function returnDelegation(job) {
725
+ const ws = reads.getWorkspace(job.workspaceId);
726
+ if (!ws)
727
+ return delegationError('workspace_not_found', 'the delegated workspace is gone', false);
728
+ if (!reads.getSession(job.parentSessionId)) {
729
+ return delegationError('session_not_found', 'the parent chat is gone', false);
730
+ }
731
+ if (job.returnCursor !== undefined) {
732
+ if (!job.returnAttachment || !job.returnText) {
733
+ return delegationError('state_invalid', 'the queued return receipt state is incomplete', false);
734
+ }
735
+ const rowid = deliveredRowSince(job.parentSessionId, job.returnText, job.returnCursor);
736
+ return rowid === null
737
+ ? {
738
+ ok: true,
739
+ pending: true,
740
+ returnCursor: job.returnCursor,
741
+ returnAttachment: job.returnAttachment,
742
+ returnText: job.returnText
743
+ }
744
+ : { ok: true, returnRowid: rowid };
745
+ }
746
+ let attachment;
747
+ let text;
748
+ try {
749
+ attachment = delegationReturnAttachment(job, ws);
750
+ text = delegationReturnText(job, attachment);
751
+ }
752
+ catch (err) {
753
+ return delegationError('return_failed', err instanceof Error ? err.message : String(err), false);
754
+ }
755
+ const cursor = reads.getMessages(job.parentSessionId).cursor;
756
+ if (job.returnMode === 'steer') {
757
+ const result = await withUiPriority('background', () => deliverPrompt(ws, job.parentSessionId, text, SEND_BUDGET_MS, false));
758
+ if (!result.ok)
759
+ return delegationError('return_failed', result.error ?? 'the delegated result did not return');
760
+ const rowid = deliveredRowSince(job.parentSessionId, text, cursor);
761
+ return rowid === null
762
+ ? delegationError('return_failed', 'the delegated result has no transcript receipt')
763
+ : { ok: true, returnRowid: rowid };
764
+ }
765
+ const located = locateChat(ws, job.parentSessionId);
766
+ if ('error' in located)
767
+ return delegationError('return_failed', located.error, false);
768
+ const result = await withUiPriority('background', () => actuator.send({ workspace: ws, sessionId: job.parentSessionId, tab: located.tab }, text, {
769
+ deadline: Date.now() + SEND_BUDGET_MS,
770
+ queue: true
771
+ }));
772
+ const immediate = deliveredRowSince(job.parentSessionId, text, cursor);
773
+ if (immediate !== null)
774
+ return { ok: true, returnRowid: immediate };
775
+ if (!result.ok) {
776
+ const late = await confirmDeliveryRow(job.parentSessionId, text, cursor, Date.now() + CONFIRM_WINDOW_MS);
777
+ if (late !== null)
778
+ return { ok: true, returnRowid: late };
779
+ return delegationError('return_failed', result.error ?? 'Conductor did not accept the queued result');
780
+ }
781
+ return {
782
+ ok: true,
783
+ pending: true,
784
+ returnCursor: cursor,
785
+ returnAttachment: attachment,
786
+ returnText: text
787
+ };
788
+ }
789
+ const delegationQueue = new DelegationQueue({
790
+ open: openDelegation,
791
+ configure: configureDelegation,
792
+ send: sendDelegation,
793
+ completion: delegationCompletion,
794
+ returnResult: returnDelegation
795
+ }, {
796
+ blockedError: error => error instanceof UiBusyError
797
+ });
798
+ sessionPoller.subscribe(() => {
799
+ void delegationQueue.wake();
800
+ });
801
+ function projectDelegation(job) {
802
+ return {
803
+ id: job.id,
804
+ workspaceId: job.workspaceId,
805
+ parentSessionId: job.parentSessionId,
806
+ ...(job.childSessionId ? { childSessionId: job.childSessionId } : {}),
807
+ role: job.role,
808
+ resolvedRole: job.resolvedRole,
809
+ prompt: job.prompt,
810
+ returnMode: job.returnMode,
811
+ status: job.status,
812
+ attempts: job.attempts,
813
+ createdAt: job.createdAt,
814
+ updatedAt: job.updatedAt,
815
+ ...(job.outcome ? { outcome: job.outcome } : {}),
816
+ ...(job.failure ? { failure: job.failure } : {})
817
+ };
818
+ }
819
+ function attachDelegationState(workspaces) {
820
+ for (const ws of workspaces) {
821
+ const store = delegationStore(ws);
822
+ if (!store)
823
+ continue;
824
+ const listed = store.list();
825
+ const jobs = listed.jobs.filter(job => job.status !== 'returned').map(projectDelegation);
826
+ const roles = store.sessionRoles();
827
+ if (jobs.length)
828
+ Object.assign(ws, { delegations: jobs });
829
+ if (Object.keys(roles.sessions).length)
830
+ Object.assign(ws, { session_roles: roles.sessions });
831
+ const warnings = [...listed.warnings.map(warning => `${warning.file}: ${warning.message}`)];
832
+ if (roles.warning)
833
+ warnings.push(`sessions.json: ${roles.warning}`);
834
+ if (warnings.length)
835
+ Object.assign(ws, { delegation_warning: warnings.join('; ') });
836
+ }
837
+ }
838
+ function delegationHttpStatus(error) {
839
+ if (error.code === 'workspace_not_found' ||
840
+ error.code === 'session_not_found' ||
841
+ error.code === 'role_not_found' ||
842
+ error.code === 'delegation_not_found') {
843
+ return 404;
844
+ }
845
+ if (error.code === 'invalid_request')
846
+ return 400;
847
+ if (error.code === 'state_invalid')
848
+ return 500;
849
+ return 409;
850
+ }
851
+ function intakeError(code, message, retryable = false) {
852
+ return { ok: false, error: { code, message, retryable } };
853
+ }
854
+ /** Validate and persist one job; no UI work is awaited by the caller. */
855
+ function acceptDelegation(parentSessionId, body) {
856
+ const roleName = typeof body.role === 'string' ? body.role.trim() : '';
857
+ const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
858
+ if (!roleName || !prompt)
859
+ return intakeError('invalid_request', 'role and prompt are required');
860
+ if (body.returnMode !== undefined && body.returnMode !== 'queue' && body.returnMode !== 'steer') {
861
+ return intakeError('invalid_request', 'returnMode must be queue or steer');
862
+ }
863
+ if (body.throughRowid !== undefined && (!Number.isSafeInteger(body.throughRowid) || body.throughRowid < 1)) {
864
+ return intakeError('invalid_request', 'throughRowid must be a positive integer');
865
+ }
866
+ if (body.includeThinking !== undefined && typeof body.includeThinking !== 'boolean') {
867
+ return intakeError('invalid_request', 'includeThinking must be a boolean');
868
+ }
869
+ const actualWorkspaceId = reads.sessionWorkspaceId(parentSessionId);
870
+ if (!actualWorkspaceId)
871
+ return intakeError('session_not_found', 'parent chat not found');
872
+ if (body.workspaceId && body.workspaceId !== actualWorkspaceId) {
873
+ return intakeError('invalid_request', 'parent chat is not in that workspace');
874
+ }
875
+ const ws = reads.getWorkspace(actualWorkspaceId);
876
+ if (!ws)
877
+ return intakeError('workspace_not_found', 'workspace for parent chat not found');
878
+ if (!ws.worktree)
879
+ return intakeError('worktree_unavailable', 'worktree path unresolved');
880
+ const parent = reads.getSession(parentSessionId);
881
+ if (!parent)
882
+ return intakeError('session_not_found', 'parent chat not found in that workspace');
883
+ if (!parent.agent_type)
884
+ return intakeError('provider_unknown', 'the parent chat provider is unknown');
885
+ const storedRoles = roleStore.read();
886
+ if (storedRoles.warning)
887
+ return intakeError('state_invalid', storedRoles.warning);
888
+ const resolved = resolveRole(storedRoles.config, roleName, modelCache.list());
889
+ if (!resolved.ok)
890
+ return { ok: false, error: resolved.error };
891
+ if (resolved.role.agentType === parent.agent_type) {
892
+ return intakeError('same_provider', `Role ${roleName} uses the parent's ${parent.agent_type} provider.`);
893
+ }
894
+ if (body.throughRowid !== undefined) {
895
+ const { entries } = reads.getMessages(parentSessionId);
896
+ if (!transcriptThrough(entries, body.throughRowid)) {
897
+ return intakeError('invalid_request', 'throughRowid is not in the parent chat');
898
+ }
899
+ }
900
+ const now = Date.now();
901
+ const job = {
902
+ version: 1,
903
+ id: crypto.randomUUID(),
904
+ workspaceId: ws.id,
905
+ parentSessionId,
906
+ role: roleName,
907
+ resolvedRole: resolved.role,
908
+ prompt,
909
+ returnMode: body.returnMode ?? 'queue',
910
+ includeThinking: body.includeThinking !== false,
911
+ ...(body.throughRowid === undefined ? {} : { throughRowid: body.throughRowid }),
912
+ status: 'queued',
913
+ attempts: 0,
914
+ createdAt: now,
915
+ updatedAt: now
916
+ };
917
+ try {
918
+ const store = delegationStore(ws);
919
+ if (!store)
920
+ return intakeError('worktree_unavailable', 'worktree path unresolved');
921
+ delegationQueue.enqueue(store, job);
922
+ }
923
+ catch (err) {
924
+ return intakeError('state_invalid', err instanceof Error ? err.message : String(err));
925
+ }
926
+ return { ok: true, delegationId: job.id, role: roleName, model: resolved.role.model };
927
+ }
456
928
  /**
457
929
  * One prompt per tap, however many requests carry it (src/sendonce.ts). Keyed on the
458
930
  * phone's own `PendingMessage.id`, which Retry reuses and a fresh send re-rolls, so a
@@ -884,6 +1356,7 @@ const server = http.createServer(async (req, res) => {
884
1356
  const workspaces = reads.listWorkspaces();
885
1357
  attachChangeStats(workspaces); // serves the cache now; refreshes stale git stats in the background
886
1358
  attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
1359
+ attachDelegationState(workspaces);
887
1360
  // An undelivered first prompt rides along with its workspace: the phone renders it
888
1361
  // in that chat rather than tracking delivery itself (see src/firstprompt.ts).
889
1362
  // Prompts parked for the lock screen ride the same way, one list per workspace,
@@ -997,6 +1470,100 @@ const server = http.createServer(async (req, res) => {
997
1470
  if (isRoute(routes.planUsage, req.method, pathname)) {
998
1471
  return json(req, res, 200, await planUsage.read(url.searchParams.get('refresh') === '1'));
999
1472
  }
1473
+ if (isRoute(routes.roles, req.method, pathname)) {
1474
+ const stored = roleStore.read();
1475
+ return json(req, res, 200, {
1476
+ ...stored.config,
1477
+ issues: roleModelIssues(stored.config, modelCache.list()),
1478
+ ...(stored.warning ? { warning: stored.warning } : {})
1479
+ });
1480
+ }
1481
+ if (isRoute(routes.updateRoles, req.method, pathname)) {
1482
+ let config;
1483
+ try {
1484
+ config = decodeRoles(JSON.parse((await readBody(req)) || '{}'));
1485
+ }
1486
+ catch (err) {
1487
+ const error = {
1488
+ code: 'invalid_request',
1489
+ message: err instanceof Error ? err.message : String(err),
1490
+ retryable: false
1491
+ };
1492
+ return json(req, res, 400, { ok: false, error });
1493
+ }
1494
+ const issues = roleModelIssues(config, modelCache.list());
1495
+ if (issues.length)
1496
+ return json(req, res, 409, { ok: false, error: issues[0].error, issues });
1497
+ const written = roleStore.write(config);
1498
+ if (!written.ok) {
1499
+ return json(req, res, 500, {
1500
+ ok: false,
1501
+ error: { code: 'state_invalid', message: written.error, retryable: true }
1502
+ });
1503
+ }
1504
+ return json(req, res, 200, { ok: true, config: written.config });
1505
+ }
1506
+ if (isRoute(routes.delegations, req.method, pathname)) {
1507
+ const workspaceId = url.searchParams.get('workspaceId');
1508
+ const workspaces = reads.listWorkspaces().filter(ws => !workspaceId || ws.id === workspaceId);
1509
+ if (workspaceId && !workspaces.length)
1510
+ return json(req, res, 404, { error: 'workspace not found' });
1511
+ const delegations = workspaces.flatMap(ws => {
1512
+ const store = delegationStore(ws);
1513
+ return store
1514
+ ? store
1515
+ .list()
1516
+ .jobs.filter(job => job.status !== 'returned')
1517
+ .map(projectDelegation)
1518
+ : [];
1519
+ });
1520
+ return json(req, res, 200, { delegations });
1521
+ }
1522
+ const dismissDelegation = routeParam(routes.dismissDelegation, req.method, pathname);
1523
+ if (dismissDelegation) {
1524
+ for (const ws of reads.listWorkspaces()) {
1525
+ const store = delegationStore(ws);
1526
+ if (!store)
1527
+ continue;
1528
+ let job;
1529
+ try {
1530
+ job = store.get(dismissDelegation);
1531
+ }
1532
+ catch (err) {
1533
+ const error = {
1534
+ code: 'state_invalid',
1535
+ message: `Cannot dismiss unreadable delegation: ${err instanceof Error ? err.message : err}`,
1536
+ retryable: false
1537
+ };
1538
+ return json(req, res, 409, {
1539
+ ok: false,
1540
+ error
1541
+ });
1542
+ }
1543
+ if (!job)
1544
+ continue;
1545
+ if (job.status !== 'failed') {
1546
+ return json(req, res, 409, {
1547
+ ok: false,
1548
+ error: {
1549
+ code: 'invalid_request',
1550
+ message: 'Only a failed delegation can be dismissed.',
1551
+ retryable: false
1552
+ }
1553
+ });
1554
+ }
1555
+ store.remove(job.id);
1556
+ return json(req, res, 200, { ok: true, delegationId: job.id });
1557
+ }
1558
+ return json(req, res, 404, {
1559
+ ok: false,
1560
+ error: {
1561
+ code: 'delegation_not_found',
1562
+ message: 'Delegation not found.',
1563
+ retryable: false
1564
+ }
1565
+ });
1566
+ }
1000
1567
  // GET /api/settings — relay preferences plus what the phone needs to edit them:
1001
1568
  // the SSIDs this Mac already holds credentials for, so the picker offers a choice
1002
1569
  // instead of asking someone to type a network name from memory on a phone keyboard.
@@ -1199,11 +1766,13 @@ const server = http.createServer(async (req, res) => {
1199
1766
  return json(req, res, discardStagedAttachment(STAGED_ATTACHMENTS_DIR, stagedAttachment) ? 200 : 404, {
1200
1767
  ok: true
1201
1768
  });
1202
- // POST /api/workspaces { repo, prompt, model?, effort?, plan?, fast?, send? }
1769
+ // POST /api/workspaces { repo, prompt, workflow? | model?/effort?/plan?/fast?, send? }
1203
1770
  // — create a workspace via Conductor's deep link, then configure its first chat.
1204
1771
  if (isRoute(routes.createWorkspace, req.method, pathname)) {
1205
1772
  const body = JSON.parse((await readBody(req)) || '{}');
1206
1773
  const attachmentIds = body.attachmentIds ?? [];
1774
+ if (body.workflow !== undefined && typeof body.workflow !== 'boolean')
1775
+ return json(req, res, 400, { error: 'workflow must be a boolean' });
1207
1776
  if (body.model !== undefined && typeof body.model !== 'string')
1208
1777
  return json(req, res, 400, { error: 'model must be a picker label' });
1209
1778
  if (body.effort !== undefined && typeof body.effort !== 'string')
@@ -1215,13 +1784,16 @@ const server = http.createServer(async (req, res) => {
1215
1784
  return json(req, res, 400, { error: 'plan must be a boolean' });
1216
1785
  if (body.fast !== undefined && typeof body.fast !== 'boolean')
1217
1786
  return json(req, res, 400, { error: 'fast must be a boolean' });
1218
- const agent = {
1787
+ const requestedAgent = {
1219
1788
  model: body.model?.trim() || undefined,
1220
1789
  effort,
1221
1790
  plan: body.plan,
1222
1791
  fast: body.fast
1223
1792
  };
1224
- const configureAgent = Object.values(agent).some(value => value !== undefined);
1793
+ const hasRequestedAgent = Object.values(requestedAgent).some(value => value !== undefined);
1794
+ if (body.workflow && hasRequestedAgent) {
1795
+ return json(req, res, 400, { error: 'workflow mode owns model settings; omit model, effort, plan, and fast' });
1796
+ }
1225
1797
  if (!Array.isArray(attachmentIds) || attachmentIds.some(id => typeof id !== 'string'))
1226
1798
  return json(req, res, 400, { error: 'attachment ids must be a list of strings' });
1227
1799
  const attachments = stagedAttachments(STAGED_ATTACHMENTS_DIR, attachmentIds);
@@ -1229,11 +1801,24 @@ const server = http.createServer(async (req, res) => {
1229
1801
  return json(req, res, 409, { error: 'an attached file is no longer available; add it again' });
1230
1802
  // The prompt is optional — a bare `path=` opens an empty workspace, like
1231
1803
  // Conductor's own New workspace — but *something* has to say where it goes.
1232
- const prompt = [...attachments.map(attachment => attachment.token), (body.prompt ?? '').trim()]
1804
+ const objective = [...attachments.map(attachment => attachment.token), (body.prompt ?? '').trim()]
1233
1805
  .filter(Boolean)
1234
1806
  .join('\n');
1235
- if (!prompt && !body.repo)
1807
+ if (!objective && !body.repo)
1236
1808
  return json(req, res, 400, { error: 'need a repo or a prompt' });
1809
+ let workflowRoot;
1810
+ if (body.workflow) {
1811
+ const stored = roleStore.read();
1812
+ if (stored.warning)
1813
+ return json(req, res, 500, { error: stored.warning });
1814
+ const prepared = prepareWorkflowRoot(stored.config, modelCache.list(), objective);
1815
+ if (!prepared.ok)
1816
+ return json(req, res, delegationHttpStatus(prepared.error), { error: prepared.error.message });
1817
+ workflowRoot = prepared;
1818
+ }
1819
+ const prompt = workflowRoot?.prompt ?? objective;
1820
+ const agent = workflowRoot?.agent ?? requestedAgent;
1821
+ const configureAgent = Object.values(agent).some(value => value !== undefined);
1237
1822
  // Resolve the repo to a real path: an unmatched `path` would silently land
1238
1823
  // the workspace in whichever repo Conductor happens to list first.
1239
1824
  const repo = body.repo ? reads.listRepos().find(r => r.name === body.repo) : undefined;
@@ -1258,7 +1843,7 @@ const server = http.createServer(async (req, res) => {
1258
1843
  // callers into waiting.
1259
1844
  // Whatever happens, the prompt is already pre-filled in Conductor's composer.
1260
1845
  const settled = prompt || configureAgent
1261
- ? firstPrompts.enqueue(created.id, prompt, body.sendImmediately !== false, attachmentIds, configureAgent ? agent : undefined)
1846
+ ? firstPrompts.enqueue(created.id, prompt, body.sendImmediately !== false, attachmentIds, configureAgent ? agent : undefined, workflowRoot?.role)
1262
1847
  : null;
1263
1848
  const failed = settled && body.send === true ? await settled : null;
1264
1849
  settled?.catch(() => undefined); // fire-and-forget: it reports failure, it never rejects
@@ -1268,6 +1853,7 @@ const server = http.createServer(async (req, res) => {
1268
1853
  workspace: reads.getWorkspace(created.id) ?? created,
1269
1854
  pendingPrompt: prompt || undefined,
1270
1855
  model: agent.model,
1856
+ workflow: workflowRoot ? { role: WORKFLOW_ROOT_ROLE, model: workflowRoot.resolvedRole.model } : undefined,
1271
1857
  sent: body.send === true && !!prompt && !failed,
1272
1858
  configured: body.send === true && configureAgent && !failed,
1273
1859
  warning: failed?.error &&
@@ -1330,7 +1916,13 @@ const server = http.createServer(async (req, res) => {
1330
1916
  // GET /api/workspaces/:id/sessions
1331
1917
  const listSessionsIn = routeParam(routes.sessions, req.method, pathname);
1332
1918
  if (listSessionsIn) {
1333
- return json(req, res, 200, { sessions: reads.listSessions(listSessionsIn) });
1919
+ const ws = reads.getWorkspace(listSessionsIn);
1920
+ const store = ws ? delegationStore(ws) : null;
1921
+ const roles = store?.sessionRoles();
1922
+ return json(req, res, 200, {
1923
+ sessions: reads.listSessions(listSessionsIn),
1924
+ ...(roles && Object.keys(roles.sessions).length ? { session_roles: roles.sessions } : {})
1925
+ });
1334
1926
  }
1335
1927
  // POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
1336
1928
  const newChatIn = routeParam(routes.newChat, req.method, pathname);
@@ -1768,16 +2360,44 @@ const server = http.createServer(async (req, res) => {
1768
2360
  }
1769
2361
  });
1770
2362
  }
1771
- // POST /api/sessions/:id/prompt { text, agent? } — agent is the phone's staged
1772
- // settings patch, applied before the prompt so the two can't come apart (and so
1773
- // both park together when the Mac turns out to be locked).
2363
+ const delegateFrom = routeParam(routes.delegateTask, req.method, pathname);
2364
+ if (delegateFrom) {
2365
+ let body;
2366
+ try {
2367
+ const parsed = JSON.parse((await readBody(req)) || '{}');
2368
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2369
+ throw new Error('not an object');
2370
+ body = parsed;
2371
+ }
2372
+ catch {
2373
+ return json(req, res, 400, intakeError('invalid_request', 'could not read delegation request'));
2374
+ }
2375
+ const result = acceptDelegation(delegateFrom, body);
2376
+ return json(req, res, result.ok ? 202 : delegationHttpStatus(result.error), result);
2377
+ }
2378
+ // POST /api/sessions/:id/prompt { text, agent? | workflow? } — ordinary staged
2379
+ // settings or the planning-root role are applied before the prompt so the two
2380
+ // can't come apart (and so both park together when the Mac is locked).
1774
2381
  const promptTo = routeParam(routes.sendPrompt, req.method, pathname);
1775
2382
  if (promptTo) {
1776
2383
  const sessionId = promptTo;
1777
2384
  const body = JSON.parse((await readBody(req)) || '{}');
1778
- const text = (body.text ?? '').trim();
1779
- if (!text)
2385
+ if (body.workflow !== undefined && typeof body.workflow !== 'boolean') {
2386
+ return json(req, res, 400, { error: 'workflow must be a boolean' });
2387
+ }
2388
+ if (body.text !== undefined && typeof body.text !== 'string') {
2389
+ return json(req, res, 400, { error: 'prompt must be a string' });
2390
+ }
2391
+ const rawText = (body.text ?? '').trim();
2392
+ if (!rawText)
1780
2393
  return json(req, res, 400, { error: 'empty prompt' });
2394
+ if (body.agent !== undefined && (!body.agent || typeof body.agent !== 'object' || Array.isArray(body.agent))) {
2395
+ return json(req, res, 400, { error: 'agent must be a settings object' });
2396
+ }
2397
+ const requestedAgent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
2398
+ if (body.workflow && (requestedAgent || body.queue === true)) {
2399
+ return json(req, res, 400, { error: 'workflow owns agent settings and cannot be queued behind another turn' });
2400
+ }
1781
2401
  const ws = body.workspaceId
1782
2402
  ? reads.getWorkspace(body.workspaceId)
1783
2403
  : (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
@@ -1786,9 +2406,8 @@ const server = http.createServer(async (req, res) => {
1786
2406
  // One deadline for the whole request: settings eat into the send's budget
1787
2407
  // rather than extending it past what the phone said it would wait.
1788
2408
  const deadline = Date.now() + sendBudget(req);
1789
- const agent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
1790
2409
  const queue = body.queue === true;
1791
- if (agent?.effort && !EFFORT_LABELS[agent.effort]) {
2410
+ if (requestedAgent?.effort && !EFFORT_LABELS[requestedAgent.effort]) {
1792
2411
  return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
1793
2412
  }
1794
2413
  // One prompt per intent (src/sendonce.ts). Everything that can *say something
@@ -1799,6 +2418,61 @@ const server = http.createServer(async (req, res) => {
1799
2418
  console.info(`[relay] send to ${ws.branch ?? ws.id} already delivered for this tap — answering, not resending`);
1800
2419
  }
1801
2420
  const answer = await sendOnce.run(body.clientId, async () => {
2421
+ let text = rawText;
2422
+ let agent = requestedAgent;
2423
+ if (body.workflow) {
2424
+ const session = reads.getSession(sessionId);
2425
+ const hasUserRow = reads.getMessages(sessionId).entries.some(entry => entry.role === 'user');
2426
+ if (!session || session.last_user_message_at || hasUserRow) {
2427
+ return {
2428
+ status: 409,
2429
+ body: {
2430
+ ok: false,
2431
+ strategy: actuator.name,
2432
+ error: 'Workflow mode can only start with a new chat’s first message.'
2433
+ }
2434
+ };
2435
+ }
2436
+ if (session.status === 'working' || session.background_tasks.length) {
2437
+ return {
2438
+ status: 409,
2439
+ body: {
2440
+ ok: false,
2441
+ strategy: actuator.name,
2442
+ error: 'Workflow mode needs an idle new chat.'
2443
+ }
2444
+ };
2445
+ }
2446
+ const stored = roleStore.read();
2447
+ if (stored.warning) {
2448
+ return { status: 500, body: { ok: false, strategy: actuator.name, error: stored.warning } };
2449
+ }
2450
+ const prepared = prepareWorkflowRoot(stored.config, modelCache.list(), rawText);
2451
+ if (!prepared.ok) {
2452
+ return {
2453
+ status: delegationHttpStatus(prepared.error),
2454
+ body: { ok: false, strategy: actuator.name, error: prepared.error.message }
2455
+ };
2456
+ }
2457
+ try {
2458
+ const assigned = assignWorkflowRoot(ws, sessionId, prepared.role, Date.now());
2459
+ if (!assigned.ok) {
2460
+ return { status: 409, body: { ok: false, strategy: actuator.name, error: assigned.error } };
2461
+ }
2462
+ }
2463
+ catch (err) {
2464
+ return {
2465
+ status: 409,
2466
+ body: {
2467
+ ok: false,
2468
+ strategy: actuator.name,
2469
+ error: err instanceof Error ? err.message : String(err)
2470
+ }
2471
+ };
2472
+ }
2473
+ text = prepared.prompt;
2474
+ agent = prepared.agent;
2475
+ }
1802
2476
  // A failed first-prompt entry offers the same Retry button as an ordinary
1803
2477
  // prompt. If staging had been the failure, put its files in place before
1804
2478
  // that retry reaches the attachment tokens.
@@ -2116,6 +2790,9 @@ server.listen(cfg.port, cfg.host, () => {
2116
2790
  setInterval(sweepStagedAttachments, STAGED_ATTACHMENT_SWEEP_MS).unref();
2117
2791
  // Same for prompts parked behind the lock screen — a lock outlives relay restarts.
2118
2792
  parkedPrompts.start();
2793
+ // Active/failed delegation state lives in each live worktree. Register every
2794
+ // store on startup; the queue resumes side-effect stages at least once.
2795
+ delegationQueue.resume(liveDelegationStores());
2119
2796
  // A launchd/self-update restart kills the loopback bridge but not Tailscale's
2120
2797
  // persisted Serve mapping. Rebuild bridges for dev servers that are still up,
2121
2798
  // and remove this relay's stale mappings for ones that are not.
@@ -2125,9 +2802,10 @@ server.listen(cfg.port, cfg.host, () => {
2125
2802
  // Keep the phone's public URL reachable — re-registers Funnel when its ingress goes stale after a
2126
2803
  // network change. No-ops unless managed + public (Funnel) posture (see funnel-watchdog.ts).
2127
2804
  startFunnelWatchdog();
2128
- // Watch for turns ending and push them to subscribed phones. Idle (one small local
2129
- // query per tick) until a device subscribes; see notify.ts.
2130
- startNotifier(reads);
2805
+ // One base DB read fans out to notification and orchestration listeners. Push can
2806
+ // be disabled or have zero devices without stopping the clock delegated jobs need.
2807
+ startNotifier(reads, sessionPoller);
2808
+ sessionPoller.start();
2131
2809
  // Watch armed keep-awake windows for their recorded expiry: the helper's restore only
2132
2810
  // re-allows sleep, so a lid still shut at expiry needs the relay's `pmset sleepnow`
2133
2811
  // (see nosleep.ts). Also picks a window back up after the relay's own restarts.