@plurnk/plurnk-service 0.3.0 → 0.5.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 (116) hide show
  1. package/README.md +13 -44
  2. package/SPEC.md +559 -447
  3. package/bin/plurnk-service.js +2 -1
  4. package/dist/core/ChannelWrite.d.ts +15 -0
  5. package/dist/core/ChannelWrite.d.ts.map +1 -1
  6. package/dist/core/ChannelWrite.js.map +1 -1
  7. package/dist/core/Engine.d.ts +15 -4
  8. package/dist/core/Engine.d.ts.map +1 -1
  9. package/dist/core/Engine.js +417 -114
  10. package/dist/core/Engine.js.map +1 -1
  11. package/dist/core/PluginLoader.js +6 -6
  12. package/dist/core/PluginLoader.js.map +1 -1
  13. package/dist/core/ProviderInstantiate.d.ts +4 -0
  14. package/dist/core/ProviderInstantiate.d.ts.map +1 -0
  15. package/dist/core/ProviderInstantiate.js +41 -0
  16. package/dist/core/ProviderInstantiate.js.map +1 -0
  17. package/dist/core/ProviderRegistry.d.ts.map +1 -1
  18. package/dist/core/ProviderRegistry.js +1 -1
  19. package/dist/core/ProviderRegistry.js.map +1 -1
  20. package/dist/core/SchemeRegistry.d.ts +1 -1
  21. package/dist/core/SchemeRegistry.d.ts.map +1 -1
  22. package/dist/core/SchemeRegistry.js +4 -20
  23. package/dist/core/SchemeRegistry.js.map +1 -1
  24. package/dist/core/line-marker.d.ts +23 -0
  25. package/dist/core/line-marker.d.ts.map +1 -0
  26. package/dist/core/line-marker.js +321 -0
  27. package/dist/core/line-marker.js.map +1 -0
  28. package/dist/core/matcher.d.ts +12 -0
  29. package/dist/core/matcher.d.ts.map +1 -0
  30. package/dist/core/matcher.js +72 -0
  31. package/dist/core/matcher.js.map +1 -0
  32. package/dist/core/mimetype-binary.d.ts +6 -0
  33. package/dist/core/mimetype-binary.d.ts.map +1 -0
  34. package/dist/core/mimetype-binary.js +82 -0
  35. package/dist/core/mimetype-binary.js.map +1 -0
  36. package/dist/core/packet-wire.d.ts.map +1 -1
  37. package/dist/core/packet-wire.js +206 -30
  38. package/dist/core/packet-wire.js.map +1 -1
  39. package/dist/core/path-mimetype.d.ts +3 -0
  40. package/dist/core/path-mimetype.d.ts.map +1 -0
  41. package/dist/core/path-mimetype.js +47 -0
  42. package/dist/core/path-mimetype.js.map +1 -0
  43. package/dist/core/scheme-types.d.ts +8 -26
  44. package/dist/core/scheme-types.d.ts.map +1 -1
  45. package/dist/core/scheme-types.js +5 -8
  46. package/dist/core/scheme-types.js.map +1 -1
  47. package/dist/index.d.ts +3 -2
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +14 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/schemes/Exec.d.ts +30 -1
  52. package/dist/schemes/Exec.d.ts.map +1 -1
  53. package/dist/schemes/Exec.js +217 -6
  54. package/dist/schemes/Exec.js.map +1 -1
  55. package/dist/schemes/File.d.ts +5 -0
  56. package/dist/schemes/File.d.ts.map +1 -1
  57. package/dist/schemes/File.js +113 -23
  58. package/dist/schemes/File.js.map +1 -1
  59. package/dist/schemes/Log.d.ts +3 -0
  60. package/dist/schemes/Log.d.ts.map +1 -1
  61. package/dist/schemes/Log.js +88 -13
  62. package/dist/schemes/Log.js.map +1 -1
  63. package/dist/schemes/Plurnk.js +3 -3
  64. package/dist/schemes/Plurnk.js.map +1 -1
  65. package/dist/schemes/_entry-crud.d.ts +2 -0
  66. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  67. package/dist/schemes/_entry-crud.js +1 -0
  68. package/dist/schemes/_entry-crud.js.map +1 -1
  69. package/dist/schemes/_entry-find.d.ts.map +1 -1
  70. package/dist/schemes/_entry-find.js +64 -15
  71. package/dist/schemes/_entry-find.js.map +1 -1
  72. package/dist/schemes/_entry-ops.d.ts +3 -0
  73. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  74. package/dist/schemes/_entry-ops.js +262 -54
  75. package/dist/schemes/_entry-ops.js.map +1 -1
  76. package/dist/schemes/_entry-send.d.ts.map +1 -1
  77. package/dist/schemes/_entry-send.js +14 -7
  78. package/dist/schemes/_entry-send.js.map +1 -1
  79. package/dist/server/ClientConnection.d.ts +3 -2
  80. package/dist/server/ClientConnection.d.ts.map +1 -1
  81. package/dist/server/ClientConnection.js +4 -1
  82. package/dist/server/ClientConnection.js.map +1 -1
  83. package/dist/server/Daemon.d.ts +51 -1
  84. package/dist/server/Daemon.d.ts.map +1 -1
  85. package/dist/server/Daemon.js +337 -1
  86. package/dist/server/Daemon.js.map +1 -1
  87. package/dist/server/MethodRegistry.d.ts +31 -2
  88. package/dist/server/MethodRegistry.d.ts.map +1 -1
  89. package/dist/server/MethodRegistry.js.map +1 -1
  90. package/dist/server/dsl.d.ts +2 -2
  91. package/dist/server/dsl.d.ts.map +1 -1
  92. package/dist/server/dsl.js +10 -10
  93. package/dist/server/dsl.js.map +1 -1
  94. package/dist/server/methods/entry_read.js +5 -5
  95. package/dist/server/methods/entry_read.js.map +1 -1
  96. package/dist/server/methods/loop_cancel.d.ts +3 -0
  97. package/dist/server/methods/loop_cancel.d.ts.map +1 -0
  98. package/dist/server/methods/loop_cancel.js +27 -0
  99. package/dist/server/methods/loop_cancel.js.map +1 -0
  100. package/dist/server/methods/loop_resolve.js +2 -2
  101. package/dist/server/methods/loop_resolve.js.map +1 -1
  102. package/dist/server/methods/loop_run.d.ts.map +1 -1
  103. package/dist/server/methods/loop_run.js +48 -48
  104. package/dist/server/methods/loop_run.js.map +1 -1
  105. package/dist/server/methods/op_edit.js +3 -3
  106. package/dist/server/methods/op_edit.js.map +1 -1
  107. package/dist/server/methods/op_hide.js +3 -3
  108. package/dist/server/methods/op_hide.js.map +1 -1
  109. package/dist/server/methods/op_read.js +3 -3
  110. package/dist/server/methods/op_read.js.map +1 -1
  111. package/dist/server/methods/op_show.js +3 -3
  112. package/dist/server/methods/op_show.js.map +1 -1
  113. package/dist/server/methods/providers_list.js +1 -1
  114. package/dist/server/methods/providers_list.js.map +1 -1
  115. package/migrations/001_schema.sql +1 -1
  116. package/package.json +19 -14
@@ -1,15 +1,18 @@
1
- import { PlurnkParser } from "@plurnk/plurnk-grammar";
1
+ import { PlurnkParser, PlurnkParseError } from "@plurnk/plurnk-grammar";
2
2
  import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
