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.
- package/README.md +41 -8
- package/dist/assets/index-BdFrtyII.js +52 -0
- package/dist/assets/index-CGDfc1aU.css +1 -0
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/agent-config.js +76 -0
- package/dist-node/src/conductor.applescript +24 -8
- package/dist-node/src/delegations.js +556 -0
- package/dist-node/src/firstprompt.js +15 -1
- package/dist-node/src/mcp-tools.js +182 -8
- package/dist-node/src/notify.js +9 -14
- package/dist-node/src/reads.js +21 -1
- package/dist-node/src/roles.js +195 -0
- package/dist-node/src/routes.js +9 -0
- package/dist-node/src/server.js +731 -53
- package/dist-node/src/session-poller.js +51 -0
- package/dist-node/src/shared.js +89 -0
- package/dist-node/src/workflow.js +33 -0
- package/dist-node/src/writes.js +26 -3
- package/package.json +1 -1
- package/dist/assets/index-35kfVzU0.css +0 -1
- package/dist/assets/index-DS9xqkPd.js +0 -50
package/dist-node/src/server.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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).
|
|
245
|
-
if (fresh)
|
|
246
|
-
return {
|
|
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
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1804
|
+
const objective = [...attachments.map(attachment => attachment.token), (body.prompt ?? '').trim()]
|
|
1233
1805
|
.filter(Boolean)
|
|
1234
1806
|
.join('\n');
|
|
1235
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
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
|
-
|
|
1779
|
-
|
|
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 (
|
|
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
|
-
//
|
|
2129
|
-
//
|
|
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.
|