pi-shadow-mind 0.1.15 → 0.1.16

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
@@ -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
@@ -72,7 +72,7 @@ This Shadow is read-only. It reviews the implementation in parallel and reports
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
@@ -69,7 +69,7 @@ tools: [read, grep]
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_-]*$" });
@@ -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
  }