conductor-remote 1.96.2 → 1.96.3

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.
@@ -31,7 +31,7 @@ import { HIT_CLOSE, HIT_OPEN, isToolResult, timestampMs, workspaceTitle } from "
31
31
  /** Versions we know how to speak. The client's choice wins when we know it. */
32
32
  export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
33
33
  export const SERVER_INFO = { name: 'conductor-remote', version: '1' };
34
- export const INSTRUCTIONS = 'Drives local Conductor agents through the conductor-remote relay. search_chats and read_chat reach archived workspaces, which is where most finished work lives. create_workspace opens its workspace link without direct UI control; requested agent settings and the first prompt are applied later through Conductor’s UI. plan_usage, dismiss_prompt, keep_awake and relay_logs touch no UI. send_prompt, split_chat, stop_turn, close_chat, set_agent_options, set_default_model, a live list_models call, set_workspace_status and archive_workspace drive the real Mac UI and steal focus for a few seconds — confirm with the user before using them on a chat they did not name. archive_workspace also deletes the worktree and stops whatever is running there.';
34
+ export const INSTRUCTIONS = 'Drives local Conductor agents through the conductor-remote relay. search_chats and read_chat reach archived workspaces, which is where most finished work lives. delegate_task later drives the real Mac UI and launches another agent through a persisted cross-provider queue; use it only when the user explicitly asked for delegation. It never uses Conductor Plan mode. create_workspace opens its workspace link without direct UI control; requested agent settings and the first prompt are applied later through Conductor’s UI. Its workflow option is itself an explicit delegation request: the configured planning role owns the root chat and may use delegate_task, still in ordinary chat mode and never Plan mode. plan_usage, dismiss_prompt, keep_awake and relay_logs touch no UI. send_prompt, split_chat, stop_turn, close_chat, set_agent_options, set_default_model, a live list_models call, set_workspace_status and archive_workspace drive the real Mac UI and steal focus for a few seconds — confirm with the user before using them on a chat they did not name. archive_workspace also deletes the worktree and stops whatever is running there.';
35
35
  /** Reads are quick; a UI write is measured in tens of seconds (writes.ts ▸ SEND_ATTEMPT_MS). */
36
36
  export const READ_TIMEOUT_MS = 10_000;
37
37
  export const WRITE_TIMEOUT_MS = 75_000;
