@plurnk/plurnk-agui 1.0.5 → 1.0.7

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/dist/Module.js CHANGED
@@ -3,13 +3,13 @@
3
3
  // the AG-UI+ HTTP/SSE listener and owns the client interface from there.
4
4
  //
5
5
  // This is the SINGLE-INTERFACE surface (AG-UI+), not the legacy bridge dialect:
6
- // POST / — the only endpoint. A run streams SSE. HITL is terminate-resume: a
7
- // stopped-world emits a request_approval/request_user_input TOOL_CALL and the run
8
- // FINISHES (the loop stays paused in-engine); the resume arrives as the next run's
6
+ // POST / — the only endpoint. A worker streams SSE. HITL is terminate-resume: a
7
+ // stopped-world emits a request_approval/request_user_input TOOL_CALL and the worker
8
+ // FINISHES (the loop stays paused in-engine); the resume arrives as the next worker's
9
9
  // tool-result message → resolveProposal → the continued loop streams there.
10
10
  // Reads ride STATE_SNAPSHOT on RUN_STARTED; no /plurnk/rpc, no /resolve.
11
- // An AG-UI threadId IS a plurnk session (`<prefix>-<threadId>`); the envelope's
12
- // modelRunId binds the thread (no lazy inference — createSession returns it).
11
+ // An AG-UI threadId IS a plurnk workspace (`<prefix>-<threadId>`); the envelope's
12
+ // modelWorkerId binds the thread (no lazy inference — createWorkspace returns it).
13
13
  var _a;
14
14
  import { createServer } from "node:http";
15
15
  import Portal from "./Portal.js";
@@ -22,15 +22,15 @@ class Module {
22
22
  #portal;
23
23
  #http;
24
24
  #threads = new Map(); // threadId → envelope
25
- #threadRuns = new Map(); // threadId → conversation runId
25
+ #threadRuns = new Map(); // threadId → conversation workerId
26
26
  // The control plane vs the world. A RUN lives in a world (a conversation, or an action
27
- // that reads/writes a session's log); a control-plane action (list/create/attach/discover/
28
- // auth) does NOT — so it must not bind or forge a session (operator ruling 2026-07-10:
29
- // "every run/thread requires a world, not everything"). Only these kinds bind a session.
27
+ // that reads/writes a workspace's log); a control-plane action (list/create/attach/discover/
28
+ // auth) does NOT — so it must not bind or forge a workspace (operator ruling 2026-07-10:
29
+ // "every worker/thread requires a world, not everything"). Only these kinds bind a workspace.
30
30
  static #WORLD_SCOPED = Object.freeze(new Set([
31
- "session.runs", "log.read", "loop.inject", "loop.cancel", "session.prompts", "session.rename",
32
- "session.constrain", "session.unconstrain", "session.constraints", "entry.read",
33
- "op.exec", "op.parse", "session.members", "op.look", "run.fork",
31
+ "workspace.workers", "log.read", "loop.inject", "loop.cancel", "workspace.prompts", "workspace.rename",
32
+ "workspace.constrain", "workspace.unconstrain", "workspace.constraints", "entry.read",
33
+ "op.exec", "op.parse", "workspace.members", "op.look", "run.fork",
34
34
  ]));
35
35
  constructor(seam, opts) {
36
36
  this.#seam = seam;
@@ -84,37 +84,45 @@ class Module {
84
84
  }
85
85
  catch (err) {
86
86
  const message = err instanceof Error ? err.message : String(err);
87
- if (!res.headersSent)
87
+ if (!res.headersSent) {
88
88
  res.writeHead(500, { "content-type": "application/json" });
89
- res.end(JSON.stringify({ error: message }));
89
+ res.end(JSON.stringify({ error: message }));
90
+ return;
91
+ }
92
+ // Post-headers throw (svc#480): the SSE is already open, so a JSON body is
93
+ // invisible to the event parser — the stream reads as a silent death. Emit a
94
+ // legible terminal RUN_ERROR frame instead (501 for the no-model case), then end.
95
+ const code = /no provider configured|no model|unknown alias/i.test(message) ? "501" : "500";
96
+ res.write(`data: ${JSON.stringify({ type: "RUN_ERROR", message, code })}\n\n`);
97
+ res.end();
90
98
  }
91
99
  }
92
100
  // THE PLURNK PARADIGM (operator ruling 2026-07-10): the name IS the identity,
93
101
  // verbatim. The SESSION is the WORLD (service SPEC, machine-processes) — selected by name via
