@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
@@ -1,63 +1,28 @@
1
- var _a;
2
- import { PlurnkParser, PlurnkParseError, parsePath } from "@plurnk/plurnk-grammar";
1
+ import { PlurnkParser, PlurnkParseError } from "@plurnk/plurnk-grammar";
3
2
  import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
4
3
  import EntryCrud from "../schemes/_entry-crud.js";
5
4
  import EntryManifest from "../schemes/_entry-manifest.js";
6
5
  import GitMembership from "./git-membership.js";
7
- import { foldAuthorityIntoPath, renderAddress } from "./plurnk-uri.js";
8
6
  import GitState from "./git-state.js";
9
- import Fork from "./fork.js";
10
- import RunCap from "./run-cap.js";
11
- import { teachingLine, docsExcludeSet } from "./teaching.js";
12
- import { readPacketInject, readSystemPolicy, readProjectPolicy } from "./packet-inject.js";
13
7
  import SessionSettings from "./session-settings.js";
14
- import { decodePathParens } from "./path-decode.js";
15
- import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
16
- import { LineMarkerOps, MimetypeBinary, editedSpan } from "../content/index.js";
8
+ import { editedSpan } from "../content/index.js";
17
9
  import { readFile } from "node:fs/promises";
18
10
  import { fileURLToPath } from "node:url";
19
11
  import { setTimeout as delay } from "node:timers/promises";
20
- import Paths from "../Paths.js";
21
- import SchemeCtxImpl from "./caps/SchemeCtxImpl.js";
22
12
  // Shared module imported by both Engine and bin/digest.ts, so wire
23
13
  // projection and digest projection are structurally one function — no
24
14
  // drift between wire and digest possible.
15
+ // Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
25
16
  import PacketWire from "./packet-wire.js";
26
- // SPEC §scheme-surface: writer must be in target scheme's manifest.writableBy.
27
- // OPEN/FOLD/READ/FIND are not gated they curate the log or read, never mutating an entry.
28
- const MUTATING_OPS = new Set(["EDIT", "SEND", "COPY", "MOVE", "EXEC", "KILL"]);
17
+ // The engine's collaborators each owns one machine; Engine owns the loop/turn
18
+ // lifecycle and wires them together as the public facade.
19
+ import TelemetryChannel from "./TelemetryChannel.js";
20
+ import StrikeRail from "./StrikeRail.js";
21
+ import PacketBuilder from "./PacketBuilder.js";
22
+ import ProposalLifecycle from "./ProposalLifecycle.js";
23
+ import Dispatcher from "./Dispatcher.js";
29
24
  const DEFAULT_MAX_STRIKES = 3;
30
25
  const DEFAULT_MAX_COMMANDS = 99;
31
- const DEFAULT_BUDGET_CEILING = 0.9;
32
- // §telemetry — the uniform error channel. Every engine failure is a terse op='error'
33
- // log row: a status code + the canonical term, no prose (the packet teaches recovery).
34
- // Each surfaces as a LogCoordinate TelemetryEvent derived from log≥400 — one channel,
35
- // no per-kind handling. {§telemetry-uniform-error-channel}
36
- const ENGINE_ERRORS = Object.freeze({
37
- budget_overflow: { status: 413, term: "Budget Overflow: newest log items automatically FOLDed" },
38
- max_commands_exceeded: { status: 429, term: "Max Commands Exceeded" },
39
- // premature-terminate is NOT a terse engine-error: it's a SEND op-result (409 + an actionable
40
- // outcome, §send-premature-terminate) — the SEND row records the [200] attempt faithfully and
41
- // auto-surfaces (status≥400) like any op failure, never an erasure to 102.
42
- idle_turn: { status: 409, term: "Idle Turn" },
43
- });
44
- // Substituted into the budget readout after the assembled packet is measured
45
- // (the figure depends on the packet's own rendered size — chicken/egg).
46
- const TOKENS_FREE_PLACEHOLDER = "{{tokensFree}}";
47
- const TOKEN_USAGE_PLACEHOLDER = "{{tokenUsage}}";
48
- const TOKEN_PERCENT_PLACEHOLDER = "{{tokenPercent}}";
49
- // PLURNK_BUDGET_CEILING is dual-mode: <=1 is a fraction of the provider's
50
- // context window, >1 is an absolute token wall — lets a demo pin a tiny
51
- // ceiling regardless of the model's real window to force the grinder.
52
- const readCeiling = () => {
53
- const raw = process.env.PLURNK_BUDGET_CEILING;
54
- if (raw === undefined || raw.length === 0)
55
- return DEFAULT_BUDGET_CEILING;
56
- const n = Number.parseFloat(raw);
57
- if (!Number.isFinite(n) || n <= 0)
58
- return DEFAULT_BUDGET_CEILING;
59
- return n;
60
- };
61
26
  const readMaxStrikes = () => {
62
27
  const raw = process.env.PLURNK_MAX_STRIKES;
63
28
  if (raw === undefined || raw.length === 0)
@@ -86,23 +51,6 @@ const readFilesItems = () => {
86
51
  return normalizeFilesItems(Number.parseInt(raw, 10));
87
52
  };
88
53
  import { ProviderError } from "@plurnk/plurnk-providers";
89
- // Resolution timeout — proposed entries auto-cancel if nothing arrives
90
- // within this window. SPEC.md §engine-rails (proposal lifecycle) + §methods (loop.resolve).
91
- const PROPOSAL_TIMEOUT_DEFAULT_MS = 300000;
92
- const readProposalTimeoutMs = () => {
93
- const raw = process.env.PLURNK_PROPOSAL_TIMEOUT_MS;
94
- if (raw === undefined || raw.length === 0)
95
- return PROPOSAL_TIMEOUT_DEFAULT_MS;
96
- const n = Number(raw);
97
- if (!Number.isFinite(n) || n <= 0)
98
- return PROPOSAL_TIMEOUT_DEFAULT_MS;
99
- return n;
100
- };
101
- const pathnameFromPath = (path) => {
102
- if (path.kind === "regex")
103
- return path.raw; // regex source — parens are syntax, never encoded
104
- return decodePathParens(path.kind === "url" ? path.pathname : path.raw); // #239 item 4
105
- };
106
54
  // Default turn.status when ops were emitted but no SEND. Model is implicitly
107
55
  // continuing; loop.status stays 102 either way (only SEND broadcast advances
108
56
  // loop terminal). No strike, no telemetry.
@@ -110,10 +58,6 @@ const TURN_STATUS_IMPLICIT_CONTINUE = 102;
110
58
  // Status assigned to a turn that emitted NO ops at all. Strike-worthy; the
111
59
  // action routes through telemetry.errors[] (§telemetry, §telemetry-no-error-scheme — never an error:// scheme).
112
60
  const TURN_STATUS_NO_OPS = 422;
113
- // Rail #38: action-entry statuses that DON'T accumulate strikes. Model adapted
114
- // to a finding (not_found, op_not_supported); no penalty. Rummy parallel:
115
- // SOFT_FAILURE_OUTCOMES = {"not_found", "unparsed"}.
116
- const SOFT_FAILURE_STATUSES = new Set([404, 501]);
117
61
  const DEFAULT_MIN_CYCLES = 3;
118
62
  const DEFAULT_MAX_CYCLE_PERIOD = 4;
119
63
  const readPositiveInt = (envVar, fallback) => {
@@ -125,101 +69,24 @@ const readPositiveInt = (envVar, fallback) => {
125
69
  return fallback;
126
70
  return n;
127
71
  };
128
- // Per-op fingerprint: op verb + target URI, plus an op-specific discriminator
129
- // where the activity isn't fully captured by target alone:
130
- // - EDIT/COPY/MOVE: body excluded re-writing the same target with varied
131
- // content IS cycling (the model is producing different versions of the
132
- // same artifact instead of progressing).
133
- // - FIND/READ/OPEN/FOLD: body IS the search/selection pattern; varied
134
- // matchers on the same target ARE different activities (the model is
135
- // exploring different queries, not repeating one).
136
- const fingerprintOp = (stmt) => {
137
- const path = stmt.target;
138
- const matcherDiscriminator = () => {
139
- // For matcher-bearing ops, the body's `raw` (matcher source) plus
140
- // any lineMarker forms the activity discriminator.
141
- const parts = [];
142
- const body = stmt.body;
143
- if (body !== null && typeof body === "object" && typeof body.raw === "string") {
144
- parts.push(`body:${body.raw.slice(0, 64)}`);
145
- }
146
- const lm = stmt.lineMarker;
147
- if (lm !== null && lm !== undefined)
148
- parts.push(`L:${lm.marks.join(",")}`);
149
- return parts.length > 0 ? `|${parts.join("|")}` : "";
150
- };
151
- if (path === null) {
152
- // Path-less ops need an activity-defining discriminator other
153
- // than `target`. Picked per op so the cycle detector reflects
154
- // intent rather than syntax:
155
- // - EXEC: the command body IS the activity. Without a body
156
- // digest, varied shell commands (find / ls / wc) collapse to
157
- // one fingerprint and the detector mislabels exploration
158
- // as a loop.
159
- // - SEND: the status code (signal) IS the activity. Different
160
- // SEND[X] are different intentions; same SEND[X] with
161
- // different message bodies is the same termination signal.
162
- if (stmt.op === "EXEC") {
163
- const body = typeof stmt.body === "string" ? stmt.body : "";
164
- return `EXEC|(no-path)${body.length > 0 ? `|body:${body.slice(0, 64)}` : ""}`;
165
- }
166
- if (stmt.op === "SEND") {
167
- const signal = typeof stmt.signal === "number" ? stmt.signal : "";
168
- return `SEND|(no-path)|signal:${signal}`;
169
- }
170
- return `${stmt.op}|(no-path)`;
171
- }
172
- const base = path.kind === "url"
173
- ? `${stmt.op}|${path.scheme}://${path.pathname}`
174
- : `${stmt.op}|local:${path.raw}`;
175
- if (stmt.op === "FIND" || stmt.op === "READ" || stmt.op === "OPEN" || stmt.op === "FOLD") {
176
- return `${base}${matcherDiscriminator()}`;
177
- }
178
- return base;
179
- };
180
- class Engine {
72
+ // §operator-config-loop-timeout the loop's wall-clock budget (PLURNK_LOOP_TIMEOUT).
73
+ const DEFAULT_LOOP_TIMEOUT_MS = 86400000;
74
+ const readLoopTimeoutMs = () => readPositiveInt("PLURNK_LOOP_TIMEOUT", DEFAULT_LOOP_TIMEOUT_MS);
75
+ // The wall's abort reason runLoop branches a mid-turn teardown to the 504 terminal on it.
76
+ const LOOP_TIMEOUT_REASON = "loop_timeout";
77
+ export default class Engine {
181
78
  static computeCeiling(contextSize, config) {
182
- // Absolute wall (config > 1) is window-independent — the point of the >1
183
- // mode is to pin a ceiling even when the provider reports no window; cap at
184
- // the real window when one is known. Ratio mode needs a window to scale.
185
- if (config > 1)
186
- return contextSize === null ? Math.floor(config) : Math.min(Math.floor(config), contextSize);
187
- return contextSize === null ? null : Math.floor(contextSize * config);
79
+ return PacketBuilder.computeCeiling(contextSize, config);
188
80
  }
189
- // Per-turn fingerprint: sorted set of per-op fingerprints, joined. Order
190
- // within a turn doesn't matter — we want the SET of activities.
191
81
  static fingerprintTurn(ops) {
192
- return ops.map(fingerprintOp).toSorted().join(",");
82
+ return StrikeRail.fingerprintTurn(ops);
193
83
  }
194
- // Rail #39 cycle detector. For each candidate period k in [1, maxCyclePeriod],
195
- // check whether the last k*minCycles entries form minCycles repetitions of the
196
- // same length-k pattern. O(maxCyclePeriod × minCycles × max k) ≈ tiny. Rummy
197
- // parallel: src/plugins/error/error.js detectCycle.
198
84
  static detectCycle(history, minCycles, maxCyclePeriod) {
199
- for (let k = 1; k <= maxCyclePeriod; k++) {
200
- const needed = k * minCycles;
201
- if (history.length < needed)
202
- continue;
203
- const tail = history.slice(-needed);
204
- const cycle = tail.slice(0, k);
205
- let match = true;
206
- outer: for (let rep = 0; rep < minCycles; rep++) {
207
- for (let j = 0; j < k; j++) {
208
- if (tail[rep * k + j] !== cycle[j]) {
209
- match = false;
210
- break outer;
211
- }
212
- }
213
- }
214
- if (match)
215
- return { detected: true, period: k, cycles: minCycles };
216
- }
217
- return { detected: false };
85
+ return StrikeRail.detectCycle(history, minCycles, maxCyclePeriod);
218
86
  }
219
87
  #db;
220
88
  #schemes;
221
89
  #mimetypes;
222
- #budgetCeiling;
223
90
  // Write-time tokenizer (SPEC §tokenomics). Synchronous per the provider
224
91
  // contract (§provider-surface). Populated from the active provider's countTokens via
225
92
  // the Daemon; a divisor tripwire stands in only for bare/standalone
@@ -229,26 +96,14 @@ class Engine {
229
96
  // Boot-discovered runtime executors. Daemon builds + sets via
230
97
  // setExecutors at start(); undefined until then (and in bare tests).
231
98
  #executors;
232
- // Per-loop transient buffer of actionless failures pending surface in the
233
- // NEXT packet's user.telemetry.errors[]. Drained by #buildTelemetryErrors.
234
- // Map<loopId, TelemetryError[]>. SPEC §telemetry.
235
- #telemetryBuffer = new Map();
236
- // Rail #38 strike state per loop. `streak` = consecutive struck turns;
237
- // resets on a clean turn. `turnErrors` is bumped externally by per-turn
238
- // rails (cycle detection #39, etc.) — read and reset at end of each turn.
239
- // `history` holds per-turn fingerprints for rail #39 cycle detection.
240
- #strikeState = new Map();
241
- // Proposal lifecycle: pending dispatch pauses waiting for resolution.
242
- // Engine.runTurn awaits the promise when a scheme returns status 202;
243
- // Engine.resolveProposal feeds the resolution back in. Map is per-log-
244
- // entry-id; entries clear on resolution. SPEC.md §engine-rails + §methods (loop.resolve).
245
- #pendingProposals = new Map();
246
- // External observers of proposal lifecycle events. Daemon subscribes
247
- // here to push `loop/proposal` notifications when an entry enters
248
- // pending state. YOLO listener (Phase E.3) subscribes here too. Lean
249
- // event emitter — no priority, no veto chain at this layer; filter
250
- // chains come later if a real consumer needs them.
251
- #proposalPendingListeners = [];
99
+ // The collaborators. Engine constructs them (they share its deps via
100
+ // thunks where the value is late-injected — executors, loop signals)
101
+ // and fronts their public surface.
102
+ #telemetry;
103
+ #strikes;
104
+ #packets;
105
+ #proposals;
106
+ #dispatcher;
252
107
  // Per-loop AbortController for cancellation propagation into scheme
253
108
  // ctx.signal. runLoop creates one at entry, cleans up at end. Engine
254
109
  // cancellation paths (strikes, max_turns, external) abort it.
@@ -257,14 +112,6 @@ class Engine {
257
112
  #loopAborts = new Map();
258
113
  #streamEventNotify;
259
114
  #wakeRunNotify;
260
- #injectRun;
261
- #cancelRun;
262
- // Telemetry event fan-out: every TelemetryEvent pushed to the loop's
263
- // buffer is also broadcast live to the connected client(s) on the
264
- // session. Without this, the client sees `loop/terminated` with a
265
- // status code but has no way to surface why the loop degraded.
266
- // Per-grammar 0.17.0 protocol — see SPEC §telemetry.
267
- #telemetryEventNotify;
268
115
  // Cached plurnk GBNF — read once on the first constrained generate (#189).
269
116
  #gbnfCache = null;
270
117
  constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, injectRun, cancelRun, telemetryEventNotify, tokenize }) {
@@ -272,9 +119,6 @@ class Engine {
272
119
  this.#schemes = schemes;
273
120
  this.#streamEventNotify = streamEventNotify;
274
121
  this.#wakeRunNotify = wakeRunNotify;
275
- this.#injectRun = injectRun;
276
- this.#cancelRun = cancelRun;
277
- this.#telemetryEventNotify = telemetryEventNotify;
278
122
  // Default to empty discovery — standalone Engine construction (in
279
123
  // tests) gets no handlers, and content flows through the framework's
280
124
  // raw-content fitContent fallback. Daemon-managed Engine receives a
@@ -282,17 +126,48 @@ class Engine {
282
126
  this.#mimetypes = mimetypes ?? new Mimetypes({
283
127
  discovery: { registry: emptyRegistry(), handlers: new Map() },
284
128
  });
285
- this.#budgetCeiling = readCeiling();
286
129
  // Tripwire default matches the Mimetypes boot affordance (SPEC §mimetype-surface):
287
130
  // the divisor stands in only until the provider-backed tokenizer is
288
131
  // wired by the Daemon. Real counts come from provider.countTokens.
289
132
  this.#tokenize = tokenize ?? ((text) => Math.ceil(text.length / 4));
133
+ const executors = () => this.#executors;
134
+ const loopSignal = (loopId) => this.#loopAborts.get(loopId)?.signal;
135
+ this.#telemetry = new TelemetryChannel({ db, notify: telemetryEventNotify });
136
+ this.#strikes = new StrikeRail();
137
+ this.#packets = new PacketBuilder({ db, schemes, telemetry: this.#telemetry, executors });
138
+ this.#proposals = new ProposalLifecycle({
139
+ db, schemes, telemetry: this.#telemetry,
140
+ streamEventNotify, wakeRunNotify,
141
+ tokenize: this.#tokenize, executors, loopSignal,
142
+ });
143
+ this.#dispatcher = new Dispatcher({
144
+ db, schemes, mimetypes: this.#mimetypes,
145
+ tokenize: this.#tokenize,
146
+ telemetry: this.#telemetry, proposals: this.#proposals,
147
+ executors, loopSignal,
148
+ streamEventNotify, wakeRunNotify, injectRun, cancelRun,
149
+ });
290
150
  }
