@plurnk/plurnk-agui 1.0.4 → 1.0.6
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 +13 -13
- package/SPEC.md +23 -23
- package/dist/AguiPlus.d.ts +3 -3
- package/dist/AguiPlus.d.ts.map +1 -1
- package/dist/AguiPlus.js +2 -2
- package/dist/AguiPlus.js.map +1 -1
- package/dist/DaemonSeam.d.ts +45 -43
- package/dist/DaemonSeam.d.ts.map +1 -1
- package/dist/DaemonSeam.js +1 -1
- package/dist/DaemonSeam.js.map +1 -1
- package/dist/EventRouter.d.ts +2 -2
- package/dist/EventRouter.d.ts.map +1 -1
- package/dist/EventRouter.js +24 -3
- package/dist/EventRouter.js.map +1 -1
- package/dist/Module.d.ts.map +1 -1
- package/dist/Module.js +173 -149
- package/dist/Module.js.map +1 -1
- package/dist/Portal.d.ts +8 -8
- package/dist/Portal.d.ts.map +1 -1
- package/dist/Portal.js +29 -29
- package/dist/Portal.js.map +1 -1
- package/dist/ProposalHitl.d.ts +2 -2
- package/dist/ProposalHitl.d.ts.map +1 -1
- package/dist/ProposalHitl.js +12 -12
- package/dist/ProposalHitl.js.map +1 -1
- package/dist/Translator.d.ts +2 -2
- package/dist/Translator.d.ts.map +1 -1
- package/dist/Translator.js +36 -27
- package/dist/Translator.js.map +1 -1
- package/dist/types.d.ts +33 -10
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
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
|
|
7
|
-
// stopped-world emits a request_approval/request_user_input TOOL_CALL and the
|
|
8
|
-
// FINISHES (the loop stays paused in-engine); the resume arrives as the next
|
|
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
|
|
12
|
-
//
|
|
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
|
|
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
|
|
28
|
-
// auth) does NOT — so it must not bind or forge a
|
|
29
|
-
// "every
|
|
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
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"op.exec", "op.parse", "
|
|
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
|
-
|
|
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.
|
|
95
|
-
// name if it doesn't. No prefixes, no forged names, no dual lookup. The
|
|
96
|
-
// REQUIRED: a
|
|
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
|
|
99
|
-
// #conversationRun (svc#366 landed: the three doors are
|
|
100
|
-
//
|
|
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?.
|
|
110
|
+
const workspace = forwarded?.workspace;
|
|
103
111
|
if (typeof workspace !== "string" || workspace.length === 0)
|
|
104
|
-
throw new Error("forwardedProps.plurnk.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
128
|
-
//
|
|
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
|
|
134
|
-
? await this.#seam.
|
|
135
|
-
: (await this.#seam.
|
|
136
|
-
?? (await this.#seam.
|
|
137
|
-
this.#threadRuns.set(threadId,
|
|
138
|
-
return
|
|
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
|
|
147
|
-
//
|
|
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
|
|
153
|
-
// THREAD ↔ RUN (svc#366): the threadId is the CONVERSATION — a
|
|
154
|
-
// world. threadId ==
|
|
155
|
-
// a distinct threadId names its own
|
|
156
|
-
//
|
|
157
|
-
const
|
|
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(
|
|
181
|
+
this.#portal.closeRun(workspaceId, boundRun);
|
|
174
182
|
res.end();
|
|
175
183
|
};
|
|
176
184
|
const emit = (events) => {
|
|
@@ -188,74 +196,90 @@ class Module {
|
|
|
188
196
|
finish();
|
|
189
197
|
}
|
|
190
198
|
};
|
|
191
|
-
const boundRun = this.#portal.openThread({
|
|
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,
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
});
|
|
246
|
-
// A dropped SSE on a LIVE run cancels the loop (hangup is the abort). A run we
|
|
247
|
-
// finished ourselves — terminal event or proposal-terminate — leaves the engine
|
|
248
|
-
// alone (the paused loop is exactly what the resume run needs).
|
|
249
|
-
req.on("close", () => {
|
|
250
|
-
if (finished)
|
|
251
|
-
return;
|
|
252
|
-
this.#seam.cancelDrain(runId);
|
|
253
|
-
finish();
|
|
254
|
-
});
|
|
255
279
|
}
|
|
256
280
|
// A control-plane run: no world bound. Open the SSE, run the worldless verb, answer on
|
|
257
|
-
// our own stream. No Portal thread, no model
|
|
258
|
-
//
|
|
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).
|
|
259
283
|
async #controlRun(action, input, res) {
|
|
260
284
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", "connection": "keep-alive" });
|
|
261
285
|
const emit = (e) => { res.write(`data: ${JSON.stringify(e)}\n\n`); };
|
|
@@ -270,7 +294,7 @@ class Module {
|
|
|
270
294
|
// events the client un-projects. Built from the real surface — never a hand-kept list.
|
|
271
295
|
#capabilities() {
|
|
272
296
|
const methods = {};
|
|
273
|
-
for (const k of ["ping", "discover", "providers.list", "
|
|
297
|
+
for (const k of ["ping", "discover", "providers.list", "workspace.list", "workspace.create", "workspace.attach", "auth.authorize", "auth.authorize.poll", ..._a.#WORLD_SCOPED])
|
|
274
298
|
methods[k] = true;
|
|
275
299
|
const notifications = {};
|
|
276
300
|
for (const n of ["log/entry", "loop/terminated", "loop/proposal", "telemetry/event", "stream/event", "stream/concluded"])
|
|
@@ -278,44 +302,44 @@ class Module {
|
|
|
278
302
|
return { methods, notifications };
|
|
279
303
|
}
|
|
280
304
|
// The action executor — the verb surface. The control plane runs worldless; everything
|
|
281
|
-
// below the guard operates within a bound
|
|
305
|
+
// below the guard operates within a bound workspace. An unknown kind is an honest error,
|
|
282
306
|
// never a silent pass-through. loop.inject rides here too (§4): the seam's unified
|
|
283
307
|
// runLoop folds a prompt into the active drain; the steered effect streams on the SSE.
|
|
284
308
|
async #action(a, env, convRun) {
|
|
285
309
|
const p = a.params;
|
|
286
310
|
try {
|
|
287
|
-
// The control plane — worldless verbs (no bound
|
|
311
|
+
// The control plane — worldless verbs (no bound workspace; #WORLD_SCOPED gates this).
|
|
288
312
|
switch (a.kind) {
|
|
289
313
|
case "ping": return { ok: true, result: {} };
|
|
290
314
|
case "discover": return { ok: true, result: this.#capabilities() };
|
|
291
315
|
case "providers.list": return { ok: true, result: this.#seam.listProviders() };
|
|
292
|
-
case "
|
|
293
|
-
case "
|
|
316
|
+
case "workspace.list": return { ok: true, result: { workspaces: await this.#seam.listWorkspaces() } };
|
|
317
|
+
case "workspace.create": {
|
|
294
318
|
// The name IS the identity: an explicit name creates/attaches EXACTLY
|
|
295
|
-
// that
|
|
319
|
+
// that workspace; no name = the daemon names it and the real name binds.
|
|
296
320
|
if (typeof p.name === "string" && p.name.length > 0) {
|
|
297
|
-
// The name IS the world here — feed it as the
|
|
298
|
-
// creates/attaches exactly it (p carries no `
|
|
299
|
-
const { env: created } = await this.#envelope(p.name, { ...p,
|
|
300
|
-
return { ok: true, result: { id: created.
|
|
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 } };
|
|
301
325
|
}
|
|
302
|
-
const created = await this.#seam.
|
|
326
|
+
const created = await this.#seam.createWorkspace({
|
|
303
327
|
...(typeof p.projectRoot === "string" ? { projectRoot: p.projectRoot } : {}),
|
|
304
328
|
...(Array.isArray(p.constraints) ? { constraints: p.constraints } : {}),
|
|
305
329
|
...(typeof p.settings === "object" && p.settings !== null ? { settings: JSON.stringify(p.settings) } : {}),
|
|
306
330
|
});
|
|
307
|
-
this.#threads.set(created.
|
|
308
|
-
return { ok: true, result: { id: created.
|
|
331
|
+
this.#threads.set(created.workspaceName, created);
|
|
332
|
+
return { ok: true, result: { id: created.workspaceId, name: created.workspaceName, workerId: created.workerId } };
|
|
309
333
|
}
|
|
310
|
-
case "
|
|
311
|
-
// A REAL attach: rebind the thread map to the chosen
|
|
334
|
+
case "workspace.attach": {
|
|
335
|
+
// A REAL attach: rebind the thread map to the chosen workspace and hand
|
|
312
336
|
// back its envelope — the picker does what it says (the unwired kind +
|
|
313
337
|
// a nil-masking fallback produced the 2026-07-10 front-door disaster).
|
|
314
338
|
if (typeof p.id !== "number")
|
|
315
|
-
return { ok: false, error: "
|
|
316
|
-
const att = await this.#seam.
|
|
317
|
-
this.#threads.set(att.
|
|
318
|
-
return { ok: true, result: { id: att.
|
|
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 } };
|
|
319
343
|
}
|
|
320
344
|
case "auth.authorize": {
|
|
321
345
|
// Stateless relay to the execs-mcp driver (settled: no auth seam — the
|
|
@@ -330,50 +354,50 @@ class Module {
|
|
|
330
354
|
return { ok: true, result: await mcpPoll(p.target, { device: p.device }) };
|
|
331
355
|
}
|
|
332
356
|
}
|
|
333
|
-
// Below this line lives IN a world. An unknown kind is no
|
|
334
|
-
// world-scoped kind with no bound
|
|
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.
|
|
335
359
|
if (!_a.#WORLD_SCOPED.has(a.kind))
|
|
336
360
|
return { ok: false, error: `unknown action '${a.kind}'` };
|
|
337
361
|
if (env === null)
|
|
338
|
-
throw new Error(`action '${a.kind}' operates within a
|
|
362
|
+
throw new Error(`action '${a.kind}' operates within a workspace, but none is bound`);
|
|
339
363
|
switch (a.kind) {
|
|
340
|
-
case "
|
|
364
|
+
case "workspace.workers": return { ok: true, result: { workers: await this.#seam.listWorkers(typeof p.id === "number" ? p.id : env.workspaceId) } };
|
|
341
365
|
case "log.read": {
|
|
342
|
-
// Default run: the conversation (model
|
|
343
|
-
const readRun = typeof p.
|
|
344
|
-
const entries = await this.#seam.readLog({
|
|
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 } : {}) });
|
|
345
369
|
return { ok: true, result: { entries } };
|
|
346
370
|
}
|
|
347
371
|
case "loop.inject": {
|
|
348
372
|
if (typeof p.prompt !== "string" || p.prompt.length === 0)
|
|
349
373
|
return { ok: false, error: "loop.inject requires prompt" };
|
|
350
|
-
const ack = await this.#seam.runLoop({
|
|
374
|
+
const ack = await this.#seam.runLoop({ workspaceId: env.workspaceId, workerId: convRun ?? await this.#seam.ensureModelWorker(env.workspaceId), prompt: p.prompt });
|
|
351
375
|
return { ok: true, result: ack };
|
|
352
376
|
}
|
|
353
377
|
// The stop button (TUI /stop + Ctrl-C, nvim :PlurnkStop): abort the model
|
|
354
|
-
//
|
|
355
|
-
case "loop.cancel": return { ok: true, result: { cancelled: this.#seam.cancelDrain(convRun ?? await this.#seam.
|
|
356
|
-
case "
|
|
357
|
-
case "
|
|
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": {
|
|
358
382
|
if (typeof p.name !== "string" || p.name.length === 0)
|
|
359
|
-
return { ok: false, error: "
|
|
360
|
-
return { ok: true, result: await this.#seam.
|
|
383
|
+
return { ok: false, error: "workspace.rename requires name" };
|
|
384
|
+
return { ok: true, result: await this.#seam.renameWorkspace(env.workspaceId, p.name) };
|
|
361
385
|
}
|
|
362
|
-
case "
|
|
386
|
+
case "workspace.constrain": {
|
|
363
387
|
if (typeof p.effect !== "string" || typeof p.glob !== "string")
|
|
364
|
-
return { ok: false, error: "
|
|
365
|
-
return { ok: true, result: await this.#seam.constrain(env.
|
|
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) };
|
|
366
390
|
}
|
|
367
|
-
case "
|
|
391
|
+
case "workspace.unconstrain": {
|
|
368
392
|
if (typeof p.effect !== "string" || typeof p.glob !== "string")
|
|
369
|
-
return { ok: false, error: "
|
|
370
|
-
return { ok: true, result: await this.#seam.unconstrain(env.
|
|
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) };
|
|
371
395
|
}
|
|
372
|
-
case "
|
|
396
|
+
case "workspace.constraints": return { ok: true, result: { constraints: await this.#seam.listConstraints(env.workspaceId) } };
|
|
373
397
|
case "entry.read": {
|
|
374
398
|
if (typeof p.target !== "string")
|
|
375
399
|
return { ok: false, error: "entry.read requires target" };
|
|
376
|
-
return { ok: true, result: await this.#seam.readEntry({
|
|
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 } : {}) }) };
|
|
377
401
|
}
|
|
378
402
|
case "op.exec": {
|
|
379
403
|
// EXEC constructed structurally (no DSL text): the model-facing shape,
|
|
@@ -382,8 +406,8 @@ class Module {
|
|
|
382
406
|
return { ok: false, error: "op.exec requires command" };
|
|
383
407
|
const statement = { op: "EXEC", suffix: "", signal: null, target: null, lineMarker: null, body: p.command, position: { line: 1, col: 1 } };
|
|
384
408
|
// Client ops journal as client-origin turns in the CLIENT run (run-split:
|
|
385
|
-
// only LOOPS live in the model
|
|
386
|
-
return { ok: true, result: await this.#seam.dispatchAsClient({
|
|
409
|
+
// only LOOPS live in the model worker).
|
|
410
|
+
return { ok: true, result: await this.#seam.dispatchAsClient({ workspaceId: env.workspaceId, workerId: env.workerId, statement }) };
|
|
387
411
|
}
|
|
388
412
|
case "op.parse": {
|
|
389
413
|
// Raw DSL parsed at the module's edge (the grammar is a family-internal
|
|
@@ -393,7 +417,7 @@ class Module {
|
|
|
393
417
|
return { ok: false, error: "op.parse requires text" };
|
|
394
418
|
const parsed = PlurnkParser.parseClient(p.text);
|
|
395
419
|
const results = [];
|
|
396
|
-
const
|
|
420
|
+
const workerId = env.workerId; // client ops ride the client worker
|
|
397
421
|
for (const item of parsed.items) {
|
|
398
422
|
if (item.kind === "error") {
|
|
399
423
|
results.push({ status: 400, error: String(item.error.message ?? item.error) });
|
|
@@ -401,11 +425,11 @@ class Module {
|
|
|
401
425
|
}
|
|
402
426
|
if (item.kind !== "statement")
|
|
403
427
|
continue; // interstitial text isn't dispatchable
|
|
404
|
-
results.push(await this.#seam.dispatchAsClient({
|
|
428
|
+
results.push(await this.#seam.dispatchAsClient({ workspaceId: env.workspaceId, workerId, statement: item.statement }));
|
|
405
429
|
}
|
|
406
430
|
return { ok: true, result: { results } };
|
|
407
431
|
}
|
|
408
|
-
case "
|
|
432
|
+
case "workspace.members": return { ok: true, result: await this.#seam.listMembers(env.workspaceId) };
|
|
409
433
|
case "op.look": {
|
|
410
434
|
// Parse at the edge; LOOK is the wire spelling of the read-projection —
|
|
411
435
|
// rewrite to READ (Engine.look enforces READ-only).
|
|
@@ -416,9 +440,9 @@ class Module {
|
|
|
416
440
|
if (item === undefined || item.kind !== "statement")
|
|
417
441
|
return { ok: false, error: "op.look: no statement parsed" };
|
|
418
442
|
const statement = { ...item.statement, op: "READ" };
|
|
419
|
-
return { ok: true, result: await this.#seam.look({
|
|
443
|
+
return { ok: true, result: await this.#seam.look({ workspaceId: env.workspaceId, workerId: env.workerId, statement }) };
|
|
420
444
|
}
|
|
421
|
-
case "run.fork": return { ok: true, result: await this.#seam.
|
|
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 } : {}) }) };
|
|
422
446
|
default: return { ok: false, error: `unknown action '${a.kind}'` };
|
|
423
447
|
}
|
|
424
448
|
}
|
|
@@ -426,7 +450,7 @@ class Module {
|
|
|
426
450
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
427
451
|
}
|
|
428
452
|
}
|
|
429
|
-
#threadRouterReplay(
|
|
453
|
+
#threadRouterReplay(workspaceId, entries) {
|
|
430
454
|
// Replay via the thread's own router (MESSAGES_SNAPSHOT of the model SENDs).
|
|
431
455
|
const messages = [];
|
|
432
456
|
for (const e of entries) {
|