@openagents-org/agent-launcher 0.2.193 → 0.2.195

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/src/mcp-server.js CHANGED
@@ -22,6 +22,8 @@ const _activeTunnels = {};
22
22
 
23
23
  // ── Tool definitions ────────────────────────────────────────────────────────
24
24
 
25
+ const v11 = require('./tools-v11');
26
+
25
27
  function buildToolDefs(disabledModules) {
26
28
  const tools = [
27
29
  // -- Workspace core (always enabled) --
@@ -73,6 +75,21 @@ function buildToolDefs(disabledModules) {
73
75
  required: ['file_id'],
74
76
  },
75
77
  },
78
+ {
79
+ name: 'workspace_publish_project',
80
+ description: 'Publish a static web project as one previewable artifact. Include its HTML, images, CSS, JavaScript and data using relative paths. No servers or dependencies are installed. Exclude secrets and hidden files. Reuse project_id and expected_revision to update while keeping its link and pins.',
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: {
84
+ title: { type: 'string' }, entrypoint: { type: 'string', description: 'Relative HTML entry page, usually index.html' },
85
+ project_id: { type: 'string' }, expected_revision: { type: 'string', description: 'Revision returned by the last publish or preview' },
86
+ files: { type: 'array', maxItems: 500, items: { type: 'object', properties: {
87
+ path: { type: 'string' }, content: { type: 'string', description: 'UTF-8 text, or use content_base64 for binary files' },
88
+ content_base64: { type: 'string' },
89
+ }, required: ['path'] } },
90
+ }, required: ['title', 'files'],
91
+ },
92
+ },
76
93
  {
77
94
  name: 'workspace_write_file',
78
95
  description: 'Write/upload a file to shared workspace storage.',
@@ -442,6 +459,40 @@ function buildToolDefs(disabledModules) {
442
459
  );
443
460
  }
444
461
 
462
+ // -- Approvals (always on: a governance primitive, not a skill) --
463
+ tools.push(
464
+ {
465
+ name: 'workspace_request_approval',
466
+ description: 'Ask a human for permission BEFORE doing something consequential: deploying to production, spending money or calling paid APIs, sending email/messages to customers or external parties, deleting data, or any irreversible action you are unsure about. Workspace policy decides: the request may be auto-approved, auto-blocked, or paused until a person with the right role approves it in the thread. This call blocks (up to wait_seconds) until a decision arrives and returns it. If it returns "pending", stop and wait — the decision will arrive as a message in this thread.',
467
+ inputSchema: {
468
+ type: 'object',
469
+ properties: {
470
+ action: { type: 'string', description: 'Short label of what you want to do, e.g. "Deploy 2.4 hotfix to prod-eu" or "Email 38 affected customers"' },
471
+ kind: {
472
+ type: 'string',
473
+ enum: ['deploy', 'spend', 'external_send', 'repo_read', 'repo_write', 'data_delete', 'shell', 'other'],
474
+ description: 'Category the policy is keyed on. deploy=production deploys; spend=money/paid APIs; external_send=email/messages leaving the workspace; repo_read/repo_write=git; data_delete=destructive data ops; shell=commands with side effects; other=anything else',
475
+ },
476
+ details: { type: 'string', description: 'The exact command, diff summary, recipient list, URL or amount — what a reviewer needs to decide' },
477
+ risk: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Your own risk estimate' },
478
+ wait_seconds: { type: 'integer', description: 'How long to wait for a decision before returning "pending" (default 300, max 900)' },
479
+ },
480
+ required: ['action', 'kind'],
481
+ },
482
+ },
483
+ {
484
+ name: 'workspace_check_approval',
485
+ description: 'Check the status of an approval request you made earlier (by id). Use after workspace_request_approval returned "pending".',
486
+ inputSchema: {
487
+ type: 'object',
488
+ properties: {
489
+ approval_id: { type: 'string', description: 'The approval id returned by workspace_request_approval' },
490
+ },
491
+ required: ['approval_id'],
492
+ },
493
+ },
494
+ );
495
+
445
496
  // -- Knowledge Base --
