pi-shadow-mind 0.1.15 → 0.1.17

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.
package/DESIGN.md CHANGED
@@ -111,7 +111,7 @@ active_for_models:
111
111
  | `run_with_model` | Shadow 自己使用的模型;省略时使用插件默认模型 |
112
112
  | `thinking_level` | Shadow 使用的 thinking level;省略时使用插件默认值,再回退到 Main 会话当前生效等级 |
113
113
  | `timeout_seconds` | Shadow 单次运行超时;省略时使用插件默认超时 |
114
- | `tools` | 在 Pi SDK `readOnlyTools` 之上追加的工具白名单;默认 `[]` |
114
+ | `tools` | 在 Pi SDK `readOnlyTools` 之上追加的工具白名单;`["*"]` 授权当前 Session 的全部工具;默认 `[]` |
115
115
 
116
116
  `name` 只用于 `shadow-report` 和状态界面展示,不参与身份判断;省略时回退到最终解析出的 `id`。Markdown 正文就是 Shadow 的认知定义、长期职责和行为要求。
117
117
 
@@ -131,9 +131,9 @@ Pi SDK readOnlyTools
131
131
 
132
132
  默认名称集合包含 `read`、`grep`、`find` 和 `ls`。激活时插件按名称从当前 Main Session 的最终工具 registry 解析实际实现,因此会继承其他插件对同名工具的覆盖以及当前环境适配。这里的 `readOnlyTools` 表示默认名称约定,不保证被覆盖后的实现仍然没有副作用。
133
133
 
134
- `tools` 是在该集合之上追加的显式白名单,省略时视为 `[]`。例如 `tools: [write]` 会获得当前 registry 中的默认四个工具、`write` 和内置 `report_to_main`。
134
+ `tools` 是在该集合之上追加的显式白名单,省略时视为 `[]`。例如 `tools: [write]` 会获得当前 registry 中的默认四个工具、`write` 和内置 `report_to_main`。使用 `tools: ["*"]` 时,Shadow 会获得激活时 Main Session registry 中的全部工具以及 `report_to_main`;之后新增或改名的工具会自动随 registry 解析,不需要同步维护定义文件。
135
135
 
136
- Pi 的通用 `ToolDefinition` 没有可供插件查询的只读属性,因此自定义工具不会被自动归类或加入;编辑文件、执行 Shell 或其他能力也不会被隐式继承自 Main。配置的追加工具在当前 Session 不存在时,插件忽略该项、写入轻量运行事件,其余工具继续正常提供。
136
+ Pi 的通用 `ToolDefinition` 没有可供插件查询的只读属性,因此自定义工具不会被自动归类或加入;编辑文件、执行 Shell 或其他能力也不会被隐式继承自 Main,除非显式列出名称或使用 `"*"`。配置的追加工具在当前 Session 不存在时,插件忽略该项、写入轻量运行事件,其余工具继续正常提供。
137
137
 
138
138
  `tools` 白名单本身就是用户对该 Shadow 的执行授权。列入白名单的写入、Shell 或其他工具可以由后台 Shadow 直接调用,插件不增加逐次确认层。
139
139
 
@@ -340,9 +340,11 @@ Main 在会话中切换模型后,后续 heartbeat 直接依据新模型重新
340
340
 
341
341
  配置了 `final_response` 的 Shadow 在 Main 发出非空最终文字,并进入 `agent_settled` 状态后参与调度。该模式仍应用 `enabled` 和 `active_for_models`,但绕过 `heartbeat_probability` 与 `activation_probability`,所有匹配项都必须获得一次运行机会。
342
342
 
343
- 最终回复检查使用当时的完整净化轨迹快照。`max_parallel_shadows` 仍是硬并发上限;没有空闲槽位或同一 Shadow 正在运行时,检查进入专用队列,槽位释放后继续执行。新用户输入或 Session 关闭会清空旧 epoch 的队列,防止过期结果介入新任务。
343
+ 最终回复检查使用当时的完整净化轨迹快照。`max_parallel_shadows` 仍是硬并发上限;没有空闲槽位或同一 Shadow 正在运行时,检查进入专用队列,槽位释放后继续执行。旧 epoch 的运行释放槽位时同样继续泵送当前队列,避免跨 epoch 的异步收尾把检查永久卡住。
344
344
 
