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