@vornrun/mcp 0.7.2 → 0.7.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.
Files changed (2) hide show
  1. package/dist/index.js +879 -226
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -3065,6 +3065,10 @@ function registerSessionTools(server) {
3065
3065
 
3066
3066
  // src/tools/workflows.ts
3067
3067
  import crypto4 from "crypto";
3068
+ import { isDeepStrictEqual } from "util";
3069
+
3070
+ // ../shared/src/template-vars.ts
3071
+ var MAX_OUTPUT_LENGTH = 5e4;
3068
3072
 
3069
3073
  // ../shared/src/workflow-graph.ts
3070
3074
  var MAX_GATE_ROUNDS = 10;
@@ -3089,6 +3093,8 @@ function nodesBetween(from, to, edges) {
3089
3093
  const above = reach(to, predecessors);
3090
3094
  return new Set([...below].filter((id) => above.has(id)));
3091
3095
  }
3096
+ var LOOP_RESULT_OUTPUT_CHARS = 8e3;
3097
+ var MAX_LOOP_ITERATIONS = 10;
3092
3098
  function isSignInWait(state) {
3093
3099
  return state.status === "waiting" && state.waitingFor === "signIn";
3094
3100
  }
@@ -3101,9 +3107,391 @@ function buildGraph(edges) {
3101
3107
  }
3102
3108
  return { successors, predecessors };
3103
3109
  }
3110
+ function loopBodyOwners(nodes) {
3111
+ const exists = new Set(nodes.map((n) => n.id));
3112
+ const owners = /* @__PURE__ */ new Map();
3113
+ for (const n of nodes) {
3114
+ if (n.type !== "loop") continue;
3115
+ for (const id of n.config.bodyNodeIds ?? []) {
3116
+ if (exists.has(id) && id !== n.id && !owners.has(id)) owners.set(id, n.id);
3117
+ }
3118
+ }
3119
+ return owners;
3120
+ }
3121
+ function loopBodyGraph(nodes, edges, loop) {
3122
+ const owners = loopBodyOwners(nodes);
3123
+ const byId = new Map(nodes.map((n) => [n.id, n]));
3124
+ const members = (loop.config.bodyNodeIds ?? []).filter((id) => owners.get(id) === loop.id).map((id) => byId.get(id));
3125
+ const ids = new Set(members.map((m) => m.id));
3126
+ let inner = edges.filter((e) => ids.has(e.source) && ids.has(e.target));
3127
+ if (inner.length === 0 && members.length > 1) {
3128
+ inner = members.slice(1).map((m, i) => ({
3129
+ id: `${members[i].id}->${m.id}:chain`,
3130
+ source: members[i].id,
3131
+ target: m.id
3132
+ }));
3133
+ }
3134
+ const fed = new Set(inner.map((e) => e.target));
3135
+ return { members, edges: inner, entries: members.filter((m) => !fed.has(m.id)).map((m) => m.id) };
3136
+ }
3137
+ function loopStructureError(nodes, edges, loop) {
3138
+ const { members, edges: inner } = loopBodyGraph(nodes, edges, loop);
3139
+ if (members.length === 0) return "Loop has no body steps. Add at least one step for it to repeat.";
3140
+ const ids = new Set(members.map((m) => m.id));
3141
+ for (const m of members) {
3142
+ if (m.type === "approval") {
3143
+ return `Loop body contains an approval gate ("${m.label}"), which is not supported.`;
3144
+ }
3145
+ if (m.type === "loop")
3146
+ return `Loop body contains another loop ("${m.label}"), which is not supported.`;
3147
+ if (m.type === "trigger") return `Loop body contains a trigger ("${m.label}").`;
3148
+ }
3149
+ for (const e of edges) {
3150
+ if (ids.has(e.target) && !ids.has(e.source) && e.source !== loop.id) {
3151
+ const from = nodes.find((n) => n.id === e.source)?.label ?? e.source;
3152
+ return `"${from}" feeds a step inside the loop from outside it. Only the loop starts its steps.`;
3153
+ }
3154
+ if (ids.has(e.source) && !ids.has(e.target) && e.conditionBranch) {
3155
+ return "A condition inside the loop branches to a step outside it. Keep both branches inside the loop.";
3156
+ }
3157
+ }
3158
+ const indegree = new Map(members.map((m) => [m.id, 0]));
3159
+ for (const e of inner) indegree.set(e.target, (indegree.get(e.target) ?? 0) + 1);
3160
+ const queue = [...indegree].filter(([, d]) => d === 0).map(([id]) => id);
3161
+ let visited = 0;
3162
+ while (queue.length > 0) {
3163
+ const id = queue.shift();
3164
+ visited++;
3165
+ for (const e of inner) {
3166
+ if (e.source !== id) continue;
3167
+ const d = (indegree.get(e.target) ?? 0) - 1;
3168
+ indegree.set(e.target, d);
3169
+ if (d === 0) queue.push(e.target);
3170
+ }
3171
+ }
3172
+ if (visited < members.length) return "The steps inside the loop form a cycle.";
3173
+ return void 0;
3174
+ }
3104
3175
 
3105
3176
  // src/tools/workflows.ts
3177
+ import { z as z6 } from "zod";
3178
+
3179
+ // src/tools/node-config-schemas.ts
3106
3180
  import { z as z5 } from "zod";
