@volter/supercode-orchestrator 0.4.4
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/README.md +532 -0
- package/activation.mjs +270 -0
- package/adapters/api-server.mjs +615 -0
- package/adapters/chat-sdk.mjs +475 -0
- package/adapters/chunks.mjs +14 -0
- package/adapters/loopback.mjs +37 -0
- package/adapters/rh2.mjs +349 -0
- package/apply/doors.d.ts +2 -0
- package/apply/doors.mjs +292 -0
- package/apply/hermes_door.py +129 -0
- package/apply/index.d.ts +4 -0
- package/apply/index.mjs +441 -0
- package/apply/state.mjs +142 -0
- package/bin/hermes-worker.mjs +11 -0
- package/bin/orchestrator.mjs +730 -0
- package/board.mjs +81 -0
- package/commands.mjs +37 -0
- package/cron.mjs +967 -0
- package/events.mjs +107 -0
- package/gates.mjs +281 -0
- package/hermes-cli.mjs +440 -0
- package/hermes-home.mjs +264 -0
- package/hermes-locks.mjs +188 -0
- package/hosted.mjs +81 -0
- package/index.d.ts +508 -0
- package/index.mjs +38 -0
- package/ir.mjs +205 -0
- package/kanban-worker.mjs +155 -0
- package/lease.mjs +51 -0
- package/loop.mjs +864 -0
- package/mcp/bridge.mjs +133 -0
- package/mcp/hermes-kanban-tools.json +477 -0
- package/mcp/hermes-tools.json +288 -0
- package/mcp/mount.mjs +161 -0
- package/mcp/server.mjs +160 -0
- package/mcp/tools.mjs +57 -0
- package/mcp/verbs.mjs +348 -0
- package/ontology.d.ts +557 -0
- package/operator.mjs +426 -0
- package/orchestration.mjs +166 -0
- package/package.json +51 -0
- package/reducer.mjs +792 -0
- package/webhook.mjs +263 -0
- package/worker-route.mjs +122 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "cronjob",
|
|
4
|
+
"description": "Manage scheduled cron jobs: action='create' schedules a job from a prompt and/or skills; 'list' inspects jobs; 'update'/'pause'/'resume'/'remove' manage one by job_id (always list first — never guess job IDs); 'run' fires a job immediately in the BACKGROUND (returns a handle at once, outcome re-enters the conversation when done — do not wait or poll; optional 'prompt' adds transient context for that fire only).\n\nJobs run in a fresh session with no current-chat context, so prompts must be self-contained, and the agent's FINAL RESPONSE is what gets delivered — cron runs are autonomous and cannot ask questions. Prefer updating an existing job over creating near-duplicates.",
|
|
5
|
+
"inputSchema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"action": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "One of: create, list, update, pause, resume, remove, run. When action=create, the 'schedule' and 'prompt' fields are REQUIRED."
|
|
11
|
+
},
|
|
12
|
+
"job_id": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"description": "Required for update/pause/resume/remove/run"
|
|
15
|
+
},
|
|
16
|
+
"prompt": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "For create: the full self-contained prompt (paired with any skills as the task instruction). For run: optional transient context for that single fire (never persisted)."
|
|
19
|
+
},
|
|
20
|
+
"schedule": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "REQUIRED for create. Schedule forms: (1) recurring interval — '30m', 'every 2h', 'every hour' (EVERY 30 minutes / 2 hours / hour, forever by default); (2) explicit one-shot by duration — 'in 30m', 'in 2h' (fires ONCE that far from now; use this for 'remind me in N minutes' — do NOT hand-compute an absolute timestamp); (3) natural day/time — 'every monday 9am', 'weekdays at 9am', 'every day at 9am' (recurring weekly/daily); (4) cron syntax — '0 9 * * *' (daily 9am); (5) absolute one-shot — ISO timestamp '2026-06-01T09:00:00'."
|
|
23
|
+
},
|
|
24
|
+
"name": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"description": "Optional human-friendly name"
|
|
27
|
+
},
|
|
28
|
+
"repeat": {
|
|
29
|
+
"type": "integer",
|
|
30
|
+
"description": "Optional repeat count. Omit for defaults (once for one-shot, forever for recurring)."
|
|
31
|
+
},
|
|
32
|
+
"deliver": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Where the job's output is POSTED as a one-way message (the job itself always runs in a fresh session with no chat context). Omit to address the chat/topic this job was created from. Otherwise: 'local' (save only, no delivery), 'all' (every connected home channel, resolved at fire time), 'bot-chat' or 'bot-chat:<profile>' (inject into a Bot Chat as a real message), or platform:chat_id:thread_id (e.g. 'telegram:-1001234567890:17585'). Comma-combine like 'origin,all'."
|
|
35
|
+
},
|
|
36
|
+
"skills": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "string"
|
|
40
|
+
},
|
|
41
|
+
"description": "Optional ordered skill names loaded before the cron prompt. On update, [] clears."
|
|
42
|
+
},
|
|
43
|
+
"script": {
|
|
44
|
+
"type": "string",
|
|
45
|
+
"description": "Optional script run each tick; stdout is injected into the agent's prompt as context (with no_agent=True the script IS the job). Relative paths resolve under the home's scripts/; .sh/.bash via bash, else Python. On update, '' clears."
|
|
46
|
+
},
|
|
47
|
+
"monitor": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "Optional change-detector that gates the agent: an http(s) URL (fetched each tick) or a script path (same rules as `script`, run each tick) — cheap, no LLM. Output identical to the previous tick skips the agent run entirely; changed output wakes the agent with a diff injected into the prompt. First tick always runs (baseline). Output must be deterministic (no timestamps) or every tick looks changed. Incompatible with no_agent. On update, '' clears."
|
|
50
|
+
},
|
|
51
|
+
"no_agent": {
|
|
52
|
+
"type": "boolean",
|
|
53
|
+
"default": false,
|
|
54
|
+
"description": "True = no LLM: the scheduler runs `script` (required) on schedule and delivers its stdout verbatim; empty stdout sends nothing (watchdog pattern). Use for script-only pings with fixed output; keep False for anything needing reasoning."
|
|
55
|
+
},
|
|
56
|
+
"context_from": {
|
|
57
|
+
"type": "array",
|
|
58
|
+
"items": {
|
|
59
|
+
"type": "string"
|
|
60
|
+
},
|
|
61
|
+
"description": "Optional job ID(s) whose most recent completed output is injected as context each run — chains jobs (A collects, B processes). For a job's OWN previous output prefer `continuity`. On update, [] clears."
|
|
62
|
+
},
|
|
63
|
+
"continuity": {
|
|
64
|
+
"type": "boolean",
|
|
65
|
+
"description": "True = each run sees the job's own previous output, so it can dedupe and continue where it left off (scouts, monitors, incremental digests). Default false. On update, false turns it off."
|
|
66
|
+
},
|
|
67
|
+
"enabled_toolsets": {
|
|
68
|
+
"type": "array",
|
|
69
|
+
"items": {
|
|
70
|
+
"type": "string"
|
|
71
|
+
},
|
|
72
|
+
"description": "Optional toolset names to restrict the job's agent to (e.g. [\"web\", \"terminal\"]) — cuts token overhead. Infer from the prompt. Omit for all default tools. On update, [] clears."
|
|
73
|
+
},
|
|
74
|
+
"workdir": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"description": "Optional absolute existing path to run the job from: injects that directory's AGENTS.md/context files and anchors terminal/file tools there. On update, '' clears."
|
|
77
|
+
},
|
|
78
|
+
"attach_to_session": {
|
|
79
|
+
"type": "boolean",
|
|
80
|
+
"description": "True = the job's delivery is CONTINUABLE — the user can reply and the agent has the brief in context (threads on thread-capable platforms, mirrored into the DM elsewhere). Use for conversational recurring jobs (briefings); leave unset for fire-and-forget alerts. Scope: the job's own conversation only — the origin chat, the home-channel fallback when deliver='origin' captured no origin (script-created jobs), or the job's single explicit platform:chat target (this flag is the only way to attach an explicit target). Broadcast targets are never attached; no effect when deliver='local'."
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"required": [
|
|
84
|
+
"action"
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"name": "send_message",
|
|
90
|
+
"description": "Send a message to a connected messaging platform, or list available targets.\n\nIMPORTANT: When the user asks to send to a specific channel or person (not just a bare platform name), call send_message(action='list') FIRST to see available targets, then send to the correct one.\nIf the user just says a platform name like 'send to telegram', send directly to the home channel without listing first.",
|
|
91
|
+
"inputSchema": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"properties": {
|
|
94
|
+
"action": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"enum": [
|
|
97
|
+
"send",
|
|
98
|
+
"list",
|
|
99
|
+
"react",
|
|
100
|
+
"unreact"
|
|
101
|
+
],
|
|
102
|
+
"description": "Action to perform. 'send' (default) sends a message. 'list' returns all available channels/contacts across connected platforms. 'react' attaches an emoji reaction to a message (platforms that support it, e.g. photon/iMessage tapbacks). 'unreact' retracts a previously-added reaction."
|
|
103
|
+
},
|
|
104
|
+
"target": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics and Discord threads. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'signal:+155****4567', 'matrix:!roomid:server.org', 'matrix:@user:server.org', 'ntfy:alerts-channel' (explicit ntfy topic), 'yuanbao:direct:<account_id>' (DM), 'yuanbao:group:<group_code>' (group chat)"
|
|
107
|
+
},
|
|
108
|
+
"message": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"description": "The message text to send. To send an image or file, include MEDIA:<local_path> (e.g. 'MEDIA:/tmp/report.pdf') in the message — the platform will deliver it as a native media attachment."
|
|
111
|
+
},
|
|
112
|
+
"emoji": {
|
|
113
|
+
"type": "string",
|
|
114
|
+
"description": "For action='react': the emoji to react with (e.g. '❤️'). On iMessage, ❤️👍👎😂‼️❓ render as native tapbacks; other emoji use custom-emoji reactions."
|
|
115
|
+
},
|
|
116
|
+
"message_id": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"description": "For action='react'/'unreact': id of the message to react to. Omit to target the most recent message received in that chat (usually the one being replied to)."
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"required": []
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"name": "session_search",
|
|
126
|
+
"description": "Search past Hermes sessions (FTS5 over the local session DB), or read/scroll inside one. Four shapes, picked by args: `query` = discovery (top-N matching sessions, top result fully hydrated); `session_id` + `around_message_id` = scroll (window of messages around an anchor); `session_id` alone = read a whole session — how you resolve an `@session:<profile>/<id>` link (split on '/' into profile + id); no args = browse recent sessions. Results are actual DB messages, no LLM. Searches conversation history ONLY — when the user gave a direct source (URL, file, contact, live system), inspect that first; never conclude 'not found' from history alone. Use for questions about past conversations: 'what did we do about X', 'where did we leave Y'. When referring the user to a session, write its `link` value verbatim inline (it renders as a titled link).",
|
|
127
|
+
"inputSchema": {
|
|
128
|
+
"type": "object",
|
|
129
|
+
"properties": {
|
|
130
|
+
"query": {
|
|
131
|
+
"type": "string",
|
|
132
|
+
"description": "Search query (discovery shape). Keywords, phrases, or boolean expressions to find in past sessions. Omit to browse recent sessions. Ignored when session_id + around_message_id are set (scroll shape)."
|
|
133
|
+
},
|
|
134
|
+
"limit": {
|
|
135
|
+
"type": "integer",
|
|
136
|
+
"description": "Discovery shape only. Max sessions to return (default 3, max 10). Bump to 5–10 when the topic likely spans several sessions and you want to pick the right one to scroll into.",
|
|
137
|
+
"default": 3
|
|
138
|
+
},
|
|
139
|
+
"sort": {
|
|
140
|
+
"type": "string",
|
|
141
|
+
"enum": [
|
|
142
|
+
"newest",
|
|
143
|
+
"oldest"
|
|
144
|
+
],
|
|
145
|
+
"description": "Discovery shape only. Temporal bias on top of FTS5 ranking: omit for relevance-only (exploratory recall), 'newest' for \"where did we leave X\", 'oldest' for \"how did X start\"."
|
|
146
|
+
},
|
|
147
|
+
"detail": {
|
|
148
|
+
"type": "string",
|
|
149
|
+
"enum": [
|
|
150
|
+
"adaptive",
|
|
151
|
+
"full"
|
|
152
|
+
],
|
|
153
|
+
"description": "Discovery shape only. 'adaptive' (default) fully hydrates the top-ranked result and returns only the exact anchor message for lower-ranked results. 'full' returns bookends and the complete anchored window for every result.",
|
|
154
|
+
"default": "adaptive"
|
|
155
|
+
},
|
|
156
|
+
"session_id": {
|
|
157
|
+
"type": "string",
|
|
158
|
+
"description": "Scroll shape. Session to read inside. Use the session_id returned from a prior discovery call. Must be paired with around_message_id."
|
|
159
|
+
},
|
|
160
|
+
"around_message_id": {
|
|
161
|
+
"type": "integer",
|
|
162
|
+
"description": "Scroll shape. Message id to center the window on — use match_message_id from a discovery result, or any id from a prior window."
|
|
163
|
+
},
|
|
164
|
+
"window": {
|
|
165
|
+
"type": "integer",
|
|
166
|
+
"description": "Scroll shape only. Messages to return on each side of the anchor (anchor itself always included). Clamped to [1, 20]. Default 5.",
|
|
167
|
+
"default": 5
|
|
168
|
+
},
|
|
169
|
+
"role_filter": {
|
|
170
|
+
"type": "string",
|
|
171
|
+
"description": "Optional. Comma-separated roles to include. Discovery defaults to 'user,assistant' (tool output is usually noise). Pass 'user,assistant,tool' to include tool output (debugging tool behaviour) or 'tool' to search tool output only."
|
|
172
|
+
},
|
|
173
|
+
"profile": {
|
|
174
|
+
"type": "string",
|
|
175
|
+
"description": "Optional. Read sessions from another Hermes profile's database (read-only). Use when resolving an `@session:<profile>/<id>` link: pass the profile segment here with session_id as the id segment. Omit to use the current profile."
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
"required": []
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"name": "memory",
|
|
183
|
+
"description": "Save durable facts to persistent memory that survive across sessions. Memory is injected into every future turn, so keep entries compact and high-signal.\n\nHOW: make ALL your changes in ONE call via an 'operations' array (each item: {action, content?, old_text?}). The batch applies atomically and the char limit is checked only on the FINAL result — so a single call can remove/replace stale entries to free room AND add new ones, even when an add alone would overflow. The response reports current/limit chars and confirms completion; one batch call finishes the update, so don't repeat it. Use the bare action/content/old_text fields only for a single lone change.\n\nWHEN: save proactively when the user states a preference, correction, or personal detail, or you learn a stable fact about their environment, conventions, or workflow. Priority: user preferences & corrections > environment facts > procedures. The best memory stops the user repeating themselves.\n\nIF FULL: an add is rejected with the current entries shown. Reissue as ONE batch that removes or shortens enough stale entries and adds the new one together.\n\nTARGETS: 'user' = who the user is (name, role, preferences, style). 'memory' = your notes (environment, conventions, tool quirks, lessons).\n\nSKIP: trivial/obvious info, easily re-discovered facts, raw data dumps, task progress, completed-work logs, temporary TODO state (use session_search for those). Reusable procedures belong in a skill, not memory.",
|
|
184
|
+
"inputSchema": {
|
|
185
|
+
"type": "object",
|
|
186
|
+
"properties": {
|
|
187
|
+
"action": {
|
|
188
|
+
"type": "string",
|
|
189
|
+
"enum": [
|
|
190
|
+
"add",
|
|
191
|
+
"replace",
|
|
192
|
+
"remove"
|
|
193
|
+
],
|
|
194
|
+
"description": "The action to perform (single-op shape). Omit when using 'operations'."
|
|
195
|
+
},
|
|
196
|
+
"target": {
|
|
197
|
+
"type": "string",
|
|
198
|
+
"enum": [
|
|
199
|
+
"memory",
|
|
200
|
+
"user"
|
|
201
|
+
],
|
|
202
|
+
"description": "Which memory store: 'memory' for personal notes, 'user' for user profile."
|
|
203
|
+
},
|
|
204
|
+
"content": {
|
|
205
|
+
"type": "string",
|
|
206
|
+
"description": "The entry content. Required for 'add' and 'replace' (single-op shape). Alias: 'new_text' is also accepted (mirrors old_text)."
|
|
207
|
+
},
|
|
208
|
+
"old_text": {
|
|
209
|
+
"type": "string",
|
|
210
|
+
"description": "REQUIRED for 'replace' and 'remove' (single-op shape): a short unique substring identifying the existing entry to modify. Omit only for 'add'."
|
|
211
|
+
},
|
|
212
|
+
"new_text": {
|
|
213
|
+
"type": "string",
|
|
214
|
+
"description": "Alias for 'content' (single-op shape). Provided so the replace/remove old_text/new_text pairing works; if both are set, 'content' wins."
|
|
215
|
+
},
|
|
216
|
+
"operations": {
|
|
217
|
+
"type": "array",
|
|
218
|
+
"description": "Batch shape: a list of operations applied atomically in one call against the final char budget. Preferred when making multiple changes or consolidating to make room. Each item is {action, content?, old_text?}.",
|
|
219
|
+
"items": {
|
|
220
|
+
"type": "object",
|
|
221
|
+
"properties": {
|
|
222
|
+
"action": {
|
|
223
|
+
"type": "string",
|
|
224
|
+
"enum": [
|
|
225
|
+
"add",
|
|
226
|
+
"replace",
|
|
227
|
+
"remove"
|
|
228
|
+
]
|
|
229
|
+
},
|
|
230
|
+
"content": {
|
|
231
|
+
"type": "string",
|
|
232
|
+
"description": "Entry content for add/replace. Alias: 'new_text'."
|
|
233
|
+
},
|
|
234
|
+
"new_text": {
|
|
235
|
+
"type": "string",
|
|
236
|
+
"description": "Alias for 'content' in a batch op."
|
|
237
|
+
},
|
|
238
|
+
"old_text": {
|
|
239
|
+
"type": "string",
|
|
240
|
+
"description": "Substring identifying the entry for replace/remove."
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
"required": [
|
|
244
|
+
"action"
|
|
245
|
+
]
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
"required": [
|
|
250
|
+
"target"
|
|
251
|
+
]
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"name": "skills_list",
|
|
256
|
+
"description": "List available skills (name + description). Use skill_view(name) to load full content.",
|
|
257
|
+
"inputSchema": {
|
|
258
|
+
"type": "object",
|
|
259
|
+
"properties": {
|
|
260
|
+
"category": {
|
|
261
|
+
"type": "string",
|
|
262
|
+
"description": "Optional category filter to narrow results"
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
"required": []
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"name": "skill_view",
|
|
270
|
+
"description": "Skills allow for loading information about specific tasks and workflows, as well as scripts and templates. Load a skill's full content or access its linked files (references, templates, scripts). First call returns SKILL.md content plus a 'linked_files' dict showing available references/templates/scripts. To access those, call again with file_path parameter.",
|
|
271
|
+
"inputSchema": {
|
|
272
|
+
"type": "object",
|
|
273
|
+
"properties": {
|
|
274
|
+
"name": {
|
|
275
|
+
"type": "string",
|
|
276
|
+
"description": "The skill name (use skills_list to see available skills). For plugin-provided skills, use the qualified form 'plugin:skill' (e.g. 'superpowers:writing-plans')."
|
|
277
|
+
},
|
|
278
|
+
"file_path": {
|
|
279
|
+
"type": "string",
|
|
280
|
+
"description": "OPTIONAL: Path to a linked file within the skill (e.g., 'references/api.md', 'templates/config.yaml', 'scripts/validate.py'). Omit to get the main SKILL.md content."
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"required": [
|
|
284
|
+
"name"
|
|
285
|
+
]
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
]
|
package/mcp/mount.mjs
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Where the orchestrator mounts its MCP server in each worker harness
|
|
2
|
+
// (docs/ORCHESTRATOR-IR.md §7; §5's `RuntimeStart` row).
|
|
3
|
+
//
|
|
4
|
+
// The door table and the per-door writers are the harness SDK's
|
|
5
|
+
// (`@volter/supercode-harness-sdk/mcp-doors`, which carries each door's
|
|
6
|
+
// inventory citation); this module keeps what is the orchestrator's own: its
|
|
7
|
+
// server's binding, and the profile's own servers mounted beside it.
|
|
8
|
+
//
|
|
9
|
+
// Only the flag and ACP doors are per-SESSION: they are named at launch. The
|
|
10
|
+
// codex and opencode doors are per-config-home, i.e. per profile, so the
|
|
11
|
+
// per-session half of the binding (`SUPERCODE_ORCHESTRATOR_SURFACE`) is not
|
|
12
|
+
// written into them — the MCP server is spawned BY the worker and inherits the
|
|
13
|
+
// worker's own environment, which `activationEnv` already set per session.
|
|
14
|
+
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import {
|
|
19
|
+
acpEntry, doorFor, opencodeEntry, readOpencodeConfig, removeTomlBlock, serverToml, tomlString, upsertTomlBlock, withMcpConfigFlag, writeMcpConfigFile,
|
|
20
|
+
} from '@volter/supercode-harness-sdk/mcp-doors';
|
|
21
|
+
import { ORCH_ENV } from '../activation.mjs';
|
|
22
|
+
import { SERVER_NAME } from './tools.mjs';
|
|
23
|
+
import { SOCKET_ENV, socketPathFor } from './server.mjs';
|
|
24
|
+
import { mcpServers } from '../hermes-home.mjs';
|
|
25
|
+
import { KANBAN_ENV } from '../hermes-cli.mjs';
|
|
26
|
+
|
|
27
|
+
export { MCP_DOORS, doorFor } from '@volter/supercode-harness-sdk/mcp-doors';
|
|
28
|
+
|
|
29
|
+
/** The stdio server this module mounts. */
|
|
30
|
+
export const SERVER_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'server.mjs');
|
|
31
|
+
|
|
32
|
+
const slug = (surfaceKey) => (surfaceKey ? String(surfaceKey).replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'session' : 'session');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Mount the orchestrator's MCP server for one worker session.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} o
|
|
38
|
+
* @param {string} o.root orchestrator root
|
|
39
|
+
* @param {object} o.profile the Profile (its `dir` and `name` are used)
|
|
40
|
+
* @param {string} o.harness worker harness id
|
|
41
|
+
* @param {string|null} o.surfaceKey the surface this session is bound to
|
|
42
|
+
* @param {object|undefined} o.launch the launch the loop resolved, if any
|
|
43
|
+
* @param {string} [o.socketPath] defaults to `<root>/orchestrator.sock`
|
|
44
|
+
* @param {string} [o.serverPath] defaults to this package's `mcp/server.mjs`
|
|
45
|
+
* @param {string} [o.program] the interpreter, defaults to this node
|
|
46
|
+
* @returns {{door: string, ran: string, launch: object|undefined, mcp_servers: object[]|null, path: string|null}}
|
|
47
|
+
*/
|
|
48
|
+
export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null, launch, socketPath, serverPath = SERVER_PATH, program = process.execPath, vault = new Map(), task = null }) {
|
|
49
|
+
const door = doorFor(harness);
|
|
50
|
+
// the profile's own servers (`.mcp.json`, the workers' form; docs/plans/hermes-compat.md row 4), mounted
|
|
51
|
+
// beside this one through the same door, `${VAR}` resolved from the profile's vault
|
|
52
|
+
const own = resolveServers(mcpServers(profile.dir), vault);
|
|
53
|
+
const sock = socketPath ?? socketPathFor(root);
|
|
54
|
+
const home = join(profile.dir, harness);
|
|
55
|
+
const env = {
|
|
56
|
+
[ORCH_ENV.root]: root,
|
|
57
|
+
[ORCH_ENV.profile]: profile.name,
|
|
58
|
+
[SOCKET_ENV]: sock,
|
|
59
|
+
};
|
|
60
|
+
// a dispatched worker's task (`task`, Hermes's KANBAN_ENV) rides with the session: the door publishes the
|
|
61
|
+
// board's tools for it (docs/plans/hermes-compat.md row 14)
|
|
62
|
+
const sessionEnv = { ...env, ...(surfaceKey ? { [ORCH_ENV.surface]: surfaceKey } : {}), ...(task ?? {}) };
|
|
63
|
+
|
|
64
|
+
switch (door) {
|
|
65
|
+
case 'flag': {
|
|
66
|
+
if (!launch) {
|
|
67
|
+
return { door, ran: `${harness} takes its MCP config as a launch flag, but no launch was resolved`, launch, mcp_servers: null, path: null };
|
|
68
|
+
}
|
|
69
|
+
const file = join(home, `mcp-orchestrator-${slug(surfaceKey)}.json`);
|
|
70
|
+
mkdirSync(home, { recursive: true });
|
|
71
|
+
writeMcpConfigFile(file, { ...own, [SERVER_NAME]: { command: program, args: [serverPath], env: sessionEnv } });
|
|
72
|
+
return { door, ran: `wrote ${file} and passed --mcp-config`, launch: withMcpConfigFlag(launch, file), mcp_servers: null, path: file };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case 'toml': {
|
|
76
|
+
const file = join(home, 'config.toml');
|
|
77
|
+
mkdirSync(home, { recursive: true });
|
|
78
|
+
const block = tomlBlock(program, serverPath, env);
|
|
79
|
+
let text = upsertTomlBlock(existsSync(file) ? readFileSync(file, 'utf8') : '', block, SERVER_NAME);
|
|
80
|
+
const written = ownNames(home);
|
|
81
|
+
for (const name of written) if (!(name in own)) text = removeTomlBlock(text, name);
|
|
82
|
+
for (const [name, server] of Object.entries(own)) {
|
|
83
|
+
text = upsertTomlBlock(text, serverToml(name, server, fullTrust(profile, name)), name);
|
|
84
|
+
}
|
|
85
|
+
writeFileSync(file, text, { mode: 0o600 });
|
|
86
|
+
recordOwnNames(home, Object.keys(own));
|
|
87
|
+
return { door, ran: `wrote [mcp_servers.${[SERVER_NAME, ...Object.keys(own)].join('], [mcp_servers.')}] into ${file}`, launch, mcp_servers: null, path: file };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
case 'json': {
|
|
91
|
+
const file = join(home, 'opencode.json');
|
|
92
|
+
mkdirSync(home, { recursive: true });
|
|
93
|
+
const config = readOpencodeConfig(file);
|
|
94
|
+
config.mcp = { ...(config.mcp ?? {}) };
|
|
95
|
+
for (const name of ownNames(home)) if (!(name in own)) delete config.mcp[name];
|
|
96
|
+
for (const [name, server] of Object.entries(own)) config.mcp[name] = opencodeEntry(server);
|
|
97
|
+
recordOwnNames(home, Object.keys(own));
|
|
98
|
+
config.mcp[SERVER_NAME] = opencodeEntry({ command: program, args: [serverPath], env });
|
|
99
|
+
writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
100
|
+
return { door, ran: `wrote mcp.${SERVER_NAME} into ${file}`, launch, mcp_servers: null, path: file };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
case 'none':
|
|
104
|
+
return { door, ran: `${harness} has no MCP door (it deliberately ships no MCP client); nothing was mounted`, launch, mcp_servers: null, path: null };
|
|
105
|
+
|
|
106
|
+
case 'acp':
|
|
107
|
+
default:
|
|
108
|
+
return {
|
|
109
|
+
door: 'acp',
|
|
110
|
+
ran: `passed ${SERVER_NAME} to session/new's mcpServers`,
|
|
111
|
+
launch,
|
|
112
|
+
mcp_servers: [
|
|
113
|
+
...Object.entries(own).filter(([, server]) => server.command).map(([name, server]) => acpEntry(name, server)),
|
|
114
|
+
acpEntry(SERVER_NAME, { command: program, args: [serverPath], env: sessionEnv }),
|
|
115
|
+
],
|
|
116
|
+
path: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The orchestrator's own `[mcp_servers.orchestrator]` block. */
|
|
122
|
+
function tomlBlock(program, serverPath, env) {
|
|
123
|
+
const lines = [
|
|
124
|
+
`[mcp_servers.${SERVER_NAME}]`,
|
|
125
|
+
`command = ${tomlString(program)}`,
|
|
126
|
+
`args = [${tomlString(serverPath)}]`,
|
|
127
|
+
];
|
|
128
|
+
for (const [k, v] of Object.entries(env)) lines.push(`env.${k} = ${tomlString(v)}`);
|
|
129
|
+
// The per-session half rides codex's own env allowlist: the server is a child
|
|
130
|
+
// of the worker, whose environment the loop set for this session.
|
|
131
|
+
lines.push(`env_vars = [${[ORCH_ENV.surface, ...KANBAN_ENV].map(tomlString).join(', ')}]`);
|
|
132
|
+
// the orchestrator's door is Hermes's own tools, which Hermes does not gate; Codex's app-server otherwise
|
|
133
|
+
// asks its client about each MCP call and rejects the ones nobody answers (measured: `kanban_show` in a
|
|
134
|
+
// dispatched worker came back "user rejected MCP tool call")
|
|
135
|
+
lines.push('default_tools_approval_mode = "approve"');
|
|
136
|
+
return `${lines.join('\n')}\n`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `${VAR}` in a server's strings, from the profile's vault, else this process's environment. */
|
|
140
|
+
function resolveServers(servers, vault) {
|
|
141
|
+
const sub = (v) => (typeof v === 'string' ? v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => vault.get?.(name) ?? process.env[name] ?? m) : v);
|
|
142
|
+
const walk = (v) => (Array.isArray(v) ? v.map(walk) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])) : sub(v));
|
|
143
|
+
return Object.fromEntries(Object.entries(servers).filter(([name]) => name !== SERVER_NAME).map(([name, server]) => [name, walk(server)]));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The profile servers this door last wrote into a config home, so a server dropped from `.mcp.json` is removed. */
|
|
147
|
+
const OWN_FILE = '.mcp-from-profile.json';
|
|
148
|
+
function ownNames(home) { try { return JSON.parse(readFileSync(join(home, OWN_FILE), 'utf8')); } catch { return []; } }
|
|
149
|
+
function recordOwnNames(home, names) { writeFileSync(join(home, OWN_FILE), JSON.stringify(names)); }
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Hermes's trust for one of the profile's own servers (`mcp_servers.<name>.trust` in `config.yaml`, read as
|
|
153
|
+
* Hermes's `_normalize_server_trust` reads it): unset is full; `untrusted`, or any other word, is not, and
|
|
154
|
+
* Hermes asks before its write-capable tools run. A full-trust server's tools run unasked, as under Hermes;
|
|
155
|
+
* an untrusted one keeps Codex's own default, where a call nobody approves is rejected: stricter than Hermes,
|
|
156
|
+
* which would ask, never looser.
|
|
157
|
+
*/
|
|
158
|
+
function fullTrust(profile, name) {
|
|
159
|
+
const trust = profile?.residue?.config?.mcp_servers?.[name]?.trust;
|
|
160
|
+
return trust === undefined || trust === null || String(trust).trim().toLowerCase() === 'full';
|
|
161
|
+
}
|
package/mcp/server.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The orchestrator's model-facing door: ONE stdio MCP server per worker
|
|
3
|
+
// session, bound to the conversation that spawned it (docs/ORCHESTRATOR-IR.md
|
|
4
|
+
// §7).
|
|
5
|
+
//
|
|
6
|
+
// It is a thin process. Everything it can do is a line on the orchestrator's
|
|
7
|
+
// own Unix socket (`mcp/bridge.mjs`); it holds no state and reads no file of
|
|
8
|
+
// the model's world. The binding — which profile, which surface — arrives in
|
|
9
|
+
// the environment the loop already sets (`activationEnv`), never in a tool
|
|
10
|
+
// argument, so a worker cannot address another conversation by asking.
|
|
11
|
+
//
|
|
12
|
+
// The framing is minimal by hand, no SDK: newline-delimited JSON-RPC 2.0 over
|
|
13
|
+
// stdin/stdout, `initialize` -> `tools/list` -> `tools/call`, mirroring what
|
|
14
|
+
// `crates/harness/src/mcp.rs::handle_request` accepts (notifications carry no
|
|
15
|
+
// id and get no reply; an unknown method is -32601). That module and
|
|
16
|
+
// `supercode mcp serve` are a different server for a different purpose and are
|
|
17
|
+
// untouched by this one.
|
|
18
|
+
|
|
19
|
+
import { createInterface } from 'node:readline';
|
|
20
|
+
import { connect } from 'node:net';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { KANBAN_TOOLS, PROTOCOL_VERSION, SERVER_NAME, TOOLS } from './tools.mjs';
|
|
23
|
+
import { KANBAN_ENV, hermesTool } from '../hermes-cli.mjs';
|
|
24
|
+
|
|
25
|
+
/** Env var naming the socket, when the loop wants one off the root's default. */
|
|
26
|
+
export const SOCKET_ENV = 'SUPERCODE_ORCHESTRATOR_SOCK';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The loop's socket for a root: `<root>/orchestrator.sock`, or SOCKET_ENV where
|
|
30
|
+
* the embedding keeps the socket off the root (a root on a network filesystem
|
|
31
|
+
* cannot hold a Unix socket). The CLI's door derives the same path.
|
|
32
|
+
*/
|
|
33
|
+
export function socketPathFor(root, env = process.env) {
|
|
34
|
+
return env[SOCKET_ENV] || join(root, 'orchestrator.sock');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The binding this server was spawned with, from the loop's activation env (and a dispatched task's). */
|
|
38
|
+
export function bindingFromEnv(env = process.env) {
|
|
39
|
+
const root = env.SUPERCODE_ORCHESTRATOR_ROOT;
|
|
40
|
+
const profile = env.SUPERCODE_ORCHESTRATOR_PROFILE;
|
|
41
|
+
const surface = env.SUPERCODE_ORCHESTRATOR_SURFACE ?? null;
|
|
42
|
+
if (!root || !profile) throw new Error('the orchestrator MCP server needs SUPERCODE_ORCHESTRATOR_ROOT and _PROFILE');
|
|
43
|
+
const kanban = env.HERMES_KANBAN_TASK ? Object.fromEntries(KANBAN_ENV.filter((k) => env[k]).map((k) => [k, env[k]])) : null;
|
|
44
|
+
return { root, profile, surface, socket: env[SOCKET_ENV] || socketPathFor(root), kanban };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The profile's folder: the root is the `default` profile, `profiles/<name>` the rest (a Hermes home's layout). */
|
|
48
|
+
const profileDir = (binding) => (binding.profile === 'default' ? binding.root : join(binding.root, 'profiles', binding.profile));
|
|
49
|
+
|
|
50
|
+
/** The tools this door publishes: the orchestrator's, and the board's where a dispatch gave it a task. */
|
|
51
|
+
const published = (binding) => (binding.kanban ? [...TOOLS, ...KANBAN_TOOLS] : TOOLS);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A board tool, answered by Hermes's own handler in this process: the task, its run and its claim are
|
|
55
|
+
* this worker's environment, as they are a Hermes worker's, never the daemon's.
|
|
56
|
+
*/
|
|
57
|
+
async function kanbanCall(binding, name, args) {
|
|
58
|
+
try {
|
|
59
|
+
const out = await hermesTool(profileDir(binding), name, args, { env: binding.kanban });
|
|
60
|
+
return { ok: true, result: { ran: name, verbatim: typeof out === 'string' ? out : JSON.stringify(out) } };
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return { ok: false, error: error.message };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One request on the bridge socket: connect, write one JSON line, read one
|
|
68
|
+
* back. A connection per call keeps this process stateless and means a
|
|
69
|
+
* restarted loop is picked up on the next tool call, not never.
|
|
70
|
+
*/
|
|
71
|
+
export function ask(binding, verb, args) {
|
|
72
|
+
return new Promise((resolve, reject) => {
|
|
73
|
+
const socket = connect(binding.socket);
|
|
74
|
+
let buffer = '';
|
|
75
|
+
const fail = (error) => { socket.destroy(); reject(error); };
|
|
76
|
+
socket.setEncoding('utf8');
|
|
77
|
+
socket.on('error', fail);
|
|
78
|
+
socket.on('connect', () => {
|
|
79
|
+
socket.write(`${JSON.stringify({ verb, args, profile: binding.profile, surface: binding.surface })}\n`);
|
|
80
|
+
});
|
|
81
|
+
socket.on('data', (chunk) => {
|
|
82
|
+
buffer += chunk;
|
|
83
|
+
const nl = buffer.indexOf('\n');
|
|
84
|
+
if (nl < 0) return;
|
|
85
|
+
socket.end();
|
|
86
|
+
try { resolve(JSON.parse(buffer.slice(0, nl))); } catch (error) { reject(error); }
|
|
87
|
+
});
|
|
88
|
+
socket.on('close', () => { if (!buffer.includes('\n')) reject(new Error('the orchestrator closed the connection without answering')); });
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `crates/harness/src/mcp.rs::handle_request`'s shape, in JavaScript. */
|
|
93
|
+
export async function handleRequest(binding, request) {
|
|
94
|
+
const id = request?.id;
|
|
95
|
+
const method = request?.method ?? '';
|
|
96
|
+
const reply = (result) => ({ jsonrpc: '2.0', id, result });
|
|
97
|
+
switch (method) {
|
|
98
|
+
case 'initialize':
|
|
99
|
+
return reply({
|
|
100
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
101
|
+
capabilities: { tools: {} },
|
|
102
|
+
serverInfo: { name: `supercode-${SERVER_NAME}`, version: '1' },
|
|
103
|
+
});
|
|
104
|
+
case 'tools/list':
|
|
105
|
+
return reply({ tools: published(binding).map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })) });
|
|
106
|
+
case 'tools/call': {
|
|
107
|
+
const name = request?.params?.name ?? '';
|
|
108
|
+
const args = request?.params?.arguments ?? {};
|
|
109
|
+
if (!published(binding).some((t) => t.name === name)) {
|
|
110
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `unknown tool \`${name}\`` } };
|
|
111
|
+
}
|
|
112
|
+
let answer;
|
|
113
|
+
if (name.startsWith('kanban_')) answer = await kanbanCall(binding, name, args);
|
|
114
|
+
else {
|
|
115
|
+
try { answer = await ask(binding, name, args); }
|
|
116
|
+
catch (error) { answer = { ok: false, error: `the orchestrator is not answering: ${error.message}` }; }
|
|
117
|
+
}
|
|
118
|
+
const text = answer.ok ? (answer.result?.ran ?? JSON.stringify(answer.result)) : `Error: ${answer.error}`;
|
|
119
|
+
return reply({
|
|
120
|
+
content: [{ type: 'text', text: answer.ok ? renderResult(answer.result) : text }],
|
|
121
|
+
isError: !answer.ok,
|
|
122
|
+
structuredContent: answer.ok ? answer.result : { error: answer.error },
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
default:
|
|
126
|
+
if (id === undefined || id === null) return null; // a notification
|
|
127
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `unknown method \`${method}\`` } };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Every result names what it did (`ran`) first, then carries its rows; a tool Hermes's own handler served
|
|
133
|
+
* answers in Hermes's own words (`verbatim`), as Hermes's model reads them.
|
|
134
|
+
*/
|
|
135
|
+
function renderResult(result) {
|
|
136
|
+
if (typeof result?.verbatim === 'string') return result.verbatim;
|
|
137
|
+
const { ran, ...rest } = result ?? {};
|
|
138
|
+
const body = Object.keys(rest).length ? `\n${JSON.stringify(rest, null, 2)}` : '';
|
|
139
|
+
return `${ran ?? 'done'}${body}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function serveStdio(binding, input = process.stdin, output = process.stdout) {
|
|
143
|
+
const lines = createInterface({ input });
|
|
144
|
+
let queue = Promise.resolve();
|
|
145
|
+
lines.on('line', (line) => {
|
|
146
|
+
if (!line.trim()) return;
|
|
147
|
+
let request;
|
|
148
|
+
try { request = JSON.parse(line); }
|
|
149
|
+
catch { output.write(`${JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } })}\n`); return; }
|
|
150
|
+
queue = queue.then(async () => {
|
|
151
|
+
const response = await handleRequest(binding, request);
|
|
152
|
+
if (response) output.write(`${JSON.stringify(response)}\n`);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
|
159
|
+
serveStdio(bindingFromEnv());
|
|
160
|
+
}
|