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.
- package/dist/assets/{PierrePatch-4wYckD3t.js → PierrePatch-Sohj8IVv.js} +1 -1
- package/dist/assets/index-CJ_yLSwD.css +1 -0
- package/dist/assets/{index-D0mVtqUh.js → index-CMkZa4wP.js} +41 -41
- package/dist/index.html +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/src/contracts/agent-inputs.js +44 -0
- package/dist-node/src/contracts/validation.js +16 -0
- package/dist-node/src/http/input.js +9 -0
- package/dist-node/src/http/router.js +3 -0
- package/dist-node/src/http/routes/create-workspace.js +10 -25
- package/dist-node/src/http/routes/prompts.js +9 -17
- package/dist-node/src/http/routes/sessions.js +5 -5
- package/dist-node/src/http/services/base.js +9 -3
- package/dist-node/src/http/services/workflow-notifications.js +53 -0
- package/dist-node/src/http/services/workflow.js +19 -4
- package/dist-node/src/mcp/client.js +66 -0
- package/dist-node/src/mcp/define-tool.js +11 -0
- package/dist-node/src/mcp/tools/agent-options.js +15 -33
- package/dist-node/src/mcp/tools/chat-actions.js +14 -20
- package/dist-node/src/mcp/tools/workspaces.js +21 -43
- package/dist-node/src/mcp.js +3 -57
- package/dist-node/src/orchestration/persistence/db.js +4 -2
- package/dist-node/src/orchestration/persistence/effect-transitions.js +17 -0
- package/dist-node/src/orchestration/persistence/events.js +10 -0
- package/dist-node/src/orchestration/workflow/blocked.js +93 -0
- package/dist-node/src/orchestration/workflow/compatibility.js +42 -0
- package/dist-node/src/orchestration/workflow/effects.js +46 -1
- package/dist-node/src/orchestration/workflow/errors.js +14 -0
- package/dist-node/src/orchestration/workflow/helpers.js +2 -0
- package/dist-node/src/orchestration/workflow/job-results.js +9 -1
- package/dist-node/src/orchestration/workflow/report.js +31 -0
- package/dist-node/src/orchestration/workflow/wake.js +20 -0
- package/dist-node/src/prefs.js +2 -1
- package/dist-node/src/shared.js +5 -0
- package/dist-node/src/wire.js +2 -2
- package/docs/voice-setup.md +7 -4
- package/package.json +1 -1
- 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-
|
|
29
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
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
|
+
}
|
|
@@ -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 {
|
|
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
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 =
|
|
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
|
|
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
|
|
55
|
-
if ('workflow' in
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
107
|
-
throw new WorkflowCoordinatorError('workflow_effect_failed', '
|
|
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
|
-
|
|
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 =
|
|
96
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 {
|