94
- // `forwardedProps.plurnk.session`; attach it if it exists, create it with EXACTLY that
95
- // name if it doesn't. No prefixes, no forged names, no dual lookup. The session is
96
- // REQUIRED: a run has no existence without a world, so its absence is a contract
102
+ // `forwardedProps.plurnk.workspace`; attach it if it exists, create it with EXACTLY that
103
+ // name if it doesn't. No prefixes, no forged names, no dual lookup. The workspace is
104
+ // REQUIRED: a worker has no existence without a world, so its absence is a contract
97
105
  // violation the client must fix — never a workspace forged from the threadId.
98
- // The threadId is the CONVERSATION over that world — resolved to a run by
99
- // #conversationRun (svc#366 landed: the three doors are ensureModelRun, forkRun,
100
- // createConversationRun).
106
+ // The threadId is the CONVERSATION over that world — resolved to a worker by
107
+ // #conversationRun (svc#366 landed: the three doors are ensureModelWorker, forkWorker,
108
+ // createConversationWorker).
101
109
  async #envelope(threadId, forwarded) {
102
- const workspace = forwarded?.session;
110
+ const workspace = forwarded?.workspace;
103
111
  if (typeof workspace !== "string" || workspace.length === 0)
104
- throw new Error("forwardedProps.plurnk.session (a session name) is required");
112
+ throw new Error("forwardedProps.plurnk.workspace (a workspace name) is required");
105
113
  const cached = this.#threads.get(threadId);
106
114
  if (cached !== undefined)
107
115
  return { env: cached, reattached: true };
