@agentproto/runtime 2.1.0 → 2.2.0

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/index.mjs CHANGED
@@ -29,12 +29,13 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
29
29
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
30
30
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
31
31
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
32
- import { runWorkflow, compileWorkflow } from '@agentproto/workflow-runtime';
32
+ import { loadAppHandle } from '@agentproto/app-kit';
33
+ import { normalizeToolId } from '@agentproto/driver';
34
+ import { runWorkflow, buildAgentStep, compileWorkflow } from '@agentproto/workflow-runtime';
33
35
  import { loadWorkflowHandle } from '@agentproto/workflow-loader';
34
36
  import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
35
37
  import { Cron } from 'croner';
36
38
  import { parseRoutineManifest } from '@agentproto/routine';
37
- import { normalizeToolId } from '@agentproto/driver';
38
39
  import { createServer as createServer$1 } from 'net';
39
40
  import { promisify } from 'util';
40
41
  import { createRequire } from 'module';
@@ -21944,6 +21945,18 @@ async function startHttpServer(opts) {
21944
21945
  const handled = await handleRoutineDefs(req, res, path, opts.routineRegistrar);
21945
21946
  if (handled) return;
21946
21947
  }
21948
+ if (opts.appRegistry && opts.performAppInstall && opts.listRegisteredToolIds && (path.startsWith("/apps/") || path.startsWith("/scopes/"))) {
21949
+ const handled = await handleApps(
21950
+ req,
21951
+ res,
21952
+ path,
21953
+ opts.appRegistry,
21954
+ opts.performAppInstall,
21955
+ opts.listRegisteredToolIds,
21956
+ opts.resolveAgentAdapter
21957
+ );
21958
+ if (handled) return;
21959
+ }
21947
21960
  res.writeHead(404, { "content-type": "application/json" });
21948
21961
  res.end(JSON.stringify({ error: "not_found", path }));
