substrattice 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,12 +61,18 @@ All env vars are optional — you can also pass `room`/`token`/`url` straight to
61
61
 
62
62
  | Tool | Purpose |
63
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. |
64
+ | `omni_connect` | Join/spin up a room as an agent. Accepts `role` (planner/coder/reviewer…) and a stable wake `key` so a reconnect reclaims your slot + queued work. |
65
+ | `omni_wait_for_message` | Block for the next live request; loop on it. |
66
66
  | `omni_reply` | Send your answer back into the room. |
67
+ | `omni_inbox` | Your **async** inbox — open work left for you/your role to answer later (even when you were offline). |
68
+ | `omni_leave_handoff` | Leave async work for a teammate or another AI role to pick up later (linked to a repo/url/artifact). |
69
+ | `omni_resolve_handoff` | Progress/answer a handoff: in_progress / done (attach the result artifact) / cancelled. |
67
70
  | `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. |
71
+ | `omni_edit_artifact` | Co-edit a shared artifact (collaborative coding); bumps its version, optimistic concurrency. |
72
+ | `omni_request_action` | Propose a governed connector action (e.g. github.open_pr) into the host approval queue. |
73
+ | `omni_stream` | Live-stream your sandbox activity (tool/terminal/progress) into the room. |
74
+ | `omni_upload` | Upload a real file as a downloadable artifact. |
75
+ | `omni_status` / `omni_history` / `omni_help` | Connection state · room audit feed · the full guide. |
70
76
 
71
77
  ## Links
72
78
 
package/dist/bridge.js CHANGED
@@ -14,6 +14,7 @@ export class OmniBridge {
14
14
  agentId = "";
15
15
  room = "";
16
16
  label = "";
17
+ role = "";
17
18
  connected = false;
18
19
  /** Whether the agent has completed the forced onboarding (read the room's
19
20
  * skills/rules). The loop is gated on this so bots can't skip context. */
@@ -32,10 +33,17 @@ export class OmniBridge {
32
33
  connect(opts) {
33
34
  this.room = opts.room;
34
35
  this.label = opts.label ?? "Claude Code";
36
+ this.role = opts.role ?? "";
35
37
  this.httpUrl = opts.url.replace(/\/$/, "");
36
38
  this.token = opts.token;
37
39
  const wsBase = opts.url.replace(/^http/, "ws").replace(/\/$/, "");
38
40
  const qs = new URLSearchParams({ room: opts.room, label: this.label });
41
+ if (opts.role)
42
+ qs.set("role", opts.role);
43
+ // Stable wake key: lets this runner reclaim its roster slot + drain queued
44
+ // work after a reconnect (the server also derives one from owner+role).
45
+ if (opts.key)
46
+ qs.set("key", opts.key);
39
47
  if (opts.token)
40
48
  qs.set("token", opts.token);
41
49
  const url = `${wsBase}/agent?${qs.toString()}`;
@@ -198,6 +206,128 @@ export class OmniBridge {
198
206
  const data = (await res.json());
199
207
  return { id: data.artifact.id };
200
208
  }
209
+ /** Fetch one artifact (e.g. to read the current version before co-editing). */
210
+ async getArtifact(artifactId) {
211
+ if (!this.httpUrl)
212
+ throw new Error("not connected");
213
+ const res = await fetch(`${this.httpUrl}/api/artifacts/${encodeURIComponent(artifactId)}`, {
214
+ headers: { cookie: `omni_session=${this.token}` },
215
+ });
216
+ if (!res.ok)
217
+ return null;
218
+ const data = (await res.json());
219
+ return data.artifact;
220
+ }
221
+ /** Co-edit a shared artifact (collaborative coding). Bumps its version; pass
222
+ * `expectedVersion` for optimistic concurrency (409 if another edit landed). */
223
+ async editArtifact(input) {
224
+ if (!this.httpUrl)
225
+ throw new Error("not connected");
226
+ const body = {};
227
+ if (input.content !== undefined)
228
+ body.content = input.content;
229
+ if (input.title !== undefined)
230
+ body.title = input.title;
231
+ if (input.language !== undefined)
232
+ body.language = input.language;
233
+ if (input.expectedVersion !== undefined)
234
+ body.expectedVersion = input.expectedVersion;
235
+ const res = await fetch(`${this.httpUrl}/api/artifacts/${encodeURIComponent(input.id)}`, {
236
+ method: "PATCH",
237
+ headers: {
238
+ "content-type": "application/json",
239
+ "x-omni-csrf": "1",
240
+ cookie: `omni_session=${this.token}`,
241
+ },
242
+ body: JSON.stringify(body),
243
+ });
244
+ if (!res.ok)
245
+ throw new Error(`edit failed: HTTP ${res.status} ${await res.text()}`);
246
+ const data = (await res.json());
247
+ return { id: data.artifact.id, version: data.artifact.version };
248
+ }
249
+ /** Your async **inbox**: open work left in this room addressed to your role (or
250
+ * to "any" AI), waiting to be answered. This is how you "respond later". */
251
+ async inbox() {
252
+ if (!this.httpUrl || !this.room)
253
+ return [];
254
+ const roleQ = this.role ? `&toRole=${encodeURIComponent(this.role)}` : "";
255
+ const res = await fetch(`${this.httpUrl}/api/handoffs?room=${encodeURIComponent(this.room)}&status=open${roleQ}`, {
256
+ headers: { cookie: `omni_session=${this.token}` },
257
+ });
258
+ if (!res.ok)
259
+ throw new Error(`inbox failed: HTTP ${res.status} ${await res.text()}`);
260
+ const data = (await res.json());
261
+ return data.items;
262
+ }
263
+ /** Leave async work for a teammate or another AI role to pick up later. */
264
+ async leaveHandoff(input) {
265
+ if (!this.httpUrl)
266
+ throw new Error("not connected");
267
+ const body = { roomId: this.room, title: input.title, body: input.body };
268
+ if (input.toUserId)
269
+ body.toUserId = input.toUserId;
270
+ if (input.toAgentRole)
271
+ body.toAgentRole = input.toAgentRole;
272
+ if (input.repo)
273
+ body.repo = input.repo;
274
+ if (input.url)
275
+ body.url = input.url;
276
+ if (input.artifactId)
277
+ body.artifactId = input.artifactId;
278
+ const res = await fetch(`${this.httpUrl}/api/handoffs`, {
279
+ method: "POST",
280
+ headers: { "content-type": "application/json", "x-omni-csrf": "1", cookie: `omni_session=${this.token}` },
281
+ body: JSON.stringify(body),
282
+ });
283
+ if (!res.ok)
284
+ throw new Error(`leave failed: HTTP ${res.status} ${await res.text()}`);
285
+ const data = (await res.json());
286
+ return { id: data.handoff.id };
287
+ }
288
+ /** Answer (or progress) a handoff: mark it in_progress/done/cancelled, with an
289
+ * optional result summary and the artifact you produced as the answer. */
290
+ async resolveHandoff(input) {
291
+ if (!this.httpUrl)
292
+ throw new Error("not connected");
293
+ const body = {};
294
+ if (input.status)
295
+ body.status = input.status;
296
+ if (input.result !== undefined)
297
+ body.result = input.result;
298
+ if (input.resultArtifactId)
299
+ body.resultArtifactId = input.resultArtifactId;
300
+ const res = await fetch(`${this.httpUrl}/api/handoffs/${encodeURIComponent(input.id)}`, {
301
+ method: "PATCH",
302
+ headers: { "content-type": "application/json", "x-omni-csrf": "1", cookie: `omni_session=${this.token}` },
303
+ body: JSON.stringify(body),
304
+ });
305
+ if (!res.ok)
306
+ throw new Error(`resolve failed: HTTP ${res.status} ${await res.text()}`);
307
+ const data = (await res.json());
308
+ return { id: data.handoff.id, status: data.handoff.status };
309
+ }
310
+ /** Upload a FILE into the room (a real downloadable file artifact). Text by
311
+ * default; pass base64 for binary. Returns the created artifact id. */
312
+ async uploadFile(input) {
313
+ if (!this.httpUrl)
314
+ throw new Error("not connected");
315
+ const bytes = Buffer.from(input.content, input.base64 ? "base64" : "utf8");
316
+ const res = await fetch(`${this.httpUrl}/api/uploads?room=${encodeURIComponent(this.room)}`, {
317
+ method: "POST",
318
+ headers: {
319
+ "x-omni-csrf": "1",
320
+ "content-type": input.contentType ?? "text/plain; charset=utf-8",
321
+ "x-filename": encodeURIComponent(input.filename),
322
+ cookie: `omni_session=${this.token}`,
323
+ },
324
+ body: bytes,
325
+ });
326
+ if (!res.ok)
327
+ throw new Error(`upload failed: HTTP ${res.status} ${await res.text()}`);
328
+ const data = (await res.json());
329
+ return { id: data.artifact.id };
330
+ }
201
331
  /** Recent room history (actions, artifacts, activity) — the audit/activity feed. */
202
332
  async history(limit = 20) {
203
333
  if (!this.httpUrl || !this.room)
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * (`omni_wait_for_message`), you think with full context, and you answer
10
10
  * (`omni_reply`). No fresh `claude -p` per turn.
11
11
  *
12
- * Env (auto-connect on start if set): OMNI_URL, OMNI_TOKEN, OMNI_ROOM, OMNI_LABEL.
12
+ * Env (auto-connect on start if set): OMNI_URL, OMNI_TOKEN, OMNI_ROOM, OMNI_LABEL, OMNI_ROLE, OMNI_AGENT_KEY.
13
13
  */
14
14
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
15
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -67,6 +67,8 @@ async function autoConnect() {
67
67
  token: env.OMNI_TOKEN ?? "",
68
68
  room: env.OMNI_ROOM,
69
69
  label: env.OMNI_LABEL ?? "Claude Code",
70
+ role: env.OMNI_ROLE,
71
+ key: env.OMNI_AGENT_KEY,
70
72
  });
71
73
  return `Connected to room ${env.OMNI_ROOM} as agent ${agentId} ("${bridge.label}").`;
72
74
  }
@@ -85,6 +87,8 @@ const tools = [
85
87
  token: { type: "string", description: "Agent token for the room (overrides OMNI_TOKEN env)." },
86
88
  url: { type: "string", description: "Server URL (overrides OMNI_URL env)." },
87
89
  label: { type: "string", description: "Display name shown in the room (optional)." },
90
+ role: { type: "string", description: "Declared role for this agent (e.g. planner, coder, reviewer) — shown in the roster (optional)." },
91
+ key: { type: "string", description: "Stable wake key (optional) — reconnecting with the same key reclaims this agent's roster slot and drains work queued while it was offline." },
88
92
  },
89
93
  required: ["room"],
90
94
  },
@@ -148,6 +152,56 @@ const tools = [
148
152
  required: ["connector", "action"],
149
153
  },
150
154
  },
155
+ {
156
+ name: "omni_upload",
157
+ description: "Upload a FILE into the room — a real, downloadable file artifact (code, a doc, data), not pasted text. Provide a filename + content (text); set base64:true for binary. Use this to hand a teammate an actual file.",
158
+ inputSchema: {
159
+ type: "object",
160
+ properties: {
161
+ filename: { type: "string", description: "File name, e.g. parser.ts or report.md." },
162
+ content: { type: "string", description: "File contents (utf-8 text, or base64 if base64:true)." },
163
+ content_type: { type: "string", description: "MIME type (optional), e.g. text/markdown." },
164
+ base64: { type: "boolean", description: "True if content is base64-encoded binary." },
165
+ },
166
+ required: ["filename", "content"],
167
+ },
168
+ },
169
+ {
170
+ name: "omni_inbox",
171
+ description: "Your ASYNC INBOX: open work other people left in this room for you (or your role) to answer LATER — even when they're not around. Call this on connect and whenever you catch up. Each item has a title, body, and optional linked repo/url/artifact. Do the work in your own environment, share results with omni_share_artifact, then close it with omni_resolve_handoff. This is how async, leave-it-and-walk-away collaboration works.",
172
+ inputSchema: { type: "object", properties: {} },
173
+ },
174
+ {
175
+ name: "omni_leave_handoff",
176
+ description: "Leave ASYNC WORK for a teammate or another AI role to pick up later (without them being live). Provide a title + body; optionally address a person (to_user_id), an AI role (to_agent_role, e.g. 'coder' or 'any'), and link a repo/url/artifact. The work waits durably in the room until it's answered.",
177
+ inputSchema: {
178
+ type: "object",
179
+ properties: {
180
+ title: { type: "string", description: "Short headline for the work." },
181
+ body: { type: "string", description: "Describe the task / leave the message." },
182
+ to_user_id: { type: "string", description: "Address a specific teammate (account id), optional." },
183
+ to_agent_role: { type: "string", description: "Address an AI role (e.g. coder, reviewer) or 'any', optional." },
184
+ repo: { type: "string", description: "Linked repo (owner/name or URL), optional." },
185
+ url: { type: "string", description: "Any external link, optional." },
186
+ artifact_id: { type: "string", description: "A related artifact id (the spec/input), optional." },
187
+ },
188
+ required: ["title", "body"],
189
+ },
190
+ },
191
+ {
192
+ name: "omni_resolve_handoff",
193
+ description: "Progress or ANSWER a handoff from your inbox: set status to in_progress (you're on it), done (answered — attach the result artifact you produced), or cancelled. Include a short result summary so the room sees what you did.",
194
+ inputSchema: {
195
+ type: "object",
196
+ properties: {
197
+ id: { type: "string", description: "Handoff id (from omni_inbox)." },
198
+ status: { type: "string", description: "in_progress | done | cancelled" },
199
+ result: { type: "string", description: "Short summary of the answer/outcome." },
200
+ result_artifact_id: { type: "string", description: "The artifact you produced as the answer (from omni_share_artifact)." },
201
+ },
202
+ required: ["id"],
203
+ },
204
+ },
151
205
  {
152
206
  name: "omni_history",
153
207
  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.",
@@ -180,6 +234,21 @@ const tools = [
180
234
  required: ["title", "kind"],
181
235
  },
182
236
  },
237
+ {
238
+ name: "omni_edit_artifact",
239
+ description: "Co-edit a SHARED artifact in the room (collaborative coding): replace its content/title/language. Bumps its version and updates it live for everyone — multiple agents and humans can iterate on the same code/doc. Best practice: read it first (you get its version), then pass expected_version so a concurrent edit can't silently clobber yours (a conflict returns 409 — re-read and retry).",
240
+ inputSchema: {
241
+ type: "object",
242
+ properties: {
243
+ id: { type: "string", description: "Artifact id to edit (from omni_share_artifact or a shared card)." },
244
+ content: { type: "string", description: "New full content (replaces the prior text)." },
245
+ title: { type: "string", description: "New title (optional)." },
246
+ language: { type: "string", description: "New language/format hint (optional)." },
247
+ expected_version: { type: "number", description: "The version you edited (optimistic concurrency; 409 if it moved on)." },
248
+ },
249
+ required: ["id"],
250
+ },
251
+ },
183
252
  ];
184
253
  const server = new Server({ name: "omni", version: "0.1.0" }, { capabilities: { tools: {} } });
185
254
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
@@ -193,6 +262,8 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
193
262
  token: args.token ? String(args.token) : env.OMNI_TOKEN ?? "",
194
263
  room: String(args.room),
195
264
  label: args.label ? String(args.label) : env.OMNI_LABEL ?? "Claude Code",
265
+ role: args.role ? String(args.role) : env.OMNI_ROLE,
266
+ key: args.key ? String(args.key) : env.OMNI_AGENT_KEY,
196
267
  });
197
268
  roomSkills = await bridge.listSkills();
198
269
  const skills = roomSkills;
@@ -293,6 +364,79 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
293
364
  return text(`Could not share artifact: ${e.message}`);
294
365
  }
295
366
  }
367
+ if (name === "omni_edit_artifact") {
368
+ if (!bridge.connected)
369
+ return text("Not connected — call omni_connect first.");
370
+ const aid = String(args.id ?? "");
371
+ if (!aid)
372
+ return text("Provide the artifact `id` to edit.");
373
+ try {
374
+ const { id, version } = await bridge.editArtifact({
375
+ id: aid,
376
+ content: args.content !== undefined ? String(args.content) : undefined,
377
+ title: args.title !== undefined ? String(args.title) : undefined,
378
+ language: args.language !== undefined ? String(args.language) : undefined,
379
+ expectedVersion: args.expected_version !== undefined ? Number(args.expected_version) : undefined,
380
+ });
381
+ return text(`Edited artifact ${id} → version ${version}. Everyone in the room sees the update live.`);
382
+ }
383
+ catch (e) {
384
+ return text(`Could not edit artifact: ${e.message} (on a version conflict, re-fetch with omni_history/the shared card and retry).`);
385
+ }
386
+ }
387
+ if (name === "omni_inbox") {
388
+ if (!bridge.connected)
389
+ return text("Not connected — call omni_connect first.");
390
+ try {
391
+ const items = await bridge.inbox();
392
+ if (items.length === 0)
393
+ return text("📭 Your async inbox is empty — no open work left for you in this room.");
394
+ const lines = items.map((h) => `• [${h.id}] ${h.title}${h.toAgentRole ? ` (for @${h.toAgentRole})` : ""}\n ${h.body.slice(0, 240)}${h.body.length > 240 ? "…" : ""}` +
395
+ `${h.repo ? `\n repo: ${h.repo}` : ""}${h.url ? `\n link: ${h.url}` : ""}${h.artifactId ? `\n artifact: ${h.artifactId}` : ""}`);
396
+ return text(`📥 ${items.length} item(s) waiting for you:\n\n${lines.join("\n\n")}\n\nDo the work, share results with omni_share_artifact, then close with omni_resolve_handoff({ id, status: "done", result, result_artifact_id }).`);
397
+ }
398
+ catch (e) {
399
+ return text(`Could not read inbox: ${e.message}`);
400
+ }
401
+ }
402
+ if (name === "omni_leave_handoff") {
403
+ if (!bridge.connected)
404
+ return text("Not connected — call omni_connect first.");
405
+ try {
406
+ const { id } = await bridge.leaveHandoff({
407
+ title: String(args.title ?? "Untitled"),
408
+ body: String(args.body ?? ""),
409
+ toUserId: args.to_user_id ? String(args.to_user_id) : undefined,
410
+ toAgentRole: args.to_agent_role ? String(args.to_agent_role) : undefined,
411
+ repo: args.repo ? String(args.repo) : undefined,
412
+ url: args.url ? String(args.url) : undefined,
413
+ artifactId: args.artifact_id ? String(args.artifact_id) : undefined,
414
+ });
415
+ return text(`Left async work ${id} in the room — it waits until it's picked up.`);
416
+ }
417
+ catch (e) {
418
+ return text(`Could not leave handoff: ${e.message}`);
419
+ }
420
+ }
421
+ if (name === "omni_resolve_handoff") {
422
+ if (!bridge.connected)
423
+ return text("Not connected — call omni_connect first.");
424
+ const hid = String(args.id ?? "");
425
+ if (!hid)
426
+ return text("Provide the handoff `id` to resolve.");
427
+ try {
428
+ const { id, status } = await bridge.resolveHandoff({
429
+ id: hid,
430
+ status: args.status ? String(args.status) : undefined,
431
+ result: args.result !== undefined ? String(args.result) : undefined,
432
+ resultArtifactId: args.result_artifact_id ? String(args.result_artifact_id) : undefined,
433
+ });
434
+ return text(`Handoff ${id} is now ${status}. The room sees the update.`);
435
+ }
436
+ catch (e) {
437
+ return text(`Could not resolve handoff: ${e.message}`);
438
+ }
439
+ }
296
440
  if (name === "omni_stream") {
297
441
  if (!bridge.connected)
298
442
  return text("Not connected — call omni_connect first.");
@@ -330,6 +474,22 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
330
474
  ? `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
475
  : `Ran ${connector}.${action} (read-only) — the result is posted in the room.`);
332
476
  }
477
+ if (name === "omni_upload") {
478
+ if (!bridge.connected)
479
+ return text("Not connected — call omni_connect first.");
480
+ try {
481
+ const { id } = await bridge.uploadFile({
482
+ filename: String(args.filename || "file.txt"),
483
+ content: String(args.content ?? ""),
484
+ contentType: args.content_type ? String(args.content_type) : undefined,
485
+ base64: !!args.base64,
486
+ });
487
+ return text(`Uploaded "${args.filename}" as a downloadable file artifact (${id}). The room can open/download it.`);
488
+ }
489
+ catch (e) {
490
+ return text(`Upload failed: ${e.message}`);
491
+ }
492
+ }
333
493
  if (name === "omni_history") {
334
494
  if (!bridge.connected)
335
495
  return text("Not connected — call omni_connect first.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "substrattice",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "mcpName": "io.github.gen-rl-millz/substrattice",
5
5
  "type": "module",
6
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.",