@plurnk/plurnk-service 0.3.0 → 0.4.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 (92) hide show
  1. package/SPEC.md +35 -26
  2. package/dist/core/ChannelWrite.d.ts +10 -0
  3. package/dist/core/ChannelWrite.d.ts.map +1 -1
  4. package/dist/core/ChannelWrite.js.map +1 -1
  5. package/dist/core/Engine.d.ts +9 -1
  6. package/dist/core/Engine.d.ts.map +1 -1
  7. package/dist/core/Engine.js +260 -53
  8. package/dist/core/Engine.js.map +1 -1
  9. package/dist/core/line-marker.d.ts +23 -0
  10. package/dist/core/line-marker.d.ts.map +1 -0
  11. package/dist/core/line-marker.js +321 -0
  12. package/dist/core/line-marker.js.map +1 -0
  13. package/dist/core/matcher.d.ts +14 -0
  14. package/dist/core/matcher.d.ts.map +1 -0
  15. package/dist/core/matcher.js +195 -0
  16. package/dist/core/matcher.js.map +1 -0
  17. package/dist/core/mimetype-binary.d.ts +6 -0
  18. package/dist/core/mimetype-binary.d.ts.map +1 -0
  19. package/dist/core/mimetype-binary.js +82 -0
  20. package/dist/core/mimetype-binary.js.map +1 -0
  21. package/dist/core/packet-wire.d.ts.map +1 -1
  22. package/dist/core/packet-wire.js +97 -21
  23. package/dist/core/packet-wire.js.map +1 -1
  24. package/dist/core/path-mimetype.d.ts +3 -0
  25. package/dist/core/path-mimetype.d.ts.map +1 -0
  26. package/dist/core/path-mimetype.js +47 -0
  27. package/dist/core/path-mimetype.js.map +1 -0
  28. package/dist/core/scheme-types.d.ts +5 -0
  29. package/dist/core/scheme-types.d.ts.map +1 -1
  30. package/dist/core/scheme-types.js.map +1 -1
  31. package/dist/schemes/Exec.d.ts +30 -1
  32. package/dist/schemes/Exec.d.ts.map +1 -1
  33. package/dist/schemes/Exec.js +229 -6
  34. package/dist/schemes/Exec.js.map +1 -1
  35. package/dist/schemes/File.d.ts +4 -0
  36. package/dist/schemes/File.d.ts.map +1 -1
  37. package/dist/schemes/File.js +107 -23
  38. package/dist/schemes/File.js.map +1 -1
  39. package/dist/schemes/Log.d.ts +2 -0
  40. package/dist/schemes/Log.d.ts.map +1 -1
  41. package/dist/schemes/Log.js +82 -13
  42. package/dist/schemes/Log.js.map +1 -1
  43. package/dist/schemes/Plurnk.js +3 -3
  44. package/dist/schemes/Plurnk.js.map +1 -1
  45. package/dist/schemes/_entry-crud.d.ts +2 -0
  46. package/dist/schemes/_entry-crud.d.ts.map +1 -1
  47. package/dist/schemes/_entry-crud.js +1 -0
  48. package/dist/schemes/_entry-crud.js.map +1 -1
  49. package/dist/schemes/_entry-find.d.ts.map +1 -1
  50. package/dist/schemes/_entry-find.js +64 -15
  51. package/dist/schemes/_entry-find.js.map +1 -1
  52. package/dist/schemes/_entry-ops.d.ts +3 -0
  53. package/dist/schemes/_entry-ops.d.ts.map +1 -1
  54. package/dist/schemes/_entry-ops.js +268 -55
  55. package/dist/schemes/_entry-ops.js.map +1 -1
  56. package/dist/schemes/_entry-send.d.ts.map +1 -1
  57. package/dist/schemes/_entry-send.js +14 -7
  58. package/dist/schemes/_entry-send.js.map +1 -1
  59. package/dist/server/ClientConnection.d.ts +3 -2
  60. package/dist/server/ClientConnection.d.ts.map +1 -1
  61. package/dist/server/ClientConnection.js +4 -1
  62. package/dist/server/ClientConnection.js.map +1 -1
  63. package/dist/server/Daemon.d.ts +40 -1
  64. package/dist/server/Daemon.d.ts.map +1 -1
  65. package/dist/server/Daemon.js +319 -1
  66. package/dist/server/Daemon.js.map +1 -1
  67. package/dist/server/MethodRegistry.d.ts +29 -0
  68. package/dist/server/MethodRegistry.d.ts.map +1 -1
  69. package/dist/server/MethodRegistry.js.map +1 -1
  70. package/dist/server/dsl.d.ts +2 -2
  71. package/dist/server/dsl.d.ts.map +1 -1
  72. package/dist/server/dsl.js +10 -10
  73. package/dist/server/dsl.js.map +1 -1
  74. package/dist/server/methods/entry_read.js +5 -5
  75. package/dist/server/methods/entry_read.js.map +1 -1
  76. package/dist/server/methods/loop_cancel.d.ts +3 -0
  77. package/dist/server/methods/loop_cancel.d.ts.map +1 -0
  78. package/dist/server/methods/loop_cancel.js +27 -0
  79. package/dist/server/methods/loop_cancel.js.map +1 -0
  80. package/dist/server/methods/loop_run.d.ts.map +1 -1
  81. package/dist/server/methods/loop_run.js +46 -47
  82. package/dist/server/methods/loop_run.js.map +1 -1
  83. package/dist/server/methods/op_edit.js +3 -3
  84. package/dist/server/methods/op_edit.js.map +1 -1
  85. package/dist/server/methods/op_hide.js +3 -3
  86. package/dist/server/methods/op_hide.js.map +1 -1
  87. package/dist/server/methods/op_read.js +3 -3
  88. package/dist/server/methods/op_read.js.map +1 -1
  89. package/dist/server/methods/op_show.js +3 -3
  90. package/dist/server/methods/op_show.js.map +1 -1
  91. package/migrations/001_schema.sql +1 -1
  92. package/package.json +5 -4