21949
21962
  } catch (err) {
@@ -24152,6 +24165,116 @@ async function handleProviderInbound(req, res, slug, deps2) {
24152
24165
  res.writeHead(200, { "content-type": "application/json" });
24153
24166
  res.end(JSON.stringify(result));
24154
24167
  }
24168
+ async function handleApps(req, res, path, appRegistry, performInstall2, listRegisteredToolIds, resolveAgentAdapter) {
24169
+ const method = req.method ?? "GET";
24170
+ const applyMatch = path.match(/^\/apps\/([^/]+)\/apply$/);
24171
+ if (applyMatch && method === "POST") {
24172
+ const appId = decodeURIComponent(applyMatch[1]);
24173
+ const body = await readJsonBody(req);
24174
+ const scopeId = body?.scopeId ?? "root";
24175
+ let installed = appRegistry.getApp(appId);
24176
+ if (!installed && body?.dir) {
24177
+ const installResult = await performInstall2(body.dir, appRegistry, listRegisteredToolIds, resolveAgentAdapter);
24178
+ if (!installResult.ok) {
24179
+ res.writeHead(400, { "content-type": "application/json" });
24180
+ res.end(JSON.stringify({ error: installResult.error }));
24181
+ return true;
24182
+ }
24183
+ installed = installResult.record;
24184
+ }
24185
+ if (!installed) {
24186
+ res.writeHead(400, { "content-type": "application/json" });
24187
+ res.end(
24188
+ JSON.stringify({
24189
+ error: `app "${appId}" is not installed. Either call app_install first or provide a 'dir' parameter.`
24190
+ })
24191
+ );
24192
+ return true;
24193
+ }
24194
+ if (installed.requires && installed.requires.length > 0) {
24195
+ const applied = appRegistry.listApplied(scopeId);
24196
+ const appliedIds = new Set(applied.map((m) => m.appId));
24197
+ const missing = installed.requires.filter((reqId) => !appliedIds.has(reqId));
24198
+ if (missing.length > 0) {
24199
+ res.writeHead(400, { "content-type": "application/json" });
24200
+ res.end(
24201
+ JSON.stringify({
24202
+ error: `app "${appId}" requires the following apps to be applied to scope "${scopeId}" first: ${missing.join(", ")}`
24203
+ })
24204
+ );
24205
+ return true;
24206
+ }
24207
+ }
24208
+ const mount = appRegistry.applyApp({ scopeId, appId });
24209
+ res.writeHead(200, { "content-type": "application/json" });
24210
+ res.end(
24211
+ JSON.stringify({
24212
+ scopeId: mount.scopeId,
24213
+ appId: mount.appId,
24214
+ appliedAt: mount.appliedAt,
24215
+ agents: installed.agents,
24216
+ workflows: installed.workflows,
24217
+ unvalidatedAgentTools: installed.unvalidatedAgentTools
24218
+ })
24219
+ );
24220
+ return true;
24221
+ }
24222
+ if (applyMatch && method === "DELETE") {
24223
+ const appId = decodeURIComponent(applyMatch[1]);
24224
+ const body = await readJsonBody(req);
24225
+ const url = new URL(req.url ?? "", `http://${req.headers.host}`);
24226
+ const scopeId = body?.scopeId ?? url.searchParams.get("scopeId") ?? "root";
24227
+ const applied = appRegistry.listApplied(scopeId);
24228
+ const dependents = [];
24229
+ for (const mount of applied) {
24230
+ if (mount.appId === appId) continue;
24231
+ const app = appRegistry.getApp(mount.appId);
24232
+ if (app?.requires?.includes(appId)) {
24233
+ dependents.push(mount.appId);
24234
+ }
24235
+ }
24236
+ if (dependents.length > 0) {
24237
+ res.writeHead(400, { "content-type": "application/json" });
24238
+ res.end(
24239
+ JSON.stringify({
24240
+ error: `cannot unapply app "${appId}" from scope "${scopeId}" \u2014 the following apps in this scope require it: ${dependents.join(", ")}`
24241
+ })
24242
+ );
24243
+ return true;
24244
+ }
24245
+ const removed = appRegistry.unapplyApp({ scopeId, appId });
24246
+ if (!removed) {
24247
+ res.writeHead(404, { "content-type": "application/json" });
24248
+ res.end(JSON.stringify({ error: `app "${appId}" is not applied to scope "${scopeId}".` }));
24249
+ return true;
24250
+ }
24251
+ res.writeHead(200, { "content-type": "application/json" });
24252
+ res.end(JSON.stringify({ scopeId: removed.scopeId, appId: removed.appId, appliedAt: removed.appliedAt }));
24253
+ return true;
24254
+ }
24255
+ const scopesMatch = path.match(/^\/scopes\/([^/]+)\/apps$/);
24256
+ if (scopesMatch && method === "GET") {
24257
+ const scopeId = decodeURIComponent(scopesMatch[1]);
24258
+ const mounts = appRegistry.listApplied(scopeId);
24259
+ const result = mounts.map((mount) => {
24260
+ const app = appRegistry.getApp(mount.appId);
24261
+ return {
24262
+ scopeId: mount.scopeId,
24263
+ appId: mount.appId,
24264
+ appliedAt: mount.appliedAt,
24265
+ ...app ? {
24266
+ agents: app.agents,
24267
+ workflows: app.workflows,
24268
+ unvalidatedAgentTools: app.unvalidatedAgentTools
24269
+ } : {}
24270
+ };
24271
+ });
24272
+ res.writeHead(200, { "content-type": "application/json" });
24273
+ res.end(JSON.stringify(result));
24274
+ return true;
24275
+ }
24276
+ return false;
24277
+ }
24155
24278
  async function readJsonBody(req) {
24156
24279
  const chunks = [];
24157
24280
  for await (const chunk of req) {
@@ -25260,6 +25383,525 @@ stderr: ${stderrText.slice(-600)}` : msg);
25260
25383
  `import "${entry.alias}" has unsupported transport type "${snap.type}"`
25261
25384
  );
25262
25385
  }
25386
+ function collectToolIds(steps, out) {
25387
+ for (const step of steps) {
25388
+ switch (step.kind) {
25389
+ case "tool": {
25390
+ const ref = typeof step.tool === "string" ? step.tool : step.tool.entry;
25391
+ out.add(normalizeToolId(ref));
25392
+ break;
25393
+ }
25394
+ case "map":
25395
+ case "loop":
25396
+ collectToolIds(step.steps, out);
25397
+ break;
25398
+ case "parallel":
25399
+ for (const branch of step.branches) collectToolIds(branch.steps, out);
25400
+ break;
25401
+ }
25402
+ }
25403
+ }
25404
+ function passthroughToolHandle(id) {
25405
+ return {
25406
+ id,
25407
+ name: id,
25408
+ description: `Daemon tool '${id}', dispatched via the routine/cron dispatchTool bridge.`,
25409
+ mutates: [],
25410
+ requires: { network: [], secrets: [], tools: [] },
25411
+ approval: "auto",
25412
+ riskLevel: 0,
25413
+ costClass: "trivial",
25414
+ timeoutMs: 3e4,
25415
+ tags: [],
25416
+ metadata: {},
25417
+ idempotent: false,
25418
+ driverConstraints: { forbid: [], requireKind: [] }
25419
+ };
25420
+ }
25421
+ function unwrapDispatchResult(result) {
25422
+ if (!result || typeof result !== "object" || !("content" in result)) return result;
25423
+ const r = result;
25424
+ const text9 = (r.content ?? []).map((c) => c.text ?? "").join("\n").trim();
25425
+ if (r.isError) throw new Error(text9 || "tool call failed");
25426
+ if (!text9) return void 0;
25427
+ try {
25428
+ return JSON.parse(text9);
25429
+ } catch {
25430
+ return text9;
25431
+ }
25432
+ }
25433
+ function createDaemonToolRegistry(handle, dispatchTool) {
25434
+ const toolIds = /* @__PURE__ */ new Set();
25435
+ collectToolIds(handle.steps, toolIds);
25436
+ const tools = {};
25437
+ const execute = {};
25438
+ for (const id of toolIds) {
25439
+ tools[id] = passthroughToolHandle(id);
25440
+ execute[id] = async ({ input }) => unwrapDispatchResult(await dispatchTool(id, input ?? {}));
25441
+ }
25442
+ if (toolIds.size === 0) return { tools, candidates: [] };
25443
+ const daemonDriver = {
25444
+ id: "daemon-tool-dispatch",
25445
+ name: "Daemon tool dispatch",
25446
+ description: "Catch-all AIP-30 provider routing every referenced WORKFLOW.md tool step through the daemon's dispatchTool.",
25447
+ kind: "builtin",
25448
+ implements: [...toolIds].map((id) => ({ tool: id, version: "*" })),
25449
+ execute,
25450
+ install: [],
25451
+ network: { egress: [], ingress: [] },
25452
+ region: ["global"],
25453
+ policyTags: [],
25454
+ tags: [],
25455
+ metadata: {}
25456
+ };
25457
+ return { tools, candidates: [daemonDriver] };
25458
+ }
25459
+ var DEFAULT_PERSIST_PATH2 = () => join(homedir(), ".agentproto", "apps.json");
25460
+ function loadState(persistPath) {
25461
+ const empty = { apps: [], runs: [], applied: [] };
25462
+ if (!existsSync(persistPath)) return empty;
25463
+ let raw;
25464
+ try {
25465
+ raw = readFileSync(persistPath, "utf8");
25466
+ } catch {
25467
+ return empty;
25468
+ }
25469
+ try {
25470
+ const parsed = JSON.parse(raw);
25471
+ return {
25472
+ apps: Array.isArray(parsed.apps) ? parsed.apps : [],
25473
+ runs: Array.isArray(parsed.runs) ? parsed.runs : [],
25474
+ applied: Array.isArray(parsed.applied) ? parsed.applied : []
25475
+ };
25476
+ } catch {
25477
+ return empty;
25478
+ }
25479
+ }
25480
+ function saveState(state, persistPath) {
25481
+ try {
25482
+ mkdirSync(dirname(persistPath), { recursive: true });
25483
+ const tmp = `${persistPath}.tmp.${process.pid}`;
25484
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
25485
+ renameSync(tmp, persistPath);
25486
+ } catch {
25487
+ }
25488
+ }
25489
+ function createAppRegistry(opts) {
25490
+ const persistPath = opts?.persistPath ?? DEFAULT_PERSIST_PATH2();
25491
+ const shouldPersist = opts?.persist ?? opts?.persistPath !== void 0;
25492
+ const state = shouldPersist ? loadState(persistPath) : { apps: [], runs: [], applied: [] };
25493
+ const persist = () => {
25494
+ if (shouldPersist) saveState(state, persistPath);
25495
+ };
25496
+ return {
25497
+ upsertApp(input) {
25498
+ const now = (/* @__PURE__ */ new Date()).toISOString();
25499
+ const idx = state.apps.findIndex((a) => a.appId === input.appId);
25500
+ const record2 = {
25501
+ ...input,
25502
+ installedAt: idx === -1 ? now : state.apps[idx].installedAt,
25503
+ updatedAt: now
25504
+ };
25505
+ if (idx === -1) state.apps.push(record2);
25506
+ else state.apps[idx] = record2;
25507
+ persist();
25508
+ return record2;
25509
+ },
25510
+ getApp(appId) {
25511
+ return state.apps.find((a) => a.appId === appId);
25512
+ },
25513
+ listApps() {
25514
+ return [...state.apps];
25515
+ },
25516
+ createRun(input) {
25517
+ const run = {
25518
+ appRunId: `apprun_${randomUUID()}`,
25519
+ appId: input.appId,
25520
+ sessions: input.sessions,
25521
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
25522
+ status: "running"
25523
+ };
25524
+ state.runs.push(run);
25525
+ persist();
25526
+ return run;
25527
+ },
25528
+ getRun(appRunId) {
25529
+ return state.runs.find((r) => r.appRunId === appRunId);
25530
+ },
25531
+ listRuns() {
25532
+ return [...state.runs];
25533
+ },
25534
+ endRun(appRunId) {
25535
+ const run = state.runs.find((r) => r.appRunId === appRunId);
25536
+ if (!run) return void 0;
25537
+ run.status = "stopped";
25538
+ run.endedAt = (/* @__PURE__ */ new Date()).toISOString();
25539
+ persist();
25540
+ return run;
25541
+ },
25542
+ applyApp(input) {
25543
+ const idx = state.applied.findIndex(
25544
+ (m) => m.scopeId === input.scopeId && m.appId === input.appId
25545
+ );
25546
+ const mount = {
25547
+ scopeId: input.scopeId,
25548
+ appId: input.appId,
25549
+ appliedAt: idx === -1 ? (/* @__PURE__ */ new Date()).toISOString() : state.applied[idx].appliedAt
25550
+ };
25551
+ if (idx === -1) state.applied.push(mount);
25552
+ else state.applied[idx] = mount;
25553
+ persist();
25554
+ return mount;
25555
+ },
25556
+ unapplyApp(input) {
25557
+ const idx = state.applied.findIndex(
25558
+ (m) => m.scopeId === input.scopeId && m.appId === input.appId
25559
+ );
25560
+ if (idx === -1) return void 0;
25561
+ const removed = state.applied[idx];
25562
+ state.applied.splice(idx, 1);
25563
+ persist();
25564
+ return removed;
25565
+ },
25566
+ listApplied(scopeId) {
25567
+ if (scopeId === void 0) return [...state.applied];
25568
+ return state.applied.filter((m) => m.scopeId === scopeId);
25569
+ }
25570
+ };
25571
+ }
25572
+
25573
+ // src/app-tools.ts
25574
+ var DEFAULT_AGENT_ADAPTER = "mastra-agent";
25575
+ function resolveAgentRefsForWorkflow(appRegistry, workflowId) {
25576
+ const app = appRegistry.listApps().find((a) => a.workflows.some((w) => w.id === workflowId));
25577
+ if (!app) return void 0;
25578
+ const refs = {};
25579
+ for (const agent of app.agents) {
25580
+ refs[agent.id] = { adapter: DEFAULT_AGENT_ADAPTER, options: { agent: agent.path } };
25581
+ }
25582
+ return refs;
25583
+ }
25584
+ function textResult(body) {
25585
+ return { content: [{ type: "text", text: JSON.stringify(body, null, 2) }] };
25586
+ }
25587
+ function errorResult(text9) {
25588
+ return { content: [{ type: "text", text: JSON.stringify({ error: text9 }) }], isError: true };
25589
+ }
25590
+ function refIdOf(ref) {
25591
+ if (typeof ref === "string") return ref;
25592
+ return ref.ref ?? ref.file ?? "inline";
25593
+ }
25594
+ function resolveRef(dir, path) {
25595
+ return isAbsolute(path) ? path : join(dir, path);
25596
+ }
25597
+ async function readAppRefs(dir) {
25598
+ const appPath = join(dir, ".agentproto", "APP.md");
25599
+ const source = await readFile(appPath, "utf8");
25600
+ const { data } = matter(source);
25601
+ const toRefs = (v) => Array.isArray(v) ? v.filter(
25602
+ (e) => typeof e === "object" && e !== null && typeof e.id === "string" && typeof e.path === "string"
25603
+ ).map((e) => ({ id: e.id, path: resolveRef(dir, e.path) })) : [];
25604
+ return { agents: toRefs(data.agents), workflows: toRefs(data.workflows) };
25605
+ }
25606
+ async function performInstall(dir, appRegistry, listRegisteredToolIds, resolveAgentAdapter) {
25607
+ let handle;
25608
+ try {
25609
+ handle = await loadAppHandle(dir);
25610
+ } catch (err) {
25611
+ return { ok: false, error: `${err instanceof Error ? err.message : String(err)}` };
25612
+ }
25613
+ if (!handle.id) {
25614
+ return { ok: false, error: "the app has no `id` \u2014 set one in defineApp()/APP.md frontmatter to install it." };
25615
+ }
25616
+ const missingByWorkflow = {};
25617
+ const registeredIds = new Set(await listRegisteredToolIds());
25618
+ for (const workflow of handle.workflows) {
25619
+ const { tools } = createDaemonToolRegistry(workflow, async () => void 0);
25620
+ const missing = Object.keys(tools).filter((id) => !registeredIds.has(id));
25621
+ if (missing.length > 0) missingByWorkflow[workflow.id] = missing;
25622
+ }
25623
+ if (Object.keys(missingByWorkflow).length > 0) {
25624
+ return {
25625
+ ok: false,
25626
+ error: `unknown daemon tool id(s) referenced by workflow step(s) \u2014 would otherwise fail at STEP-DISPATCH time: ${JSON.stringify(missingByWorkflow)}`
25627
+ };
25628
+ }
25629
+ if (handle.agents.length > 0) {
25630
+ const resolved = resolveAgentAdapter ? await resolveAgentAdapter(DEFAULT_AGENT_ADAPTER) : null;
25631
+ if (!resolved) {
25632
+ return {
25633
+ ok: false,
25634
+ error: `agent adapter "${DEFAULT_AGENT_ADAPTER}" could not be resolved \u2014 run \`agentproto install ${DEFAULT_AGENT_ADAPTER}\` first.`
25635
+ };
25636
+ }
25637
+ }
25638
+ const refs = await readAppRefs(dir);
25639
+ const unvalidatedAgentTools = [
25640
+ ...new Set(handle.agents.flatMap((e) => (e.agent.tools ?? []).map(refIdOf)))
25641
+ ];
25642
+ const record2 = appRegistry.upsertApp({
25643
+ appId: handle.id,
25644
+ dir,
25645
+ ...handle.version ? { version: handle.version } : {},
25646
+ ...handle.name ? { name: handle.name } : {},
25647
+ agents: refs.agents,
25648
+ workflows: refs.workflows,
25649
+ unvalidatedAgentTools,
25650
+ ...handle.requires ? { requires: handle.requires } : {}
25651
+ });
25652
+ return { ok: true, record: record2 };
25653
+ }
25654
+ function registerAppTools(server, opts) {
25655
+ const { registry, resolveAgentAdapter, listRegisteredToolIds, workflowRunner } = opts;
25656
+ const appRegistry = opts.appRegistry ?? createAppRegistry({
25657
+ ...opts.persistPath !== void 0 ? { persistPath: opts.persistPath } : {},
25658
+ ...opts.persist !== void 0 ? { persist: opts.persist } : {}
25659
+ });
25660
+ const notEnabled = (tool) => errorResult(
25661
+ `${tool} is not enabled \u2014 the daemon was started without an adapter resolver. Re-run the daemon with the \`@agentproto/cli\` shim wired (see playground/scripts/gateway.ts).`
25662
+ );
25663
+ server.tool(
25664
+ "app_install",
25665
+ "Install an @agentproto/app-kit app from its emitted directory (`<dir>/.agentproto/APP.md` \u2014 see `defineApp().emit(dir)`). Validates every WORKFLOW.md `tool` step's id against the daemon's dispatchable tools (missing ids are reported ALL at once, instead of failing one at a time at STEP-DISPATCH time) and checks the `mastra-agent` adapter resolves. Agent-declared tool refs (workspace tools like `read_file`) are the adapter's own business and are never validated here \u2014 see `unvalidatedAgentTools` on the result. Re-installing the same appId upserts.",
25666
+ { dir: z.string().describe("Absolute path to the app's directory.") },
25667
+ async (input) => {
25668
+ const result = await performInstall(input.dir, appRegistry, listRegisteredToolIds, resolveAgentAdapter);
25669
+ if (!result.ok) return errorResult(`app_install: ${result.error}`);
25670
+ return textResult(result.record);
25671
+ }
25672
+ );
25673
+ server.tool(
25674
+ "app_list",
25675
+ "List installed apps, each with a summary of its app_run history.",
25676
+ {},
25677
+ async () => {
25678
+ const runs = appRegistry.listRuns();
25679
+ const apps = appRegistry.listApps().map((app) => ({
25680
+ ...app,
25681
+ runs: runs.filter((r) => r.appId === app.appId).map((r) => ({
25682
+ appRunId: r.appRunId,
25683
+ status: r.status,
25684
+ startedAt: r.startedAt,
25685
+ ...r.endedAt ? { endedAt: r.endedAt } : {},
25686
+ sessions: r.sessions.length
25687
+ }))
25688
+ }));
25689
+ return textResult(apps);
25690
+ }
25691
+ );
25692
+ server.tool(
25693
+ "app_run",
25694
+ "Run an installed app's agents as live sessions \u2014 one `agent_start`-equivalent spawn per selected agent (adapter `mastra-agent`, pointed at that agent's emitted AGENT.md via the adapter's `agent` option), grouped under a fresh appRunId. Re-reads the app's directory first, so a stale install record (paths moved, a workflow renamed) is refreshed before spawning \u2014 the same refreshed paths are what make `workflow_run_file` work against this app's WORKFLOW.md files. Poll with `app_status`, kill with `app_stop`.",
25695
+ {
25696
+ appId: z.string(),
25697
+ agents: z.array(z.string()).optional().describe("Agent ids to run. Omit to run every agent the app bundles."),
25698
+ prompt: z.string().optional().describe("Prompt to send to each spawned agent session."),
25699
+ cwd: z.string().optional().describe("Working directory for spawned sessions. Defaults to the app's installed `dir`."),
25700
+ scopeId: z.string().optional().describe("When passed, refuse to run if the app is not applied to this scope.")
25701
+ // follow-up: no sandbox support in this WP — the e2b image doesn't carry
25702
+ // the mastra-agent adapter yet (see output/phase-a-findings.md A3). Thread
25703
+ // a `sandbox` field through to `spawnAgentSession` here once an image
25704
+ // provisions it (or `app_install`/boot does `agentproto install
25705
+ // mastra-agent` inside the box).
25706
+ },
25707
+ async (input) => {
25708
+ if (!resolveAgentAdapter) return notEnabled("app_run");
25709
+ const installed = appRegistry.getApp(input.appId);
25710
+ if (!installed) {
25711
+ return errorResult(`app_run: no installed app "${input.appId}" \u2014 call app_install first.`);
25712
+ }
25713
+ if (input.scopeId) {
25714
+ const applied = appRegistry.listApplied(input.scopeId);
25715
+ if (!applied.some((m) => m.appId === input.appId)) {
25716
+ return errorResult(
25717
+ `app_run: app "${input.appId}" is not applied to scope "${input.scopeId}". Call app_apply first.`
25718
+ );
25719
+ }
25720
+ }
25721
+ let refs;
25722
+ try {
25723
+ refs = await readAppRefs(installed.dir);
25724
+ } catch (err) {
25725
+ return errorResult(
25726
+ `app_run: could not re-read "${installed.dir}": ${err instanceof Error ? err.message : String(err)}`
25727
+ );
25728
+ }
25729
+ const app = appRegistry.upsertApp({ ...installed, agents: refs.agents, workflows: refs.workflows });
25730
+ const selected = input.agents ?? app.agents.map((a) => a.id);
25731
+ const unknown = selected.filter((id) => !app.agents.some((a) => a.id === id));
25732
+ if (unknown.length > 0) {
25733
+ return errorResult(
25734
+ `app_run: unknown agent id(s) for app "${input.appId}": ${unknown.join(", ")}`
25735
+ );
25736
+ }
25737
+ const sessions = [];
25738
+ const errors = [];
25739
+ for (const agentId of selected) {
25740
+ const agentPath = app.agents.find((a) => a.id === agentId).path;
25741
+ const result = await spawnAgentSession(
25742
+ { registry, resolveAgentAdapter },
25743
+ {
25744
+ adapter: DEFAULT_AGENT_ADAPTER,
25745
+ cwd: input.cwd ?? app.dir,
25746
+ ...input.prompt ? { prompt: input.prompt } : {},
25747
+ options: { agent: agentPath },
25748
+ label: `app:${app.appId}:${agentId}`
25749
+ }
25750
+ );
25751
+ if (result.ok) sessions.push({ agentId, sessionId: result.descriptor.id });
25752
+ else errors.push({ agentId, error: result.message });
25753
+ }
25754
+ const run = appRegistry.createRun({ appId: app.appId, sessions });
25755
+ return textResult({
25756
+ appRunId: run.appRunId,
25757
+ sessions,
25758
+ ...errors.length > 0 ? { errors } : {}
25759
+ });
25760
+ }
25761
+ );
25762
+ server.tool(
25763
+ "app_status",
25764
+ "Status of an app_run: its sessions' live descriptors, plus any workflow runs belonging to the app (any run of one of its bundled WORKFLOW.md files, however it was started).",
25765
+ { appRunId: z.string() },
25766
+ async (input) => {
25767
+ const run = appRegistry.getRun(input.appRunId);
25768
+ if (!run) return errorResult(`app_status: no app run "${input.appRunId}".`);
25769
+ const app = appRegistry.getApp(run.appId);
25770
+ const sessions = run.sessions.map((s) => ({
25771
+ agentId: s.agentId,
25772
+ sessionId: s.sessionId,
25773
+ descriptor: registry.get(s.sessionId)
25774
+ }));
25775
+ const workflowRuns = workflowRunner && app ? workflowRunner.list().filter((r) => app.workflows.some((w) => w.id === r.workflowId)) : [];
25776
+ return textResult({
25777
+ appRunId: run.appRunId,
25778
+ appId: run.appId,
25779
+ status: run.status,
25780
+ startedAt: run.startedAt,
25781
+ ...run.endedAt ? { endedAt: run.endedAt } : {},
25782
+ sessions,
25783
+ workflowRuns
25784
+ });
25785
+ }
25786
+ );
25787
+ server.tool(
25788
+ "app_stop",
25789
+ "Kill every session in an app_run (existing kill path) and mark the run ended.",
25790
+ { appRunId: z.string() },
25791
+ async (input) => {
25792
+ const run = appRegistry.getRun(input.appRunId);
25793
+ if (!run) return errorResult(`app_stop: no app run "${input.appRunId}".`);
25794
+ const killed = [];
25795
+ const notFound = [];
25796
+ for (const s of run.sessions) {
25797
+ if (registry.kill(s.sessionId)) killed.push(s.sessionId);
25798
+ else notFound.push(s.sessionId);
25799
+ }
25800
+ const ended = appRegistry.endRun(input.appRunId);
25801
+ return textResult({
25802
+ appRunId: input.appRunId,
25803
+ killed,
25804
+ ...notFound.length > 0 ? { notFound } : {},
25805
+ status: ended?.status ?? run.status
25806
+ });
25807
+ }
25808
+ );
25809
+ server.tool(
25810
+ "app_apply",
25811
+ "Apply an app to a scope, making its capabilities available in that scope. If the app is not installed and `dir` is provided, installs it first. Validates that all `requires` dependencies are already applied to the same scope. Idempotent \u2014 re-applying the same app to the same scope updates the timestamp.",
25812
+ {
25813
+ appId: z.string(),
25814
+ scopeId: z.string().optional().describe("Scope to apply to. Defaults to 'root'."),
25815
+ dir: z.string().optional().describe("Absolute path to install from if not already installed.")
25816
+ },
25817
+ async (input) => {
25818
+ const scopeId = input.scopeId ?? "root";
25819
+ let installed = appRegistry.getApp(input.appId);
25820
+ if (!installed && input.dir) {
25821
+ const installResult = await performInstall(input.dir, appRegistry, listRegisteredToolIds, resolveAgentAdapter);
25822
+ if (!installResult.ok) return errorResult(`app_apply: ${installResult.error}`);
25823
+ installed = installResult.record;
25824
+ } else if (!installed) {
25825
+ return errorResult(
25826
+ `app_apply: app "${input.appId}" is not installed. Either call app_install first or provide a 'dir' parameter.`
25827
+ );
25828
+ }
25829
+ if (installed.requires && installed.requires.length > 0) {
25830
+ const applied = appRegistry.listApplied(scopeId);
25831
+ const appliedIds = new Set(applied.map((m) => m.appId));
25832
+ const missing = installed.requires.filter((reqId) => !appliedIds.has(reqId));
25833
+ if (missing.length > 0) {
25834
+ return errorResult(
25835
+ `app_apply: app "${input.appId}" requires the following apps to be applied to scope "${scopeId}" first: ${missing.join(", ")}`
25836
+ );
25837
+ }
25838
+ }
25839
+ const mount = appRegistry.applyApp({ scopeId, appId: input.appId });
25840
+ return textResult({
25841
+ scopeId: mount.scopeId,
25842
+ appId: mount.appId,
25843
+ appliedAt: mount.appliedAt,
25844
+ agents: installed.agents,
25845
+ workflows: installed.workflows,
25846
+ unvalidatedAgentTools: installed.unvalidatedAgentTools
25847
+ });
25848
+ }
25849
+ );
25850
+ server.tool(
25851
+ "app_unapply",
25852
+ "Remove an app from a scope. Refuses if another applied app in the same scope requires this one.",
25853
+ {
25854
+ appId: z.string(),
25855
+ scopeId: z.string().optional().describe("Scope to unapply from. Defaults to 'root'.")
25856
+ },
25857
+ async (input) => {
25858
+ const scopeId = input.scopeId ?? "root";
25859
+ const applied = appRegistry.listApplied(scopeId);
25860
+ const dependents = [];
25861
+ for (const mount of applied) {
25862
+ if (mount.appId === input.appId) continue;
25863
+ const app = appRegistry.getApp(mount.appId);
25864
+ if (app?.requires?.includes(input.appId)) {
25865
+ dependents.push(mount.appId);
25866
+ }
25867
+ }
25868
+ if (dependents.length > 0) {
25869
+ return errorResult(
25870
+ `app_unapply: cannot unapply app "${input.appId}" from scope "${scopeId}" \u2014 the following apps in this scope require it: ${dependents.join(", ")}`
25871
+ );
25872
+ }
25873
+ const removed = appRegistry.unapplyApp({ scopeId, appId: input.appId });
25874
+ if (!removed) {
25875
+ return errorResult(`app_unapply: app "${input.appId}" is not applied to scope "${scopeId}".`);
25876
+ }
25877
+ return textResult({ scopeId: removed.scopeId, appId: removed.appId, appliedAt: removed.appliedAt });
25878
+ }
25879
+ );
25880
+ server.tool(
25881
+ "app_list_applied",
25882
+ "List applied mounts, optionally filtered by scope. Each mount is joined with its installed app summary.",
25883
+ {
25884
+ scopeId: z.string().optional().describe("Filter by scope. Omit to list all scopes.")
25885
+ },
25886
+ async (input) => {
25887
+ const mounts = appRegistry.listApplied(input.scopeId);
25888
+ const result = mounts.map((mount) => {
25889
+ const app = appRegistry.getApp(mount.appId);
25890
+ return {
25891
+ scopeId: mount.scopeId,
25892
+ appId: mount.appId,
25893
+ appliedAt: mount.appliedAt,
25894
+ ...app ? {
25895
+ agents: app.agents,
25896
+ workflows: app.workflows,
25897
+ unvalidatedAgentTools: app.unvalidatedAgentTools
25898
+ } : {}
25899
+ };
25900
+ });
25901
+ return textResult(result);
25902
+ }
25903
+ );
25904
+ }
25263
25905
  function createSessionEventBus() {
25264
25906
  const ee = new EventEmitter();
25265
25907
  ee.setMaxListeners(100);
@@ -25433,7 +26075,8 @@ var SessionsRegistryAgentHost = class {
25433
26075
  cwd,
25434
26076
  workspaceSlug,
25435
26077
  sandbox,
25436
- label: `agent-step:${adapter}`
26078
+ label: `agent-step:${adapter}`,
26079
+ ...opts.options !== void 0 ? { options: opts.options } : {}
25437
26080
  }
25438
26081
  );