@@ -282,9 +282,10 @@ export function createTools(call) {
282
282
  return status ? `no workspace is ${status}` : 'no live workspaces';
283
283
  return shown
284
284
  .map(w => {
285
+ const assigned = w.active_session_id ? w.session_roles?.[w.active_session_id] : undefined;
285
286
  const lines = [
286
287
  `${w.session_status === 'working' ? '▶' : '·'} ${workspaceTitle(w)}`,
287
- ` ${[w.repo_name, w.branch, w.model, w.pr_number ? `PR #${w.pr_number} ${w.pr_status ?? ''}`.trim() : null].filter(Boolean).join(' · ')}`,
288
+ ` ${[w.repo_name, w.branch, w.model, assigned ? `role ${assigned.role}` : null, w.pr_number ? `PR #${w.pr_number} ${w.pr_status ?? ''}`.trim() : null].filter(Boolean).join(' · ')}`,
288
289
  ` workspace_id: ${w.id}${w.active_session_id ? ` session_id: ${w.active_session_id}` : ''}`
289
290
  ];
290
291
  // An undelivered prompt is invisible from the DB — it lives in the relay's own
@@ -297,6 +298,11 @@ export function createTools(call) {
297
298
  for (const p of w.parked_prompts ?? []) {
298
299
  lines.push(` ! prompt ${p.status} for session ${p.sessionId} (${p.reason}): ${clip(unmark(p.text), 120)}`);
299
300
  }
301
+ for (const job of w.delegations ?? []) {
302
+ lines.push(` ${job.status === 'failed' ? '!' : '→'} delegation ${job.role}: ${job.status} (${job.id})${job.failure ? ` — ${job.failure.message}` : ''}`);
303
+ }
304
+ if (w.delegation_warning)
305
+ lines.push(` ! delegation state: ${w.delegation_warning}`);
300
306
  return lines.join('\n');
301
307
  })
302
308
  .join('\n');
@@ -327,11 +333,164 @@ export function createTools(call) {
327
333
  // second prompt into the wait.
328
334
  const waiting = (s.background_tasks ?? []).map(t => `\n waiting for task: ${t.description} (${Math.max(0, Math.round((Date.now() - timestampMs(t.since)) / 60_000))}m so far)`);
329
335
  const glyph = s.status === 'working' ? '▶' : waiting.length ? '⧗' : '·';
330
- return `${glyph} ${s.title ?? '(untitled)'} — ${s.status ?? '?'} · ${s.model ?? '?'}${ctx}\n session_id: ${s.id}${waiting.join('')}`;
336
+ const role = data.session_roles?.[s.id];
337
+ return `${glyph} ${s.title ?? '(untitled)'} — ${s.status ?? '?'} · ${s.model ?? '?'}${role ? ` · role ${role.role}` : ''}${ctx}\n session_id: ${s.id}${waiting.join('')}`;
338
+ })
339
+ .join('\n');
340
+ }
341
+ },
342
+ {
343
+ name: 'list_roles',
344
+ description: 'List the relay’s picker-backed cross-provider roles. A role marked invalid cannot be delegated until its exact model is selected. Delegated roles never enable Conductor Plan mode.',
345
+ inputSchema: { type: 'object', properties: {} },
346
+ run: async () => {
347
+ const data = await call(routes.roles.path());
348
+ const issueByRole = new Map(data.issues.map(issue => [issue.role, issue.error]));
349
+ const lines = Object.entries(data.roles).map(([name, role]) => {
350
+ const settings = [role.model, role.effort, role.fast ? 'fast' : null].filter(Boolean).join(' · ');
351
+ const issue = issueByRole.get(name);
352
+ return `${issue ? '!' : '·'} ${name}: ${settings}${issue ? ` — ${issue.code}: ${issue.message}` : ''}`;
353
+ });
354
+ if (data.warning)
355
+ lines.unshift(`! ${data.warning}`);
356
+ return lines.join('\n') || 'no delegated roles are configured';
357
+ }
358
+ },
359
+ {
360
+ name: 'set_role',
361
+ description: 'Set one cross-provider delegated role using an exact label from list_models. This changes relay config only and does not drive Conductor. Plan mode is intentionally not a role option.',
362
+ inputSchema: {
363
+ type: 'object',
364
+ properties: {
365
+ role: { type: 'string', description: 'Lowercase role name, for example exploration.' },
366
+ model: { type: 'string', description: 'Exact picker label from list_models.' },
367
+ effort: {
368
+ type: 'string',
369
+ enum: ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode']
370
+ },
371
+ fast: { type: 'boolean' },
372
+ preamble: { type: 'string', description: 'Optional instructions prepended to each delegated task.' }
373
+ },
374
+ required: ['role', 'model']
375
+ },
376
+ run: async (args) => {
377
+ const name = need(args, 'role');
378
+ const model = need(args, 'model');
379
+ const effort = str(args.effort);
380
+ const efforts = ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'];
381
+ if (effort && !efforts.includes(effort))
382
+ throw new Error('effort is invalid');
383
+ const current = await call(routes.roles.path());
384
+ const prior = current.roles[name];
385
+ const role = {
386
+ model,
387
+ ...(effort ? { effort: effort } : prior?.effort ? { effort: prior.effort } : {}),
388
+ ...(typeof args.fast === 'boolean'
389
+ ? { fast: args.fast }
390
+ : prior?.fast !== undefined
391
+ ? { fast: prior.fast }
392
+ : {}),
393
+ ...(typeof args.preamble === 'string'
394
+ ? { preamble: args.preamble }
395
+ : prior?.preamble !== undefined
396
+ ? { preamble: prior.preamble }
397
+ : {})
398
+ };
399
+ const config = { version: 1, roles: { ...current.roles, [name]: role } };
400
+ const result = await call(routes.updateRoles.path(), {
401
+ method: routes.updateRoles.method,
402
+ body: config
403
+ });
404
+ if (!result.ok)
405
+ throw new Error(`${result.error.code}: ${result.error.message}`);
406
+ return `${name}: ${[role.model, role.effort, role.fast ? 'fast' : null].filter(Boolean).join(' · ')}`;
407
+ }
408
+ },
409
+ {
410
+ name: 'delegate_task',
411
+ description: 'Queue work in a real sibling Conductor chat using a configured role on a different provider. The relay attaches this chat’s transcript, configures the child without Plan mode, sends the task, waits for a stable answer, and returns its Baton here. Returns immediately with a delegation id; use list_delegations to observe it. Only use it when the user asked for cross-provider delegation because the queue launches a real agent and drives the Mac UI after this call returns.',
412
+ inputSchema: {
413
+ type: 'object',
414
+ properties: {
415
+ session_id: { type: 'string', description: 'Parent chat that receives the eventual Baton.' },
416
+ workspace_id: { type: 'string', description: 'Optional assertion for the parent workspace.' },
417
+ role: { type: 'string', description: 'Exact role name from list_roles.' },
418
+ prompt: { type: 'string' },
419
+ through: { type: 'string', description: 'Optional cursor from read_chat; later rows are omitted.' },
420
+ include_thinking: { type: 'boolean', description: 'Include parent reasoning in the handoff (default true).' },
421
+ return_mode: {
422
+ type: 'string',
423
+ enum: ['queue', 'steer'],
424
+ description: 'Queue a new parent turn by default; steer explicitly injects into a running turn.'
425
+ }
426
+ },
427
+ required: ['session_id', 'role', 'prompt']
428
+ },
429
+ run: async (args) => {
430
+ const sessionId = need(args, 'session_id');
431
+ const through = str(args.through);
432
+ const throughRowid = through ? parseChatCursor(through) : undefined;
433
+ if (through && throughRowid === null)
434
+ throw new Error('through must be a cursor returned by read_chat');
435
+ const returnMode = str(args.return_mode);
436
+ if (returnMode && returnMode !== 'queue' && returnMode !== 'steer')
437
+ throw new Error('return_mode is invalid');
438
+ const result = await call(routes.delegateTask.path(sessionId), {
439
+ method: routes.delegateTask.method,
440
+ body: {
441
+ role: need(args, 'role'),
442
+ prompt: need(args, 'prompt'),
443
+ workspaceId: str(args.workspace_id),
444
+ throughRowid,
445
+ includeThinking: args.include_thinking === undefined ? undefined : args.include_thinking === true,
446
+ returnMode
447
+ }
448
+ });
449
+ if (!result.ok)
450
+ throw new Error(`${result.error.code}: ${result.error.message}`);
451
+ return `delegation ${result.delegationId} queued · ${result.role} · ${result.model}`;
452
+ }
453
+ },
454
+ {
455
+ name: 'list_delegations',
456
+ description: 'List active and failed delegated jobs. Successful jobs leave their chats and role chips, not a second history log.',
457
+ inputSchema: {
458
+ type: 'object',
459
+ properties: { workspace_id: { type: 'string', description: 'Optional workspace filter.' } }
460
+ },
461
+ run: async (args) => {
462
+ const workspaceId = str(args.workspace_id);
463
+ const suffix = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : '';
464
+ const data = await call(`${routes.delegations.path()}${suffix}`);
465
+ if (!data.delegations.length)
466
+ return 'no active or failed delegations';
467
+ return data.delegations
468
+ .map(job => {
469
+ const child = job.childSessionId ? ` · child ${job.childSessionId}` : '';
470
+ const failure = job.failure ? ` · ${job.failure.code}: ${job.failure.message}` : '';
471
+ return `${job.status} · ${job.role} · ${job.resolvedRole.model} · ${job.id}${child}${failure}`;
331
472
  })
332
473
  .join('\n');
333
474
  }
334
475
  },
