@possumtech/rummy 2.0.1 → 2.1.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 (114) hide show
  1. package/.env.example +12 -7
  2. package/BENCH_ENVIRONMENT.md +230 -0
  3. package/CLIENT_INTERFACE.md +396 -0
  4. package/PLUGINS.md +93 -1
  5. package/SPEC.md +305 -28
  6. package/bin/postinstall.js +2 -2
  7. package/bin/rummy.js +2 -2
  8. package/last_run.txt +5617 -0
  9. package/migrations/001_initial_schema.sql +2 -1
  10. package/package.json +6 -2
  11. package/scriptify/cache_probe.js +66 -0
  12. package/scriptify/cache_probe_grok.js +74 -0
  13. package/service.js +22 -11
  14. package/src/agent/AgentLoop.js +33 -139
  15. package/src/agent/ContextAssembler.js +2 -9
  16. package/src/agent/Entries.js +36 -101
  17. package/src/agent/ProjectAgent.js +2 -9
  18. package/src/agent/TurnExecutor.js +45 -83
  19. package/src/agent/XmlParser.js +247 -273
  20. package/src/agent/budget.js +5 -28
  21. package/src/agent/config.js +38 -0
  22. package/src/agent/errors.js +7 -13
  23. package/src/agent/httpStatus.js +1 -19
  24. package/src/agent/known_store.sql +7 -2
  25. package/src/agent/materializeContext.js +12 -17
  26. package/src/agent/pathEncode.js +5 -0
  27. package/src/agent/rummyHome.js +9 -0
  28. package/src/agent/runs.sql +18 -0
  29. package/src/agent/tokens.js +2 -8
  30. package/src/hooks/HookRegistry.js +1 -16
  31. package/src/hooks/Hooks.js +8 -33
  32. package/src/hooks/PluginContext.js +3 -21
  33. package/src/hooks/RpcRegistry.js +1 -4
  34. package/src/hooks/RummyContext.js +2 -16
  35. package/src/hooks/ToolRegistry.js +5 -15
  36. package/src/llm/LlmProvider.js +28 -23
  37. package/src/llm/errors.js +41 -4
  38. package/src/llm/openaiStream.js +125 -0
  39. package/src/llm/retry.js +61 -15
  40. package/src/plugins/budget/budget.js +14 -81
  41. package/src/plugins/cli/README.md +87 -0
  42. package/src/plugins/cli/bin.js +61 -0
  43. package/src/plugins/cli/cli.js +120 -0
  44. package/src/plugins/env/README.md +2 -1
  45. package/src/plugins/env/env.js +4 -6
  46. package/src/plugins/env/envDoc.md +2 -2
  47. package/src/plugins/error/error.js +23 -23
  48. package/src/plugins/file/file.js +2 -22
  49. package/src/plugins/get/get.js +12 -34
  50. package/src/plugins/get/getDoc.md +5 -3
  51. package/src/plugins/hedberg/edits.js +1 -11
  52. package/src/plugins/hedberg/hedberg.js +3 -26
  53. package/src/plugins/hedberg/normalize.js +1 -5
  54. package/src/plugins/hedberg/patterns.js +4 -15
  55. package/src/plugins/hedberg/sed.js +1 -7
  56. package/src/plugins/helpers.js +28 -20
  57. package/src/plugins/index.js +25 -41
  58. package/src/plugins/instructions/README.md +18 -0
  59. package/src/plugins/instructions/instructions.js +13 -76
  60. package/src/plugins/instructions/instructions.md +19 -18
  61. package/src/plugins/instructions/instructions_104.md +5 -4
  62. package/src/plugins/instructions/instructions_105.md +16 -15
  63. package/src/plugins/instructions/instructions_106.md +15 -14
  64. package/src/plugins/instructions/instructions_107.md +13 -6
  65. package/src/plugins/known/README.md +26 -6
  66. package/src/plugins/known/known.js +36 -34
  67. package/src/plugins/log/README.md +2 -2
  68. package/src/plugins/log/log.js +6 -33
  69. package/src/plugins/ollama/ollama.js +50 -66
  70. package/src/plugins/openai/openai.js +26 -44
  71. package/src/plugins/openrouter/openrouter.js +28 -52
  72. package/src/plugins/policy/README.md +8 -2
  73. package/src/plugins/policy/policy.js +8 -21
  74. package/src/plugins/prompt/README.md +22 -0
  75. package/src/plugins/prompt/prompt.js +8 -16
  76. package/src/plugins/rm/rm.js +5 -2
  77. package/src/plugins/rm/rmDoc.md +4 -4
  78. package/src/plugins/rpc/README.md +2 -1
  79. package/src/plugins/rpc/rpc.js +51 -47
  80. package/src/plugins/set/README.md +5 -1
  81. package/src/plugins/set/set.js +23 -33
  82. package/src/plugins/set/setDoc.md +1 -1
  83. package/src/plugins/sh/README.md +2 -1
  84. package/src/plugins/sh/sh.js +5 -11
  85. package/src/plugins/sh/shDoc.md +2 -2
  86. package/src/plugins/stream/README.md +6 -5
  87. package/src/plugins/stream/stream.js +6 -35
  88. package/src/plugins/telemetry/telemetry.js +26 -19
  89. package/src/plugins/think/think.js +4 -7
  90. package/src/plugins/unknown/unknown.js +8 -13
  91. package/src/plugins/update/update.js +36 -35
  92. package/src/plugins/update/updateDoc.md +3 -3
  93. package/src/plugins/xai/xai.js +30 -20
  94. package/src/plugins/yolo/yolo.js +8 -41
  95. package/src/server/ClientConnection.js +17 -47
  96. package/src/server/SocketServer.js +14 -14
  97. package/src/server/protocol.js +1 -10
  98. package/src/sql/functions/slugify.js +5 -7
  99. package/src/sql/v_model_context.sql +4 -11
  100. package/turns/cli_1777462658211/turn_001.txt +772 -0
  101. package/turns/cli_1777462658211/turn_002.txt +606 -0
  102. package/turns/cli_1777462658211/turn_003.txt +667 -0
  103. package/turns/cli_1777462658211/turn_004.txt +297 -0
  104. package/turns/cli_1777462658211/turn_005.txt +301 -0
  105. package/turns/cli_1777462658211/turn_006.txt +262 -0
  106. package/turns/cli_1777465095132/turn_001.txt +715 -0
  107. package/turns/cli_1777465095132/turn_002.txt +236 -0
  108. package/turns/cli_1777465095132/turn_003.txt +287 -0
  109. package/turns/cli_1777465095132/turn_004.txt +694 -0
  110. package/turns/cli_1777465095132/turn_005.txt +422 -0
  111. package/turns/cli_1777465095132/turn_006.txt +365 -0
  112. package/turns/cli_1777465095132/turn_007.txt +885 -0
  113. package/turns/cli_1777465095132/turn_008.txt +1277 -0
  114. package/turns/cli_1777465095132/turn_009.txt +736 -0
