@plurnk/plurnk-service 0.60.0 → 0.61.0

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.
Files changed (102) hide show
  1. package/.env.example +36 -9
  2. package/PLURNK_PERSONALITY.md +61 -0
  3. package/SPEC.md +20 -17
  4. package/dist/core/Dispatcher.d.ts +61 -0
  5. package/dist/core/Dispatcher.d.ts.map +1 -0
  6. package/dist/core/Dispatcher.js +811 -0
  7. package/dist/core/Dispatcher.js.map +1 -0
  8. package/dist/core/Engine.d.ts +9 -46
  9. package/dist/core/Engine.d.ts.map +1 -1
  10. package/dist/core/Engine.js +209 -1513
  11. package/dist/core/Engine.js.map +1 -1
  12. package/dist/core/Engine.sql +7 -2
  13. package/dist/core/ExecutorRegistry.d.ts +2 -1
  14. package/dist/core/ExecutorRegistry.d.ts.map +1 -1
  15. package/dist/core/ExecutorRegistry.js +16 -3
  16. package/dist/core/ExecutorRegistry.js.map +1 -1
  17. package/dist/core/PacketBuilder.d.ts +64 -0
  18. package/dist/core/PacketBuilder.d.ts.map +1 -0
  19. package/dist/core/PacketBuilder.js +365 -0
  20. package/dist/core/PacketBuilder.js.map +1 -0
  21. package/dist/core/ProposalLifecycle.d.ts +56 -0
  22. package/dist/core/ProposalLifecycle.d.ts.map +1 -0
  23. package/dist/core/ProposalLifecycle.js +195 -0
  24. package/dist/core/ProposalLifecycle.js.map +1 -0
  25. package/dist/core/SchemeRegistry.d.ts +2 -0
  26. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  27. package/dist/core/SchemeRegistry.js +20 -15
  28. package/dist/core/SchemeRegistry.js.map +1 -1
  29. package/dist/core/StrikeRail.d.ts +27 -0
  30. package/dist/core/StrikeRail.d.ts.map +1 -0
  31. package/dist/core/StrikeRail.js +147 -0
  32. package/dist/core/StrikeRail.js.map +1 -0
  33. package/dist/core/TelemetryChannel.d.ts +37 -0
  34. package/dist/core/TelemetryChannel.d.ts.map +1 -0
  35. package/dist/core/TelemetryChannel.js +77 -0
  36. package/dist/core/TelemetryChannel.js.map +1 -0
  37. package/dist/core/fork.d.ts +1 -0
  38. package/dist/core/fork.d.ts.map +1 -1
  39. package/dist/core/fork.js +17 -4
  40. package/dist/core/fork.js.map +1 -1
  41. package/dist/core/fork.sql +6 -0
  42. package/dist/core/packet-wire.js +1 -1
  43. package/dist/core/packet-wire.js.map +1 -1
  44. package/dist/core/plurnk-uri.d.ts +2 -0
  45. package/dist/core/plurnk-uri.d.ts.map +1 -1
  46. package/dist/core/plurnk-uri.js +11 -0
  47. package/dist/core/plurnk-uri.js.map +1 -1
  48. package/dist/core/run-cap.js +1 -1
  49. package/dist/core/run-cap.js.map +1 -1
  50. package/dist/digest/Digest.d.ts +14 -0
  51. package/dist/digest/Digest.d.ts.map +1 -0
  52. package/dist/digest/Digest.js +321 -0
  53. package/dist/digest/Digest.js.map +1 -0
  54. package/dist/digest/digest.sql +55 -0
  55. package/dist/schemes/Exec.d.ts.map +1 -1
  56. package/dist/schemes/Exec.js +22 -23
  57. package/dist/schemes/Exec.js.map +1 -1
  58. package/dist/schemes/File.js +2 -2
  59. package/dist/schemes/File.js.map +1 -1
  60. package/dist/schemes/Log.d.ts.map +1 -1
  61. package/dist/schemes/Log.js +5 -1
  62. package/dist/schemes/Log.js.map +1 -1
  63. package/dist/schemes/Run.js +3 -3
  64. package/dist/schemes/Run.js.map +1 -1
  65. package/dist/server/ClientConnection.d.ts.map +1 -1
  66. package/dist/server/ClientConnection.js +48 -2
  67. package/dist/server/ClientConnection.js.map +1 -1
  68. package/dist/server/Daemon.d.ts +1 -1
  69. package/dist/server/Daemon.d.ts.map +1 -1
  70. package/dist/server/Daemon.js +5 -1
  71. package/dist/server/Daemon.js.map +1 -1
  72. package/dist/server/methods/auth.d.ts +6 -0
  73. package/dist/server/methods/auth.d.ts.map +1 -0
  74. package/dist/server/methods/auth.js +45 -0
  75. package/dist/server/methods/auth.js.map +1 -0
  76. package/dist/server/methods/mcp_install.d.ts +6 -0
  77. package/dist/server/methods/mcp_install.d.ts.map +1 -0
  78. package/dist/server/methods/mcp_install.js +66 -0
  79. package/dist/server/methods/mcp_install.js.map +1 -0
  80. package/dist/server/methods/op_copy.d.ts.map +1 -1
  81. package/dist/server/methods/op_copy.js +1 -0
  82. package/dist/server/methods/op_copy.js.map +1 -1
  83. package/dist/server/methods/op_dispatch.d.ts.map +1 -1
  84. package/dist/server/methods/op_dispatch.js +1 -0
  85. package/dist/server/methods/op_dispatch.js.map +1 -1
  86. package/dist/server/methods/op_edit.d.ts.map +1 -1
  87. package/dist/server/methods/op_edit.js +1 -0
  88. package/dist/server/methods/op_edit.js.map +1 -1
  89. package/dist/server/methods/op_exec.d.ts.map +1 -1
  90. package/dist/server/methods/op_exec.js +1 -0
  91. package/dist/server/methods/op_exec.js.map +1 -1
  92. package/dist/server/methods/op_move.d.ts.map +1 -1
  93. package/dist/server/methods/op_move.js +1 -0
  94. package/dist/server/methods/op_move.js.map +1 -1
  95. package/dist/server/yolo.js +1 -1
  96. package/dist/server/yolo.js.map +1 -1
  97. package/dist/service.d.ts.map +1 -1
  98. package/dist/service.js +6 -0
  99. package/dist/service.js.map +1 -1
  100. package/docs/run.md +7 -3
  101. package/migrations/0000-00-00.01_schema.sql +3 -3
  102. package/package.json +22 -17