476
+ {
477
+ name: 'dismiss_delegation',
478
+ description: 'Dismiss one failed delegation job. Its parent/child chats and role identity remain.',
479
+ inputSchema: {
480
+ type: 'object',
481
+ properties: { delegation_id: { type: 'string' } },
482
+ required: ['delegation_id']
483
+ },
484
+ run: async (args) => {
485
+ const id = need(args, 'delegation_id');
486
+ const result = await call(routes.dismissDelegation.path(id), {
487
+ method: routes.dismissDelegation.method
488
+ });
489
+ if (!result.ok)
490
+ throw new Error(`${result.error.code}: ${result.error.message}`);
491
+ return `dismissed delegation ${result.delegationId}`;
492
+ }
493
+ },
335
494
  {
336
495
  name: 'workspace_diff',
337
496
  description: 'The git diff of a live workspace against its target branch, untracked files included.',
@@ -381,7 +540,7 @@ export function createTools(call) {
381
540
  },
382
541
  {
383
542
  name: 'create_workspace',
384
- description: 'Start a new Conductor workspace in a repo, optionally with a first prompt and agent settings. The workspace starts from a Conductor deep link, so creation itself needs no Accessibility. The relay applies requested model, effort, plan, and fast settings through Conductor’s UI after it creates the first chat and before it delivers the prompt. Returns as soon as the workspace row exists (~2s), before the worktree is built.',
543
+ description: 'Start a new Conductor workspace in a repo, optionally with a first prompt and agent settings. The workspace starts from a Conductor deep link, so creation itself needs no Accessibility. The relay applies requested model, effort, plan, and fast settings through Conductor’s UI after it creates the first chat and before it delivers the prompt. Workflow mode instead configures the root from the planning role and explicitly authorizes cross-provider delegate_task calls; it never enables Conductor Plan mode. Returns as soon as the workspace row exists (~2s), before the worktree is built.',
385
544
  inputSchema: {
386
545
  type: 'object',
387
546
  properties: {
@@ -393,11 +552,15 @@ export function createTools(call) {
393
552
  },
394
553
  effort: {
395
554
  type: 'string',
396
- enum: ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'],
555
+ enum: ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'],
397
556
  description: 'Reasoning effort applied before the first prompt.'
398
557
  },
399
558
  plan: { type: 'boolean', description: 'Start the first chat in Conductor’s Plan mode.' },
400
559
  fast: { type: 'boolean', description: 'Set Fast mode before the first prompt.' },
560
+ workflow: {
561
+ type: 'boolean',
562
+ description: 'Start the first prompt as a delegated workflow rooted in the configured planning role. Requires a prompt and cannot be combined with model, effort, plan, or fast. Uses ordinary chat mode, never Conductor Plan mode.'
563
+ },
401
564
  wait_for_send: {
402
565
  type: 'boolean',
403
566
  description: 'Block until the first prompt is actually delivered (tens of seconds, and longer behind other queued sends). Default false.'
@@ -416,19 +579,30 @@ export function createTools(call) {
416
579
  const effort = str(args.effort);
417
580
  const plan = typeof args.plan === 'boolean' ? args.plan : undefined;
418
581
  const fast = typeof args.fast === 'boolean' ? args.fast : undefined;
419
- const configured = model !== undefined || effort !== undefined || plan !== undefined || fast !== undefined;
582
+ const workflow = args.workflow === true;
583
+ const explicitAgent = model !== undefined || effort !== undefined || plan !== undefined || fast !== undefined;
584
+ if (workflow && explicitAgent) {
585
+ throw new Error('workflow cannot be combined with model, effort, plan, or fast');
586
+ }
587
+ if (workflow && !prompt)
588
+ throw new Error('workflow needs a first prompt');
589
+ const configured = workflow || explicitAgent;
420
590
  const send = args.wait_for_send === true;
421
591
  // Default on, matching the relay: an omitted flag must never mean "wait".
422
592
  const sendImmediately = args.send_immediately !== false;
423
593
  const data = await call(routes.createWorkspace.path(), {
424
594
  method: routes.createWorkspace.method,
425
- body: { repo, prompt, model, effort, plan, fast, send, sendImmediately },
595
+ body: workflow
596
+ ? { repo, prompt, workflow: true, send, sendImmediately }
597
+ : { repo, prompt, model, effort, plan, fast, send, sendImmediately },
426
598
  timeoutMs: send ? WRITE_TIMEOUT_MS : 30_000
427
599
  });
428
600
  const lines = [
429
601
  `created ${data.workspace ? workspaceTitle(data.workspace) : data.workspaceId}`,
430
602
  `workspace_id: ${data.workspaceId}`
431
603
  ];
604
+ if (data.workflow)
605
+ lines.push(`workflow root: ${data.workflow.role} · ${data.workflow.model}`);
432
606
  if (data.warning)
433
607
  lines.push(`! ${data.warning}`);
434
608
  else if (data.pendingPrompt && !data.sent)
@@ -695,7 +869,7 @@ export function createTools(call) {
695
869
  description: 'Strongly recommended: it is what the relay asserts against before pressing anything.'
696
870
  },
697
871
  model: { type: 'string', description: 'A label from list_models. An unambiguous prefix is enough.' },
698
- effort: { type: 'string', enum: ['low', 'medium', 'high', 'xhigh', 'max', 'ultracode'] },
872
+ effort: { type: 'string', enum: ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'] },
699
873
  plan: { type: 'boolean', description: 'Conductor’s Plan checkbox.' },
700
874
  fast: {
701
875
  type: 'boolean',
@@ -31,8 +31,6 @@ import path from 'node:path';
31
31
  import { stateDir } from "./config.js";
32
32
  import { generateVapidKeys, MAX_PAYLOAD_BYTES, sendPush } from "./webpush.js";
33
33
  const STORE_VERSION = 1;
34
- /** Poll cadence for status transitions. Matches the phone's own list poll — a local SQLite read is cheap. */
35
- const TICK_MS = 3000;
36
34
  /**
37
35
  * How long a push service should hold a notification for a phone that's offline.
38
36
  * An hour: long enough to survive a tunnel/airplane-mode blip, short enough that
@@ -415,12 +413,12 @@ export class TurnWatcher {
415
413
  }
416
414
  }
417
415
  const watcher = new TurnWatcher();
418
- function tick(reads) {
416
+ function tick(reads, states) {
419
417
  if (deviceCount() === 0) {
420
418
  watcher.reset();
421
419
  return;
422
420
  }
423
- for (const due of watcher.step(reads.listSessionStates()))
421
+ for (const due of watcher.step(states))
424
422
  void fire(reads, due.state.sessionId, due.kind, due.state);
425
423
  }
426
424
  async function fire(reads, sessionId, kind, state) {
@@ -448,23 +446,20 @@ async function fire(reads, sessionId, kind, state) {
448
446
  console.info(`[push] ${kind} in ${where} → ${sent} device${sent === 1 ? '' : 's'}`);
449
447
  }
450
448
  /**
451
- * Start watching for turn endings. Safe to call unconditionally — with no
452
- * subscribed devices this is one small local query every few seconds, and
453
- * `PUSH_NOTIFY=off` skips it entirely.
449
+ * Subscribe notification decisions to the shared session clock. Push settings
450
+ * gate only this listener; the poller itself still runs for delegation progress.
454
451
  */
455
- export function startNotifier(reads) {
452
+ export function startNotifier(reads, poller) {
456
453
  if (!notificationsEnabled()) {
457
454
  console.info('[push] notifications disabled (PUSH_NOTIFY=off)');
458
- return;
455
+ return () => undefined;
459
456
  }
460
- const timer = setInterval(() => {
457
+ return poller.subscribe(states => {
461
458
  try {
462
- tick(reads);
459
+ tick(reads, states);
463
460
  }
464
461
  catch (err) {
465
- // A transient DB read failure must not kill the interval.
466
462
  console.warn(`[push] watcher tick failed: ${err instanceof Error ? err.message : err}`);
467
463
  }
468
- }, TICK_MS);
469
- timer.unref();
464
+ });
470
465
  }
@@ -8,9 +8,12 @@ import { parseMessage, parseOutboxMessage, toolImageAt } from "./transcript.js";
8
8
  /** Keep the stable wire field in sync with whichever provider owns the chat. */
9
9
  function toSessionRow(row, background_tasks) {
10
10
  const { codex_thinking_level, ...session } = row;
11
+ const effort = row.agent_type === 'codex' ? codex_thinking_level : row.claude_effort_level;
11
12
  return {
12
13
  ...session,
13
- claude_effort_level: row.agent_type === 'codex' ? codex_thinking_level : row.claude_effort_level,
14
+ // Codex calls its top level `ultra`; the relay's long-lived wire value is
15
+ // `ultracode`, which is also Claude's stored spelling and the phone's key.
16
+ claude_effort_level: effort === 'ultra' ? 'ultracode' : effort,
14
17
  background_tasks
15
18
  };
16
19
  }
@@ -346,6 +349,23 @@ export class Reads {
346
349
  return toSessionRow(row, started === undefined ? [] : this.openBackgroundTasks(row.id, started));
347
350
  });
348
351
  }
352
+ /** One delegated child, including its provider-specific open-task guard. */
353
+ getSession(sessionId) {
354
+ const rows = this.db.query(`SELECT s.id, s.status, s.title, s.model, s.permission_mode,
355
+ s.claude_effort_level, s.codex_thinking_level, s.fast_mode, s.agent_type,
356
+ s.context_used_percent, s.unread_count,
357
+ s.created_at, s.updated_at, s.last_user_message_at,
358
+ (SELECT MAX(m.sent_at) FROM session_messages m
359
+ WHERE m.session_id = s.id AND m.queue_order IS NOT NULL AND m.sent_at IS NOT NULL) AS turn_started_at
360
+ FROM sessions s
361
+ WHERE s.id = ? AND COALESCE(s.is_hidden, 0) = 0
362
+ LIMIT 1`, [sessionId]);
363
+ const row = rows[0];
364
+ if (!row)
365
+ return null;
366
+ const started = this.liveAgents().get(row.id);
367
+ return toSessionRow(row, started === undefined ? [] : this.openBackgroundTasks(row.id, started));
368
+ }
349
369
  /**
350
370
  * The background tasks a chat is still waiting on (src/background-tasks.ts).
351
371
  *
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Global delegated-role definitions.
3
+ *
4
+ * Roles are relay preferences, not Conductor state, and therefore live beside the
5
+ * relay's other private JSON files. The decoder is strict on purpose: silently
6
+ * ignoring a hand-written `plan` field would make a role appear safe while still
7
+ * inviting callers to depend on Conductor's currently unreliable Plan mode.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { stateDir } from "./config.js";
12
+ import { modelAgentType, modelCatalogIncludes, modelPickerLabel } from "./shared.js";
13
+ const EFFORTS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode']);
14
+ const ROLE_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
15
+ const MAX_ROLES = 32;
16
+ const MAX_MODEL_LENGTH = 256;
17
+ const MAX_PREAMBLE_LENGTH = 50_000;
18
+ const ROLE_FIELDS = new Set(['model', 'effort', 'fast', 'preamble']);
19
+ const batonPreamble = (role) => `You are the ${role} agent for this workspace. End your final answer with a \`## Baton\` section: Decision, Evidence, Files changed, Risks, Suggested next role.`;
20
+ export const DEFAULT_ROLES = {
21
+ version: 1,
22
+ roles: {
23
+ planning: { model: 'Fable 5', effort: 'max', fast: false, preamble: batonPreamble('planning') },
24
+ // Conductor currently offers two distinct Muse Spark rows. This descriptive
25
+ // placeholder intentionally matches neither, so the user must choose exactly.
26
+ exploration: { model: 'Muse Spark', effort: 'high', fast: false, preamble: batonPreamble('exploration') },
27
+ implementation: { model: '5.6 Sol', effort: 'xhigh', fast: false, preamble: batonPreamble('implementation') }
28
+ }
29
+ };
30
+ function object(raw) {
31
+ return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
32
+ }
33
+ function cloneRole(role) {
34
+ return { ...role };
35
+ }
36
+ function cloneConfig(config) {
37
+ return {
38
+ version: 1,
39
+ roles: Object.fromEntries(Object.entries(config.roles).map(([name, role]) => [name, cloneRole(role)]))
40
+ };
41
+ }
42
+ function decodeRole(name, raw) {
43
+ if (!ROLE_NAME.test(name))
44
+ throw new Error(`invalid role name ${name}`);
45
+ const value = object(raw);
46
+ if (!value)
47
+ throw new Error(`role ${name} must be an object`);
48
+ const unknown = Object.keys(value).find(field => !ROLE_FIELDS.has(field));
49
+ if (unknown)
50
+ throw new Error(`role ${name} has unknown field ${unknown}`);
51
+ if (typeof value.model !== 'string' || !value.model.trim() || value.model.length > MAX_MODEL_LENGTH) {
52
+ throw new Error(`role ${name} needs a picker model label`);
53
+ }
54
+ const role = { model: modelPickerLabel(value.model.trim()) };
55
+ if (value.effort !== undefined) {
56
+ if (typeof value.effort !== 'string' || !EFFORTS.has(value.effort)) {
57
+ throw new Error(`role ${name} has an invalid effort`);
58
+ }
59
+ role.effort = value.effort;
60
+ }
61
+ if (value.fast !== undefined) {
62
+ if (typeof value.fast !== 'boolean')
63
+ throw new Error(`role ${name} has an invalid fast value`);
64
+ role.fast = value.fast;
65
+ }
66
+ if (value.preamble !== undefined) {
67
+ if (typeof value.preamble !== 'string' || value.preamble.length > MAX_PREAMBLE_LENGTH) {
68
+ throw new Error(`role ${name} has an invalid preamble`);
69
+ }
70
+ role.preamble = value.preamble;
71
+ }
72
+ return role;
73
+ }
74
+ export function decodeRoles(raw) {
75
+ const value = object(raw);
76
+ if (!value)
77
+ throw new Error('roles document must be an object');
78
+ if (value.version !== 1)
79
+ throw new Error(`unsupported roles version ${String(value.version)}`);
80
+ const roles = object(value.roles);
81
+ if (!roles)
82
+ throw new Error('roles must be an object');
83
+ const entries = Object.entries(roles);
84
+ if (!entries.length || entries.length > MAX_ROLES)
85
+ throw new Error(`roles must contain 1-${MAX_ROLES} entries`);
86
+ return { version: 1, roles: Object.fromEntries(entries.map(([name, role]) => [name, decodeRole(name, role)])) };
87
+ }
88
+ function issue(code, message) {
89
+ return { code, message, retryable: false };
90
+ }
91
+ function effortIssue(name, role, agentType) {
92
+ return role.effort === 'none' && agentType !== 'codex'
93
+ ? issue('invalid_request', `Role ${name} can use None effort only with a Codex model.`)
94
+ : null;
95
+ }
96
+ /** Picker validation stays separate from shape decoding so a vanished model remains editable. */
97
+ export function roleModelIssues(config, groups) {
98
+ const issues = [];
99
+ for (const [name, role] of Object.entries(config.roles)) {
100
+ if (!modelCatalogIncludes(role.model, groups)) {
101
+ issues.push({
102
+ role: name,
103
+ error: issue('model_missing', `Role ${name} needs an exact model from Conductor's current picker.`)
104
+ });
105
+ continue;
106
+ }
107
+ const agentType = modelAgentType(role.model);
108
+ if (!agentType) {
109
+ issues.push({
110
+ role: name,
111
+ error: issue('provider_unknown', `Role ${name}'s model label does not identify a supported provider.`)
112
+ });
113
+ continue;
114
+ }
115
+ const invalidEffort = effortIssue(name, role, agentType);
116
+ if (invalidEffort)
117
+ issues.push({ role: name, error: invalidEffort });
118
+ }
119
+ return issues;
120
+ }
121
+ /** Resolve and freeze the provider encoded by an exact cached picker label. */
122
+ export function resolveRole(config, name, groups) {
123
+ const role = config.roles[name];
124
+ if (!role)
125
+ return { ok: false, error: issue('role_not_found', `Unknown delegated role ${name}.`) };
126
+ if (!modelCatalogIncludes(role.model, groups)) {
127
+ return {
128
+ ok: false,
129
+ error: issue('model_missing', `Role ${name} needs an exact model from Conductor's current picker.`)
130
+ };
131
+ }
132
+ const agentType = modelAgentType(role.model);
133
+ if (!agentType) {
134
+ return {
135
+ ok: false,
136
+ error: issue('provider_unknown', `Role ${name}'s model label does not identify a supported provider.`)
137
+ };
138
+ }
139
+ const invalidEffort = effortIssue(name, role, agentType);
140
+ if (invalidEffort)
141
+ return { ok: false, error: invalidEffort };
142
+ return { ok: true, role: { ...cloneRole(role), agentType } };
143
+ }
144
+ /** Cached, process-local store. A rejected write leaves both memory and disk untouched. */
145
+ export class RoleStore {
146
+ file;
147
+ cache = null;
148
+ constructor(file = path.join(stateDir(), 'roles.json')) {
149
+ this.file = file;
150
+ }
151
+ read() {
152
+ if (this.cache)
153
+ return { ...this.cache, config: cloneConfig(this.cache.config) };
154
+ try {
155
+ this.cache = { config: decodeRoles(JSON.parse(fs.readFileSync(this.file, 'utf8'))) };
156
+ }
157
+ catch (err) {
158
+ if (err.code === 'ENOENT')
159
+ this.cache = { config: cloneConfig(DEFAULT_ROLES) };
160
+ else {
161
+ this.cache = {
162
+ config: cloneConfig(DEFAULT_ROLES),
163
+ warning: `Could not read roles.json: ${err instanceof Error ? err.message : String(err)}`
164
+ };
165
+ }
166
+ }
167
+ return { ...this.cache, config: cloneConfig(this.cache.config) };
168
+ }
169
+ write(raw) {
170
+ let config;
171
+ try {
172
+ config = decodeRoles(raw);
173
+ }
174
+ catch (err) {
175
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
176
+ }
177
+ const directory = path.dirname(this.file);
178
+ const temporary = `${this.file}.${process.pid}.tmp`;
179
+ try {
180
+ fs.mkdirSync(directory, { recursive: true });
181
+ fs.writeFileSync(temporary, `${JSON.stringify(config, null, '\t')}\n`, { mode: 0o600 });
182
+ fs.chmodSync(temporary, 0o600);
183
+ fs.renameSync(temporary, this.file);
184
+ }
185
+ catch (err) {
186
+ try {
187
+ fs.unlinkSync(temporary);
188
+ }
189
+ catch { }
190
+ return { ok: false, error: `could not persist roles: ${err instanceof Error ? err.message : String(err)}` };
191
+ }
192
+ this.cache = { config };
193
+ return { ok: true, config: cloneConfig(config) };
194
+ }
195
+ }
@@ -86,6 +86,13 @@ export const routes = {
86
86
  pushSubscribe: flat('POST', '/api/push/subscribe'),
87
87
  pushUnsubscribe: flat('POST', '/api/push/unsubscribe'),
88
88
  pushTest: flat('POST', '/api/push/test'),
89
+ /** Global picker-backed delegated-role definitions. */
90
+ roles: flat('GET', '/api/roles'),
91
+ updateRoles: flat('PATCH', '/api/roles'),
92
+ /** Active and failed jobs; optionally filtered with `?workspaceId=`. */
93
+ delegations: flat('GET', '/api/delegations'),
94
+ /** Dismiss one failed job without touching its chats or session-role identity. */
95
+ dismissDelegation: param('DELETE', '/api/delegations/:delegationId'),
89
96
  // ── workspaces ──
90
97
  createWorkspace: flat('POST', '/api/workspaces'),
91
98
  /** One workspace by id, archived included — what `/api/state` deliberately leaves out. */
@@ -121,6 +128,8 @@ export const routes = {
121
128
  uploadAttachment: param('POST', '/api/sessions/:sessionId/attachments'),
122
129
  /** Copy a chat into a fresh tab beside it, as a Conductor attachment (src/attachments.ts). */
123
130
  splitChat: param('POST', '/api/sessions/:sessionId/split'),
131
+ /** Accept a persisted cross-provider job and return before its UI stages run. */
132
+ delegateTask: param('POST', '/api/sessions/:sessionId/delegate'),
124
133
  /** Dismiss a prompt parked behind the lock screen (src/parked.ts). */
125
134
  dismissParkedPrompt: param('DELETE', '/api/sessions/:sessionId/prompt')
126
135
  };