291
151
  // Late injection: the executor registry is async-built at daemon start()
292
152
  // (discover + probe), after Engine construction.
293
153
  setExecutors(executors) {
294
154
  this.#executors = executors;
295
155
  }
156
+ // Runtime hotload (#289) — register an executor TAG live, after boot (the /mcp route: an MCP
157
+ // server connected at runtime becomes EXEC[<server>]). Registers on BOTH registries the boot path
158
+ // wires: the ExecutorRegistry (dispatch resolves the tag; the tools sheet, rebuilt per packet, then
159
+ // offers it to the model) and the SchemeRegistry face (the tag's READ/FIND/KILL scheme), sharing the
160
+ // same reserved/cross-family arbitration boot uses. Fail-hard if the registry isn't wired yet — a
161
+ // hotload before daemon start() is a caller bug, not a silent no-op.
162
+ hotloadRuntime(tag, entry) {
163
+ if (this.#executors === undefined)
164
+ throw new Error("hotloadRuntime: executor registry not wired yet (call after daemon start)");
165
+ // Scheme face FIRST — it is the arbitration gate (reserved / cross-family collision, #240) and
166
+ // throws before we mutate the executor registry, so a rejected tag leaves neither registry
167
+ // half-written. A brand-new tag registers on both; a reserved/claimed tag throws here untouched.
168
+ this.#schemes.registerRuntimeScheme(tag, entry.executor);
169
+ this.#executors.register(tag, entry);
170
+ }
296
171
  // Grammar-constrained sampling (#189): when PLURNK_PROVIDERS_GBNF is enabled
297
172
  // (the only knob — default-on in .env.example), hand the provider the plurnk
298
173
  // GBNF (the full shipped multi-op root, read once + cached). The provider
@@ -334,25 +209,6 @@ class Engine {
334
209
  meta: JSON.parse(row?.meta ?? "{}"),
335
210
  };
336
211
  }
337
- #pushTelemetry(sessionId, loopId, event) {
338
- const existing = this.#telemetryBuffer.get(loopId);
339
- if (existing === undefined)
340
- this.#telemetryBuffer.set(loopId, [event]);
341
- else
342
- existing.push(event);
343
- // Live fan-out: client sees the event the moment it lands in the
344
- // model's buffer (not at the next packet build). Same envelope on
345
- // both sides per the grammar 0.17.0 TelemetryEvent protocol.
346
- this.#telemetryEventNotify?.(sessionId, { loopId, event });
347
- }
348
- // Telemetry drains as it's read into the packet — each event surfaces once. §telemetry-drain-on-read
349
- #drainTelemetry(loopId) {
350
- const buf = this.#telemetryBuffer.get(loopId);
351
- if (buf === undefined)
352
- return [];
353
- this.#telemetryBuffer.delete(loopId);
354
- return buf;
355
- }
356
212
  // A @plurnk/gbnf divergence position (providers#24) is a CODE-POINT offset into the
357
213
  // model's content; the snippet/telemetry surface speaks 1-based line + 0-based column.
358
214
  // Convert over code points (not UTF-16 units) so an astral char doesn't skew the line,
@@ -386,6 +242,18 @@ class Engine {
386
242
  signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
387
243
  }
388
244
  this.#loopAborts.set(loopId, loopAbort);
245
+ // §operator-config-loop-timeout — the wall-clock budget. Expiry aborts the loop signal, so a
246
+ // mid-flight provider call (generate rides this signal) and in-flight spawns tear down; the
247
+ // loop terminates 504 (kin to the exec <T> reap's 504, §exec-timeout) — a legible engine
248
+ // terminal, never an outside kill. unref'd: the wall never holds the process open.
249
+ const wall = setTimeout(() => loopAbort.abort(LOOP_TIMEOUT_REASON), readLoopTimeoutMs());
250
+ wall.unref();
251
+ const timedOut = () => loopAbort.signal.aborted && loopAbort.signal.reason === LOOP_TIMEOUT_REASON;
252
+ const ruleTimeout = async () => {
253
+ await this.#db.engine_loop_set_status.run({ loop_id: loopId, status: 504, message: "loop_timeout" });
254
+ cleanup("forceful", "loop_timeout");
255
+ return { turnIds, finalStatus: 504, hitMaxTurns: false, reason: "loop_timeout" };
256
+ };
389
257
  // Cleanup splits by termination kind:
390
258
  // - "graceful" (SEND[202] Accepted): in-flight streaming-scheme spawns
391
259
  // are ALLOWED to outlive the loop — they complete naturally, write final