@@ -0,0 +1,811 @@
1
+ var _a;
2
+ import { parsePath } from "@plurnk/plurnk-grammar";
3
+ import { foldAuthorityIntoPath, schemeNameOf } from "./plurnk-uri.js";
4
+ import Fork from "./fork.js";
5
+ import RunCap from "./run-cap.js";
6
+ import { decodePathParens } from "./path-decode.js";
7
+ import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
8
+ import { LineMarkerOps, MimetypeBinary } from "../content/index.js";
9
+ import SchemeCtxImpl from "./caps/SchemeCtxImpl.js";
10
+ // SPEC §scheme-surface: writer must be in target scheme's manifest.writableBy.
11
+ // OPEN/FOLD/READ/FIND are not gated — they curate the log or read, never mutating an entry.
12
+ const MUTATING_OPS = new Set(["EDIT", "SEND", "COPY", "MOVE", "EXEC", "KILL"]);
13
+ const pathnameFromPath = (path) => {
14
+ if (path.kind === "regex")
15
+ return path.raw; // regex source — parens are syntax, never encoded
16
+ return decodePathParens(path.kind === "url" ? path.pathname : path.raw); // #239 item 4
17
+ };
18
+ // Op dispatch (§op-methods-op-dispatch): gates (writableBy, loop flags), the
19
+ // engine-owned op orchestrations (COPY/MOVE/KILL/SEND/READ-fanout), scheme
20
+ // routing, the durable log write, and the proposal pause.
21
+ class Dispatcher {
22
+ #db;
23
+ #schemes;
24
+ #mimetypes;
25
+ #tokenize;
26
+ #telemetry;
27
+ #proposals;
28
+ // Boot-discovered runtime executors, late-injected on Engine — thunked.
29
+ #executors;
30
+ // Per-loop abort signal, owned by Engine.runLoop — thunked.
31
+ #loopSignal;
32
+ #streamEventNotify;
33
+ #wakeRunNotify;
34
+ #injectRun;
35
+ #cancelRun;
36
+ constructor({ db, schemes, mimetypes, tokenize, telemetry, proposals, executors, loopSignal, streamEventNotify, wakeRunNotify, injectRun, cancelRun }) {
37
+ this.#db = db;
38
+ this.#schemes = schemes;
39
+ this.#mimetypes = mimetypes;
40
+ this.#tokenize = tokenize;
41
+ this.#telemetry = telemetry;
42
+ this.#proposals = proposals;
43
+ this.#executors = executors;
44
+ this.#loopSignal = loopSignal;
45
+ this.#streamEventNotify = streamEventNotify;
46
+ this.#wakeRunNotify = wakeRunNotify;
47
+ this.#injectRun = injectRun;
48
+ this.#cancelRun = cancelRun;
49
+ }
50
+ async dispatch(context) {
51
+ const { statement, sessionId, runId, loopId, turnId, sequence, origin, onDispatch, prematureRefusal } = context;
52
+ const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId, origin });
53
+ let result;
54
+ let denial = this.#checkWritable(statement, origin);
55
+ if (denial === null)
56
+ denial = await this.#checkFlagsGate(statement, loopId);
57
+ if (denial !== null) {
58
+ result = denial;
59
+ }
60
+ else if (_a.#readFansOut(statement)) {
61
+ // READ honors FIND: a glob/folder scope or a matcher fans out to one log row per MATCH
62
+ // (its own writeLogs), returning early. A READ never proposes, so it bypasses the
63
+ // single-row path below. A bare entry, body-less, falls through to the direct read. #286
64
+ return await this.#handleReadFanout(statement, schemeCtx, { runId, loopId, turnId, sequence, origin, onDispatch });
65
+ }
66
+ else {
67
+ // SPEC §scheme-surface + plurnk-schemes#1: action-entry-as-outcome. Scheme-handler
68
+ // exceptions become the action-entry's outcome (status 500), not a
69
+ // thrown bubble. The log_entry is the durable record; engine never
70
+ // skips it. Logging failures (#writeLog throws) are NOT caught —
71
+ // those are system failures.
72
+ try {
73
+ if (statement.op === "SEND" && statement.target === null) {
74
+ result = await this.#handleSendBroadcast(statement, loopId, prematureRefusal);
75
+ }
76
+ else if (statement.op === "COPY") {
77
+ result = await this.#handleCopy(statement, schemeCtx);
78
+ }
79
+ else if (statement.op === "MOVE") {
80
+ result = await this.#handleMove(statement, schemeCtx);
81
+ }
82
+ else if (statement.op === "KILL") {
83
+ result = await this.#handleKill(statement, schemeCtx);
84
+ }
85
+ else if (statement.op === "PLAN") {
86
+ result = this.#handlePlan(statement);
87
+ }
88
+ else if (statement.op === "EXEC") {
89
+ // EXEC's target slot is `cwd`, not a scheme address.
90
+ // Per plurnk.md the op routes unconditionally to the
91
+ // exec scheme; the scheme handler reads runtime
92
+ // (signal), cwd (target), and command (body).
93
+ result = await this.#run("exec", statement, schemeCtx);
94
+ }
95
+ else {
96
+ result = await this.#run(schemeNameOf(statement.target), statement, schemeCtx); // §op-methods-op-dispatch
97
+ }
98
+ }
99
+ catch (err) { // a scheme exception becomes the op's 500 outcome — §scheme-surface-exception-500
100
+ result = {
101
+ status: 500,
102
+ error: err instanceof Error ? err.message : String(err),
103
+ };
104
+ }
105
+ }
106
+ const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
107
+ onDispatch?.(logEntryId);
108
+ // Proposal lifecycle (SPEC.md §engine-rails + §methods loop.resolve; §proposal-202-pauses). When a
109
+ // side-effecting op returns status 202 (a broadcast SEND[202] park is model
110
+ // speech, not a proposal — #isProposal, #255), the entry is written
111
+ // state='proposed'; dispatch then PAUSES on a per-entry waiter until
112
+ // resolution arrives via Engine.resolveProposal (from the loop/resolve RPC,
113
+ // YOLO listener, or timeout). The post-resolution status replaces 202 in the
114
+ // result the caller sees, so runTurn never branches on a pending state.
115
+ if (_a.#isProposal(statement, result)) {
116
+ // Effect-gated auto-run (read/pure runtimes, plurnk-service#182):
117
+ // no human gate, no loop/proposal notification. Accept + apply
118
+ // in-process; the model sees the outcome directly, never a review.
119
+ if (result.attrs?.inline === true) {
120
+ const effective = await this.#proposals.runApply(statement, result, { decision: "accept" }, { sessionId, runId, loopId, turnId });
121
+ return this.#proposals.applyResolution(logEntryId, effective);
122
+ }
123
+ // Register the resolution waiter SYNCHRONOUSLY before any await
124
+ // yields. A same-tick resolveProposal() (e.g. from a test that
125
+ // awaits the onDispatch callback and immediately resolves) must
126
+ // find the waiter registered — adding an await between insert
127
+ // and waiter-registration would open a race window.
128
+ const resolutionPromise = this.#proposals.awaitResolution(logEntryId);
129
+ // Notify external listeners (Daemon broadcasts loop/proposal;
130
+ // YOLO listener auto-resolves) BEFORE awaiting — they may
131
+ // resolve synchronously inside their handlers.
132
+ const target = this.#extractTarget(statement.target);
133
+ const flags = await this.#loadLoopFlags(loopId); // the loop/proposal notification carries flags (yolo) — §dual-yolo-proposal-carries-flags
134
+ // #note10 — if the target diverged on disk this turn, the model's EDIT is based
135
+ // on a stale read; flag it so a YOLO auto-accept rejects instead of clobbering.
136
+ const diverged = await this.#db.engine_target_diverged_this_turn.get({ run_id: runId, turn_id: turnId, scheme: target.scheme, pathname: target.pathname });
137
+ const event = {
138
+ logEntryId, sessionId, runId, loopId, turnId,
139
+ op: statement.op,
140
+ target: { scheme: target.scheme, pathname: target.pathname },
141
+ body: typeof result.body === "string" ? result.body : "",
142
+ attrs: (result.attrs ?? {}),
143
+ flags,
144
+ staleClobberRisk: diverged !== undefined,
145
+ };
146
+ this.#proposals.notifyPending(event);
147
+ const resolution = await resolutionPromise;
148
+ // Run the scheme's applyResolution hook on accept (writes the
149
+ // file, spawns the process, etc.). If applyResolution returns a
150
+ // 4xx/5xx or throws, the resolution is downgraded to a reject
151
+ // with the failure outcome — engine treats it like a client
152
+ // rejection.
153
+ const effective = await this.#proposals.runApply(statement, result, resolution, { sessionId, runId, loopId, turnId });
154
+ // MOVE into a proposed dest: the deferred source-delete fires ONLY now,
155
+ // after the dest write landed (accept). On reject the source survives.
156
+ if (effective.decision === "accept") {
157
+ const moveSource = result.attrs?.moveSource;
158
+ if (moveSource !== undefined) {
159
+ const srcHandler = this.#schemes.get(moveSource.scheme);
160
+ if (srcHandler !== undefined && typeof srcHandler.deleteEntry === "function")
161
+ await srcHandler.deleteEntry(moveSource.pathname, schemeCtx);
162
+ }
163
+ }
164
+ const post = await this.#proposals.applyResolution(logEntryId, effective);
165
+ return post;
166
+ }
167
+ return result;
168
+ }
169
+ // op.look (#283) — resolve a READ and return its content WITHOUT writing a
170
+ // log_entries row: the client's off-run inspection primitive (LOOK → READ,
171
+ // invisible to the model). READ never mutates and never proposes, so this is
172
+ // dispatch's resolve path minus #writeLog. Runs on the client loop, so the
173
+ // human's inspection is never constrained by a model loop's flags. {§op-look}
174
+ async look(context) {
175
+ const { statement, sessionId, runId, loopId, origin = "client" } = context;
176
+ if (statement.op !== "READ")
177
+ throw new Error(`look resolves READ only; got ${statement.op}`);
178
+ // turnId is a write-time FK only — a look writes no row, so 0 (no turn) is inert.
179
+ const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId: 0, origin });
180
+ const denial = await this.#checkFlagsGate(statement, loopId);
181
+ if (denial !== null)
182
+ return denial;
183
+ return this.#run(schemeNameOf(statement.target), statement, schemeCtx);
184
+ }
185
+ #buildSchemeCtx(ids) {
186
+ const { sessionId, runId, loopId, turnId, origin } = ids;
187
+ return {
188
+ db: this.#db,
189
+ sessionId, runId, loopId, turnId,
190
+ writer: origin,
191
+ signal: this.#loopSignal(loopId),
192
+ streamEventNotify: this.#streamEventNotify,
193
+ wakeRunNotify: this.#wakeRunNotify,
194
+ injectRun: this.#injectRun,
195
+ mimetypes: this.#mimetypes,
196
+ tokenize: this.#tokenize,
197
+ pushTelemetry: (event) => this.#telemetry.push(sessionId, loopId, event),
198
+ executors: this.#executors(),
199
+ };
200
+ }
201
+ // Loads loops.flags (json column) and merges over DEFAULT_LOOP_FLAGS so
202
+ // missing keys read as their documented defaults. Single read site —
203
+ // ProposalPendingEvent.flags is constructed from this, and listeners
204
+ // (Daemon broadcast, YOLO auto-accept) share the result.
205
+ async #loadLoopFlags(loopId) {
206
+ const row = await this.#db.engine_get_loop_flags.get({ loop_id: loopId });
207
+ if (row === undefined)
208
+ return DEFAULT_LOOP_FLAGS;
209
+ try {
210
+ const parsed = JSON.parse(row.flags);
211
+ return { ...DEFAULT_LOOP_FLAGS, ...parsed };
212
+ }
213
+ catch {
214
+ return DEFAULT_LOOP_FLAGS;
215
+ }
216
+ }
217
+ // SPEC §scheme-surface: engine rejects writes whose origin is outside the target
218
+ // scheme's manifest.writableBy.
219
+ // - Read-side ops (READ, FIND, OPEN, FOLD) are not gated.
220
+ // - SEND broadcast (path=null) has no target scheme; not gated.
221
+ // - COPY: dst scheme writableBy applies.
222
+ // - MOVE: both src (delete) and dst (write) schemes' writableBy apply.
223
+ // - Schemes without a manifest are not gated (legacy / future allowance).
224
+ #checkWritable(statement, origin) {
225
+ if (!MUTATING_OPS.has(statement.op))
226
+ return null;
227
+ if (statement.op === "SEND" && statement.target === null)
228
+ return null;
229
+ // EXEC's target slot is `cwd`, not a scheme address. The op's
230
+ // authority always belongs to the exec scheme regardless of cwd.
231
+ if (statement.op === "EXEC") {
232
+ return this.#denyIfDisallowed("exec", origin);
233
+ }
234
+ // Run control (COPY target=run://, spawn or fork) is gated by run://'s writableBy — its
235
+ // body is a seed prompt, not a dst path, so the entry-COPY dst-parse below doesn't apply.
236
+ // §machine-processes
237
+ if (this.#isRunCopy(statement))
238
+ return this.#denyIfDisallowed("run", origin);
239
+ if (statement.op === "COPY" || statement.op === "MOVE") {
240
+ const dst = statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body;
241
+ const dstScheme = schemeNameOf(dst);
242
+ const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
243
+ if (dstDenial !== null)
244
+ return dstDenial;
245
+ if (statement.op === "MOVE") {
246
+ const srcScheme = schemeNameOf(statement.target);
247
+ if (srcScheme !== dstScheme) {
248
+ const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
249
+ if (srcDenial !== null)
250
+ return srcDenial;
251
+ }
252
+ }
253
+ return null;
254
+ }
255
+ const target = schemeNameOf(statement.target);
256
+ return this.#denyIfDisallowed(target, origin);
257
+ }
258
+ #denyIfDisallowed(schemeName, origin) {
259
+ if (schemeName === null)
260
+ return null;
261
+ const handler = this.#schemes.get(schemeName);
262
+ if (handler === undefined)
263
+ return null;
264
+ const manifest = handler.constructor.manifest;
265
+ if (manifest === undefined)
266
+ return null;
267
+ if (manifest.writableBy.includes(origin))
268
+ return null;
269
+ return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` }; // §scheme-surface-writableby-403
270
+ }
271
+ // Per-loop flag gating. Schemes self-declare their flag affinity in
272
+ // their manifest (excludedInAsk / requiresWeb /
273
+ // requiresInteraction); SchemeRegistry.resolveForLoop returns the
274
+ // active set under the loop's persisted flags. Anything outside the
275
+ // set returns 403 — action-entry-as-outcome carries the rejection.
276
+ async #checkFlagsGate(statement, loopId) {
277
+ // Broadcast SEND has no scheme to gate.
278
+ if (statement.op === "SEND" && statement.target === null)
279
+ return null;
280
+ const flags = await this.#loadLoopFlags(loopId);
281
+ // Fast path: default flags gate nothing. (yolo never gates.)
282
+ if (!flags.noWeb && !flags.noInteraction && flags.mode === "act")
283
+ return null;
284
+ const active = this.#schemes.resolveForLoop(flags);
285
+ const check = (target) => {
286
+ const scheme = schemeNameOf(target);
287
+ if (scheme === null)
288
+ return null;
289
+ if (active.has(scheme))
290
+ return null;
291
+ return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
292
+ };
293
+ if (this.#isRunCopy(statement))
294
+ return check(statement.target); // body is a spawn/fork prompt, not a dst path
295
+ if (statement.op === "COPY" || statement.op === "MOVE") {
296
+ return check(statement.target) ?? check(statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body);
297
+ }
298
+ return check(statement.target);
299
+ }
300
+ // A COPY whose TARGET is run:// is run control (spawn/fork), not an entry-copy — its body
301
+ // is the new run's seed prompt, not a destination path. The COPY gates and #handleCopy
302
+ // branch on this so they never parse the prompt as a dst path.
303
+ #isRunCopy(statement) {
304
+ return statement.op === "COPY" && schemeNameOf(statement.target) === "run";
305
+ }
306
+ // COPY(run://<dst>):prompt — run control via COPY (grammar 0.74.41 OP×resource matrix):
307
+ // • run://self → FORK: deep-copy the current run's log into a new sister (Fork), then
308
+ // continue it with the prompt (§machine-processes-fork-copies-the-log).
309
+ // • run://<name> → SPAWN: a fresh sister (empty log) named <name>, started on the prompt.
310
+ // A LIVE sister already holding <name> is a 409 conflict; a free or terminated name is
311
+ // reclaimed (§run-scheme-spawn). The self form is fork; only a name spawns.
312
+ // Both ride the daemon inject and obey the active-runs cap (508, §run-scheme-cap).
313
+ async #handleRunCopy(statement, ctx) {
314
+ const target = statement.target;
315
+ if (target === null)
316
+ return { status: 400, error: "run:// control requires a run target" };
317
+ const name = target.kind === "url" ? (target.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY (run://<name>), not the path
318
+ if (name === "")
319
+ return { status: 400, error: "run:// control requires a run name or 'self' (run://<name>)" };
320
+ if (ctx.injectRun === undefined)
321
+ throw new Error("run copy: injectRun capability absent");
322
+ const denied = await RunCap.deny(this.#db, ctx.sessionId);
323
+ if (denied !== null)
324
+ return denied;
325
+ const prompt = typeof statement.body === "string" ? statement.body : "";
326
+ if (name === "self") {
327
+ // FORK — branch the current run's log into a new sister.
328
+ const branchRunId = await Fork.fork(this.#db, ctx.runId);
329
+ const branch = await this.#db.fork_get_run.get({ id: branchRunId });
330
+ await ctx.injectRun({ sessionId: ctx.sessionId, runId: branchRunId, prompt });
331
+ return { status: 200, body: branch?.name ?? "" };
332
+ }
333
+ // SPAWN — a fresh sister named <name>. A name is frozen per run but reclaimable across time
334
+ // (§machine-processes-run-origin): a LIVE sister holding it is a 409 conflict (legible, never
335
+ // a raw UNIQUE 500); a free or terminated name is reclaimed (the resolver picks newest).
336
+ const live = await this.#db.run_live_by_name.get({ session_id: ctx.sessionId, name });
337
+ if (live !== undefined)
338
+ return { status: 409, error: `run '${name}' is already running` };
339
+ const row = await this.#db.fork_insert_run.get({
340
+ session_id: ctx.sessionId, name, parent_run_id: ctx.runId, origin: ctx.writer,
341
+ });
342
+ if (row === undefined)
343
+ throw new Error("run spawn: run insert returned no row");
344
+ await ctx.injectRun({ sessionId: ctx.sessionId, runId: row.id, prompt });
345
+ return { status: 200, body: name };
346
+ }
347
+ async #handleCopy(statement, ctx) {
348
+ if (statement.op !== "COPY")
349
+ throw new Error("unreachable");
350
+ if (this.#isRunCopy(statement))
351
+ return await this.#handleRunCopy(statement, ctx);
352
+ const srcPath = statement.target;
353
+ // Past the run-control branch above, COPY's body is a dest path (grammar §COPY).
354
+ // Parse it; an unparseable dest surfaces as a 400.
355
+ const dstPath = statement.body === null ? null : parsePath(statement.body);
356
+ if (srcPath === null)
357
+ return { status: 400, error: "COPY requires source path" };
358
+ if (dstPath === null)
359
+ return { status: 400, error: "COPY destination must be a parseable path in the body slot" };
360
+ return await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
361
+ }
362
+ async #handleMove(statement, ctx) {
363
+ if (statement.op !== "MOVE")
364
+ throw new Error("unreachable");
365
+ const srcPath = statement.target;
366
+ const dstPath = statement.body;
367
+ if (srcPath === null)
368
+ return { status: 400, error: "MOVE requires source path" };
369
+ // MOVE is relocation only — deletion is KILL's job (§move, §move-dev-null-not-special). The /dev/null
370
+ // and null-body delete-by-MOVE back-compat is retired: no silent debt.
371
+ if (dstPath === null)
372
+ return { status: 400, error: "MOVE requires a destination; use KILL to delete" }; // §move-null-body-400
373
+ const srcSchemeName = schemeNameOf(srcPath);
374
+ if (srcSchemeName === null)
375
+ return { status: 400, error: "MOVE source must be a URL path with a scheme" };
376
+ const srcHandler = this.#schemes.get(srcSchemeName);
377
+ if (srcHandler === undefined || typeof srcHandler.deleteEntry !== "function")
378
+ return { status: 501 };
379
+ // Relocation: COPY then DELETE source (§move-relocation-deletes-source).
380
+ const copyResult = await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
381
+ if (copyResult.status >= 400)
382
+ return copyResult;
383
+ const srcPathname = pathnameFromPath(srcPath);
384
+ // If the dest write is a pending proposal (file dest → §membership review), the
385
+ // source-delete MUST wait until the dest actually lands — a rejected
386
+ // proposal would otherwise lose the source. Thread it into the resolution:
387
+ // dispatch deletes the source AFTER the dest applies on accept.
388
+ if (copyResult.status === 202) {
389
+ return { ...copyResult, attrs: { ...copyResult.attrs, moveSource: { scheme: srcSchemeName, pathname: srcPathname } } };
390
+ }
391
+ const delResult = await srcHandler.deleteEntry(srcPathname, ctx);
392
+ if (delResult.status >= 400)
393
+ return { status: delResult.status };
394
+ return copyResult;
395
+ }
396
+ // KILL — scheme-polymorphic destroy (plurnk-grammar#203 / 0.28.0). Entry-KILL
397
+ // permanently deletes the entry: the canonical delete now, MOVE→/dev/null
398
+ // retired from the model's vocabulary. Process-KILL (exec:///) aborts the
399
+ // running spawn's controller (the same teardown loop.cancel rides), addressed
400
+ // by coordinate pathname (#203). The KILL body is an opaque
401
+ // annotation with no runtime meaning; it survives into the log row's tx for
402
+ // free via the statement serialization. Status: 200 killed · 404 unknown ·
403
+ // 405 log:/// (append-only) · 403 writableBy (the #checkWritable gate, KILL ∈
404
+ // MUTATING_OPS) · 200/410/304/404 exec (killed / killed-earlier / exited / unknown) · 501 no-kill/delete scheme.
405
+ async #handleKill(statement, ctx) {
406
+ if (statement.op !== "KILL")
407
+ throw new Error("unreachable");
408
+ const path = statement.target;
409
+ if (path === null)
410
+ return { status: 400, error: "KILL requires a target path" };
411
+ const schemeName = schemeNameOf(path);
412
+ if (schemeName === null)
413
+ return { status: 400, error: "KILL target must be a URL path with a scheme" };
414
+ // KILL on log:/// erases the log row(s) — the model's DB-storage curation lever
415
+ // (plurnk.md:36, :98), routed to Log.kill below via the killable.kill path. The old
416
+ // "append-only" 405 forbade what the grammar requires; FOLD only collapses the render.
417
+ // Process-KILL: any scheme whose handler exposes kill() aborts a live stream — the
418
+ // exec handler, registered as "exec" + under every runtime tag (sh/node), so a tag-
419
+ // addressed stream (sh:///l/t/s) routes here, not to deleteEntry. §exec
420
+ const killable = this.#schemes.get(schemeName);
421
+ if (killable !== undefined && typeof killable.kill === "function") {
422
+ return await killable.kill(pathnameFromPath(path), statement.signal, ctx);
423
+ }
424
+ if (schemeName === "run") {
425
+ // Entry-path present → KILL a run-scope scratch ENTRY (delete it), self-only —
426
+ // NOT run cancellation. The authority (hostname) names the owner, the pathname the
427
+ // entry; only the path-ABSENT form (run://<name>) terminates the run-as-actor. §run-scheme
428
+ const entryPath = path.kind === "url" ? (path.pathname ?? "") : "";
429
+ if (entryPath !== "" && entryPath !== "/") {
430
+ const runHandler = this.#schemes.get("run");
431
+ return await runHandler.deleteEntry(statement, ctx);
432
+ }
433
+ // terminate — abort any run by address; whoever holds it may end it.
434
+ // `run://self` = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
435
+ // (its loop closes 499); an idle run is a no-op-200, a missing run 404.
436
+ const name = path.kind === "url" ? (path.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY
437
+ if (name === "")
438
+ return { status: 400, error: "run:// kill requires a run name or 'self' (run://<name>)" };
439
+ let runId = ctx.runId;
440
+ if (name !== "self") {
441
+ const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
442
+ if (row === undefined)
443
+ return { status: 404, error: `run://${name} not found in this session` };
444
+ runId = row.id;
445
+ }
446
+ if (this.#cancelRun === undefined)
447
+ throw new Error("run kill: cancelRun capability absent");
448
+ this.#cancelRun(runId);
449
+ return { status: 200 };
450
+ }
451
+ const handler = this.#schemes.get(schemeName);
452
+ if (handler === undefined || typeof handler.deleteEntry !== "function")
453
+ return { status: 501 };
454
+ const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
455
+ return { status: delResult.status };
456
+ }
457
+ // Multi-file READ fan-out (SPEC §matcher-result — "the companion to FIND's survey"). A glob
458
+ // READ target resolves to MANY files; READ returns one log row per file that matches, each
459
+ // holding that file's matching lines. The matched SET is exactly FIND's survey (which files
460
+ // + where — matchLines), so we reuse the scheme's own find, then READ each matched file. One
461
+ // model command, N log rows — each row addresses its concrete file, so it folds/kills/re-READs
462
+ // on its own. The running sequence counter in runTurn advances by rowsWritten.
463
+ // A READ fans out (honors FIND) when it resolves to more than the single exact entry: a glob
464
+ // or folder scope, OR a matcher (which selects per-match within whatever the target resolved).
465
+ // A bare entry, body-less, is the one direct read. #286
466
+ static #readFansOut(statement) {
467
+ if (statement.op !== "READ")
468
+ return false;
469
+ if ("body" in statement && statement.body !== null)
470
+ return true; // a matcher → per-match fan-out
471
+ const t = statement.target;
472
+ const p = t === null ? "" : (t.kind === "url" ? t.pathname : t.raw);
473
+ return p.includes("*") || p.endsWith("/"); // glob/folder scope → fan out its contents
474
+ }
475
+ // Clone the READ onto one concrete match — the FIND already matched, so the per-match READ
476
+ // delivers content at the span: strip the body (no re-match) and set <L> to the span. A null
477
+ // span (body-less folder/glob fan-out) reads the whole entry. #286
478
+ static #retargetRead(statement, pathname, span) {
479
+ const t = statement.target;
480
+ const target = t !== null && t.kind === "url"
481
+ ? { ...t, pathname, raw: `${t.scheme}://${pathname}` }
482
+ : { ...t, raw: pathname };
483
+ const lineMarker = span !== null ? { marks: [span.lineStart, span.lineEnd] } : null;
484
+ return { ...statement, target: target, lineMarker, body: null };
485
+ }
486
+ async #handleReadFanout(statement, ctx, ids) {
487
+ const { runId, loopId, turnId, sequence, origin, onDispatch } = ids;
488
+ const schemeName = schemeNameOf(statement.target);
489
+ const found = await this.#run(schemeName, { ...statement, op: "FIND" }, ctx);
490
+ const matches = found.matches ?? [];
491
+ // Find-less scheme, a matcher/scope error, or zero matches → a single row carrying the
492
+ // status, exactly like a non-fanned READ. The model sees the empty/failed result, not silence.
493
+ if (found.status !== 200 || matches.length === 0) {
494
+ const result = { status: found.status === 200 ? 204 : found.status };
495
+ const id = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
496
+ onDispatch?.(id);
497
+ return { ...result, rowsWritten: 1 };
498
+ }
499
+ // One READ row per MATCH — the span's source lines (or the whole entry for a body-less
500
+ // folder/glob). The match span is SOURCE LINES, so deliver via a raw line-slice — NOT the
501
+ // scheme's <L> (which is item-index for application/json, structural for xml). Read each
502
+ // distinct entry's content once, then line-slice per match. #286
503
+ const wholeByPath = new Map();
504
+ const fannedStatuses = [];
505
+ let written = 0;
506
+ for (const m of matches) {
507
+ let whole = wholeByPath.get(m.pathname);
508
+ if (whole === undefined) {
509
+ whole = await this.#run(schemeName, _a.#retargetRead(statement, m.pathname, null), ctx);
510
+ wholeByPath.set(m.pathname, whole);
511
+ }
512
+ const result = _a.#sliceMatch(whole, m.span);
513
+ const id = await this.#writeLog({ statement: _a.#retargetRead(statement, m.pathname, m.span), result, runId, loopId, turnId, sequence: sequence + written, origin });
514
+ onDispatch?.(id);
515
+ fannedStatuses.push(result.status);
516
+ written++;
517
+ }
518
+ return { status: 200, rowsWritten: written, fannedStatuses };
519
+ }
520
+ // Deliver one match: the whole entry (body-less, span null) or the source lines at the span —
521
+ // a RAW line-slice, so a structural mimetype (json item-index / xml) doesn't mis-slice a span
522
+ // that is, by construction, source line numbers (#286).
523
+ static #sliceMatch(whole, span) {
524
+ if (whole.status !== 200 || span === null)
525
+ return whole;
526
+ const sliced = LineMarkerOps.sliceLines(typeof whole.content === "string" ? whole.content : "", { marks: [span.lineStart, span.lineEnd] });
527
+ if (sliced.status !== 200)
528
+ return { status: sliced.status, error: sliced.error };
529
+ return { status: 200, content: sliced.text ?? "", mimetype: "text/markdown", startLine: sliced.startLine ?? span.lineStart };
530
+ }
531
+ // §model-entry — mirror a verbatim model emission back as an actionless `model` log row, so
532
+ // the model can finally SEE its own prior output (and reason through its own syntax errors).
533
+ // Born FOLDED by default (budget-neutral until OPENed); the turn-0 exemplar passes folded:false
534
+ // (born open — the one worked example the model orients on, thinning the grammar). text/vnd.plurnk.
535
+ async writeModelEntry({ verbatim, runId, loopId, turnId, sequence, folded, origin = "model" }) {
536
+ const row = await this.#db.engine_insert_log_entry.get({
537
+ run_id: runId, loop_id: loopId, turn_id: turnId, sequence,
538
+ origin, source: null, op: "model", suffix: "", signal: null,
539
+ scheme: null, username: null, password: null, hostname: null, port: null,
540
+ pathname: null, params: null, fragment: null, lineMarker: null,
541
+ tx: "", mimetype_tx: "text/vnd.plurnk",
542
+ rx: JSON.stringify({ content: verbatim, mimetype: "text/vnd.plurnk" }),
543
+ mimetype_rx: "application/json",
544
+ status_rx: 200, tokens: this.#tokenize(verbatim), state: "resolved", outcome: null, attrs: "{}",
545
+ });
546
+ if (row === undefined)
547
+ throw new Error("Dispatcher.writeModelEntry: insert returned no row");
548
+ if (folded)
549
+ await this.#db.engine_fold_log_entry.run({ id: row.id });
550
+ return row.id;
551
+ }
552
+ // PLAN — the model's reasoning op (the 11th op). An ordinary op: dispatched like any
553
+ // other, logged, and broadcast to the client as a log entry — but a pure no-op for
554
+ // state (PLAN ∉ MUTATING_OPS); its body serializes into the log row's tx, no effect.
555
+ #handlePlan(statement) {
556
+ if (statement.op !== "PLAN")
557
+ throw new Error("unreachable");
558
+ return { status: 200 };
559
+ }
560
+ // Same- and cross-scheme COPY share one orchestrator — §copy-cross-scheme-copy §move-cross-scheme-move
561
+ async #copyOrchestration({ statement, srcPath, dstPath, ctx }) {
562
+ const srcSchemeName = schemeNameOf(srcPath);
563
+ const dstSchemeName = schemeNameOf(dstPath);
564
+ if (srcSchemeName === null || dstSchemeName === null)
565
+ return { status: 400, error: "COPY/MOVE require URL paths with schemes" };
566
+ const srcHandler = this.#schemes.get(srcSchemeName);
567
+ const dstHandler = this.#schemes.get(dstSchemeName);
568
+ if (srcHandler === undefined || dstHandler === undefined)
569
+ return { status: 501 };
570
+ if (typeof srcHandler.readEntry !== "function" || typeof dstHandler.writeEntry !== "function")
571
+ return { status: 501 };
572
+ const srcPathname = pathnameFromPath(srcPath);
573
+ const dstPathname = pathnameFromPath(dstPath);
574
+ const srcResult = await srcHandler.readEntry(srcPathname, ctx);
575
+ if (srcResult.status !== 200 || srcResult.entry === null)
576
+ return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` }; // §copy-missing-source-404 §move-missing-source-404
577
+ const entry = srcResult.entry;
578
+ // Destination read — the conflict/no-op verdict is deferred until the
579
+ // to-be-written content is known (after <L> slice + tag resolution below),
580
+ // so an identical re-copy resolves to 304 instead of a phantom 409.
581
+ const dstExisting = typeof dstHandler.readEntry === "function"
582
+ ? await dstHandler.readEntry(dstPathname, ctx)
583
+ : null;
584
+ // Mimetype compatibility check against the destination scheme's manifest
585
+ const dstManifest = dstHandler.constructor.manifest;
586
+ const dstChannels = dstManifest?.channels ?? {};
587
+ for (const [channelName, channelData] of Object.entries(entry.channels)) {
588
+ const expectedMimetype = dstChannels[channelName];
589
+ if (expectedMimetype !== undefined && expectedMimetype !== channelData.mimetype) {
590
+ return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` }; // cross-mimetype COPY/MOVE → 415, never coerce — §channel-mimetype-cross-mimetype-415
591
+ }
592
+ }
593
+ // `<L>` source range slicing per SPEC.md §op-invariants (symmetric with READ
594
+ // `<L>` — source range, no line-number prefix).
595
+ // Applied to every channel of the source entry. Binary channels return
596
+ // 415 since line semantics don't apply.
597
+ const lineMarker = statement.lineMarker ?? null;
598
+ let channels = entry.channels;
599
+ if (lineMarker !== null) {
600
+ const sliced = {};
601
+ for (const [channelName, channelData] of Object.entries(entry.channels)) {
602
+ if (MimetypeBinary.isBinaryMimetype(channelData.mimetype)) {
603
+ return { status: 415, error: `cannot slice <L> on binary channel '${channelName}' (${channelData.mimetype})` };
604
+ }
605
+ const r = LineMarkerOps.sliceLinesRaw(channelData.content ?? "", lineMarker);
606
+ if (r.status !== 200)
607
+ return { status: r.status, error: r.error };
608
+ sliced[channelName] = { ...channelData, content: r.text ?? "" };
609
+ }
610
+ channels = sliced;
611
+ }
612
+ // Tag resolution: signal = replace (§copy-signal-replaces-source-tags); absent/empty = carry from source (§copy-no-signal-carries-source-tags)
613
+ const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
614
+ ? statement.signal
615
+ : entry.tags;
616
+ // 304/409 on an existing destination (SPEC §copy): a re-copy that would write
617
+ // exactly what's already there — same channel contents, same tags — is a no-op
618
+ // (304), mirroring EDIT's 304-on-noop (§edit). A divergent destination is a real
619
+ // collision (409); COPY/MOVE never clobbers.
620
+ if (dstExisting !== null && dstExisting.status === 200 && dstExisting.entry !== null) {
621
+ const dstChannels = dstExisting.entry.channels;
622
+ const writeNames = Object.keys(channels).sort();
623
+ const dstNames = Object.keys(dstChannels).sort();
624
+ const sameContent = writeNames.length === dstNames.length
625
+ && writeNames.every((n, i) => n === dstNames[i] && (channels[n]?.content ?? "") === (dstChannels[n]?.content ?? ""));
626
+ const sameTags = [...tags].sort().join("") === [...dstExisting.entry.tags].sort().join("");
627
+ if (sameContent && sameTags)
628
+ return { status: 304 }; // identical → §copy-noop-304
629
+ return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` }; // §copy-conflict-409
630
+ }
631
+ const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
632
+ // A file dest returns 202 (disk write → §membership review): propagate the
633
+ // proposal so dispatch runs the gate + routes applyResolution to the dest.
634
+ if (writeResult.status === 202)
635
+ return { status: 202, attrs: writeResult.attrs, body: writeResult.body };
636
+ return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
637
+ }
638
+ async #handleSendBroadcast(statement, loopId, prematureRefusal) {
639
+ if (statement.op !== "SEND")
640
+ throw new Error("unreachable");
641
+ const status = statement.signal;
642
+ if (status === null)
643
+ return { status: 400 };
644
+ // Premature terminate (§send-premature-terminate): a terminal SEND[200] is REFUSED 409 — the row
645
+ // keeps the [200] emission + body (faithful, never erased), the loop never goes terminal. The
646
+ // decision is the runTurn PRE-DISPATCH snapshot (threaded), so a same-turn fire-and-forget spawn
647
+ // isn't miscounted. Two reasons, two terse signals: a live thing the run holds, or a READ
648
+ // submitted this turn whose result the model can't have seen (it folds back next turn).
649
+ if (status === 200 && prematureRefusal === "live-thing") {
650
+ return { status: 409, error: "Attempted [200] termination despite active streams or worker runs. You may either hibernate [202] to wait or KILL them before terminating." };
651
+ }
652
+ if (status === 200 && prematureRefusal === "submitted-read") {
653
+ return { status: 409, error: "Attempted termination with submitted READ operation(s)." };
654
+ }
655
+ // Groundless hibernation (§send-groundless-hibernate): a SEND[202] alongside a same-turn READ,
656
+ // with no wake edge — the READ's result folds back on a next turn this park would never reach,
657
+ // so the model is sleeping on its own unanswered question. Refused 409 on the record, same
658
+ // shape as the premature 200 — the row keeps the [202] attempt, the loop stays a continue, the
659
+ // steer strikes. (A bare park holding nothing is legal — the voice door; never refused here.)
660
+ if (status === 202 && prematureRefusal === "groundless-hibernate") {
661
+ return { status: 409, error: "Attempted [202] hibernation with submitted READ operation(s) and nothing to wake you — the result arrives on your next turn, which this park would never reach. SEND[102] to receive it, then act." };
662
+ }
663
+ if (status === 200 || status === 202 || status === 499) {
664
+ // The broadcast terminals (200 done, 202 parked-async, 499 cancelled) advance
665
+ // the loop; each carries its body as the loop's terminal message — the deliverable.
666
+ const body = statement.body;
667
+ const message = body === null ? null : typeof body === "string" ? body : body.raw;
668
+ await this.#db.engine_loop_set_status.run({ status, loop_id: loopId, message });
669
+ }
670
+ return { status };
671
+ }
672
+ async #run(schemeName, statement, ctx) {
673
+ if (schemeName === null)
674
+ return { status: 400 };
675
+ const handler = this.#schemes.get(schemeName);
676
+ if (handler === undefined)
677
+ return { status: 501 };
678
+ const methodName = statement.op.toLowerCase();
679
+ const method = handler[methodName];
680
+ if (typeof method !== "function")
681
+ return { status: 501 };
682
+ // External @plurnk/plurnk-schemes-* siblings receive the DB-free SchemeCtx
683
+ // (caps), never the raw PlurnkSchemeContext (schemes SPEC §channels). The dynamic
684
+ // dispatch is typed for in-tree schemes; the cast bridges the ctx shapes —
685
+ // the sibling reads caps, the in-tree handler reads db.
686
+ if (this.#schemes.isExternal(schemeName)) {
687
+ return method.call(handler, statement, new SchemeCtxImpl(ctx, schemeName));
688
+ }
689
+ return method.call(handler, statement, ctx);
690
+ }
691
+ // A status-202 result is a reviewable PROPOSAL (a side-effecting op — EDIT/EXEC/
692
+ // directed write — paused for client resolution) UNLESS it is a broadcast SEND.
693
+ // A broadcast SEND[202] is the model PARKING the loop (a terminal disposition,
694
+ // plurnk.md), never a side-effect — #255: gating the propose/await path on the
695
+ // bare 202 surfaced model speech as a loop/proposal and froze clients. The 202
696
+ // is overloaded (proposal-pause vs parked-terminal); the op disambiguates it.
697
+ static #isProposal(statement, result) {
698
+ return result.status === 202 && !(statement.op === "SEND" && statement.target === null);
699
+ }
700
+ async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
701
+ const target = this.#extractTarget(statement.target);
702
+ const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
703
+ ? JSON.stringify(statement.lineMarker)
704
+ : null;
705
+ // A proposal (status 202 from a side-effecting op) is written to the log in
706
+ // state='proposed' until the proposal lifecycle resolves it; attrs holds the
707
+ // scheme-supplied payload (file diff, exec command, etc.) the client renders
708
+ // for review and the scheme consumes on accept. A broadcast SEND[202] is a
709
+ // parked-terminal, NOT a proposal (#isProposal / #255) → state='resolved'.
710
+ const isProposed = _a.#isProposal(statement, result);
711
+ let attrsObj = (result.attrs !== undefined && result.attrs !== null)
712
+ ? { ...result.attrs }
713
+ : {};
714
+ // EXEC produces a stream entry addressed by RUNTIME TAG as authority (§exec): it lives
715
+ // at <runtime>:///<loop_seq>/<turn_seq>/<sequence> (e.g. sh:///1/1/2). That address is a
716
+ // SEPARATE `stream` link in attrs — NOT an overload of `target`, which stays faithful to
717
+ // the EXEC's own slot (the cwd, or the path to the executable). The log:/// coordinate
718
+ // shares the trailing <loop>/<turn>/<seq>, so the op still correlates to its stream.
719
+ // Runtime comes from statement.signal (EXEC's runtime slot), resolvable for failed execs
720
+ // too; empty/absent = the default shell.
721
+ if (statement.op === "EXEC") {
722
+ const seqs = await this.#db.engine_loop_turn_seqs.get({
723
+ loop_id: loopId, turn_id: turnId,
724
+ });
725
+ if (seqs === undefined)
726
+ throw new Error(`Dispatcher.#writeLog: loop_turn_seqs returned no row for loop=${loopId} turn=${turnId}`);
727
+ const runtime = (typeof statement.signal === "string" && statement.signal.length > 0) ? statement.signal : "sh";
728
+ const coordPathname = `/${seqs.loop_seq}/${seqs.turn_seq}/${sequence}`;
729
+ attrsObj.pathname = coordPathname;
730
+ attrsObj.stream = `${runtime}://${coordPathname}`;
731
+ // Mutate the in-memory result.attrs too: the dispatch path
732
+ // hands originalResult.attrs to handler.applyResolution after
733
+ // proposal accept (see ProposalLifecycle.runApply). Both views —
734
+ // the stored row AND the in-memory proposal — need the same
735
+ // pathname so applyResolution writes the entry at the same URI.
736
+ if (result.attrs !== undefined && result.attrs !== null) {
737
+ result.attrs.pathname = coordPathname;
738
+ }
739
+ }
740
+ const attrs = JSON.stringify(attrsObj);
741
+ const txJson = JSON.stringify(statement);
742
+ const rxJson = JSON.stringify(result);
743
+ const row = await this.#db.engine_insert_log_entry.get({
744
+ run_id: runId,
745
+ loop_id: loopId,
746
+ turn_id: turnId,
747
+ sequence: sequence,
748
+ origin,
749
+ source: null, // dispatch entries are self-authored; §env-delta deltas set this
750
+ op: statement.op,
751
+ suffix: statement.suffix,
752
+ signal: this.#signalToJson(statement.signal),
753
+ scheme: target.scheme,
754
+ username: target.username,
755
+ password: target.password,
756
+ hostname: target.hostname,
757
+ port: target.port,
758
+ pathname: target.pathname,
759
+ params: target.params,
760
+ fragment: target.fragment,
761
+ lineMarker: lineMarkerJson,
762
+ tx: txJson,
763
+ mimetype_tx: "application/json",
764
+ rx: rxJson,
765
+ mimetype_rx: "application/json",
766
+ status_rx: result.status,
767
+ tokens: this.#tokenize(txJson) + this.#tokenize(rxJson),
768
+ state: isProposed ? "proposed" : "resolved",
769
+ outcome: null,
770
+ attrs,
771
+ });
772
+ if (row === undefined)
773
+ throw new Error("Dispatcher.#writeLog: INSERT ... RETURNING produced no row");
774
+ return row.id;
775
+ }
776
+ // Normalize a parsed path for storage. The `file` scheme is a routing
777
+ // internal — never stored, never rendered to the model. Both bare paths
778
+ // and `file:///...` inputs collapse to scheme=null at this boundary, so
779
+ // entries.scheme / log_entries.scheme never carry the string "file".
780
+ #extractTarget(path) {
781
+ if (path === null)
782
+ return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: null, params: null, fragment: null };
783
+ // `local` (bare path) and `regex` (grammar 0.46 `#pattern#flags` target) carry no URL parts — store the raw text as the pathname for the log record, scheme=null.
784
+ if (path.kind === "regex")
785
+ return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null }; // regex source — no decode
786
+ if (path.kind === "local")
787
+ return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: decodePathParens(path.raw), params: null, fragment: null }; // #239 item 4
788
+ const scheme = path.scheme === "file" ? null : path.scheme;
789
+ // Every registered (plurnk-namespace) scheme uses its authority as a namespace segment — fold
790
+ // it into the canonical pathname so known://x ≡ known:///x ≡ /x and the log keys identically to
791
+ // the entry (/prompt/<loop>, /docs/x.md). A foreign web host (http://, unregistered) is NOT a
792
+ // namespace: keep it in hostname. run:// is the one registered EXCEPTION — its authority IS the
793
+ // run selector (§run-scheme), and run://self must stay distinct from run://name, so Run.ts
794
+ // folds the owner into the storage path itself, never here.
795
+ const foldNs = scheme !== null && scheme !== "run" && this.#schemes.has(scheme);
796
+ return {
797
+ scheme, username: path.username, password: path.password,
798
+ hostname: foldNs ? null : path.hostname, port: path.port,
799
+ pathname: decodePathParens(foldNs ? foldAuthorityIntoPath(path.hostname, path.pathname) : path.pathname), // #239 item 4
800
+ params: JSON.stringify(path.params), fragment: path.fragment,
801
+ };
802
+ }
803
+ #signalToJson(signal) {
804
+ if (signal === null || signal === undefined)
805
+ return null;
806
+ return JSON.stringify(signal);
807
+ }
808
+ }
809
+ _a = Dispatcher;
810
+ export default Dispatcher;
811
+ //# sourceMappingURL=Dispatcher.js.map