345
- 检查结果沿用普通 `shadow-report` steer/follow-up 机制。因此 Main 根据报告修正并再次给出最终回复后,可以再次触发完成检查;Shadow 没有发现时保持沉默,循环自然结束。
345
+ 每次最终回复检查拥有独立、递增的 review generation。独立的 completion-review 协调器统一拥有 generation、等待队列、运行完成计数和报告聚合,Runtime 只转发宿主生命周期事件与 Shadow 启停通知。同一 generation 的报告先暂存,所有匹配 Shadow 都进入终态后才合并为一次 `shadow-report`;因此较慢的同批检查不会在 Main 已开始修订后单独 steer。新的最终回复、新用户输入、其他报告触发的修订或 Session 关闭都会使旧 generation 失效,并清空尚未启动的项目;仍在收尾的旧运行只能记录 lifetime usage,不能再投递报告。异步刷新配置和 Registry 时还会同时校验启动请求与 epoch,防止等待 I/O 的旧最终回复在新任务中恢复调度。
346
+
347
+ 检查结果沿用普通 `shadow-report` 的 steer/follow-up 机制。因此 Main 根据聚合报告修正并再次给出最终回复后,可以再次触发新一代完成检查;Shadow 没有发现时保持沉默,循环自然结束。
346
348
 
347
349
  ## 6. Shadow 的输出与介入
348
350
 
@@ -362,7 +364,7 @@ Shadow 返回
362
364
  └── 丢弃旧结果
363
365
  ```
364
366
 
365
- 最终回复不会立即终止正在运行的 Shadow。迟到结果仍可在下一条用户消息到来前触发补充回复。
367
+ Heartbeat Shadow 不会因为 Main 给出最终回复而立即终止,迟到结果仍可在下一条用户消息到来前触发补充回复。Final-response Shadow 则按 review generation 聚合,只有整批检查结束后才允许触发一次补充回复。
366
368
 
367
369
  每个用户任务拥有递增的 `epoch`:
368
370
 
package/README.md CHANGED
@@ -66,13 +66,13 @@ Report only concrete, actionable issues grounded in the visible trajectory or
66
66
  repository. If the current work is unrelated, do not intervene.
67
67
  ```
68
68
 
69
- This Shadow is read-only. It reviews the implementation in parallel and reports concrete architectural concerns without taking control of the main task.
69
+ This Shadow is read-only. It reviews the implementation in parallel and reports concrete architectural concerns without taking control of the main task. Tool names extend the default read-only set; use `tools: ["*"]` only when the Shadow should inherit every tool currently registered in the main session, including tools added later.
70
70
 
71
71
  ## How it works
72
72
 
73
73
  Each Shadow chooses one or both activation triggers with `trigger`. The default is `[heartbeat]`: after a main-agent `turn_end` that completed at least one tool call, the extension evaluates the global heartbeat probability, then eligible Shadows roll independently using `activation_probability`. Pure text-only conversation turns are skipped.
74
74
 
75
- Use `trigger: [final_response]` for completion review. It activates after the main agent has emitted its final text, bypasses both heartbeat and activation probability, and sends findings back through the normal `shadow-report` follow-up flow. `trigger: [heartbeat, final_response]` enables both modes. `max_parallel_shadows` remains the concurrency limit; excess final-response checks are queued rather than skipped.
75
+ Use `trigger: [final_response]` for completion review. It activates after the main agent has emitted its final text and bypasses both heartbeat and activation probability. All checks for that final response finish before their findings are sent together through one `shadow-report` follow-up, so a slow sibling cannot leak into a revised answer. `trigger: [heartbeat, final_response]` enables both modes. `max_parallel_shadows` remains the concurrency limit; excess final-response checks are queued rather than skipped.
76
76
 
77
77
  Each activation starts a fresh temporary session. It inherits the main agent's unchanged system prompt but receives only a sanitized plain-text trajectory: assistant thinking is removed, while tool calls retain compact, deterministic result summaries.
78
78
 
@@ -83,7 +83,7 @@ Shadow definitions are ordinary Markdown files. They can be created and adjusted
83
83
  ## Installation
84
84
 
