conductor-remote 1.124.0 → 1.125.1

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.
Files changed (38) hide show
  1. package/dist/assets/{PierrePatch-4wYckD3t.js → PierrePatch-Sohj8IVv.js} +1 -1
  2. package/dist/assets/index-CJ_yLSwD.css +1 -0
  3. package/dist/assets/{index-D0mVtqUh.js → index-CMkZa4wP.js} +41 -41
  4. package/dist/index.html +2 -2
  5. package/dist/sw.js +1 -1
  6. package/dist-node/src/contracts/agent-inputs.js +44 -0
  7. package/dist-node/src/contracts/validation.js +16 -0
  8. package/dist-node/src/http/input.js +9 -0
  9. package/dist-node/src/http/router.js +3 -0
  10. package/dist-node/src/http/routes/create-workspace.js +10 -25
  11. package/dist-node/src/http/routes/prompts.js +9 -17
  12. package/dist-node/src/http/routes/sessions.js +5 -5
  13. package/dist-node/src/http/services/base.js +9 -3
  14. package/dist-node/src/http/services/workflow-notifications.js +53 -0
  15. package/dist-node/src/http/services/workflow.js +19 -4
  16. package/dist-node/src/mcp/client.js +66 -0
  17. package/dist-node/src/mcp/define-tool.js +11 -0
  18. package/dist-node/src/mcp/tools/agent-options.js +15 -33
  19. package/dist-node/src/mcp/tools/chat-actions.js +14 -20
  20. package/dist-node/src/mcp/tools/workspaces.js +21 -43
  21. package/dist-node/src/mcp.js +3 -57
  22. package/dist-node/src/orchestration/persistence/db.js +4 -2
  23. package/dist-node/src/orchestration/persistence/effect-transitions.js +17 -0
  24. package/dist-node/src/orchestration/persistence/events.js +10 -0
  25. package/dist-node/src/orchestration/workflow/blocked.js +93 -0
  26. package/dist-node/src/orchestration/workflow/compatibility.js +42 -0
  27. package/dist-node/src/orchestration/workflow/effects.js +46 -1
  28. package/dist-node/src/orchestration/workflow/errors.js +14 -0
  29. package/dist-node/src/orchestration/workflow/helpers.js +2 -0
  30. package/dist-node/src/orchestration/workflow/job-results.js +9 -1
  31. package/dist-node/src/orchestration/workflow/report.js +31 -0
  32. package/dist-node/src/orchestration/workflow/wake.js +20 -0
  33. package/dist-node/src/prefs.js +2 -1
  34. package/dist-node/src/shared.js +5 -0
  35. package/dist-node/src/wire.js +2 -2
  36. package/docs/voice-setup.md +7 -4
  37. package/package.json +1 -1
  38. package/dist/assets/index-8RMgvUzK.css +0 -1
package/dist/index.html CHANGED
@@ -25,8 +25,8 @@
25
25
  <title>Conductor Remote</title>
26
26
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
27
27
  <script src="/self-heal.js"></script>
28
- <script type="module" crossorigin src="/assets/index-D0mVtqUh.js"></script>
29
- <link rel="stylesheet" crossorigin href="/assets/index-8RMgvUzK.css">
28
+ <script type="module" crossorigin src="/assets/index-CMkZa4wP.js"></script>
29
+ <link rel="stylesheet" crossorigin href="/assets/index-CJ_yLSwD.css">
30
30
  <link rel="manifest" href="/manifest.webmanifest"></head>
31
31
  <body>
32
32
  <div id="root"></div>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const t=e=>i(e,l),a={module:{uri:l},exports:o,require:t};s[l]=Promise.all(n.map(e=>a[e]||t(e))).then(e=>(r(...e),o))}}define(["./workbox-dcde9eb3"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"4e0b714c0ea53e85c167e6d8f5b767aa"},{url:"index.html",revision:"10db79e1e2aa5617d1d0f7b00da03cfe"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/pierre-shiki-wasm-C8ETeZem.js",revision:null},{url:"assets/pierre-light-480U9XYS.js",revision:null},{url:"assets/pierre-dark-CyvmCCZW.js",revision:null},{url:"assets/index-D0mVtqUh.js",revision:null},{url:"assets/index-8RMgvUzK.css",revision:null},{url:"assets/PierrePatch-4wYckD3t.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]})),e.registerRoute(({url:e})=>e.pathname.startsWith("/assets/diff-syntax/"),new e.CacheFirst({cacheName:"diff-syntax",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:31536e3})]}),"GET")});
