@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/README.md +13 -13
- package/SPEC.md +59 -24
- 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 +43 -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 -154
- 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 +81 -29
- package/dist/Translator.js.map +1 -1
- package/dist/types.d.ts +38 -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,79 +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
|
-
// #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
|
|
263
|
-
//
|
|
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", "
|
|
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
|
|
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
|
|
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 "
|
|
298
|
-
case "
|
|
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
|
|
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
|
|
303
|
-
// creates/attaches exactly it (p carries no `
|
|
304
|
-
const { env: created } = await this.#envelope(p.name, { ...p,
|
|
305
|
-
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 } };
|
|
306
325
|
}
|
|
307
|
-
const created = await this.#seam.
|
|
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.
|
|
313
|
-
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 } };
|
|
314
333
|
}
|
|
315
|
-
case "
|
|
316
|
-
// 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
|
|
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: "
|
|
321
|
-
const att = await this.#seam.
|
|
322
|
-
this.#threads.set(att.
|
|
323
|
-
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 } };
|
|
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
|
|
339
|
-
// 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.
|
|
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
|
|
362
|
+
throw new Error(`action '${a.kind}' operates within a workspace, but none is bound`);
|
|
344
363
|
switch (a.kind) {
|
|
345
|
-
case "
|
|
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
|
|
348
|
-
const readRun = typeof p.
|
|
349
|
-
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 } : {}) });
|
|
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({
|
|
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
|
-
//
|
|
360
|
-
case "loop.cancel": return { ok: true, result: { cancelled: this.#seam.cancelDrain(convRun ?? await this.#seam.
|
|
361
|
-
case "
|
|
362
|
-
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": {
|
|
363
382
|
if (typeof p.name !== "string" || p.name.length === 0)
|
|
364
|
-
return { ok: false, error: "
|
|
365
|
-
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) };
|
|
366
385
|
}
|
|
367
|
-
case "
|
|
386
|
+
case "workspace.constrain": {
|
|
368
387
|
if (typeof p.effect !== "string" || typeof p.glob !== "string")
|
|
369
|
-
return { ok: false, error: "
|
|
370
|
-
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) };
|
|
371
390
|
}
|
|
372
|
-
case "
|
|
391
|
+
case "workspace.unconstrain": {
|
|
373
392
|
if (typeof p.effect !== "string" || typeof p.glob !== "string")
|
|
374
|
-
return { ok: false, error: "
|
|
375
|
-
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) };
|
|
376
395
|
}
|
|
377
|
-
case "
|
|
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({
|
|
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
|
|
391
|
-
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 }) };
|
|
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
|
|
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({
|
|
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 "
|
|
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({
|
|
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.
|
|
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(
|
|
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) {
|