85
85
  ```bash
86
- pi install npm:pi-shadow-mind@0.1.11
86
+ pi install npm:pi-shadow-mind
87
87
  ```
88
88
 
89
89
  On the first session start, the extension creates:
package/README.zh-CN.md CHANGED
@@ -63,13 +63,13 @@ tools: [read, grep]
63
63
  无关,不要介入。
64
64
  ```
65
65
 
66
- 这个 Shadow 默认只读。它会在实现过程中并行审阅架构,并向主 Agent 报告具体问题,但不会接管主任务。
66
+ 这个 Shadow 默认只读。它会在实现过程中并行审阅架构,并向主 Agent 报告具体问题,但不会接管主任务。`tools` 中的名称会追加到默认只读工具集;只有确实需要继承 Main Session 当前注册的全部工具(包括未来新增工具)时,才使用 `tools: ["*"]`。
67
67
 
68
68
  ## 工作方式
69
69
 
70
70
  每个 Shadow 可以通过 `trigger` 选择一种或两种激活方式。默认值是 `[heartbeat]`:主 Agent 的一次 `turn_end` 只有在该轮至少完成过一个工具调用时,扩展才进行全局 heartbeat 概率判断,符合条件的 Shadow 再按照各自的 `activation_probability` 独立抽选。纯文本对话轮次不会触发 heartbeat。
71
71
 
72
- 使用 `trigger: [final_response]` 可以进行完成后审查。它在主 Agent 发出最终文字并完全 settled 后激活,不受 heartbeat 和 `activation_probability` 影响,检查结果仍通过正常的 `shadow-report` follow-up 流程返回。`trigger: [heartbeat, final_response]` 会同时启用两种模式。`max_parallel_shadows` 仍然限制并发数;超出并发槽位的最终回复检查会排队,而不会被跳过。
72
+ 使用 `trigger: [final_response]` 可以进行完成后审查。它在主 Agent 发出最终文字并完全 settled 后激活,不受 heartbeat 和 `activation_probability` 影响。同一最终回复的全部检查结束后,发现会合并为一次 `shadow-report` follow-up,较慢的同批检查不会介入已经修订的回复。`trigger: [heartbeat, final_response]` 会同时启用两种模式。`max_parallel_shadows` 仍然限制并发数;超出并发槽位的最终回复检查会排队,而不会被跳过。
73
73
 
74
74
  每次激活都会创建一个全新的临时 Session。它继承主 Agent 原封不动的 system prompt,但只接收净化后的文本轨迹:思考内容会被移除,工具调用后仅保留简洁、确定性的结果概述。
75
75
 
@@ -80,7 +80,7 @@ Shadow 定义只是普通 Markdown 文件,可以由用户创建和调整,也
80
80
  ## 安装
81
81
 
82
82
  ```bash