1
+ if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const l=e||("document"in self?document.currentScript.src:"")||location.href;if(s[l])return;let o={};const a=e=>i(e,l),t={module:{uri:l},exports:o,require:a};s[l]=Promise.all(n.map(e=>t[e]||a(e))).then(e=>(r(...e),o))}}define(["./workbox-dcde9eb3"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"4e0b714c0ea53e85c167e6d8f5b767aa"},{url:"index.html",revision:"b903da08728771741e2d4c47b74f03df"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/pierre-shiki-wasm-C8ETeZem.js",revision:null},{url:"assets/pierre-light-480U9XYS.js",revision:null},{url:"assets/pierre-dark-CyvmCCZW.js",revision:null},{url:"assets/index-CMkZa4wP.js",revision:null},{url:"assets/index-CJ_yLSwD.css",revision:null},{url:"assets/PierrePatch-Sohj8IVv.js",revision:null},{url:"apple-touch-icon.png",revision:"1127bb396b4648add53dce3f22c92aee"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a9b0d962686287452216492cd2247499"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]})),e.registerRoute(({url:e})=>e.pathname.startsWith("/assets/diff-syntax/"),new e.CacheFirst({cacheName:"diff-syntax",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:31536e3})]}),"GET")});
@@ -0,0 +1,44 @@
1
+ import { z } from 'zod';
2
+ import { AGENT_EFFORTS } from "../shared.js";
3
+ /** Wire inputs shared by the HTTP handlers and their MCP tools. Browser callers import only their types. */
4
+ const optionalText = z
5
+ .string()
6
+ .trim()
7
+ .transform(value => value || undefined)
8
+ .optional();
9
+ export const sessionIdSchema = z.string().trim().min(1).describe('The chat id from list_chats / search_chats.');
10
+ const workspaceIdSchema = optionalText.describe('Its workspace. Strongly recommended: it is what the relay asserts against before touching the UI.');
11
+ export const agentPatchSchema = z.object({
12
+ model: optionalText.describe('A picker label from list_models. An unambiguous prefix is enough.'),
13
+ effort: z.enum(AGENT_EFFORTS).optional().describe('Reasoning effort applied before the next prompt.'),
14
+ plan: z.boolean().optional().describe('Conductor’s Plan checkbox.'),
15
+ fast: z
16
+ .boolean()
17
+ .optional()
18
+ .describe('Fast mode. Only some models offer it; a missing button is reported, not ignored.')
19
+ });
20
+ export const setAgentOptionsSchema = agentPatchSchema.extend({ workspaceId: workspaceIdSchema });
21
+ export const sendPromptSchema = z.object({
22
+ text: z.string({ error: 'prompt must be a string' }).trim().min(1, 'empty prompt'),
23
+ workspaceId: workspaceIdSchema,
24
+ agent: agentPatchSchema.optional(),
25
+ clientId: z.string().optional(),
26
+ queue: z.boolean().default(false)
27
+ });
28
+ export const createWorkspaceSchema = agentPatchSchema.extend({
29
+ repo: z.string().trim().min(1).optional().describe('Exact name from list_repos.'),
30
+ prompt: optionalText.describe('First prompt for the new agent. Omit to open an empty workspace.'),
31
+ send: z
32
+ .boolean()
33
+ .default(false)
34
+ .describe('Block until the first prompt is actually delivered (tens of seconds, and longer behind other queued sends). Default false.'),
35
+ sendImmediately: z
36
+ .boolean()
37
+ .default(true)
38
+ .describe("Send the first prompt without waiting for the worktree to finish building, which is how Conductor's own New workspace box behaves. Default true. Pass false only when the agent's first move needs what the repo's setup script installs."),
39
+ attachmentIds: z.array(z.string()).default([])
40
+ });
41
+ /** False is an explicit setting; an empty patch inherits the chat's existing controls. */
42
+ export function hasAgentSettings(patch) {
43
+ return patch.model !== undefined || patch.effort !== undefined || patch.plan !== undefined || patch.fast !== undefined;
44
+ }
@@ -0,0 +1,16 @@
1
+ /** Invalid caller input, distinguished from failures inside the relay. */
2
+ export class InputError extends Error {
3
+ }
4
+ export function parseInput(schema, input) {
5
+ const result = schema.safeParse(input);
6
+ if (result.success)
7
+ return result.data;
8
+ throw new InputError(result.error.issues
9
+ .map(issue => {
10
+ const message = issue.code === 'unrecognized_keys'
11
+ ? `unknown ${issue.keys.length === 1 ? 'field' : 'fields'}: ${issue.keys.join(', ')}`
12
+ : issue.message;
13
+ return issue.path.length ? `${issue.path.join('.')}: ${message}` : message;
14
+ })
15
+ .join('; '));
16
+ }
@@ -0,0 +1,9 @@
1
+ import { InputError } from "../contracts/validation.js";
2
+ export function parseJsonBody(text) {
3
+ try {
4
+ return JSON.parse(text || '{}');
5
+ }
6
+ catch {
7
+ throw new InputError('request body must be valid JSON');
8
+ }
9
+ }
@@ -1,4 +1,5 @@
1
1
  import http from 'node:http';
2
+ import { InputError } from "../contracts/validation.js";
2
3
  import { UiBusyError, uiQueueDepth, withUiPriority } from "../writes/ui-lock.js";
3
4
  import { NOT_HANDLED } from "./router-types.js";
4
5
  import { createCreateWorkspaceRoutes } from "./routes/create-workspace.js";