108
- const known = (await this.#seam.listSessions()).find((s) => s.name === workspace);
116
+ const known = (await this.#seam.listWorkspaces()).find((s) => s.name === workspace);
109
117
  let env;
110
118
  let reattached = false;
111
119
  if (known !== undefined) {
112
- env = await this.#seam.attachSession({ sessionId: known.id });
120
+ env = await this.#seam.attachWorkspace({ workspaceId: known.id });
113
121
  reattached = true;
114
122
  }
115
123
  else {
116
124
  const opts = forwarded ?? {};
117
- env = await this.#seam.createSession({
125
+ env = await this.#seam.createWorkspace({
118
126
  name: workspace,
119
127
  ...(typeof opts.projectRoot === "string" ? { projectRoot: opts.projectRoot } : {}),
120
128
  ...(Array.isArray(opts.constraints) ? { constraints: opts.constraints } : {}),
@@ -124,18 +132,18 @@ class Module {
124
132
  this.#threads.set(threadId, env);
125
133
  return { env, reattached };
126
134
  }
127
- // Resolve the thread's conversation run within its world. Cached per threadId;
128
- // run names are immutable so the binding can't rot.
135
+ // Resolve the thread's conversation worker within its world. Cached per threadId;
136
+ // worker names are immutable so the binding can't rot.
129
137
  async #conversationRun(threadId, env) {
130
138
  const cached = this.#threadRuns.get(threadId);
131
139
  if (cached !== undefined)
132
140
  return cached;
133
- const runId = threadId === env.sessionName
134
- ? await this.#seam.ensureModelRun(env.sessionId)
135
- : (await this.#seam.listRuns(env.sessionId)).find((r) => r.name === threadId)?.id
136
- ?? (await this.#seam.createConversationRun({ sessionId: env.sessionId, name: threadId })).runId;
137
- this.#threadRuns.set(threadId, runId);
138
- return runId;
141
+ const workerId = threadId === env.workspaceName
142
+ ? await this.#seam.ensureModelWorker(env.workspaceId)
143
+ : (await this.#seam.listWorkers(env.workspaceId)).find((r) => r.name === threadId)?.id
144
+ ?? (await this.#seam.createConversationWorker({ workspaceId: env.workspaceId, name: threadId })).workerId;
145
+ this.#threadRuns.set(threadId, workerId);
146
+ return workerId;
139
147
  }
140
148
  async #run(req, res) {
141
149
  const input = JSON.parse(await _a.#body(req));
@@ -143,18 +151,18 @@ class Module {
143
151
  throw new Error("RunAgentInput.threadId required");
144
152
  const forwarded = input.forwardedProps?.plurnk;
145
153
  // Control plane FIRST: a management action that doesn't live in a world (and an
146
- // unknown kind, which is no run at all) answers without binding — or forging — a
147
- // session. Only world-scoped actions and conversations reach #envelope below.
154
+ // unknown kind, which is no worker at all) answers without binding — or forging — a
155
+ // workspace. Only world-scoped actions and conversations reach #envelope below.
148
156
  const early = parseAction(input.forwardedProps);
149
157
  if (early !== null && !_a.#WORLD_SCOPED.has(early.kind))
150
158
  return await this.#controlRun(early, input, res);
151
159
  const { env, reattached } = await this.#envelope(input.threadId, forwarded);
152
- const sessionId = env.sessionId;
153
- // THREAD ↔ RUN (svc#366): the threadId is the CONVERSATION — a run over the
154
- // world. threadId == session name binds the model run (the default conversation);
155
- // a distinct threadId names its own run: found by name, else minted via
156
- // createConversationRun. The name is the identity at BOTH levels.
157
- const runId = await this.#conversationRun(input.threadId, env);
160
+ const workspaceId = env.workspaceId;
161
+ // THREAD ↔ RUN (svc#366): the threadId is the CONVERSATION — a worker over the
162
+ // world. threadId == workspace name binds the model worker (the default conversation);
163
+ // a distinct threadId names its own worker: found by name, else minted via
164
+ // createConversationWorker. The name is the identity at BOTH levels.
165
+ const workerId = await this.#conversationRun(input.threadId, env);
158
166
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
159
167
  let finished = false;
160
168
  let pausedOnProposal = false;
@@ -170,7 +178,7 @@ class Module {
170
178
  finished = true;
171
179
  if (heartbeat !== null)
172
180
  clearInterval(heartbeat);
173
- this.#portal.closeRun(sessionId, boundRun);
181
+ this.#portal.closeRun(workspaceId, boundRun);
174
182
  res.end();
175
183
  };
176
184
  const emit = (events) => {
@@ -188,79 +196,90 @@ class Module {
188
196
  finish();
189
197
  }
190
198
  };
191
- const boundRun = this.#portal.openThread({ sessionId, runId, threadId: input.threadId, emit, modelRunId: runId, inputRunId: input.runId });
199
+ const boundRun = this.#portal.openThread({ workspaceId, workerId, threadId: input.threadId, emit, modelWorkerId: workerId, inputRunId: input.runId });
192
200
  emit([
193
201
  { type: "RUN_STARTED", threadId: input.threadId, runId: input.runId },
194
- stateSnapshot({ providers: this.#seam.listProviders().aliases, session: { id: sessionId, name: env.sessionName, projectRoot: env.projectRoot } }),
202
+ stateSnapshot({ providers: this.#seam.listProviders().aliases, workspace: { id: workspaceId, name: env.workspaceName, projectRoot: env.projectRoot } }),
195
203
  ]);
196
204
  if (finished)
197
205
  return;
198
- // §3 — a management ACTION run (forwardedProps.plurnk.action): execute via the
199
- // seam; the outcome rides the session's CURRENT thread binding (Portal.finishRun),
200
- // never this closure — a proposal-gated action (op.exec → 202) terminates THIS
201
- // run and completes after the resume run rebinds the stream.
202
- const action = parseAction(input.forwardedProps);
203
- if (action !== null) {
204
- const finishAction = (outcome) => {
205
- const events = [actionResult(action.kind, outcome)];
206
- // Plain action (stream still open): answer on OUR OWN stream — concurrent
207
- // actions share a session, and the session binding is whoever bound last
208
- // (results would cross streams). Only a proposal-pause (this stream already
209
- // terminated) hands off to the session binding, which the resume run rebinds.
210
- if (!finished) {
211
- emit([...events, { type: "RUN_FINISHED", threadId: input.threadId, runId: input.runId }]);
206
+ try {
207
+ // §3 — a management ACTION run (forwardedProps.plurnk.action): execute via the
208
+ // seam; the outcome rides the workspace's CURRENT thread binding (Portal.finishRun),
209
+ // never this closure — a proposal-gated action (op.exec → 202) terminates THIS
210
+ // run and completes after the resume run rebinds the stream.
211
+ const action = parseAction(input.forwardedProps);
212
+ if (action !== null) {
213
+ const finishAction = (outcome) => {
214
+ const events = [actionResult(action.kind, outcome)];
215
+ // Plain action (stream still open): answer on OUR OWN stream — concurrent
216
+ // actions share a workspace, and the workspace binding is whoever bound last
217
+ // (results would cross streams). Only a proposal-pause (this stream already
218
+ // terminated) hands off to the workspace binding, which the resume run rebinds.
219
+ if (!finished) {
220
+ emit([...events, { type: "RUN_FINISHED", threadId: input.threadId, runId: input.runId }]);
221
+ return;
222
+ }
223
+ this.#portal.finishRun(workspaceId, events);
224
+ };
225
+ void this.#action(action, env, workerId)
226
+ // One queue barrier: a dispatch's channel notifies are enqueued but not yet
227
+ // delivered when its promise resolves — drain them so Portal's stream
228
+ // bookkeeping arms BEFORE the finish decision (then stream/concluded,
229
+ // not a timer, releases any deferral).
230
+ .then(async (outcome) => { await new Promise((r) => setImmediate(r)); finishAction(outcome); })
231
+ .catch((err) => finishAction({ ok: false, error: err instanceof Error ? err.message : String(err) }));
232
+ req.on("close", finish);
233
+ return;
234
+ }
235
+ // Terminate-resume, the resume half: a tool-result message resolves the paused
236
+ // proposal; the continued loop streams on THIS run. No new loop is driven.
237
+ const toolResult = [...(input.messages ?? [])].reverse().find((m) => m.role === "tool");
238
+ if (toolResult !== undefined && this.#portal.resolve(toolResult)) {
239
+ req.on("close", finish); // client hangup on a resume just detaches; the loop already runs
240
+ return;
241
+ }
242
+ const lastUser = [...(input.messages ?? [])].reverse().find((m) => m.role === "user");
243
+ if (lastUser?.content === undefined || lastUser.content.length === 0)
244
+ throw new Error("RunAgentInput.messages must carry a user message or a tool result");
245
+ if (reattached) {
246
+ const history = await this.#seam.readLog({ workspaceId, workerId, limit: 1000 }).catch(() => null);
247
+ if (history !== null)
248
+ emit([...this.#threadRouterReplay(workspaceId, history)]);
249
+ }
250
+ await this.#portal.run({
251
+ workspaceId, workerId, prompt: lastUser.content,
252
+ ...(typeof forwarded?.maxTurns === "number" ? { maxTurns: forwarded.maxTurns } : this.#opts.maxTurns !== undefined ? { maxTurns: this.#opts.maxTurns } : {}),
253
+ ...(typeof forwarded?.flags === "object" && forwarded.flags !== null ? { flags: forwarded.flags } : {}),
254
+ // #414 — per-loop model selection: the client sends alias+model on every loop
255
+ // (model = client-resolved <provider>/<model>, #90); forward both, the daemon's
256
+ // runLoop applies precedence (model wins) and resolves the provider per loop.
257
+ ...(typeof forwarded?.alias === "string" && forwarded.alias.length > 0 ? { alias: forwarded.alias } : {}),
258
+ ...(typeof forwarded?.model === "string" && forwarded.model.length > 0 ? { model: forwarded.model } : {}),
259
+ });
260
+ // A dropped SSE on a LIVE run cancels the loop (hangup is the abort). A worker we
261
+ // finished ourselves — terminal event or proposal-terminate — leaves the engine
262
+ // alone (the paused loop is exactly what the resume run needs).
263
+ req.on("close", () => {
264
+ if (finished)
212
265
  return;
213
- }
214
- this.#portal.finishRun(sessionId, events);
215
- };
216
- void this.#action(action, env, runId)
217
- // One queue barrier: a dispatch's channel notifies are enqueued but not yet
218
- // delivered when its promise resolves — drain them so Portal's stream
219
- // bookkeeping arms BEFORE the finish decision (then stream/concluded,
220
- // not a timer, releases any deferral).
221
- .then(async (outcome) => { await new Promise((r) => setImmediate(r)); finishAction(outcome); })
222
- .catch((err) => finishAction({ ok: false, error: err instanceof Error ? err.message : String(err) }));
223
- req.on("close", finish);
224
- return;
225
- }
226
- // Terminate-resume, the resume half: a tool-result message resolves the paused
227
- // proposal; the continued loop streams on THIS run. No new loop is driven.
228
- const toolResult = [...(input.messages ?? [])].reverse().find((m) => m.role === "tool");
229
- if (toolResult !== undefined && this.#portal.resolve(toolResult)) {
230
- req.on("close", finish); // client hangup on a resume just detaches; the loop already runs
231
- return;
266
+ this.#seam.cancelDrain(workerId);
267
+ finish();
268
+ });
232
269
  }
233
- const lastUser = [...(input.messages ?? [])].reverse().find((m) => m.role === "user");
234
- if (lastUser?.content === undefined || lastUser.content.length === 0)
235
- throw new Error("RunAgentInput.messages must carry a user message or a tool result");
236
- if (reattached) {
237
- const history = await this.#seam.readLog({ sessionId, runId, limit: 1000 }).catch(() => null);
238
- if (history !== null)
239
- emit([...this.#threadRouterReplay(sessionId, history)]);
270
+ catch (err) {
271
+ // Post-headers throw INSIDE the run (svc#480, completed): the frame alone is
272
+ // not enough — the heartbeat interval and the Portal binding are live, and a
273
+ // throw that escapes past finish() leaks them forever (the drill-hang). emit()
274
+ // writes the terminal frame AND finish()es on RUN_ERROR — one door out.
275
+ const message = err instanceof Error ? err.message : String(err);
276
+ const code = /no provider configured|no model|unknown alias/i.test(message) ? "501" : "500";
277
+ emit([{ type: "RUN_ERROR", message, code }]);
240
278
  }
241
- await this.#portal.run({
242
- sessionId, runId, prompt: lastUser.content,
243
- ...(typeof forwarded?.maxTurns === "number" ? { maxTurns: forwarded.maxTurns } : this.#opts.maxTurns !== undefined ? { maxTurns: this.#opts.maxTurns } : {}),
244
- ...(typeof forwarded?.flags === "object" && forwarded.flags !== null ? { flags: forwarded.flags } : {}),
245
- // #414 — per-loop model selection: the client sends alias+model on every loop
246
- // (model = client-resolved <provider>/<model>, #90); forward both, the daemon's
247
- // runLoop applies precedence (model wins) and resolves the provider per loop.
248
- ...(typeof forwarded?.alias === "string" && forwarded.alias.length > 0 ? { alias: forwarded.alias } : {}),
249
- ...(typeof forwarded?.model === "string" && forwarded.model.length > 0 ? { model: forwarded.model } : {}),
250
- });
251
- // A dropped SSE on a LIVE run cancels the loop (hangup is the abort). A run we
252
- // finished ourselves — terminal event or proposal-terminate — leaves the engine
253
- // alone (the paused loop is exactly what the resume run needs).
254
- req.on("close", () => {
255
- if (finished)
256
- return;
257
- this.#seam.cancelDrain(runId);
258
- finish();
259
- });
260
279
  }
261
280
  // A control-plane run: no world bound. Open the SSE, run the worldless verb, answer on
262
- // our own stream. No Portal thread, no model run — nothing to forge (operator ruling:
263
- // session-plane actions must not spin an ephemeral session).
281
+ // our own stream. No Portal thread, no model worker — nothing to forge (operator ruling:
282
+ // workspace-plane actions must not spin an ephemeral workspace).
264
283
  async #controlRun(action, input, res) {
265
284
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
266
285
  const emit = (e) => { res.write(`data: ${JSON.stringify(e)}\n\n`); };
@@ -275,7 +294,7 @@ class Module {
275
294
  // events the client un-projects. Built from the real surface — never a hand-kept list.
276
295
  #capabilities() {
277
296
  const methods = {};
278
- for (const k of ["ping", "discover", "providers.list", "session.list", "session.create", "session.attach", "auth.authorize", "auth.authorize.poll", ..._a.#WORLD_SCOPED])
297
+ for (const k of ["ping", "discover", "providers.list", "workspace.list", "workspace.create", "workspace.attach", "auth.authorize", "auth.authorize.poll", ..._a.#WORLD_SCOPED])
279
298
  methods[k] = true;
280
299
  const notifications = {};
281
300
  for (const n of ["log/entry", "loop/terminated", "loop/proposal", "telemetry/event", "stream/event", "stream/concluded"])
@@ -283,44 +302,44 @@ class Module {
283
302
  return { methods, notifications };
284
303
  }
285
304
  // The action executor — the verb surface. The control plane runs worldless; everything
286
- // below the guard operates within a bound session. An unknown kind is an honest error,
305
+ // below the guard operates within a bound workspace. An unknown kind is an honest error,
287
306
  // never a silent pass-through. loop.inject rides here too (§4): the seam's unified
288
307
  // runLoop folds a prompt into the active drain; the steered effect streams on the SSE.
289
308
  async #action(a, env, convRun) {
290
309
  const p = a.params;
291
310
  try {
292
- // The control plane — worldless verbs (no bound session; #WORLD_SCOPED gates this).
311
+ // The control plane — worldless verbs (no bound workspace; #WORLD_SCOPED gates this).
293
312
  switch (a.kind) {
294
313
  case "ping": return { ok: true, result: {} };
295
314
  case "discover": return { ok: true, result: this.#capabilities() };
296
315
  case "providers.list": return { ok: true, result: this.#seam.listProviders() };
297
- case "session.list": return { ok: true, result: { sessions: await this.#seam.listSessions() } };
298
- case "session.create": {
316
+ case "workspace.list": return { ok: true, result: { workspaces: await this.#seam.listWorkspaces() } };
317
+ case "workspace.create": {
299
318
  // The name IS the identity: an explicit name creates/attaches EXACTLY
300
- // that session; no name = the daemon names it and the real name binds.
319
+ // that workspace; no name = the daemon names it and the real name binds.
301
320
  if (typeof p.name === "string" && p.name.length > 0) {
302
- // The name IS the world here — feed it as the session so #envelope
303
- // creates/attaches exactly it (p carries no `session` of its own).
304
- const { env: created } = await this.#envelope(p.name, { ...p, session: p.name });
305
- return { ok: true, result: { id: created.sessionId, name: created.sessionName, runId: created.runId } };
321
+ // The name IS the world here — feed it as the workspace so #envelope
322
+ // creates/attaches exactly it (p carries no `workspace` of its own).
323
+ const { env: created } = await this.#envelope(p.name, { ...p, workspace: p.name });
324
+ return { ok: true, result: { id: created.workspaceId, name: created.workspaceName, workerId: created.workerId } };
306
325
  }
307
- const created = await this.#seam.createSession({
326
+ const created = await this.#seam.createWorkspace({
308
327
  ...(typeof p.projectRoot === "string" ? { projectRoot: p.projectRoot } : {}),
309
328
  ...(Array.isArray(p.constraints) ? { constraints: p.constraints } : {}),
310
329
  ...(typeof p.settings === "object" && p.settings !== null ? { settings: JSON.stringify(p.settings) } : {}),
311
330
  });
312
- this.#threads.set(created.sessionName, created);
313
- return { ok: true, result: { id: created.sessionId, name: created.sessionName, runId: created.runId } };
331
+ this.#threads.set(created.workspaceName, created);
332
+ return { ok: true, result: { id: created.workspaceId, name: created.workspaceName, workerId: created.workerId } };
314
333
  }
315
- case "session.attach": {
316
- // A REAL attach: rebind the thread map to the chosen session and hand
334
+ case "workspace.attach": {
335
+ // A REAL attach: rebind the thread map to the chosen workspace and hand
317
336
  // back its envelope — the picker does what it says (the unwired kind +
318
337
  // a nil-masking fallback produced the 2026-07-10 front-door disaster).
319
338
  if (typeof p.id !== "number")
320
- return { ok: false, error: "session.attach requires id" };
321
- const att = await this.#seam.attachSession({ sessionId: p.id, ...(typeof p.runId === "number" ? { runId: p.runId } : {}) });
322
- this.#threads.set(att.sessionName, att);
323
- return { ok: true, result: { id: att.sessionId, name: att.sessionName, runId: att.runId, modelRunId: att.modelRunId } };
339
+ return { ok: false, error: "workspace.attach requires id" };
340
+ const att = await this.#seam.attachWorkspace({ workspaceId: p.id, ...(typeof p.workerId === "number" ? { workerId: p.workerId } : {}) });
341
+ this.#threads.set(att.workspaceName, att);
342
+ return { ok: true, result: { id: att.workspaceId, name: att.workspaceName, workerId: att.workerId, modelWorkerId: att.modelWorkerId } };
324
343
  }
325
344
  case "auth.authorize": {
326
345
  // Stateless relay to the execs-mcp driver (settled: no auth seam — the
@@ -335,50 +354,50 @@ class Module {
335
354
  return { ok: true, result: await mcpPoll(p.target, { device: p.device }) };
336
355
  }
337
356
  }
338
- // Below this line lives IN a world. An unknown kind is no run at all; a
339
- // world-scoped kind with no bound session is a routing bug — both surface plainly.
357
+ // Below this line lives IN a world. An unknown kind is no worker at all; a
358
+ // world-scoped kind with no bound workspace is a routing bug — both surface plainly.
340
359
  if (!_a.#WORLD_SCOPED.has(a.kind))
341
360
  return { ok: false, error: `unknown action '${a.kind}'` };
342
361
  if (env === null)
343
- throw new Error(`action '${a.kind}' operates within a session, but none is bound`);
362
+ throw new Error(`action '${a.kind}' operates within a workspace, but none is bound`);
344
363
  switch (a.kind) {
345
- case "session.runs": return { ok: true, result: { runs: await this.#seam.listRuns(typeof p.id === "number" ? p.id : env.sessionId) } };
364
+ case "workspace.workers": return { ok: true, result: { workers: await this.#seam.listWorkers(typeof p.id === "number" ? p.id : env.workspaceId) } };
346
365
  case "log.read": {
347
- // Default run: the conversation (model run); p.runId pins another.
348
- const readRun = typeof p.runId === "number" ? p.runId : convRun ?? await this.#seam.ensureModelRun(env.sessionId);
349
- const entries = await this.#seam.readLog({ sessionId: env.sessionId, runId: readRun, ...(typeof p.limit === "number" ? { limit: p.limit } : {}), ...(typeof p.sinceId === "number" ? { sinceId: p.sinceId } : {}), ...(typeof p.loopId === "number" ? { loopId: p.loopId } : {}), ...(typeof p.turnId === "number" ? { turnId: p.turnId } : {}), ...(typeof p.loopSeq === "number" ? { loopSeq: p.loopSeq } : {}), ...(typeof p.turnSeq === "number" ? { turnSeq: p.turnSeq } : {}), ...(typeof p.sequence === "number" ? { sequence: p.sequence } : {}) });
366
+ // Default run: the conversation (model worker); p.workerId pins another.
367
+ const readRun = typeof p.workerId === "number" ? p.workerId : convRun ?? await this.#seam.ensureModelWorker(env.workspaceId);
368
+ const entries = await this.#seam.readLog({ workspaceId: env.workspaceId, workerId: readRun, ...(typeof p.limit === "number" ? { limit: p.limit } : {}), ...(typeof p.sinceId === "number" ? { sinceId: p.sinceId } : {}), ...(typeof p.loopId === "number" ? { loopId: p.loopId } : {}), ...(typeof p.turnId === "number" ? { turnId: p.turnId } : {}), ...(typeof p.loopSeq === "number" ? { loopSeq: p.loopSeq } : {}), ...(typeof p.turnSeq === "number" ? { turnSeq: p.turnSeq } : {}), ...(typeof p.sequence === "number" ? { sequence: p.sequence } : {}) });
350
369
  return { ok: true, result: { entries } };
351
370
  }
352
371
  case "loop.inject": {
353
372
  if (typeof p.prompt !== "string" || p.prompt.length === 0)
354
373
  return { ok: false, error: "loop.inject requires prompt" };
355
- const ack = await this.#seam.runLoop({ sessionId: env.sessionId, runId: convRun ?? await this.#seam.ensureModelRun(env.sessionId), prompt: p.prompt });
374
+ const ack = await this.#seam.runLoop({ workspaceId: env.workspaceId, workerId: convRun ?? await this.#seam.ensureModelWorker(env.workspaceId), prompt: p.prompt });
356
375
  return { ok: true, result: ack };
357
376
  }
358
377
  // The stop button (TUI /stop + Ctrl-C, nvim :PlurnkStop): abort the model
359
- // run's active drain. Mirrors the SSE-hangup abort, addressable as a verb.
360
- case "loop.cancel": return { ok: true, result: { cancelled: this.#seam.cancelDrain(convRun ?? await this.#seam.ensureModelRun(env.sessionId)) } };
361
- case "session.prompts": return { ok: true, result: { prompts: await this.#seam.listPrompts(env.sessionId, typeof p.limit === "number" ? p.limit : undefined) } };
362
- case "session.rename": {
378
+ // worker's active drain. Mirrors the SSE-hangup abort, addressable as a verb.
379
+ case "loop.cancel": return { ok: true, result: { cancelled: this.#seam.cancelDrain(convRun ?? await this.#seam.ensureModelWorker(env.workspaceId)) } };
380
+ case "workspace.prompts": return { ok: true, result: { prompts: await this.#seam.listPrompts(env.workspaceId, typeof p.limit === "number" ? p.limit : undefined) } };
381
+ case "workspace.rename": {
363
382
  if (typeof p.name !== "string" || p.name.length === 0)
364
- return { ok: false, error: "session.rename requires name" };
365
- return { ok: true, result: await this.#seam.renameSession(env.sessionId, p.name) };
383
+ return { ok: false, error: "workspace.rename requires name" };
384
+ return { ok: true, result: await this.#seam.renameWorkspace(env.workspaceId, p.name) };
366
385
  }
367
- case "session.constrain": {
386
+ case "workspace.constrain": {
368
387
  if (typeof p.effect !== "string" || typeof p.glob !== "string")
369
- return { ok: false, error: "session.constrain requires effect + glob" };
370
- return { ok: true, result: await this.#seam.constrain(env.sessionId, p.effect, p.glob) };
388
+ return { ok: false, error: "workspace.constrain requires effect + glob" };
389
+ return { ok: true, result: await this.#seam.constrain(env.workspaceId, p.effect, p.glob) };
371
390
  }
372
- case "session.unconstrain": {
391
+ case "workspace.unconstrain": {
373
392
  if (typeof p.effect !== "string" || typeof p.glob !== "string")
374
- return { ok: false, error: "session.unconstrain requires effect + glob" };
375
- return { ok: true, result: await this.#seam.unconstrain(env.sessionId, p.effect, p.glob) };
393
+ return { ok: false, error: "workspace.unconstrain requires effect + glob" };
394
+ return { ok: true, result: await this.#seam.unconstrain(env.workspaceId, p.effect, p.glob) };
376
395
  }
377
- case "session.constraints": return { ok: true, result: { constraints: await this.#seam.listConstraints(env.sessionId) } };
396
+ case "workspace.constraints": return { ok: true, result: { constraints: await this.#seam.listConstraints(env.workspaceId) } };
378
397
  case "entry.read": {
379
398
  if (typeof p.target !== "string")
380
399
  return { ok: false, error: "entry.read requires target" };
381
- return { ok: true, result: await this.#seam.readEntry({ sessionId: env.sessionId, target: p.target, ...(typeof p.channel === "string" ? { channel: p.channel } : {}), ...(typeof p.offset === "number" ? { offset: p.offset } : {}) }) };
400
+ return { ok: true, result: await this.#seam.readEntry({ workspaceId: env.workspaceId, target: p.target, ...(typeof p.channel === "string" ? { channel: p.channel } : {}), ...(typeof p.offset === "number" ? { offset: p.offset } : {}) }) };
382
401
  }
383
402
  case "op.exec": {
384
403
  // EXEC constructed structurally (no DSL text): the model-facing shape,
@@ -387,8 +406,8 @@ class Module {
387
406
  return { ok: false, error: "op.exec requires command" };
388
407
  const statement = { op: "EXEC", suffix: "", signal: null, target: null, lineMarker: null, body: p.command, position: { line: 1, col: 1 } };
389
408
  // Client ops journal as client-origin turns in the CLIENT run (run-split:
390
- // only LOOPS live in the model run).
391
- return { ok: true, result: await this.#seam.dispatchAsClient({ sessionId: env.sessionId, runId: env.runId, statement }) };
409
+ // only LOOPS live in the model worker).
410
+ return { ok: true, result: await this.#seam.dispatchAsClient({ workspaceId: env.workspaceId, workerId: env.workerId, statement }) };
392
411
  }
393
412
  case "op.parse": {
394
413
  // Raw DSL parsed at the module's edge (the grammar is a family-internal
@@ -398,7 +417,7 @@ class Module {
398
417
  return { ok: false, error: "op.parse requires text" };
399
418
  const parsed = PlurnkParser.parseClient(p.text);
400
419
  const results = [];
401
- const runId = env.runId; // client ops ride the client run
420
+ const workerId = env.workerId; // client ops ride the client worker
402
421
  for (const item of parsed.items) {
403
422
  if (item.kind === "error") {
404
423
  results.push({ status: 400, error: String(item.error.message ?? item.error) });
@@ -406,11 +425,11 @@ class Module {
406
425
  }
407
426
  if (item.kind !== "statement")
408
427
  continue; // interstitial text isn't dispatchable
409
- results.push(await this.#seam.dispatchAsClient({ sessionId: env.sessionId, runId, statement: item.statement }));
428
+ results.push(await this.#seam.dispatchAsClient({ workspaceId: env.workspaceId, workerId, statement: item.statement }));
410
429
  }
411
430
  return { ok: true, result: { results } };
412
431
  }
413
- case "session.members": return { ok: true, result: await this.#seam.listMembers(env.sessionId) };
432
+ case "workspace.members": return { ok: true, result: await this.#seam.listMembers(env.workspaceId) };
414
433
  case "op.look": {
415
434
  // Parse at the edge; LOOK is the wire spelling of the read-projection —
416
435
  // rewrite to READ (Engine.look enforces READ-only).
@@ -421,9 +440,9 @@ class Module {
421
440
  if (item === undefined || item.kind !== "statement")
422
441
  return { ok: false, error: "op.look: no statement parsed" };
423
442
  const statement = { ...item.statement, op: "READ" };
424
- return { ok: true, result: await this.#seam.look({ sessionId: env.sessionId, runId: env.runId, statement }) };
443
+ return { ok: true, result: await this.#seam.look({ workspaceId: env.workspaceId, workerId: env.workerId, statement }) };
425
444
  }
426
- case "run.fork": return { ok: true, result: await this.#seam.forkRun({ sessionId: env.sessionId, runId: convRun ?? await this.#seam.ensureModelRun(env.sessionId), ...(typeof p.name === "string" ? { name: p.name } : {}) }) };
445
+ case "run.fork": return { ok: true, result: await this.#seam.forkWorker({ workspaceId: env.workspaceId, workerId: convRun ?? await this.#seam.ensureModelWorker(env.workspaceId), ...(typeof p.name === "string" ? { name: p.name } : {}) }) };
427
446
  default: return { ok: false, error: `unknown action '${a.kind}'` };
428
447
  }
429
448
  }
@@ -431,7 +450,7 @@ class Module {
431
450
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
432
451
  }
433
452
  }
434
- #threadRouterReplay(sessionId, entries) {
453
+ #threadRouterReplay(workspaceId, entries) {
435
454
  // Replay via the thread's own router (MESSAGES_SNAPSHOT of the model SENDs).
436
455
  const messages = [];
437
456
  for (const e of entries) {