83
- pi install npm:pi-shadow-mind@0.1.11
83
+ pi install npm:pi-shadow-mind
84
84
  ```
85
85
 
86
86
  首次启动 Session 时,扩展会创建:
package/dist/index.js CHANGED
@@ -12,6 +12,121 @@ import {
12
12
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
13
  import { Text } from "@earendil-works/pi-tui";
14
14
 
15
+ // src/final-response-queue.ts
16
+ var FinalResponseQueue = class {
17
+ constructor(options) {
18
+ this.options = options;
19
+ }
20
+ pending = [];
21
+ enqueue(items) {
22
+ this.pending.push(...items);
23
+ this.pump();
24
+ }
25
+ slotAvailable() {
26
+ this.pump();
27
+ }
28
+ clear() {
29
+ this.pending.length = 0;
30
+ }
31
+ pump() {
32
+ while (this.options.activeCount() < this.options.maxParallel() && this.pending.length > 0) {
33
+ const epoch = this.options.currentEpoch();
34
+ const activeShadowIds = this.options.activeShadowIds();
35
+ const index = this.pending.findIndex(
36
+ (item2) => item2.epoch !== epoch || !activeShadowIds.has(item2.shadowId)
37
+ );
38
+ if (index < 0) return;
39
+ const [item] = this.pending.splice(index, 1);
40
+ if (!item || item.epoch !== epoch) continue;
41
+ this.options.launch(item);
42
+ }
43
+ }
44
+ };
45
+
46
+ // src/completion-review.ts
47
+ var CompletionReview = class {
48
+ constructor(host) {
49
+ this.host = host;
50
+ this.queue = new FinalResponseQueue({
51
+ currentEpoch: () => this.host.currentEpoch(),
52
+ maxParallel: () => this.host.maxParallel(),
53
+ activeCount: () => this.host.activeCount(),
54
+ activeShadowIds: () => this.host.activeShadowIds(),
55
+ launch: ({ job, run }) => host.launch(job, run)
56
+ });
57
+ }
58
+ requestId = 0;
59
+ state;
60
+ requestByRun = /* @__PURE__ */ new WeakMap();
61
+ queue;
62
+ begin(epoch) {
63
+ this.invalidate();
64
+ return { id: this.requestId, epoch };
65
+ }
66
+ isCurrent(request) {
67
+ return request.id === this.requestId && request.epoch === this.host.currentEpoch();
68
+ }
69
+ schedule(request, jobs) {
70
+ if (!this.isCurrent(request) || this.state?.requestId === request.id || jobs.some((job) => job.epoch !== request.epoch)) {
71
+ return false;
72
+ }
73
+ if (jobs.length === 0) {
74
+ this.retire(request.id);
75
+ return true;
76
+ }
77
+ this.state = {
78
+ requestId: request.id,
79
+ epoch: request.epoch,
80
+ remaining: jobs.length,
81
+ reports: []
82
+ };
83
+ this.queue.enqueue(
84
+ jobs.map((job) => {
85
+ const run = {
86
+ accept: (report) => this.accept(request.id, report)
87
+ };
88
+ this.requestByRun.set(run, request.id);
89
+ return { epoch: job.epoch, shadowId: job.shadowId, job, run };
90
+ })
91
+ );
92
+ return true;
93
+ }
94
+ slotAvailable() {
95
+ this.queue.slotAvailable();
96
+ }
97
+ complete(run) {
98
+ if (!run) return;
99
+ const requestId = this.requestByRun.get(run);
100
+ this.requestByRun.delete(run);
101
+ if (requestId === void 0 || this.state?.requestId !== requestId) return;
102
+ this.state.remaining -= 1;
103
+ if (this.state.remaining > 0) return;
104
+ const reports = this.state.reports;
105
+ this.retire(requestId);
106
+ if (reports.length > 0) this.host.deliver(reports);
107
+ }
108
+ cancel(request) {
109
+ if (this.isCurrent(request)) this.retire(request.id);
110
+ }
111
+ invalidate() {
112
+ this.requestId += 1;
113
+ this.state = void 0;
114
+ this.queue.clear();
115
+ }
116
+ retire(requestId) {
117
+ if (requestId !== this.requestId) return;
118
+ this.requestId += 1;
119
+ this.state = void 0;
120
+ this.queue.clear();
121
+ }
122
+ accept(requestId, report) {
123
+ if (this.state?.requestId !== requestId || report.epoch !== this.state.epoch) {
124
+ return;
125
+ }
126
+ this.state.reports.push(report);
127
+ }
128
+ };
129
+
15
130
  // src/config.ts
16
131
  import { mkdir, readFile, writeFile } from "node:fs/promises";
17
132
  import { dirname, join } from "node:path";
@@ -7007,37 +7122,6 @@ function definedOnly(value) {
7007
7122
  );
7008
7123
  }
7009
7124
 
7010
- // src/final-response-queue.ts
7011
- var FinalResponseQueue = class {
7012
- constructor(options) {
7013
- this.options = options;
7014
- }
7015
- pending = [];
7016
- enqueue(items) {
7017
- this.pending.push(...items);
7018
- this.pump();
7019
- }
7020
- slotAvailable() {
7021
- this.pump();
7022
- }
7023
- clear() {
7024
- this.pending.length = 0;
7025
- }
7026
- pump() {
7027
- while (this.options.activeCount() < this.options.maxParallel() && this.pending.length > 0) {
7028
- const epoch = this.options.currentEpoch();
7029
- const activeShadowIds = this.options.activeShadowIds();
7030
- const index = this.pending.findIndex(
7031
- (item2) => item2.epoch !== epoch || !activeShadowIds.has(item2.shadowId)
7032
- );
7033
- if (index < 0) return;
7034
- const [item] = this.pending.splice(index, 1);
7035
- if (!item || item.epoch !== epoch) continue;
7036
- this.options.launch(item);
7037
- }
7038
- }
7039
- };
7040
-
7041
7125
  // src/management-tools.ts
7042
7126
  import { Type } from "typebox";
7043
7127
  var ID = Type.String({ pattern: "^[a-z0-9][a-z0-9_-]*$" });
@@ -7663,7 +7747,7 @@ function compactJson(value) {
7663
7747
  // src/shadow-runner.ts
7664
7748
  function resolveShadowTools(whitelist, available, defaults = DEFAULT_READ_TOOLS) {
7665
7749
  const builtin = /* @__PURE__ */ new Set(["report_to_main"]);
7666
- const requested = [.../* @__PURE__ */ new Set([...defaults, ...whitelist, ...builtin])];
7750
+ const requested = whitelist.includes("*") ? [.../* @__PURE__ */ new Set([...available, ...builtin])] : [.../* @__PURE__ */ new Set([...defaults, ...whitelist, ...builtin])];
7667
7751
  const tools = [];
7668
7752
  const missing = [];
7669
7753
  for (const name of requested) {
@@ -7685,13 +7769,13 @@ function buildRunResult(options) {
7685
7769
  const usage = usageMetrics(ownMessages);
7686
7770
  return {
7687
7771
  reason,
7688
- ...error !== void 0 ? { error } : {},
7772
+ ...error === void 0 ? {} : { error },
7689
7773
  durationMs,
7690
7774
  toolNames: session?.getActiveToolNames() ?? [],
7691
7775
  missingTools,
7692
7776
  ...metrics,
7693
7777
  usage,
7694
- ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
7778
+ ...thinkingLevel === void 0 ? {} : { thinkingLevel },
7695
7779
  sessionFile: session?.sessionFile
7696
7780
  };
7697
7781
  }
@@ -8280,7 +8364,10 @@ var ShadowMindRuntime = class {
8280
8364
  this.pi = pi;
8281
8365
  this.batcher = new ReportBatcher(
8282
8366
  this.configStore.current.resultBatchWindowMs,
8283
- (reports) => this.deliverReports(reports)
8367
+ (reports) => {
8368
+ this.completionReview.invalidate();
8369
+ void this.deliverReports(reports);
8370
+ }
8284
8371
  );
8285
8372
  }
8286
8373
  agentDir = getAgentDir();
@@ -8293,22 +8380,14 @@ var ShadowMindRuntime = class {
8293
8380
  runner = new ShadowRunner();
8294
8381
  usageStore = new UsageStore(this.agentDir);
8295
8382
  active = /* @__PURE__ */ new Map();
8296
- finalResponseQueue = new FinalResponseQueue(
8297
- {
8298
- currentEpoch: () => this.epoch,
8299
- maxParallel: () => this.configStore.current.maxParallelShadows,
8300
- activeCount: () => this.active.size,
8301
- activeShadowIds: () => new Set([...this.active.values()].map(({ shadow }) => shadow.id)),
8302
- launch: (pending) => this.launchShadow(
8303
- pending.ctx,
8304
- pending.shadow,
8305
- pending.mainModel,
8306
- pending.fullModelId,
8307
- pending.context,
8308
- pending.availableTools
8309
- )
8310
- }
8311
- );
8383
+ completionReview = new CompletionReview({
8384
+ currentEpoch: () => this.epoch,
8385
+ maxParallel: () => this.configStore.current.maxParallelShadows,
8386
+ activeCount: () => this.active.size,
8387
+ activeShadowIds: () => new Set([...this.active.values()].map(({ shadow }) => shadow.id)),
8388
+ launch: (pending, review) => this.launchShadow({ ...pending, completionReview: review }),
8389
+ deliver: (reports) => void this.deliverReports(reports)
8390
+ });
8312
8391
  recentEvents = [];
8313
8392
  recentRuns = [];
8314
8393
  batcher;
@@ -8458,7 +8537,7 @@ var ShadowMindRuntime = class {
8458
8537
  );
8459
8538
  }
8460
8539
  async onHeartbeat(ctx) {
8461
- await this.refresh(ctx);
8540
+ const snapshot = await this.refresh(ctx);
8462
8541
  if (this.paused || !ctx.model) {
8463
8542
  this.record("heartbeat-skipped", {
8464
8543
  reason: this.paused ? "paused" : "no-model",
@@ -8466,7 +8545,6 @@ var ShadowMindRuntime = class {
8466
8545
  });
8467
8546
  return;
8468
8547
  }
8469
- const snapshot = await this.registry.load();
8470
8548
  const fullModelId = `${ctx.model.provider}/${ctx.model.id}`;
8471
8549
  const decision = decideHeartbeat({
8472
8550
  heartbeatProbability: this.configStore.current.heartbeatProbability,
@@ -8501,25 +8579,30 @@ var ShadowMindRuntime = class {
8501
8579
  this.pi.getAllTools().map((tool2) => tool2.name)
8502
8580
  );
8503
8581
  for (const { shadow } of decision.activated) {
8504
- this.launchShadow(
8582
+ this.launchShadow({
8505
8583
  ctx,
8506
8584
  shadow,
8507
- ctx.model,
8585
+ mainModel: ctx.model,
8508
8586
  fullModelId,
8509
8587
  context,
8510
8588
  availableTools
8511
- );
8589
+ });
8512
8590
  }
8513
8591
  }
8514
8592
  async onFinalResponse(ctx) {
8515
- await this.refresh(ctx);
8593
+ const request = this.completionReview.begin(this.epoch);
8594
+ const snapshot = await this.refresh(ctx);
8595
+ if (!this.completionReview.isCurrent(request)) {
8596
+ this.record("final-response-skipped", { reason: "superseded" });
8597
+ return;
8598
+ }
8516
8599
  if (this.paused || !ctx.model) {
8600
+ this.completionReview.cancel(request);
8517
8601
  this.record("final-response-skipped", {
8518
8602
  reason: this.paused ? "paused" : "no-model"
8519
8603
  });
8520
8604
  return;
8521
8605
  }
8522
- const snapshot = await this.registry.load();
8523
8606
  const mainModel = ctx.model;
8524
8607
  const fullModelId = `${mainModel.provider}/${mainModel.id}`;
8525
8608
  const decision = decideFinalResponse({
@@ -8532,7 +8615,10 @@ var ShadowMindRuntime = class {
8532
8615
  ...decision.modelFiltered.length ? { modelFiltered: decision.modelFiltered } : {},
8533
8616
  ...decision.runningExcluded.length ? { runningExcluded: decision.runningExcluded } : {}
8534
8617
  });
8535
- if (!decision.activated.length) return;
8618
+ if (!decision.activated.length) {
8619
+ this.completionReview.schedule(request, []);
8620
+ return;
8621
+ }
8536
8622
  const context = buildSessionContext(
8537
8623
  ctx.sessionManager.getEntries(),
8538
8624
  ctx.sessionManager.getLeafId()
@@ -8540,9 +8626,10 @@ var ShadowMindRuntime = class {
8540
8626
  const availableTools = new Set(
8541
8627
  this.pi.getAllTools().map((tool2) => tool2.name)
8542
8628
  );
8543
- this.finalResponseQueue.enqueue(
8629
+ this.completionReview.schedule(
8630
+ request,
8544
8631
  decision.activated.map(({ shadow }) => ({
8545
- epoch: this.epoch,
8632
+ epoch: request.epoch,
8546
8633
  shadowId: shadow.id,
8547
8634
  ctx,
8548
8635
  shadow,
@@ -8553,11 +8640,22 @@ var ShadowMindRuntime = class {
8553
8640
  }))
8554
8641
  );
8555
8642
  }
8556
- launchShadow(ctx, shadow, mainModel, fullModelId, context, availableTools) {
8643
+ launchShadow(options) {
8644
+ const {
8645
+ ctx,
8646
+ shadow,
8647
+ mainModel,
8648
+ fullModelId,
8649
+ context,
8650
+ availableTools,
8651
+ completionReview
8652
+ } = options;
8557
8653
  const runId = randomUUID3();
8558
- const runEpoch = this.epoch;
8654
+ const runEpoch = options.epoch ?? this.epoch;
8559
8655
  const { tools, missing } = resolveShadowTools(shadow.tools, availableTools);
8560
- this.active.set(runId, { shadow, epoch: runEpoch });
8656
+ const activeRun = { shadow, epoch: runEpoch };
8657
+ if (completionReview) activeRun.completionReview = completionReview;
8658
+ this.active.set(runId, activeRun);
8561
8659
  this.record("run-start", {
8562
8660
  runId,
8563
8661
  shadowId: shadow.id,
@@ -8573,30 +8671,39 @@ var ShadowMindRuntime = class {
8573
8671
  cwd: ctx.cwd,
8574
8672
  agentDir: this.agentDir,
8575
8673
  mainSystemPrompt: ctx.getSystemPrompt(),
8576
- // SAFETY: buildSessionContext returns Pi message objects; the runner treats them as read-only generic records.
8674
+ // SAFETY: buildSessionContext returns Pi messages; ShadowRunner treats them as read-only records.
8577
8675
  messages: context.messages,
8578
8676
  mainModel,
8579
8677
  tools,
8580
8678
  resolveModel: (id) => resolveModel(ctx, id),
8581
8679
  modelAuthOk: (model) => ctx.modelRegistry.hasConfiguredAuth(model) || ctx.modelRegistry.isUsingOAuth(model),
8582
8680
  mainThinkingLevel: ctx.thinkingLevel,
8583
- onReport: (report) => this.acceptReport(report)
8681
+ onReport: (report) => {
8682
+ if (completionReview) completionReview.accept(report);
8683
+ else this.acceptReport(report);
8684
+ }
8584
8685
  }).then((result) => this.handleRunEnd(runId, shadow, result)).catch((error) => {
8686
+ const activeRun2 = this.active.get(runId);
8585
8687
  this.active.delete(runId);
8688
+ this.completionReview.slotAvailable();
8689
+ this.completionReview.complete(activeRun2?.completionReview);
8586
8690
  this.record("run-end", {
8587
8691
  runId,
8588
8692
  shadowId: shadow.id,
8589
8693
  reason: "error",
8590
8694
  error: error instanceof Error ? error.message : String(error)
8591
8695
  });
8592
- this.finalResponseQueue.slotAvailable();
8593
8696
  });
8594
8697
  }
8595
8698
  handleRunEnd(runId, shadow, result) {
8596
8699
  const activeRun = this.active.get(runId);
8597
8700
  this.active.delete(runId);
8701
+ this.completionReview.slotAvailable();
8598
8702
  const persisted = this.usageStore.add(result.usage);
8599
- if (activeRun?.epoch !== this.epoch) return;
8703
+ if (activeRun?.epoch !== this.epoch) {
8704
+ this.completionReview.complete(activeRun?.completionReview);
8705
+ return;
8706
+ }
8600
8707
  this.sessionUsage = addUsage(this.sessionUsage, result.usage);
8601
8708
  this.recentRuns.push({
8602
8709
  shadowName: shadow.name,
@@ -8607,7 +8714,7 @@ var ShadowMindRuntime = class {
8607
8714
  this.record("run-end", { runId, shadowId: shadow.id, ...result });
8608
8715
  if (this.latestContext && this.sessionLifetime.isActive)
8609
8716
  this.updateStatus(this.latestContext);
8610
- this.finalResponseQueue.slotAvailable();
8717
+ this.completionReview.complete(activeRun.completionReview);
8611
8718
  void persisted.then(() => {
8612
8719
  if (this.latestContext && this.sessionLifetime.isActive)
8613
8720
  this.updateStatus(this.latestContext);
@@ -8655,10 +8762,11 @@ var ShadowMindRuntime = class {
8655
8762
  )
8656
8763
  ];
8657
8764
  this.updateStatus(ctx);
8765
+ return registry;
8658
8766
  }
8659
8767
  abortAll(reason) {
8660
8768
  this.runner.abortAll();
8661
- this.finalResponseQueue.clear();
8769
+ this.completionReview.invalidate();
8662
8770
  this.batcher.clear();
8663
8771
  this.record("runs-aborted", { reason, count: this.active.size });
8664
8772
  }