@@ -57,6 +58,8 @@ export function createRelayServer(services) {
57
58
  catch (err) {
58
59
  if (err instanceof PayloadTooLargeError)
59
60
  return json(req, res, 413, { error: err.message });
61
+ if (err instanceof InputError)
62
+ return json(req, res, 400, { error: err.message });
60
63
  const workflowError = workflowHttpError(err);
61
64
  if (workflowError) {
62
65
  console.warn(`[workflow] ${req.method} ${pathname}: ${workflowError.error.code}: ${workflowError.error.message}`);
@@ -1,6 +1,8 @@
1
+ import { createWorkspaceSchema, hasAgentSettings } from "../../contracts/agent-inputs.js";
2
+ import { parseInput } from "../../contracts/validation.js";
1
3
  import { stagedAttachments } from "../../files/staged-attachments.js";
2
4
  import { isRoute, routes } from "../../routes.js";
3
- import { EFFORT_LABELS } from "../../writes/agent-options.js";
5
+ import { parseJsonBody } from "../input.js";
4
6
  import { NOT_HANDLED } from "../router-types.js";
5
7
  export function createCreateWorkspaceRoutes(services) {
6
8
  const { readBody, json, STAGED_ATTACHMENTS_DIR, reads, createWorkspaceAndRead, firstPrompts } = services;
@@ -9,29 +11,12 @@ export function createCreateWorkspaceRoutes(services) {
9
11
  // POST /api/workspaces { repo, prompt, model?/effort?/plan?/fast?, send? }
10
12
  // — create a workspace via Conductor's deep link, then configure its first chat.
11
13
  if (isRoute(routes.createWorkspace, req.method, pathname)) {
12
- const body = JSON.parse((await readBody(req)) || '{}');
13
- const attachmentIds = body.attachmentIds ?? [];
14
- if ('workflow' in body)
14
+ const input = parseJsonBody(await readBody(req));
15
+ if (input && typeof input === 'object' && 'workflow' in input)
15
16
  return json(req, res, 400, { error: 'Workflow starts through POST /api/workflows.' });
16
- if (body.model !== undefined && typeof body.model !== 'string')
17
- return json(req, res, 400, { error: 'model must be a picker label' });
18
- if (body.effort !== undefined && typeof body.effort !== 'string')
19
- return json(req, res, 400, { error: 'effort must be a string' });
20
- const effort = body.effort?.trim() || undefined;
21
- if (effort && !EFFORT_LABELS[effort])
22
- return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
23
- if (body.plan !== undefined && typeof body.plan !== 'boolean')
24
- return json(req, res, 400, { error: 'plan must be a boolean' });
25
- if (body.fast !== undefined && typeof body.fast !== 'boolean')
26
- return json(req, res, 400, { error: 'fast must be a boolean' });
27
- const requestedAgent = {
28
- model: body.model?.trim() || undefined,
29
- effort,
30
- plan: body.plan,
31
- fast: body.fast
32
- };
33
- if (!Array.isArray(attachmentIds) || attachmentIds.some(id => typeof id !== 'string'))
34
- return json(req, res, 400, { error: 'attachment ids must be a list of strings' });
17
+ const body = parseInput(createWorkspaceSchema, input);
18
+ const { attachmentIds, model, effort, plan, fast } = body;
19
+ const requestedAgent = { model, effort, plan, fast };
35
20
  const attachments = stagedAttachments(STAGED_ATTACHMENTS_DIR, attachmentIds);
36
21
  if (!attachments)
37
22
  return json(req, res, 409, { error: 'an attached file is no longer available; add it again' });
@@ -44,7 +29,7 @@ export function createCreateWorkspaceRoutes(services) {
44
29
  return json(req, res, 400, { error: 'need a repo or a prompt' });
45
30
  const prompt = objective;
46
31
  const agent = requestedAgent;
47
- const configureAgent = Object.values(agent).some(value => value !== undefined);
32
+ const configureAgent = hasAgentSettings(agent);
48
33
  // Resolve the repo to a real path: an unmatched `path` would silently land
49
34
  // the workspace in whichever repo Conductor happens to list first.
50
35
  const repo = body.repo ? reads.listRepos().find(r => r.name === body.repo) : undefined;
@@ -69,7 +54,7 @@ export function createCreateWorkspaceRoutes(services) {
69
54
  // callers into waiting.
70
55
  // Whatever happens, the prompt is already pre-filled in Conductor's composer.
71
56
  const settled = prompt || configureAgent
72
- ? firstPrompts.enqueue(created.id, prompt, body.sendImmediately !== false, attachmentIds, configureAgent ? agent : undefined)
57
+ ? firstPrompts.enqueue(created.id, prompt, body.sendImmediately, attachmentIds, configureAgent ? agent : undefined)
73
58
  : null;
74
59
  const failed = settled && body.send === true ? await settled : null;
75
60
  settled?.catch(() => undefined); // fire-and-forget: it reports failure, it never rejects
@@ -1,3 +1,5 @@
1
+ import { hasAgentSettings, sendPromptSchema } from "../../contracts/agent-inputs.js";
2
+ import { parseInput } from "../../contracts/validation.js";
1
3
  import { attachmentPrompt, writeAttachment } from "../../files/attachments.js";
2
4
  import { discardStagedAttachment, materializeStagedAttachments, stageAttachment } from "../../files/staged-attachments.js";
3
5
  import { captureForkWorkspace, materializeForkWorkspace, releaseForkWorkspace } from "../../git/fork-workspace.js";
@@ -5,8 +7,8 @@ import { acceptDelegation, delegationHttpStatus } from "../../orchestration/dele
5
7
  import { WorkflowCoordinatorError } from "../../orchestration/workflow/errors.js";
6
8
  import { routeParam, routes } from "../../routes.js";
7
9
  import { renderTranscript, transcriptMessage, transcriptThrough } from "../../transcript/parser.js";
8
- import { EFFORT_LABELS } from "../../writes/agent-options.js";
9
10
  import { lockBlocked } from "../../writes/guards.js";
11
+ import { parseJsonBody } from "../input.js";
10
12
  import { NOT_HANDLED } from "../router-types.js";
11
13
  export function createPromptsRoutes(services) {
12
14
  const { orchestration, orchestrationUnavailableReason, readBody, json, workflowOwningSession, reads, roleStore, modelCache, delegationStore, delegationQueue, workflowFrozenError, sendBudget, sendOnce, firstPrompts, actuator, STAGED_ATTACHMENTS_DIR, applyAgentPatch, parkedPrompts, PARKED_ERROR, deliverPrompt, createWorkspaceAndRead, sleep, openChat } = services;
@@ -51,20 +53,13 @@ export function createPromptsRoutes(services) {
51
53
  const promptTo = routeParam(routes.sendPrompt, req.method, pathname);
52
54
  if (promptTo) {
53
55
  const sessionId = promptTo;
54
- const body = JSON.parse((await readBody(req)) || '{}');
55
- if ('workflow' in body) {
56
+ const input = parseJsonBody(await readBody(req));
57
+ if (input && typeof input === 'object' && 'workflow' in input) {
56
58
  return json(req, res, 400, { error: 'Workflow starts through POST /api/workflows.' });
57
59
  }
58
- if (body.text !== undefined && typeof body.text !== 'string') {
59
- return json(req, res, 400, { error: 'prompt must be a string' });
60
- }
61
- const rawText = (body.text ?? '').trim();
62
- if (!rawText)
63
- return json(req, res, 400, { error: 'empty prompt' });
64
- if (body.agent !== undefined && (!body.agent || typeof body.agent !== 'object' || Array.isArray(body.agent))) {
65
- return json(req, res, 400, { error: 'agent must be a settings object' });
66
- }
67
- const requestedAgent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
60
+ const body = parseInput(sendPromptSchema, input);
61
+ const rawText = body.text;
62
+ const requestedAgent = body.agent && hasAgentSettings(body.agent) ? body.agent : undefined;
68
63
  const frozen = workflowFrozenError(sessionId);
69
64
  if (frozen &&
70
65
  requestedAgent &&
@@ -79,10 +74,7 @@ export function createPromptsRoutes(services) {
79
74
  // One deadline for the whole request: settings eat into the send's budget
80
75
  // rather than extending it past what the phone said it would wait.
81
76
  const deadline = Date.now() + sendBudget(req);
82
- const queue = body.queue === true;
83
- if (requestedAgent?.effort && !EFFORT_LABELS[requestedAgent.effort]) {
84
- return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
85
- }
77
+ const queue = body.queue;
86
78
  // One prompt per intent (src/delivery/sendonce.ts). Everything that can *say something
87
79
  // to Conductor* sits inside, so an answer the phone never heard is replayed
88
80
  // rather than re-performed — including the parked branches, since parking the
@@ -1,9 +1,12 @@
1
+ import { setAgentOptionsSchema } from "../../contracts/agent-inputs.js";
2
+ import { parseInput } from "../../contracts/validation.js";
1
3
  import { writeAttachment } from "../../files/attachments.js";
2
4
  import { noteViewing } from "../../notifications/notify.js";
3
5
  import { routeParam, routes } from "../../routes.js";
4
6
  import { VIEWING_HEADER } from "../../shared.js";
5
- import { EFFORT_LABELS, listAgentModels, setDefaultModel } from "../../writes/agent-options.js";
7
+ import { listAgentModels, setDefaultModel } from "../../writes/agent-options.js";
6
8
  import { closeChat, restoreChat, stopTurn } from "../../writes/chats.js";
9
+ import { parseJsonBody } from "../input.js";
7
10
  import { NOT_HANDLED } from "../router-types.js";
8
11
  export function createSessionsRoutes(services) {
9
12
  const { json, reads, workflowFrozenError, locateChat, modelCache, readBody, applyAgentPatch, actuator, sleep, attachmentHeaderName, readAttachmentBody } = services;
@@ -108,10 +111,7 @@ export function createSessionsRoutes(services) {
108
111
  const agentOf = routeParam(routes.agent, req.method, pathname);
109
112
  if (agentOf) {
110
113
  const sessionId = agentOf;
111
- const body = JSON.parse((await readBody(req)) || '{}');
112
- if (body.effort && !EFFORT_LABELS[body.effort]) {
113
- return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
114
- }
114
+ const body = parseInput(setAgentOptionsSchema, parseJsonBody(await readBody(req)));
115
115
  const frozen = workflowFrozenError(sessionId);
116
116
  if (frozen && (body.model !== undefined || body.effort !== undefined || body.fast !== undefined)) {
117
117
  return json(req, res, 409, frozen);
@@ -177,17 +177,23 @@ export function createBaseServices() {
177
177
  /** Fail closed if another UI-capable relay cannot prove it speaks this protocol. */
178
178
  async function workflowCompatibilityError() {
179
179
  if (!orchestration.writable) {
180
- return `Workflow is disabled because ${orchestrationUnavailableReason()}.`;
180
+ return { kind: 'incompatible', message: `Workflow is disabled because ${orchestrationUnavailableReason()}.` };
181
181
  }
182
182
  try {
183
183
  const processes = await listUiCapableRelayProcesses();
184
184
  const incompatible = incompatibleRelayProcesses(processes, orchestration.listRelayInstances(), ORCHESTRATION_PROTOCOL_VERSION);
185
185
  if (!incompatible.length)
186
186
  return null;
187
- return `Workflow needs every live conductor-remote UI relay on protocol ${ORCHESTRATION_PROTOCOL_VERSION}. Stop incompatible PID${incompatible.length === 1 ? '' : 's'} ${incompatible.map(process => process.pid).join(', ')} and try again.`;
187
+ return {
188
+ kind: 'incompatible',
189
+ message: `Workflow needs every live conductor-remote UI relay on protocol ${ORCHESTRATION_PROTOCOL_VERSION}. Stop incompatible PID${incompatible.length === 1 ? '' : 's'} ${incompatible.map(process => process.pid).join(', ')} and try again.`
190
+ };
188
191
  }
189
192
  catch (error) {
190
- return `Workflow could not verify the live relay processes: ${error instanceof Error ? error.message : String(error)}`;
193
+ return {
194
+ kind: 'unverified',
195
+ message: `Workflow could not verify the live relay processes: ${error instanceof Error ? error.message : String(error)}`
196
+ };
191
197
  }
192
198
  }
193
199
  /**
@@ -0,0 +1,53 @@
1
+ import { chatRoute, notifyAll } from "../../notifications/notify.js";
2
+ import { workflowBlockMessage } from "../../orchestration/workflow/blocked.js";
3
+ import { scrubWorkflowSecrets, workspaceTitle } from "../../shared.js";
4
+ import { uiTurn } from "../../writes/ui-lock.js";
5
+ export function createWorkflowNotificationServices(services) {
6
+ const { reads, orchestration, actuator, workflowCompatibilityError, locateChat, confirmDelivery, CONFIRM_WINDOW_MS } = services;
7
+ function title(notice) {
8
+ const ws = notice.run.workspaceId ? reads.getWorkspace(notice.run.workspaceId) : null;
9
+ return scrubWorkflowSecrets(ws ? workspaceTitle(ws) : notice.run.target.kind === 'new_workspace' ? notice.run.target.repo : 'Conductor').slice(0, 160);
10
+ }
11
+ return {
12
+ notifyBlocked: async (notice) => {
13
+ const { run } = notice;
14
+ await notifyAll({
15
+ title: title(notice),
16
+ body: workflowBlockMessage(notice, title(notice)),
17
+ tag: `workflow-block-${run.id}-${notice.eventId}`,
18
+ url: run.workspaceId && run.rootSessionId ? chatRoute(run.workspaceId, run.rootSessionId) : '/',
19
+ kind: 'error',
20
+ ts: Date.now()
21
+ });
22
+ },
23
+ sendBlockedNotice: (notice) => uiTurn(async () => {
24
+ if (await workflowCompatibilityError())
25
+ return;
26
+ // Recheck under both UI locks. Recovery or a different failure may have won
27
+ // while this background send waited behind a phone operation or its probe.
28
+ const run = orchestration.getWorkflowRun(notice.run.id);
29
+ const latest = orchestration
30
+ .listWorkflowEvents(notice.run.id)
31
+ .findLast(event => event.type === 'workflow_blocked');
32
+ if (run?.phase !== 'blocked' || latest?.id !== notice.eventId || orchestration.getUiQuarantine().active)
33
+ return;
34
+ const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
35
+ const sessionId = run.rootSessionId;
36
+ if (!ws || !sessionId || !reads.getSession(sessionId) || reads.sessionWorkspaceId(sessionId) !== ws.id)
37
+ return;
38
+ const located = locateChat(ws, sessionId);
39
+ if ('error' in located)
40
+ return;
41
+ const text = `${workflowBlockMessage(notice, workspaceTitle(ws))}\n\nFurther Baton sends are paused until this Workflow recovers.`;
42
+ const before = reads.deliveryCursor(sessionId);
43
+ const deadline = Date.now() + 25_000;
44
+ const result = await actuator.send({ workspace: ws, sessionId, tab: located.tab }, text, {
45
+ queue: true,
46
+ deadline: deadline - CONFIRM_WINDOW_MS
47
+ });
48
+ const receipt = await confirmDelivery(sessionId, text, before, deadline);
49
+ if (!receipt)
50
+ throw new Error(result.error ?? 'Conductor did not record the Workflow block notice.');
51
+ })
52
+ };
53
+ }
@@ -1,13 +1,16 @@
1
1
  import { materializeStagedAttachments } from "../../files/staged-attachments.js";
2
2
  import { WorkflowCoordinator } from "../../orchestration/workflow/coordinator.js";
3
- import { WorkflowCoordinatorError } from "../../orchestration/workflow/errors.js";
3
+ import { WorkflowCompatibilityReadError, WorkflowCoordinatorError, WorkflowRoleVerificationError } from "../../orchestration/workflow/errors.js";
4
4
  import { WorkflowRequestError, workflowClientIsMcp } from "../../orchestration/workflow/http.js";
5
+ import { workflowReportBody } from "../../orchestration/workflow/report.js";
5
6
  import { renderTranscript } from "../../transcript/parser.js";
6
7
  import { retryWontHelp, screenLocked } from "../../writes/guards.js";
8
+ import { createWorkflowNotificationServices } from "./workflow-notifications.js";
7
9
  export function createWorkflowServices(services) {
8
10
  const { orchestration, relayIdentity, reads, stagedAttachmentIdsInObjective, STAGED_ATTACHMENTS_DIR, createWorkspaceAndRead, workflowCompatibilityError, actuator, sessionPoller, workflowRootInspection, withWorkflowEffectGate, sessionMatchesWorkflowRole, workflowDeliveryCursor, stableWorkflowAttachment, sendWorkflowPrompt, workflowSessionId, assertWorkflowRootStillPristine, workspaceBaseline, workflowWorkspaceCandidate, sessionBaseline, workflowSessionCandidate, workflowEffectPrompt, readsDeliveryCursor, workflowEffectMarker, applyAgentPatch, openChat, batonText } = services;
9
11
  const workflowCoordinator = orchestration.writable
10
12
  ? new WorkflowCoordinator(orchestration, relayIdentity, {
13
+ ...createWorkflowNotificationServices(services),
11
14
  captureWorkspaceBaseline: async (repoName) => {
12
15
  const repo = reads.listRepos().find(candidate => candidate.name === repoName);
13
16
  if (!repo) {
@@ -103,9 +106,11 @@ export function createWorkflowServices(services) {
103
106
  throw new WorkflowCoordinatorError('workflow_effect_failed', applied.error ?? 'Conductor rejected the frozen role settings.');
104
107
  }
105
108
  const session = reads.getSession(sessionId);
106
- if (!session || !sessionMatchesWorkflowRole(session, role)) {
107
- throw new WorkflowCoordinatorError('workflow_effect_failed', 'Conductor no longer matches every frozen role setting; no fallback was selected.');
109
+ if (!session) {
110
+ throw new WorkflowCoordinatorError('workflow_effect_failed', 'The Workflow chat disappeared after applying its role settings.');
108
111
  }
112
+ if (!sessionMatchesWorkflowRole(session, role))
113
+ throw new WorkflowRoleVerificationError();
109
114
  return {
110
115
  sessionId,
111
116
  agentType: session.agent_type,
@@ -180,6 +185,12 @@ export function createWorkflowServices(services) {
180
185
  return stableWorkflowAttachment(ws.worktree, job.id, `Workflow ${job.role} handoff.md`, body);
181
186
  },
182
187
  sendPrompt: call => sendWorkflowPrompt(call, false),
188
+ materializeReport: async ({ run, job, outcome }) => {
189
+ const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
190
+ if (!ws?.worktree)
191
+ throw new Error('The Workflow report worktree is unavailable.');
192
+ return stableWorkflowAttachment(ws.worktree, `${job.id}:report:${job.attemptCount}`, `Workflow ${job.role} report.md`, workflowReportBody(run, job, outcome));
193
+ },
183
194
  returnBaton: call => sendWorkflowPrompt(call, true),
184
195
  resolveDeliveryReceipt: async ({ sessionId, receipt }) => {
185
196
  const current = reads.deliveryReceiptForId(sessionId, receipt.id);
@@ -223,6 +234,8 @@ export function createWorkflowServices(services) {
223
234
  return {
224
235
  kind: 'success',
225
236
  baton: batonText(last.text),
237
+ text: last.text,
238
+ assistantRowid: last.rowid,
226
239
  evidence: { assistantRowid: last.rowid }
227
240
  };
228
241
  },
@@ -329,7 +342,9 @@ export function createWorkflowServices(services) {
329
342
  assertCompatibleRelays: async () => {
330
343
  const error = await workflowCompatibilityError();
331
344
  if (error) {
332
- throw new WorkflowCoordinatorError('workflow_incompatible_relay', error, {
345
+ if (error.kind === 'unverified')
346
+ throw new WorkflowCompatibilityReadError(error.message);
347
+ throw new WorkflowCoordinatorError('workflow_incompatible_relay', error.message, {
333
348
  status: 409,
334
349
  retryable: true
335
350
  });
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { stateDir } from "../config.js";
4
+ import { READ_TIMEOUT_MS } from "./protocol.js";
5
+ function relayErrorMessage(error, status) {
6
+ if (typeof error === 'string' && error)
7
+ return error;
8
+ if (error && typeof error === 'object') {
9
+ const fields = error;
10
+ const parts = [fields.code, fields.message].filter((part) => typeof part === 'string' && !!part);
11
+ if (parts.length)
12
+ return parts.join(': ');
13
+ }
14
+ return `HTTP ${status}`;
15
+ }
16
+ export function relayBase() {
17
+ const port = process.env.RELAY_PORT ?? '8787';
18
+ // The relay binds loopback (see config.ts); RELAY_HOST only widens who else can
19
+ // reach it, so 127.0.0.1 is always right for a client on the same Mac.
20
+ return `http://127.0.0.1:${port}`;
21
+ }
22
+ function relayToken() {
23
+ if (process.env.RELAY_TOKEN)
24
+ return process.env.RELAY_TOKEN;
25
+ try {
26
+ return readFileSync(path.join(stateDir(), 'token'), 'utf8').trim();
27
+ }
28
+ catch {
29
+ throw new Error(`no relay token at ${path.join(stateDir(), 'token')} — start the relay once (conductor-remote service install), or set RELAY_TOKEN`);
30
+ }
31
+ }
32
+ export async function call(route, opts = {}) {
33
+ const timeoutMs = opts.timeoutMs ?? READ_TIMEOUT_MS;
34
+ let res;
35
+ try {
36
+ res = await fetch(`${relayBase()}${route}`, {
37
+ method: opts.method ?? 'GET',
38
+ signal: AbortSignal.timeout(timeoutMs),
39
+ headers: {
40
+ authorization: `Bearer ${relayToken()}`,
41
+ 'content-type': 'application/json',
42
+ // Marks this caller as an agent: the relay drops it to background priority on
43
+ // the UI lock, behind anyone using the phone (src/http/router.ts ▸ withUiPriority).
44
+ 'x-relay-client': 'mcp',
45
+ // The relay retries a failed send inside this budget and never past it, so
46
+ // stating it here is what stops it outliving us — see src/http/services/delivery.ts ▸ sendBudget.
47
+ 'x-client-timeout-ms': String(timeoutMs)
48
+ },
49
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body)
50
+ });
51
+ }
52
+ catch (err) {
53
+ if (err instanceof DOMException && err.name === 'TimeoutError')
54
+ throw new Error(`the relay did not answer within ${Math.round(timeoutMs / 1000)}s`);
55
+ throw new Error(`cannot reach the relay at ${relayBase()} (${err instanceof Error ? err.message : err}). Is it running? \`conductor-remote service status\``);
56
+ }
57
+ const payload = await res.json().catch(() => ({}));
58
+ if (!res.ok) {
59
+ // 503 is the UI lock refusing a deep queue, and it is worth naming as such: it
60
+ // means "retry shortly", not "this failed".
61
+ const busy = res.status === 503 ? ' (Conductor’s UI is busy — retry shortly)' : '';
62
+ const error = payload && typeof payload === 'object' && 'error' in payload ? payload.error : undefined;
63
+ throw new Error(`${relayErrorMessage(error, res.status)}${busy}`);
64
+ }
65
+ return payload;
66
+ }
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ import { parseInput } from "../contracts/validation.js";
3
+ /** Export the caller's input shape and validate it before the tool can make an HTTP call. */
4
+ export function defineTool(definition) {
5
+ const { inputSchema, run, ...metadata } = definition;
6
+ return {
7
+ ...metadata,
8
+ inputSchema: z.toJSONSchema(inputSchema, { io: 'input' }),
9
+ run: async (args) => run(parseInput(inputSchema, args))
10
+ };
11
+ }
@@ -1,5 +1,7 @@
1
+ import { hasAgentSettings, sessionIdSchema, setAgentOptionsSchema } from "../../contracts/agent-inputs.js";
1
2
  import { routes } from "../../routes.js";
2
3
  import { need, str } from "../arguments.js";
4
+ import { defineTool } from "../define-tool.js";
3
5
  import { WRITE_TIMEOUT_MS } from "../protocol.js";
4
6
  export function createListModelsTool(call) {
5
7
  return {
@@ -69,45 +71,25 @@ export function createSetDefaultModelTool(call) {
69
71
  }
70
72
  };
71
73
  }
74
+ const setAgentOptionsInputSchema = setAgentOptionsSchema
75
+ .omit({ workspaceId: true })
76
+ .extend({
77
+ session_id: sessionIdSchema,
78
+ workspace_id: setAgentOptionsSchema.shape.workspaceId
79
+ })
80
+ .strict();
72
81
  export function createSetAgentOptionsTool(call) {
73
- return {
82
+ return defineTool({
74
83
  name: 'set_agent_options',
75
84
  description: 'Change how a chat’s agent runs: model, reasoning effort, plan mode, fast mode. Conductor keeps these in its composer and nowhere else, so this is the only way to reach them — a prompt cannot. DRIVES THE REAL UI and steals focus for a few seconds. The change is confirmed against Conductor’s database before this answers. Applies to the NEXT turn, so set it before send_prompt, not during one. Ask the user before re-pointing a chat they did not name at a different model.',
76
- inputSchema: {
77
- type: 'object',
78
- properties: {
79
- session_id: { type: 'string' },
80
- workspace_id: {
81
- type: 'string',
82
- description: 'Strongly recommended: it is what the relay asserts against before pressing anything.'
83
- },
84
- model: { type: 'string', description: 'A label from list_models. An unambiguous prefix is enough.' },
85
- effort: { type: 'string', enum: ['none', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'] },
86
- plan: { type: 'boolean', description: 'Conductor’s Plan checkbox.' },
87
- fast: {
88
- type: 'boolean',
89
- description: 'Fast mode. Only some models offer it; a missing button is reported, not ignored.'
90
- }
91
- },
92
- required: ['session_id']
93
- },
85
+ inputSchema: setAgentOptionsInputSchema,
94
86
  run: async (args) => {
95
- const sessionId = need(args, 'session_id');
96
- const patch = {
97
- model: str(args.model),
98
- effort: str(args.effort),
99
- plan: typeof args.plan === 'boolean' ? args.plan : undefined,
100
- fast: typeof args.fast === 'boolean' ? args.fast : undefined,
101
- workspaceId: str(args.workspace_id)
102
- };
103
- if (patch.model === undefined &&
104
- patch.effort === undefined &&
105
- patch.plan === undefined &&
106
- patch.fast === undefined)
87
+ const { session_id: sessionId, workspace_id: workspaceId, ...patch } = args;
88
+ if (!hasAgentSettings(patch))
107
89
  throw new Error('nothing to change — pass at least one of model, effort, plan, fast');
108
90
  const data = await call(routes.agent.path(sessionId), {
109
91
  method: routes.agent.method,
110
- body: patch,
92
+ body: { ...patch, workspaceId },
111
93
  timeoutMs: WRITE_TIMEOUT_MS
112
94
  });
113
95
  if (!data.ok)
@@ -118,5 +100,5 @@ export function createSetAgentOptionsTool(call) {
118
100
  ? `now: ${[s.model, s.claude_effort_level, s.permission_mode, s.fast_mode ? 'fast' : null].filter(Boolean).join(' · ')}`
119
101
  : 'applied';
120
102
  }
121
- };
103
+ });
122
104
  }
@@ -1,32 +1,26 @@
1
+ import { z } from 'zod';
2
+ import { sendPromptSchema, sessionIdSchema } from "../../contracts/agent-inputs.js";
1
3
  import { routes } from "../../routes.js";
2
4
  import { parseChatCursor } from "../../transcript/cursor.js";
3
- import { need, rejectUnknown, str } from "../arguments.js";
5
+ import { need, str } from "../arguments.js";
6
+ import { defineTool } from "../define-tool.js";
4
7
  import { plural } from "../formatters.js";
5
8
  import { WRITE_TIMEOUT_MS } from "../protocol.js";
9
+ const sendPromptInputSchema = z.strictObject({
10
+ session_id: sessionIdSchema,
11
+ workspace_id: sendPromptSchema.shape.workspaceId,
12
+ text: sendPromptSchema.shape.text
13
+ });
6
14
  export function createSendPromptTool(call) {
7
- return {
15
+ return defineTool({
8
16
  name: 'send_prompt',
9
17
  description: 'Send a prompt into an existing Conductor chat, exactly as typing it on the Mac would. This DRIVES THE REAL UI: it focuses the workspace, selects the chat tab and presses Enter, so it steals focus for a few seconds. If that chat is already working, the message STEERS the running agent rather than starting a new turn — do not use it to poll or test. Ask the user before sending into a chat they did not name.',
10
- inputSchema: {
11
- type: 'object',
12
- properties: {
13
- session_id: { type: 'string', description: 'The chat to send to (list_chats / search_chats).' },
14
- workspace_id: {
15
- type: 'string',
16
- description: 'Its workspace. Strongly recommended: it is what the relay asserts against before typing.'
17
- },
18
- text: { type: 'string' }
19
- },
20
- required: ['session_id', 'text'],
21
- additionalProperties: false
22
- },
18
+ inputSchema: sendPromptInputSchema,
23
19
  run: async (args) => {
24
- rejectUnknown(args, ['session_id', 'workspace_id', 'text']);
25
- const sessionId = need(args, 'session_id');
26
- const text = need(args, 'text');
20
+ const { session_id: sessionId, workspace_id: workspaceId, text } = args;
27
21
  const data = await call(routes.sendPrompt.path(sessionId), {
28
22
  method: routes.sendPrompt.method,
29
- body: { text, workspaceId: str(args.workspace_id) },
23
+ body: { text, workspaceId },
30
24
  timeoutMs: WRITE_TIMEOUT_MS
31
25
  });
32
26
  if (data.parked)
@@ -35,7 +29,7 @@ export function createSendPromptTool(call) {
35
29
  throw new Error(data.error ?? 'the send did not land');
36
30
  return data.warning ? `sent (${data.warning})` : 'sent';
37
31
  }
38
- };
32
+ });
39
33
  }
40
34
  export function createSplitChatTool(call) {
41
35
  return {