25439
26082
  if (!result.ok) {
@@ -25452,7 +26095,8 @@ var SessionsRegistryAgentHost = class {
25452
26095
  env: {
25453
26096
  [SESSION_ID_ENV]: stepSessionId,
25454
26097
  [WORKSPACE_SLUG_ENV]: workspaceSlug
25455
- }
26098
+ },
26099
+ ...opts.options !== void 0 ? { options: opts.options } : {}
25456
26100
  });
25457
26101
  const desc = this.registry.spawnAgent({
25458
26102
  id: stepSessionId,
@@ -25648,16 +26292,32 @@ function translateStages(stages, workflowId) {
25648
26292
  const branches = stage.steps.map((step) => ({
25649
26293
  id: step.label,
25650
26294
  steps: [
25651
- {
25652
- kind: "agent",
25653
- id: step.label,
26295
+ buildAgentStep(step.label, {
26296
+ prompt: (b) => {
26297
+ const base = step.prompt ?? "";
26298
+ const prevTexts = [];
26299
+ if (b.steps && typeof b.steps === "object") {
26300
+ for (const [id, val] of Object.entries(b.steps)) {
26301
+ if (val && typeof val === "object" && "text" in val) {
26302
+ const text9 = val.text;
26303
+ if (text9) prevTexts.push(`[Output from step "${id}"]
26304
+ ${text9}`);
26305
+ }
26306
+ }
26307
+ }
26308
+ if (prevTexts.length > 0) return `${prevTexts.join("\n\n")}
26309
+
26310
+ ---
26311
+
26312
+ ${base}`;
26313
+ return base;
26314
+ },
25654
26315
  ...step.adapter !== void 0 ? { adapter: step.adapter } : {},
25655
26316
  ...step.sessionRef !== void 0 ? { sessionRef: step.sessionRef } : {},
25656
26317
  ...step.sandbox !== void 0 ? { sandbox: step.sandbox } : {},
25657
26318
  ...step.cacheable ? { cacheable: true } : {},
25658
- prompt: () => step.prompt ?? "",
25659
- policy: step.policy ?? { awaiting: "fail" }
25660
- }
26319
+ policy: step.policy
26320
+ })
25661
26321
  ]
25662
26322
  }));
25663
26323
  return {
@@ -25710,7 +26370,7 @@ function runtimeWorkflowToStages(workflow) {
25710
26370
  }
25711
26371
  ];
25712
26372
  }
25713
- var DEFAULT_PERSIST_PATH2 = () => join(homedir(), ".agentproto", "workflow-runs.json");
26373
+ var DEFAULT_PERSIST_PATH3 = () => join(homedir(), ".agentproto", "workflow-runs.json");
25714
26374
  function loadRuns(persistPath) {
25715
26375
  const result = /* @__PURE__ */ new Map();
25716
26376
  if (!existsSync(persistPath)) return result;
@@ -25825,7 +26485,7 @@ function fillStepStates(stages, defs, agents) {
25825
26485
  }
25826
26486
  return sessionIds;
25827
26487
  }
25828
- async function executeRunWorkflow(state, runtimeWf, agents, signal, cache, cacheKey, input) {
26488
+ async function executeRunWorkflow(state, runtimeWf, agents, signal, cache, cacheKey, input, persist) {
25829
26489
  try {
25830
26490
  await runWorkflow({
25831
26491
  workflow: runtimeWf,
@@ -25834,13 +26494,49 @@ async function executeRunWorkflow(state, runtimeWf, agents, signal, cache, cache
25834
26494
  cwd: state.cwd,
25835
26495
  workspaceSlug: state.workspaceSlug,
25836
26496
  input,
25837
- ...cache ? { cache, cacheKey } : {}
26497
+ ...cache ? { cache, cacheKey } : {},
26498
+ onStepStart: (stepId) => {
26499
+ for (const stage of state.run.stages) {
26500
+ const step = stage.steps.find((s) => s.label === stepId);
26501
+ if (step) {
26502
+ if (step.status === "pending") {
26503
+ step.status = "running";
26504
+ step.startedAt = (/* @__PURE__ */ new Date()).toISOString();
26505
+ }
26506
+ if (stage.status === "pending") {
26507
+ stage.status = "running";
26508
+ }
26509
+ persist?.();
26510
+ break;
26511
+ }
26512
+ }
26513
+ },
26514
+ onStepComplete: (stepId, output) => {
26515
+ for (const stage of state.run.stages) {
26516
+ const step = stage.steps.find((s) => s.label === stepId);
26517
+ if (step) {
26518
+ step.status = "done";
26519
+ step.endedAt = (/* @__PURE__ */ new Date()).toISOString();
26520
+ if (output && typeof output === "object" && "sessionId" in output) {
26521
+ step.sessionId = output.sessionId;
26522
+ }
26523
+ const allDone = stage.steps.every((s) => s.status === "done");
26524
+ if (allDone) {
26525
+ stage.status = "done";
26526
+ }
26527
+ persist?.();
26528
+ break;
26529
+ }
26530
+ }
26531
+ }
25838
26532
  });
25839
26533
  for (const stage of state.run.stages) {
25840
- stage.status = "done";
26534
+ if (stage.status !== "done") stage.status = "done";
25841
26535
  for (const step of stage.steps) {
25842
- step.status = "done";
25843
- step.endedAt = (/* @__PURE__ */ new Date()).toISOString();
26536
+ if (step.status !== "done") {
26537
+ step.status = "done";
26538
+ step.endedAt = (/* @__PURE__ */ new Date()).toISOString();
26539
+ }
25844
26540
  }
25845
26541
  }
25846
26542
  state.run.status = "done";
@@ -25875,7 +26571,7 @@ async function executeRunWorkflow(state, runtimeWf, agents, signal, cache, cache
25875
26571
  }
25876
26572
  function createWorkflowRunner(opts) {
25877
26573
  const { registry, sessionEvents, resolveAgentAdapter, compileWorkflow: compileWorkflow2 } = opts;
25878
- const persistPath = opts.persistPath ?? DEFAULT_PERSIST_PATH2();
26574
+ const persistPath = opts.persistPath ?? DEFAULT_PERSIST_PATH3();
25879
26575
  const shouldPersist = opts.persist ?? opts.persistPath !== void 0;
25880
26576
  const runs = shouldPersist ? loadRuns(persistPath) : /* @__PURE__ */ new Map();
25881
26577
  const persist = () => {
@@ -25926,7 +26622,7 @@ function createWorkflowRunner(opts) {
25926
26622
  }
25927
26623
  );
25928
26624
  const cache = input.cacheKey ? createFileStepCache(input.cacheKey) : void 0;
25929
- void executeRunWorkflow(state, workflow, agents, abort.signal, cache, input.cacheKey).then(() => {
26625
+ void executeRunWorkflow(state, workflow, agents, abort.signal, cache, input.cacheKey, void 0, persist).then(() => {
25930
26626
  persist();
25931
26627
  });
25932
26628
  return run;
@@ -25987,7 +26683,8 @@ function createWorkflowRunner(opts) {
25987
26683
  abort.signal,
25988
26684
  cache,
25989
26685
  args.cacheKey,
25990
- args.input
26686
+ args.input,
26687
+ persist
25991
26688
  ).then(() => {
25992
26689
  persist();
25993
26690
  });
@@ -26620,7 +27317,7 @@ function toDescriptor2(state) {
26620
27317
  spawned: state.spawned
26621
27318
  };
26622
27319
  }
26623
- var DEFAULT_PERSIST_PATH3 = () => join(homedir(), ".agentproto", "cron-jobs.json");
27320
+ var DEFAULT_PERSIST_PATH4 = () => join(homedir(), ".agentproto", "cron-jobs.json");
26624
27321
  var TICK_INTERVAL_MS = 2e4;
26625
27322
  function loadJobs(persistPath) {
26626
27323
  const result = /* @__PURE__ */ new Map();
@@ -26678,7 +27375,7 @@ function nextFireDate(cronInstance) {
26678
27375
  }
26679
27376
  function createCronScheduler(opts) {
26680
27377
  const { sessionEvents, registry, resolveAgentAdapter, dispatchTool, workspace } = opts;
26681
- const persistPath = opts.persistPath ?? DEFAULT_PERSIST_PATH3();
27378
+ const persistPath = opts.persistPath ?? DEFAULT_PERSIST_PATH4();
26682
27379
  const shouldPersist = opts.persist ?? opts.persistPath !== void 0;
26683
27380
  const jobs = shouldPersist ? loadJobs(persistPath) : /* @__PURE__ */ new Map();
26684
27381
  for (const state of jobs.values()) {
@@ -27123,79 +27820,6 @@ function createRoutineRegistrar(opts) {
27123
27820
  }
27124
27821
  };
27125
27822
  }
27126
- function collectToolIds(steps, out) {
27127
- for (const step of steps) {
27128
- switch (step.kind) {
27129
- case "tool": {
27130
- const ref = typeof step.tool === "string" ? step.tool : step.tool.entry;
27131
- out.add(normalizeToolId(ref));
27132
- break;
27133
- }
27134
- case "map":
27135
- case "loop":
27136
- collectToolIds(step.steps, out);
27137
- break;
27138
- case "parallel":
27139
- for (const branch of step.branches) collectToolIds(branch.steps, out);
27140
- break;
27141
- }
27142
- }
27143
- }
27144
- function passthroughToolHandle(id) {
27145
- return {
27146
- id,
27147
- name: id,
27148
- description: `Daemon tool '${id}', dispatched via the routine/cron dispatchTool bridge.`,
27149
- mutates: [],
27150
- requires: { network: [], secrets: [], tools: [] },
27151
- approval: "auto",
27152
- riskLevel: 0,
27153
- costClass: "trivial",
27154
- timeoutMs: 3e4,
27155
- tags: [],
27156
- metadata: {},
27157
- idempotent: false,
27158
- driverConstraints: { forbid: [], requireKind: [] }
27159
- };
27160
- }
27161
- function unwrapDispatchResult(result) {
27162
- if (!result || typeof result !== "object" || !("content" in result)) return result;
27163
- const r = result;
27164
- const text9 = (r.content ?? []).map((c) => c.text ?? "").join("\n").trim();
27165
- if (r.isError) throw new Error(text9 || "tool call failed");
27166
- if (!text9) return void 0;
27167
- try {
27168
- return JSON.parse(text9);
27169
- } catch {
27170
- return text9;
27171
- }
27172
- }
27173
- function createDaemonToolRegistry(handle, dispatchTool) {
27174
- const toolIds = /* @__PURE__ */ new Set();
27175
- collectToolIds(handle.steps, toolIds);
27176
- const tools = {};
27177
- const execute = {};
27178
- for (const id of toolIds) {
27179
- tools[id] = passthroughToolHandle(id);
27180
- execute[id] = async ({ input }) => unwrapDispatchResult(await dispatchTool(id, input ?? {}));
27181
- }
27182
- if (toolIds.size === 0) return { tools, candidates: [] };
27183
- const daemonDriver = {
27184
- id: "daemon-tool-dispatch",
27185
- name: "Daemon tool dispatch",
27186
- description: "Catch-all AIP-30 provider routing every referenced WORKFLOW.md tool step through the daemon's dispatchTool.",
27187
- kind: "builtin",
27188
- implements: [...toolIds].map((id) => ({ tool: id, version: "*" })),
27189
- execute,
27190
- install: [],
27191
- network: { egress: [], ingress: [] },
27192
- region: ["global"],
27193
- policyTags: [],
27194
- tags: [],
27195
- metadata: {}
27196
- };
27197
- return { tools, candidates: [daemonDriver] };
27198
- }
27199
27823
  var DEFAULT_ORCHESTRATOR_TOOLS = [
27200
27824
  "agent_start",
27201
27825
  "agent_prompt",
@@ -30776,6 +31400,13 @@ async function createGateway(opts) {
30776
31400
  }
30777
31401
  return dispatchToolBox.fn(name, inputs);
30778
31402
  };
31403
+ const listToolIdsBox = {};
31404
+ const listRegisteredToolIds = async () => {
31405
+ if (!listToolIdsBox.fn) {
31406
+ throw new Error("app_install: tool registry not ready yet (daemon still booting)");
31407
+ }
31408
+ return listToolIdsBox.fn();
31409
+ };
30779
31410
  const cronScheduler = createCronScheduler({
30780
31411
  sessionEvents,
30781
31412
  registry: sessions,
@@ -30789,6 +31420,7 @@ async function createGateway(opts) {
30789
31420
  cronScheduler,
30790
31421
  dispatchTool
30791
31422
  });
31423
+ const appRegistry = createAppRegistry();
30792
31424
  const workflowRunner = opts.resolveAgentAdapter ? createWorkflowRunner({
30793
31425
  registry: sessions,
30794
31426
  sessionEvents,
@@ -30805,7 +31437,13 @@ async function createGateway(opts) {
30805
31437
  // the same `dispatchTool` the routine registrar / cron scheduler use
30806
31438
  // (see `workflow-tool-registry.ts`). Agent-step workflows are
30807
31439
  // unaffected: an empty tool registry compiles them exactly as before.
30808
- compileWorkflow: (handle) => compileWorkflow(handle, createDaemonToolRegistry(handle, dispatchTool))
31440
+ // `agentRefs` resolves a declarative agent-step's `agent.ref` against
31441
+ // whichever installed app bundles this workflow id (undefined when
31442
+ // none does — a plain `workflow_run_file` outside any app).
31443
+ compileWorkflow: (handle) => compileWorkflow(handle, {
31444
+ ...createDaemonToolRegistry(handle, dispatchTool),
31445
+ agentRefs: resolveAgentRefsForWorkflow(appRegistry, handle.id)
31446
+ })
30809
31447
  }) : void 0;
30810
31448
  const operatorWorkspaceSlug = await loadWorkspacesConfig().then((cfg) => getActiveWorkspace(cfg)?.slug).catch(() => void 0);
30811
31449
  const taskLedger = createTaskLedger({
@@ -30981,6 +31619,13 @@ async function createGateway(opts) {
30981
31619
  endpointStore: inboundEndpointStore,
30982
31620
  telegramCreds: telegramBotCreds
30983
31621
  });
31622
+ registerAppTools(server, {
31623
+ registry: sessions,
31624
+ listRegisteredToolIds,
31625
+ appRegistry,
31626
+ ...opts.resolveAgentAdapter ? { resolveAgentAdapter: opts.resolveAgentAdapter } : {},
31627
+ ...workflowRunner ? { workflowRunner } : {}
31628
+ });
30984
31629
  registerTelegramBotTools(server, { telegramCreds: telegramBotCreds });
30985
31630
  const listSessionsFiltered = (filter) => {
30986
31631
  let rows = sessions.list().filter((s) => s.kind !== "command");
@@ -31078,6 +31723,12 @@ async function createGateway(opts) {
31078
31723
  }
31079
31724
  return tool.handler(inputs, {});
31080
31725
  };
31726
+ listToolIdsBox.fn = async () => {
31727
+ if (!internalToolServerPromise) internalToolServerPromise = mcpServerFactory();
31728
+ const internalServer = await internalToolServerPromise;
31729
+ const internal = internalServer;
31730
+ return Object.keys(internal._registeredTools ?? {});
31731
+ };
31081
31732
  try {
31082
31733
  routineRegistrar.reconcile();
31083
31734
  } catch (err) {
@@ -31136,6 +31787,9 @@ async function createGateway(opts) {
31136
31787
  activityProjector,
31137
31788
  taskLedger,
31138
31789
  ...workflowRunner ? { workflowRunner } : {},
31790
+ appRegistry,
31791
+ performAppInstall: performInstall,
31792
+ listRegisteredToolIds,
31139
31793
  // POST /inbound push ingress — same shared transmitterBindings store
31140
31794
  // and liveness/restart adapters the inbound watcher's "route"/
31141
31795
  // "route-or-spawn" modes use above. No `spawnForContact`: an