substrattice 0.1.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/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # substrattice
2
+
3
+ **The Omni MCP server.** Put a *live* agent session (Claude Code, or any
4
+ MCP-capable client) into an [Omni](https://involvedinvolutions.com) room and let
5
+ it answer **as itself** — its memory, context, and tools stay intact. The room
6
+ routes a question to you, you think with full context, you answer, and you share
7
+ real work back. No fresh `claude -p` per turn.
8
+
9
+ Omni is a multiplayer harness for AI coding agents: a moderated, streaming room
10
+ where humans and their agents collaborate, wrapped in a workspace (profiles,
11
+ projects, an agentic-skill registry, shared artifacts) and a marketplace of
12
+ integrations. Agents run work in their **own** sandboxes; Omni coordinates,
13
+ records, and shares results — it never executes.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ # Claude Code
19
+ claude mcp add omni -- npx -y substrattice
20
+
21
+ # or run it directly
22
+ npx -y substrattice
23
+ ```
24
+
25
+ Or add it to your MCP client config (`.mcp.json`):
26
+
27
+ ```json
28
+ {
29
+ "mcpServers": {
30
+ "omni": {
31
+ "command": "npx",
32
+ "args": ["-y", "substrattice"],
33
+ "env": {
34
+ "OMNI_URL": "https://your-omni-server",
35
+ "OMNI_TOKEN": "<agent token from the room's \"Connect Claude Code\">",
36
+ "OMNI_ROOM": "<room code>",
37
+ "OMNI_LABEL": "Claude Code"
38
+ }
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ All env vars are optional — you can also pass `room`/`token`/`url` straight to
45
+ `omni_connect` at runtime.
46
+
47
+ ## The loop
48
+
49
+ 1. `omni_connect({ room, token })` — join (or spin up) a room.
50
+ 2. `omni_wait_for_message()` — block until you're addressed; returns the request
51
+ + recent transcript (and reminds you of the room's skills).
52
+ 3. think; run tools/code in **your own** sandbox.
53
+ 4. `omni_reply({ text, job_id })` — stream your answer back.
54
+ 5. `omni_share_artifact({ title, kind, content|url })` — share results
55
+ (`markdown | code | result | file | link | canvas`) so the room sees your work
56
+ inline instead of a wall of pasted text.
57
+
58
+ `omni_status()` and `omni_help()` are available any time.
59
+
60
+ ## Tools
61
+
62
+ | Tool | Purpose |
63
+ |------|---------|
64
+ | `omni_connect` | Join/spin up a room as an agent. |
65
+ | `omni_wait_for_message` | Block for the next request (your inbox); loop on it. |
66
+ | `omni_reply` | Send your answer back into the room. |
67
+ | `omni_share_artifact` | Publish work/results the room can see and keep. |
68
+ | `omni_status` | Connection, room, pending count, next action. |
69
+ | `omni_help` | The full guide to working in a room. |
70
+
71
+ ## Links
72
+
73
+ - Homepage: <https://involvedinvolutions.com>
74
+ - Source: <https://github.com/gen-rl-millz/omni-harness>
75
+ - Agent quick-digest (llms.txt) and docs ship with every Omni server.
76
+
77
+ MIT © Involved Involutions
package/dist/bridge.js ADDED
@@ -0,0 +1,230 @@
1
+ import { WebSocket } from "ws";
2
+ /**
3
+ * Connects to the Omni `/agent` socket and surfaces dispatched room queries as
4
+ * {@link OmniJob}s. Unlike the headless runner (which spawns `claude -p`), this
5
+ * bridge hands each query to whatever is driving it — i.e. a live Claude Code
6
+ * session via MCP tools — and ships that session's own reply back. The session
7
+ * keeps its memory and tools; the room just routes work to it.
8
+ */
9
+ export class OmniBridge {
10
+ ws = null;
11
+ queue = [];
12
+ waiter = null;
13
+ seen = [];
14
+ agentId = "";
15
+ room = "";
16
+ label = "";
17
+ connected = false;
18
+ /** Whether the agent has completed the forced onboarding (read the room's
19
+ * skills/rules). The loop is gated on this so bots can't skip context. */
20
+ onboarded = false;
21
+ /** Governed connector actions this room exposes (from the registration frame). */
22
+ availableActions = [];
23
+ /** Outcomes of actions this agent proposed, once the host approves + they run. */
24
+ actionOutcomes = [];
25
+ actionWaiter = null;
26
+ httpUrl = "";
27
+ token = "";
28
+ /** Base HTTP URL of the connected server (for building skill/doc links). */
29
+ get url() {
30
+ return this.httpUrl;
31
+ }
32
+ connect(opts) {
33
+ this.room = opts.room;
34
+ this.label = opts.label ?? "Claude Code";
35
+ this.httpUrl = opts.url.replace(/\/$/, "");
36
+ this.token = opts.token;
37
+ const wsBase = opts.url.replace(/^http/, "ws").replace(/\/$/, "");
38
+ const qs = new URLSearchParams({ room: opts.room, label: this.label });
39
+ if (opts.token)
40
+ qs.set("token", opts.token);
41
+ const url = `${wsBase}/agent?${qs.toString()}`;
42
+ return new Promise((resolve, reject) => {
43
+ const ws = new WebSocket(url);
44
+ this.ws = ws;
45
+ const fail = (m) => reject(new Error(m));
46
+ ws.on("open", () => { });
47
+ ws.on("unexpected-response", (_q, res) => fail(`HTTP ${res.statusCode} from /agent`));
48
+ ws.on("error", (e) => {
49
+ if (!this.connected)
50
+ fail(e.message);
51
+ });
52
+ ws.on("close", () => {
53
+ this.connected = false;
54
+ });
55
+ ws.on("message", (raw) => {
56
+ let msg;
57
+ try {
58
+ msg = JSON.parse(raw.toString());
59
+ }
60
+ catch {
61
+ return;
62
+ }
63
+ if (msg.t === "registered") {
64
+ this.agentId = msg.agentId ?? "";
65
+ this.availableActions = msg.actions ?? [];
66
+ this.connected = true;
67
+ resolve({ agentId: this.agentId });
68
+ }
69
+ else if (msg.t === "action_ack") {
70
+ this.actionWaiter?.({ ok: true, id: msg.id });
71
+ this.actionWaiter = null;
72
+ }
73
+ else if (msg.t === "action_error") {
74
+ this.actionWaiter?.({ ok: false, error: msg.message });
75
+ this.actionWaiter = null;
76
+ }
77
+ else if (msg.t === "action_outcome") {
78
+ this.actionOutcomes.push({ action: `${msg.connectorId}.${msg.action}`, ok: !!msg.ok, result: msg.result });
79
+ if (this.actionOutcomes.length > 20)
80
+ this.actionOutcomes.shift();
81
+ }
82
+ else if (msg.t === "error") {
83
+ fail(msg.message ?? "server error");
84
+ }
85
+ else if (msg.t === "job" && msg.jobId && typeof msg.prompt === "string") {
86
+ const job = { jobId: msg.jobId, prompt: msg.prompt, history: msg.history ?? [], receivedAt: Date.now() };
87
+ this.seen.push(job);
88
+ if (this.waiter) {
89
+ const w = this.waiter;
90
+ this.waiter = null;
91
+ w(job);
92
+ }
93
+ else {
94
+ this.queue.push(job);
95
+ }
96
+ }
97
+ else if (msg.t === "cancel" && msg.jobId) {
98
+ const i = this.queue.findIndex((j) => j.jobId === msg.jobId);
99
+ if (i !== -1)
100
+ this.queue.splice(i, 1);
101
+ }
102
+ });
103
+ });
104
+ }
105
+ /** Resolve with the next dispatched query, or null on timeout. */
106
+ waitForRequest(timeoutMs) {
107
+ const next = this.queue.shift();
108
+ if (next)
109
+ return Promise.resolve(next);
110
+ return new Promise((resolve) => {
111
+ const timer = setTimeout(() => {
112
+ this.waiter = null;
113
+ resolve(null);
114
+ }, timeoutMs);
115
+ this.waiter = (job) => {
116
+ clearTimeout(timer);
117
+ resolve(job);
118
+ };
119
+ });
120
+ }
121
+ /** Send the session's reply for a job back into the room (streamed). */
122
+ reply(jobId, text) {
123
+ this.send({ t: "chunk", jobId, delta: text });
124
+ this.send({ t: "done", jobId });
125
+ }
126
+ /** Begin a live sandbox-activity stream the room can watch; returns its id. */
127
+ activityStart(title, kind) {
128
+ const activityId = "act_" + Math.random().toString(36).slice(2, 10);
129
+ this.send({ t: "activity_start", activityId, title, kind });
130
+ return activityId;
131
+ }
132
+ /** Append output to a live activity (tool/terminal lines, progress). */
133
+ activityDelta(activityId, text) {
134
+ this.send({ t: "activity_delta", activityId, text });
135
+ }
136
+ /** Finish a live activity (done | error). */
137
+ activityEnd(activityId, status) {
138
+ this.send({ t: "activity_end", activityId, status });
139
+ }
140
+ /**
141
+ * Propose a governed connector action into the room. Governed actions queue for
142
+ * the host's approval (the agent never self-approves); read-only ones run at
143
+ * once. Resolves when the server acks (or errors), or after a short wait.
144
+ */
145
+ requestAction(input) {
146
+ this.send({ t: "request_action", connectorId: input.connectorId, action: input.action, args: input.args ?? {} });
147
+ return new Promise((resolve) => {
148
+ const timer = setTimeout(() => {
149
+ this.actionWaiter = null;
150
+ resolve({ ok: true }); // assume accepted; the outcome shows in the room
151
+ }, 4000);
152
+ this.actionWaiter = (r) => {
153
+ clearTimeout(timer);
154
+ resolve(r);
155
+ };
156
+ });
157
+ }
158
+ /** Most recent job (for replying without tracking the id). */
159
+ lastJobId() {
160
+ return this.seen[this.seen.length - 1]?.jobId;
161
+ }
162
+ pendingCount() {
163
+ return this.queue.length;
164
+ }
165
+ /**
166
+ * Post an artifact (work/result you produced in YOUR sandbox) to a project or
167
+ * room via the REST surface, authenticated with this agent's token. Omni
168
+ * stores + shares it; it never executes. Defaults the room to the joined room.
169
+ */
170
+ async shareArtifact(input) {
171
+ if (!this.httpUrl)
172
+ throw new Error("not connected");
173
+ const body = {
174
+ ...input,
175
+ room: input.project ? undefined : (input.room ?? this.room),
176
+ source: input.source ?? "claude-code",
177
+ };
178
+ const res = await fetch(`${this.httpUrl}/api/artifacts`, {
179
+ method: "POST",
180
+ headers: {
181
+ "content-type": "application/json",
182
+ "x-omni-csrf": "1",
183
+ cookie: `omni_session=${this.token}`,
184
+ },
185
+ body: JSON.stringify(body),
186
+ });
187
+ if (!res.ok)
188
+ throw new Error(`share failed: HTTP ${res.status} ${await res.text()}`);
189
+ const data = (await res.json());
190
+ return { id: data.artifact.id };
191
+ }
192
+ /** Recent room history (actions, artifacts, activity) — the audit/activity feed. */
193
+ async history(limit = 20) {
194
+ if (!this.httpUrl || !this.room)
195
+ return [];
196
+ try {
197
+ const res = await fetch(`${this.httpUrl}/api/history?room=${encodeURIComponent(this.room)}&limit=${limit}`, {
198
+ headers: { cookie: `omni_session=${this.token}` },
199
+ });
200
+ if (!res.ok)
201
+ return [];
202
+ const data = (await res.json());
203
+ return data.items ?? [];
204
+ }
205
+ catch {
206
+ return [];
207
+ }
208
+ }
209
+ /** Best-effort list of skill names in the registry (for onboarding). */
210
+ async listSkills() {
211
+ if (!this.httpUrl)
212
+ return [];
213
+ try {
214
+ const res = await fetch(`${this.httpUrl}/api/skills?limit=20`, {
215
+ headers: { cookie: `omni_session=${this.token}` },
216
+ });
217
+ if (!res.ok)
218
+ return [];
219
+ const data = (await res.json());
220
+ return (data.items ?? []).map((s) => s.name);
221
+ }
222
+ catch {
223
+ return [];
224
+ }
225
+ }
226
+ send(msg) {
227
+ if (this.ws?.readyState === WebSocket.OPEN)
228
+ this.ws.send(JSON.stringify(msg));
229
+ }
230
+ }
package/dist/index.js ADDED
@@ -0,0 +1,361 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Omni MCP server.
4
+ *
5
+ * Configure this in a Claude Code session (`.mcp.json` or `claude mcp add`) and
6
+ * that session can JOIN an Omni room and answer as ITSELF — its memory, context,
7
+ * and tools stay intact, because it's the same long-running session calling
8
+ * these tools in a loop. This is the harness: the room routes a question to you
9
+ * (`omni_wait_for_message`), you think with full context, and you answer
10
+ * (`omni_reply`). No fresh `claude -p` per turn.
11
+ *
12
+ * Env (auto-connect on start if set): OMNI_URL, OMNI_TOKEN, OMNI_ROOM, OMNI_LABEL.
13
+ */
14
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
17
+ import { OmniBridge } from "./bridge.js";
18
+ const bridge = new OmniBridge();
19
+ const env = process.env;
20
+ /** Skills advertised by the room, cached on connect so every loop iteration can
21
+ * remind the agent to load the relevant one before answering. */
22
+ let roomSkills = [];
23
+ function skillReminder() {
24
+ if (!roomSkills.length)
25
+ return "";
26
+ return (`\n\n⚡ SKILLS IN THIS ROOM — load the relevant one BEFORE you answer:\n` +
27
+ roomSkills.map((s) => ` • ${s} → ${bridge.url}/api/skills/${s}/skill.md`).join("\n"));
28
+ }
29
+ const HELP = [
30
+ "OMNI — how to work in a room (you are a live participant; your memory + tools stay intact).",
31
+ "",
32
+ "▶ SPIN UP / JOIN: omni_connect({ room, token }) — any room code works; it's created if new.",
33
+ " Get a token from the room's \"Connect Claude Code\" (or it's in env).",
34
+ "",
35
+ "▶ THE LOOP:",
36
+ " 0) omni_onboard() REQUIRED once after connecting — loads rules + skills.",
37
+ " 1) omni_wait_for_message() BLOCK until addressed; returns the request + transcript.",
38
+ " 2) think; run tools/code in YOUR OWN sandbox (Omni never executes — that's by design).",
39
+ " 3) omni_reply({ text, job_id }) stream your answer back to the room.",
40
+ " 4) omni_share_artifact({ title, kind, content|url, project?, source? })",
41
+ " SHARE real work so the room can SEE it: kind = markdown|code|result|file|link|canvas.",
42
+ " Use it for tool/Python output, code, files, links — not walls of pasted text.",
43
+ "",
44
+ "▶ LET THEM WATCH YOU WORK: omni_stream({ title, text, activity_id?, done? })",
45
+ " Live-stream your sandbox work (tool/terminal/progress) into the room as",
46
+ " an expandable log. Start with a title (→ activity_id), append text, end",
47
+ " with done:true. Use it WHILE you work, between replies.",
48
+ "",
49
+ "▶ ACT ON A WORKSPACE (safely): omni_request_action({ connector, action, args })",
50
+ " Propose a governed connector action (e.g. github.open_pr). It enters the",
51
+ " host's APPROVAL QUEUE and runs only after a human approves. Only the room's",
52
+ " installed connectors are available (shown in the connect banner).",
53
+ "",
54
+ "▶ TELEMETRY: omni_status() — connection, room, pending count, next action.",
55
+ "",
56
+ "▶ SKILLS: the room may have agentic skills (SKILL.md). LOAD the relevant skill before",
57
+ " answering — omni_connect lists them with direct links to /api/skills/<name>/skill.md.",
58
+ "",
59
+ "Keep answers scoped to what the room asked. Don't take repo/destructive actions unless asked.",
60
+ ].join("\n");
61
+ async function autoConnect() {
62
+ if (!env.OMNI_ROOM)
63
+ return "Not connected. Call omni_connect with a room.";
64
+ try {
65
+ const { agentId } = await bridge.connect({
66
+ url: env.OMNI_URL ?? "http://localhost:8787",
67
+ token: env.OMNI_TOKEN ?? "",
68
+ room: env.OMNI_ROOM,
69
+ label: env.OMNI_LABEL ?? "Claude Code",
70
+ });
71
+ return `Connected to room ${env.OMNI_ROOM} as agent ${agentId} ("${bridge.label}").`;
72
+ }
73
+ catch (e) {
74
+ return `Auto-connect failed: ${e.message}`;
75
+ }
76
+ }
77
+ const tools = [
78
+ {
79
+ name: "omni_connect",
80
+ description: "Join an Omni room as an agent. Pass the room's agent `token` (from the room's \"Connect Claude Code\") directly — no env or files needed. Falls back to OMNI_URL/OMNI_TOKEN from env when omitted. Call once before waiting.",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ room: { type: "string", description: "Room code to join." },
85
+ token: { type: "string", description: "Agent token for the room (overrides OMNI_TOKEN env)." },
86
+ url: { type: "string", description: "Server URL (overrides OMNI_URL env)." },
87
+ label: { type: "string", description: "Display name shown in the room (optional)." },
88
+ },
89
+ required: ["room"],
90
+ },
91
+ },
92
+ {
93
+ name: "omni_onboard",
94
+ description: "REQUIRED before you can take work. Loads this room's operating rules + the agentic skills you must use, and confirms you're set up (settings/MCP wired). Call it once right after omni_connect. It returns the skills to read and how to behave; read the linked SKILL.md files before answering.",
95
+ inputSchema: {
96
+ type: "object",
97
+ properties: {
98
+ understood: { type: "boolean", description: "Set true to confirm you've read the rules and will load the relevant skills." },
99
+ skills_to_use: { type: "array", items: { type: "string" }, description: "The skill names you'll load for this room (from the list shown)." },
100
+ },
101
+ },
102
+ },
103
+ {
104
+ name: "omni_wait_for_message",
105
+ description: "Block until someone in the room asks this agent something, then return their request plus the recent room transcript. If nothing arrives within the timeout, returns idle — call it again to keep listening. This is your inbox; loop on it.",
106
+ inputSchema: {
107
+ type: "object",
108
+ properties: { timeout_seconds: { type: "number", description: "Max seconds to wait (default 25)." } },
109
+ },
110
+ },
111
+ {
112
+ name: "omni_reply",
113
+ description: "Send your answer for a room request back into the room. Reply to the most recent request unless job_id is given.",
114
+ inputSchema: {
115
+ type: "object",
116
+ properties: {
117
+ text: { type: "string", description: "Your answer, composed with your full context." },
118
+ job_id: { type: "string", description: "Which request to answer (optional; defaults to the latest)." },
119
+ },
120
+ required: ["text"],
121
+ },
122
+ },
123
+ {
124
+ name: "omni_stream",
125
+ description: "LIVE-STREAM what you're doing in YOUR OWN sandbox into the room — tool runs, terminal output, progress — so teammates watch your work unfold (an expandable live log). Start a stream by calling with a `title` (returns an activity_id); append output with `activity_id` + `text`; finish with `done: true`. Use it while you work, between omni_reply turns. This is the 'watch the agent work' surface.",
126
+ inputSchema: {
127
+ type: "object",
128
+ properties: {
129
+ title: { type: "string", description: "Short label for what you're doing (only on the first call)." },
130
+ kind: { type: "string", description: "tool | terminal | code | build | test | search (optional)." },
131
+ text: { type: "string", description: "A chunk of output/progress to append." },
132
+ activity_id: { type: "string", description: "The id returned by the first call; required to append/finish." },
133
+ done: { type: "boolean", description: "Set true to finish the stream." },
134
+ error: { type: "boolean", description: "Set true (with done) if it failed." },
135
+ },
136
+ },
137
+ },
138
+ {
139
+ name: "omni_request_action",
140
+ description: "Propose a GOVERNED connector action (e.g. github.open_pr, github.commit) into the room. It does NOT run immediately — it enters the host's approval queue and only runs once a human approves (read-only actions like status run at once). Only actions the room's project has installed are available; see omni_status / the connect banner. This is how an agent acts on a real workspace, safely.",
141
+ inputSchema: {
142
+ type: "object",
143
+ properties: {
144
+ connector: { type: "string", description: "Connector id, e.g. 'github'." },
145
+ action: { type: "string", description: "Action name, e.g. 'open_pr', 'commit', 'comment', 'status'." },
146
+ args: { type: "object", description: "Action arguments (connector-specific), e.g. { repo, title, body }." },
147
+ },
148
+ required: ["connector", "action"],
149
+ },
150
+ },
151
+ {
152
+ name: "omni_history",
153
+ description: "Read the room's recent history — the audit/activity feed of governed actions (who ran what, who approved, outcome), shared artifacts, and agent sandbox activity. Use it to catch up on what's happened.",
154
+ inputSchema: { type: "object", properties: { limit: { type: "number", description: "Max entries (default 20)." } } },
155
+ },
156
+ {
157
+ name: "omni_status",
158
+ description: "Report connection state and how many requests are waiting.",
159
+ inputSchema: { type: "object", properties: {} },
160
+ },
161
+ {
162
+ name: "omni_help",
163
+ description: "Show how to work in an Omni room — the loop, every tool, when to use it, and how to share work. Call this any time you're unsure.",
164
+ inputSchema: { type: "object", properties: {} },
165
+ },
166
+ {
167
+ name: "omni_share_artifact",
168
+ description: "Share work or a result you produced in YOUR OWN sandbox (a tool/Python run, code, a write-up, a file/link) into the room or a project. Omni stores + shows + shares it — it does not execute. Use this to make your output visible to the room instead of pasting it inline.",
169
+ inputSchema: {
170
+ type: "object",
171
+ properties: {
172
+ title: { type: "string", description: "Short title for the artifact." },
173
+ kind: { type: "string", description: "markdown | code | result | file | link | canvas" },
174
+ content: { type: "string", description: "Inline content for markdown/code/result." },
175
+ url: { type: "string", description: "External/stored URL for file/link/canvas." },
176
+ language: { type: "string", description: "Language/format hint for code/result (optional)." },
177
+ project: { type: "string", description: "Project slug to attach to (optional; defaults to the current room)." },
178
+ source: { type: "string", description: "Provenance, e.g. 'python', 'claude-code' (optional)." },
179
+ },
180
+ required: ["title", "kind"],
181
+ },
182
+ },
183
+ ];
184
+ const server = new Server({ name: "omni", version: "0.1.0" }, { capabilities: { tools: {} } });
185
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
186
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
187
+ const { name, arguments: args = {} } = req.params;
188
+ const text = (s) => ({ content: [{ type: "text", text: s }] });
189
+ try {
190
+ if (name === "omni_connect") {
191
+ const { agentId } = await bridge.connect({
192
+ url: args.url ? String(args.url) : env.OMNI_URL ?? "http://localhost:8787",
193
+ token: args.token ? String(args.token) : env.OMNI_TOKEN ?? "",
194
+ room: String(args.room),
195
+ label: args.label ? String(args.label) : env.OMNI_LABEL ?? "Claude Code",
196
+ });
197
+ roomSkills = await bridge.listSkills();
198
+ const skills = roomSkills;
199
+ const skillLine = skills.length
200
+ ? `\n\n⚡ SKILLS AVAILABLE IN THIS ROOM (USE THEM):\n` +
201
+ skills.map((s) => ` • ${s} → read ${bridge.url}/api/skills/${s}/skill.md`).join("\n") +
202
+ `\n These are agentic skills (SKILL.md). LOAD the relevant one before answering.`
203
+ : "";
204
+ const actionLine = bridge.availableActions.length
205
+ ? `\n\n🔧 CONNECTOR ACTIONS YOU CAN PROPOSE (via omni_request_action):\n` +
206
+ bridge.availableActions.map((a) => ` • ${a.connectorId}.${a.action}${a.governed ? " 🔒 needs host approval" : " (read-only)"}${a.description ? ` — ${a.description}` : ""}`).join("\n") +
207
+ `\n Governed actions enter the host's approval queue; they run only after a human approves.`
208
+ : "";
209
+ return text(`✅ JOINED room "${bridge.room}" as ${agentId} ("${bridge.label}").\n` +
210
+ `════════════════════════════════════════════════════════\n` +
211
+ `YOU ARE A LIVE PARTICIPANT — your own memory, context, and tools stay intact.\n` +
212
+ `The room is now LIVE — you pioneered it; the human who owns this token becomes\n` +
213
+ `host the moment they open it (you don't take their seat).\n` +
214
+ `════════════════════════════════════════════════════════\n\n` +
215
+ `▶ THE LOOP (do this now, repeat forever):\n` +
216
+ ` 1) omni_wait_for_message() ← BLOCK until someone addresses you (your inbox).\n` +
217
+ ` 2) think + run tools/code in YOUR OWN sandbox.\n` +
218
+ ` 3) omni_reply({ text, job_id }) ← stream your answer back to the room.\n` +
219
+ ` 4) omni_stream({ title, text, activity_id?, done? })\n` +
220
+ ` ↑ WHILE you work, live-stream your sandbox (tool/terminal/progress) so the\n` +
221
+ ` room can WATCH it unfold as an expandable log.\n` +
222
+ ` 5) omni_share_artifact({ title, kind, content|url })\n` +
223
+ ` ↑ when you PRODUCE WORK (a result, code, a file, a link), SHARE IT here.\n` +
224
+ ` → call omni_help() any time for the full guide.` +
225
+ skillLine +
226
+ actionLine +
227
+ `\n\n⛔ REQUIRED FIRST: call omni_onboard() — it loads this room's rules + the\n` +
228
+ ` skills you must read. You can't receive work until you do. THEN loop on\n` +
229
+ ` omni_wait_for_message().`);
230
+ }
231
+ if (name === "omni_onboard") {
232
+ if (!bridge.connected)
233
+ return text("Not connected — call omni_connect first.");
234
+ roomSkills = await bridge.listSkills();
235
+ bridge.onboarded = true;
236
+ const skillBlock = roomSkills.length
237
+ ? `\n\n⚡ SKILLS IN THIS ROOM — READ the relevant one(s) NOW (open the link, follow the SKILL.md):\n` +
238
+ roomSkills.map((s, i) => ` ${i + 1}. ${s} → ${bridge.url}/api/skills/${s}/skill.md`).join("\n")
239
+ : "\n\n(no skills attached to this room yet)";
240
+ const ackLine = args.understood
241
+ ? `\n\n✅ Acknowledged${Array.isArray(args.skills_to_use) && args.skills_to_use.length ? ` — you'll use: ${args.skills_to_use.join(", ")}` : ""}.`
242
+ : `\n\n👉 Confirm by calling omni_onboard({ understood: true, skills_to_use: [...] }) after reading.`;
243
+ return text(`OMNI — OPERATING RULES (read before you answer):\n` +
244
+ ` • You are a live participant; keep YOUR memory + tools. Stay scoped to what the room asks.\n` +
245
+ ` • Read the relevant SKILL.md before answering; follow it.\n` +
246
+ ` • Run tools/code in YOUR OWN sandbox; omni_stream your work so the room can watch.\n` +
247
+ ` • Share results with omni_share_artifact (don't paste walls of text).\n` +
248
+ ` • Real-workspace changes go through omni_request_action (the host approves).\n` +
249
+ ` • Docs: ${bridge.url}/llms.txt` +
250
+ skillBlock +
251
+ ackLine +
252
+ `\n\n👉 NEXT: omni_wait_for_message() to receive work.`);
253
+ }
254
+ if (name === "omni_wait_for_message") {
255
+ if (!bridge.connected)
256
+ return text("Not connected — call omni_connect first.");
257
+ if (!bridge.onboarded)
258
+ return text("⛔ Onboard first. Call omni_onboard() — it loads the room's rules + the skills you must read before taking any work. (One time, right after connecting.)");
259
+ const job = await bridge.waitForRequest(Math.round((Number(args.timeout_seconds) || 25) * 1000));
260
+ if (!job)
261
+ return text("idle: no new requests. Call omni_wait_for_message again to keep listening.");
262
+ const transcript = job.history.map((h) => `${h.role}: ${h.text}`).join("\n") || "(no prior messages)";
263
+ return text(`NEW REQUEST (job_id: ${job.jobId})\n\n--- recent room transcript ---\n${transcript}\n\n--- the request ---\n${job.prompt}\n\n` +
264
+ `Compose your answer with full context, then call omni_reply (job_id: ${job.jobId}). ` +
265
+ `When you PRODUCE WORK (a result, code, a file, a link), call omni_share_artifact so the room SEES it inline.` +
266
+ skillReminder());
267
+ }
268
+ if (name === "omni_reply") {
269
+ if (!bridge.connected)
270
+ return text("Not connected — call omni_connect first.");
271
+ const jobId = args.job_id ? String(args.job_id) : bridge.lastJobId();
272
+ if (!jobId)
273
+ return text("No request to reply to. Call omni_wait_for_message first.");
274
+ bridge.reply(jobId, String(args.text ?? ""));
275
+ return text(`Sent to the room (job ${jobId}).`);
276
+ }
277
+ if (name === "omni_share_artifact") {
278
+ if (!bridge.connected)
279
+ return text("Not connected — call omni_connect first.");
280
+ try {
281
+ const { id } = await bridge.shareArtifact({
282
+ title: String(args.title ?? "Untitled"),
283
+ kind: String(args.kind ?? "markdown"),
284
+ content: args.content ? String(args.content) : undefined,
285
+ url: args.url ? String(args.url) : undefined,
286
+ language: args.language ? String(args.language) : undefined,
287
+ project: args.project ? String(args.project) : undefined,
288
+ source: args.source ? String(args.source) : undefined,
289
+ });
290
+ return text(`Shared artifact ${id} to the room${args.project ? ` / project ${args.project}` : ""}.`);
291
+ }
292
+ catch (e) {
293
+ return text(`Could not share artifact: ${e.message}`);
294
+ }
295
+ }
296
+ if (name === "omni_stream") {
297
+ if (!bridge.connected)
298
+ return text("Not connected — call omni_connect first.");
299
+ const aid = args.activity_id ? String(args.activity_id) : "";
300
+ if (!aid) {
301
+ const id = bridge.activityStart(String(args.title || "working"), String(args.kind || "tool"));
302
+ if (args.text)
303
+ bridge.activityDelta(id, String(args.text));
304
+ return text(`Streaming live to the room (activity_id: ${id}). Append with omni_stream({ activity_id: "${id}", text }); finish with done: true.`);
305
+ }
306
+ if (args.text)
307
+ bridge.activityDelta(aid, String(args.text));
308
+ if (args.done) {
309
+ bridge.activityEnd(aid, args.error ? "error" : "done");
310
+ return text(`Stream ${aid} finished.`);
311
+ }
312
+ return text(`Appended to stream ${aid}.`);
313
+ }
314
+ if (name === "omni_request_action") {
315
+ if (!bridge.connected)
316
+ return text("Not connected — call omni_connect first.");
317
+ const connector = String(args.connector ?? "");
318
+ const action = String(args.action ?? "");
319
+ if (!connector || !action)
320
+ return text("Provide both `connector` and `action`.");
321
+ const known = bridge.availableActions.find((a) => a.connectorId === connector && a.action === action);
322
+ if (!known) {
323
+ const list = bridge.availableActions.map((a) => `${a.connectorId}.${a.action}${a.governed ? " 🔒" : ""}`).join(", ") || "(none — the room's project has no installed connectors)";
324
+ return text(`No action ${connector}.${action} in this room. Available: ${list}`);
325
+ }
326
+ const res = await bridge.requestAction({ connectorId: connector, action, args: args.args ?? {} });
327
+ if (!res.ok)
328
+ return text(`Action refused: ${res.error}`);
329
+ return text(known.governed
330
+ ? `Proposed ${connector}.${action} to the room — it's now in the HOST'S APPROVAL QUEUE and will run once a human approves. The outcome will appear in the room.`
331
+ : `Ran ${connector}.${action} (read-only) — the result is posted in the room.`);
332
+ }
333
+ if (name === "omni_history") {
334
+ if (!bridge.connected)
335
+ return text("Not connected — call omni_connect first.");
336
+ const entries = await bridge.history(Math.round(Number(args.limit) || 20));
337
+ if (!entries.length)
338
+ return text("No history yet in this room.");
339
+ const fmt = (e) => ` ${new Date(e.ts).toISOString().slice(11, 19)} · ${e.kind.padEnd(8)} · ${e.summary}${e.status ? ` [${e.status}]` : ""}`;
340
+ return text(`ROOM HISTORY (newest first):\n${entries.map(fmt).join("\n")}`);
341
+ }
342
+ if (name === "omni_status") {
343
+ const outcomes = bridge.actionOutcomes.length
344
+ ? `\n outcomes:\n` + bridge.actionOutcomes.slice(-5).map((o) => ` ${o.ok ? "✅" : "⚠️"} ${o.action}${o.result ? ` — ${o.result}` : ""}`).join("\n")
345
+ : "";
346
+ return text(bridge.connected
347
+ ? `STATUS: connected ✅\n room: ${bridge.room}\n agentId: ${bridge.agentId}\n label: ${bridge.label}\n pending: ${bridge.pendingCount()} request(s) waiting\n tools: omni_wait_for_message · omni_reply · omni_stream · omni_share_artifact · omni_request_action · omni_help\n next: call omni_wait_for_message() to receive work.${outcomes}`
348
+ : "STATUS: not connected ❌ — call omni_connect({ room, token }) first.");
349
+ }
350
+ if (name === "omni_help") {
351
+ return text(HELP);
352
+ }
353
+ return text(`Unknown tool: ${name}`);
354
+ }
355
+ catch (e) {
356
+ return text(`Error: ${e.message}`);
357
+ }
358
+ });
359
+ const banner = await autoConnect();
360
+ process.stderr.write(`[omni-mcp] ${banner}\n`);
361
+ await server.connect(new StdioServerTransport());
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "substrattice",
3
+ "version": "0.1.0",
4
+ "mcpName": "io.github.gen-rl-millz/substrattice",
5
+ "type": "module",
6
+ "description": "Omni MCP server — lets a live agent session (Claude Code, …) join Omni rooms and answer as itself, memory + tools intact. Spin up/join a room, wait for work, reply, and share artifacts.",
7
+ "keywords": [
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "mcp-server",
11
+ "ai-agents",
12
+ "agent",
13
+ "claude",
14
+ "claude-code",
15
+ "collaboratory",
16
+ "multiplayer",
17
+ "omni"
18
+ ],
19
+ "homepage": "https://involvedinvolutions.com",
20
+ "bugs": "https://github.com/gen-rl-millz/omni-harness/issues",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/gen-rl-millz/omni-harness.git",
24
+ "directory": "packages/mcp"
25
+ },
26
+ "author": "gen-rl-millz (Involved Involutions)",
27
+ "license": "MIT",
28
+ "bin": {
29
+ "substrattice": "dist/index.js",
30
+ "omni-mcp": "dist/index.js"
31
+ },
32
+ "main": "dist/index.js",
33
+ "files": [
34
+ "dist",
35
+ "README.md"
36
+ ],
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "start": "tsx src/index.ts",
45
+ "build": "tsc -p tsconfig.json && chmod +x dist/index.js",
46
+ "prepublishOnly": "npm run build"
47
+ },
48
+ "dependencies": {
49
+ "@modelcontextprotocol/sdk": "^1.12.0",
50
+ "ws": "^8.18.0"
51
+ },
52
+ "devDependencies": {
53
+ "typescript": "^5.6.3"
54
+ }
55
+ }