@@ -394,14 +262,18 @@ class Engine {
394
262
  // - "forceful" (SEND[200] done, max_turns, strike, cancel, budget, 4xx/5xx):
395
263
  // fire the loop-level abort so leftover spawns tear down. "Done" reaps.
396
264
  const cleanup = (kind, reason) => {
265
+ clearTimeout(wall);
397
266
  if (kind === "forceful" && !loopAbort.signal.aborted) {
398
267
  loopAbort.abort(reason ?? "loop_forceful_termination");
399
268
  }
400
269
  this.#loopAborts.delete(loopId);
401
- this.#strikeState.delete(loopId);
402
- this.#telemetryBuffer.delete(loopId);
270
+ this.#strikes.delete(loopId);
271
+ this.#telemetry.delete(loopId);
403
272
  };
404
273
  while (true) {
274
+ // The wall fired between turns — rule 504 before anything else reads the loop.
275
+ if (timedOut())
276
+ return await ruleTimeout();
405
277
  signal?.throwIfAborted();
406
278
  const row = await this.#db.engine_loop_status.get({ loop_id: loopId });
407
279
  if (row === undefined)
@@ -430,10 +302,20 @@ class Engine {
430
302
  if (execHandler?.hasActiveSpawns?.(runId) === true)
431
303
  await delay(execWaitMs, undefined, { signal });
432
304
  }
433
- const turn = await this.runTurn({
434
- provider, messages, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
435
- turnNumber: turnIds.length + 1, maxTurns,
436
- });
305
+ let turn;
306
+ try {
307
+ turn = await this.runTurn({
308
+ provider, messages, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
309
+ turnNumber: turnIds.length + 1, maxTurns,
310
+ });
311
+ }
312
+ catch (err) {
313
+ // The wall fired mid-turn — the abort tore the turn down (generate rides the loop
314
+ // signal); rule the legible 504, never a generic drain error.
315
+ if (timedOut())
316
+ return await ruleTimeout();
317
+ throw err;
318
+ }
437
319
  turnIds.push(turn.turnId);
438
320
  // SPEC §grinder: budget hard-stop — packet won't fit even collapsed → abandon.
439
321
  if (turn.budgetHardStop) {
@@ -442,59 +324,26 @@ class Engine {
442
324
  cleanup("forceful", "budget_overflow");
443
325
  return { turnIds, finalStatus: 413, hitMaxTurns: false, reason: "budget_overflow" };
444
326
  }
445
- // Rail #39: cycle detection. Push this turn's fingerprint to
446
- // history, scan for repetition patterns. Detection bumps
447
- // turnErrors so the strike system handles abandonment
448
- // naturally same internal-only role rummy gave it
449
- // (plugins/error/error.js#verdict). Intentionally NOT a
450
- // model-facing telemetry kind: model sees the strike pile-up
451
- // (which IS the actionable signal); cycle is the engine's
452
- // reason for treating the turn as a failure, not its own alert.
453
- const state = this.#strikeState.get(loopId) ?? { streak: 0, turnErrors: 0, history: [] };
454
- state.history.push(turn.fingerprint);
455
- const cycle = _a.detectCycle(state.history, minCycles, maxCyclePeriod);
456
- if (cycle.detected)
457
- state.turnErrors++;
458
- // SPEC §grinder: a non-soft grinder fire counts toward the strike streak.
459
- if (turn.budgetStruck)
460
- state.turnErrors++; // a grinder fire bumps the strike streak — §grinder-strike-coupling
461
- if (turn.steerStruck)
462
- state.turnErrors++; // idle / premature-terminate steer struck — §send the terminal contract
463
- this.#strikeState.set(loopId, state);
464
- // Rail #38: strike accounting. Three sources strike a turn:
465
- // 1. recordedFailed — any action-entry at hard failure status
466
- // (>= 400 and not in SOFT_FAILURE_STATUSES).
467
- // 2. noOps — turn.status === TURN_STATUS_NO_OPS (per #41).
468
- // 3. turnErrors — externally bumped by per-turn rails (#39 cycle).
469
- // Struck → streak++; clean → streak = 0. Threshold → abandon.
470
- // Strike accounting is engine-internal bookkeeping. Per rummy
471
- // precedent (plugins/error/error.js#verdict) and SPEC §telemetry
472
- // policy: model sees errors that happened (parse_error,
473
- // action_failure), never the engine's accounting about them
474
- // (strike counts, cycle detection, sudden-death threshold).
475
- // Surfacing internal state to the model creates a gamification
476
- // surface — model optimizes for engine metrics rather than
477
- // task progress.
478
- const recordedFailed = turn.statuses.some((s) => s >= 400 && !SOFT_FAILURE_STATUSES.has(s));
479
- const noOps = turn.status === TURN_STATUS_NO_OPS;
480
- const struck = noOps || recordedFailed || state.turnErrors > 0;
481
- if (struck) {
482
- state.streak++;
483
- if (state.streak >= maxStrikes) {
484
- // §loop-terminals — a cycle-driven strike is the model spinning in place
485
- // (508 Loop Detected); a failure/no-op strike is the model failing (500
486
- // Internal Server Error). The straw that crossed the threshold picks it.
487
- const status = cycle.detected ? 508 : 500;
488
- await this.#db.engine_loop_set_status.run({ loop_id: loopId, status, message: "strike_threshold" });
489
- cleanup("forceful", "strike_threshold");
490
- return { turnIds, finalStatus: status, hitMaxTurns: false, reason: "strike_threshold" };
491
- }
492
- }
493
- else {
494
- state.streak = 0;
327
+ // Rails #38/#39 per-turn strike accounting (cycle detection, the
328
+ // grinder/steer coupling, hard-failure statuses). StrikeRail owns the
329
+ // bookkeeping; runLoop owns abandonment.
330
+ const verdict = this.#strikes.assess(loopId, {
331
+ fingerprint: turn.fingerprint,
332
+ statuses: turn.statuses,
333
+ noOps: turn.status === TURN_STATUS_NO_OPS,
334
+ budgetStruck: turn.budgetStruck,
335
+ steerStruck: turn.steerStruck,
336
+ minCycles, maxCyclePeriod, maxStrikes,
337
+ });
338
+ if (verdict.thresholdCrossed) {
339
+ // §loop-terminals — a cycle-driven strike is the model spinning in place
340
+ // (508 Loop Detected); a failure/no-op strike is the model failing (500
341
+ // Internal Server Error). The straw that crossed the threshold picks it.
342
+ const status = verdict.cycleDetected ? 508 : 500;
343
+ await this.#db.engine_loop_set_status.run({ loop_id: loopId, status, message: "strike_threshold" });
344
+ cleanup("forceful", "strike_threshold");
345
+ return { turnIds, finalStatus: status, hitMaxTurns: false, reason: "strike_threshold" };
495
346
  }
496
- state.turnErrors = 0;
497
- this.#strikeState.set(loopId, state);
498
347
  // Sudden-death threshold is engine-internal — abandonment
499
348
  // happens at maxTurns regardless. Per gamification policy:
500
349
  // we don't warn the model that it's nearing our limit.
@@ -615,7 +464,7 @@ class Engine {
615
464
  tokenize: this.#tokenize,
616
465
  mimetypes: this.#mimetypes,
617
466
  defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
618
- pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
467
+ pushTelemetry: (event) => this.#telemetry.push(sessionId, loopId, event),
619
468
  };
620
469
  // SPEC §membership D4/D5 — git-ls-files workspace membership, resolved at
621
470
  // prompt-composition (EMI is eager + exhaustive — git is the only bound). When the
@@ -729,7 +578,7 @@ class Engine {
729
578
  // so the grammar can stay thin. Subsequent turns mirror the model's real output, folded.
730
579
  if (runFirstLoop) {
731
580
  const emission = ["<<PLAN:Initialize:PLAN", ...turnZeroMoves, "<<SEND[102]:Initialized:SEND"].join("\n");
732
- await this.#writeModelEntry({ verbatim: emission, runId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
581
+ await this.#dispatcher.writeModelEntry({ verbatim: emission, runId, loopId, turnId, sequence: 1, folded: false, origin: "plurnk" });
733
582
  }
734
583
  }
735
584
  // §environment-observation — pre-seed the run's ambient observations (what changed since
@@ -747,15 +596,15 @@ class Engine {
747
596
  // (a service-side `git status` shell-out) and threaded into the budget
748
597
  // rebuild too so it isn't re-shelled on overflow.
749
598
  const gitStatus = await GitState.status(this.#db, sessionId, this.#loopAborts.get(loopId)?.signal);
750
- // Build the spec'd packet (Packet.json) request half. #buildLog
599
+ // Build the spec'd packet (Packet.json) request half. The log build
751
600
  // queries log_entries scoped to the run — the prompt entry just
752
601
  // written (if turn 1) is part of that query result.
753
- let requestPacket = await this.#buildRequestPacket({
602
+ let requestPacket = await this.#packets.buildRequestPacket({
754
603
  initialMessages: messages, requirements, sessionId, runId, loopId,
755
604
  currentTurnSeq: seq, provider, gitStatus,
756
605
  });
757
606
  // SPEC §grinder — budget grinder, pre-LLM: reclaim window on actual overflow.
758
- const enforced = await this.#enforceBudget({
607
+ const enforced = await this.#packets.enforceBudget({
759
608
  packet: requestPacket, provider, runId, loopId, turnId,
760
609
  // The overflow error row is minted at the turn's running sequence (nextActionIndex), pre-generate;
761
610
  // runTurn advances the counter past it below so the post-generate dispatch rows never collide.
@@ -763,7 +612,7 @@ class Engine {
763
612
  // No preset telemetry — the rebuild RE-DERIVES the errors section from log≥400 so the
764
613
  // overflow row just minted surfaces THIS turn (§grinder-overflow-error-row). Safe: the
765
614
  // ephemeral buffer is empty pre-generate (events drain on the next turn's build).
766
- rebuild: () => this.#buildRequestPacket({
615
+ rebuild: () => this.#packets.buildRequestPacket({
767
616
  initialMessages: messages, requirements, sessionId, runId, loopId,
768
617
  currentTurnSeq: seq, provider, gitStatus,
769
618
  }),
@@ -774,7 +623,7 @@ class Engine {
774
623
  if (!enforced.fit) {
775
624
  // Hard 413: won't fit even with only the manifest left. Skip the LLM,
776
625
  // close the turn, and let runLoop abandon (499).
777
- const hardPacket = this.#completePacket(requestPacket, { content: "", ops: [], reasoning: null }, null, provider);
626
+ const hardPacket = this.#packets.completePacket(requestPacket, { content: "", ops: [], reasoning: null }, null, provider);
778
627
  await this.#db.engine_close_turn.run({
779
628
  id: turnId, status: 413, packet: JSON.stringify(hardPacket),
780
629
  usage_prompt: 0, usage_completion: 0, usage_cached: 0, usage_cost_pico: 0,
@@ -783,7 +632,7 @@ class Engine {
783
632
  });
784
633
  return { turnId, status: 413, statuses: [], fingerprint: "", budgetStruck: enforced.struck, budgetHardStop: true, steerStruck: false };
785
634
  }
786
- const modelMessages = this.#packetToWireMessages(requestPacket);
635
+ const modelMessages = PacketWire.packetToWireMessages(requestPacket);
787
636
  // No decode cap. Our budget governs the TRANSMISSION packet (the grinder folds
788
637
  // the input under the ceiling); the model's decode — reasoning + emission — is
789
638
  // out of band, owned by the provider's own context window. Deriving a maxTokens
@@ -801,7 +650,20 @@ class Engine {
801
650
  // #249 — session-stable frontend id, forwarded as Plurnk-Client by the plurnk provider only.
802
651
  const { client } = await SessionSettings.read(this.#db, sessionId);
803
652
  try {
804
- response = await provider.generate({ messages: modelMessages, runId: String(runId), signal, grammar: await this.#grammarConstraint(), attributions: attributions.length > 0 ? attributions : undefined, client: client ?? undefined }); // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved §client-telemetry
653
+ // §turn-lifecycle (#301) the provider call is the long, opaque window (submit first
654
+ // committed op is provider latency + a full first-turn generation, ~70s local): a static
655
+ // screen there is indistinguishable from a hang. Bracket generate() with two telemetry beats
656
+ // so a client can show "awaiting model" the instant the turn starts and flip to "parsing" when
657
+ // ops are about to land. Base telemetry/event channel (the embed_progress precedent, §tokenomics
658
+ // clients already render it unconditionally); the abort guard keeps a cancelled loop silent.
659
+ if (!signal?.aborted)
660
+ this.#telemetry.push(sessionId, loopId, { source: "engine:turn", kind: "turn_awaiting_model", level: "info", message: "awaiting model response" });
661
+ // generate rides the LOOP signal (already chained from the caller's), so a loop-level
662
+ // abort — the §operator-config-loop-timeout wall — cancels a stuck provider call, not
663
+ // just the schemes. Bare runTurn (no runLoop) has no loop entry → the caller's signal.
664
+ response = await provider.generate({ messages: modelMessages, runId: String(runId), signal: this.#loopAborts.get(loopId)?.signal ?? signal, grammar: await this.#grammarConstraint(), attributions: attributions.length > 0 ? attributions : undefined, client: client ?? undefined }); // §provider-surface-generate §provider-guarantees-single-call §provider-guarantees-signal-wired §attribution-plurnk-namespace-reserved §client-telemetry
665
+ if (!signal?.aborted)
666
+ this.#telemetry.push(sessionId, loopId, { source: "engine:turn", kind: "turn_generated", level: "info", message: "parsing model response" });
805
667
  }
806
668
  catch (err) {
807
669
  // Every provider error surfaces as telemetry (the client/model sees the cause). #256:
@@ -814,7 +676,7 @@ class Engine {
814
676
  // In GBNF-filter mode the provider returns the bytes with a grammar_unenforced telemetry
815
677
  // event instead — recovered on the success path below (response.telemetry), no empty turn.
816
678
  if (err instanceof ProviderError) {
817
- this.#pushTelemetry(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message, level: "error" });
679
+ this.#telemetry.push(sessionId, loopId, { source: "provider", kind: err.kind, message: err.message, level: "error" });
818
680
  if (err.kind !== "grammar_unenforced")
819
681
  throw err;
820
682
  response = {
@@ -861,7 +723,7 @@ class Engine {
861
723
  : null;
862
724
  if (located !== null)
863
725
  hadContentOffsetNotice = true;
864
- this.#pushTelemetry(sessionId, loopId, {
726
+ this.#telemetry.push(sessionId, loopId, {
865
727
  source: event.source,
866
728
  kind: event.kind,
867
729
  message: event.message ?? "",
@@ -888,17 +750,44 @@ class Engine {
888
750
  // Premature terminate: a SEND[200] while the run still holds a live thing — an open stream/spawn
889
751
  // OR a non-terminal child run (§run-lifecycle: children and streams are the same kind of "live
890
752
  // thing a run holds"). The model declared done with work running. The SEND is REFUSED 409 at
891
- // dispatch (#handleSendBroadcast) — the row records the [200] attempt + body faithfully (no
892
- // erasure to 102) and auto-surfaces (status≥400); the loop never goes terminal. Here we only
893
- // flag it so the turn stays a continue and the strike couples to the grinder (steerStruck →
894
- // turnErrors): a model that won't stop premature-200ing escalates out via the rails.
895
- const prematureTerminate = sendOp?.signal === 200 && await this.#runHoldsLiveThing(runId);
753
+ // dispatch (Dispatcher's send-broadcast handler) — the row records the [200] attempt + body
754
+ // faithfully (no erasure to 102) and auto-surfaces (status≥400); the loop never goes terminal.
755
+ // Here we only flag it so the turn stays a continue and the strike couples to the grinder
756
+ // (steerStruck → turnErrors): a model that won't stop premature-200ing escalates out via the rails.
757
+ // A terminal SEND[200] is premature when the run still holds a live thing (open stream/spawn or
758
+ // a non-terminal child), OR when the model submitted a READ THIS turn whose result it cannot have
759
+ // seen — results fold back on the NEXT turn, so terminating now means terminating on data it
760
+ // doesn't have (the classic "READ + SEND[200] in one turn" that ends mid-sentence). Both refuse
761
+ // 409 + strike; live-thing takes precedence when both hold.
762
+ let prematureReason;
763
+ if (sendOp?.signal === 200) {
764
+ prematureReason = (await this.#runHoldsLiveThing(runId)) ? "live-thing"
765
+ : packetAssistant.ops.some((op) => op.op === "READ") ? "submitted-read"
766
+ : undefined;
767
+ }
768
+ else if (sendOp?.signal === 202) {
769
+ // §send-groundless-hibernate — a park is refused only when it ORPHANS work: the turn
770
+ // submitted a READ (its result folds back on a next turn this park may never have) AND no
771
+ // wake edge exists — nothing held (the pre-dispatch snapshot) and nothing wake-capable
772
+ // opened this turn (a spawn takes effect mid-turn, after the snapshot — the same
773
+ // emission-scan shape as submitted-read). A bare park holding nothing stays LEGAL — the
774
+ // voice door: a sibling irc / operator inject wakes it (§actor-boundary-passive-wake) and
775
+ // the daemon surfaces it as loop/quiesced (§run-lifecycle-quiesced), so it is never refused.
776
+ if (packetAssistant.ops.some((op) => op.op === "READ")) {
777
+ const wakeEdge = (await this.#runHoldsLiveThing(runId))
778
+ || packetAssistant.ops.some((op) => Engine.#opCanOpenWakeEdge(op));
779
+ if (!wakeEdge)
780
+ prematureReason = "groundless-hibernate";
781
+ }
782
+ }
783
+ const prematureTerminate = prematureReason !== undefined;
896
784
  if (prematureTerminate)
897
785
  steerStruck = true;
898
786
  // Rail #41 (revised): the per-turn requirement is "emit at least one op," not "emit a terminal
899
787
  // SEND." SEND is purely a signal verb; many turns pass without one. An empty op list strikes.
900
- // A refused premature-terminate keeps the turn a continue (102) though the SEND's signal stays
901
- // 200 (the un-erased record) — the loop never went terminal, so the turn didn't either.
788
+ // A refused terminal (premature [200] or groundless [202]) keeps the turn a continue (102)
789
+ // though the SEND's signal stays on the row (the un-erased record) — the loop never went
790
+ // terminal, so the turn didn't either.
902
791
  const turnStatus = prematureTerminate
903
792
  ? TURN_STATUS_IMPLICIT_CONTINUE
904
793
  : sendOp !== undefined
@@ -912,7 +801,7 @@ class Engine {
912
801
  pendingEngineErrors.push("idle_turn");
913
802
  }
914
803
  // Close the turn with the final packet, status, and usage stats.
915
- const packet = this.#completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
804
+ const packet = this.#packets.completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
916
805
  const { usage, finishReason, model } = callMetadata;
917
806
  await this.#db.engine_close_turn.run({
918
807
  id: turnId,
@@ -960,7 +849,7 @@ class Engine {
960
849
  statement, sessionId, runId, loopId, turnId,
961
850
  sequence: rowSeq,
962
851
  origin, onDispatch,
963
- prematureRefusal: prematureTerminate && statement === sendOp, // the pre-dispatch snapshot's decision, only for this turn's terminal SEND
852
+ prematureRefusal: prematureTerminate && statement === sendOp ? prematureReason : undefined, // the pre-dispatch snapshot's reason, only for this turn's terminal SEND
964
853
  });
965
854
  statuses.push(result.status);
966
855
  rowSeq += result.rowsWritten ?? 1;
@@ -973,7 +862,7 @@ class Engine {
973
862
  if (droppedCount > 0)
974
863
  pendingEngineErrors.push("max_commands_exceeded");
975
864
  for (const kind of pendingEngineErrors)
976
- await this.#mintEngineError(kind, { runId, loopId, turnId, sequence: errSeq++ });
865
+ await this.#telemetry.mintEngineError(kind, { runId, loopId, turnId, sequence: errSeq++ });
977
866
  // Parse errors carry the parser message + a content-offset line:col (a ContentOffset position),
978
867
  // resolved against the model's born-OPEN emission (§model-entry) — origin 'model', not engine.
979
868
  for (const { message, line, column, source } of parseErrors ?? []) {
@@ -999,13 +888,13 @@ class Engine {
999
888
  // struck/silent turn) write nothing — no prior output to mirror.
1000
889
  if (packetAssistant.content.trim().length > 0) {
1001
890
  const hadEmissionError = (parseErrors?.length ?? 0) > 0 || hadContentOffsetNotice;
1002
- await this.#writeModelEntry({ verbatim: packetAssistant.content, runId, loopId, turnId, sequence: errSeq++, folded: !hadEmissionError });
891
+ await this.#dispatcher.writeModelEntry({ verbatim: packetAssistant.content, runId, loopId, turnId, sequence: errSeq++, folded: !hadEmissionError });
1003
892
  }
1004
893
  // Zero ops is NOT an error to report — the model knows it emitted
1005
894
  // nothing. Strike accounting (engine-internal) treats it as a
1006
895
  // struck turn; the model just sees an empty packet next turn.
1007
896
  // Per SPEC §telemetry gamification policy.
1008
- return { turnId, status: turnStatus, statuses, fingerprint: _a.fingerprintTurn(packetAssistant.ops), budgetStruck: enforced.struck, budgetHardStop: false, steerStruck };
897
+ return { turnId, status: turnStatus, statuses, fingerprint: StrikeRail.fingerprintTurn(packetAssistant.ops), budgetStruck: enforced.struck, budgetHardStop: false, steerStruck };
1009
898
  }
1010
899
  // Split the wire-level ProviderResponse into the two destinations:
1011
900
  // packet.assistant gets the model's emission (content, ops, reasoning);
@@ -1071,340 +960,10 @@ class Engine {
1071
960
  parseErrors,
1072
961
  };
1073
962
  }
1074
- // Assemble the request half of the spec'd packet (Packet.json §system
1075
- // and §user) BEFORE the provider call. The same packet object is then
1076
- // completed with assistant + assistantRaw after the model responds, so
1077
- // the stored packet and the wire payload share one source of truth.
1078
- async #buildRequestPacket({ initialMessages, requirements, sessionId, runId, loopId, currentTurnSeq, provider, gitStatus, telemetryErrors: presetTelemetry, }) {
1079
- const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
1080
- // plurnk.md (grammar/dialects) ONLY — the definition is the hot-path grammar.
1081
- // The scheme catalogue is its own `schemes` section below tools (§schemes-directory),
1082
- // NOT appended here: grammar 0.49+ is scheme-agnostic, so the service advertises
1083
- // the scheme set at packet-time (grammar#239 item 7) via SchemeRegistry.teach().
1084
- const system_definition = byRole("system");
1085
- // the prompt section sources from the loop's most recent prompt entry first
1086
- // (plurnk:///prompt/<loop_id>/<N> for the highest N written to date).
1087
- // This is what inject + the turn-1 foist write into. Falls back to
1088
- // the runLoop caller's messages.user for tests that bypass the
1089
- // foist mechanism entirely.
1090
- const promptRows = (await this.#db.drain_get_all_prompt_bodies_for_loop.all({ pattern: `/prompt/${loopId}/%` }))
1091
- .filter((r) => typeof r.content === "string" && r.content.length > 0);
1092
- const promptCap = Number.parseInt(process.env.PLURNK_PROMPT_PREVIEW_CHARS ?? "", 10);
1093
- const prompt = promptRows.length > 0
1094
- ? PacketWire.renderActivePrompts(promptRows, Number.isInteger(promptCap) ? promptCap : -1)
1095
- : byRole("user");
1096
- // Requirements is engine-sourced, NOT threaded from callers — that threading is
1097
- // exactly how it went missing (callers read the sysprompt but never the
1098
- // requirements). Read Paths.defaultRequirements (PLURNK_REQUIREMENTS env →
1099
- // requirements.md) fresh each build so edits take effect; a non-empty param wins.
1100
- const baseRequirements = requirements.length > 0 ? requirements : await readFile(Paths.defaultRequirements, "utf8");
1101
- // No injected syntax line: the grammar already headlines the system definition (§Syntax) and
1102
- // leads requirements.md, so a third copy here was pure duplication in the model's packet. PLAN
1103
- // is mandated unconditionally by plurnk.md §Imperatives (grammar 0.70 requires every turn to
1104
- // lead with PLAN), so the service injects no separate plan directive either (the former
1105
- // PLURNK_PLAN gating is retired — PLURNK_PLAN is no longer a flag).
1106
- const log = await this.#buildLog(runId);
1107
- const telemetryErrors = presetTelemetry ?? await this.#buildTelemetryErrors(loopId, currentTurnSeq);
1108
- const countTokens = (t) => provider.countTokens(t); // §provider-surface-counttokens
1109
- const tools = this.#collectTools();
1110
- // Budget readout (SPEC.md §tokenomics). Two-pass: render the budget from
1111
- // the structured log's subtotals with a {{tokensFree}} placeholder, build
1112
- // the section list, measure the assembled total, resolve free, substitute.
1113
- // Subtotals come from the real log render — meta and fences included — not
1114
- // a serialized approximation. ceiling is the provider's window ×
1115
- // PLURNK_BUDGET_CEILING (null when no window is reported → headline
1116
- // omitted, section lines still shown). §tokenomics-render-weight-budget
1117
- const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
1118
- const budgetReadout = this.#renderBudget(PacketWire.measureLogBudget(log, countTokens), ceiling);
1119
- // The default packet: an ordered list of addressable sections (§packet-construction).
1120
- // `slot` is a TRUST boundary (and the prompt-cache boundary): system holds only
1121
- // framework-authored, non-injectable sections — the static head (definition/tools/
1122
- // schemes/policy) forms the cached prefix, then the volatile-but-trusted tail of
1123
- // errors/git/budget; user holds injectable content (the log, the operator prompt) plus
1124
- // the requirements footer. The budget section carries its {{tokensFree}} placeholders
1125
- // here; they resolve below once the assembled total is known.
1126
- const inject = await readPacketInject(); // #240 — operator section, per-turn, fail-hard on a broken path
1127
- const sessionRoot = (await this.#db.envelope_get_session.get({ id: sessionId }))?.project_root ?? null;
1128
- const systemPolicy = await readSystemPolicy(); // ~/.plurnk/AGENTS.md (or PLURNK_POLICY)
1129
- const projectPolicy = await readProjectPolicy(sessionRoot); // <projectRoot>/AGENTS.md (or PLURNK_PROJECT)
1130
- // Child-orientation (§child-orientation): the live things THIS run holds — open streams +
1131
- // unconcluded child runs — surfaced every turn as terse `* <status> <path>` pointers (same shape
1132
- // as errors) just above the errors section. Orienting STATE so the model never loses track of
1133
- // what it's holding (the premature-terminate trap), never advice on what to do. Empty → omitted.
1134
- const childStreams = (await this.#db.engine_child_streams_open.all({ run_id: runId }))
1135
- .map((s) => ({ status: "active", path: renderAddress(s.scheme, s.pathname) }));
1136
- const childRuns = (await this.#db.engine_child_runs_live.all({ run_id: runId }))
1137
- .map((r) => ({ status: r.status, path: `run://${r.name}` }));
1138
- const defaults = [
1139
- { name: "definition", slot: "system", header: null, content: system_definition, tokens: 0 },
1140
- { name: "tools", slot: "system", header: null, content: tools.join("\n"), tokens: 0 }, // titleless — the examples flow on from plurnk.md (definition) directly above
1141
- { name: "schemes", slot: "system", header: "Plurnk Service Schemes", content: this.#schemes.teach(), tokens: 0 },
1142
- ...(inject !== null ? [{ name: "inject", slot: "system", header: "Plurnk Operator Notes", content: inject, tokens: 0 }] : []),
1143
- // policy: the client's privileged rules — ~/.plurnk/AGENTS.md (system) then <root>/AGENTS.md (project) — below grammar/tools/schemes, above budget-the-law. AGENTS is POLICY here, never a curatable READable entry. Empty content ⇒ section omitted.
1144
- { name: "system-policy", slot: "system", header: "Plurnk Service Policy", content: systemPolicy ?? "", tokens: 0 },
1145
- { name: "project-policy", slot: "system", header: "Project Policy", content: projectPolicy ?? "", tokens: 0 },
1146
- // The packet split is a TRUST boundary: system carries only framework-authored, non-injectable
1147
- // sections; anything that could carry attacker-reachable text (a READ result, exec output, the
1148
- // model's own mirrored bytes) stays in user. errors + git are framework status — the errors
1149
- // section is uri+status POINTERS (the error item + body live in the log), git is counts — so
1150
- // neither is an injection surface; both sit at the bottom of system, just above budget-the-law.
1151
- // child-orientation: what THIS run holds live — streams then runs — just above errors. Terse
1152
- // pointers (the path is the actionable address the model READs/OPENs/KILLs), never advice. §child-orientation
1153
- { name: "child-streams", slot: "system", header: "Plurnk Service Child Streams", content: PacketWire.renderChildPointers(childStreams), tokens: 0 },
1154
- { name: "child-runs", slot: "system", header: "Plurnk Service Child Runs", content: PacketWire.renderChildPointers(childRuns), tokens: 0 },
1155
- { name: "errors", slot: "system", header: "Plurnk Service Errors", content: PacketWire.renderErrors(telemetryErrors), tokens: 0 },
1156
- { name: "git", slot: "system", header: "Plurnk Service Git Status", content: PacketWire.renderGit(gitStatus), tokens: 0 },
1157
- // budget is the very last system line — LAW (a hard ceiling the model must obey), the final word before the model acts.
1158
- { name: "budget", slot: "system", header: "Plurnk Service Budget", content: budgetReadout, tokens: 0 },
1159
- // log in the user slot: injectable content (READ results, exec output, the model's own mirror) — data, never rules — kept at the action point so the model consults its history.
1160
- { name: "log", slot: "user", header: "Plurnk Service Log", content: PacketWire.renderLog(log, countTokens), tokens: 0 },
1161
- // the ACTIVE user prompts (all the current loop holds, in order — a loop admits injected
1162
- // prompts) render at the BOTTOM, just above requirements — at the action point, closest to
1163
- // the model's turn. Each is a bare heredoc (the fence is the link); §prompt-fold.
1164
- { name: "prompt", slot: "user", header: "Plurnk Service Active User Prompts", content: prompt, tokens: 0 },
1165
- // requirements renders LAST — the user-slot footer, the syntax contract closest to the model's turn (a recency carve-out for weak models).
1166
- { name: "requirements", slot: "user", header: "Plurnk Service Requirements", content: baseRequirements, tokens: 0 },
1167
- ];
1168
- // Plugin packet control (§packet-construction): trusted schemes rewrite the
1169
- // default list — add, remove, reorder — in-process, before measurement.
1170
- const sections = await this.#schemes.transformSections(defaults);
1171
- // Pass 1: measure the assembled total with the placeholder budget in
1172
- // place, resolve free/percent, substitute into the budget section.
1173
- const total = countTokens(PacketWire.renderSlot(sections, "system")) + countTokens(PacketWire.renderSlot(sections, "user"));
1174
- const tokensFree = ceiling === null ? null : Math.max(0, ceiling - total); // free floors at 0 on overshoot — §tokenomics-over-budget-floor
1175
- const percent = ceiling === null ? null : Math.round((total / ceiling) * 100); // usage as % of the ceiling — §tokenomics-context-percent
1176
- if (tokensFree !== null) {
1177
- const budgetSec = sections.find((s) => s.name === "budget"); // a plugin may have removed it
1178
- if (budgetSec) {
1179
- budgetSec.content = budgetSec.content
1180
- .replace(TOKEN_USAGE_PLACEHOLDER, String(total))
1181
- .replace(TOKEN_PERCENT_PLACEHOLDER, percent === 0 && total > 0 ? "<1" : String(percent))
1182
- .replace(TOKENS_FREE_PLACEHOLDER, String(tokensFree));
1183
- }
1184
- }
1185
- // Pass 2: per-section render-weight + the assembled packet total (post
1186
- // substitution — the placeholder/number length delta is negligible).
1187
- for (const s of sections)
1188
- s.tokens = countTokens(PacketWire.renderSection(s));
1189
- const packetTokens = countTokens(PacketWire.renderSlot(sections, "system")) + countTokens(PacketWire.renderSlot(sections, "user"));
1190
- return { tokens: packetTokens, sections, telemetryErrors };
1191
- }
1192
- // Budget readout body, rendered into the `## Plurnk Service Budget` section.
1193
- // Headline `ceiling/free` only when a ceiling exists; section lines for the
1194
- // curatable index/log weight the model can FOLD back. tokensFree is a
1195
- // placeholder here — buildSystem substitutes it after measuring the packet.
1196
- #renderBudget(log, ceiling) {
1197
- const lines = [];
1198
- if (ceiling !== null)
1199
- lines.push(`Token Ceiling ${ceiling} · Token Usage ${TOKEN_USAGE_PLACEHOLDER} (${TOKEN_PERCENT_PLACEHOLDER}%) · Tokens Free ${TOKENS_FREE_PLACEHOLDER}`);
1200
- if (log.entries > 0) {
1201
- if (lines.length > 0)
1202
- lines.push("");
1203
- lines.push(`Log entries: ${log.entries} entries, ${log.tokens} tokens`);
1204
- // Per-turn weight — chronological (oldest first); the turn is the grinder's
1205
- // rollback unit and the rail folds the newest first (§tokenomics {§tokenomics-turn-totals}).
1206
- if (log.byTurn.length > 0) {
1207
- lines.push("", "Turns:", "| turn | tokens |", "|---|--:|");
1208
- for (const t of log.byTurn)
1209
- lines.push(`| ${t.turn} | ${t.tokens} |`);
1210
- }
1211
- // The heaviest individual log items — the FOLD targets behind the weight
1212
- // (§tokenomics {§tokenomics-largest-entries}). "items", not "entries": the readout
1213
- // lists log:/// rows (log items), distinct from catalog entries (plurnk.md: "EDIT
1214
- // is only for entries. Do not attempt to edit log items.").
1215
- if (log.largest.length > 0) {
1216
- lines.push("", "Heaviest items:", "| item | tokens |", "|---|--:|");
1217
- for (const e of log.largest)
1218
- lines.push(`| ${e.path} | ${e.tokens} |`);
1219
- }
1220
- }
1221
- return lines.join("\n");
1222
- }
1223
- // The ## Plurnk Service Tools capability sheet (SPEC §tools). A hook: each enabled
1224
- // capability contributes one line, rendered above Requirements so the model sees what
1225
- // it can do before the rules. Each available executor tag contributes its self-documenting
1226
- // example (plurnk-execs#7), retiring the blind EXEC.
1227
- // The capability sheet — the live tool surface (wired executor tags). §tools-capability-sheet
1228
- #collectTools() {
1229
- const tools = [];
1230
- // Each available runtime tag contributes its self-documenting example —
1231
- // the example carries syntax + purpose, so there's no prose line. Tags
1232
- // with no example (sh/node, covered by the core prompt) contribute
1233
- // nothing; available-only, so the model never sees an unusable tag. `* `
1234
- // bullets + bare op forms match the packet's list/op rendering (no `- `,
1235
- // no backticks — see packet-wire.ts).
1236
- if (this.#executors !== undefined) {
1237
- const excluded = docsExcludeSet();
1238
- for (const tag of this.#executors.availableRuntimes()) {
1239
- if (excluded.has(tag))
1240
- continue; // #240 — PLURNK_DOCS_EXCLUDE drops the oneliner + the doc
1241
- const entry = this.#executors.entry(tag);
1242
- // #240 — identical treatment with the scheme directory: the example IS the oneliner,
1243
- // the fuller doc (materialized at plurnk://docs/<tag>.md) rides an inline link whose
1244
- // token cost lives on that manifest entry. No example → no line (like a provisional scheme).
1245
- if (entry?.example)
1246
- tools.push(teachingLine(entry.example));
1247
- }
1248
- }
1249
- return tools;
1250
- }
1251
963
  // #note12 — the daughter-provided reference docs (schemes' + execs' `documentation`),
1252
- // materialized at plurnk:///docs/<name>.md by loop_run (like operator docs) so the
1253
- // catalogue's doc-links READ and the manifest carries each doc's token cost.
964
+ // materialized at plurnk:///docs/<name>.md by loop_run (like operator docs).
1254
965
  docEntries() {
1255
- const out = this.#schemes.docs(); // scheme docs already drop PLURNK_DOCS_EXCLUDE names
1256
- if (this.#executors !== undefined) {
1257
- const excluded = docsExcludeSet();
1258
- for (const tag of this.#executors.availableRuntimes()) {
1259
- if (excluded.has(tag))
1260
- continue; // #240 — exec docs honor the same exclude
1261
- const doc = this.#executors.entry(tag)?.documentation;
1262
- if (doc !== undefined && doc.length > 0)
1263
- out.push({ name: tag, content: doc });
1264
- }
1265
- }
1266
- return out;
1267
- }
1268
- // SPEC §grinder — the budget grinder. Runs pre-LLM (in runTurn, after the packet
1269
- // is built, before provider.generate); fires only on actual overflow. Two
1270
- // passes, re-measuring between. Folds (never deletes) — the prior turn's logs,
1271
- // then the catalog except the manifest lifeline. The strike it raises and the
1272
- // hard-stop it can signal are returned to runLoop, which owns abandonment.
1273
- // §grinder-overflow-only — fires only on actual overflow, never speculatively
1274
- async #enforceBudget({ packet, provider, runId, loopId, turnId, mintSequence, rebuild }) {
1275
- const ceiling = _a.computeCeiling(provider.contextSize, this.#budgetCeiling);
1276
- const measure = (p) => p.tokens;
1277
- if (ceiling === null || measure(packet) <= ceiling)
1278
- return { packet, fit: true, struck: false };
1279
- // The grinder may compact ONLY the newest turn — the immediately-prior turn's emissions
1280
- // (turn N>1), or, when there is no prior turn (turn 1), THIS turn's own foists. It NEVER
1281
- // reaches older history; the model alone curates history via FOLD/KILL, and the engine never
1282
- // janitors stale context. §grinder-newest-turn-only
1283
- let foldedAny = false;
1284
- const priorLogs = await this.#db.engine_grinder_prior_turn_logs.all({ loop_id: loopId, turn_id: turnId });
1285
- if (priorLogs.length > 0) {
1286
- await this.#db.engine_grinder_fold_prior_turn_logs.run({ loop_id: loopId, turn_id: turnId });
1287
- foldedAny = true;
1288
- }
1289
- else {
1290
- // Turn 1 — no prior turn: fold THIS turn's own foists (the catalog/prompt that overflowed). §grinder-turn-1-self-fold (#2)
1291
- const curLogs = await this.#db.engine_grinder_current_turn_logs.all({ loop_id: loopId, turn_id: turnId });
1292
- if (curLogs.length > 0) {
1293
- await this.#db.engine_grinder_fold_current_turn_logs.run({ loop_id: loopId, turn_id: turnId });
1294
- foldedAny = true;
1295
- }
1296
- }
1297
- if (!foldedAny)
1298
- return { packet, fit: measure(packet) <= ceiling, struck: false };
1299
- // Mint the overflow as a terse op='error' log row BEFORE the rebuild, so the rebuild's
1300
- // re-derived errors section surfaces it THIS turn — the warning lands at strike 1, not a
1301
- // turn late. The row is grinder-exempt, so it stacks into a visible recurrence trail. It
1302
- // sits at the turn's reserved running sequence (mintSequence) so it never collides with the
1303
- // post-generate dispatch rows. §telemetry-uniform-error-channel, §grinder-overflow-error-row
1304
- await this.#mintEngineError("budget_overflow", { runId, loopId, turnId, sequence: mintSequence });
1305
- const current = await rebuild();
1306
- // Every compaction is a strike — including turn 0/1 (no soft exemption, #4). §grinder-compaction-strikes
1307
- return { packet: current, fit: measure(current) <= ceiling, struck: true };
1308
- }
1309
- // Mint an engine failure as a uniform op='error' log row (§telemetry-uniform-error-channel):
1310
- // a terse status + canonical term keyed by `kind` (the packet teaches recovery, not the row),
1311
- // origin engine:rail. The errors section derives its LogCoordinate pointer from log≥400 — one
1312
- // channel, no per-kind handling.
1313
- async #mintEngineError(kind, { runId, loopId, turnId, sequence }) {
1314
- const { status, term } = ENGINE_ERRORS[kind];
1315
- await this.#db.engine_insert_log_entry.get({
1316
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence,
1317
- origin: "plurnk", source: "rail", op: "error", suffix: "", signal: null,
1318
- scheme: null, username: null, password: null, hostname: null, port: null,
1319
- pathname: null, params: null, fragment: null, lineMarker: null,
1320
- tx: "", mimetype_tx: "text/plain",
1321
- rx: JSON.stringify({ kind, message: term }),
1322
- mimetype_rx: "application/json",
1323
- status_rx: status, tokens: 0, state: "resolved", outcome: null, attrs: "{}",
1324
- });
1325
- }
1326
- // Wire projection lives in ./packet-wire.ts so Engine and
1327
- // bin/digest.ts import the exact same function — structurally one
1328
- // implementation, no drift between wire and digest possible.
1329
- // Format: markdown (user pick over rummy's XML alternative, 2026-05-22).
1330
- #packetToWireMessages(packet) {
1331
- return PacketWire.packetToWireMessages(packet);
1332
- }
1333
- // Complete the packet by adding the model's response. After this the
1334
- // packet matches Packet.json fully and is ready for storage.
1335
- #completePacket(requestPacket, assistant, assistantRaw, provider) {
1336
- const assistantTokens = provider.countTokens(assistant.content);
1337
- return {
1338
- tokens: requestPacket.tokens + assistantTokens,
1339
- sections: requestPacket.sections,
1340
- telemetryErrors: requestPacket.telemetryErrors,
1341
- assistant,
1342
- assistantRaw,
1343
- };
1344
- }
1345
- // Render-time mimetype invocation (SPEC §mimetype {§mimetype-handlers-fire-render-time},
1346
- // §per-entry-channels {§per-entry-channels-preview-is-handler-output}). For each (run, entry, channel)
1347
- // with expanded=1, pass the channel's current content through
1348
- // mimetype.preview(content, budget). State is included verbatim — engine
1349
- // does NOT branch on it (§channel-state {§channel-state-engine-does-not-branch-on-state}).
1350
- // SPEC §telemetry: model-facing alert surface.
1351
- // Two sources, merged on each packet build:
1352
- // 1. Previous-turn action-bound failures (status_rx >= 400 on log_entries).
1353
- // 2. Engine-buffered actionless failures (no_send, parse, watchdog, rails).
1354
- // Buffer drains on read — each error appears in exactly one packet.
1355
- async #buildTelemetryErrors(loopId, currentTurnSeq) {
1356
- // The uniform error channel (§telemetry-uniform-error-channel): every 4xx/5xx log row
1357
- // becomes a LogCoordinate-positioned TelemetryEvent — a terse pointer; the model READs the
1358
- // row for its term + detail. Buffer events that point at the model's own emission keep their
1359
- // ContentOffset position. info-level notices (progress) are not errors and never surface here.
1360
- const rows = await this.#db.engine_render_telemetry_errors.all({ loop_id: loopId, current_turn_seq: currentTurnSeq });
1361
- const logErrors = rows.map((r) => ({
1362
- source: "engine:rail",
1363
- kind: "log_error",
1364
- level: "error",
1365
- status: r.status_rx,
1366
- position: { type: "log-coordinate", coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}/${r.op}` },
1367
- }));
1368
- const bufferEvents = this.#drainTelemetry(loopId).filter((e) => e.level !== "info");
1369
- return [...bufferEvents, ...logErrors];
1370
- }
1371
- // SPEC §packet the log section — chronological action-entries for the loop.
1372
- // Snapshot is taken at packet build (pre-dispatch this turn), so it
1373
- // reflects "what has happened before this turn." Each row carries a
1374
- // log:///<loop_seq>/<turn_seq>/<sequence> coordinate the model can READ.
1375
- async #buildLog(runId) {
1376
- // SPEC §packet-terms: runs own log entries — log is the run's history,
1377
- // not the loop's. Span all loops in the run so the model sees
1378
- // earlier loops' work as conversational memory.
1379
- //
1380
- // User prompts are first-class log entries: runTurn writes a
1381
- // client-origin SEND[200] row at sequence=0 of each new
1382
- // turn-1. Prompts thus surface naturally in this query — no
1383
- // synthetic / shim layer.
1384
- const rows = await this.#db.engine_render_log.all({ run_id: runId });
1385
- return rows.map((r) => ({
1386
- coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
1387
- origin: r.origin,
1388
- op: r.op,
1389
- suffix: r.suffix,
1390
- signal: r.signal === null ? null : JSON.parse(r.signal),
1391
- target: {
1392
- scheme: r.scheme,
1393
- username: r.username, password: r.password,
1394
- hostname: r.hostname, port: r.port,
1395
- pathname: r.pathname,
1396
- params: r.params === null ? null : JSON.parse(r.params),
1397
- fragment: r.fragment,
1398
- },
1399
- status: r.status_rx,
1400
- rx: r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx,
1401
- mimetype_rx: r.mimetype_rx,
1402
- tx: r.mimetype_tx === "application/json" ? JSON.parse(r.tx) : r.tx,
1403
- mimetype_tx: r.mimetype_tx,
1404
- folded: r.expanded === 0,
1405
- source: r.source,
1406
- attrs: r.attrs === null ? null : JSON.parse(r.attrs),
1407
- }));
966
+ return this.#packets.docEntries();
1408
967
  }
1409
968
  // §env-delta (§actor-boundary-no-mutex: runs share without locks; a conflict surfaces as a delta, never prevented) — at pre-turn build, surface what changed in the shared world since this
1410
969
  // run last looked. No per-run snapshot (§machine-processes "a run is its log"): every
@@ -1523,241 +1082,27 @@ class Engine {
1523
1082
  }
1524
1083
  }
1525
1084
  async dispatch(context) {
1526
- const { statement, sessionId, runId, loopId, turnId, sequence, origin, onDispatch, prematureRefusal } = context;
1527
- const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId, origin });
1528
- let result;
1529
- let denial = this.#checkWritable(statement, origin);
1530
- if (denial === null)
1531
- denial = await this.#checkFlagsGate(statement, loopId);
1532
- if (denial !== null) {
1533
- result = denial;
1534
- }
1535
- else if (_a.#readFansOut(statement)) {
1536
- // READ honors FIND: a glob/folder scope or a matcher fans out to one log row per MATCH
1537
- // (its own writeLogs), returning early. A READ never proposes, so it bypasses the
1538
- // single-row path below. A bare entry, body-less, falls through to the direct read. #286
1539
- return await this.#handleReadFanout(statement, schemeCtx, { runId, loopId, turnId, sequence, origin, onDispatch });
1540
- }
1541
- else {
1542
- // SPEC §scheme-surface + plurnk-schemes#1: action-entry-as-outcome. Scheme-handler
1543
- // exceptions become the action-entry's outcome (status 500), not a
1544
- // thrown bubble. The log_entry is the durable record; engine never
1545
- // skips it. Logging failures (#writeLog throws) are NOT caught —
1546
- // those are system failures.
1547
- try {
1548
- if (statement.op === "SEND" && statement.target === null) {
1549
- result = await this.#handleSendBroadcast(statement, loopId, prematureRefusal === true);
1550
- }
1551
- else if (statement.op === "COPY") {
1552
- result = await this.#handleCopy(statement, schemeCtx);
1553
- }
1554
- else if (statement.op === "MOVE") {
1555
- result = await this.#handleMove(statement, schemeCtx);
1556
- }
1557
- else if (statement.op === "KILL") {
1558
- result = await this.#handleKill(statement, schemeCtx);
1559
- }
1560
- else if (statement.op === "PLAN") {
1561
- result = this.#handlePlan(statement);
1562
- }
1563
- else if (statement.op === "EXEC") {
1564
- // EXEC's target slot is `cwd`, not a scheme address.
1565
- // Per plurnk.md the op routes unconditionally to the
1566
- // exec scheme; the scheme handler reads runtime
1567
- // (signal), cwd (target), and command (body).
1568
- result = await this.#run("exec", statement, schemeCtx);
1569
- }
1570
- else {
1571
- result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx); // §op-methods-op-dispatch
1572
- }
1573
- }
1574
- catch (err) { // a scheme exception becomes the op's 500 outcome — §scheme-surface-exception-500
1575
- result = {
1576
- status: 500,
1577
- error: err instanceof Error ? err.message : String(err),
1578
- };
1579
- }
1580
- }
1581
- const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
1582
- onDispatch?.(logEntryId);
1583
- // Proposal lifecycle (SPEC.md §engine-rails + §methods loop.resolve; §proposal-202-pauses). When a
1584
- // side-effecting op returns status 202 (a broadcast SEND[202] park is model
1585
- // speech, not a proposal — #isProposal, #255), the entry is written
1586
- // state='proposed'; dispatch then PAUSES on a per-entry waiter until
1587
- // resolution arrives via Engine.resolveProposal (from the loop/resolve RPC,
1588
- // YOLO listener, or timeout). The post-resolution status replaces 202 in the
1589
- // result the caller sees, so runTurn never branches on a pending state.
1590
- if (_a.#isProposal(statement, result)) {
1591
- // Effect-gated auto-run (read/pure runtimes, plurnk-service#182):
1592
- // no human gate, no loop/proposal notification. Accept + apply
1593
- // in-process; the model sees the outcome directly, never a review.
1594
- if (result.attrs?.inline === true) {
1595
- const effective = await this.#runApplyResolution(statement, result, { decision: "accept" }, { sessionId, runId, loopId, turnId });
1596
- return this.#applyResolution(logEntryId, effective);
1597
- }
1598
- // Register the resolution waiter SYNCHRONOUSLY before any await
1599
- // yields. A same-tick resolveProposal() (e.g. from a test that
1600
- // awaits the onDispatch callback and immediately resolves) must
1601
- // find the waiter registered — adding an await between insert
1602
- // and waiter-registration would open a race window.
1603
- const resolutionPromise = this.#awaitResolution(logEntryId);
1604
- // Notify external listeners (Daemon broadcasts loop/proposal;
1605
- // YOLO listener auto-resolves) BEFORE awaiting — they may
1606
- // resolve synchronously inside their handlers.
1607
- const target = this.#extractTarget(statement.target);
1608
- const flags = await this.#loadLoopFlags(loopId); // the loop/proposal notification carries flags (yolo) — §dual-yolo-proposal-carries-flags
1609
- // #note10 — if the target diverged on disk this turn, the model's EDIT is based
1610
- // on a stale read; flag it so a YOLO auto-accept rejects instead of clobbering.
1611
- const diverged = await this.#db.engine_target_diverged_this_turn.get({ run_id: runId, turn_id: turnId, scheme: target.scheme, pathname: target.pathname });
1612
- const event = {
1613
- logEntryId, sessionId, runId, loopId, turnId,
1614
- op: statement.op,
1615
- target: { scheme: target.scheme, pathname: target.pathname },
1616
- body: typeof result.body === "string" ? result.body : "",
1617
- attrs: (result.attrs ?? {}),
1618
- flags,
1619
- staleClobberRisk: diverged !== undefined,
1620
- };
1621
- for (const listener of this.#proposalPendingListeners) {
1622
- try {
1623
- listener(event);
1624
- }
1625
- catch (_) { /* listener errors don't break dispatch */ }
1626
- }
1627
- const resolution = await resolutionPromise;
1628
- // Run the scheme's applyResolution hook on accept (writes the
1629
- // file, spawns the process, etc.). If applyResolution returns a
1630
- // 4xx/5xx or throws, the resolution is downgraded to a reject
1631
- // with the failure outcome — engine treats it like a client
1632
- // rejection.
1633
- const effective = await this.#runApplyResolution(statement, result, resolution, { sessionId, runId, loopId, turnId });
1634
- // MOVE into a proposed dest: the deferred source-delete fires ONLY now,
1635
- // after the dest write landed (accept). On reject the source survives.
1636
- if (effective.decision === "accept") {
1637
- const moveSource = result.attrs?.moveSource;
1638
- if (moveSource !== undefined) {
1639
- const srcHandler = this.#schemes.get(moveSource.scheme);
1640
- if (srcHandler !== undefined && typeof srcHandler.deleteEntry === "function")
1641
- await srcHandler.deleteEntry(moveSource.pathname, schemeCtx);
1642
- }
1643
- }
1644
- const post = await this.#applyResolution(logEntryId, effective);
1645
- return post;
1646
- }
1647
- return result;
1085
+ return this.#dispatcher.dispatch(context);
1648
1086
  }
1649
1087
  // op.look (#283) — resolve a READ and return its content WITHOUT writing a
1650
- // log_entries row: the client's off-run inspection primitive (LOOK → READ,
1651
- // invisible to the model). READ never mutates and never proposes, so this is
1652
- // dispatch's resolve path minus #writeLog. Runs on the client loop, so the
1653
- // human's inspection is never constrained by a model loop's flags. {§op-look}
1088
+ // log_entries row: the client's off-run inspection primitive. {§op-look}
1654
1089
  async look(context) {
1655
- const { statement, sessionId, runId, loopId, origin = "client" } = context;
1656
- if (statement.op !== "READ")
1657
- throw new Error(`look resolves READ only; got ${statement.op}`);
1658
- // turnId is a write-time FK only — a look writes no row, so 0 (no turn) is inert.
1659
- const schemeCtx = this.#buildSchemeCtx({ sessionId, runId, loopId, turnId: 0, origin });
1660
- const denial = await this.#checkFlagsGate(statement, loopId);
1661
- if (denial !== null)
1662
- return denial;
1663
- return this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
1664
- }
1665
- #buildSchemeCtx(ids) {
1666
- const { sessionId, runId, loopId, turnId, origin } = ids;
1667
- return {
1668
- db: this.#db,
1669
- sessionId, runId, loopId, turnId,
1670
- writer: origin,
1671
- signal: this.#loopAborts.get(loopId)?.signal,
1672
- streamEventNotify: this.#streamEventNotify,
1673
- wakeRunNotify: this.#wakeRunNotify,
1674
- injectRun: this.#injectRun,
1675
- mimetypes: this.#mimetypes,
1676
- tokenize: this.#tokenize,
1677
- pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
1678
- executors: this.#executors,
1679
- };
1680
- }
1681
- // On accept, run the scheme's applyResolution — File writes disk, Exec spawns. §proposal-accept-applies
1682
- async #runApplyResolution(statement, originalResult, resolution, ids) {
1683
- const { sessionId, runId, loopId, turnId } = ids;
1684
- if (resolution.decision !== "accept")
1685
- return resolution;
1686
- // EXEC routes to the exec scheme regardless of target (cwd, not
1687
- // a scheme address). All other ops resolve their handler from
1688
- // statement.target's scheme.
1689
- // COPY/MOVE write the DEST (statement.body), not the source (target): the
1690
- // accept must reach the dest scheme's applyResolution (File writes disk).
1691
- const schemeName = statement.op === "EXEC" ? "exec"
1692
- : (statement.op === "COPY" || statement.op === "MOVE") ? this.#schemeNameOf(statement.body)
1693
- : this.#schemeNameOf(statement.target);
1694
- if (schemeName === null)
1695
- return resolution;
1696
- const handler = this.#schemes.get(schemeName);
1697
- if (handler === undefined || typeof handler.applyResolution !== "function")
1698
- return resolution;
1699
- try {
1700
- // Build a ctx for the scheme's applyResolution. The proposal
1701
- // was raised inside a specific (session, run, loop, turn);
1702
- // the scheme uses ctx to write the entry that makes the
1703
- // operation's artifact visible in the next packet's index.
1704
- const applyCtx = {
1705
- db: this.#db, sessionId, runId, loopId, turnId,
1706
- writer: "model", signal: this.#loopAborts.get(loopId)?.signal,
1707
- streamEventNotify: this.#streamEventNotify,
1708
- wakeRunNotify: this.#wakeRunNotify,
1709
- tokenize: this.#tokenize,
1710
- pushTelemetry: (event) => this.#pushTelemetry(sessionId, loopId, event),
1711
- executors: this.#executors,
1712
- };
1713
- const applyResult = await handler.applyResolution({
1714
- attrs: (originalResult.attrs ?? {}),
1715
- body: resolution.body,
1716
- }, applyCtx);
1717
- if (applyResult.status >= 400) {
1718
- return {
1719
- decision: "reject",
1720
- outcome: applyResult.outcome ?? "apply_failed",
1721
- body: applyResult.body,
1722
- };
1723
- }
1724
- // Propagate applyResolution.outcome onto the accepted resolution
1725
- // (operational metadata, e.g. exec's "exit_N") AND its body — an
1726
- // inline (read/pure) run returns its output as the body, which has
1727
- // to reach the model-facing result this turn, not just stream to
1728
- // the entry. Host accepts carry no body (fire-and-forget).
1729
- const withOutcome = applyResult.outcome !== undefined && resolution.outcome === undefined
1730
- ? { ...resolution, outcome: applyResult.outcome }
1731
- : resolution;
1732
- return applyResult.body === undefined ? withOutcome : { ...withOutcome, body: applyResult.body };
1733
- }
1734
- catch (err) {
1735
- return {
1736
- decision: "reject",
1737
- outcome: "apply_threw",
1738
- body: err instanceof Error ? err.message : String(err),
1739
- };
1740
- }
1090
+ return this.#dispatcher.look(context);
1741
1091
  }
1742
- // Engine.resolveProposal: external API to feed a resolution into a
1743
- // pending proposal. Called by the loop/resolve RPC handler (Phase E.2),
1744
- // the in-tree YOLO listener (Phase E.3), or the timeout watcher. Throws
1745
- // when the logEntryId has no pending waiter — duplicate resolutions, IDs
1746
- // for non-proposed entries, or entries already-resolved are caller
1747
- // errors.
1092
+ // External API to feed a resolution into a pending proposal — the loop/resolve
1093
+ // RPC handler, the in-tree YOLO listener, or the timeout watcher.
1748
1094
  resolveProposal(logEntryId, resolution) {
1749
- const waiter = this.#pendingProposals.get(logEntryId);
1750
- if (waiter === undefined) {
1751
- throw new Error(`Engine.resolveProposal: no pending proposal for log_entry ${logEntryId}`);
1752
- }
1753
- clearTimeout(waiter.timeoutHandle);
1754
- this.#pendingProposals.delete(logEntryId);
1755
- waiter.resolve(resolution);
1095
+ this.#proposals.resolve(logEntryId, resolution);
1756
1096
  }
1757
- // Snapshot of pending proposals (for diagnostic / RPC listings). Returns
1758
- // the log entry IDs currently awaiting resolution.
1097
+ // Snapshot of pending proposals (for diagnostic / RPC listings).
1759
1098
  pendingProposalIds() {
1760
- return [...this.#pendingProposals.keys()];
1099
+ return this.#proposals.pendingIds();
1100
+ }
1101
+ // Subscribe to proposal-pending events. Daemon registers a listener
1102
+ // that broadcasts the loop/proposal WS notification; YOLO listener
1103
+ // registers one that auto-resolves.
1104
+ onProposalPending(listener) {
1105
+ this.#proposals.onPending(listener);
1761
1106
  }
1762
1107
  // Used by wake-on-completion (daemon side): "is there any loop in this
1763
1108
  // run still accepting turns?" If yes, skip the wake — the active loop
@@ -1784,7 +1129,7 @@ class Engine {
1784
1129
  tokenize: this.#tokenize,
1785
1130
  mimetypes: this.#mimetypes,
1786
1131
  defaultChannelFor: (s) => this.#schemes.defaultChannelFor(s),
1787
- pushTelemetry: (event) => this.#telemetryEventNotify?.(sessionId, { loopId: 0, event }),
1132
+ pushTelemetry: (event) => this.#telemetry.notify(sessionId, 0, event),
1788
1133
  };
1789
1134
  await EntryManifest.maintainDerivations(ctx);
1790
1135
  }
@@ -1810,7 +1155,7 @@ class Engine {
1810
1155
  const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
1811
1156
  if (sessionRow === undefined)
1812
1157
  throw new Error(`Engine.inject: run ${runId} not found`);
1813
- const pathname = `/prompt/${loopId}/${turnSeq}`; // canonical storage form (leading slash), matching the foist via #pathnameOf
1158
+ const pathname = `/prompt/${loopId}/${turnSeq}`; // canonical storage form (leading slash), matching the turn-1 foist
1814
1159
  const ctx = {
1815
1160
  db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
1816
1161
  turnId: 0, // no turn open at inject time; entries don't pin turnId
@@ -1819,7 +1164,7 @@ class Engine {
1819
1164
  streamEventNotify: this.#streamEventNotify,
1820
1165
  wakeRunNotify: this.#wakeRunNotify,
1821
1166
  tokenize: this.#tokenize,
1822
- pushTelemetry: (event) => this.#pushTelemetry(sessionRow.session_id, loopId, event),
1167
+ pushTelemetry: (event) => this.#telemetry.push(sessionRow.session_id, loopId, event),
1823
1168
  };
1824
1169
  const entry = {
1825
1170
  channels: { body: { content: prompt, mimetype: "text/markdown" } },
@@ -1828,503 +1173,26 @@ class Engine {
1828
1173
  await EntryCrud.writeEntry(pathname, entry, ctx, "plurnk");
1829
1174
  return { loopId, turnSeq };
1830
1175
  }
1831
- // Subscribe to proposal-pending events. Daemon registers a listener
1832
- // that broadcasts the loop/proposal WS notification; YOLO listener
1833
- // (Phase E.3) registers one that auto-resolves. Listeners fire BEFORE
1834
- // dispatch awaits resolution, so synchronous (or fast-async) handlers
1835
- // can resolve inline.
1836
- onProposalPending(listener) {
1837
- this.#proposalPendingListeners.push(listener);
1838
- }
1839
- // Loads loops.flags (json column) and merges over DEFAULT_LOOP_FLAGS so
1840
- // missing keys read as their documented defaults. Single read site
1841
- // ProposalPendingEvent.flags is constructed from this, and listeners
1842
- // (Daemon broadcast, YOLO auto-accept) share the result.
1843
- async #loadLoopFlags(loopId) {
1844
- const row = await this.#db.engine_get_loop_flags.get({ loop_id: loopId });
1845
- if (row === undefined)
1846
- return DEFAULT_LOOP_FLAGS;
1847
- try {
1848
- const parsed = JSON.parse(row.flags);
1849
- return { ...DEFAULT_LOOP_FLAGS, ...parsed };
1850
- }
1851
- catch {
1852
- return DEFAULT_LOOP_FLAGS;
1853
- }
1854
- }
1855
- #awaitResolution(logEntryId) {
1856
- const timeoutMs = readProposalTimeoutMs();
1857
- return new Promise((resolve) => {
1858
- const timeoutHandle = setTimeout(() => {
1859
- // Timeout: synthesize a cancel resolution and feed it back
1860
- // through the same path as any other resolution. State
1861
- // transitions to cancelled with outcome='timeout'.
1862
- if (this.#pendingProposals.has(logEntryId)) {
1863
- this.#pendingProposals.delete(logEntryId);
1864
- resolve({ decision: "cancel", outcome: "timeout" }); // §proposal-timeout-cancels
1865
- }
1866
- }, timeoutMs);
1867
- this.#pendingProposals.set(logEntryId, { resolve, timeoutHandle });
1868
- });
1869
- }
1870
- async #applyResolution(logEntryId, resolution) {
1871
- // Map decision → terminal state + HTTP-aligned status:
1872
- // accept → state='resolved', status=200
1873
- // reject → state='failed', status=400, outcome='rejected' (default) §proposal-reject-fails
1874
- // cancel → state='cancelled',status=499, outcome='loop_aborted' (default) §proposal-cancel-aborts
1875
- // resolution.outcome wins over the default when supplied; this is how
1876
- // veto filters (Phase E.2 proposal.accepting) can specify a more
1877
- // precise outcome string like 'policy_veto' or 'timeout'.
1878
- const decision = resolution.decision;
1879
- const state = decision === "accept" ? "resolved"
1880
- : decision === "reject" ? "failed"
1881
- : "cancelled";
1882
- const status = decision === "accept" ? 200
1883
- : decision === "reject" ? 400
1884
- : 499;
1885
- const defaultOutcome = decision === "accept" ? null
1886
- : decision === "reject" ? "rejected"
1887
- : "loop_aborted";
1888
- const outcome = resolution.outcome ?? defaultOutcome;
1889
- // rx is the model-facing operation result. Status always; outcome is
1890
- // operational (stays on log_entries for forensics, never model-facing).
1891
- // Body is normally dropped — the propose preview was an input echo —
1892
- // EXCEPT an inline auto-run (read/pure) carries its run output AS the
1893
- // body, which is exactly the "what happened" the model needs this turn.
1894
- // Per AGENTS.md "Operational hygiene on what the model sees."
1895
- const rx = (decision === "accept" && resolution.body !== undefined)
1896
- ? JSON.stringify({ status, body: resolution.body })
1897
- : JSON.stringify({ status });
1898
- await this.#db.engine_resolve_log_entry.run({
1899
- id: logEntryId, state, outcome, status_rx: status, rx,
1900
- });
1901
- return { status, outcome, body: resolution.body };
1902
- }
1903
- // SPEC §scheme-surface: engine rejects writes whose origin is outside the target
1904
- // scheme's manifest.writableBy.
1905
- // - Read-side ops (READ, FIND, OPEN, FOLD) are not gated.
1906
- // - SEND broadcast (path=null) has no target scheme; not gated.
1907
- // - COPY: dst scheme writableBy applies.
1908
- // - MOVE: both src (delete) and dst (write) schemes' writableBy apply.
1909
- // - Schemes without a manifest are not gated (legacy / future allowance).
1910
- #checkWritable(statement, origin) {
1911
- if (!MUTATING_OPS.has(statement.op))
1912
- return null;
1913
- if (statement.op === "SEND" && statement.target === null)
1914
- return null;
1915
- // EXEC's target slot is `cwd`, not a scheme address. The op's
1916
- // authority always belongs to the exec scheme regardless of cwd.
1917
- if (statement.op === "EXEC") {
1918
- return this.#denyIfDisallowed("exec", origin);
1919
- }
1920
- // Run control (COPY target=run://, spawn or fork) is gated by run://'s writableBy — its
1921
- // body is a seed prompt, not a dst path, so the entry-COPY dst-parse below doesn't apply.
1922
- // §machine-processes
1923
- if (this.#isRunCopy(statement))
1924
- return this.#denyIfDisallowed("run", origin);
1925
- if (statement.op === "COPY" || statement.op === "MOVE") {
1926
- const dst = statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body;
1927
- const dstScheme = this.#schemeNameOf(dst);
1928
- const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
1929
- if (dstDenial !== null)
1930
- return dstDenial;
1931
- if (statement.op === "MOVE") {
1932
- const srcScheme = this.#schemeNameOf(statement.target);
1933
- if (srcScheme !== dstScheme) {
1934
- const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
1935
- if (srcDenial !== null)
1936
- return srcDenial;
1937
- }
1938
- }
1939
- return null;
1940
- }
1941
- const target = this.#schemeNameOf(statement.target);
1942
- return this.#denyIfDisallowed(target, origin);
1943
- }
1944
- #denyIfDisallowed(schemeName, origin) {
1945
- if (schemeName === null)
1946
- return null;
1947
- const handler = this.#schemes.get(schemeName);
1948
- if (handler === undefined)
1949
- return null;
1950
- const manifest = handler.constructor.manifest;
1951
- if (manifest === undefined)
1952
- return null;
1953
- if (manifest.writableBy.includes(origin))
1954
- return null;
1955
- return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` }; // §scheme-surface-writableby-403
1956
- }
1957
- // Per-loop flag gating. Schemes self-declare their flag affinity in
1958
- // their manifest (excludedInAsk / requiresWeb /
1959
- // requiresInteraction); SchemeRegistry.resolveForLoop returns the
1960
- // active set under the loop's persisted flags. Anything outside the
1961
- // set returns 403 — action-entry-as-outcome carries the rejection.
1962
- async #checkFlagsGate(statement, loopId) {
1963
- // Broadcast SEND has no scheme to gate.
1964
- if (statement.op === "SEND" && statement.target === null)
1965
- return null;
1966
- const flags = await this.#loadLoopFlags(loopId);
1967
- // Fast path: default flags gate nothing. (yolo never gates.)
1968
- if (!flags.noWeb && !flags.noInteraction && flags.mode === "act")
1969
- return null;
1970
- const active = this.#schemes.resolveForLoop(flags);
1971
- const check = (target) => {
1972
- const scheme = this.#schemeNameOf(target);
1973
- if (scheme === null)
1974
- return null;
1975
- if (active.has(scheme))
1976
- return null;
1977
- return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
1978
- };
1979
- if (this.#isRunCopy(statement))
1980
- return check(statement.target); // body is a spawn/fork prompt, not a dst path
1981
- if (statement.op === "COPY" || statement.op === "MOVE") {
1982
- return check(statement.target) ?? check(statement.op === "COPY" ? (statement.body === null ? null : parsePath(statement.body)) : statement.body);
1983
- }
1984
- return check(statement.target);
1985
- }
1986
- // A COPY whose TARGET is run:// is run control (spawn/fork), not an entry-copy — its body
1987
- // is the new run's seed prompt, not a destination path. The COPY gates and #handleCopy
1988
- // branch on this so they never parse the prompt as a dst path.
1989
- #isRunCopy(statement) {
1990
- return statement.op === "COPY" && this.#schemeNameOf(statement.target) === "run";
1991
- }
1992
- // COPY(run://<dst>):prompt — run control via COPY (grammar 0.74.41 OP×resource matrix):
1993
- // • run://self → FORK: deep-copy the current run's log into a new sister (Fork), then
1994
- // continue it with the prompt (§machine-processes-fork-copies-the-log).
1995
- // • run://<name> → SPAWN: a fresh sister (empty log) named <name>, started on the prompt.
1996
- // A LIVE sister already holding <name> is a 409 conflict; a free or terminated name is
1997
- // reclaimed (§run-scheme-spawn). The self form is fork; only a name spawns.
1998
- // Both ride the daemon inject and obey the active-runs cap (508, §run-scheme-cap).
1999
- async #handleRunCopy(statement, ctx) {
2000
- const target = statement.target;
2001
- if (target === null)
2002
- return { status: 400, error: "run:// control requires a run target" };
2003
- const name = target.kind === "url" ? (target.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY (run://<name>), not the path
2004
- if (name === "")
2005
- return { status: 400, error: "run:// control requires a run name or 'self' (run://<name>)" };
2006
- if (ctx.injectRun === undefined)
2007
- throw new Error("run copy: injectRun capability absent");
2008
- const denied = await RunCap.deny(this.#db, ctx.sessionId);
2009
- if (denied !== null)
2010
- return denied;
2011
- const prompt = typeof statement.body === "string" ? statement.body : "";
2012
- if (name === "self") {
2013
- // FORK — branch the current run's log into a new sister.
2014
- const branchRunId = await Fork.fork(this.#db, ctx.runId);
2015
- const branch = await this.#db.fork_get_run.get({ id: branchRunId });
2016
- await ctx.injectRun({ sessionId: ctx.sessionId, runId: branchRunId, prompt });
2017
- return { status: 200, body: branch?.name ?? "" };
2018
- }
2019
- // SPAWN — a fresh sister named <name>. A name is frozen per run but reclaimable across time
2020
- // (§machine-processes-run-origin): a LIVE sister holding it is a 409 conflict (legible, never
2021
- // a raw UNIQUE 500); a free or terminated name is reclaimed (the resolver picks newest).
2022
- const live = await this.#db.run_live_by_name.get({ session_id: ctx.sessionId, name });
2023
- if (live !== undefined)
2024
- return { status: 409, error: `run '${name}' is already running` };
2025
- const row = await this.#db.fork_insert_run.get({
2026
- session_id: ctx.sessionId, name, parent_run_id: ctx.runId, origin: ctx.writer,
2027
- });
2028
- if (row === undefined)
2029
- throw new Error("run spawn: run insert returned no row");
2030
- await ctx.injectRun({ sessionId: ctx.sessionId, runId: row.id, prompt });
2031
- return { status: 200, body: name };
2032
- }
2033
- async #handleCopy(statement, ctx) {
2034
- if (statement.op !== "COPY")
2035
- throw new Error("unreachable");
2036
- if (this.#isRunCopy(statement))
2037
- return await this.#handleRunCopy(statement, ctx);
2038
- const srcPath = statement.target;
2039
- // Past the run-control branch above, COPY's body is a dest path (grammar §COPY).
2040
- // Parse it; an unparseable dest surfaces as a 400.
2041
- const dstPath = statement.body === null ? null : parsePath(statement.body);
2042
- if (srcPath === null)
2043
- return { status: 400, error: "COPY requires source path" };
2044
- if (dstPath === null)
2045
- return { status: 400, error: "COPY destination must be a parseable path in the body slot" };
2046
- return await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
2047
- }
2048
- async #handleMove(statement, ctx) {
2049
- if (statement.op !== "MOVE")
2050
- throw new Error("unreachable");
2051
- const srcPath = statement.target;
2052
- const dstPath = statement.body;
2053
- if (srcPath === null)
2054
- return { status: 400, error: "MOVE requires source path" };
2055
- // MOVE is relocation only — deletion is KILL's job (§move, §move-dev-null-not-special). The /dev/null
2056
- // and null-body delete-by-MOVE back-compat is retired: no silent debt.
2057
- if (dstPath === null)
2058
- return { status: 400, error: "MOVE requires a destination; use KILL to delete" }; // §move-null-body-400
2059
- const srcSchemeName = this.#schemeNameOf(srcPath);
2060
- if (srcSchemeName === null)
2061
- return { status: 400, error: "MOVE source must be a URL path with a scheme" };
2062
- const srcHandler = this.#schemes.get(srcSchemeName);
2063
- if (srcHandler === undefined || typeof srcHandler.deleteEntry !== "function")
2064
- return { status: 501 };
2065
- // Relocation: COPY then DELETE source (§move-relocation-deletes-source).
2066
- const copyResult = await this.#copyOrchestration({ statement, srcPath, dstPath, ctx });
2067
- if (copyResult.status >= 400)
2068
- return copyResult;
2069
- const srcPathname = pathnameFromPath(srcPath);
2070
- // If the dest write is a pending proposal (file dest → §membership review), the
2071
- // source-delete MUST wait until the dest actually lands — a rejected
2072
- // proposal would otherwise lose the source. Thread it into the resolution:
2073
- // dispatch deletes the source AFTER the dest applies on accept.
2074
- if (copyResult.status === 202) {
2075
- return { ...copyResult, attrs: { ...copyResult.attrs, moveSource: { scheme: srcSchemeName, pathname: srcPathname } } };
2076
- }
2077
- const delResult = await srcHandler.deleteEntry(srcPathname, ctx);
2078
- if (delResult.status >= 400)
2079
- return { status: delResult.status };
2080
- return copyResult;
2081
- }
2082
- // KILL — scheme-polymorphic destroy (plurnk-grammar#203 / 0.28.0). Entry-KILL
2083
- // permanently deletes the entry: the canonical delete now, MOVE→/dev/null
2084
- // retired from the model's vocabulary. Process-KILL (exec:///) aborts the
2085
- // running spawn's controller (the same teardown loop.cancel rides), addressed
2086
- // by coordinate pathname (#203). The KILL body is an opaque
2087
- // annotation with no runtime meaning; it survives into the log row's tx for
2088
- // free via the statement serialization. Status: 200 killed · 404 unknown ·
2089
- // 405 log:/// (append-only) · 403 writableBy (the #checkWritable gate, KILL ∈
2090
- // MUTATING_OPS) · 200/410/304/404 exec (killed / killed-earlier / exited / unknown) · 501 no-kill/delete scheme.
2091
- async #handleKill(statement, ctx) {
2092
- if (statement.op !== "KILL")
2093
- throw new Error("unreachable");
2094
- const path = statement.target;
2095
- if (path === null)
2096
- return { status: 400, error: "KILL requires a target path" };
2097
- const schemeName = this.#schemeNameOf(path);
2098
- if (schemeName === null)
2099
- return { status: 400, error: "KILL target must be a URL path with a scheme" };
2100
- // KILL on log:/// erases the log row(s) — the model's DB-storage curation lever
2101
- // (plurnk.md:36, :98), routed to Log.kill below via the killable.kill path. The old
2102
- // "append-only" 405 forbade what the grammar requires; FOLD only collapses the render.
2103
- // Process-KILL: any scheme whose handler exposes kill() aborts a live stream — the
2104
- // exec handler, registered as "exec" + under every runtime tag (sh/node), so a tag-
2105
- // addressed stream (sh:///l/t/s) routes here, not to deleteEntry. §exec
2106
- const killable = this.#schemes.get(schemeName);
2107
- if (killable !== undefined && typeof killable.kill === "function") {
2108
- return await killable.kill(pathnameFromPath(path), statement.signal, ctx);
2109
- }
2110
- if (schemeName === "run") {
2111
- // Entry-path present → KILL a run-scope scratch ENTRY (delete it), self-only —
2112
- // NOT run cancellation. The authority (hostname) names the owner, the pathname the
2113
- // entry; only the path-ABSENT form (run://<name>) terminates the run-as-actor. §run-scheme
2114
- const entryPath = path.kind === "url" ? (path.pathname ?? "") : "";
2115
- if (entryPath !== "" && entryPath !== "/") {
2116
- const runHandler = this.#schemes.get("run");
2117
- return await runHandler.deleteEntry(statement, ctx);
2118
- }
2119
- // terminate — abort any run by address; whoever holds it may end it.
2120
- // `run://self` = self. cancelRun (→ Daemon.cancelDrain) aborts the run's signal
2121
- // (its loop closes 499); an idle run is a no-op-200, a missing run 404.
2122
- const name = path.kind === "url" ? (path.hostname ?? "") : ""; // §run-scheme — run is the AUTHORITY
2123
- if (name === "")
2124
- return { status: 400, error: "run:// kill requires a run name or 'self' (run://<name>)" };
2125
- let runId = ctx.runId;
2126
- if (name !== "self") {
2127
- const row = await this.#db.run_resolve_by_name.get({ session_id: ctx.sessionId, name });
2128
- if (row === undefined)
2129
- return { status: 404, error: `run://${name} not found in this session` };
2130
- runId = row.id;
2131
- }
2132
- if (this.#cancelRun === undefined)
2133
- throw new Error("run kill: cancelRun capability absent");
2134
- this.#cancelRun(runId);
2135
- return { status: 200 };
2136
- }
2137
- const handler = this.#schemes.get(schemeName);
2138
- if (handler === undefined || typeof handler.deleteEntry !== "function")
2139
- return { status: 501 };
2140
- const delResult = await handler.deleteEntry(pathnameFromPath(path), ctx);
2141
- return { status: delResult.status };
2142
- }
2143
- // Multi-file READ fan-out (SPEC §matcher-result — "the companion to FIND's survey"). A glob
2144
- // READ target resolves to MANY files; READ returns one log row per file that matches, each
2145
- // holding that file's matching lines. The matched SET is exactly FIND's survey (which files
2146
- // + where — matchLines), so we reuse the scheme's own find, then READ each matched file. One
2147
- // model command, N log rows — each row addresses its concrete file, so it folds/kills/re-READs
2148
- // on its own. The running sequence counter in runTurn advances by rowsWritten.
2149
- // A READ fans out (honors FIND) when it resolves to more than the single exact entry: a glob
2150
- // or folder scope, OR a matcher (which selects per-match within whatever the target resolved).
2151
- // A bare entry, body-less, is the one direct read. #286
2152
- static #readFansOut(statement) {
2153
- if (statement.op !== "READ")
2154
- return false;
2155
- if ("body" in statement && statement.body !== null)
2156
- return true; // a matcher → per-match fan-out
2157
- const t = statement.target;
2158
- const p = t === null ? "" : (t.kind === "url" ? t.pathname : t.raw);
2159
- return p.includes("*") || p.endsWith("/"); // glob/folder scope → fan out its contents
2160
- }
2161
- // Clone the READ onto one concrete match — the FIND already matched, so the per-match READ
2162
- // delivers content at the span: strip the body (no re-match) and set <L> to the span. A null
2163
- // span (body-less folder/glob fan-out) reads the whole entry. #286
2164
- static #retargetRead(statement, pathname, span) {
2165
- const t = statement.target;
2166
- const target = t !== null && t.kind === "url"
2167
- ? { ...t, pathname, raw: `${t.scheme}://${pathname}` }
2168
- : { ...t, raw: pathname };
2169
- const lineMarker = span !== null ? { marks: [span.lineStart, span.lineEnd] } : null;
2170
- return { ...statement, target: target, lineMarker, body: null };
2171
- }
2172
- async #handleReadFanout(statement, ctx, ids) {
2173
- const { runId, loopId, turnId, sequence, origin, onDispatch } = ids;
2174
- const schemeName = this.#schemeNameOf(statement.target);
2175
- const found = await this.#run(schemeName, { ...statement, op: "FIND" }, ctx);
2176
- const matches = found.matches ?? [];
2177
- // Find-less scheme, a matcher/scope error, or zero matches → a single row carrying the
2178
- // status, exactly like a non-fanned READ. The model sees the empty/failed result, not silence.
2179
- if (found.status !== 200 || matches.length === 0) {
2180
- const result = { status: found.status === 200 ? 204 : found.status };
2181
- const id = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
2182
- onDispatch?.(id);
2183
- return { ...result, rowsWritten: 1 };
2184
- }
2185
- // One READ row per MATCH — the span's source lines (or the whole entry for a body-less
2186
- // folder/glob). The match span is SOURCE LINES, so deliver via a raw line-slice — NOT the
2187
- // scheme's <L> (which is item-index for application/json, structural for xml). Read each
2188
- // distinct entry's content once, then line-slice per match. #286
2189
- const wholeByPath = new Map();
2190
- const fannedStatuses = [];
2191
- let written = 0;
2192
- for (const m of matches) {
2193
- let whole = wholeByPath.get(m.pathname);
2194
- if (whole === undefined) {
2195
- whole = await this.#run(schemeName, _a.#retargetRead(statement, m.pathname, null), ctx);
2196
- wholeByPath.set(m.pathname, whole);
2197
- }
2198
- const result = _a.#sliceMatch(whole, m.span);
2199
- const id = await this.#writeLog({ statement: _a.#retargetRead(statement, m.pathname, m.span), result, runId, loopId, turnId, sequence: sequence + written, origin });
2200
- onDispatch?.(id);
2201
- fannedStatuses.push(result.status);
2202
- written++;
2203
- }
2204
- return { status: 200, rowsWritten: written, fannedStatuses };
2205
- }
2206
- // Deliver one match: the whole entry (body-less, span null) or the source lines at the span —
2207
- // a RAW line-slice, so a structural mimetype (json item-index / xml) doesn't mis-slice a span
2208
- // that is, by construction, source line numbers (#286).
2209
- static #sliceMatch(whole, span) {
2210
- if (whole.status !== 200 || span === null)
2211
- return whole;
2212
- const sliced = LineMarkerOps.sliceLines(typeof whole.content === "string" ? whole.content : "", { marks: [span.lineStart, span.lineEnd] });
2213
- if (sliced.status !== 200)
2214
- return { status: sliced.status, error: sliced.error };
2215
- return { status: 200, content: sliced.text ?? "", mimetype: "text/markdown", startLine: sliced.startLine ?? span.lineStart };
2216
- }
2217
- // §model-entry — mirror a verbatim model emission back as an actionless `model` log row, so
2218
- // the model can finally SEE its own prior output (and reason through its own syntax errors).
2219
- // Born FOLDED by default (budget-neutral until OPENed); the turn-0 exemplar passes folded:false
2220
- // (born open — the one worked example the model orients on, thinning the grammar). text/vnd.plurnk.
2221
- async #writeModelEntry({ verbatim, runId, loopId, turnId, sequence, folded, origin = "model" }) {
2222
- const row = await this.#db.engine_insert_log_entry.get({
2223
- run_id: runId, loop_id: loopId, turn_id: turnId, sequence,
2224
- origin, source: null, op: "model", suffix: "", signal: null,
2225
- scheme: null, username: null, password: null, hostname: null, port: null,
2226
- pathname: null, params: null, fragment: null, lineMarker: null,
2227
- tx: "", mimetype_tx: "text/vnd.plurnk",
2228
- rx: JSON.stringify({ content: verbatim, mimetype: "text/vnd.plurnk" }),
2229
- mimetype_rx: "application/json",
2230
- status_rx: 200, tokens: this.#tokenize(verbatim), state: "resolved", outcome: null, attrs: "{}",
2231
- });
2232
- if (row === undefined)
2233
- throw new Error("Engine.#writeModelEntry: insert returned no row");
2234
- if (folded)
2235
- await this.#db.engine_fold_log_entry.run({ id: row.id });
2236
- return row.id;
2237
- }
2238
- // PLAN — the model's reasoning op (the 11th op). An ordinary op: dispatched like any
2239
- // other, logged, and broadcast to the client as a log entry — but a pure no-op for
2240
- // state (PLAN ∉ MUTATING_OPS); its body serializes into the log row's tx, no effect.
2241
- #handlePlan(statement) {
2242
- if (statement.op !== "PLAN")
2243
- throw new Error("unreachable");
2244
- return { status: 200 };
2245
- }
2246
- // Same- and cross-scheme COPY share one orchestrator — §copy-cross-scheme-copy §move-cross-scheme-move
2247
- async #copyOrchestration({ statement, srcPath, dstPath, ctx }) {
2248
- const srcSchemeName = this.#schemeNameOf(srcPath);
2249
- const dstSchemeName = this.#schemeNameOf(dstPath);
2250
- if (srcSchemeName === null || dstSchemeName === null)
2251
- return { status: 400, error: "COPY/MOVE require URL paths with schemes" };
2252
- const srcHandler = this.#schemes.get(srcSchemeName);
2253
- const dstHandler = this.#schemes.get(dstSchemeName);
2254
- if (srcHandler === undefined || dstHandler === undefined)
2255
- return { status: 501 };
2256
- if (typeof srcHandler.readEntry !== "function" || typeof dstHandler.writeEntry !== "function")
2257
- return { status: 501 };
2258
- const srcPathname = pathnameFromPath(srcPath);
2259
- const dstPathname = pathnameFromPath(dstPath);
2260
- const srcResult = await srcHandler.readEntry(srcPathname, ctx);
2261
- if (srcResult.status !== 200 || srcResult.entry === null)
2262
- return { status: 404, error: `COPY/MOVE source not found: ${srcSchemeName}://${srcPathname}` }; // §copy-missing-source-404 §move-missing-source-404
2263
- const entry = srcResult.entry;
2264
- // Destination read — the conflict/no-op verdict is deferred until the
2265
- // to-be-written content is known (after <L> slice + tag resolution below),
2266
- // so an identical re-copy resolves to 304 instead of a phantom 409.
2267
- const dstExisting = typeof dstHandler.readEntry === "function"
2268
- ? await dstHandler.readEntry(dstPathname, ctx)
2269
- : null;
2270
- // Mimetype compatibility check against the destination scheme's manifest
2271
- const dstManifest = dstHandler.constructor.manifest;
2272
- const dstChannels = dstManifest?.channels ?? {};
2273
- for (const [channelName, channelData] of Object.entries(entry.channels)) {
2274
- const expectedMimetype = dstChannels[channelName];
2275
- if (expectedMimetype !== undefined && expectedMimetype !== channelData.mimetype) {
2276
- 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
2277
- }
2278
- }
2279
- // `<L>` source range slicing per SPEC.md §op-invariants (symmetric with READ
2280
- // `<L>` — source range, no line-number prefix).
2281
- // Applied to every channel of the source entry. Binary channels return
2282
- // 415 since line semantics don't apply.
2283
- const lineMarker = statement.lineMarker ?? null;
2284
- let channels = entry.channels;
2285
- if (lineMarker !== null) {
2286
- const sliced = {};
2287
- for (const [channelName, channelData] of Object.entries(entry.channels)) {
2288
- if (MimetypeBinary.isBinaryMimetype(channelData.mimetype)) {
2289
- return { status: 415, error: `cannot slice <L> on binary channel '${channelName}' (${channelData.mimetype})` };
2290
- }
2291
- const r = LineMarkerOps.sliceLinesRaw(channelData.content ?? "", lineMarker);
2292
- if (r.status !== 200)
2293
- return { status: r.status, error: r.error };
2294
- sliced[channelName] = { ...channelData, content: r.text ?? "" };
2295
- }
2296
- channels = sliced;
2297
- }
2298
- // Tag resolution: signal = replace (§copy-signal-replaces-source-tags); absent/empty = carry from source (§copy-no-signal-carries-source-tags)
2299
- const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
2300
- ? statement.signal
2301
- : entry.tags;
2302
- // 304/409 on an existing destination (SPEC §copy): a re-copy that would write
2303
- // exactly what's already there — same channel contents, same tags — is a no-op
2304
- // (304), mirroring EDIT's 304-on-noop (§edit). A divergent destination is a real
2305
- // collision (409); COPY/MOVE never clobbers.
2306
- if (dstExisting !== null && dstExisting.status === 200 && dstExisting.entry !== null) {
2307
- const dstChannels = dstExisting.entry.channels;
2308
- const writeNames = Object.keys(channels).sort();
2309
- const dstNames = Object.keys(dstChannels).sort();
2310
- const sameContent = writeNames.length === dstNames.length
2311
- && writeNames.every((n, i) => n === dstNames[i] && (channels[n]?.content ?? "") === (dstChannels[n]?.content ?? ""));
2312
- const sameTags = [...tags].sort().join("") === [...dstExisting.entry.tags].sort().join("");
2313
- if (sameContent && sameTags)
2314
- return { status: 304 }; // identical → §copy-noop-304
2315
- return { status: 409, error: `COPY/MOVE destination exists: ${dstSchemeName}://${dstPathname}` }; // §copy-conflict-409
2316
- }
2317
- const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
2318
- // A file dest returns 202 (disk write → §membership review): propagate the
2319
- // proposal so dispatch runs the gate + routes applyResolution to the dest.
2320
- if (writeResult.status === 202)
2321
- return { status: 202, attrs: writeResult.attrs, body: writeResult.body };
2322
- return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
1176
+ // §send-groundless-hibernate can this op open a wake edge mid-turn? The grounding scan for a
1177
+ // same-turn spawn-then-hibernate: an EXEC (stream conclusion / poll cadence wakes), a COPY to
1178
+ // run:// (child-conclusion wake, §run-lifecycle-child-wake), a directed SEND to run:// (irc — the
1179
+ // addressee can act and conclude back), or an http READ (a web fetch streams into a subscription).
1180
+ // Conservative on purpose: a false PERMIT risks a dead park only in the spawn-failed corner; a
1181
+ // false REFUSE breaks legitimate hibernation.
1182
+ static #opCanOpenWakeEdge(op) {
1183
+ if (op.op === "EXEC")
1184
+ return true;
1185
+ const scheme = op.target !== null && op.target.kind === "url" ? op.target.scheme : null;
1186
+ if (op.op === "COPY" && scheme === "run")
1187
+ return true;
1188
+ if (op.op === "SEND" && scheme === "run")
1189
+ return true;
1190
+ return op.op === "READ" && (scheme === "http" || scheme === "https");
2323
1191
  }
2324
1192
  // A run "holds a live thing" iff it has an open stream/spawn (subscription registry or an
2325
1193
  // exec spawn) OR a non-terminal child run — the structured-concurrency invariant a terminal
2326
- // SEND[200] must respect (§send-premature-terminate, §run-lifecycle: children and streams are
2327
- // the same kind of live thing a run holds).
1194
+ // SEND must respect (§send-premature-terminate, §send-groundless-hibernate, §run-lifecycle:
1195
+ // children and streams are the same kind of live thing a run holds).
2328
1196
  async #runHoldsLiveThing(runId) {
2329
1197
  const openSubs = await this.#db.find_open_subscriptions_for_run.all({ run_id: runId });
2330
1198
  if (openSubs.length > 0)
@@ -2335,177 +1203,5 @@ class Engine {
2335
1203
  const liveChild = await this.#db.engine_run_has_live_child.get({ run_id: runId });
2336
1204
  return liveChild !== undefined;
2337
1205
  }
2338
- async #handleSendBroadcast(statement, loopId, prematureRefusal) {
2339
- if (statement.op !== "SEND")
2340
- throw new Error("unreachable");
2341
- const status = statement.signal;
2342
- if (status === null)
2343
- return { status: 400 };
2344
- // Premature terminate (§send-premature-terminate): a terminal SEND[200] while the run holds a
2345
- // live thing is REFUSED 409 — the row keeps the [200] emission + body (faithful, never erased),
2346
- // the loop never goes terminal. The model hibernates [202] to wait or KILLs before terminating.
2347
- // The decision is the runTurn PRE-DISPATCH snapshot (threaded), so a same-turn fire-and-forget
2348
- // spawn isn't miscounted as a live thing the SEND holds.
2349
- if (status === 200 && prematureRefusal) {
2350
- 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." };
2351
- }
2352
- if (status === 200 || status === 202 || status === 499) {
2353
- // The broadcast terminals (200 done, 202 parked-async, 499 cancelled) advance
2354
- // the loop; each carries its body as the loop's terminal message — the deliverable.
2355
- const body = statement.body;
2356
- const message = body === null ? null : typeof body === "string" ? body : body.raw;
2357
- await this.#db.engine_loop_set_status.run({ status, loop_id: loopId, message });
2358
- }
2359
- return { status };
2360
- }
2361
- async #run(schemeName, statement, ctx) {
2362
- if (schemeName === null)
2363
- return { status: 400 };
2364
- const handler = this.#schemes.get(schemeName);
2365
- if (handler === undefined)
2366
- return { status: 501 };
2367
- const methodName = statement.op.toLowerCase();
2368
- const method = handler[methodName];
2369
- if (typeof method !== "function")
2370
- return { status: 501 };
2371
- // External @plurnk/plurnk-schemes-* siblings receive the DB-free SchemeCtx
2372
- // (caps), never the raw PlurnkSchemeContext (schemes SPEC §channels). The dynamic
2373
- // dispatch is typed for in-tree schemes; the cast bridges the ctx shapes —
2374
- // the sibling reads caps, the in-tree handler reads db.
2375
- if (this.#schemes.isExternal(schemeName)) {
2376
- return method.call(handler, statement, new SchemeCtxImpl(ctx, schemeName));
2377
- }
2378
- return method.call(handler, statement, ctx);
2379
- }
2380
- // Bare paths default to the file scheme per plurnk.md (grammar sysprompt):
2381
- // "Bare paths (no scheme) default to local relative project file paths."
2382
- // file:/// remains an optional explicit form for absolute paths.
2383
- #schemeNameOf(path) {
2384
- if (path === null)
2385
- return null;
2386
- // http + https are one scheme — the http sibling owns both prefixes (#195).
2387
- if (path.kind === "url")
2388
- return path.scheme === "https" ? "http" : path.scheme;
2389
- return "file"; // local (bare) → file
2390
- }
2391
- // A status-202 result is a reviewable PROPOSAL (a side-effecting op — EDIT/EXEC/
2392
- // directed write — paused for client resolution) UNLESS it is a broadcast SEND.
2393
- // A broadcast SEND[202] is the model PARKING the loop (a terminal disposition,
2394
- // plurnk.md), never a side-effect — #255: gating the propose/await path on the
2395
- // bare 202 surfaced model speech as a loop/proposal and froze clients. The 202
2396
- // is overloaded (proposal-pause vs parked-terminal); the op disambiguates it.
2397
- static #isProposal(statement, result) {
2398
- return result.status === 202 && !(statement.op === "SEND" && statement.target === null);
2399
- }
2400
- async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
2401
- const target = this.#extractTarget(statement.target);
2402
- const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
2403
- ? JSON.stringify(statement.lineMarker)
2404
- : null;
2405
- // A proposal (status 202 from a side-effecting op) is written to the log in
2406
- // state='proposed' until the proposal lifecycle resolves it; attrs holds the
2407
- // scheme-supplied payload (file diff, exec command, etc.) the client renders
2408
- // for review and the scheme consumes on accept. A broadcast SEND[202] is a
2409
- // parked-terminal, NOT a proposal (#isProposal / #255) → state='resolved'.
2410
- const isProposed = _a.#isProposal(statement, result);
2411
- let attrsObj = (result.attrs !== undefined && result.attrs !== null)
2412
- ? { ...result.attrs }
2413
- : {};
2414
- // EXEC produces a stream entry addressed by RUNTIME TAG as authority (§exec): it lives
2415
- // at <runtime>:///<loop_seq>/<turn_seq>/<sequence> (e.g. sh:///1/1/2). That address is a
2416
- // SEPARATE `stream` link in attrs — NOT an overload of `target`, which stays faithful to
2417
- // the EXEC's own slot (the cwd, or the path to the executable). The log:/// coordinate
2418
- // shares the trailing <loop>/<turn>/<seq>, so the op still correlates to its stream.
2419
- // Runtime comes from statement.signal (EXEC's runtime slot), resolvable for failed execs
2420
- // too; empty/absent = the default shell.
2421
- if (statement.op === "EXEC") {
2422
- const seqs = await this.#db.engine_loop_turn_seqs.get({
2423
- loop_id: loopId, turn_id: turnId,
2424
- });
2425
- if (seqs === undefined)
2426
- throw new Error(`Engine.#writeLog: loop_turn_seqs returned no row for loop=${loopId} turn=${turnId}`);
2427
- const runtime = (typeof statement.signal === "string" && statement.signal.length > 0) ? statement.signal : "sh";
2428
- const coordPathname = `/${seqs.loop_seq}/${seqs.turn_seq}/${sequence}`;
2429
- attrsObj.pathname = coordPathname;
2430
- attrsObj.stream = `${runtime}://${coordPathname}`;
2431
- // Mutate the in-memory result.attrs too: the dispatch path
2432
- // hands originalResult.attrs to handler.applyResolution after
2433
- // proposal accept (see #acceptResolution). Both views — the
2434
- // stored row AND the in-memory proposal — need the same
2435
- // pathname so applyResolution writes the entry at the same URI.
2436
- if (result.attrs !== undefined && result.attrs !== null) {
2437
- result.attrs.pathname = coordPathname;
2438
- }
2439
- }
2440
- const attrs = JSON.stringify(attrsObj);
2441
- const txJson = JSON.stringify(statement);
2442
- const rxJson = JSON.stringify(result);
2443
- const row = await this.#db.engine_insert_log_entry.get({
2444
- run_id: runId,
2445
- loop_id: loopId,
2446
- turn_id: turnId,
2447
- sequence: sequence,
2448
- origin,
2449
- source: null, // dispatch entries are self-authored; §env-delta deltas set this
2450
- op: statement.op,
2451
- suffix: statement.suffix,
2452
- signal: this.#signalToJson(statement.signal),
2453
- scheme: target.scheme,
2454
- username: target.username,
2455
- password: target.password,
2456
- hostname: target.hostname,
2457
- port: target.port,
2458
- pathname: target.pathname,
2459
- params: target.params,
2460
- fragment: target.fragment,
2461
- lineMarker: lineMarkerJson,
2462
- tx: txJson,
2463
- mimetype_tx: "application/json",
2464
- rx: rxJson,
2465
- mimetype_rx: "application/json",
2466
- status_rx: result.status,
2467
- tokens: this.#tokenize(txJson) + this.#tokenize(rxJson),
2468
- state: isProposed ? "proposed" : "resolved",
2469
- outcome: null,
2470
- attrs,
2471
- });
2472
- if (row === undefined)
2473
- throw new Error("Engine.#writeLog: INSERT ... RETURNING produced no row");
2474
- return row.id;
2475
- }
2476
- // Normalize a parsed path for storage. The `file` scheme is a routing
2477
- // internal — never stored, never rendered to the model. Both bare paths
2478
- // and `file:///...` inputs collapse to scheme=null at this boundary, so
2479
- // entries.scheme / log_entries.scheme never carry the string "file".
2480
- #extractTarget(path) {
2481
- if (path === null)
2482
- return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: null, params: null, fragment: null };
2483
- // `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.
2484
- if (path.kind === "regex")
2485
- return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null }; // regex source — no decode
2486
- if (path.kind === "local")
2487
- return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: decodePathParens(path.raw), params: null, fragment: null }; // #239 item 4
2488
- const scheme = path.scheme === "file" ? null : path.scheme;
2489
- // Every registered (plurnk-namespace) scheme uses its authority as a namespace segment — fold
2490
- // it into the canonical pathname so known://x ≡ known:///x ≡ /x and the log keys identically to
2491
- // the entry (/prompt/<loop>, /docs/x.md). A foreign web host (http://, unregistered) is NOT a
2492
- // namespace: keep it in hostname. run:// is the one registered EXCEPTION — its authority IS the
2493
- // run selector (§run-scheme), and run://self must stay distinct from run://name, so Run.ts
2494
- // folds the owner into the storage path itself, never here.
2495
- const foldNs = scheme !== null && scheme !== "run" && this.#schemes.has(scheme);
2496
- return {
2497
- scheme, username: path.username, password: path.password,
2498
- hostname: foldNs ? null : path.hostname, port: path.port,
2499
- pathname: decodePathParens(foldNs ? foldAuthorityIntoPath(path.hostname, path.pathname) : path.pathname), // #239 item 4
2500
- params: JSON.stringify(path.params), fragment: path.fragment,
2501
- };
2502
- }
2503
- #signalToJson(signal) {
2504
- if (signal === null || signal === undefined)
2505
- return null;
2506
- return JSON.stringify(signal);
2507
- }
2508
1206
  }
2509
- _a = Engine;
2510
- export default Engine;
2511
1207
  //# sourceMappingURL=Engine.js.map