@@ -1,5 +1,6 @@
1
1
  import slugify from "../sql/functions/slugify.js";
2
2
  import { PermissionError } from "./errors.js";
3
+ import encodeSegment from "./pathEncode.js";
3
4
 
4
5
  export default class Entries {
5
6
  #db;
@@ -14,10 +15,7 @@ export default class Entries {
14
15
  this.#onChanged = onChanged;
15
16
  }
16
17
 
17
- /**
18
- * Populate the scheme cache. Can be called explicitly (e.g. at boot
19
- * after initPlugins finishes) or runs lazily on first need. Idempotent.
20
- */
18
+ // Populate the scheme cache; idempotent, lazy on first need.
21
19
  async loadSchemes(db) {
22
20
  const rows = await (db || this.#db).get_all_schemes.all();
23
21
  this.#schemes.clear();
@@ -51,9 +49,9 @@ export default class Entries {
51
49
  try {
52
50
  // Decode first (idempotent), then encode — but preserve slashes
53
51
  const decoded = decodeURIComponent(rest);
54
- return `${scheme}://${decoded.split("/").map(encodeURIComponent).join("/")}`;
52
+ return `${scheme}://${decoded.split("/").map(encodeSegment).join("/")}`;
55
53
  } catch {
56
- return `${scheme}://${rest.split("/").map(encodeURIComponent).join("/")}`;
54
+ return `${scheme}://${rest.split("/").map(encodeSegment).join("/")}`;
57
55
  }
58
56
  }
59
57
 
@@ -63,7 +61,7 @@ export default class Entries {
63
61
  }
64
62
 
65
63
  async dedup(runId, scheme, target, turn) {
66
- const encodedTarget = encodeURIComponent(target);
64
+ const encodedTarget = encodeSegment(target);
67
65
  const turnPrefix = turn ? `turn_${turn}/` : "";
68
66
  const candidate = `${scheme}://${turnPrefix}${encodedTarget}`;
69
67
  const existing = await this.#db.get_entry_body.get({
@@ -74,12 +72,15 @@ export default class Entries {
74
72
  return `${candidate}_${++this.#seq}`;
75
73
  }
76
74
 
77
- // Log entries share a single namespace at log://turn_N/action/slug.
78
- // The action segment is the tool/plugin name (set, get, search, update,
79
- // error, etc.). Target is URL-encoded so slashes and scheme separators
80
- // survive round-trips.
75
+ // Single namespace log://turn_N/action/slug; target URL-encoded for round-trip safety.
81
76
  async logPath(runId, turn, action, target) {
82
- const encodedTarget = encodeURIComponent(target);
77
+ // Cap target before encoding: the schema's CHECK(length(path) <= 2048)
78
+ // otherwise blows up when callers pass long error messages or other
79
+ // arbitrary text. encodeURIComponent expands ~3x for ASCII, more for
80
+ // Unicode; 150 raw chars stays comfortably under 2048 even after
81
+ // worst-case expansion. The full message belongs in body, not path.
82
+ const safeTarget = String(target).slice(0, 150);
83
+ const encodedTarget = encodeSegment(safeTarget);
83
84
  const candidate = `log://turn_${turn}/${action}/${encodedTarget}`;
84
85
  const existing = await this.#db.get_entry_body.get({
85
86
  run_id: runId,
@@ -90,9 +91,7 @@ export default class Entries {
90
91
  }
91
92
 
92
93
  async slugPath(runId, scheme, content, summary) {
93
- // Prefer summary, fall back to body content, then empty slugify
94
- // handles empty explicitly by returning "" and the caller generates
95
- // a sequence-only path.
94
+ // summary > content > empty; slugify("") yields "" and we sequence-only.
96
95
  let source = "";
97
96
  if (summary) source = summary;
98
97
  else if (content) source = content;
@@ -111,12 +110,7 @@ export default class Entries {
111
110
  return `${prefix}${base}_${++this.#seq}`;
112
111
  }
113
112
 
114
- /**
115
- * Resolve a scheme's declared scope kind + writer list + category.
116
- * Unregistered or declaration-less schemes default to run-level +
117
- * model/plugin writers so ad-hoc paths (e.g. bare filenames) still
118
- * work.
119
- */
113
+ // Scheme's scope/writers/category; bare paths default to run + model/plugin.
120
114
  async #schemeRules(scheme) {
121
115
  await this.#ensureSchemes();
122
116
  const row = scheme ? this.#schemes.get(scheme) : null;
@@ -154,17 +148,7 @@ export default class Entries {
154
148
  return `run:${runId}`;
155
149
  }
156
150
 
157
- /**
158
- * set — create or update an entry. The semantically wide primitive.
159
- *
160
- * Modes (selected by which options are present):
161
- * — write content: body given, state ∈ {proposed,streaming,resolved,failed,cancelled}
162
- * — change visibility only: visibility given, body omitted
163
- * — change state only: state given, body omitted (resolve a proposal)
164
- * — merge attributes: attributes given, body omitted
165
- * — append to body: append:true (streaming)
166
- * — pattern match: path contains wildcards or bodyFilter set
167
- */
151
+ // set — create or update an entry; see PLUGINS.md primitives.
168
152
  async set({
169
153
  runId,
170
154
  projectId = null,
@@ -185,14 +169,9 @@ export default class Entries {
185
169
  if (!runId) throw new Error("set: runId is required");
186
170
  if (!path) throw new Error("set: path is required");
187
171
 
188
- // Pattern mode is explicit (pattern: true) or implicit when a
189
- // body filter is supplied. The literal `*` character can appear
190
- // inside legitimate exact paths (e.g. rm://foo%2F* as a result
191
- // path for an rm against a pattern); we don't infer pattern mode
192
- // from the path alone.
172
+ // Pattern mode is explicit; never inferred from `*` in path.
193
173
  const isPattern = pattern === true || bodyFilter !== null;
194
174
 
195
- // Pattern mode: update matching entries (visibility / body / both).
196
175
  if (isPattern) {
197
176
  if (body != null && !append) {
198
177
  await this.#db.update_body_by_pattern.run({
@@ -279,14 +258,7 @@ export default class Entries {
279
258
  throw new PermissionError(scheme, writer, writers);
280
259
  }
281
260
  const scope = this.#resolveScope(kind, runId, projectId);
282
- // Log entries self-describe via `action` so consumers (renderer,
283
- // client UIs, tests) can read the action without parsing the
284
- // path. Only inject `action` when the caller passes attributes
285
- // — a null `attributes` means "don't touch existing" and the
286
- // SQL's COALESCE handles preservation on UPDATE. If we generated
287
- // `{action: m[1]}` for every null-attributes log write, every
288
- // body-only update to a log entry would clobber existing attrs
289
- // (command, summary, demotedCount, ...).
261
+ // Inject `action` only when caller passes attributes; null means COALESCE preserves existing.
290
262
  const effectiveAttributes = attributes ? { ...attributes } : null;
291
263
  if (scheme === "log" && effectiveAttributes) {
292
264
  const m = normalized.match(/^log:\/\/turn_\d+\/([^/]+)\//);
@@ -321,11 +293,7 @@ export default class Entries {
321
293
  }
322
294
  }
323
295
 
324
- /**
325
- * get — promote entry(ies) to visible visibility. Default visibility is
326
- * "visible"; pass visibility explicitly for a read-with-side-effect at
327
- * a different visibility (rare).
328
- */
296
+ // get — promote entry(ies); see PLUGINS.md primitives.
329
297
  async get({
330
298
  runId,
331
299
  turn = 0,
@@ -352,11 +320,7 @@ export default class Entries {
352
320
  this.#emitChanged(runId, path, "promote");
353
321
  }
354
322
 
355
- /**
356
- * rm — remove entry view(s). Matches single path or pattern; optional
357
- * bodyFilter narrows pattern matches. `filesOnly` restricts to bare
358
- * file-scheme entries (scheme IS NULL).
359
- */
323
+ // rm — remove entry view(s); see PLUGINS.md primitives.
360
324
  async rm({ runId, path, bodyFilter = null, filesOnly = false }) {
361
325
  if (!runId) throw new Error("rm: runId is required");
362
326
  if (!path) throw new Error("rm: path is required");
@@ -381,10 +345,7 @@ export default class Entries {
381
345
  this.#emitChanged(runId, path, "remove");
382
346
  }
383
347
 
384
- /**
385
- * cp — copy an entry to a new path. Source body becomes new body;
386
- * source view unchanged.
387
- */
348
+ // cp — copy an entry to a new path; see PLUGINS.md primitives.
388
349
  async cp({
389
350
  runId,
390
351
  turn = 0,
@@ -411,9 +372,7 @@ export default class Entries {
411
372
  });
412
373
  }
413
374
 
414
- /**
415
- * mv — rename an entry. Equivalent to cp + rm on source.
416
- */
375
+ // mv — rename (cp + rm).
417
376
  async mv({
418
377
  runId,
419
378
  turn = 0,
@@ -439,13 +398,7 @@ export default class Entries {
439
398
  await this.rm({ runId, path: from });
440
399
  }
441
400
 
442
- /**
443
- * update — once-per-turn lifecycle signal from the model (or plugin
444
- * speaking on its behalf). Writes to update://<slug> with body as the
445
- * content and attributes.status carrying the model's continuation code
446
- * (102 continue, 200/204 terminal, 422 can't-answer). Returns the
447
- * slug path.
448
- */
401
+ // update — once-per-turn lifecycle signal; see PLUGINS.md.
449
402
  async update({
450
403
  runId,
451
404
  turn = 0,
@@ -475,7 +428,12 @@ export default class Entries {
475
428
  runId,
476
429
  path,
477
430
  body = null,
478
- { limit = null, offset = null, includeAuditSchemes = false } = {},
431
+ {
432
+ limit = null,
433
+ offset = null,
434
+ since = null,
435
+ includeAuditSchemes = false,
436
+ } = {},
479
437
  ) {
480
438
  return this.#db.get_entries_by_pattern.all({
481
439
  run_id: runId,
@@ -483,6 +441,7 @@ export default class Entries {
483
441
  body: body ? body : null,
484
442
  limit,
485
443
  offset,
444
+ since,
486
445
  include_audit_schemes: includeAuditSchemes ? 1 : null,
487
446
  });
488
447
  }
@@ -497,10 +456,7 @@ export default class Entries {
497
456
  }
498
457
 
499
458
  async waitForResolution(runId, path) {
500
- // Check current state first if a synchronous in-process resolver
501
- // (yolo) flipped the entry to terminal during proposal.pending,
502
- // the state change has already happened and no future drain will
503
- // fire. Without this guard, in-process resolvers would deadlock.
459
+ // Pre-check: yolo's synchronous resolver may have already flipped state, no drain will fire.
504
460
  const current = await this.getState(runId, path);
505
461
  if (
506
462
  current &&
@@ -559,9 +515,7 @@ export default class Entries {
559
515
  return new Set(rows.map((r) => r.body));
560
516
  }
561
517
 
562
- /**
563
- * Unknown entries for a run, in DB order. Rows include path + body.
564
- */
518
+ // Unknown entries in DB order; rows include path + body.
565
519
  async getUnknowns(runId) {
566
520
  return this.#db.get_unknowns.all({ run_id: runId });
567
521
  }
@@ -580,14 +534,7 @@ export default class Entries {
580
534
  });
581
535
  }
582
536
 
583
- /**
584
- * Demote all promoted entries for a run on a given turn. Returns the
585
- * affected rows (path, tokens) so callers can summarize.
586
- *
587
- * Implemented as SELECT-then-UPDATE because SQLite's RETURNING doesn't
588
- * support the cross-table lookup needed to report content paths/tokens
589
- * from the view-layer update.
590
- */
537
+ // SELECT-then-UPDATE: SQLite RETURNING can't cross to the view layer.
591
538
  async demoteTurnEntries(runId, turn) {
592
539
  const targets = await this.#db.get_turn_demotion_targets.all({
593
540
  run_id: runId,
@@ -597,14 +544,7 @@ export default class Entries {
597
544
  return targets;
598
545
  }
599
546
 
600
- /**
601
- * Demote every currently-visible entry in a run. Used by budget
602
- * postDispatch as the fallback when this-turn demotion finds nothing
603
- * and the packet still overflows — left-over promotions from prior
604
- * turns the model didn't demote themselves. Returns the affected
605
- * rows (path, tokens, turn) ordered oldest promotion first so the
606
- * error body can name them.
607
- */
547
+ // Budget postDispatch fallback: demote every visible entry in the run.
608
548
  async demoteRunVisibleEntries(runId) {
609
549
  const targets = await this.#db.get_run_visible_targets.all({
610
550
  run_id: runId,
@@ -613,17 +553,12 @@ export default class Entries {
613
553
  return targets;
614
554
  }
615
555
 
616
- /**
617
- * Run metadata lookup. Exposed here so plugins don't reach into
618
- * core.db for run-scoped lookups.
619
- */
556
+ // Plugin-facing run lookup; avoids reaching into core.db.
620
557
  async getRun(runId) {
621
558
  return this.#db.get_run_by_id.get({ id: runId });
622
559
  }
623
560
 
624
- /**
625
- * Turn-level usage stats write (telemetry). Same rationale as getRun.
626
- */
561
+ // Plugin-facing turn-stats write.
627
562
  async updateTurnStats(stats) {
628
563
  return this.#db.update_turn_stats.run(stats);
629
564
  }
@@ -87,10 +87,7 @@ export default class ProjectAgent {
87
87
  return this.#agentLoop.inject(run, message, mode, options);
88
88
  }
89
89
 
90
- // Synchronously create (or fork) a run row and return the alias.
91
- // Caller is expected to follow up with a kickoff (ask/act) that
92
- // operates on the returned alias. Lets RPC respond with the real
93
- // alias before the long-running loop starts.
90
+ // Create/fork the run row synchronously; caller follows up with ask/act.
94
91
  async ensureRun(projectId, model, run, prompt, options = {}) {
95
92
  return this.#agentLoop.ensureRun(projectId, model, run, prompt, options);
96
93
  }
@@ -103,11 +100,7 @@ export default class ProjectAgent {
103
100
  this.#agentLoop.abort(runId);
104
101
  }
105
102
 
106
- /**
107
- * Abort every in-flight run and wait for them to settle. Called
108
- * from the server's close path so the Node event loop isn't held
109
- * open by detached kickoff Promises after shutdown.
110
- */
103
+ // Abort all in-flight runs and drain so the event loop can exit.
111
104
  async shutdown() {
112
105
  await this.#agentLoop.abortAll();
113
106
  }
@@ -1,5 +1,6 @@
1
1
  import RummyContext from "../hooks/RummyContext.js";
2
2
  import { ContextExceededError } from "../llm/errors.js";
3
+ import { PermissionError } from "./errors.js";
3
4
  import materializeContext from "./materializeContext.js";
4
5
  import XmlParser from "./XmlParser.js";
5
6
 
@@ -44,7 +45,6 @@ export default class TurnExecutor {
44
45
  sequence: turn,
45
46
  });
46
47
 
47
- // Build RummyContext before turn.started so plugins can write entries
48
48
  const rummy = new RummyContext(
49
49
  {
50
50
  tag: "turn",
@@ -78,7 +78,6 @@ export default class TurnExecutor {
78
78
  loopPrompt,
79
79
  },
80
80
  );
81
- // Plugins write prompt/instructions entries
82
81
  await this.#hooks.turn.started.emit({
83
82
  rummy,
84
83
  mode,
@@ -89,12 +88,9 @@ export default class TurnExecutor {
89
88
 
90
89
  await this.#hooks.processTurn(rummy);
91
90
 
92
- // Project instructions://system through the instructions tool's projection
93
91
  const systemPrompt =
94
92
  await this.#hooks.instructions.resolveSystemPrompt(rummy);
95
93
 
96
- // Materialize turn_context: VIEW rows projected through tools
97
- const demoted = [];
98
94
  const budgetCtx = {
99
95
  runId: currentRunId,
100
96
  loopId: currentLoopId,
@@ -102,7 +98,6 @@ export default class TurnExecutor {
102
98
  systemPrompt,
103
99
  mode,
104
100
  toolSet,
105
- demoted,
106
101
  loopIteration,
107
102
  };
108
103
  const initial = await materializeContext({
@@ -118,13 +113,6 @@ export default class TurnExecutor {
118
113
  rowCount: initial.rows.length,
119
114
  });
120
115
 
121
- await this.#hooks.run.progress.emit({
122
- projectId,
123
- run: currentAlias,
124
- turn,
125
- status: "thinking",
126
- });
127
-
128
116
  const budgetResult = await this.#hooks.budget.enforce({
129
117
  contextSize,
130
118
  messages: initial.messages,
@@ -158,15 +146,19 @@ export default class TurnExecutor {
158
146
  turn,
159
147
  });
160
148
 
161
- // Call LLM. Transient-error retry + context-exceeded detection live
162
- // in LlmProvider; context-exceeded surfaces as ContextExceededError.
163
149
  await this.#hooks.llm.request.started.emit({ model: requestedModel, turn });
164
150
  let rawResult;
165
151
  try {
166
152
  rawResult = await this.#llmProvider.completion(
167
153
  filteredMessages,
168
154
  requestedModel,
169
- { temperature: options?.temperature, signal },
155
+ {
156
+ temperature: options?.temperature,
157
+ signal,
158
+ // Per-run stable identifier for provider-side prompt caching
159
+ // (xAI prompt_cache_key, OpenAI prompt_cache_key, etc.).
160
+ runAlias: runRow?.alias || `run_${currentRunId}`,
161
+ },
170
162
  );
171
163
  } catch (err) {
172
164
  if (err instanceof ContextExceededError) {
@@ -201,19 +193,8 @@ export default class TurnExecutor {
201
193
  usage: result.usage,
202
194
  });
203
195
  const responseMessage = result.choices?.[0]?.message;
204
- // A valid completion response always carries content (possibly
205
- // empty) on the message; protect against that specific case so
206
- // downstream parsers see a string.
207
196
  const content = responseMessage?.content ? responseMessage.content : "";
208
197
 
209
- await this.#hooks.run.progress.emit({
210
- projectId,
211
- run: currentAlias,
212
- turn,
213
- status: "processing",
214
- });
215
-
216
- // Parse and emit — plugins handle audit storage
217
198
  const { commands, warnings, unparsed } = XmlParser.parse(content);
218
199
  for (const w of warnings) {
219
200
  await this.#hooks.error.log.emit({
@@ -225,7 +206,7 @@ export default class TurnExecutor {
225
206
  status: 422,
226
207
  });
227
208
  }
228
- if (commands.length === 0 && !!unparsed?.trim() && warnings.length === 0) {
209
+ if (commands.length === 0 && unparsed?.trim() && warnings.length === 0) {
229
210
  await this.#hooks.error.log.emit({
230
211
  store: this.#entries,
231
212
  runId: currentRunId,
@@ -236,10 +217,7 @@ export default class TurnExecutor {
236
217
  });
237
218
  }
238
219
 
239
- // Merge reasoning contributions from subscribers (think plugin's
240
- // <think> tag, other plugin reasoning sources). Filter starts with
241
- // the API-provided reasoning_content and layers on each plugin's
242
- // contribution.
220
+ // Layer plugin reasoning contributions onto the API-provided seed.
243
221
  if (responseMessage) {
244
222
  const seed = responseMessage.reasoning_content
245
223
  ? responseMessage.reasoning_content
@@ -264,7 +242,7 @@ export default class TurnExecutor {
264
242
  userMsg: userMsg?.content,
265
243
  });
266
244
 
267
- // --- PHASE 1: RECORD ---
245
+ // PHASE 1: RECORD
268
246
  const recorded = [];
269
247
  for (const cmd of commands) {
270
248
  const entry = await this.#record(
@@ -277,14 +255,7 @@ export default class TurnExecutor {
277
255
  if (entry) recorded.push(entry);
278
256
  }
279
257
 
280
- // --- PHASE 2: DISPATCH ---
281
- // Sequential queue. Each tool completes before the next starts.
282
- // On failure: abort remaining. On proposal: notify client, await
283
- // resolution, continue.
284
- // Narration text outside tags is fine when the turn also emitted
285
- // at least one command — "OK", "Let me check:", reasoning prefixes
286
- // are natural. Parse warnings and no-tags responses already emitted
287
- // errors above; dispatch crashes and failed entries emit below.
258
+ // PHASE 2: DISPATCH — sequential; abort-after-failure; proposals notify-and-await.
288
259
  let abortAfter = null;
289
260
 
290
261
  for (const entry of recorded) {
@@ -309,6 +280,21 @@ export default class TurnExecutor {
309
280
  try {
310
281
  await this.#hooks.tools.dispatch(entry.scheme, entry, rummy);
311
282
  } catch (dispatchErr) {
283
+ // PermissionError is the model attempting a documented-forbidden
284
+ // write (e.g. <set path="prompt://1"> with body). Surface as a
285
+ // soft 403 so the model can adjust on the next turn; do not
286
+ // abort sibling entries — the rest of the turn was valid.
287
+ if (dispatchErr instanceof PermissionError) {
288
+ await this.#hooks.error.log.emit({
289
+ store: this.#entries,
290
+ runId: currentRunId,
291
+ turn,
292
+ loopId: currentLoopId,
293
+ message: dispatchErr.message,
294
+ status: 403,
295
+ });
296
+ continue;
297
+ }
312
298
  await this.#hooks.error.log.emit({
313
299
  store: this.#entries,
314
300
  runId: currentRunId,
@@ -323,11 +309,9 @@ export default class TurnExecutor {
323
309
  await this.#hooks.tool.after.emit({ entry, rummy });
324
310
  await this.#hooks.entry.created.emit(entry);
325
311
 
326
- // Plugins (e.g. set) materialize pending proposals from the
327
- // recorded entry — e.g. search/replace revisions → set:// 202.
312
+ // Plugins materialize pending proposals (e.g. set search/replace 202).
328
313
  await this.#hooks.proposal.prepare.emit({ rummy, recorded: [entry] });
329
314
 
330
- // Check for any proposals created by this entry's dispatch
331
315
  const proposed = await this.#entries.getUnresolved(currentRunId);
332
316
  for (const p of proposed) {
333
317
  await this.#hooks.proposal.pending.emit({
@@ -338,39 +322,18 @@ export default class TurnExecutor {
338
322
  });
339
323
  await this.#entries.waitForResolution(currentRunId, p.path);
340
324
  const resolved = await this.#entries.getState(currentRunId, p.path);
341
- if (resolved?.status >= 400) {
342
- await this.#hooks.error.log.emit({
343
- store: this.#entries,
344
- runId: currentRunId,
345
- turn,
346
- loopId: currentLoopId,
347
- message: `Proposal ${p.path} rejected: status ${resolved.status}.`,
348
- status: resolved.status,
349
- });
350
- abortAfter = entry.scheme;
351
- }
325
+ // Failure surfaces in the proposal entry itself; abort cascade
326
+ // triggers the trailing-action "Aborted — preceding <X>" body.
327
+ if (resolved?.status >= 400) abortAfter = entry.scheme;
352
328
  }
353
329
 
354
330
  if (!abortAfter) {
355
331
  const entryPath = entry.resultPath || entry.path;
356
332
  const row = await this.#entries.getState(currentRunId, entryPath);
357
- if (row?.status >= 400) {
358
- await this.#hooks.error.log.emit({
359
- store: this.#entries,
360
- runId: currentRunId,
361
- turn,
362
- loopId: currentLoopId,
363
- message: `Entry ${entryPath} failed: status ${row.status}.`,
364
- status: row.status,
365
- });
366
- abortAfter = entry.scheme;
367
- }
333
+ if (row?.status >= 400) abortAfter = entry.scheme;
368
334
  }
369
335
  }
370
336
 
371
- // Turn Demotion: budget plugin re-materializes end-of-turn context
372
- // and demotes this turn's promoted entries on overflow. Overflow
373
- // emits an error (status 413) via the unified error channel.
374
337
  await this.#hooks.budget.postDispatch({
375
338
  contextSize,
376
339
  ctx: budgetCtx,
@@ -409,21 +372,14 @@ export default class TurnExecutor {
409
372
  return turnResult;
410
373
  }
411
374
 
412
- /**
413
- * Record a parsed command as a known_entries row.
414
- * Returns the recorded entry descriptor, or null if rejected/skipped.
415
- */
375
+ // Record a parsed command; returns the entry descriptor or rejects on bad shapes.
416
376
  async #record(runId, loopId, turn, mode, cmd) {
417
377
  const scheme = cmd.name;
418
- // Each tool's XmlParser shape surfaces exactly one of these
419
- // three fields as its addressable target. Treat absent as empty
420
- // so the length/control-char validation below catches bad shapes
421
- // rather than letting an undefined slip through.
422
378
  let rawTarget = "";
423
379
  if (cmd.path) rawTarget = cmd.path;
424
380
  else if (cmd.command) rawTarget = cmd.command;
425
381
  else if (cmd.question) rawTarget = cmd.question;
426
- // Reject paths that are likely reasoning bleed too long or contain non-printing chars
382
+ // Reject likely reasoning bleed: oversize or control chars in target.
427
383
  if (rawTarget.length > 512 || /\p{Cc}/u.test(rawTarget)) {
428
384
  const rejectPath = await this.#entries.logPath(
429
385
  runId,
@@ -454,18 +410,14 @@ export default class TurnExecutor {
454
410
  const target = rawTarget;
455
411
  const resultPath = await this.#entries.logPath(runId, turn, scheme, target);
456
412
 
457
- // Pass parsed command fields through as attributes
458
413
  const { name: _, ...attributes } = cmd;
459
414
  if (cmd.path) attributes.path = target;
460
415
 
461
- // Same per-shape resolution as rawTarget; the three sources are
462
- // mutually exclusive per tool. Empty string when none set.
463
416
  let body = "";
464
417
  if (cmd.body) body = cmd.body;
465
418
  else if (cmd.command) body = cmd.command;
466
419
  else if (cmd.question) body = cmd.question;
467
420
 
468
- // Filter: plugins can validate/transform before recording
469
421
  const filtered = await this.#hooks.entry.recording.filter(
470
422
  {
471
423
  scheme,
@@ -478,7 +430,17 @@ export default class TurnExecutor {
478
430
  { store: this.#entries, runId, turn, loopId, mode },
479
431
  );
480
432
  if (filtered.state === "failed" || filtered.state === "cancelled") {
481
- return filtered;
433
+ await this.#entries.set({
434
+ runId,
435
+ turn,
436
+ loopId,
437
+ path: filtered.path,
438
+ body: filtered.body,
439
+ state: filtered.state,
440
+ outcome: filtered.outcome,
441
+ attributes: filtered.attributes,
442
+ });
443
+ return { ...filtered, resultPath: filtered.path };
482
444
  }
483
445
 
484
446
  return {