substrattice 0.1.2 → 0.1.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 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,107 @@ 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
+ }
201
310
  /** Upload a FILE into the room (a real downloadable file artifact). Text by
202
311
  * default; pass base64 for binary. Returns the created artifact id. */
203
312
  async uploadFile(input) {
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";
@@ -46,6 +46,18 @@ const HELP = [
46
46
  " an expandable log. Start with a title (→ activity_id), append text, end",
47
47
  " with done:true. Use it WHILE you work, between replies.",
48
48
  "",
49
+ "▶ ASYNC WORK (respond later, even when no one's around):",
50
+ " • omni_inbox() open work left for you/your role — your async inbox.",
51
+ " • omni_leave_handoff({ title, body, to_agent_role?, to_user_id?, repo? })",
52
+ " leave work for a teammate or another AI to pick up later.",
53
+ " • omni_resolve_handoff({ id, status:\"done\", result, result_artifact_id })",
54
+ " answer one: do the work, share the result artifact, then close it.",
55
+ " Connect with a role + a stable key so a reconnect reclaims your queued work:",
56
+ " omni_connect({ room, token, role:\"coder\", key:\"me-coder\" }).",
57
+ "",
58
+ "▶ CO-EDIT CODE: omni_edit_artifact({ id, content, expected_version? })",
59
+ " Iterate with others on a shared code/doc artifact; bumps its version.",
60
+ "",
49
61
  "▶ ACT ON A WORKSPACE (safely): omni_request_action({ connector, action, args })",
50
62
  " Propose a governed connector action (e.g. github.open_pr). It enters the",
51
63
  " host's APPROVAL QUEUE and runs only after a human approves. Only the room's",
@@ -67,6 +79,8 @@ async function autoConnect() {
67
79
  token: env.OMNI_TOKEN ?? "",
68
80
  room: env.OMNI_ROOM,
69
81
  label: env.OMNI_LABEL ?? "Claude Code",
82
+ role: env.OMNI_ROLE,
83
+ key: env.OMNI_AGENT_KEY,
70
84
  });
71
85
  return `Connected to room ${env.OMNI_ROOM} as agent ${agentId} ("${bridge.label}").`;
72
86
  }
@@ -85,6 +99,8 @@ const tools = [
85
99
  token: { type: "string", description: "Agent token for the room (overrides OMNI_TOKEN env)." },
86
100
  url: { type: "string", description: "Server URL (overrides OMNI_URL env)." },
87
101
  label: { type: "string", description: "Display name shown in the room (optional)." },
102
+ role: { type: "string", description: "Declared role for this agent (e.g. planner, coder, reviewer) — shown in the roster (optional)." },
103
+ 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
104
  },
89
105
  required: ["room"],
90
106
  },
@@ -162,6 +178,42 @@ const tools = [
162
178
  required: ["filename", "content"],
163
179
  },
164
180
  },
181
+ {
182
+ name: "omni_inbox",
183
+ 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.",
184
+ inputSchema: { type: "object", properties: {} },
185
+ },
186
+ {
187
+ name: "omni_leave_handoff",
188
+ 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.",
189
+ inputSchema: {
190
+ type: "object",
191
+ properties: {
192
+ title: { type: "string", description: "Short headline for the work." },
193
+ body: { type: "string", description: "Describe the task / leave the message." },
194
+ to_user_id: { type: "string", description: "Address a specific teammate (account id), optional." },
195
+ to_agent_role: { type: "string", description: "Address an AI role (e.g. coder, reviewer) or 'any', optional." },
196
+ repo: { type: "string", description: "Linked repo (owner/name or URL), optional." },
197
+ url: { type: "string", description: "Any external link, optional." },
198
+ artifact_id: { type: "string", description: "A related artifact id (the spec/input), optional." },
199
+ },
200
+ required: ["title", "body"],
201
+ },
202
+ },
203
+ {
204
+ name: "omni_resolve_handoff",
205
+ 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.",
206
+ inputSchema: {
207
+ type: "object",
208
+ properties: {
209
+ id: { type: "string", description: "Handoff id (from omni_inbox)." },
210
+ status: { type: "string", description: "in_progress | done | cancelled" },
211
+ result: { type: "string", description: "Short summary of the answer/outcome." },
212
+ result_artifact_id: { type: "string", description: "The artifact you produced as the answer (from omni_share_artifact)." },
213
+ },
214
+ required: ["id"],
215
+ },
216
+ },
165
217
  {
166
218
  name: "omni_history",
167
219
  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.",
@@ -194,6 +246,21 @@ const tools = [
194
246
  required: ["title", "kind"],
195
247
  },
196
248
  },
249
+ {
250
+ name: "omni_edit_artifact",
251
+ 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).",
252
+ inputSchema: {
253
+ type: "object",
254
+ properties: {
255
+ id: { type: "string", description: "Artifact id to edit (from omni_share_artifact or a shared card)." },
256
+ content: { type: "string", description: "New full content (replaces the prior text)." },
257
+ title: { type: "string", description: "New title (optional)." },
258
+ language: { type: "string", description: "New language/format hint (optional)." },
259
+ expected_version: { type: "number", description: "The version you edited (optimistic concurrency; 409 if it moved on)." },
260
+ },
261
+ required: ["id"],
262
+ },
263
+ },
197
264
  ];