3181
+ var longText = z5.string().max(2e5, "Text must be 200000 characters or less");
3182
+ var taskStatus = z5.enum(["todo", "in_progress", "in_review", "done", "cancelled"]);
3183
+ var workflowInputDefSchema = z5.object({
3184
+ key: z5.string().regex(
3185
+ /^[A-Za-z_][A-Za-z0-9_]*$/,
3186
+ "key must be a valid identifier \u2014 it becomes {{inputs.<key>}}"
3187
+ ).max(100).describe("Identifier the value is read by, as {{inputs.<key>}}"),
3188
+ label: V.shortText.describe("What the run dialog calls this field"),
3189
+ type: z5.enum(["text", "textarea", "number", "select", "boolean", "project", "branch"]).describe("Which control the run dialog shows, and how a supplied value is checked"),
3190
+ required: z5.boolean().optional().describe("A run cannot start without a value"),
3191
+ defaultValue: V.shortText.optional().describe(
3192
+ 'Used when the run supplies nothing. A number must be finite, a boolean "true" or "false", a select one of its options'
3193
+ ),
3194
+ options: z5.array(
3195
+ z5.object({
3196
+ value: V.shortText.describe("What {{inputs.<key>}} expands to"),
3197
+ label: V.shortText.describe("What the dialog shows")
3198
+ }).loose()
3199
+ ).optional().describe("Choices for a select; a select without any is refused"),
3200
+ placeholder: V.shortText.optional().describe("Hint shown in an empty field"),
3201
+ description: V.shortText.optional().describe("Help text shown under the field")
3202
+ }).loose().superRefine((def, ctx) => {
3203
+ const options = def.options ?? [];
3204
+ if (def.type === "select" && options.length === 0) {
3205
+ ctx.addIssue({
3206
+ code: "custom",
3207
+ path: ["options"],
3208
+ message: `select input "${def.key}" declares no options, so the run dialog could offer nothing`
3209
+ });
3210
+ }
3211
+ if (def.defaultValue === void 0) return;
3212
+ if (def.type === "number" && !Number.isFinite(Number(def.defaultValue))) {
3213
+ ctx.addIssue({
3214
+ code: "custom",
3215
+ path: ["defaultValue"],
3216
+ message: `default "${def.defaultValue}" for number input "${def.key}" is not a finite number`
3217
+ });
3218
+ }
3219
+ if (def.type === "boolean" && !["true", "false"].includes(def.defaultValue)) {
3220
+ ctx.addIssue({
3221
+ code: "custom",
3222
+ path: ["defaultValue"],
3223
+ message: `default "${def.defaultValue}" for boolean input "${def.key}" must be "true" or "false"`
3224
+ });
3225
+ }
3226
+ if (def.type === "select" && options.length > 0 && !options.some((o) => o.value === def.defaultValue)) {
3227
+ ctx.addIssue({
3228
+ code: "custom",
3229
+ path: ["defaultValue"],
3230
+ message: `default "${def.defaultValue}" for select input "${def.key}" is not one of its options`
3231
+ });
3232
+ }
3233
+ });
3234
+ var workflowInputsSchema = z5.array(workflowInputDefSchema).superRefine((inputs, ctx) => {
3235
+ const seen = /* @__PURE__ */ new Set();
3236
+ inputs.forEach((def, index) => {
3237
+ if (seen.has(def.key)) {
3238
+ ctx.addIssue({
3239
+ code: "custom",
3240
+ path: [index, "key"],
3241
+ message: `duplicate input key "${def.key}" \u2014 only one value can survive under {{inputs.${def.key}}}`
3242
+ });
3243
+ }
3244
+ seen.add(def.key);
3245
+ });
3246
+ });
3247
+ var projectFilter = V.name.optional().describe("Only fire for this project, by name. Omit to fire for every project");
3248
+ var triggerConfigSchema = z5.discriminatedUnion("triggerType", [
3249
+ z5.object({
3250
+ triggerType: z5.literal("manual").describe("Runs when someone starts it, or execute_workflow"),
3251
+ contextual: z5.boolean().optional().describe(
3252
+ "Offered from a task card or terminal right-click, inheriting its folder, branch and worktree as {{context.*}}"
3253
+ ),
3254
+ inputs: workflowInputsSchema.optional().describe("Parameters asked for before each run, read as {{inputs.<key>}}")
3255
+ }).loose(),
3256
+ z5.object({
3257
+ triggerType: z5.literal("once").describe("Runs once at a set time"),
3258
+ runAt: V.shortText.describe("When to run, as an ISO 8601 date-time")
3259
+ }).loose(),
3260
+ z5.object({
3261
+ triggerType: z5.literal("recurring").describe("Runs on a cron schedule"),
3262
+ cron: V.shortText.describe('Five-field cron expression, e.g. "0 9 * * 1-5"'),
3263
+ timezone: V.shortText.optional().describe('IANA zone the cron is read in, e.g. "Europe/Madrid". Defaults to the machine')
3264
+ }).loose(),
3265
+ z5.object({
3266
+ triggerType: z5.literal("taskCreated").describe("Runs when a task is created; the task is {{task.*}}"),
3267
+ projectFilter
3268
+ }).loose(),
3269
+ z5.object({
3270
+ triggerType: z5.literal("taskStatusChanged").describe(
3271
+ "Runs when a task moves between statuses; the task is {{task.*}}, the move {{trigger.fromStatus}} and {{trigger.toStatus}}"
3272
+ ),
3273
+ projectFilter,
3274
+ fromStatus: taskStatus.optional().describe("Only when it leaves this status"),
3275
+ toStatus: taskStatus.optional().describe("Only when it enters this status")
3276
+ }).loose(),
3277
+ z5.object({
3278
+ triggerType: z5.literal("sessionRestored").describe(
3279
+ "Runs when a session comes back after a quit, crash or reboot; {{trigger.restore}} says cold or warm"
3280
+ ),
3281
+ projectFilter,
3282
+ restore: z5.enum(["cold", "any"]).optional().describe(
3283
+ "cold (default): only when a process starts again. any: also when a pane reattaches to one still running"
3284
+ ),
3285
+ concurrency: z5.enum(["perProject", "unbounded"]).optional().describe(
3286
+ "perProject (default) runs one at a time per project, so restored sessions do not race for one port"
3287
+ )
3288
+ }).loose(),
3289
+ z5.object({
3290
+ triggerType: z5.literal("connectorPoll").describe(
3291
+ "Polls a connection on a cron and runs once per new item; the item is {{connectorItem.*}}"
3292
+ ),
3293
+ connectionId: V.id.describe("Connection to poll, from list_connections"),
3294
+ event: V.shortText.describe(
3295
+ "Event type from the connector's manifest, e.g. issueCreated (list_connector_actions shows them)"
3296
+ ),
3297
+ cron: V.shortText.describe("How often to poll, as a five-field cron expression"),
3298
+ timezone: V.shortText.optional().describe("IANA zone the cron is read in")
3299
+ }).loose(),
3300
+ z5.object({
3301
+ triggerType: z5.literal("webhook").describe(
3302
+ "Runs when the local webhook route receives a request; its body, headers and query are {{trigger.*}}"
3303
+ ),
3304
+ method: z5.enum(["POST", "GET"]).describe("HTTP method the route answers"),
3305
+ token: V.shortText.describe(
3306
+ "Secret path segment; a request without it is refused. Use a long random string"
3307
+ )
3308
+ }).loose()
3309
+ ]);
3310
+ var launchAgentNodeConfigSchema = z5.object({
3311
+ agentType: z5.enum(["claude", "copilot", "codex", "opencode", "gemini", "fromTask"]).describe(
3312
+ "Which agent CLI runs. fromTask uses the task's assigned agent, so it needs a task in scope (a task trigger, taskId or taskFromQueue)"
3313
+ ),
3314
+ model: V.shortText.optional().describe('Model passed to the CLI as written, e.g. "opus". Needs a concrete agentType'),
3315
+ projectName: z5.string().max(200).describe("Registered project the agent works in, by name (list_projects)"),
3316
+ projectPath: z5.string().max(1e3).describe("That project's absolute path; templates such as {{context.projectPath}} work"),
3317
+ prompt: V.prompt.optional().describe("What the agent is asked to do. Templates expand before it is sent"),
3318
+ headless: z5.boolean().optional().describe(
3319
+ "Run in the background and wait for it to finish, so later steps can read its output. Without it the agent opens in a terminal tab and the run does not wait on its answer"
3320
+ ),
3321
+ outputSchema: z5.record(z5.string(), z5.unknown()).optional().describe(
3322
+ "JSON Schema its final answer must match (headless only). Each top-level property becomes {{steps.<slug>.<field>}}; an answer that does not match fails the step"
3323
+ ),
3324
+ timeoutMs: z5.number().int().positive().optional().describe(
3325
+ "How long a headless run may take before it is killed and the step fails, in milliseconds. Defaults to the app setting"
3326
+ ),
3327
+ worktreeMode: z5.enum(["none", "new", "fromStep", "existing"]).optional().describe(
3328
+ "Where it works: none (the project folder), new (a fresh git worktree), fromStep (the worktree an earlier step made), existing (existingWorktreePath)"
3329
+ ),
3330
+ worktreeFromStepSlug: V.shortText.optional().describe(
3331
+ "For worktreeMode fromStep: slug of the earlier agent step whose worktree to reuse"
3332
+ ),
3333
+ existingWorktreePath: z5.string().max(1e3).optional().describe("For worktreeMode existing: absolute path of the worktree to work in"),
3334
+ useWorktree: z5.union([z5.boolean(), z5.literal("fromContext")]).optional().describe(
3335
+ "Older switch for a new worktree; prefer worktreeMode. fromContext inherits it from a contextual manual trigger"
3336
+ ),
3337
+ branch: V.shortText.optional().describe("Branch to check out or create for the worktree"),
3338
+ args: z5.array(V.shortText).optional().describe("Extra command-line arguments for the agent CLI"),
3339
+ displayName: V.shortText.optional().describe("Name shown on the session tab"),
3340
+ remoteHostId: V.id.optional().describe("Run on this remote host instead of this machine"),
3341
+ promptDelayMs: z5.number().nonnegative().optional().describe("Wait this long after the agent starts before sending the prompt, in milliseconds"),
3342
+ taskId: V.id.optional().describe("Task the session is attached to"),
3343
+ taskFromQueue: z5.boolean().optional().describe("Take the project's next todo task and attach the session to it")
3344
+ }).loose().refine((c) => !c.outputSchema || c.headless === true, {
3345
+ // The engine only parses typed output for headless runs; reject a config that
3346
+ // declares a schema it would silently ignore instead of accepting a lie.
3347
+ message: "outputSchema requires headless: true",
3348
+ path: ["outputSchema"]
3349
+ });
3350
+ var scriptConfigSchema = z5.object({
3351
+ scriptType: z5.enum(["bash", "powershell", "python", "node"]).describe("Interpreter the script runs under"),
3352
+ scriptContent: longText.describe(
3353
+ "The script. Templates expand before it runs, so quote them as data. Its stdout is {{steps.<slug>.output}}; a non-zero exit fails the step"
3354
+ ),
3355
+ cwd: z5.string().max(1e3).optional().describe("Directory it runs in. Defaults to projectPath"),
3356
+ projectName: z5.string().max(200).optional().describe("Project the script belongs to, by name"),
3357
+ projectPath: z5.string().max(1e3).optional().describe("That project's absolute path, used as the working directory when cwd is unset"),
3358
+ args: z5.array(V.shortText).optional().describe("Arguments passed to the script"),
3359
+ secretsFrom: V.id.optional().describe(
3360
+ "Connection whose stored secrets become this script's environment variables at run time, so no secret is written into the workflow"
3361
+ ),
3362
+ runId: V.id.optional().describe("Set by the engine; leave it out")
3363
+ }).loose();
3364
+ var conditionConfigSchema = z5.object({
3365
+ variable: longText.describe(
3366
+ "The value to test, usually a template such as {{steps.review.approved}}"
3367
+ ),
3368
+ operator: z5.enum(["equals", "notEquals", "contains", "notContains", "isEmpty", "isNotEmpty"]).describe("How variable is compared with value. Comparisons are on text"),
3369
+ value: longText.optional().describe("What variable is compared with; ignored by isEmpty and isNotEmpty")
3370
+ }).loose();
3371
+ var approvalConfigSchema = z5.object({
3372
+ message: longText.optional().describe("The question the reviewer is asked. Templates expand when the gate opens"),
3373
+ timeoutMs: z5.number().int().positive().optional().describe("Reject automatically after this long, in milliseconds. Omit to wait for ever"),
3374
+ view: longText.optional().describe(
3375
+ "A review page shown beside the question: HTML, or the path of a .html file, with templates filled in when the gate opens"
3376
+ ),
3377
+ edit: longText.optional().describe(
3378
+ "Text the reviewer may rewrite, as a template. Later steps read the result as {{steps.<gate>.text}}. When it expands to a JSON list of records the reviewer sees a table, and the rows they keep are {{steps.<gate>.items}}"
3379
+ ),
3380
+ feedback: z5.object({
3381
+ from: V.id.describe(
3382
+ "Id of an earlier step, outside any loop body, that leads to this gate. Send back re-runs everything from it to the gate"
3383
+ ),
3384
+ maxRounds: z5.number().int().min(2).max(MAX_GATE_ROUNDS).describe(`How many times the gate may ask, the first included (2 to ${MAX_GATE_ROUNDS})`)
3385
+ }).loose().optional().describe(
3386
+ "Lets the reviewer send the work back with a comment instead of only approving or rejecting; the comment is {{steps.<gate>.feedback}} to the steps that redo it"
3387
+ )
3388
+ }).loose();
3389
+ var createTaskFromItemConfigSchema = z5.object({
3390
+ nodeType: z5.literal("createTaskFromItem").optional().describe("Always createTaskFromItem"),
3391
+ project: z5.string().max(200).describe(
3392
+ "Project the task lands in, by name, or fromConnection to use the connection's execution project"
3393
+ ),
3394
+ initialStatus: taskStatus.describe(
3395
+ "Status of a newly created task. Re-syncing an existing one never changes its status"
3396
+ )
3397
+ }).loose();
3398
+ var callConnectorActionConfigSchema = z5.object({
3399
+ nodeType: z5.literal("callConnectorAction").optional().describe("Always callConnectorAction"),
3400
+ connectionId: z5.string().max(100).describe("Connection the action runs against, from list_connections"),
3401
+ action: V.shortText.describe(
3402
+ "Action type from the connector, e.g. commentOnPullRequest; list_connector_actions lists them with their args"
3403
+ ),
3404
+ actionLabel: V.shortText.optional().describe("The action's display name, for the canvas"),
3405
+ connectorId: V.shortText.optional().describe("Connector the action belongs to, for a step placed before a connection exists"),
3406
+ args: z5.record(z5.string(), longText).optional().describe(
3407
+ "The action's arguments by name, every value a string template; a list or object a template names arrives as JSON text. list_connector_actions gives each action's args"
3408
+ )
3409
+ }).loose();
3410
+ var httpRequestConfigSchema = z5.object({
3411
+ nodeType: z5.literal("httpRequest").optional().describe("Always httpRequest"),
3412
+ method: z5.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).describe("HTTP method"),
3413
+ url: z5.string().max(2048).describe(
3414
+ "Absolute URL, or a path resolved against the profile's base URL. Templates expand"
3415
+ ),
3416
+ headers: z5.record(z5.string(), longText).optional().describe("Request headers. Names are literal; values are templates"),
3417
+ body: longText.optional().describe("Request body as a template; empty sends none"),
3418
+ profileConnectionId: V.id.optional().describe(
3419
+ "An http connection whose base URL and auth are applied server-side, so no credential sits in the workflow"
3420
+ )
3421
+ }).loose();
3422
+ var loopConfigSchema = z5.object({
3423
+ nodeType: z5.literal("loop").optional().describe("Always loop"),
3424
+ mode: z5.enum(["repeat", "forEach"]).optional().describe(
3425
+ `repeat (default) runs the body up to maxIterations times; forEach runs it once for each item of items, with no cap on how many`
3426
+ ),
3427
+ items: longText.optional().describe(
3428
+ "For forEach: the list to walk, as a template naming it, e.g. {{steps.gate.items}}. JSON text of a list works too, and so does an object holding exactly one list. Each pass reads its item as {{loop.item}} or {{loop.item.<field>}}"
3429
+ ),
3430
+ bodyNodeIds: z5.array(V.id).min(1).describe(
3431
+ "Ids of the steps the loop runs each pass. Wire them with edges: loop -> the entry steps, then between body steps, and from the last body steps to the step after the loop"
3432
+ ),
3433
+ maxIterations: z5.number().optional().describe(
3434
+ `For repeat: how many passes at most, a whole number from 1 to ${MAX_LOOP_ITERATIONS}. Required for repeat, ignored for forEach`
3435
+ ),
3436
+ until: conditionConfigSchema.optional().describe(
3437
+ "Checked after each pass; the loop stops early once it holds, e.g. {{steps.review.approved}} equals true"
3438
+ )
3439
+ }).loose().superRefine((config, ctx) => {
3440
+ if (config.mode === "forEach") {
3441
+ if (!config.items?.trim()) {
3442
+ ctx.addIssue({
3443
+ code: "custom",
3444
+ path: ["items"],
3445
+ message: "a forEach loop needs items: a template naming the list to walk"
3446
+ });
3447
+ }
3448
+ return;
3449
+ }
3450
+ const max = config.maxIterations;
3451
+ if (typeof max !== "number" || !Number.isInteger(max) || max < 1 || max > MAX_LOOP_ITERATIONS) {
3452
+ ctx.addIssue({
3453
+ code: "custom",
3454
+ path: ["maxIterations"],
3455
+ message: `a repeat loop needs maxIterations as a whole number from 1 to ${MAX_LOOP_ITERATIONS}`
3456
+ });
3457
+ }
3458
+ });
3459
+ var edgeSchema = z5.object({
3460
+ id: V.id.describe("Unique within the workflow"),
3461
+ source: V.id.describe("Id of the node that runs first"),
3462
+ target: V.id.describe("Id of the node that runs after it"),
3463
+ // Which branch of a `condition` node this edge represents. Omit for normal
3464
+ // edges; required to wire both outcomes of a condition.
3465
+ conditionBranch: z5.enum(["true", "false"]).optional().describe(
3466
+ "On an edge leaving a condition: which outcome takes it. Wire both. Omit on every other edge"
3467
+ )
3468
+ });
3469
+ var NODE_TYPES = [
3470
+ "trigger",
3471
+ "launchAgent",
3472
+ "script",
3473
+ "condition",
3474
+ "approval",
3475
+ "createTaskFromItem",
3476
+ "callConnectorAction",
3477
+ "httpRequest",
3478
+ "loop"
3479
+ ];
3480
+ var configSchemaByType = {
3481
+ trigger: triggerConfigSchema,
3482
+ launchAgent: launchAgentNodeConfigSchema,
3483
+ script: scriptConfigSchema,
3484
+ condition: conditionConfigSchema,
3485
+ approval: approvalConfigSchema,
3486
+ createTaskFromItem: createTaskFromItemConfigSchema,
3487
+ callConnectorAction: callConnectorActionConfigSchema,
3488
+ httpRequest: httpRequestConfigSchema,
3489
+ loop: loopConfigSchema
3490
+ };
3491
+ function nodeConfigIssues(type, config) {
3492
+ const result = configSchemaByType[type].safeParse(config);
3493
+ return result.success ? [] : result.error.issues;
3494
+ }
3107
3495
 