@@ -1,6 +1,9 @@
1
1
  import { PlurnkParser } 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 } from "./line-marker.js";
6
+ import { isBinaryMimetype } from "./mimetype-binary.js";
4
7
  // Plain JS module shared with bin/digest.js so wire projection and
5
8
  // digest projection are structurally one function. tsconfig.build.json
6
9
  // has allowJs:true so this gets copied through to dist/.
@@ -67,17 +70,57 @@ const readPositiveInt = (envVar, fallback) => {
67
70
  return fallback;
68
71
  return n;
69
72
  };
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 '='.
73
+ // Per-op fingerprint: op verb + target URI, plus an op-specific discriminator
74
+ // where the activity isn't fully captured by target alone:
75
+ // - EDIT/COPY/MOVE: body excluded re-writing the same target with varied
76
+ // content IS cycling (the model is producing different versions of the
77
+ // same artifact instead of progressing).
78
+ // - FIND/READ/SHOW/HIDE: body IS the search/selection pattern; varied
79
+ // matchers on the same target ARE different activities (the model is
80
+ // exploring different queries, not repeating one).
74
81
  const fingerprintOp = (stmt) => {
75
- const path = stmt.path;
76
- if (path === null)
82
+ const path = stmt.target;
83
+ const matcherDiscriminator = () => {
84
+ // For matcher-bearing ops, the body's `raw` (matcher source) plus
85
+ // any lineMarker forms the activity discriminator.
86
+ const parts = [];
87
+ const body = stmt.body;
88
+ if (body !== null && typeof body === "object" && typeof body.raw === "string") {
89
+ parts.push(`body:${body.raw.slice(0, 64)}`);
90
+ }
91
+ const lm = stmt.lineMarker;
92
+ if (lm !== null && lm !== undefined)
93
+ parts.push(`L:${lm.first},${lm.last ?? ""}`);
94
+ return parts.length > 0 ? `|${parts.join("|")}` : "";
95
+ };
96
+ if (path === null) {
97
+ // Path-less ops need an activity-defining discriminator other
98
+ // than `target`. Picked per op so the cycle detector reflects
99
+ // intent rather than syntax:
100
+ // - EXEC: the command body IS the activity. Without a body
101
+ // digest, varied shell commands (find / ls / wc) collapse to
102
+ // one fingerprint and the detector mislabels exploration
103
+ // as a loop.
104
+ // - SEND: the status code (signal) IS the activity. Different
105
+ // SEND[X] are different intentions; same SEND[X] with
106
+ // different message bodies is the same termination signal.
107
+ if (stmt.op === "EXEC") {
108
+ const body = typeof stmt.body === "string" ? stmt.body : "";
109
+ return `EXEC|(no-path)${body.length > 0 ? `|body:${body.slice(0, 64)}` : ""}`;
110
+ }
111
+ if (stmt.op === "SEND") {
112
+ const signal = typeof stmt.signal === "number" ? stmt.signal : "";
113
+ return `SEND|(no-path)|signal:${signal}`;
114
+ }
77
115
  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}`;
116
+ }
117
+ const base = path.kind === "url"
118
+ ? `${stmt.op}|${path.scheme}://${path.pathname}`
119
+ : `${stmt.op}|local:${path.raw}`;
120
+ if (stmt.op === "FIND" || stmt.op === "READ" || stmt.op === "SHOW" || stmt.op === "HIDE") {
121
+ return `${base}${matcherDiscriminator()}`;
122
+ }
123
+ return base;
81
124
  };