198
265
  const server = new Server({ name: "omni", version: "0.1.0" }, { capabilities: { tools: {} } });
199
266
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
@@ -207,6 +274,8 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
207
274
  token: args.token ? String(args.token) : env.OMNI_TOKEN ?? "",
208
275
  room: String(args.room),
209
276
  label: args.label ? String(args.label) : env.OMNI_LABEL ?? "Claude Code",
277
+ role: args.role ? String(args.role) : env.OMNI_ROLE,
278
+ key: args.key ? String(args.key) : env.OMNI_AGENT_KEY,
210
279
  });
211
280
  roomSkills = await bridge.listSkills();
212
281
  const skills = roomSkills;
@@ -307,6 +376,79 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
307
376
  return text(`Could not share artifact: ${e.message}`);
308
377
  }
309
378
  }
379
+ if (name === "omni_edit_artifact") {
380
+ if (!bridge.connected)
381
+ return text("Not connected — call omni_connect first.");
382
+ const aid = String(args.id ?? "");
383
+ if (!aid)
384
+ return text("Provide the artifact `id` to edit.");
385
+ try {
386
+ const { id, version } = await bridge.editArtifact({
387
+ id: aid,
388
+ content: args.content !== undefined ? String(args.content) : undefined,
389
+ title: args.title !== undefined ? String(args.title) : undefined,
390
+ language: args.language !== undefined ? String(args.language) : undefined,
391
+ expectedVersion: args.expected_version !== undefined ? Number(args.expected_version) : undefined,
392
+ });
393
+ return text(`Edited artifact ${id} → version ${version}. Everyone in the room sees the update live.`);
394
+ }
395
+ catch (e) {
396
+ return text(`Could not edit artifact: ${e.message} (on a version conflict, re-fetch with omni_history/the shared card and retry).`);
397
+ }
398
+ }
399
+ if (name === "omni_inbox") {
400
+ if (!bridge.connected)
401
+ return text("Not connected — call omni_connect first.");
402
+ try {
403
+ const items = await bridge.inbox();
404
+ if (items.length === 0)
405
+ return text("📭 Your async inbox is empty — no open work left for you in this room.");
406
+ const lines = items.map((h) => `• [${h.id}] ${h.title}${h.toAgentRole ? ` (for @${h.toAgentRole})` : ""}\n ${h.body.slice(0, 240)}${h.body.length > 240 ? "…" : ""}` +
407
+ `${h.repo ? `\n repo: ${h.repo}` : ""}${h.url ? `\n link: ${h.url}` : ""}${h.artifactId ? `\n artifact: ${h.artifactId}` : ""}`);
408
+ 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 }).`);
409
+ }
410
+ catch (e) {
411
+ return text(`Could not read inbox: ${e.message}`);
412
+ }
413
+ }
414
+ if (name === "omni_leave_handoff") {
415
+ if (!bridge.connected)
416
+ return text("Not connected — call omni_connect first.");
417
+ try {
418
+ const { id } = await bridge.leaveHandoff({
419
+ title: String(args.title ?? "Untitled"),
420
+ body: String(args.body ?? ""),
421
+ toUserId: args.to_user_id ? String(args.to_user_id) : undefined,
422
+ toAgentRole: args.to_agent_role ? String(args.to_agent_role) : undefined,
423
+ repo: args.repo ? String(args.repo) : undefined,
424
+ url: args.url ? String(args.url) : undefined,
425
+ artifactId: args.artifact_id ? String(args.artifact_id) : undefined,
426
+ });
427
+ return text(`Left async work ${id} in the room — it waits until it's picked up.`);
428
+ }
429
+ catch (e) {
430
+ return text(`Could not leave handoff: ${e.message}`);
431
+ }
432
+ }
433
+ if (name === "omni_resolve_handoff") {
434
+ if (!bridge.connected)
435
+ return text("Not connected — call omni_connect first.");
436
+ const hid = String(args.id ?? "");
437
+ if (!hid)
438
+ return text("Provide the handoff `id` to resolve.");
439
+ try {
440
+ const { id, status } = await bridge.resolveHandoff({
441
+ id: hid,
442
+ status: args.status ? String(args.status) : undefined,
443
+ result: args.result !== undefined ? String(args.result) : undefined,
444
+ resultArtifactId: args.result_artifact_id ? String(args.result_artifact_id) : undefined,
445
+ });
446
+ return text(`Handoff ${id} is now ${status}. The room sees the update.`);
447
+ }
448
+ catch (e) {
449
+ return text(`Could not resolve handoff: ${e.message}`);
450
+ }
451
+ }
310
452
  if (name === "omni_stream") {
311
453
  if (!bridge.connected)
312
454
  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.2",
3
+ "version": "0.1.4",
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.",