446
497
  if (!disabledModules.has('knowledge')) {
447
498
  tools.push(
@@ -489,9 +540,37 @@ function buildToolDefs(disabledModules) {
489
540
  );
490
541
  }
491
542
 
543
+ // -- v1.1 milestone tools (always on) --
544
+ tools.push(...v11.toolDefs());
545
+
492
546
  return tools;
493
547
  }
494
548
 
549
+ // ── Approvals ───────────────────────────────────────────────────────────────
550
+
551
+ /**
552
+ * What the agent reads back from an approval. The verdict comes first so a
553
+ * model skimming the tool result cannot miss it.
554
+ */
555
+ function formatApproval(a) {
556
+ const who = a.resolved_by === 'policy'
557
+ ? 'workspace policy'
558
+ : a.resolved_by ? `${a.resolved_by}${a.resolved_by_role ? ` (${a.resolved_by_role})` : ''}` : null;
559
+ const note = a.note ? `\nNote from approver: ${a.note}` : '';
560
+ switch (a.status) {
561
+ case 'approved':
562
+ return `APPROVED — "${a.action}" was approved by ${who}. You may proceed.${note}\n(approval id: ${a.id})`;
563
+ case 'rejected':
564
+ return `REJECTED — "${a.action}" was rejected by ${who}. Do NOT do it. Explain in the thread what you would have done and ask how to proceed.${note}\n(approval id: ${a.id})`;
565
+ case 'expired':
566
+ return `EXPIRED — nobody decided on "${a.action}" in time. Do not proceed; ask in the thread.\n(approval id: ${a.id})`;
567
+ default: {
568
+ const role = a.required_role === 'admin' ? 'an Admin' : a.required_role === 'owner' ? 'the Owner' : 'a workspace member';
569
+ return `PENDING — "${a.action}" is waiting for ${role} to approve. Do NOT proceed with this action. Stop here and end your turn; the decision will arrive as a new message in this thread (@mentioning you), or call workspace_check_approval with id ${a.id} later.`;
570
+ }
571
+ }
572
+ }
573
+
495
574
  // ── MIME type detection ─────────────────────────────────────────────────────
496
575
 