82
125
  // Per-turn fingerprint: sorted set of per-op fingerprints, joined. Order
83
126
  // within a turn doesn't matter — we want the SET of activities.
@@ -135,9 +178,19 @@ export default class Engine {
135
178
  // event emitter — no priority, no veto chain at this layer; filter
136
179
  // chains come later if a real consumer needs them.
137
180
  #proposalPendingListeners = [];
138
- constructor({ db, schemes, mimetypes }) {
181
+ // Per-loop AbortController for cancellation propagation into scheme
182
+ // ctx.signal. runLoop creates one at entry, cleans up at end. Engine
183
+ // cancellation paths (strikes, max_turns, external) abort it.
184
+ // Streaming schemes (exec) chain their per-spawn controllers off
185
+ // ctx.signal so cancelled loops tear down their background spawns.
186
+ #loopAborts = new Map();
187
+ #streamEventNotify;
188
+ #wakeRunNotify;
189
+ constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify }) {
139
190
  this.#db = db;
140
191
  this.#schemes = schemes;
192
+ this.#streamEventNotify = streamEventNotify;
193
+ this.#wakeRunNotify = wakeRunNotify;
141
194
  // Default to empty discovery — standalone Engine construction (in
142
195
  // tests) gets no handlers, and content flows through the framework's
143
196
  // raw-content fitContent fallback. Daemon-managed Engine receives a
@@ -164,7 +217,29 @@ export default class Engine {
164
217
  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, }) {
165
218
  const turnIds = [];
166
219
  const suddenDeathThreshold = maxTurns - maxStrikes;
167
- const cleanup = () => {
220
+ // Per-loop AbortController for scheme-side cancellation propagation.
221
+ // Chained from the caller's `signal` so an external abort cascades.
222
+ const loopAbort = new AbortController();
223
+ if (signal !== undefined) {
224
+ if (signal.aborted)
225
+ loopAbort.abort(signal.reason);
226
+ else
227
+ signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
228
+ }
229
+ this.#loopAborts.set(loopId, loopAbort);
230
+ // Cleanup splits by termination kind:
231
+ // - "graceful" (loop emitted SEND[2xx]): in-flight streaming-scheme
232
+ // spawns are ALLOWED to outlive the loop. They complete naturally,
233
+ // write final channel state, and wake-on-completion (E.4) opens a
234
+ // fresh loop in the same run if the model needs to react.
235
+ // - "forceful" (max_turns, strike_threshold, external cancel,
236
+ // non-2xx loop status): fire the loop-level abort so spawns
237
+ // tear down immediately.
238
+ const cleanup = (kind, reason) => {
239
+ if (kind === "forceful" && !loopAbort.signal.aborted) {
240
+ loopAbort.abort(reason ?? "loop_forceful_termination");
241
+ }
242
+ this.#loopAborts.delete(loopId);
168
243
  this.#strikeState.delete(loopId);
169
244
  this.#telemetryBuffer.delete(loopId);
170
245
  };
@@ -174,12 +249,15 @@ export default class Engine {
174
249
  if (row === undefined)
175
250
  throw new Error(`Engine.runLoop: loop ${loopId} not found`);
176
251
  if (row.status !== 102) {
177
- cleanup();
252
+ // Status 2xx = graceful (model said done); 4xx/5xx = forceful
253
+ // (external cancel or upstream failure). The threshold splits
254
+ // at 400 to match HTTP success/error semantics.
255
+ cleanup(row.status < 400 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
178
256
  return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
179
257
  }
180
258
  if (turnIds.length >= maxTurns) {
181
259
  await this.#db.engine_loop_cancel.run({ loop_id: loopId });
182
- cleanup();
260
+ cleanup("forceful", "max_turns");
183
261
  return { turnIds, finalStatus: 499, hitMaxTurns: true, reason: "max_turns" };
184
262
  }
185
263
  const turn = await this.runTurn({
@@ -199,7 +277,7 @@ export default class Engine {
199
277
  kind: "cycle",
200
278
  period: cycle.period,
201
279
  cycles: cycle.cycles,
202
- message: `repeating pattern detected: ${cycle.cycles}× period-${cycle.period}; vary your approach`,
280
+ message: "Loop detected",
203
281
  });
204
282
  }
205
283
  this.#strikeState.set(loopId, state);
@@ -214,15 +292,17 @@ export default class Engine {
214
292
  const struck = noOps || recordedFailed || state.turnErrors > 0;
215
293
  if (struck) {
216
294
  state.streak++;
295
+ const reason = noOps ? "no_ops" : recordedFailed ? "recorded_failure" : "rail";
217
296
  this.#pushTelemetry(loopId, {
218
297
  kind: "strike",
219
298
  streak: state.streak,
220
299
  maxStrikes,
221
- reason: noOps ? "no_ops" : recordedFailed ? "recorded_failure" : "rail",
300
+ reason,
301
+ message: `strike ${state.streak}/${maxStrikes} (${reason}); ${maxStrikes - state.streak} before abandonment`,
222
302
  });
223
303
  if (state.streak >= maxStrikes) {
224
304
  await this.#db.engine_loop_cancel.run({ loop_id: loopId });
225
- cleanup();
305
+ cleanup("forceful", "strike_threshold");
226
306
  return { turnIds, finalStatus: 499, hitMaxTurns: false, reason: "strike_threshold" };
227
307
  }
228
308
  }
@@ -238,7 +318,7 @@ export default class Engine {
238
318
  if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
239
319
  this.#pushTelemetry(loopId, {
240
320
  kind: "sudden_death",
241
- message: `approaching max turns: ${turnIds.length} of ${maxTurns}; emit SEND[200] to complete`,
321
+ message: `${turnIds.length} of ${maxTurns}`,
242
322
  remaining: maxTurns - turnIds.length,
243
323
  });
244
324
  }
@@ -263,26 +343,30 @@ export default class Engine {
263
343
  if (openRow === undefined)
264
344
  throw new Error("Engine.runTurn: turn open returned no row");
265
345
  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).
346
+ // Pre-model writes. Each turn opens with a system-origin EDIT
347
+ // against `plurnk://prompt/<loop_id>/<seq>` IF there's a prompt
348
+ // for THIS turn the model hasn't seen yet:
349
+ // - Turn 1: loop.prompt is the initial user prompt.
350
+ // - Turn N>1: only if Engine.inject (or wake-on-completion via
351
+ // daemon.inject) wrote a prompt entry for this turn slot
352
+ // between turn N-1 and N. Inject writes directly to entries;
353
+ // we DON'T re-foist here for N>1.
354
+ // The log records the EDIT for forensics. Model ops dispatch
355
+ // from sequence=2 onward on prompt-foisted turns; 1 onward
356
+ // otherwise.
273
357
  let nextActionIndex = 1;
274
358
  if (seq === 1) {
275
359
  const promptRow = await this.#db.engine_get_loop_prompt.get({ loop_id: loopId });
276
360
  if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
277
361
  const promptPath = {
278
- kind: "url", raw: `plurnk://prompt/${loopId}`,
362
+ kind: "url", raw: `plurnk://prompt/${loopId}/${seq}`,
279
363
  scheme: "plurnk", username: null, password: null,
280
364
  hostname: null, port: null,
281
- pathname: `prompt/${loopId}`, params: {}, fragment: null,
365
+ pathname: `prompt/${loopId}/${seq}`, params: {}, fragment: null,
282
366
  };
283
367
  const promptStmt = {
284
368
  op: "EDIT", suffix: "", signal: null,
285
- path: promptPath, lineMarker: null,
369
+ target: promptPath, lineMarker: null,
286
370
  body: promptRow.prompt, position: { line: 1, column: 1 },
287
371
  };
288
372
  await this.dispatch({
@@ -305,7 +389,15 @@ export default class Engine {
305
389
  // parsed ops) → packet.assistant per Packet.json §assistant;
306
390
  // call-metadata (usage, finishReason, model) → Turn columns per
307
391
  // Turn.json. Mixing the two on packet.assistant was the wrong layer.
308
- const { packetAssistant, callMetadata } = this.#splitResponse(response);
392
+ const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response);
393
+ // Surface parse errors to the model's NEXT packet so it can self-
394
+ // correct. Without this, malformed emissions (e.g. a READ matcher
395
+ // body starting with `//` being interpreted as xpath) silently
396
+ // drop, the model sees zero ops dispatched, strike-rail fires,
397
+ // model has no feedback on WHY its emission didn't take effect.
398
+ for (const message of parseErrors ?? []) {
399
+ this.#pushTelemetry(loopId, { kind: "parse_error", message });
400
+ }
309
401
  const opsCount = packetAssistant.ops.length;
310
402
  const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number");
311
403
  // Rail #41 (revised): the per-turn requirement is "emit at least one
@@ -366,6 +458,7 @@ export default class Engine {
366
458
  const preParsedOps = assistant.ops;
367
459
  const ops = [];
368
460
  const textFragments = [];
461
+ const parseErrors = [];
369
462
  if (preParsedOps !== undefined) {
370
463
  ops.push(...preParsedOps);
371
464
  }
@@ -379,6 +472,11 @@ export default class Engine {
379
472
  if (trimmed.length > 0)
380
473
  textFragments.push(trimmed);
381
474
  }
475
+ else if (item.kind === "error") {
476
+ const err = item.error;
477
+ const msg = (err && typeof err.message === "string") ? err.message : "parse error";
478
+ parseErrors.push(msg);
479
+ }
382
480
  }
383
481
  }
384
482
  const wireReasoning = assistant.reasoning ?? "";
@@ -388,6 +486,7 @@ export default class Engine {
388
486
  return {
389
487
  packetAssistant: { content: assistant.content, ops, reasoning },
390
488
  callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
489
+ parseErrors,
391
490
  };
392
491
  }
393
492
  // Assemble the request half of the spec'd packet (Packet.json §system
@@ -400,7 +499,15 @@ export default class Engine {
400
499
  async #buildRequestPacket({ initialMessages, persona: defaultPersona, runId, loopId, turnNumber, currentTurnSeq, maxTurns, provider, }) {
401
500
  const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
402
501
  const system_definition = byRole("system");
403
- const prompt = byRole("user");
502
+ // user.prompt sources from the loop's most recent prompt entry first
503
+ // (plurnk://prompt/<loop_id>/<N> for the highest N written to date).
504
+ // This is what inject + the turn-1 foist write into. Falls back to
505
+ // the runLoop caller's messages.user for tests that bypass the
506
+ // foist mechanism entirely.
507
+ const latestPromptRow = await this.#db.drain_get_latest_prompt_body_for_loop.get({ pattern: `prompt/${loopId}/%` });
508
+ const prompt = (latestPromptRow !== undefined && typeof latestPromptRow.content === "string" && latestPromptRow.content.length > 0)
509
+ ? latestPromptRow.content
510
+ : byRole("user");
404
511
  // Resolve persona cascade: loops.persona > runs.persona >
405
512
  // sessions.persona > caller-supplied default. SQL coalesces in one
406
513
  // query; null result means no DB override exists, use the default.
@@ -549,19 +656,26 @@ export default class Engine {
549
656
  status: r.status_rx,
550
657
  rx: r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx,
551
658
  mimetype_rx: r.mimetype_rx,
659
+ tx: r.mimetype_tx === "application/json" ? JSON.parse(r.tx) : r.tx,
660
+ mimetype_tx: r.mimetype_tx,
552
661
  }));
553
662
  }
554
663
  async #buildIndex(runId, currentLoopId) {
555
664
  const rows = await this.#db.engine_render_index.all({ run_id: runId });
556
665
  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}`;
666
+ // Foist the CURRENT loop's prompt entries out of the index render —
667
+ // their bodies are materialized in packet.user.prompt instead. With
668
+ // multi-turn injection, a loop can have multiple prompt entries at
669
+ // plurnk://prompt/<loop_id>/<N>; all of them get foisted, leaving
670
+ // only previous loops' prompts (still addressable for HIDE).
671
+ const foistedPrefix = `prompt/${currentLoopId}/`;
672
+ // Backward compat: legacy single-slot path. Tests / older runs may
673
+ // still have a `prompt/<loop_id>` entry (no trailing /N); foist it too.
674
+ const foistedExact = `prompt/${currentLoopId}`;
562
675
  const entries = new Map();
563
676
  for (const row of rows) {
564
- if (row.scheme === "plurnk" && row.pathname === foistedPathname)
677
+ if (row.scheme === "plurnk"
678
+ && (row.pathname === foistedExact || row.pathname.startsWith(foistedPrefix)))
565
679
  continue;
566
680
  let entry = entries.get(row.entry_id);
567
681
  if (entry === undefined) {
@@ -611,7 +725,10 @@ export default class Engine {
611
725
  db: this.#db,
612
726
  sessionId, runId, loopId, turnId,
613
727
  writer: origin,
614
- signal: undefined,
728
+ signal: this.#loopAborts.get(loopId)?.signal,
729
+ streamEventNotify: this.#streamEventNotify,
730
+ wakeRunNotify: this.#wakeRunNotify,
731
+ mimetypes: this.#mimetypes,
615
732
  };
616
733
  let result;
617
734
  let denial = this.#checkWritable(statement, origin);
@@ -627,7 +744,7 @@ export default class Engine {
627
744
  // skips it. Logging failures (#writeLog throws) are NOT caught —
628
745
  // those are system failures.
629
746
  try {
630
- if (statement.op === "SEND" && statement.path === null) {
747
+ if (statement.op === "SEND" && statement.target === null) {
631
748
  result = await this.#handleSendBroadcast(statement, loopId);
632
749
  }
633
750
  else if (statement.op === "COPY") {
@@ -636,8 +753,15 @@ export default class Engine {
636
753
  else if (statement.op === "MOVE") {
637
754
  result = await this.#handleMove(statement, schemeCtx);
638
755
  }
756
+ else if (statement.op === "EXEC") {
757
+ // EXEC's target slot is `cwd`, not a scheme address.
758
+ // Per plurnk.md the op routes unconditionally to the
759
+ // exec scheme; the scheme handler reads runtime
760
+ // (signal), cwd (target), and command (body).
761
+ result = await this.#run("exec", statement, schemeCtx);
762
+ }
639
763
  else {
640
- result = await this.#run(this.#schemeNameOf(statement.path), statement, schemeCtx);
764
+ result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
641
765
  }
642
766
  }
643
767
  catch (err) {
@@ -666,7 +790,7 @@ export default class Engine {
666
790
  // Notify external listeners (Daemon broadcasts loop/proposal;
667
791
  // YOLO listener auto-resolves) BEFORE awaiting — they may
668
792
  // resolve synchronously inside their handlers.
669
- const target = this.#extractTarget(statement.path);
793
+ const target = this.#extractTarget(statement.target);
670
794
  const flags = await this.#loadLoopFlags(loopId);
671
795
  const event = {
672
796
  logEntryId, sessionId, runId, loopId, turnId,
@@ -698,7 +822,10 @@ export default class Engine {
698
822
  const { sessionId, runId, loopId, turnId } = ids;
699
823
  if (resolution.decision !== "accept")
700
824
  return resolution;
701
- const schemeName = this.#schemeNameOf(statement.path);
825
+ // EXEC routes to the exec scheme regardless of target (cwd, not
826
+ // a scheme address). All other ops resolve their handler from
827
+ // statement.target's scheme.
828
+ const schemeName = statement.op === "EXEC" ? "exec" : this.#schemeNameOf(statement.target);
702
829
  if (schemeName === null)
703
830
  return resolution;
704
831
  const handler = this.#schemes.get(schemeName);
@@ -711,7 +838,9 @@ export default class Engine {
711
838
  // operation's artifact visible in the next packet's index.
712
839
  const applyCtx = {
713
840
  db: this.#db, sessionId, runId, loopId, turnId,
714
- writer: "model", signal: undefined,
841
+ writer: "model", signal: this.#loopAborts.get(loopId)?.signal,
842
+ streamEventNotify: this.#streamEventNotify,
843
+ wakeRunNotify: this.#wakeRunNotify,
715
844
  };
716
845
  const applyResult = await handler.applyResolution({
717
846
  attrs: (originalResult.attrs ?? {}),
@@ -724,6 +853,14 @@ export default class Engine {
724
853
  body: applyResult.body,
725
854
  };
726
855
  }
856
+ // Propagate applyResolution.outcome onto the accepted resolution
857
+ // so the log entry's outcome column reflects operational metadata
858
+ // (e.g. exec's "exit_N"). Without this, only failures get an
859
+ // outcome on the durable record, and "ran cleanly but with a
860
+ // notable detail" has nowhere to land.
861
+ if (applyResult.outcome !== undefined && resolution.outcome === undefined) {
862
+ return { ...resolution, outcome: applyResult.outcome };
863
+ }
727
864
  return resolution;
728
865
  }
729
866
  catch (err) {
@@ -754,6 +891,52 @@ export default class Engine {
754
891
  pendingProposalIds() {
755
892
  return [...this.#pendingProposals.keys()];
756
893
  }
894
+ // Used by wake-on-completion (daemon side): "is there any loop in this
895
+ // run still accepting turns?" If yes, skip the wake — the active loop
896
+ // will pick up the channel transition at its next turn boundary. If no,
897
+ // the daemon opens a fresh loop with the wake prompt.
898
+ async hasActiveLoopForRun(runId) {
899
+ const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
900
+ return (row?.n ?? 0) > 0;
901
+ }
902
+ // Inject a prompt into the run's currently-executing loop. Writes a
903
+ // plurnk://prompt/<loop_id>/<next-turn> entry whose body becomes
904
+ // packet.user.prompt at the next turn boundary. Last-wins: if two
905
+ // injects target the same next-turn slot, the second overwrites the
906
+ // first.
907
+ //
908
+ // Returns null when no loop in the run is currently active (status=102).
909
+ // The daemon-side inject path then enqueues a fresh loop with this
910
+ // prompt; engine doesn't open loops itself.
911
+ //
912
+ // Rummy parallel: AgentLoop.inject(). The "active drain → write
913
+ // prompt entry, return immediately" branch.
914
+ async inject(runId, prompt) {
915
+ const loopRow = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
916
+ if (loopRow === undefined)
917
+ return null;
918
+ const loopId = loopRow.id;
919
+ const turnRow = await this.#db.drain_next_turn_seq_for_loop.get({ loop_id: loopId });
920
+ const turnSeq = turnRow?.next ?? 1;
921
+ const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
922
+ if (sessionRow === undefined)
923
+ throw new Error(`Engine.inject: run ${runId} not found`);
924
+ const pathname = `prompt/${loopId}/${turnSeq}`;
925
+ const ctx = {
926
+ db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
927
+ turnId: 0, // no turn open at inject time; entries don't pin turnId
928
+ writer: "system",
929
+ signal: this.#loopAborts.get(loopId)?.signal,
930
+ streamEventNotify: this.#streamEventNotify,
931
+ wakeRunNotify: this.#wakeRunNotify,
932
+ };
933
+ const entry = {
934
+ channels: { body: { content: prompt, mimetype: "text/markdown" } },
935
+ tags: [],
936
+ };
937
+ await writeEntry(pathname, entry, ctx, "plurnk");
938
+ return { loopId, turnSeq };
939
+ }
757
940
  // Subscribe to proposal-pending events. Daemon registers a listener
758
941
  // that broadcasts the loop/proposal WS notification; YOLO listener
759
942
  // (Phase E.3) registers one that auto-resolves. Listeners fire BEFORE
@@ -834,15 +1017,20 @@ export default class Engine {
834
1017
  #checkWritable(statement, origin) {
835
1018
  if (!MUTATING_OPS.has(statement.op))
836
1019
  return null;
837
- if (statement.op === "SEND" && statement.path === null)
1020
+ if (statement.op === "SEND" && statement.target === null)
838
1021
  return null;
1022
+ // EXEC's target slot is `cwd`, not a scheme address. The op's
1023
+ // authority always belongs to the exec scheme regardless of cwd.
1024
+ if (statement.op === "EXEC") {
1025
+ return this.#denyIfDisallowed("exec", origin);
1026
+ }
839
1027
  if (statement.op === "COPY" || statement.op === "MOVE") {
840
1028
  const dstScheme = this.#schemeNameOf(statement.body);
841
1029
  const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
842
1030
  if (dstDenial !== null)
843
1031
  return dstDenial;
844
1032
  if (statement.op === "MOVE") {
845
- const srcScheme = this.#schemeNameOf(statement.path);
1033
+ const srcScheme = this.#schemeNameOf(statement.target);
846
1034
  if (srcScheme !== dstScheme) {
847
1035
  const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
848
1036
  if (srcDenial !== null)
@@ -851,7 +1039,7 @@ export default class Engine {
851
1039
  }
852
1040
  return null;
853
1041
  }
854
- const target = this.#schemeNameOf(statement.path);
1042
+ const target = this.#schemeNameOf(statement.target);
855
1043
  return this.#denyIfDisallowed(target, origin);
856
1044
  }
857
1045
  #denyIfDisallowed(schemeName, origin) {
@@ -874,15 +1062,15 @@ export default class Engine {
874
1062
  // set returns 403 — action-entry-as-outcome carries the rejection.
875
1063
  async #checkFlagsGate(statement, loopId) {
876
1064
  // Broadcast SEND has no scheme to gate.
877
- if (statement.op === "SEND" && statement.path === null)
1065
+ if (statement.op === "SEND" && statement.target === null)
878
1066
  return null;
879
1067
  const flags = await this.#loadLoopFlags(loopId);
880
1068
  // Fast path: default flags gate nothing. (yolo never gates.)
881
1069
  if (!flags.noProposals && !flags.noWeb && !flags.noInteraction && flags.mode === "act")
882
1070
  return null;
883
1071
  const active = this.#schemes.resolveForLoop(flags);
884
- const check = (path) => {
885
- const scheme = this.#schemeNameOf(path);
1072
+ const check = (target) => {
1073
+ const scheme = this.#schemeNameOf(target);
886
1074
  if (scheme === null)
887
1075
  return null;
888
1076
  if (active.has(scheme))
@@ -890,14 +1078,14 @@ export default class Engine {
890
1078
  return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
891
1079
  };
892
1080
  if (statement.op === "COPY" || statement.op === "MOVE") {
893
- return check(statement.path) ?? check(statement.body);
1081
+ return check(statement.target) ?? check(statement.body);
894
1082
  }
895
- return check(statement.path);
1083
+ return check(statement.target);
896
1084
  }
897
1085
  async #handleCopy(statement, ctx) {
898
1086
  if (statement.op !== "COPY")
899
1087
  throw new Error("unreachable");
900
- const srcPath = statement.path;
1088
+ const srcPath = statement.target;
901
1089
  const dstPath = statement.body;
902
1090
  if (srcPath === null)
903
1091
  return { status: 400, error: "COPY requires source path" };
@@ -908,7 +1096,7 @@ export default class Engine {
908
1096
  async #handleMove(statement, ctx) {
909
1097
  if (statement.op !== "MOVE")
910
1098
  throw new Error("unreachable");
911
- const srcPath = statement.path;
1099
+ const srcPath = statement.target;
912
1100
  const dstPath = statement.body;
913
1101
  if (srcPath === null)
914
1102
  return { status: 400, error: "MOVE requires source path" };
@@ -966,11 +1154,30 @@ export default class Engine {
966
1154
  return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` };