3108
3496
  // ../shared/src/workflow-portability.ts
3109
3497
  var PROJECT_PATH_TOKEN = "{{project.path}}";
@@ -3270,168 +3658,73 @@ function residualAbsolutePaths(portable) {
3270
3658
  }
3271
3659
 
3272
3660
  // src/tools/workflows.ts
3273
- var launchAgentConfigSchema = z5.object({
3274
- agentType: z5.enum(["claude", "copilot", "codex", "opencode", "gemini"]),
3661
+ var launchAgentConfigSchema = z6.object({
3662
+ agentType: z6.enum(["claude", "copilot", "codex", "opencode", "gemini"]),
3275
3663
  projectName: V.name,
3276
3664
  projectPath: V.absolutePath,
3277
- args: z5.array(V.shortText).optional(),
3665
+ args: z6.array(V.shortText).optional(),
3278
3666
  displayName: V.shortText.optional(),
3279
3667
  branch: V.shortText.optional(),
3280
3668
  // boolean | 'fromContext' — `'fromContext'` only valid when the workflow's
3281
3669
  // manual trigger is contextual (validated at runtime, not here).
3282
- useWorktree: z5.union([z5.boolean(), z5.literal("fromContext")]).optional(),
3670
+ useWorktree: z6.union([z6.boolean(), z6.literal("fromContext")]).optional(),
3283
3671
  remoteHostId: V.id.optional(),
3284
3672
  prompt: V.prompt.optional(),
3285
- promptDelayMs: z5.number().optional(),
3673
+ promptDelayMs: z6.number().optional(),
3286
3674
  taskId: V.id.optional(),
3287
- taskFromQueue: z5.boolean().optional(),
3675
+ taskFromQueue: z6.boolean().optional(),
3288
3676
  // Runs the agent in the background and waits for it to finish (required for
3289
3677
  // typed output). Opens a terminal tab when false/omitted.
3290
- headless: z5.boolean().optional(),
3678
+ headless: z6.boolean().optional(),
3291
3679
  // JSON Schema the agent's final answer must satisfy (headless only). When set,
3292
3680
  // the engine parses a matching object from the run and exposes its fields as
3293
3681
  // `{{steps.<slug>.<field>}}` for downstream condition nodes.
3294
- outputSchema: z5.record(z5.string(), z5.unknown()).optional()
3682
+ outputSchema: z6.record(z6.string(), z6.unknown()).optional()
3295
3683
  }).refine((c) => !c.outputSchema || c.headless === true, {
3296
3684
  // The engine only parses typed output for headless runs; reject a config that
3297
3685
  // declares a schema it would silently ignore instead of accepting a lie.
3298
3686
  message: "outputSchema requires headless: true",
3299
3687
  path: ["outputSchema"]
3300
3688
  });
3301
- var workflowInputDefSchema = z5.object({
3302
- key: z5.string().regex(
3303
- /^[A-Za-z_][A-Za-z0-9_]*$/,
3304
- "key must be a valid identifier \u2014 it becomes {{inputs.<key>}}"
3305
- ).max(100),
3306
- label: V.shortText,
3307
- type: z5.enum(["text", "textarea", "number", "select", "boolean", "project", "branch"]),
3308
- required: z5.boolean().optional(),
3309
- defaultValue: V.shortText.optional(),
3310
- options: z5.array(z5.object({ value: V.shortText, label: V.shortText })).optional(),
3311
- placeholder: V.shortText.optional(),
3312
- description: V.shortText.optional()
3313
- }).superRefine((def, ctx) => {
3314
- const options = def.options ?? [];
3315
- if (def.type === "select" && options.length === 0) {
3316
- ctx.addIssue({
3317
- code: "custom",
3318
- path: ["options"],
3319
- message: `select input "${def.key}" declares no options, so the run dialog could offer nothing`
3320
- });
3321
- }
3322
- if (def.defaultValue === void 0) return;
3323
- if (def.type === "number" && !Number.isFinite(Number(def.defaultValue))) {
3324
- ctx.addIssue({
3325
- code: "custom",
3326
- path: ["defaultValue"],
3327
- message: `default "${def.defaultValue}" for number input "${def.key}" is not a finite number`
3328
- });
3329
- }
3330
- if (def.type === "boolean" && !["true", "false"].includes(def.defaultValue)) {
3331
- ctx.addIssue({
3332
- code: "custom",
3333
- path: ["defaultValue"],
3334
- message: `default "${def.defaultValue}" for boolean input "${def.key}" must be "true" or "false"`
3335
- });
3336
- }
3337
- if (def.type === "select" && options.length > 0 && !options.some((o) => o.value === def.defaultValue)) {
3338
- ctx.addIssue({
3339
- code: "custom",
3340
- path: ["defaultValue"],
3341
- message: `default "${def.defaultValue}" for select input "${def.key}" is not one of its options`
3342
- });
3343
- }
3344
- });
3345
- var workflowInputsSchema = z5.array(workflowInputDefSchema).superRefine((inputs, ctx) => {
3346
- const seen = /* @__PURE__ */ new Set();
3347
- inputs.forEach((def, index) => {
3348
- if (seen.has(def.key)) {
3349
- ctx.addIssue({
3350
- code: "custom",
3351
- path: [index, "key"],
3352
- message: `duplicate input key "${def.key}" \u2014 only one value can survive under {{inputs.${def.key}}}`
3353
- });
3354
- }
3355
- seen.add(def.key);
3356
- });
3357
- });
3358
- var triggerConfigSchema = z5.union([
3359
- z5.object({
3360
- triggerType: z5.literal("manual"),
3361
- contextual: z5.boolean().optional(),
3362
- inputs: workflowInputsSchema.optional()
3363
- }),
3364
- z5.object({ triggerType: z5.literal("once"), runAt: V.shortText }),
3365
- z5.object({
3366
- triggerType: z5.literal("recurring"),
3367
- cron: V.shortText,
3368
- timezone: V.shortText.optional()
3369
- }),
3370
- z5.object({ triggerType: z5.literal("taskCreated"), projectFilter: V.name.optional() }),
3371
- z5.object({
3372
- triggerType: z5.literal("taskStatusChanged"),
3373
- projectFilter: V.name.optional(),
3374
- fromStatus: z5.enum(["todo", "in_progress", "in_review", "done", "cancelled"]).optional(),
3375
- toStatus: z5.enum(["todo", "in_progress", "in_review", "done", "cancelled"]).optional()
3376
- }),
3377
- z5.object({
3378
- triggerType: z5.literal("connectorPoll"),
3379
- connectionId: V.id,
3380
- event: V.shortText,
3381
- cron: V.shortText,
3382
- timezone: V.shortText.optional()
3383
- }),
3384
- z5.object({
3385
- triggerType: z5.literal("webhook"),
3386
- method: z5.enum(["POST", "GET"]),
3387
- token: V.shortText
3388
- })
3389
- ]);
3390
- var nodeSchema = z5.object({
3391
- id: V.id,
3392
- // Full node palette — parity with the editor. `config` is a passthrough so
3393
- // each type carries its own shape (ConditionConfig, ApprovalConfig, etc.).
3394
- type: z5.enum([
3395
- "trigger",
3396
- "launchAgent",
3397
- "script",
3398
- "condition",
3399
- "approval",
3400
- "createTaskFromItem",
3401
- "callConnectorAction",
3402
- "httpRequest",
3403
- "loop"
3404
- ]),
3405
- label: V.shortText,
3689
+ var nodeShapeSchema = z6.object({
3690
+ id: V.id.describe("Unique within the workflow; edges and bodyNodeIds refer to it"),
3691
+ // Full node palette — parity with the editor.
3692
+ type: z6.enum(NODE_TYPES).describe("Node type; describe_workflow_nodes gives the config each one takes"),
3693
+ label: V.shortText.describe("Name shown on the canvas and in run history"),
3406
3694
  // Referenced by typed step vars as `{{steps.<slug>.<field>}}`. Set one on any
3407
3695
  // node whose output a later node consumes.
3408
- slug: V.shortText.optional(),
3409
- config: z5.record(z5.string(), z5.unknown()),
3410
- position: z5.object({ x: z5.number(), y: z5.number() }),
3696
+ slug: V.shortText.optional().describe("Name later steps read this one by, as {{steps.<slug>.<field>}}"),
3697
+ config: z6.record(z6.string(), z6.unknown()).describe("The type's own settings; describe_workflow_nodes has the schema for each type"),
3698
+ position: z6.object({ x: z6.number(), y: z6.number() }).describe("Where the node sits on the canvas"),
3411
3699
  // Omitted means stop. Declared here because the object strips what it does
3412
3700
  // not name: without it a workflow authored over MCP could never say a step
3413
3701
  // is survivable, and the field would be dropped without complaint.
3414
- onError: z5.enum(["stop", "continue"]).optional()
3415
- }).superRefine((node, ctx) => {
3416
- if (node.type !== "loop") return;
3417
- const config = node.config;
3418
- if (!Array.isArray(config.bodyNodeIds) || config.bodyNodeIds.length === 0) {
3419
- ctx.addIssue({
3420
- code: "custom",
3421
- path: ["config", "bodyNodeIds"],
3422
- message: `loop "${node.id}" must list at least one body step in bodyNodeIds`
3423
- });
3702
+ onError: z6.enum(["stop", "continue"]).optional().describe("stop (default) ends the run when this step fails; continue carries on past it")
3703
+ });
3704
+ function describeConfigIssues(node) {
3705
+ return nodeConfigIssues(node.type, node.config).map(
3706
+ (issue) => `${["config", ...issue.path.map(String)].join(".")}: ${issue.message}`
3707
+ );
3708
+ }
3709
+ function checkNodeConfigs(nodes, stored) {
3710
+ const errors = [];
3711
+ const warnings = [];
3712
+ for (const node of nodes) {
3713
+ const issues = describeConfigIssues(node);
3714
+ if (issues.length === 0) continue;
3715
+ const before = stored.find((n) => n.id === node.id && n.type === node.type);
3716
+ const untouched = before !== void 0 && isDeepStrictEqual(before.config, node.config);
3717
+ const line = `node "${node.label || node.id}" ${issues.join(", ")}`;
3718
+ if (untouched) warnings.push(line);
3719
+ else errors.push(line);
3424
3720
  }
3425
- const max = config.maxIterations;
3426
- if (typeof max !== "number" || !Number.isInteger(max) || max < 1 || max > MAX_LOOP_ITERATIONS) {
3427
- ctx.addIssue({
3428
- code: "custom",
3429
- path: ["config", "maxIterations"],
3430
- message: `loop "${node.id}" needs maxIterations as a whole number from 1 to ${MAX_LOOP_ITERATIONS}`
3431
- });
3721
+ return { errors, warnings };
3722
+ }
3723
+ var nodeSchema = nodeShapeSchema.superRefine((node, ctx) => {
3724
+ for (const issue of nodeConfigIssues(node.type, node.config)) {
3725
+ ctx.addIssue({ code: "custom", path: ["config", ...issue.path], message: issue.message });
3432
3726
  }
3433
3727
  });
3434
- var MAX_LOOP_ITERATIONS = 10;
3435
3728
  function validateLoopBodies(nodes) {
3436
3729
  const ids = new Set(nodes.map((n) => n.id));
3437
3730
  const errors = [];
@@ -3449,14 +3742,21 @@ function validateLoopBodies(nodes) {
3449
3742
  }
3450
3743
  return errors;
3451
3744
  }
3452
- var edgeSchema = z5.object({
3453
- id: V.id,
3454
- source: V.id,
3455
- target: V.id,
3456
- // Which branch of a `condition` node this edge represents. Omit for normal
3457
- // edges; required to wire both outcomes of a condition.
3458
- conditionBranch: z5.enum(["true", "false"]).optional()
3459
- });
3745
+ function validateLoopStructures(nodes, edges) {
3746
+ const graph = nodes;
3747
+ const wires = edges;
3748
+ return graph.filter((node) => node.type === "loop").flatMap((loop) => {
3749
+ const error = loopStructureError(graph, wires, loop);
3750
+ return error ? [`loop "${loop.label || loop.id}": ${error}`] : [];
3751
+ });
3752
+ }
3753
+ function validateGraph(nodes, edges) {
3754
+ return [
3755
+ ...validateLoopBodies(nodes),
3756
+ ...validateLoopStructures(nodes, edges),
3757
+ ...validateGateFeedback(nodes, edges)
3758
+ ];
3759
+ }
3460
3760
  function buildGraphFromFlat(trigger, actions) {
3461
3761
  const nodes = [];
3462
3762
  const edges = [];
@@ -3594,6 +3894,7 @@ var ANSWERED = {
3594
3894
  };
3595
3895
  function validateGateFeedback(nodes, edges) {
3596
3896
  const errors = [];
3897
+ const owners = loopBodyOwners(nodes);
3597
3898
  for (const node of nodes) {
3598
3899
  if (node.type !== "approval" || node.config?.feedback === void 0) continue;
3599
3900
  const feedback = node.config.feedback;
@@ -3602,7 +3903,12 @@ function validateGateFeedback(nodes, edges) {
3602
3903
  const source2 = nodes.find((n) => n.id === from);
3603
3904
  if (!source2) errors.push(`gate "${name}" redoes from unknown step "${from}"`);
3604
3905
  else if (source2.type === "trigger") errors.push(`gate "${name}" cannot redo from its trigger`);
3605
- else if (nodesBetween(from, node.id, edges).size === 0) {
3906
+ else if (owners.has(from)) {
3907
+ const loop = nodes.find((n) => n.id === owners.get(from));
3908
+ errors.push(
3909
+ `gate "${name}" redoes from "${source2.label || from}", which is inside loop "${loop?.label || owners.get(from)}"; redo from the loop, or a step before it`
3910
+ );
3911
+ } else if (nodesBetween(from, node.id, edges).size === 0) {
3606
3912
  errors.push(`gate "${name}" redoes from "${source2.label || from}", which does not lead to it`);
3607
3913
  }
3608
3914
  const rounds = Number(feedback?.maxRounds);
@@ -3665,17 +3971,19 @@ function registerWorkflowTools(server) {
3665
3971
  );
3666
3972
  server.tool(
3667
3973
  "create_workflow",
3668
- 'Create a new workflow. Accepts either full nodes/edges (advanced mode \u2014 every node type is supported: trigger, launchAgent, script, condition, approval, createTaskFromItem, callConnectorAction; wire condition outcomes with edge conditionBranch "true"/"false") or a convenience flat format (trigger + actions array). Give a node a slug to reference its output downstream as {{steps.<slug>.<field>}}. A headless launchAgent with an outputSchema returns typed fields for condition nodes.',
3974
+ 'Create a workflow: a graph of steps started by one trigger. Call describe_workflow_nodes first \u2014 it gives the config schema, outputs and rules of every node type, the template namespaces, and a complete example to copy. Pass nodes and edges (every node type is supported), or the convenience format (trigger + actions, a straight line of agents).\n\nNode types: trigger (exactly one, what starts the run), launchAgent (an AI agent; headless with an outputSchema gives typed fields), script (bash/python/node/powershell), condition (branches: wire both outcomes with edge conditionBranch "true" and "false"), approval (a human gate; can show a table to edit and send work back), createTaskFromItem, callConnectorAction (an action of a connected service; list_connector_actions gives its args), httpRequest, and loop.\n\nGive a node a slug and later steps read its output as {{steps.<slug>.<field>}}.\n\nLoops: config.bodyNodeIds lists the steps the loop runs each pass. Wire an edge from the loop to each body entry step, edges between body steps, and from the last body steps to the step after the loop; nothing outside the loop may point into its body, and a condition inside it keeps both branches inside. mode "repeat" runs up to maxIterations (1-10) passes, stopping early when until holds; mode "forEach" runs once per item of items (e.g. {{steps.gate.items}}), and body steps read {{loop.item.<field>}}. A loop body cannot hold an approval, a trigger or another loop.\n\nEach node config is checked against its type, and the graph for loop and gate wiring; a refusal names the node and field.',
3669
3975
  {
3670
3976
  name: V.title.describe("Workflow name"),
3671
3977
  trigger: triggerConfigSchema.optional().describe("Trigger config (convenience mode). Defaults to manual."),
3672
- actions: z5.array(launchAgentConfigSchema).optional().describe("Actions to execute (convenience mode). Auto-generates graph."),
3673
- nodes: z5.array(nodeSchema).optional().describe("Full graph nodes (advanced mode)"),
3674
- edges: z5.array(edgeSchema).optional().describe("Full graph edges (advanced mode)"),
3978
+ actions: z6.array(launchAgentConfigSchema).optional().describe("Agents to run one after another (convenience mode). Auto-generates the graph."),
3979
+ nodes: z6.array(nodeSchema).optional().describe("Graph nodes; pass with edges. describe_workflow_nodes has each config schema"),
3980
+ edges: z6.array(edgeSchema).optional().describe("Graph edges; pass with nodes. A step runs once every edge into it has run"),
3675
3981
  icon: V.shortText.optional().describe("Lucide icon name (default: zap)"),
3676
3982
  icon_color: V.hexColor.optional().describe("Hex color (default: #6366f1)"),
3677
- enabled: z5.boolean().optional().describe("Whether workflow is enabled (default: true)"),
3678
- stagger_delay_ms: z5.number().optional().describe("Delay in ms between actions")
3983
+ enabled: z6.boolean().optional().describe(
3984
+ "Whether its trigger fires on its own (default: true). Manual runs work either way"
3985
+ ),
3986
+ stagger_delay_ms: z6.number().optional().describe("Delay in ms between actions")
3679
3987
  },
3680
3988
  async (args) => {
3681
3989
  let nodes;
@@ -3683,11 +3991,10 @@ function registerWorkflowTools(server) {
3683
3991
  if (args.nodes && args.edges) {
3684
3992
  nodes = args.nodes;
3685
3993
  edges = args.edges;
3686
- const loose = nodes;
3687
- const loopErrors = [...validateLoopBodies(loose), ...validateGateFeedback(loose, edges)];
3688
- if (loopErrors.length > 0) {
3994
+ const graphErrors = validateGraph(nodes, edges);
3995
+ if (graphErrors.length > 0) {
3689
3996
  return {
3690
- content: [{ type: "text", text: `Error: ${loopErrors.join("; ")}` }],
3997
+ content: [{ type: "text", text: `Error: ${graphErrors.join("; ")}` }],
3691
3998
  isError: true
3692
3999
  };
3693
4000
  }
@@ -3715,17 +4022,19 @@ function registerWorkflowTools(server) {
3715
4022
  );
3716
4023
  server.tool(
3717
4024
  "update_workflow",
3718
- "Update a workflow's properties",
4025
+ "Change a workflow. Only the fields you pass change; nodes and edges each replace the whole list, so read the workflow first (list_workflows), edit it, and send the full list back. Checked like create_workflow: each node config against its type (describe_workflow_nodes), and the graph for loop and gate wiring. A node whose config you left exactly as stored is let through with a warning even if it breaks a newer rule, so an older workflow stays editable; fix what the warning names when you can.",
3719
4026
  {
3720
4027
  workflow_id: V.id.optional().describe("Workflow ID (from list_workflows)"),
3721
4028
  id: V.id.optional().describe("Deprecated alias for workflow_id"),
3722
- name: V.title.optional(),
3723
- nodes: z5.array(nodeSchema).optional(),
3724
- edges: z5.array(edgeSchema).optional(),
3725
- icon: V.shortText.optional(),
3726
- icon_color: V.hexColor.optional(),
3727
- enabled: z5.boolean().optional(),
3728
- stagger_delay_ms: z5.number().optional()
4029
+ name: V.title.optional().describe("New name"),
4030
+ // The shape only: configs are checked in the handler, where an untouched
4031
+ // stored config can be told apart from a new mistake.
4032
+ nodes: z6.array(nodeShapeSchema).optional().describe("Every node of the workflow, replacing the stored list"),
4033
+ edges: z6.array(edgeSchema).optional().describe("Every edge of the workflow, replacing the stored list"),
4034
+ icon: V.shortText.optional().describe("Lucide icon name"),
4035
+ icon_color: V.hexColor.optional().describe("Hex color"),
4036
+ enabled: z6.boolean().optional().describe("Whether its trigger fires on its own. Manual runs work either way"),
4037
+ stagger_delay_ms: z6.number().optional().describe("Delay in ms between actions")
3729
4038
  },
3730
4039
  async (args) => {
3731
4040
  const resolved = resolveWorkflowId(args);
@@ -3740,20 +4049,31 @@ function registerWorkflowTools(server) {
3740
4049
  isError: true
3741
4050
  };
3742
4051
  }
3743
- const updates = {};
3744
- if (args.name !== void 0) updates.name = args.name;
4052
+ const warnings = [];
3745
4053
  if (args.nodes !== void 0) {
3746
- const loose = args.nodes;
4054
+ const { errors, warnings: legacy } = checkNodeConfigs(args.nodes, workflow.nodes);
4055
+ if (errors.length > 0) {
4056
+ return {
4057
+ content: [{ type: "text", text: `Error: ${errors.join("; ")}` }],
4058
+ isError: true
4059
+ };
4060
+ }
4061
+ warnings.push(...legacy);
4062
+ }
4063
+ if (args.nodes !== void 0 || args.edges !== void 0) {
4064
+ const nodes = args.nodes ?? workflow.nodes;
3747
4065
  const edges = args.edges ?? workflow.edges;
3748
- const loopErrors = [...validateLoopBodies(loose), ...validateGateFeedback(loose, edges)];
3749
- if (loopErrors.length > 0) {
4066
+ const graphErrors = validateGraph(nodes, edges);
4067
+ if (graphErrors.length > 0) {
3750
4068
  return {
3751
- content: [{ type: "text", text: `Error: ${loopErrors.join("; ")}` }],
4069
+ content: [{ type: "text", text: `Error: ${graphErrors.join("; ")}` }],
3752
4070
  isError: true
3753
4071
  };
3754
4072
  }
3755
- updates.nodes = args.nodes;
3756
4073
  }
4074
+ const updates = {};
4075
+ if (args.name !== void 0) updates.name = args.name;
4076
+ if (args.nodes !== void 0) updates.nodes = args.nodes;
3757
4077
  if (args.edges !== void 0) updates.edges = args.edges;
3758
4078
  if (args.icon !== void 0) updates.icon = args.icon;
3759
4079
  if (args.icon_color !== void 0) updates.iconColor = args.icon_color;
@@ -3761,8 +4081,17 @@ function registerWorkflowTools(server) {
3761
4081
  if (args.stagger_delay_ms !== void 0) updates.staggerDelayMs = args.stagger_delay_ms;
3762
4082
  await dbUpdateWorkflow(resolved.id, updates);
3763
4083
  dbSignalChange();
4084
+ const saved = JSON.stringify({ ...workflow, ...updates }, null, 2);
3764
4085
  return {
3765
- content: [{ type: "text", text: JSON.stringify({ ...workflow, ...updates }, null, 2) }]
4086
+ content: [
4087
+ {
4088
+ type: "text",
4089
+ text: warnings.length > 0 ? `Warning: saved, but these configs were stored before and break a current rule, so they were kept as they are:
4090
+ - ${warnings.join("\n - ")}
4091
+
4092
+ ${saved}` : saved
4093
+ }
4094
+ ]
3766
4095
  };
3767
4096
  }
3768
4097
  );
@@ -3797,7 +4126,7 @@ function registerWorkflowTools(server) {
3797
4126
  {
3798
4127
  workflow_id: V.id.optional().describe("Filter by workflow ID"),
3799
4128
  task_id: V.id.optional().describe("Filter by task ID (runs triggered by this task)"),
3800
- limit: z5.number().int().min(1).max(100).optional().describe("Max results (default: 20)")
4129
+ limit: z6.number().int().min(1).max(100).optional().describe("Max results (default: 20)")
3801
4130
  },
3802
4131
  async (args) => {
3803
4132
  if (args.workflow_id && args.task_id) {
@@ -3877,13 +4206,13 @@ The run is stopped by the instance holding it, so confirm with list_workflow_run
3877
4206
  "Approve, reject, or send back the approval gate a workflow run is parked on, the way the Vorn app does. Requires the Vorn app to be running: the decision is broadcast, and the instance holding the run is what resumes it. Read what is being approved first \u2014 list_workflow_runs names the waiting node and what it asks.",
3878
4207
  {
3879
4208
  run_id: V.id.describe("Run ID (from list_workflow_runs)"),
3880
- decision: z5.enum(["approve", "reject", "changes"]).describe(
4209
+ decision: z6.enum(["approve", "reject", "changes"]).describe(
3881
4210
  "approve lets the run go on; reject ends it; changes sends the work back to the step the gate redoes from, with your comment, and the gate asks again"
3882
4211
  ),
3883
- comment: z5.string().max(2e4).optional().describe(
4212
+ comment: z6.string().max(2e4).optional().describe(
3884
4213
  "Required for changes: what to change. Optional on approve and reject; kept with the run."
3885
4214
  ),
3886
- edited: z5.string().max(2e5).optional().describe(
4215
+ edited: z6.string().max(2e5).optional().describe(
3887
4216
  "Your rewrite of the gate's editable text, when it has one. Kept beside the original, and read by later steps as {{steps.<gate>.text}}. Ignored on reject."
3888
4217
  ),
3889
4218
  node_id: V.id.optional().describe("The waiting node, when a run has more than one gate open")
@@ -3930,19 +4259,22 @@ The run is stopped by the instance holding it, so confirm with list_workflow_run
3930
4259
  const gateNode = approvalNode(workflow, target.nodeId);
3931
4260
  const asked = run.nodeStates.find((n) => n.nodeId === target.nodeId)?.message ?? askedBy(gateNode);
3932
4261
  try {
3933
- const answer = await rpcCall("workflow:resolveGate", {
3934
- runId: args.run_id,
3935
- nodeId: target.nodeId,
3936
- decision: args.decision,
3937
- ...args.comment?.trim() && { comment: args.comment.trim() },
3938
- ...args.edited?.trim() && { edited: args.edited.trim() }
3939
- });
4262
+ const answer = await rpcCall(
4263
+ "workflow:resolveGate",
4264
+ {
4265
+ runId: args.run_id,
4266
+ nodeId: target.nodeId,
4267
+ decision: args.decision,
4268
+ ...args.comment?.trim() && { comment: args.comment.trim() },
4269
+ ...args.edited?.trim() && { edited: args.edited.trim() }
4270
+ }
4271
+ );
3940
4272
  if (answer?.accepted === false) {
3941
4273
  return {
3942
4274
  content: [
3943
4275
  {
3944
4276
  type: "text",
3945
- text: args.decision === "changes" ? "Error: this gate takes no changes now: it has no step to redo from, or its rounds are used up. Approve or reject it instead." : "Error: the gate did not take that answer. Check list_workflow_runs."
4277
+ text: answer.reason ? `Error: ${answer.reason}` : args.decision === "changes" ? "Error: this gate takes no changes now: it has no step to redo from, or its rounds are used up. Approve or reject it instead." : "Error: the gate did not take that answer. Check list_workflow_runs."
3946
4278
  }
3947
4279
  ],
3948
4280
  isError: true
@@ -3974,7 +4306,7 @@ The decision went out; the instance holding the run acts on it, so a desktop has
3974
4306
  "Get scheduler info for a workflow: execution log or next scheduled run. Requires the Vorn app to be running.",
3975
4307
  {
3976
4308
  workflow_id: V.id.optional().describe("Workflow ID (required for next_run, optional for log)"),
3977
- info: z5.enum(["log", "next_run"]).optional().describe("What to retrieve (default: log)")
4309
+ info: z6.enum(["log", "next_run"]).optional().describe("What to retrieve (default: log)")
3978
4310
  },
3979
4311
  async (args) => {
3980
4312
  const info = args.info ?? "log";
@@ -4012,7 +4344,7 @@ The decision went out; the instance holding the run acts on it, so a desktop has
4012
4344
  "Run a workflow now, as if triggered manually. Supply values for any parameters the workflow declares (see the trigger node's inputs); declared defaults fill in anything omitted. Requires the Vorn app to be running. Returns as soon as the run is queued \u2014 poll list_workflow_runs for the outcome. Runs of one workflow go side by side; only a run repeating one started in the last ten seconds with the same inputs is refused as a duplicate.",
4013
4345
  {
4014
4346
  workflow_id: V.id.describe("Workflow ID (from list_workflows)"),
4015
- inputs: z5.record(z5.string(), z5.union([z5.string(), z5.number(), z5.boolean()])).optional().describe("Values for the declared parameters, keyed by input key ({{inputs.<key>}})")
4347
+ inputs: z6.record(z6.string(), z6.union([z6.string(), z6.number(), z6.boolean()])).optional().describe("Values for the declared parameters, keyed by input key ({{inputs.<key>}})")
4016
4348
  },
4017
4349
  async (args) => {
4018
4350
  const workflow = (await dbListWorkflows()).find((w) => w.id === args.workflow_id);
@@ -4120,7 +4452,7 @@ Warning: ${unnamed.length} step(s) point at a connection this install could not
4120
4452
  "import_workflow",
4121
4453
  "Import a workflow exported by export_workflow, resolving {{project.path}} and {{project.name}} against a registered project. Recorded connection requirements are rebound when this machine has one unambiguous match, and reported as still to connect otherwise. The id is derived from the bundle and the workflow's slug, so importing the same file again updates it in place instead of creating a duplicate.",
4122
4454
  {
4123
- workflow: z5.string().max(5e5).describe("The exported workflow JSON"),
4455
+ workflow: z6.string().max(5e5).describe("The exported workflow JSON"),
4124
4456
  project_name: V.name.describe("Registered project to resolve paths against"),
4125
4457
  bundle: V.name.optional().describe("Namespace for the derived id (default: the project name)")
4126
4458
  },
@@ -4168,11 +4500,7 @@ Warning: ${unnamed.length} step(s) point at a connection this install could not
4168
4500
  isError: true
4169
4501
  };
4170
4502
  }
4171
- const loose = parsed.nodes;
4172
- const loopErrors = [
4173
- ...validateLoopBodies(loose),
4174
- ...validateGateFeedback(loose, parsed.edges)
4175
- ];
4503
+ const loopErrors = validateGraph(parsed.nodes, parsed.edges);
4176
4504
  if (loopErrors.length > 0) {
4177
4505
  return {
4178
4506
  content: [{ type: "text", text: `Error: ${loopErrors.join("; ")}` }],
@@ -4219,6 +4547,330 @@ Still to connect: ${pending}` : "")
4219
4547
  );
4220
4548
  }
4221
4549
 
4550
+ // src/tools/describe-nodes.ts
4551
+ import { z as z7 } from "zod";
4552
+ var COMMON_OUTPUTS = [
4553
+ { field: "status", description: "success or error" },
4554
+ { field: "error", description: "Why it failed, or empty" }
4555
+ ];
4556
+ var DOCS = {
4557
+ trigger: {
4558
+ summary: "What starts a run: by hand, on a schedule, on a task event, when a session is restored, by polling a connection, or on a webhook. config.triggerType picks which.",
4559
+ outputs: [],
4560
+ rules: [
4561
+ "A workflow has exactly one trigger, and no edge points into it.",
4562
+ "A manual trigger may declare inputs; each is read anywhere as {{inputs.<key>}}, and execute_workflow takes their values.",
4563
+ "What the trigger carries is read as {{task.*}}, {{trigger.*}} or {{connectorItem.*}}, depending on its type."
4564
+ ]
4565
+ },
4566
+ launchAgent: {
4567
+ summary: "Starts an AI coding agent in a project with a prompt. Headless, it runs in the background and the run waits for its answer; otherwise it opens a terminal tab.",
4568
+ outputs: [
4569
+ { field: "output", description: "The agent's final answer (headless)" },
4570
+ {
4571
+ field: "<outputSchema property>",
4572
+ description: "Each top-level property of config.outputSchema, parsed from the answer (headless only)"
4573
+ },
4574
+ { field: "worktreePath", description: "The directory the agent worked in" },
4575
+ ...COMMON_OUTPUTS
4576
+ ],
4577
+ rules: [
4578
+ "outputSchema requires headless: true, and an answer that does not match it fails the step.",
4579
+ "Read typed fields in a condition, e.g. variable {{steps.review.approved}} equals true, rather than matching words in output.",
4580
+ "agentType fromTask needs a task in scope: a task trigger, taskId or taskFromQueue. model needs a concrete agentType.",
4581
+ "worktreeMode fromStep reuses the worktree of the earlier agent step named by worktreeFromStepSlug."
4582
+ ]
4583
+ },
4584
+ script: {
4585
+ summary: "Runs a bash, python, node or powershell script.",
4586
+ outputs: [
4587
+ { field: "output", description: "What the script printed to stdout" },
4588
+ ...COMMON_OUTPUTS
4589
+ ],
4590
+ rules: [
4591
+ "Templates expand into the script text before it runs. Keep a value as data rather than code, e.g. inside a quoted heredoc: cat <<'EOF' ... EOF.",
4592
+ "A non-zero exit fails the step.",
4593
+ "secretsFrom names a connection whose secrets become environment variables, so no secret is written into the workflow."
4594
+ ]
4595
+ },
4596
+ condition: {
4597
+ summary: "Compares a value, usually a step output, and sends the run down its true or its false branch.",
4598
+ outputs: [{ field: "output", description: '"true" or "false"' }, ...COMMON_OUTPUTS],
4599
+ rules: [
4600
+ 'Give it two outgoing edges, one with conditionBranch "true" and one with "false". Steps on the branch not taken are skipped.',
4601
+ 'Comparisons are on text: equals true matches the text "true".',
4602
+ "Inside a loop body, both branches must stay inside the body."
4603
+ ]
4604
+ },
4605
+ approval: {
4606
+ summary: "Pauses the run until a person approves or rejects, in the app or with resolve_gate. It can show a review page, let the reviewer rewrite some text, and send the work back with a comment.",
4607
+ outputs: [
4608
+ { field: "text", description: "The editable text as the reviewer left it" },
4609
+ {
4610
+ field: "items",
4611
+ description: "When the editable text is a JSON list of records: the rows the reviewer kept, as a list, ready for a forEach loop"
4612
+ },
4613
+ { field: "feedback", description: "The reviewer's latest comment" },
4614
+ { field: "feedbackAll", description: "Every comment, one line per round" },
4615
+ { field: "round", description: "Which time the gate asked, from 1" },
4616
+ ...COMMON_OUTPUTS
4617
+ ],
4618
+ rules: [
4619
+ "A gate cannot sit inside a loop body.",
4620
+ "When edit expands to a JSON list of records the reviewer sees a table; a rewrite that is no longer valid JSON is refused.",
4621
+ `feedback.from is the id of an earlier step that leads to this gate, not the trigger and not a step inside a loop. Sending back re-runs every step from it to the gate. maxRounds is 2 to ${MAX_GATE_ROUNDS}.`,
4622
+ "A reject ends the run."
4623
+ ]
4624
+ },
4625
+ createTaskFromItem: {
4626
+ summary: "Creates a task from the connector item that started the run, or updates the one it created before.",
4627
+ outputs: [
4628
+ { field: "output", description: "Imported or Updated, with the item id and title" },
4629
+ ...COMMON_OUTPUTS
4630
+ ],
4631
+ rules: [
4632
+ "Needs {{connectorItem.*}}: put it after a connectorPoll trigger.",
4633
+ "Re-syncing an item updates its title and description but never its status."
4634
+ ]
4635
+ },
4636
+ callConnectorAction: {
4637
+ summary: "Runs one action of a connected service (a comment, an issue, a merge...) with arguments built from templates.",
4638
+ outputs: [
4639
+ { field: "output", description: "The action result as text" },
4640
+ {
4641
+ field: "<the action's output fields>",
4642
+ description: "Each field the action declares in its outputSchema; list_connector_actions gives them"
4643
+ },
4644
+ ...COMMON_OUTPUTS
4645
+ ],
4646
+ rules: [
4647
+ "connectionId comes from list_connections; action and its args from list_connector_actions.",
4648
+ "A step waits, rather than fails, when its connection needs signing in again."
4649
+ ]
4650
+ },
4651
+ httpRequest: {
4652
+ summary: "Sends one HTTP request, optionally through an http connection that adds auth.",
4653
+ outputs: [
4654
+ { field: "body", description: "Response body, JSON-parsed when possible" },
4655
+ { field: "headers", description: "Response headers, by lower-case name" },
4656
+ {
4657
+ field: "output",
4658
+ description: 'HTTP <code>, e.g. "HTTP 404": the way to read the response status'
4659
+ },
4660
+ {
4661
+ field: "status",
4662
+ description: "success or error: the step's own status, which replaces the response code under this name"
4663
+ },
4664
+ { field: "error", description: "Why it failed, or empty" }
4665
+ ],
4666
+ rules: [
4667
+ "Put credentials in an http connection (profileConnectionId), not in headers.",
4668
+ 'Any answer succeeds, a 4xx or 5xx included: to act on the code, test {{steps.<slug>.output}} (e.g. contains "HTTP 2"). Only a network error or the 30 second timeout fails the step. Redirects are not followed.'
4669
+ ]
4670
+ },
4671
+ loop: {
4672
+ summary: `Runs a group of steps (its body) several times: repeat up to ${MAX_LOOP_ITERATIONS} passes, stopping early when until holds, or forEach once per item of a list.`,
4673
+ outputs: [
4674
+ { field: "output", description: "How many passes ran" },
4675
+ { field: "passes", description: "How many passes ran, as a number" },
4676
+ {
4677
+ field: "count",
4678
+ description: "How many passes were planned: maxIterations, or the number of items"
4679
+ },
4680
+ {
4681
+ field: "results",
4682
+ description: "Per pass: {index, item?, status, steps: {<slug>: {output, status, error, ...}}}; each output cut to loopResultOutputChars"
4683
+ },
4684
+ {
4685
+ field: "outputs",
4686
+ description: "Per pass, the output of the last body step that ran (on the branch taken), cut to loopResultOutputChars"
4687
+ },
4688
+ ...COMMON_OUTPUTS
4689
+ ],
4690
+ rules: [
4691
+ "bodyNodeIds lists every step in the body. Wire the loop to each body entry step, body steps to each other, and the last body steps to the step after the loop.",
4692
+ "Nothing outside the loop may point into its body; only the loop starts its steps.",
4693
+ "A body cannot hold an approval gate, a trigger or another loop, and cannot contain a cycle.",
4694
+ `repeat needs maxIterations, 1 to ${MAX_LOOP_ITERATIONS}. forEach needs items and has no cap.`,
4695
+ "Body steps read {{loop.item}} / {{loop.item.<field>}} (forEach), {{loop.index}} from 0, {{loop.number}} from 1 and {{loop.count}}.",
4696
+ "A body step that fails ends the loop there, unless that step has onError: continue.",
4697
+ "Inside the body, {{steps.<slug>.*}} of a body step is this pass. After the loop, read the passes through {{steps.<loop>.results}} or .outputs."
4698
+ ]
4699
+ }
4700
+ };
4701
+ var EXAMPLE = {
4702
+ nodes: [
4703
+ {
4704
+ id: "start",
4705
+ type: "trigger",
4706
+ label: "Start",
4707
+ config: {
4708
+ triggerType: "manual",
4709
+ inputs: [{ key: "pullRequest", label: "Pull request", type: "text", required: true }]
4710
+ },
4711
+ position: { x: 0, y: 0 }
4712
+ },
4713
+ {
4714
+ id: "review",
4715
+ type: "callConnectorAction",
4716
+ label: "Read review findings",
4717
+ slug: "review",
4718
+ config: {
4719
+ nodeType: "callConnectorAction",
4720
+ connectionId: "<connection id from list_connections>",
4721
+ action: "listReviewFindings",
4722
+ args: { pullRequest: "{{inputs.pullRequest}}" }
4723
+ },
4724
+ position: { x: 0, y: 140 }
4725
+ },
4726
+ {
4727
+ id: "gate",
4728
+ type: "approval",
4729
+ label: "Pick findings to post",
4730
+ slug: "gate",
4731
+ config: {
4732
+ message: "Keep the findings worth a comment, and delete the rest.",
4733
+ edit: "{{steps.review.findings}}"
4734
+ },
4735
+ position: { x: 0, y: 280 }
4736
+ },
4737
+ {
4738
+ id: "loop",
4739
+ type: "loop",
4740
+ label: "For each finding",
4741
+ slug: "loop",
4742
+ config: {
4743
+ nodeType: "loop",
4744
+ mode: "forEach",
4745
+ items: "{{steps.gate.items}}",
4746
+ bodyNodeIds: ["severe", "comment", "skip"]
4747
+ },
4748
+ position: { x: 0, y: 420 }
4749
+ },
4750
+ {
4751
+ id: "severe",
4752
+ type: "condition",
4753
+ label: "High severity?",
4754
+ slug: "severe",
4755
+ config: { variable: "{{loop.item.severity}}", operator: "equals", value: "high" },
4756
+ position: { x: 0, y: 560 }
4757
+ },
4758
+ {
4759
+ id: "comment",
4760
+ type: "callConnectorAction",
4761
+ label: "Comment on the line",
4762
+ slug: "comment",
4763
+ config: {
4764
+ nodeType: "callConnectorAction",
4765
+ connectionId: "<connection id from list_connections>",
4766
+ action: "commentOnPullRequest",
4767
+ args: {
4768
+ pullRequest: "{{inputs.pullRequest}}",
4769
+ path: "{{loop.item.path}}",
4770
+ line: "{{loop.item.line}}",
4771
+ body: "{{loop.item.body}}"
4772
+ }
4773
+ },
4774
+ position: { x: -160, y: 700 }
4775
+ },
4776
+ {
4777
+ id: "skip",
4778
+ type: "script",
4779
+ label: "Note the skip",
4780
+ slug: "skip",
4781
+ config: {
4782
+ scriptType: "bash",
4783
+ scriptContent: "cat <<'EOF'\nNot posted: {{loop.item.path}}\nEOF\n"
4784
+ },
4785
+ position: { x: 160, y: 700 }
4786
+ },
4787
+ {
4788
+ id: "summary",
4789
+ type: "script",
4790
+ label: "Summarise",
4791
+ slug: "summary",
4792
+ config: {
4793
+ scriptType: "bash",
4794
+ scriptContent: "cat <<'EOF'\nWhat each finding came to:\n{{steps.loop.outputs}}\nEOF\n"
4795
+ },
4796
+ position: { x: 0, y: 840 }
4797
+ }
4798
+ ],
4799
+ edges: [
4800
+ { id: "e-start-review", source: "start", target: "review" },
4801
+ { id: "e-review-gate", source: "review", target: "gate" },
4802
+ { id: "e-gate-loop", source: "gate", target: "loop" },
4803
+ { id: "e-loop-severe", source: "loop", target: "severe" },
4804
+ { id: "e-severe-comment", source: "severe", target: "comment", conditionBranch: "true" },
4805
+ { id: "e-severe-skip", source: "severe", target: "skip", conditionBranch: "false" },
4806
+ { id: "e-comment-summary", source: "comment", target: "summary" },
4807
+ { id: "e-skip-summary", source: "skip", target: "summary" }
4808
+ ]
4809
+ };
4810
+ function jsonSchema(schema) {
4811
+ return z7.toJSONSchema(schema, { io: "input", unrepresentable: "any" });
4812
+ }
4813
+ var WORKFLOW_NODES_REFERENCE = {
4814
+ nodeTypes: Object.fromEntries(
4815
+ NODE_TYPES.map((type) => [
4816
+ type,
4817
+ {
4818
+ summary: DOCS[type].summary,
4819
+ configSchema: jsonSchema(configSchemaByType[type]),
4820
+ outputs: DOCS[type].outputs,
4821
+ rules: DOCS[type].rules
4822
+ }
4823
+ ])
4824
+ ),
4825
+ edgeSchema: jsonSchema(edgeSchema),
4826
+ limits: {
4827
+ repeatMaxIterations: MAX_LOOP_ITERATIONS,
4828
+ forEachMaxItems: "uncapped",
4829
+ maxGateRounds: MAX_GATE_ROUNDS,
4830
+ templateTextChars: MAX_OUTPUT_LENGTH,
4831
+ loopResultOutputChars: LOOP_RESULT_OUTPUT_CHARS
4832
+ },
4833
+ templates: {
4834
+ syntax: "{{namespace.path.to.field}} anywhere a config takes text. An unknown namespace is left as written; a known one with nothing there becomes empty. Text longer than templateTextChars keeps its end.",
4835
+ namespaces: {
4836
+ steps: "{{steps.<slug>.<field>}}: the output of an earlier step, by its slug; the fields are listed under each node type",
4837
+ task: "{{task.title}}, .description, .id, .status, .branch, .projectName: the task a task trigger fired for",
4838
+ trigger: "{{trigger.*}}: what the trigger carried: fromStatus/toStatus, restore, or a webhook body, headers and query",
4839
+ connectorItem: "{{connectorItem.title}}, .body, .externalId, .externalUrl: the item a connectorPoll trigger fired for",
4840
+ inputs: "{{inputs.<key>}}: the values a manual run was started with",
4841
+ context: "{{context.cwd}}, .projectPath, .projectName, .branch, .worktreePath: where a contextual manual run was started from",
4842
+ loop: "{{loop.item}}, {{loop.item.<field>}}, {{loop.index}} (from 0), {{loop.number}} (from 1), {{loop.count}}: only inside a loop body"
4843
+ },
4844
+ examples: [
4845
+ "{{steps.review.approved}}",
4846
+ "{{steps.gate.items}}",
4847
+ "{{loop.item.path}}",
4848
+ "{{steps.loop.outputs}}",
4849
+ "{{inputs.pullRequest}}",
4850
+ "{{task.title}}"
4851
+ ]
4852
+ },
4853
+ example: EXAMPLE
4854
+ };
4855
+ function registerDescribeNodesTool(server) {
4856
+ server.tool(
4857
+ "describe_workflow_nodes",
4858
+ "The reference for writing a workflow: for each node type its config schema, the outputs later steps read, and the rules create_workflow checks; the edge schema; limits; the template namespaces; and a complete example workflow to adapt. Call it before create_workflow or update_workflow.",
4859
+ {
4860
+ types: z7.array(z7.enum(NODE_TYPES)).optional().describe("Only these node types. Omit for all nine")
4861
+ },
4862
+ async (args) => {
4863
+ const reference = args.types?.length ? {
4864
+ ...WORKFLOW_NODES_REFERENCE,
4865
+ nodeTypes: Object.fromEntries(
4866
+ args.types.map((type) => [type, WORKFLOW_NODES_REFERENCE.nodeTypes[type]])
4867
+ )
4868
+ } : WORKFLOW_NODES_REFERENCE;
4869
+ return { content: [{ type: "text", text: JSON.stringify(reference, null, 2) }] };
4870
+ }
4871
+ );
4872
+ }
4873
+
4222
4874
  // src/tools/config.ts
4223
4875
  function registerConfigTools(server) {
4224
4876
  server.tool(
@@ -4233,7 +4885,7 @@ function registerConfigTools(server) {
4233
4885
 
4234
4886
  // src/tools/workspaces.ts
4235
4887
  import crypto5 from "crypto";
4236
- import { z as z6 } from "zod";
4888
+ import { z as z8 } from "zod";
4237
4889
  function registerWorkspaceTools(server) {
4238
4890
  server.tool("list_workspaces", "List all workspaces", async () => {
4239
4891
  const workspaces = await dbListWorkspaces();
@@ -4270,7 +4922,7 @@ function registerWorkspaceTools(server) {
4270
4922
  name: V.name.optional().describe("New name"),
4271
4923
  icon: V.shortText.optional().describe("Lucide icon name"),
4272
4924
  icon_color: V.hexColor.optional().describe("Hex color for icon"),
4273
- order: z6.number().int().min(0).optional().describe("Sort order")
4925
+ order: z8.number().int().min(0).optional().describe("Sort order")
4274
4926
  },
4275
4927
  async (args) => {
4276
4928
  const existing = await dbListWorkspaces();
@@ -4318,7 +4970,7 @@ function registerWorkspaceTools(server) {
4318
4970
  }
4319
4971
 
4320
4972
  // src/tools/connectors.ts
4321
- import { z as z7 } from "zod";
4973
+ import { z as z9 } from "zod";
4322
4974
  var PROBE_TIMEOUT_MS = 12e4;
4323
4975
  var json = (value) => ({
4324
4976
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
@@ -4347,8 +4999,8 @@ function registerConnectorTools(server) {
4347
4999
  "list_connectors",
4348
5000
  "List every connector and extension: the ones built into Vorn, the ones installable from a package, and how many connections each already has. A connector polls a service; an extension adds footers and panes to a session card and says what it may touch. Use this before creating a workflow that calls a connector action, or to find an id to install.",
4349
5001
  {
4350
- installable_only: z7.boolean().optional().describe("Only connectors that are not set up yet"),
4351
- kind: z7.enum(["connector", "extension"]).optional().describe("Only one kind: what polls a service, or what shows on a card")
5002
+ installable_only: z9.boolean().optional().describe("Only connectors that are not set up yet"),
5003
+ kind: z9.enum(["connector", "extension"]).optional().describe("Only one kind: what polls a service, or what shows on a card")
4352
5004
  },
4353
5005
  async (args) => {
4354
5006
  const [builtIns, snapshot, connections, statuses, packs] = await Promise.all([
@@ -4426,7 +5078,7 @@ function registerConnectorTools(server) {
4426
5078
  "List configured connector connections, including when each last synced and the error from its last failure. Use this to diagnose a connector that is not producing tasks.",
4427
5079
  {
4428
5080
  connector_id: V.id.optional().describe("Only connections for this connector"),
4429
- failing_only: z7.boolean().optional().describe("Only connections whose last sync failed")
5081
+ failing_only: z9.boolean().optional().describe("Only connections whose last sync failed")
4430
5082
  },
4431
5083
  async (args) => {
4432
5084
  const connections = await rpcCall("connection:list", {
@@ -4495,8 +5147,8 @@ function registerConnectorTools(server) {
4495
5147
  name: V.title.optional().describe("Connection name (defaults to the connector name)"),
4496
5148
  project: V.name.optional().describe("Vorn project tasks should be created in"),
4497
5149
  trigger: V.shortText.optional().describe("Trigger type to configure (defaults to the first the connector offers)"),
4498
- env: z7.record(z7.string(), z7.string()).optional().describe("Non-secret environment variables the connector needs"),
4499
- sync_interval_minutes: z7.number().int().min(1).max(1440).optional()
5150
+ env: z9.record(z9.string(), z9.string()).optional().describe("Non-secret environment variables the connector needs"),
5151
+ sync_interval_minutes: z9.number().int().min(1).max(1440).optional()
4500
5152
  },
4501
5153
  async (args) => {
4502
5154
  const { items: catalog } = await rpcCall("connector:catalog");
@@ -4608,7 +5260,7 @@ function registerConnectorTools(server) {
4608
5260
  {
4609
5261
  connection_id: V.id.describe("Connection ID"),
4610
5262
  action: V.shortText.describe("Action name from list_connector_actions"),
4611
- args: z7.record(z7.string(), z7.unknown()).optional().describe("Action arguments")
5263
+ args: z9.record(z9.string(), z9.unknown()).optional().describe("Action arguments")
4612
5264
  },
4613
5265
  async (args) => {
4614
5266
  const result = await rpcCall("connection:executeAction", {
@@ -4660,7 +5312,7 @@ function plural(count, word) {
4660
5312
 
4661
5313
  // src/tools/browser.ts
4662
5314
  import crypto6 from "crypto";
4663
- import { z as z8 } from "zod";
5315
+ import { z as z10 } from "zod";
4664
5316
  function sessionId(env = process.env) {
4665
5317
  const id = env.VORN_SESSION_ID;
4666
5318
  return id && id.length > 0 ? id : null;
@@ -4718,9 +5370,9 @@ function registerBrowserTools(server) {
4718
5370
  "read_page",
4719
5371
  'Read your session browser pane as an accessibility tree. Interactive elements carry a "ref" you can pass to browser_interact. Prefer this over screenshot: it is far cheaper and gives you actionable handles. Long pages paginate \u2014 pass the returned nextCursor back as `cursor`. When the page is a design artifact it also returns "artifact" (what the file declares) and "artifactValues" (what it is currently showing). Work from artifactValues: a person can turn a control without spending a turn, so the value on screen is routinely not the default in the file.',
4720
5372
  {
4721
- filter: z8.enum(["interactive", "all"]).optional().describe('"interactive" (default) returns only actionable elements; "all" adds text'),
5373
+ filter: z10.enum(["interactive", "all"]).optional().describe('"interactive" (default) returns only actionable elements; "all" adds text'),
4722
5374
  cursor: V.shortText.optional().describe("nextCursor from a previous read_page result"),
4723
- limit: z8.number().int().min(1).max(200).optional().describe("Max nodes (default 200)")
5375
+ limit: z10.number().int().min(1).max(200).optional().describe("Max nodes (default 200)")
4724
5376
  },
4725
5377
  async (args) => withSession(
4726
5378
  async (id) => pageResult(
@@ -4751,7 +5403,7 @@ function registerBrowserTools(server) {
4751
5403
  server.tool(
4752
5404
  "read_console_messages",
4753
5405
  "Read console output captured from your session browser pane since it opened.",
4754
- { limit: z8.number().int().min(1).max(200).optional().describe("Max messages (default 50)") },
5406
+ { limit: z10.number().int().min(1).max(200).optional().describe("Max messages (default 50)") },
4755
5407
  async (args) => withSession(
4756
5408
  async (id) => pageResult(
4757
5409
  await rpcCall("browser:consoleMessages", {
@@ -4764,7 +5416,7 @@ function registerBrowserTools(server) {
4764
5416
  server.tool(
4765
5417
  "read_network_requests",
4766
5418
  "Read network requests captured from your session browser pane since it opened.",
4767
- { limit: z8.number().int().min(1).max(200).optional().describe("Max requests (default 50)") },
5419
+ { limit: z10.number().int().min(1).max(200).optional().describe("Max requests (default 50)") },
4768
5420
  async (args) => withSession(
4769
5421
  async (id) => pageResult(
4770
5422
  await rpcCall("browser:networkRequests", {
@@ -4777,7 +5429,7 @@ function registerBrowserTools(server) {
4777
5429
  server.tool(
4778
5430
  "browser_screenshot",
4779
5431
  "Capture your session browser pane as a PNG. This is the expensive last resort \u2014 reach for read_page first, and use this only when layout or rendering is the actual question.",
4780
- { full_page: z8.boolean().optional().describe("Capture beyond the viewport") },
5432
+ { full_page: z10.boolean().optional().describe("Capture beyond the viewport") },
4781
5433
  async (args) => withSession(async (id) => {
4782
5434
  const { data } = await rpcCall("browser:screenshot", {
4783
5435
  sessionId: id,
@@ -4799,7 +5451,7 @@ function registerBrowserTools(server) {
4799
5451
  "Find elements in your session browser pane whose accessible name contains some text. Cheaper than reading the whole page when you already know what you are looking for.",
4800
5452
  {
4801
5453
  text: V.shortText.describe("Text to match against element names (case-insensitive)"),
4802
- limit: z8.number().int().min(1).max(50).optional().describe("Max matches (default 20)")
5454
+ limit: z10.number().int().min(1).max(50).optional().describe("Max matches (default 20)")
4803
5455
  },
4804
5456
  async (args) => withSession(
4805
5457
  async (id) => pageResult(
@@ -4815,12 +5467,12 @@ function registerBrowserTools(server) {
4815
5467
  "browser_interact",
4816
5468
  'Act on your session browser pane: click, hover, type, press a key, or scroll. Address the target by "ref" from read_page where possible \u2014 refs survive reflow, coordinates do not. A ref from before a navigation is refused rather than guessed at; re-read the page. A ref target is scrolled into view first, so it does not need to be on screen already. "ok" means the input was dispatched, not that the page did what you expected \u2014 read the state back after anything that matters.',
4817
5469
  {
4818
- action: z8.enum(["click", "hover", "type", "key", "scroll"]).describe('What to do. "type" clicks the target first when one is given.'),
5470
+ action: z10.enum(["click", "hover", "type", "key", "scroll"]).describe('What to do. "type" clicks the target first when one is given.'),
4819
5471
  ref: V.shortText.optional().describe("Element ref from read_page"),
4820
- x: z8.number().optional().describe("Viewport x, when no ref is available"),
4821
- y: z8.number().optional().describe("Viewport y, when no ref is available"),
5472
+ x: z10.number().optional().describe("Viewport x, when no ref is available"),
5473
+ y: z10.number().optional().describe("Viewport y, when no ref is available"),
4822
5474
  text: V.description.optional().describe('Text for "type", or the key name for "key" (e.g. "Enter")'),
4823
- delta_y: z8.number().optional().describe("Scroll amount in pixels (default 400)")
5475
+ delta_y: z10.number().optional().describe("Scroll amount in pixels (default 400)")
4824
5476
  },
4825
5477
  async (args) => withSession(async (id) => {
4826
5478
  await rpcCall("browser:interact", {
@@ -4846,9 +5498,9 @@ function registerBrowserTools(server) {
4846
5498
  "browser_tabs",
4847
5499
  'List, add, close, or switch tabs in your session browser pane. "close" and "select" take a zero-based index \u2014 call "list" first to see what those indices name, since a tab that redirected or followed a link is no longer on the page it was opened with. Closing the last remaining tab closes the pane.',
4848
5500
  {
4849
- action: z8.enum(["list", "add", "close", "select"]).describe("What to do with tabs"),
5501
+ action: z10.enum(["list", "add", "close", "select"]).describe("What to do with tabs"),
4850
5502
  url: V.url.optional().describe('URL for "add"'),
4851
- index: z8.number().int().min(0).optional().describe("Zero-based tab index for close/select")
5503
+ index: z10.number().int().min(0).optional().describe("Zero-based tab index for close/select")
4852
5504
  },
4853
5505
  async (args) => withSession(async (id) => {
4854
5506
  if (args.action === "list") {
@@ -4881,7 +5533,7 @@ function registerBrowserTools(server) {
4881
5533
  server.tool(
4882
5534
  "browser_history",
4883
5535
  "Step back or forward through your session browser pane's own history \u2014 the same thing the pane's back and forward buttons do. Fails when there is no page to move to, rather than quietly staying put.",
4884
- { direction: z8.enum(["back", "forward"]).describe("Which way to step") },
5536
+ { direction: z10.enum(["back", "forward"]).describe("Which way to step") },
4885
5537
  async (args) => withSession(async (id) => {
4886
5538
  const result = await rpcCall("browser:history", {
4887
5539
  sessionId: id,
@@ -4895,7 +5547,7 @@ function registerBrowserTools(server) {
4895
5547
  }
4896
5548
 
4897
5549
  // src/tools/device.ts
4898
- import { z as z9 } from "zod";
5550
+ import { z as z11 } from "zod";
4899
5551
  var DEVICE_FENCE = "DEVICE CONTENT";
4900
5552
  var required = (description) => V.shortText.min(1, "Value must not be empty").describe(description);
4901
5553
  async function withSession2(run) {
@@ -4960,9 +5612,9 @@ function registerDeviceTools(server) {
4960
5612
  "read_screen",
4961
5613
  'Read your claimed simulator as an accessibility tree. Elements carry a "ref" you can pass to device_interact. Prefer this over device_screenshot: it is far cheaper and gives you handles rather than pixels. Coordinates are in POINTS, which is what taps take \u2014 a screenshot is typically 3x larger in pixels. Long screens paginate via nextCursor.',
4962
5614
  {
4963
- filter: z9.enum(["interactive", "all"]).optional().describe('"interactive" (default) returns only actionable elements; "all" adds text'),
5615
+ filter: z11.enum(["interactive", "all"]).optional().describe('"interactive" (default) returns only actionable elements; "all" adds text'),
4964
5616
  cursor: V.shortText.optional().describe("nextCursor from a previous read_screen result"),
4965
- limit: z9.number().int().min(1).max(200).optional().describe("Max elements (default 200)")
5617
+ limit: z11.number().int().min(1).max(200).optional().describe("Max elements (default 200)")
4966
5618
  },
4967
5619
  async (args) => withSession2(
4968
5620
  async (id) => pageResult(
@@ -4981,7 +5633,7 @@ function registerDeviceTools(server) {
4981
5633
  "Find elements on your claimed simulator whose label or accessibility identifier contains some text. Searches the whole screen, not just the first page read_screen would return.",
4982
5634
  {
4983
5635
  text: required("Text to match against labels and identifiers (case-insensitive)"),
4984
- limit: z9.number().int().min(1).max(50).optional().describe("Max matches (default 20)")
5636
+ limit: z11.number().int().min(1).max(50).optional().describe("Max matches (default 20)")
4985
5637
  },
4986
5638
  async (args) => withSession2(
4987
5639
  async (id) => pageResult(
@@ -4998,17 +5650,17 @@ function registerDeviceTools(server) {
4998
5650
  "device_interact",
4999
5651
  'Act on your claimed simulator: tap, swipe, type, press a hardware button, or long-press. Address the target by "ref" from read_screen where possible. Coordinates are in POINTS. A ref from before an earlier interaction is refused rather than guessed at \u2014 read the screen again. A swipe starting at the very edge of the screen is refused too: iOS claims those as system gestures and swallows them, which looks to you like nothing happened.',
5000
5652
  {
5001
- action: z9.enum(["tap", "swipe", "type", "button", "press"]).describe('"press" is a long press; "button" takes a name in `text`'),
5653
+ action: z11.enum(["tap", "swipe", "type", "button", "press"]).describe('"press" is a long press; "button" takes a name in `text`'),
5002
5654
  ref: required("Element ref from read_screen or device_find").optional(),
5003
- x: z9.number().optional().describe("Screen x in points, when no ref is available"),
5004
- y: z9.number().optional().describe("Screen y in points, when no ref is available"),
5005
- to_x: z9.number().optional().describe("Swipe destination x, in points"),
5006
- to_y: z9.number().optional().describe("Swipe destination y, in points"),
5655
+ x: z11.number().optional().describe("Screen x in points, when no ref is available"),
5656
+ y: z11.number().optional().describe("Screen y in points, when no ref is available"),
5657
+ to_x: z11.number().optional().describe("Swipe destination x, in points"),
5658
+ to_y: z11.number().optional().describe("Swipe destination y, in points"),
5007
5659
  text: V.description.optional().describe(
5008
5660
  'Text for "type", or the button name for "button" (HOME, LOCK, SIRI, SIDE_BUTTON)'
5009
5661
  ),
5010
- duration: z9.number().min(0).max(30).optional().describe('Seconds to hold, for "press"'),
5011
- system_gesture: z9.boolean().optional().describe("Allow a stroke starting in the bezel band, when a system gesture is the intent")
5662
+ duration: z11.number().min(0).max(30).optional().describe('Seconds to hold, for "press"'),
5663
+ system_gesture: z11.boolean().optional().describe("Allow a stroke starting in the bezel band, when a system gesture is the intent")
5012
5664
  },
5013
5665
  async (args) => withSession2(async (id) => {
5014
5666
  const r = await rpcCall("device:interact", {
@@ -5034,7 +5686,7 @@ function registerDeviceTools(server) {
5034
5686
  "device_screenshot",
5035
5687
  "Capture your claimed simulator as a downscaled PNG. The expensive last resort \u2014 reach for read_screen first, and use this only when layout or rendering is the actual question. The result reports the scale factor: divide an image coordinate by it to get the point coordinate device_interact takes.",
5036
5688
  {
5037
- max_edge: z9.number().int().min(200).max(2e3).optional().describe("Longest edge of the returned image in pixels (default 1000)")
5689
+ max_edge: z11.number().int().min(200).max(2e3).optional().describe("Longest edge of the returned image in pixels (default 1000)")
5038
5690
  },
5039
5691
  async (args) => withSession2(async (id) => {
5040
5692
  const r = await rpcCall("device:screenshot", { sessionId: id, maxEdge: args.max_edge });
@@ -5089,7 +5741,7 @@ Screen is ${r.screen.width}x${r.screen.height} points. Image pixels are ${r.scal
5089
5741
  server.tool(
5090
5742
  "device_logs",
5091
5743
  "Read log output captured from your claimed simulator since it was claimed.",
5092
- { limit: z9.number().int().min(1).max(500).optional().describe("Max lines (default 100)") },
5744
+ { limit: z11.number().int().min(1).max(500).optional().describe("Max lines (default 100)") },
5093
5745
  async (args) => withSession2(
5094
5746
  async (id) => pageResult(
5095
5747
  await rpcCall("device:logs", { sessionId: id, limit: args.limit }),
@@ -5122,6 +5774,7 @@ function createMcpServer(version) {
5122
5774
  registerProjectTools(server);
5123
5775
  registerTaskTools(server);
5124
5776
  registerWorkflowTools(server);
5777
+ registerDescribeNodesTool(server);
5125
5778
  registerWorkspaceTools(server);
5126
5779
  return server;
5127
5780
  }
@@ -5135,7 +5788,7 @@ console.warn = (...args) => _origError("[mcp:warn]", ...args);
5135
5788
  console.error = (...args) => _origError("[mcp:error]", ...args);
5136
5789
  async function main() {
5137
5790
  configManager.init();
5138
- const version = true ? "0.7.2" : createRequire(import.meta.url)("../package.json").version;
5791
+ const version = true ? "0.7.3" : createRequire(import.meta.url)("../package.json").version;
5139
5792
  const server = createMcpServer(version);
5140
5793
  const transport = new StdioServerTransport();
5141
5794
  await server.connect(transport);