3
+ import { writeEntry } from "../schemes/_entry-crud.js";
3
4
  import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
5
+ import { sliceLinesRaw, isBinaryMimetype } from "@plurnk/plurnk-schemes";
4
6
  // Plain JS module shared with bin/digest.js so wire projection and
5
7
  // digest projection are structurally one function. tsconfig.build.json
6
8
  // has allowJs:true so this gets copied through to dist/.
7
9
  import { packetToWireMessages } from "./packet-wire.js";
8
- // SCHEMES.md §8: writer must be in target scheme's manifest.writableBy.
10
+ // SPEC §3.6: writer must be in target scheme's manifest.writableBy.
9
11
  // SHOW/HIDE/READ/FIND are not gated — they touch visibility metadata or read.
10
12
  const MUTATING_OPS = new Set(["EDIT", "SEND", "COPY", "MOVE", "EXEC"]);
11
13
  const DEFAULT_PREVIEW_BUDGET = 256;
12
14
  const DEFAULT_MAX_STRIKES = 3;
15
+ const DEFAULT_MAX_COMMANDS = 99;
13
16
  const readBudget = () => {
14
17
  const raw = process.env.PLURNK_ENTRY_SIZE_DEFAULT_TOKENS;
15
18
  if (raw === undefined || raw.length === 0)
@@ -28,8 +31,17 @@ const readMaxStrikes = () => {
28
31
  return DEFAULT_MAX_STRIKES;
29
32
  return n;
30
33
  };
34
+ const readMaxCommands = () => {
35
+ const raw = process.env.PLURNK_MAX_COMMANDS;
36
+ if (raw === undefined || raw.length === 0)
37
+ return DEFAULT_MAX_COMMANDS;
38
+ const n = Number.parseInt(raw, 10);
39
+ if (!Number.isFinite(n) || n < 1)
40
+ return DEFAULT_MAX_COMMANDS;
41
+ return n;
42
+ };
31
43
  // Resolution timeout — proposed entries auto-cancel if nothing arrives
32
- // within this window. Per AGENTS.md §Phase E.2.
44
+ // within this window. SPEC.md §0.5 (proposal lifecycle) + §13.5 (loop.resolve).
33
45
  const PROPOSAL_TIMEOUT_DEFAULT_MS = 300000;
34
46
  const readProposalTimeoutMs = () => {
35
47
  const raw = process.env.PLURNK_PROPOSAL_TIMEOUT_MS;
@@ -67,17 +79,57 @@ const readPositiveInt = (envVar, fallback) => {
67
79
  return fallback;
68
80
  return n;
69
81
  };
70
- // Per-op fingerprint: op verb + target URI. Body deliberately excluded so the
71
- // model writing varied content to the same target still trips. Path kind is
72
- // included as a discriminator (url vs local). Rummy parallel: scheme +
73
- // sorted attributes joined by '='.
82
+ // Per-op fingerprint: op verb + target URI, plus an op-specific discriminator
83
+ // where the activity isn't fully captured by target alone:
84
+ // - EDIT/COPY/MOVE: body excluded re-writing the same target with varied
85
+ // content IS cycling (the model is producing different versions of the
86
+ // same artifact instead of progressing).
87
+ // - FIND/READ/SHOW/HIDE: body IS the search/selection pattern; varied
88
+ // matchers on the same target ARE different activities (the model is
89
+ // exploring different queries, not repeating one).
74
90
  const fingerprintOp = (stmt) => {
75
- const path = stmt.path;
76
- if (path === null)
91
+ const path = stmt.target;
92
+ const matcherDiscriminator = () => {
93
+ // For matcher-bearing ops, the body's `raw` (matcher source) plus
94
+ // any lineMarker forms the activity discriminator.
95
+ const parts = [];
96
+ const body = stmt.body;
97
+ if (body !== null && typeof body === "object" && typeof body.raw === "string") {
98
+ parts.push(`body:${body.raw.slice(0, 64)}`);
99
+ }
100
+ const lm = stmt.lineMarker;
101
+ if (lm !== null && lm !== undefined)
102
+ parts.push(`L:${lm.first},${lm.last ?? ""}`);
103
+ return parts.length > 0 ? `|${parts.join("|")}` : "";
104
+ };
105
+ if (path === null) {
106
+ // Path-less ops need an activity-defining discriminator other
107
+ // than `target`. Picked per op so the cycle detector reflects
108
+ // intent rather than syntax:
109
+ // - EXEC: the command body IS the activity. Without a body
110
+ // digest, varied shell commands (find / ls / wc) collapse to
111
+ // one fingerprint and the detector mislabels exploration
112
+ // as a loop.
113
+ // - SEND: the status code (signal) IS the activity. Different
114
+ // SEND[X] are different intentions; same SEND[X] with
115
+ // different message bodies is the same termination signal.
116
+ if (stmt.op === "EXEC") {
117
+ const body = typeof stmt.body === "string" ? stmt.body : "";
118
+ return `EXEC|(no-path)${body.length > 0 ? `|body:${body.slice(0, 64)}` : ""}`;
119
+ }
120
+ if (stmt.op === "SEND") {
121
+ const signal = typeof stmt.signal === "number" ? stmt.signal : "";
122
+ return `SEND|(no-path)|signal:${signal}`;
123
+ }
77
124
  return `${stmt.op}|(no-path)`;
78
- if (path.kind === "url")
79
- return `${stmt.op}|${path.scheme}://${path.pathname}`;
80
- return `${stmt.op}|local:${path.raw}`;
125
+ }
126
+ const base = path.kind === "url"
127
+ ? `${stmt.op}|${path.scheme}://${path.pathname}`
128
+ : `${stmt.op}|local:${path.raw}`;
129
+ if (stmt.op === "FIND" || stmt.op === "READ" || stmt.op === "SHOW" || stmt.op === "HIDE") {
130
+ return `${base}${matcherDiscriminator()}`;
131
+ }
132
+ return base;
81
133
  };
82
134
  // Per-turn fingerprint: sorted set of per-op fingerprints, joined. Order
83
135
  // within a turn doesn't matter — we want the SET of activities.
@@ -123,11 +175,10 @@ export default class Engine {
123
175
  // rails (cycle detection #39, etc.) — read and reset at end of each turn.
124
176
  // `history` holds per-turn fingerprints for rail #39 cycle detection.
125
177
  #strikeState = new Map();
126
- // Proposal lifecycle (task #42): pending dispatch pauses waiting for
127
- // resolution. Engine.runTurn awaits the promise when a scheme returns
128
- // status 202; Engine.resolveProposal feeds the resolution back in. Map
129
- // is per-log-entry-id; entries clear on resolution. See AGENTS.md
130
- // §Phase E for the broader lifecycle plan.
178
+ // Proposal lifecycle: pending dispatch pauses waiting for resolution.
179
+ // Engine.runTurn awaits the promise when a scheme returns status 202;
180
+ // Engine.resolveProposal feeds the resolution back in. Map is per-log-
181
+ // entry-id; entries clear on resolution. SPEC.md §0.5 + §13.5 (loop.resolve).
131
182
  #pendingProposals = new Map();
132
183
  // External observers of proposal lifecycle events. Daemon subscribes
133
184
  // here to push `loop/proposal` notifications when an entry enters
@@ -135,9 +186,26 @@ export default class Engine {
135
186
  // event emitter — no priority, no veto chain at this layer; filter
136
187
  // chains come later if a real consumer needs them.
137
188
  #proposalPendingListeners = [];
138
- constructor({ db, schemes, mimetypes }) {
189
+ // Per-loop AbortController for cancellation propagation into scheme
190
+ // ctx.signal. runLoop creates one at entry, cleans up at end. Engine
191
+ // cancellation paths (strikes, max_turns, external) abort it.
192
+ // Streaming schemes (exec) chain their per-spawn controllers off
193
+ // ctx.signal so cancelled loops tear down their background spawns.
194
+ #loopAborts = new Map();
195
+ #streamEventNotify;
196
+ #wakeRunNotify;
197
+ // Telemetry event fan-out: every TelemetryEvent pushed to the loop's
198
+ // buffer is also broadcast live to the connected client(s) on the
199
+ // session. Without this, the client sees `loop/terminated` with a
200
+ // status code but has no way to surface why the loop degraded.
201
+ // Per-grammar 0.17.0 protocol — see SPEC §15.1.
202
+ #telemetryEventNotify;
203
+ constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify, telemetryEventNotify }) {
139
204
  this.#db = db;
140
205
  this.#schemes = schemes;
206
+ this.#streamEventNotify = streamEventNotify;
207
+ this.#wakeRunNotify = wakeRunNotify;
208
+ this.#telemetryEventNotify = telemetryEventNotify;
141
209
  // Default to empty discovery — standalone Engine construction (in
142
210
  // tests) gets no handlers, and content flows through the framework's
143
211
  // raw-content fitContent fallback. Daemon-managed Engine receives a
@@ -147,12 +215,16 @@ export default class Engine {
147
215
  });
148
216
  this.#previewBudget = readBudget();
149
217
  }
150
- #pushTelemetry(loopId, error) {
218
+ #pushTelemetry(sessionId, loopId, event) {
151
219
  const existing = this.#telemetryBuffer.get(loopId);
152
220
  if (existing === undefined)
153
- this.#telemetryBuffer.set(loopId, [error]);
221
+ this.#telemetryBuffer.set(loopId, [event]);
154
222
  else
155
- existing.push(error);
223
+ existing.push(event);
224
+ // Live fan-out: client sees the event the moment it lands in the
225
+ // model's buffer (not at the next packet build). Same envelope on
226
+ // both sides per the grammar 0.17.0 TelemetryEvent protocol.
227
+ this.#telemetryEventNotify?.(sessionId, { loopId, event });
156
228
  }
157
229
  #drainTelemetry(loopId) {
158
230
  const buf = this.#telemetryBuffer.get(loopId);
@@ -161,10 +233,53 @@ export default class Engine {
161
233
  this.#telemetryBuffer.delete(loopId);
162
234
  return buf;
163
235
  }
164
- async runLoop({ provider, messages, persona = "", sessionId, runId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
236
+ // Pull a ±windowLines context block around `targetLine` (1-based) from
237
+ // `content`, formatted with N:\t prefixes — same shape the model
238
+ // already knows from READ output and numbered Index entries. Used to
239
+ // give parse_error telemetry concrete locality: the model sees what
240
+ // it wrote on the offending line, not just an abstract error message.
241
+ //
242
+ // Lenient: targetLine ≤ 0 clamps to 1; targetLine beyond content
243
+ // returns whatever overlap exists; empty content returns "".
244
+ #extractSnippet(content, targetLine, windowLines) {
245
+ if (content.length === 0)
246
+ return "";
247
+ const lines = content.split("\n");
248
+ const target = Math.max(1, targetLine);
249
+ const start = Math.max(1, target - windowLines);
250
+ const end = Math.min(lines.length, target + windowLines);
251
+ const slice = [];
252
+ for (let i = start; i <= end; i++) {
253
+ slice.push(`${i}:\t${lines[i - 1] ?? ""}`);
254
+ }
255
+ return slice.join("\n");
256
+ }
257
+ async runLoop({ provider, messages, persona = "", requirements = "", sessionId, runId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
165
258
  const turnIds = [];
166
259
  const suddenDeathThreshold = maxTurns - maxStrikes;
167
- const cleanup = () => {
260
+ // Per-loop AbortController for scheme-side cancellation propagation.
261
+ // Chained from the caller's `signal` so an external abort cascades.
262
+ const loopAbort = new AbortController();
263
+ if (signal !== undefined) {
264
+ if (signal.aborted)
265
+ loopAbort.abort(signal.reason);
266
+ else
267
+ signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
268
+ }
269
+ this.#loopAborts.set(loopId, loopAbort);
270
+ // Cleanup splits by termination kind:
271
+ // - "graceful" (loop emitted SEND[2xx]): in-flight streaming-scheme
272
+ // spawns are ALLOWED to outlive the loop. They complete naturally,
273
+ // write final channel state, and wake-on-completion (E.4) opens a
274
+ // fresh loop in the same run if the model needs to react.
275
+ // - "forceful" (max_turns, strike_threshold, external cancel,
276
+ // non-2xx loop status): fire the loop-level abort so spawns
277
+ // tear down immediately.
278
+ const cleanup = (kind, reason) => {
279
+ if (kind === "forceful" && !loopAbort.signal.aborted) {
280
+ loopAbort.abort(reason ?? "loop_forceful_termination");
281
+ }
282
+ this.#loopAborts.delete(loopId);
168
283
  this.#strikeState.delete(loopId);
169
284
  this.#telemetryBuffer.delete(loopId);
170
285
  };
@@ -174,16 +289,19 @@ export default class Engine {
174
289
  if (row === undefined)
175
290
  throw new Error(`Engine.runLoop: loop ${loopId} not found`);
176
291
  if (row.status !== 102) {
177
- cleanup();
292
+ // Status 2xx = graceful (model said done); 4xx/5xx = forceful
293
+ // (external cancel or upstream failure). The threshold splits
294
+ // at 400 to match HTTP success/error semantics.
295
+ cleanup(row.status < 400 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
178
296
  return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
179
297
  }
180
298
  if (turnIds.length >= maxTurns) {
181
299
  await this.#db.engine_loop_cancel.run({ loop_id: loopId });
182
- cleanup();
300
+ cleanup("forceful", "max_turns");
183
301
  return { turnIds, finalStatus: 499, hitMaxTurns: true, reason: "max_turns" };
184
302
  }
185
303
  const turn = await this.runTurn({
186
- provider, messages, persona, sessionId, runId, loopId, origin, signal, onDispatch,
304
+ provider, messages, persona, requirements, sessionId, runId, loopId, origin, signal, onDispatch,
187
305
  turnNumber: turnIds.length + 1, maxTurns,
188
306
  });
189
307
  turnIds.push(turn.turnId);
@@ -195,11 +313,10 @@ export default class Engine {
195
313
  const cycle = detectCycle(state.history, minCycles, maxCyclePeriod);
196
314
  if (cycle.detected) {
197
315
  state.turnErrors++;
198
- this.#pushTelemetry(loopId, {
316
+ this.#pushTelemetry(sessionId, loopId, {
199
317
  kind: "cycle",
200
318
  period: cycle.period,
201
319
  cycles: cycle.cycles,
202
- message: `repeating pattern detected: ${cycle.cycles}× period-${cycle.period}; vary your approach`,
203
320
  });
204
321
  }
205
322
  this.#strikeState.set(loopId, state);
@@ -214,15 +331,16 @@ export default class Engine {
214
331
  const struck = noOps || recordedFailed || state.turnErrors > 0;
215
332
  if (struck) {
216
333
  state.streak++;
217
- this.#pushTelemetry(loopId, {
334
+ const reason = noOps ? "no_ops" : recordedFailed ? "recorded_failure" : "rail";
335
+ this.#pushTelemetry(sessionId, loopId, {
218
336
  kind: "strike",
219
337
  streak: state.streak,
220
338
  maxStrikes,
221
- reason: noOps ? "no_ops" : recordedFailed ? "recorded_failure" : "rail",
339
+ reason,
222
340
  });
223
341
  if (state.streak >= maxStrikes) {
224
342
  await this.#db.engine_loop_cancel.run({ loop_id: loopId });
225
- cleanup();
343
+ cleanup("forceful", "strike_threshold");
226
344
  return { turnIds, finalStatus: 499, hitMaxTurns: false, reason: "strike_threshold" };
227
345
  }
228
346
  }
@@ -236,15 +354,14 @@ export default class Engine {
236
354
  // each turn so the model can wrap up before the hard cancel.
237
355
  // Soft: no strike, no loop-status change. SPEC §15.1.
238
356
  if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
239
- this.#pushTelemetry(loopId, {
357
+ this.#pushTelemetry(sessionId, loopId, {
240
358
  kind: "sudden_death",
241
- message: `approaching max turns: ${turnIds.length} of ${maxTurns}; emit SEND[200] to complete`,
242
359
  remaining: maxTurns - turnIds.length,
243
360
  });
244
361
  }
245
362
  }
246
363
  }
247
- async runTurn({ provider, messages, persona = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
364
+ async runTurn({ provider, messages, persona = "", requirements = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
248
365
  // === Turn-as-container model ===
249
366
  //
250
367
  // Turn rows are created at runTurn OPEN (status=102, placeholder
@@ -263,26 +380,30 @@ export default class Engine {
263
380
  if (openRow === undefined)
264
381
  throw new Error("Engine.runTurn: turn open returned no row");
265
382
  const turnId = openRow.id;
266
- // Pre-model writes. For the loop's first turn (DB sequence 1),
267
- // the user's prompt becomes the first action: a system-origin
268
- // EDIT against `plurnk://prompt/<loop_id>` (loop-scoped indexed
269
- // entry, body=prompt text). The log row records the EDIT;
270
- // content lives at the plurnk:// entry. Model ops dispatch from
271
- // sequence=2 onward on the first turn; 1 onward thereafter.
272
- // 1-based throughout (migration 019).
383
+ // Pre-model writes. Each turn opens with a system-origin EDIT
384
+ // against `plurnk://prompt/<loop_id>/<seq>` IF there's a prompt
385
+ // for THIS turn the model hasn't seen yet:
386
+ // - Turn 1: loop.prompt is the initial user prompt.
387
+ // - Turn N>1: only if Engine.inject (or wake-on-completion via
388
+ // daemon.inject) wrote a prompt entry for this turn slot
389
+ // between turn N-1 and N. Inject writes directly to entries;
390
+ // we DON'T re-foist here for N>1.
391
+ // The log records the EDIT for forensics. Model ops dispatch
392
+ // from sequence=2 onward on prompt-foisted turns; 1 onward
393
+ // otherwise.
273
394
  let nextActionIndex = 1;
274
395
  if (seq === 1) {
275
396
  const promptRow = await this.#db.engine_get_loop_prompt.get({ loop_id: loopId });
276
397
  if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
277
398
  const promptPath = {
278
- kind: "url", raw: `plurnk://prompt/${loopId}`,
399
+ kind: "url", raw: `plurnk://prompt/${loopId}/${seq}`,
279
400
  scheme: "plurnk", username: null, password: null,
280
401
  hostname: null, port: null,
281
- pathname: `prompt/${loopId}`, params: {}, fragment: null,
402
+ pathname: `prompt/${loopId}/${seq}`, params: {}, fragment: null,
282
403
  };
283
404
  const promptStmt = {
284
405
  op: "EDIT", suffix: "", signal: null,
285
- path: promptPath, lineMarker: null,
406
+ target: promptPath, lineMarker: null,
286
407
  body: promptRow.prompt, position: { line: 1, column: 1 },
287
408
  };
288
409
  await this.dispatch({
@@ -296,8 +417,8 @@ export default class Engine {
296
417
  // queries log_entries scoped to the run — the prompt entry just
297
418
  // written (if turn 1) is part of that query result.
298
419
  const requestPacket = await this.#buildRequestPacket({
299
- initialMessages: messages, persona, runId, loopId,
300
- turnNumber, currentTurnSeq: seq, maxTurns, provider,
420
+ initialMessages: messages, persona, requirements, runId, loopId,
421
+ currentTurnSeq: seq, provider,
301
422
  });
302
423
  const modelMessages = this.#packetToWireMessages(requestPacket);
303
424
  const response = await provider.generate({ messages: modelMessages, signal });
@@ -305,7 +426,30 @@ export default class Engine {
305
426
  // parsed ops) → packet.assistant per Packet.json §assistant;
306
427
  // call-metadata (usage, finishReason, model) → Turn columns per
307
428
  // Turn.json. Mixing the two on packet.assistant was the wrong layer.
308
- const { packetAssistant, callMetadata } = this.#splitResponse(response);
429
+ const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response);
430
+ // Surface parse errors to the model's NEXT packet so it can self-
431
+ // correct. Without this, malformed emissions (e.g. a READ matcher
432
+ // body starting with `//` being interpreted as xpath) silently
433
+ // drop, the model sees zero ops dispatched, strike-rail fires,
434
+ // model has no feedback on WHY its emission didn't take effect.
435
+ //
436
+ // Envelope per @plurnk/plurnk-grammar 0.17.0 TelemetryEvent:
437
+ // { source, kind, message, position: { type: "content-offset", line, column } }
438
+ // Plus a `snippet` field (additionalProperties) carrying ±N lines
439
+ // of the assistant's own content around the error line. Without
440
+ // the snippet, the model sees "invalid xpath at 1:0" but can't
441
+ // connect that to what IT wrote — and tends to regenerate the
442
+ // same broken emission. See edit-todo demo for the canonical case.
443
+ for (const { message, line, column, source } of parseErrors ?? []) {
444
+ this.#pushTelemetry(sessionId, loopId, {
445
+ source: "grammar",
446
+ kind: "parse_error",
447
+ message,
448
+ position: { type: "content-offset", line, column },
449
+ snippet: this.#extractSnippet(packetAssistant.content, line, 2),
450
+ parserSource: source,
451
+ });
452
+ }
309
453
  const opsCount = packetAssistant.ops.length;
310
454
  const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number");
311
455
  // Rail #41 (revised): the per-turn requirement is "emit at least one
@@ -330,8 +474,19 @@ export default class Engine {
330
474
  });
331
475
  // Dispatch model ops starting at nextActionIndex (continues the
332
476
  // turn's running counter after any pre-model writes).
477
+ //
478
+ // Max-commands cap: a single emission with more than `maxCommands`
479
+ // ops is the runaway-loop fingerprint observed in pathological cases
480
+ // (html-attrs demo: 635 ops in one turn). Cap dispatches at the
481
+ // configured limit; overflow ops are dropped without per-op log
482
+ // entries (avoids bloating forensics with hundreds of identical refusals)
483
+ // and the model gets a single telemetry signal next packet so it knows
484
+ // its emission was truncated.
485
+ const maxCommands = readMaxCommands();
486
+ const opsToDispatch = packetAssistant.ops.slice(0, maxCommands);
487
+ const droppedCount = opsCount - opsToDispatch.length;
333
488
  const statuses = [];
334
- for (const [i, statement] of packetAssistant.ops.entries()) {
489
+ for (const [i, statement] of opsToDispatch.entries()) {
335
490
  const result = await this.dispatch({
336
491
  statement, sessionId, runId, loopId, turnId,
337
492
  sequence: nextActionIndex + i,
@@ -339,14 +494,21 @@ export default class Engine {
339
494
  });
340
495
  statuses.push(result.status);
341
496
  }
497
+ if (droppedCount > 0) {
498
+ this.#pushTelemetry(sessionId, loopId, {
499
+ kind: "max_commands_exceeded",
500
+ emitted: opsCount,
501
+ cap: maxCommands,
502
+ dropped: droppedCount,
503
+ });
504
+ }
342
505
  if (opsCount === 0) {
343
506
  // Rail #41 (revised): per-turn requirement is "emit at least one
344
507
  // op." Zero ops = actionless failure. SEND specifically is not
345
508
  // required — any of the 9 grammar ops satisfies. Pushed AFTER
346
509
  // #buildPacket so this turn's drain doesn't consume it.
347
- this.#pushTelemetry(loopId, {
510
+ this.#pushTelemetry(sessionId, loopId, {
348
511
  kind: "no_ops",
349
- message: "turn ended without emitting any op; emit at least one operation per turn",
350
512
  });
351
513
  }
352
514
  return { turnId, status: turnStatus, statuses, fingerprint: fingerprintTurn(packetAssistant.ops) };
@@ -354,8 +516,9 @@ export default class Engine {
354
516
  // Split the wire-level ProviderResponse into the two destinations:
355
517
  // packet.assistant gets the model's emission (content, ops, reasoning);
356
518
  // Turn columns get the call-metadata (usage, finishReason, model).
357
- // PROVIDERS.md §3.3 text-fragment scraping policy lives here — engine
358
- // owns the parse and the scraping rule, providers stay grammar-unaware.
519
+ // SPEC §2.1 / plurnk-providers#1: text-fragment scraping policy lives
520
+ // here — engine owns the parse and the scraping rule, providers stay
521
+ // grammar-unaware.
359
522
  //
360
523
  // Test-fixture escape hatch: the Mock provider may pre-supply `ops` on
361
524
  // its assistant payload to skip the parse roundtrip. The wire Provider
@@ -366,6 +529,11 @@ export default class Engine {
366
529
  const preParsedOps = assistant.ops;
367
530
  const ops = [];
368
531
  const textFragments = [];
532
+ // Full PlurnkParseError context (line/column/source) is preserved
533
+ // here so runTurn can build TelemetryEvent envelopes per the
534
+ // grammar 0.17.0 protocol — model needs position info to locate
535
+ // its own offending content on the next turn.
536
+ const parseErrors = [];
369
537
  if (preParsedOps !== undefined) {
370
538
  ops.push(...preParsedOps);
371
539
  }
@@ -379,6 +547,16 @@ export default class Engine {
379
547
  if (trimmed.length > 0)
380
548
  textFragments.push(trimmed);
381
549
  }
550
+ else if (item.kind === "error") {
551
+ const err = item.error;
552
+ if (err instanceof PlurnkParseError) {
553
+ parseErrors.push({ message: err.message, line: err.line, column: err.column, source: err.source });
554
+ }
555
+ else {
556
+ const msg = err?.message ?? "parse error";
557
+ parseErrors.push({ message: msg, line: 0, column: 0, source: "parser" });
558
+ }
559
+ }
382
560
  }
383
561
  }
384
562
  const wireReasoning = assistant.reasoning ?? "";
@@ -388,19 +566,25 @@ export default class Engine {
388
566
  return {
389
567
  packetAssistant: { content: assistant.content, ops, reasoning },
390
568
  callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
569
+ parseErrors,
391
570
  };
392
571
  }
393
572
  // Assemble the request half of the spec'd packet (Packet.json §system
394
573
  // and §user) BEFORE the provider call. The same packet object is then
395
574
  // completed with assistant + assistantRaw after the model responds, so
396
575
  // the stored packet and the wire payload share one source of truth.
397
- // Per Packet.json: user.prompt is "Copy of loop.prompt never null on
398
- // a continuation turn"; the turn-N-of-M continuation marker rides on
399
- // user.system_requirements (per-turn rules), NOT a mutated prompt.
400
- async #buildRequestPacket({ initialMessages, persona: defaultPersona, runId, loopId, turnNumber, currentTurnSeq, maxTurns, provider, }) {
576
+ async #buildRequestPacket({ initialMessages, persona: defaultPersona, requirements, runId, loopId, currentTurnSeq, provider, }) {
401
577
  const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
402
578
  const system_definition = byRole("system");
403
- const prompt = byRole("user");
579
+ // user.prompt sources from the loop's most recent prompt entry first
580
+ // (plurnk://prompt/<loop_id>/<N> for the highest N written to date).
581
+ // This is what inject + the turn-1 foist write into. Falls back to
582
+ // the runLoop caller's messages.user for tests that bypass the
583
+ // foist mechanism entirely.
584
+ const latestPromptRow = await this.#db.drain_get_latest_prompt_body_for_loop.get({ pattern: `prompt/${loopId}/%` });
585
+ const prompt = (latestPromptRow !== undefined && typeof latestPromptRow.content === "string" && latestPromptRow.content.length > 0)
586
+ ? latestPromptRow.content
587
+ : byRole("user");
404
588
  // Resolve persona cascade: loops.persona > runs.persona >
405
589
  // sessions.persona > caller-supplied default. SQL coalesces in one
406
590
  // query; null result means no DB override exists, use the default.
@@ -409,13 +593,6 @@ export default class Engine {
409
593
  const index = await this.#buildIndex(runId, loopId);
410
594
  const log = await this.#buildLog(runId);
411
595
  const telemetryErrors = await this.#buildTelemetryErrors(loopId, currentTurnSeq);
412
- // Rummy AgentLoop.js #buildContinuationPrompt: literally
413
- // `Turn ${turn}/${maxTurns}`. That's the whole string. The model
414
- // can read the action log to see what it already did; it does
415
- // not need editorial instructions from us about not repeating.
416
- const system_requirements = turnNumber > 1
417
- ? `Turn ${turnNumber}/${maxTurns}`
418
- : "";
419
596
  // Per-section render-cost subtotals via provider's tokenizer.
420
597
  // Engine approximates each section by tokenizing its serialized
421
598
  // form — wire-payload tokens may differ slightly because chat-
@@ -426,13 +603,13 @@ export default class Engine {
426
603
  provider.countTokens(JSON.stringify(index)) +
427
604
  provider.countTokens(JSON.stringify(log));
428
605
  // user.telemetry.budget — shimmed with section-aggregate table.
429
- // Real per-scheme breakdown is on AGENTS.md TODO (provider doesn't
430
- // yet expose getContextSize; user-side token count is below).
431
- const budget = this.#renderBudgetShim(systemTokens, provider, prompt, system_requirements, telemetryErrors);
606
+ // Real per-scheme breakdown is the tokenomics chapter (SPEC.md §14.2);
607
+ // depends on provider.getContextSize and write-time per-row tokens.
608
+ const budget = this.#renderBudgetShim(systemTokens, provider, prompt, telemetryErrors);
432
609
  const userTokens = provider.countTokens(prompt) +
433
- provider.countTokens(system_requirements) +
434
610
  provider.countTokens(JSON.stringify(telemetryErrors)) +
435
- provider.countTokens(budget);
611
+ provider.countTokens(budget) +
612
+ provider.countTokens(requirements);
436
613
  return {
437
614
  system: {
438
615
  tokens: systemTokens,
@@ -445,20 +622,19 @@ export default class Engine {
445
622
  tokens: userTokens,
446
623
  prompt,
447
624
  telemetry: { budget, errors: telemetryErrors },
448
- system_requirements,
625
+ system_requirements: requirements,
449
626
  },
450
627
  };
451
628
  }
452
- // user.telemetry.budget — SHIM. Per AGENTS.md §Open: real per-scheme
453
- // breakdown + context-window "free/percent-of-total" awaits provider
454
- // contract additions (getContextSize). Until then, render the section-
455
- // aggregate counts we already compute so the wire's `# Plurnk System
456
- // Budget` section is non-empty for picking-apart purposes.
457
- #renderBudgetShim(systemTokens, provider, prompt, system_requirements, telemetryErrors) {
629
+ // user.telemetry.budget — SHIM. Real per-scheme breakdown +
630
+ // context-window "free/percent-of-total" is SPEC.md §14.2; depends on
631
+ // provider.getContextSize and write-time per-row tokens. Until then,
632
+ // render the section-aggregate counts we already compute so the wire's
633
+ // `# Plurnk System Budget` section is non-empty for picking-apart purposes.
634
+ #renderBudgetShim(systemTokens, provider, prompt, telemetryErrors) {
458
635
  const userPromptTokens = provider.countTokens(prompt);
459
- const userReqTokens = provider.countTokens(system_requirements);
460
636
  const userErrTokens = provider.countTokens(JSON.stringify(telemetryErrors));
461
- const userTokens = userPromptTokens + userReqTokens + userErrTokens;
637
+ const userTokens = userPromptTokens + userErrTokens;
462
638
  const total = systemTokens + userTokens;
463
639
  const pct = (n) => total === 0 ? "0.0%" : `${((n / total) * 100).toFixed(1)}%`;
464
640
  return [
@@ -511,7 +687,7 @@ export default class Engine {
511
687
  op: r.op,
512
688
  target,
513
689
  status: r.status_rx,
514
- message: typeof parsedRx === "object" && parsedRx !== null && "error" in parsedRx
690
+ error: typeof parsedRx === "object" && parsedRx !== null && "error" in parsedRx
515
691
  ? parsedRx.error
516
692
  : typeof parsedRx === "string" ? parsedRx : "",
517
693
  };
@@ -549,19 +725,26 @@ export default class Engine {
549
725
  status: r.status_rx,
550
726
  rx: r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx,
551
727
  mimetype_rx: r.mimetype_rx,
728
+ tx: r.mimetype_tx === "application/json" ? JSON.parse(r.tx) : r.tx,
729
+ mimetype_tx: r.mimetype_tx,
552
730
  }));
553
731
  }
554
732
  async #buildIndex(runId, currentLoopId) {
555
733
  const rows = await this.#db.engine_render_index.all({ run_id: runId });
556
734
  const tagsStmt = this.#db.engine_entry_tags;
557
- // Foist the CURRENT loop's prompt entry out of the index render —
558
- // it's materialized in packet.user.prompt instead. Previous loops'
559
- // prompt entries stay in the index, addressable for the model to
560
- // HIDE if it wants.
561
- const foistedPathname = `prompt/${currentLoopId}`;
735
+ // Foist the CURRENT loop's prompt entries out of the index render —
736
+ // their bodies are materialized in packet.user.prompt instead. With
737
+ // multi-turn injection, a loop can have multiple prompt entries at
738
+ // plurnk://prompt/<loop_id>/<N>; all of them get foisted, leaving
739
+ // only previous loops' prompts (still addressable for HIDE).
740
+ const foistedPrefix = `prompt/${currentLoopId}/`;
741
+ // Backward compat: legacy single-slot path. Tests / older runs may
742
+ // still have a `prompt/<loop_id>` entry (no trailing /N); foist it too.
743
+ const foistedExact = `prompt/${currentLoopId}`;
562
744
  const entries = new Map();
563
745
  for (const row of rows) {
564
- if (row.scheme === "plurnk" && row.pathname === foistedPathname)
746
+ if (row.scheme === "plurnk"
747
+ && (row.pathname === foistedExact || row.pathname.startsWith(foistedPrefix)))
565
748
  continue;
566
749
  let entry = entries.get(row.entry_id);
567
750
  if (entry === undefined) {
@@ -611,7 +794,10 @@ export default class Engine {
611
794
  db: this.#db,
612
795
  sessionId, runId, loopId, turnId,
613
796
  writer: origin,
614
- signal: undefined,
797
+ signal: this.#loopAborts.get(loopId)?.signal,
798
+ streamEventNotify: this.#streamEventNotify,
799
+ wakeRunNotify: this.#wakeRunNotify,
800
+ mimetypes: this.#mimetypes,
615
801
  };
616
802
  let result;
617
803
  let denial = this.#checkWritable(statement, origin);
@@ -621,13 +807,13 @@ export default class Engine {
621
807
  result = denial;
622
808
  }
623
809
  else {
624
- // SCHEMES.md §7.1 / §8: action-entry-as-outcome. Scheme-handler
810
+ // SPEC §3.6 + plurnk-schemes#1: action-entry-as-outcome. Scheme-handler
625
811
  // exceptions become the action-entry's outcome (status 500), not a
626
812
  // thrown bubble. The log_entry is the durable record; engine never
627
813
  // skips it. Logging failures (#writeLog throws) are NOT caught —
628
814
  // those are system failures.
629
815
  try {
630
- if (statement.op === "SEND" && statement.path === null) {
816
+ if (statement.op === "SEND" && statement.target === null) {
631
817
  result = await this.#handleSendBroadcast(statement, loopId);
632
818
  }
633
819
  else if (statement.op === "COPY") {
@@ -636,8 +822,15 @@ export default class Engine {
636
822
  else if (statement.op === "MOVE") {
637
823
  result = await this.#handleMove(statement, schemeCtx);
638
824
  }
825
+ else if (statement.op === "EXEC") {
826
+ // EXEC's target slot is `cwd`, not a scheme address.
827
+ // Per plurnk.md the op routes unconditionally to the
828
+ // exec scheme; the scheme handler reads runtime
829
+ // (signal), cwd (target), and command (body).
830
+ result = await this.#run("exec", statement, schemeCtx);
831
+ }
639
832
  else {
640
- result = await this.#run(this.#schemeNameOf(statement.path), statement, schemeCtx);
833
+ result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
641
834
  }
642
835
  }
643
836
  catch (err) {
@@ -649,13 +842,13 @@ export default class Engine {
649
842
  }
650
843
  const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
651
844
  onDispatch?.(logEntryId);
652
- // Proposal lifecycle (task #42, AGENTS.md §Phase E). When a scheme
653
- // returns status 202, the entry is written as state='proposed';
845
+ // Proposal lifecycle (SPEC.md §0.5 + §13.5 loop.resolve). When a
846
+ // scheme returns status 202, the entry is written as state='proposed';
654
847
  // dispatch then PAUSES on a per-entry waiter until resolution
655
848
  // arrives via Engine.resolveProposal (from the loop/resolve RPC,
656
- // YOLO listener, or timeout — Phase E.2/E.3 work). The post-
657
- // resolution status replaces 202 in the result the caller sees,
658
- // so runTurn never branches on a pending state.
849
+ // YOLO listener, or timeout). The post-resolution status replaces
850
+ // 202 in the result the caller sees, so runTurn never branches on
851
+ // a pending state.
659
852
  if (result.status === 202) {
660
853
  // Register the resolution waiter SYNCHRONOUSLY before any await
661
854
  // yields. A same-tick resolveProposal() (e.g. from a test that
@@ -666,7 +859,7 @@ export default class Engine {
666
859
  // Notify external listeners (Daemon broadcasts loop/proposal;
667
860
  // YOLO listener auto-resolves) BEFORE awaiting — they may
668
861
  // resolve synchronously inside their handlers.
669
- const target = this.#extractTarget(statement.path);
862
+ const target = this.#extractTarget(statement.target);
670
863
  const flags = await this.#loadLoopFlags(loopId);
671
864
  const event = {
672
865
  logEntryId, sessionId, runId, loopId, turnId,
@@ -698,7 +891,10 @@ export default class Engine {
698
891
  const { sessionId, runId, loopId, turnId } = ids;
699
892
  if (resolution.decision !== "accept")
700
893
  return resolution;
701
- const schemeName = this.#schemeNameOf(statement.path);
894
+ // EXEC routes to the exec scheme regardless of target (cwd, not
895
+ // a scheme address). All other ops resolve their handler from
896
+ // statement.target's scheme.
897
+ const schemeName = statement.op === "EXEC" ? "exec" : this.#schemeNameOf(statement.target);
702
898
  if (schemeName === null)
703
899
  return resolution;
704
900
  const handler = this.#schemes.get(schemeName);
@@ -711,7 +907,9 @@ export default class Engine {
711
907
  // operation's artifact visible in the next packet's index.
712
908
  const applyCtx = {
713
909
  db: this.#db, sessionId, runId, loopId, turnId,
714
- writer: "model", signal: undefined,
910
+ writer: "model", signal: this.#loopAborts.get(loopId)?.signal,
911
+ streamEventNotify: this.#streamEventNotify,
912
+ wakeRunNotify: this.#wakeRunNotify,
715
913
  };
716
914
  const applyResult = await handler.applyResolution({
717
915
  attrs: (originalResult.attrs ?? {}),
@@ -724,6 +922,14 @@ export default class Engine {
724
922
  body: applyResult.body,
725
923
  };
726
924
  }
925
+ // Propagate applyResolution.outcome onto the accepted resolution
926
+ // so the log entry's outcome column reflects operational metadata
927
+ // (e.g. exec's "exit_N"). Without this, only failures get an
928
+ // outcome on the durable record, and "ran cleanly but with a
929
+ // notable detail" has nowhere to land.
930
+ if (applyResult.outcome !== undefined && resolution.outcome === undefined) {
931
+ return { ...resolution, outcome: applyResult.outcome };
932
+ }
727
933
  return resolution;
728
934
  }
729
935
  catch (err) {
@@ -754,6 +960,52 @@ export default class Engine {
754
960
  pendingProposalIds() {
755
961
  return [...this.#pendingProposals.keys()];
756
962
  }
963
+ // Used by wake-on-completion (daemon side): "is there any loop in this
964
+ // run still accepting turns?" If yes, skip the wake — the active loop
965
+ // will pick up the channel transition at its next turn boundary. If no,
966
+ // the daemon opens a fresh loop with the wake prompt.
967
+ async hasActiveLoopForRun(runId) {
968
+ const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
969
+ return (row?.n ?? 0) > 0;
970
+ }
971
+ // Inject a prompt into the run's currently-executing loop. Writes a
972
+ // plurnk://prompt/<loop_id>/<next-turn> entry whose body becomes
973
+ // packet.user.prompt at the next turn boundary. Last-wins: if two
974
+ // injects target the same next-turn slot, the second overwrites the
975
+ // first.
976
+ //
977
+ // Returns null when no loop in the run is currently active (status=102).
978
+ // The daemon-side inject path then enqueues a fresh loop with this
979
+ // prompt; engine doesn't open loops itself.
980
+ //
981
+ // Rummy parallel: AgentLoop.inject(). The "active drain → write
982
+ // prompt entry, return immediately" branch.
983
+ async inject(runId, prompt) {
984
+ const loopRow = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
985
+ if (loopRow === undefined)
986
+ return null;
987
+ const loopId = loopRow.id;
988
+ const turnRow = await this.#db.drain_next_turn_seq_for_loop.get({ loop_id: loopId });
989
+ const turnSeq = turnRow?.next ?? 1;
990
+ const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
991
+ if (sessionRow === undefined)
992
+ throw new Error(`Engine.inject: run ${runId} not found`);
993
+ const pathname = `prompt/${loopId}/${turnSeq}`;
994
+ const ctx = {
995
+ db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
996
+ turnId: 0, // no turn open at inject time; entries don't pin turnId
997
+ writer: "system",
998
+ signal: this.#loopAborts.get(loopId)?.signal,
999
+ streamEventNotify: this.#streamEventNotify,
1000
+ wakeRunNotify: this.#wakeRunNotify,
1001
+ };
1002
+ const entry = {
1003
+ channels: { body: { content: prompt, mimetype: "text/markdown" } },
1004
+ tags: [],
1005
+ };
1006
+ await writeEntry(pathname, entry, ctx, "plurnk");
1007
+ return { loopId, turnSeq };
1008
+ }
757
1009
  // Subscribe to proposal-pending events. Daemon registers a listener
758
1010
  // that broadcasts the loop/proposal WS notification; YOLO listener
759
1011
  // (Phase E.3) registers one that auto-resolves. Listeners fire BEFORE
@@ -824,8 +1076,8 @@ export default class Engine {
824
1076
  });
825
1077
  return { status, outcome, body: resolution.body };
826
1078
  }
827
- // SCHEMES.md §8 {§8-writable-by-enforcement}: engine rejects writes whose
828
- // origin is outside the target scheme's manifest.writableBy.
1079
+ // SPEC §3.6: engine rejects writes whose origin is outside the target
1080
+ // scheme's manifest.writableBy.
829
1081
  // - Read-side ops (READ, FIND, SHOW, HIDE) are not gated.
830
1082
  // - SEND broadcast (path=null) has no target scheme; not gated.
831
1083
  // - COPY: dst scheme writableBy applies.
@@ -834,15 +1086,20 @@ export default class Engine {
834
1086
  #checkWritable(statement, origin) {
835
1087
  if (!MUTATING_OPS.has(statement.op))
836
1088
  return null;
837
- if (statement.op === "SEND" && statement.path === null)
1089
+ if (statement.op === "SEND" && statement.target === null)
838
1090
  return null;
1091
+ // EXEC's target slot is `cwd`, not a scheme address. The op's
1092
+ // authority always belongs to the exec scheme regardless of cwd.
1093
+ if (statement.op === "EXEC") {
1094
+ return this.#denyIfDisallowed("exec", origin);
1095
+ }
839
1096
  if (statement.op === "COPY" || statement.op === "MOVE") {
840
1097
  const dstScheme = this.#schemeNameOf(statement.body);
841
1098
  const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
842
1099
  if (dstDenial !== null)
843
1100
  return dstDenial;
844
1101
  if (statement.op === "MOVE") {
845
- const srcScheme = this.#schemeNameOf(statement.path);
1102
+ const srcScheme = this.#schemeNameOf(statement.target);
846
1103
  if (srcScheme !== dstScheme) {
847
1104
  const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
848
1105
  if (srcDenial !== null)
@@ -851,7 +1108,7 @@ export default class Engine {
851
1108
  }
852
1109
  return null;
853
1110
  }
854
- const target = this.#schemeNameOf(statement.path);
1111
+ const target = this.#schemeNameOf(statement.target);
855
1112
  return this.#denyIfDisallowed(target, origin);
856
1113
  }
857
1114
  #denyIfDisallowed(schemeName, origin) {
@@ -874,15 +1131,15 @@ export default class Engine {
874
1131
  // set returns 403 — action-entry-as-outcome carries the rejection.
875
1132
  async #checkFlagsGate(statement, loopId) {
876
1133
  // Broadcast SEND has no scheme to gate.
877
- if (statement.op === "SEND" && statement.path === null)
1134
+ if (statement.op === "SEND" && statement.target === null)
878
1135
  return null;
879
1136
  const flags = await this.#loadLoopFlags(loopId);
880
1137
  // Fast path: default flags gate nothing. (yolo never gates.)
881
1138
  if (!flags.noProposals && !flags.noWeb && !flags.noInteraction && flags.mode === "act")
882
1139
  return null;
883
1140
  const active = this.#schemes.resolveForLoop(flags);
884
- const check = (path) => {
885
- const scheme = this.#schemeNameOf(path);
1141
+ const check = (target) => {
1142
+ const scheme = this.#schemeNameOf(target);
886
1143
  if (scheme === null)
887
1144
  return null;
888
1145
  if (active.has(scheme))
@@ -890,14 +1147,14 @@ export default class Engine {
890
1147
  return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
891
1148
  };
892
1149
  if (statement.op === "COPY" || statement.op === "MOVE") {
893
- return check(statement.path) ?? check(statement.body);
1150
+ return check(statement.target) ?? check(statement.body);
894
1151
  }
895
- return check(statement.path);
1152
+ return check(statement.target);
896
1153
  }
897
1154
  async #handleCopy(statement, ctx) {
898
1155
  if (statement.op !== "COPY")
899
1156
  throw new Error("unreachable");
900
- const srcPath = statement.path;
1157
+ const srcPath = statement.target;
901
1158
  const dstPath = statement.body;
902
1159
  if (srcPath === null)
903
1160
  return { status: 400, error: "COPY requires source path" };
@@ -908,7 +1165,7 @@ export default class Engine {
908
1165
  async #handleMove(statement, ctx) {
909
1166
  if (statement.op !== "MOVE")
910
1167
  throw new Error("unreachable");
911
- const srcPath = statement.path;
1168
+ const srcPath = statement.target;
912
1169
  const dstPath = statement.body;
913
1170
  if (srcPath === null)
914
1171
  return { status: 400, error: "MOVE requires source path" };
@@ -966,11 +1223,30 @@ export default class Engine {
966
1223
  return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` };
967
1224
  }
968
1225
  }
1226
+ // `<L>` source range slicing per SPEC.md §16.9 (symmetric with READ
1227
+ // `<L>` — source range, no line-number prefix).
1228
+ // Applied to every channel of the source entry. Binary channels return
1229
+ // 415 since line semantics don't apply.
1230
+ const lineMarker = statement.lineMarker ?? null;
1231
+ let channels = entry.channels;
1232
+ if (lineMarker !== null) {
1233
+ const sliced = {};
1234
+ for (const [channelName, channelData] of Object.entries(entry.channels)) {
1235
+ if (isBinaryMimetype(channelData.mimetype)) {
1236
+ return { status: 415, error: `cannot slice <L> on binary channel '${channelName}' (${channelData.mimetype})` };
1237
+ }
1238
+ const r = sliceLinesRaw(channelData.content ?? "", lineMarker);
1239
+ if (r.status !== 200)
1240
+ return { status: r.status, error: r.error };
1241
+ sliced[channelName] = { ...channelData, content: r.text ?? "" };
1242
+ }
1243
+ channels = sliced;
1244
+ }
969
1245
  // Tag resolution: signal = replace; absent/empty = carry from source
970
1246
  const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
971
1247
  ? statement.signal
972
1248
  : entry.tags;
973
- const writeResult = await dstHandler.writeEntry(dstPathname, { channels: entry.channels, tags }, ctx);
1249
+ const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
974
1250
  return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
975
1251
  }
976
1252
  async #handleSendBroadcast(statement, loopId) {
@@ -1007,7 +1283,7 @@ export default class Engine {
1007
1283
  return "file"; // local (bare) → file
1008
1284
  }
1009
1285
  async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
1010
- const target = this.#extractTarget(statement.path);
1286
+ const target = this.#extractTarget(statement.target);
1011
1287
  const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
1012
1288
  ? JSON.stringify(statement.lineMarker)
1013
1289
  : null;
@@ -1018,9 +1294,36 @@ export default class Engine {
1018
1294
  // consumes on accept. All other statuses are terminal — state =
1019
1295
  // 'resolved' for the common case.
1020
1296
  const isProposed = result.status === 202;
1021
- const attrs = (result.attrs !== undefined && result.attrs !== null)
1022
- ? JSON.stringify(result.attrs)
1023
- : "{}";
1297
+ let attrsObj = (result.attrs !== undefined && result.attrs !== null)
1298
+ ? { ...result.attrs }
1299
+ : {};
1300
+ // EXEC pathname is coordinate-based — the stream entry lives at
1301
+ // exec://<loop_seq>/<turn_seq>/<sequence>/EXEC, mirroring the log
1302
+ // row's own URI at log://<...>/EXEC. The model can correlate the
1303
+ // log meta to its stream output by structural parallelism — no
1304
+ // random slug injection. Coordinate is resolved here because both
1305
+ // log row write and applyResolution need it.
1306
+ if (statement.op === "EXEC") {
1307
+ const seqs = await this.#db.engine_loop_turn_seqs.get({
1308
+ loop_id: loopId, turn_id: turnId,
1309
+ });
1310
+ if (seqs === undefined)
1311
+ throw new Error(`Engine.#writeLog: loop_turn_seqs returned no row for loop=${loopId} turn=${turnId}`);
1312
+ const coordPathname = `${seqs.loop_seq}/${seqs.turn_seq}/${sequence}/EXEC`;
1313
+ target.scheme = "exec";
1314
+ target.pathname = coordPathname;
1315
+ attrsObj.pathname = coordPathname;
1316
+ // Mutate the in-memory result.attrs too: the dispatch path
1317
+ // hands originalResult.attrs to handler.applyResolution after
1318
+ // proposal accept (see #acceptResolution). Both views — the
1319
+ // stored row AND the in-memory proposal — need the same
1320
+ // coordinate-based pathname so applyResolution writes the
1321
+ // entry at exec://<coord>/EXEC.
1322
+ if (result.attrs !== undefined && result.attrs !== null) {
1323
+ result.attrs.pathname = coordPathname;
1324
+ }
1325
+ }
1326
+ const attrs = JSON.stringify(attrsObj);
1024
1327
  const row = await this.#db.engine_insert_log_entry.get({
1025
1328
  run_id: runId,
1026
1329
  loop_id: loopId,