967
1155
  }
968
1156
  }
1157
+ // `<L>` source range slicing per AGENTS.md "Resolved ambiguities" §4
1158
+ // (symmetric with READ `<L>` — source range, no line-number prefix).
1159
+ // Applied to every channel of the source entry. Binary channels return
1160
+ // 415 since line semantics don't apply.
1161
+ const lineMarker = statement.lineMarker ?? null;
1162
+ let channels = entry.channels;
1163
+ if (lineMarker !== null) {
1164
+ const sliced = {};
1165
+ for (const [channelName, channelData] of Object.entries(entry.channels)) {
1166
+ if (isBinaryMimetype(channelData.mimetype)) {
1167
+ return { status: 415, error: `cannot slice <L> on binary channel '${channelName}' (${channelData.mimetype})` };
1168
+ }
1169
+ const r = sliceLinesRaw(channelData.content ?? "", lineMarker);
1170
+ if (r.status !== 200)
1171
+ return { status: r.status, error: r.error };
1172
+ sliced[channelName] = { ...channelData, content: r.text ?? "" };
1173
+ }
1174
+ channels = sliced;
1175
+ }
969
1176
  // Tag resolution: signal = replace; absent/empty = carry from source
970
1177
  const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
971
1178
  ? statement.signal
972
1179
  : entry.tags;
973
- const writeResult = await dstHandler.writeEntry(dstPathname, { channels: entry.channels, tags }, ctx);
1180
+ const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
974
1181
  return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
975
1182
  }
976
1183
  async #handleSendBroadcast(statement, loopId) {
@@ -1007,7 +1214,7 @@ export default class Engine {
1007
1214
  return "file"; // local (bare) → file
1008
1215
  }
1009
1216
  async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
1010
- const target = this.#extractTarget(statement.path);
1217
+ const target = this.#extractTarget(statement.target);
1011
1218
  const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
1012
1219
  ? JSON.stringify(statement.lineMarker)
1013
1220
  : null;