497
576
  const MIME_MAP = {
@@ -521,12 +600,13 @@ function isTextMime(mime) {
521
600
  // ── MCP Server ──────────────────────────────────────────────────────────────
522
601
 
523
602
  class McpServer {
524
- constructor({ wsClient, workspaceId, channelName, agentName, token, disabledModules }) {
603
+ constructor({ wsClient, workspaceId, channelName, agentName, token, disabledModules, requesterEmail = process.env.OPENAGENTS_REQUESTER_EMAIL || null }) {
525
604
  this.ws = wsClient;
526
605
  this.workspaceId = workspaceId;
527
606
  this.channelName = channelName;
528
607
  this.agentName = agentName;
529
608
  this.token = token;
609
+ this.requesterEmail = requesterEmail;
530
610
  this.disabledModules = disabledModules || new Set();
531
611
  this.tools = buildToolDefs(this.disabledModules);
532
612
  }
@@ -662,6 +742,20 @@ class McpServer {
662
742
  return text(str);
663
743
  }
664
744
 
745
+ case 'workspace_publish_project': {
746
+ const channel = this.channelName && !this.channelName.startsWith('dm:') ? this.channelName : undefined;
747
+ const files = args.files.map(file => {
748
+ if (typeof file.content_base64 !== 'string' && typeof file.content !== 'string') throw new Error('Each file needs content or content_base64');
749
+ return { path:file.path, content_base64:file.content_base64 ?? Buffer.from(file.content,'utf8').toString('base64') };
750
+ });
751
+ const result = await this.ws.publishWebProject(this.workspaceId, this.token, {
752
+ title:args.title, entrypoint:args.entrypoint || 'index.html', files,
753
+ channel_name:channel, visibility:channel ? 'inherit' : 'private',
754
+ source:`openagents:${this.agentName}`, expected_revision:args.expected_revision,
755
+ }, args.project_id);
756
+ return text(`Project published (id: ${result.id}, revision: ${result.revision}). Link it in your reply: [${args.title}](/v1/files/${encodeURIComponent(result.id)})`);
757
+ }
758
+
665
759
  case 'workspace_write_file': {
666
760
  const mime = args.content_type || detectMime(args.filename);
667
761
  let b64;
@@ -674,9 +768,10 @@ class McpServer {
674
768
  contentType: mime,
675
769
  source: `openagents:${this.agentName}`,
676
770
  channelName: this.channelName,
771
+ visibility: this.channelName ? 'inherit' : undefined,
677
772
  });
678
773
  const fileId = result.id || result.file_id || 'unknown';
679
- return text(`File written: ${args.filename} (id: ${fileId})`);
774
+ return text(`File written: ${args.filename} (id: ${fileId}). Link it in your reply: [${args.filename}](/v1/files/${encodeURIComponent(fileId)})`);
680
775
  }
681
776
 
682
777
  case 'workspace_delete_file': {
@@ -745,6 +840,7 @@ class McpServer {
745
840
  const opts = {
746
841
  url: args.url || 'about:blank',
747
842
  source: `openagents:${this.agentName}`,
843
+ channel: this.channelName,
748
844
  };
749
845
  // Resolve context_name to context_id
750
846
  if (args.context_name) {
@@ -767,29 +863,29 @@ class McpServer {
767
863
  }
768
864
 
769
865
  case 'workspace_browser_navigate': {
770
- const result = await this.ws.browserNavigate(this.workspaceId, this.token, args.tab_id, args.url);
866
+ const result = await this.ws.browserNavigate(this.workspaceId, this.token, args.tab_id, args.url, { source: `openagents:${this.agentName}`, channel: this.channelName });
771
867
  const title = result.title || '';
772
868
  return text(`Navigated to: ${args.url}${title ? ` (title: ${title})` : ''}`);
773
869
  }
774
870
 
775
871
  case 'workspace_browser_click': {
776
- const result = await this.ws.browserClick(this.workspaceId, this.token, args.tab_id, args.selector);
872
+ const result = await this.ws.browserClick(this.workspaceId, this.token, args.tab_id, args.selector, { source: `openagents:${this.agentName}`, channel: this.channelName });
777
873
  return text(`Clicked: ${args.selector}${result.url ? ` (url now: ${result.url})` : ''}`);
778
874
  }
779
875
 
780
876
  case 'workspace_browser_type': {
781
- await this.ws.browserType(this.workspaceId, this.token, args.tab_id, args.selector, args.text);
877
+ await this.ws.browserType(this.workspaceId, this.token, args.tab_id, args.selector, args.text, { source: `openagents:${this.agentName}`, channel: this.channelName });
782
878
  return text(`Typed into ${args.selector}: ${args.text.slice(0, 50)}${args.text.length > 50 ? '...' : ''}`);
783
879
  }
784
880
 
785
881
  case 'workspace_browser_screenshot': {
786
- const buf = await this.ws.browserScreenshot(this.workspaceId, this.token, args.tab_id);
882
+ const buf = await this.ws.browserScreenshot(this.workspaceId, this.token, args.tab_id, { source: `openagents:${this.agentName}`, channel: this.channelName });
787
883
  const b64 = Buffer.isBuffer(buf) ? buf.toString('base64') : buf;
788
884
  return image(b64, 'image/png');
789
885
  }
790
886
 
791
887
  case 'workspace_browser_snapshot': {
792
- const snapshot = await this.ws.browserSnapshot(this.workspaceId, this.token, args.tab_id);
888
+ const snapshot = await this.ws.browserSnapshot(this.workspaceId, this.token, args.tab_id, { source: `openagents:${this.agentName}`, channel: this.channelName });
793
889
  return text(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot));
794
890
  }
795
891
 
@@ -1039,6 +1135,35 @@ class McpServer {
1039
1135
  return text(lines.join('\n'));
1040
1136
  }
1041
1137
 
1138
+ // ── Approvals ──
1139
+
1140
+ case 'workspace_request_approval': {
1141
+ const waitSec = Math.max(0, Math.min(900, Number(args.wait_seconds) || 300));
1142
+ let approval = await this.ws.createApproval(this.workspaceId, this.token, {
1143
+ channel: this.channelName,
1144
+ kind: args.kind || 'other',
1145
+ action: args.action,
1146
+ details: args.details,
1147
+ risk: args.risk,
1148
+ source: `openagents:${this.agentName}`,
1149
+ });
1150
+ const deadline = Date.now() + waitSec * 1000;
1151
+ while (approval.status === 'pending' && Date.now() < deadline) {
1152
+ await new Promise((r) => setTimeout(r, 3000));
1153
+ try {
1154
+ approval = await this.ws.getApproval(this.workspaceId, this.token, approval.id);
1155
+ } catch (e) {
1156
+ this._log(`approval poll failed: ${e.message}`);
1157
+ }
1158
+ }
1159
+ return text(formatApproval(approval));
1160
+ }
1161
+
1162
+ case 'workspace_check_approval': {
1163
+ const approval = await this.ws.getApproval(this.workspaceId, this.token, args.approval_id);
1164
+ return text(formatApproval(approval));
1165
+ }
1166
+
1042
1167
  // ── Knowledge Base ──
1043
1168
 
1044
1169
  case 'workspace_list_knowledge': {
@@ -1087,8 +1212,11 @@ class McpServer {
1087
1212
  return text(`Knowledge entry deleted: ${args.entry_id}`);
1088
1213
  }
1089
1214
 
1090
- default:
1215
+ default: {
1216
+ const handled = await v11.dispatch(this, name, args, { text, image });
1217
+ if (handled !== undefined) return handled;
1091
1218
  throw new Error(`Unknown tool: ${name}`);
1219
+ }
1092
1220
  }
1093
1221
  }
1094
1222
 
@@ -1114,7 +1242,8 @@ function jsonRpcError(id, code, message) {
1114
1242
  // ── Entry point (called from cli.js) ────────────────────────────────────────
1115
1243
 
1116
1244
  function runMcpServer(opts) {
1117
- const wsClient = new WorkspaceClient(opts.endpoint);
1245
+ // Every tool call (incl. tools-v11 via server.ws) is made for opts.agentName.
1246
+ const wsClient = new WorkspaceClient(opts.endpoint, { agentName: opts.agentName });
1118
1247
  const server = new McpServer({
1119
1248
  wsClient,
1120
1249
  workspaceId: opts.workspaceId,
@@ -98,7 +98,7 @@ function recordPairing(nodeKey, pairing) {
98
98
  const stamped = { ...pairing, paired_at: new Date().toISOString() };
99
99
  const pairings = [
100
100
  stamped,
101
- ...pairingsOf(readNode()).filter((p) => p.workspace_id !== pairing.workspace_id),
101
+ ...pairingsOf(readNode()).filter((p) => pairing.node_id && p.node_id ? p.node_id !== pairing.node_id : p.workspace_id !== pairing.workspace_id),
102
102
  ];
103
103
  saveNode({ node_key: nodeKey, ...stamped, pairings });
104
104
  }
@@ -116,17 +116,19 @@ function recordPairing(nodeKey, pairing) {
116
116
  * @param workspaceId which pairing to drop; defaults to the top-level one.
117
117
  * @returns the pairing that was dropped, or null if there was no such pairing.
118
118
  */
119
- function clearPairing(workspaceId) {
119
+ function clearPairing(workspaceId, nodeId) {
120
120
  const record = readNode();
121
121
  if (!record) return null;
122
122
  const target = workspaceId || record.workspace_id;
123
123
  if (!target) return null;
124
124
 
125
125
  const pairings = pairingsOf(record);
126
- const dropped = pairings.find((p) => p.workspace_id === target);
126
+ const candidates = pairings.filter((p) => p.workspace_id === target);
127
+ if (!nodeId && candidates.length > 1) throw new Error("Choose a specific device connection");
128
+ const dropped = candidates.find((p) => !nodeId || p.node_id === nodeId);
127
129
  if (!dropped) return null;
128
130
 
129
- const remaining = pairings.filter((p) => p.workspace_id !== target);
131
+ const remaining = pairings.filter((p) => p !== dropped);
130
132
  saveNode({ node_key: record.node_key, ...remaining[0], pairings: remaining });
131
133
  return {
132
134
  node_id: dropped.node_id,
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The placeholder an npm package ships where its native binary goes.
5
+ *
6
+ * opencode-ai and @anthropic-ai/claude-code are both published this way: the
7
+ * package's `bin` (bin/opencode.exe, bin/claude.exe) is a few hundred bytes of
8
+ * shell that prints "postinstall … not run" and exits 1, and the package's
9
+ * postinstall script swaps in the real binary from a per-platform optional
10
+ * dependency. Skip that script — `ignore-scripts=true` in an npmrc,
11
+ * `--ignore-scripts`, pnpm and bun by default — and the install still exits 0
12
+ * with the placeholder sitting where the CLI should be. It resolves on PATH
13
+ * and it has the right name, so nothing about it looks like a missing install:
14
+ * the launcher reports the agent installed, and every run fails on whatever
15
+ * check happens to come next.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ // A native CLI is tens of megabytes; the placeholders are about 500 bytes.
22
+ const STUB_MAX_BYTES = 4096;
23
+ const STUB_SIGNATURE = /postinstall (script )?(was|did) not run|native binary not installed/i;
24
+
25
+ /** Whether text a CLI printed is its package's placeholder talking. */
26
+ function isPostinstallStubOutput(text) {
27
+ return STUB_SIGNATURE.test(String(text || ''));
28
+ }
29
+
30
+ function isStubFile(file) {
31
+ try {
32
+ const st = fs.statSync(file);
33
+ if (!st.isFile() || st.size > STUB_MAX_BYTES) return false;
34
+ return STUB_SIGNATURE.test(fs.readFileSync(file, 'utf-8'));
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * The files an npm `.cmd` shim launches. The shim is a wrapper npm wrote
42
+ * beside the package, never the placeholder itself — the placeholder is what
43
+ * it points at (`"%dp0%\node_modules\opencode-ai\bin\opencode.exe" %*`).
44
+ */
45
+ function cmdShimTargets(file) {
46
+ if (!/\.cmd$/i.test(file)) return [];
47
+ try {
48
+ if (fs.statSync(file).size > STUB_MAX_BYTES) return [];
49
+ const dir = path.dirname(file);
50
+ const text = fs.readFileSync(file, 'utf-8');
51
+ return [...text.matchAll(/"%(?:~dp0|dp0%)\\?([^"\r\n]+)"/g)]
52
+ .map((m) => path.join(dir, ...m[1].split(/[\\/]+/)));
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Whether `file` is such a placeholder — directly, through the symlink npm
60
+ * puts in `.bin`, or through a Windows `.cmd` shim. Read off disk rather than
61
+ * by running it: on Windows the placeholder is shell text named `.exe`, and
62
+ * starting it raises a "this app can't run on your PC" dialog.
63
+ */
64
+ function isPostinstallStub(file) {
65
+ if (!file) return false;
66
+ return [file, ...cmdShimTargets(file)].some(isStubFile);
67
+ }
68
+
69
+ module.exports = { isPostinstallStub, isPostinstallStubOutput };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+ /**
3
+ * v1.1 M5 — the shared work brief.
4
+ *
5
+ * Every thread has one brief (objective, owner, latest result, open
6
+ * questions, next step) that people read instead of scrolling the
7
+ * transcript. Agents keep it current after each meaningful step.
8
+ *
9
+ * workspace_get_brief { channel? }
10
+ * workspace_update_brief { objective?, latest_result?, open_questions?, next_step?, owner? }
11
+ *
12
+ * Only the fields passed to update are changed; the backend records the
13
+ * writer as `openagents:<agent>`.
14
+ */
15
+
16
+ const defs = [
17
+ {
18
+ name: 'workspace_get_brief',
19
+ description:
20
+ 'Read the shared work brief of a thread: objective, owner of the next step, latest result, ' +
21
+ 'open questions and next step, plus who directs the thread (director_email). Defaults to the current thread.',
22
+ inputSchema: {
23
+ type: 'object',
24
+ properties: {
25
+ channel: { type: 'string', description: 'Thread id (default: the current thread)' },
26
+ },
27
+ },
28
+ },
29
+ {
30
+ name: 'workspace_update_brief',
31
+ description:
32
+ 'Update the current thread\'s shared work brief. Pass only the fields that changed. ' +
33
+ 'Call it after each meaningful step so people can see where the work stands without reading the transcript.',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ objective: { type: 'string', description: 'What this thread is trying to achieve (one or two sentences)' },
38
+ latest_result: { type: 'string', description: 'The most recent concrete outcome (what was produced / verified)' },
39
+ open_questions: { type: 'array', items: { type: 'string' }, description: 'Questions blocking or shaping the work. Replaces the list.' },
40
+ next_step: { type: 'string', description: 'The very next action' },
41
+ owner: { type: 'string', description: 'Who owns the next step: "openagents:<agent>" or "human:<email>"' },
42
+ },
43
+ },
44
+ },
45
+ ];
46
+
47
+ const FIELDS = ['objective', 'latest_result', 'open_questions', 'next_step', 'owner'];
48
+
49
+ function renderBrief(b) {
50
+ if (!b) return 'No brief yet.';
51
+ const lines = [`Brief for ${b.channel || 'this thread'}:`];
52
+ if (b.director_email) lines.push(`Directed by: ${b.director_email}`);
53
+ lines.push(`Objective: ${b.objective || '—'}`);
54
+ lines.push(`Owner of next step: ${b.owner || '—'}`);
55
+ lines.push(`Latest result: ${b.latest_result || '—'}`);
56
+ const qs = Array.isArray(b.open_questions) ? b.open_questions : [];
57
+ lines.push(qs.length ? `Open questions:\n${qs.map((q) => ` - ${q}`).join('\n')}` : 'Open questions: —');
58
+ lines.push(`Next step: ${b.next_step || '—'}`);
59
+ if (b.updated_by) lines.push(`Updated by ${b.updated_by}${b.updated_at ? ` at ${b.updated_at}` : ''}`);
60
+ return lines.join('\n');
61
+ }
62
+
63
+ async function handle(server, name, args, helpers) {
64
+ const { text } = helpers;
65
+ args = args || {};
66
+
67
+ if (name === 'workspace_get_brief') {
68
+ const channel = (args.channel && String(args.channel).trim()) || server.channelName;
69
+ if (!channel) return text('No thread in scope — pass `channel`.');
70
+ const brief = await server.ws.getBrief(server.workspaceId, server.token, channel);
71
+ return text(renderBrief(brief));
72
+ }
73
+
74
+ if (name === 'workspace_update_brief') {
75
+ if (!server.channelName) return text('No thread in scope — the brief belongs to a thread.');
76
+ const fields = {};
77
+ for (const k of FIELDS) {
78
+ if (args[k] === undefined) continue;
79
+ if (k === 'open_questions') {
80
+ fields[k] = Array.isArray(args[k]) ? args[k].map((q) => String(q)) : [];
81
+ } else {
82
+ fields[k] = args[k] === null ? null : String(args[k]);
83
+ }
84
+ }
85
+ if (Object.keys(fields).length === 0) {
86
+ return text('Nothing to update — pass at least one of: ' + FIELDS.join(', ') + '.');
87
+ }
88
+ const brief = await server.ws.putBrief(server.workspaceId, server.token, server.channelName, fields, {
89
+ source: `openagents:${server.agentName}`,
90
+ });
91
+ return text(`Brief updated (${Object.keys(fields).join(', ')}).\n${renderBrief(brief)}`);
92
+ }
93
+
94
+ return undefined;
95
+ }
96
+
97
+ module.exports = { defs, handle };
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ /**
3
+ * Roadmap v1.1 workspace tools — one module per milestone so parallel work
4
+ * never edits the same file. Each module exports:
5
+ * defs : MCP tool definitions (array)
6
+ * handle : async (server, name, args, helpers) => result | undefined
7
+ * `server` is the McpServer instance (ws, workspaceId, channelName,
8
+ * agentName, token, _log); `helpers` is { text, image }.
9
+ */
10
+ const modules = [
11
+ require('./presence-handoff'), // M3/M6 — presence detail + structured hand-off
12
+ require('./brief'), // M5 — shared work brief
13
+ ];
14
+
15
+ function toolDefs() {
16
+ return modules.flatMap((m) => m.defs || []);
17
+ }
18
+
19
+ async function dispatch(server, name, args, helpers) {
20
+ for (const m of modules) {
21
+ if (!m.handle) continue;
22
+ const r = await m.handle(server, name, args, helpers);
23
+ if (r !== undefined) return r;
24
+ }
25
+ return undefined;
26
+ }
27
+
28
+ module.exports = { toolDefs, dispatch };