pi-shadow-mind 0.1.14 → 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/dist/index.js CHANGED
@@ -5,13 +5,128 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // src/runtime.ts
8
- import { randomUUID } from "node:crypto";
8
+ import { randomUUID as randomUUID3 } from "node:crypto";
9
9
  import {
10
10
  buildSessionContext
11
11
  } from "@earendil-works/pi-coding-agent";
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";
@@ -6760,6 +6875,12 @@ var browser_default = dist_exports;
6760
6875
  // src/registry.ts
6761
6876
  import { mkdir as mkdir2, readFile as readFile2, readdir, stat } from "node:fs/promises";
6762
6877
  import { basename, extname, join as join2 } from "node:path";
6878
+
6879
+ // src/types.ts
6880
+ var DEFAULT_READ_TOOLS = ["read", "grep", "find", "ls"];
6881
+ var SHADOW_TRIGGERS = ["heartbeat", "final_response"];
6882
+
6883
+ // src/registry.ts
6763
6884
  var ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
6764
6885
  var ShadowRegistry = class {
6765
6886
  directory;
@@ -6787,8 +6908,14 @@ var ShadowRegistry = class {
6787
6908
  continue;
6788
6909
  }
6789
6910
  try {
6790
- const definition = parseShadowMarkdown(await readFile2(filePath, "utf8"), filePath);
6791
- this.cache.set(filePath, { mtimeMs: fileStat.mtimeMs, shadow: definition });
6911
+ const definition = parseShadowMarkdown(
6912
+ await readFile2(filePath, "utf8"),
6913
+ filePath
6914
+ );
6915
+ this.cache.set(filePath, {
6916
+ mtimeMs: fileStat.mtimeMs,
6917
+ shadow: definition
6918
+ });
6792
6919
  shadows.push(definition);
6793
6920
  } catch (error) {
6794
6921
  const message = error instanceof Error ? error.message : String(error);
@@ -6803,7 +6930,10 @@ var ShadowRegistry = class {
6803
6930
  const ids = /* @__PURE__ */ new Set();
6804
6931
  for (const shadow of shadows) {
6805
6932
  if (ids.has(shadow.id)) {
6806
- diagnostics.push({ filePath: shadow.filePath, message: `duplicate shadow id: ${shadow.id}` });
6933
+ diagnostics.push({
6934
+ filePath: shadow.filePath,
6935
+ message: `duplicate shadow id: ${shadow.id}`
6936
+ });
6807
6937
  } else {
6808
6938
  ids.add(shadow.id);
6809
6939
  unique.push(shadow);
@@ -6813,14 +6943,18 @@ var ShadowRegistry = class {
6813
6943
  }
6814
6944
  };
6815
6945
  function parseShadowMarkdown(source, filePath) {
6816
- const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)([\s\S]*)$/.exec(source);
6946
+ const match = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)([\s\S]*)$/.exec(
6947
+ source
6948
+ );
6817
6949
  if (!match) throw new Error("missing YAML frontmatter");
6818
6950
  const meta = browser_default.parse(match[1]);
6819
- if (!meta || typeof meta !== "object" || Array.isArray(meta)) throw new Error("frontmatter must be an object");
6951
+ if (!meta || typeof meta !== "object" || Array.isArray(meta))
6952
+ throw new Error("frontmatter must be an object");
6820
6953
  const value = meta;
6821
6954
  const fallbackId = basename(filePath, extname(filePath));
6822
6955
  const id = stringValue(value.id, fallbackId, "id");
6823
- if (!ID_PATTERN.test(id)) throw new Error("id must match [a-z0-9][a-z0-9_-]*");
6956
+ if (!ID_PATTERN.test(id))
6957
+ throw new Error("id must match [a-z0-9][a-z0-9_-]*");
6824
6958
  const prompt = match[2].trim();
6825
6959
  if (!prompt) throw new Error("shadow prompt body is empty");
6826
6960
  return {
@@ -6828,11 +6962,23 @@ function parseShadowMarkdown(source, filePath) {
6828
6962
  name: stringValue(value.name, id, "name"),
6829
6963
  enabled: booleanValue(value.enabled, true, "enabled"),
6830
6964
  debug: booleanValue(value.debug, false, "debug"),
6831
- activationProbability: probabilityValue(value.activation_probability, 0.3, "activation_probability"),
6832
- activeForModels: stringArray(value.active_for_models, ["*"], "active_for_models"),
6965
+ activationProbability: probabilityValue(
6966
+ value.activation_probability,
6967
+ 0.3,
6968
+ "activation_probability"
6969
+ ),
6970
+ trigger: triggerArray(value.trigger, ["heartbeat"], "trigger"),
6971
+ activeForModels: stringArray(
6972
+ value.active_for_models,
6973
+ ["*"],
6974
+ "active_for_models"
6975
+ ),
6833
6976
  runWithModel: optionalString(value.run_with_model, "run_with_model"),
6834
6977
  thinkingLevel: optionalThinking(value.thinking_level),
6835
- timeoutSeconds: optionalPositiveNumber(value.timeout_seconds, "timeout_seconds"),
6978
+ timeoutSeconds: optionalPositiveNumber(
6979
+ value.timeout_seconds,
6980
+ "timeout_seconds"
6981
+ ),
6836
6982
  tools: stringArray(value.tools, [], "tools"),
6837
6983
  prompt,
6838
6984
  filePath
@@ -6840,7 +6986,8 @@ function parseShadowMarkdown(source, filePath) {
6840
6986
  }
6841
6987
  function stringValue(value, fallback, name) {
6842
6988
  if (value === void 0) return fallback;
6843
- if (!isNonEmptyString(value)) throw new Error(`${name} must be a non-empty string`);
6989
+ if (!isNonEmptyString(value))
6990
+ throw new Error(`${name} must be a non-empty string`);
6844
6991
  return value.trim();
6845
6992
  }
6846
6993
  function optionalString(value, name) {
@@ -6853,19 +7000,31 @@ function booleanValue(value, fallback, name) {
6853
7000
  }
6854
7001
  function probabilityValue(value, fallback, name) {
6855
7002
  if (value === void 0) return fallback;
6856
- if (!isFiniteNumber(value) || !inRange(value, 0, 1)) throw new Error(`${name} must be between 0 and 1`);
7003
+ if (!isFiniteNumber(value) || !inRange(value, 0, 1))
7004
+ throw new Error(`${name} must be between 0 and 1`);
6857
7005
  return value;
6858
7006
  }
6859
7007
  function optionalPositiveNumber(value, name) {
6860
7008
  if (value === void 0) return void 0;
6861
- if (!isFiniteNumber(value) || value <= 0) throw new Error(`${name} must be positive`);
7009
+ if (!isFiniteNumber(value) || value <= 0)
7010
+ throw new Error(`${name} must be positive`);
6862
7011
  return value;
6863
7012
  }
6864
7013
  function stringArray(value, fallback, name) {
6865
7014
  if (value === void 0) return [...fallback];
6866
- if (!isStringArray(value)) throw new Error(`${name} must be an array of non-empty strings`);
7015
+ if (!isStringArray(value))
7016
+ throw new Error(`${name} must be an array of non-empty strings`);
6867
7017
  return [...new Set(value.map((item) => item.trim()))];
6868
7018
  }
7019
+ function triggerArray(value, fallback, name) {
7020
+ const values = typeof value === "string" ? [value] : value;
7021
+ const items = stringArray(values, fallback, name);
7022
+ const allowed = new Set(SHADOW_TRIGGERS);
7023
+ const invalid = items.filter((item) => !allowed.has(item));
7024
+ if (invalid.length)
7025
+ throw new Error(`${name} contains invalid trigger: ${invalid.join(", ")}`);
7026
+ return items;
7027
+ }
6869
7028
  function optionalThinking(value) {
6870
7029
  if (value === void 0) return void 0;
6871
7030
  if (!isThinkingLevel(value)) throw new Error("thinking_level is invalid");
@@ -6887,17 +7046,37 @@ var EntityStore = class {
6887
7046
  return shadow;
6888
7047
  }
6889
7048
  async create(draft) {
6890
- if ((await this.list()).some((shadow) => shadow.id === draft.id)) throw new Error(`shadow already exists: ${draft.id}`);
6891
- return this.writeParsed(join3(this.registry.directory, `${draft.id}.md`), draft, { overwrite: false });
7049
+ if ((await this.list()).some((shadow) => shadow.id === draft.id))
7050
+ throw new Error(`shadow already exists: ${draft.id}`);
7051
+ return this.writeParsed(
7052
+ join3(this.registry.directory, `${draft.id}.md`),
7053
+ draft,
7054
+ { overwrite: false }
7055
+ );
6892
7056
  }
6893
7057
  async update(id, patch) {
6894
7058
  const current = await this.get(id);
6895
- return this.writeParsed(current.filePath, { ...current, ...definedOnly(patch), id, prompt: patch.prompt ?? current.prompt }, { overwrite: true });
7059
+ return this.writeParsed(
7060
+ current.filePath,
7061
+ {
7062
+ ...current,
7063
+ ...definedOnly(patch),
7064
+ id,
7065
+ prompt: patch.prompt ?? current.prompt
7066
+ },
7067
+ { overwrite: true }
7068
+ );
6896
7069
  }
6897
7070
  async writeParsed(filePath, draft, options) {
6898
- const source = serializeShadow({ ...draft, prompt: draft.prompt ?? "Describe this Shadow Mind's responsibility." });
7071
+ const source = serializeShadow({
7072
+ ...draft,
7073
+ prompt: draft.prompt ?? "Describe this Shadow Mind's responsibility."
7074
+ });
6899
7075
  const parsed = parseShadowMarkdown(source, filePath);
6900
- await writeFile2(filePath, source, { encoding: "utf8", ...options.overwrite ? {} : { flag: "wx" } });
7076
+ await writeFile2(filePath, source, {
7077
+ encoding: "utf8",
7078
+ ...options.overwrite ? {} : { flag: "wx" }
7079
+ });
6901
7080
  return parsed;
6902
7081
  }
6903
7082
  async setEnabled(id, enabled) {
@@ -6920,6 +7099,7 @@ function serializeShadow(shadow) {
6920
7099
  enabled: shadow.enabled ?? true,
6921
7100
  debug: shadow.debug ?? false,
6922
7101
  activation_probability: shadow.activationProbability ?? 0.3,
7102
+ trigger: shadow.trigger ?? ["heartbeat"],
6923
7103
  active_for_models: shadow.activeForModels ?? ["*"],
6924
7104
  ...shadow.runWithModel ? { run_with_model: shadow.runWithModel } : {},
6925
7105
  ...shadow.thinkingLevel ? { thinking_level: shadow.thinkingLevel } : {},
@@ -6934,21 +7114,37 @@ ${(shadow.prompt ?? "").trim()}
6934
7114
  `;
6935
7115
  }
6936
7116
  function describeShadow(shadow) {
6937
- return `${shadow.enabled ? "enabled" : "disabled"} ${shadow.id} (${shadow.name}) p=${shadow.activationProbability} models=${shadow.activeForModels.join(",")} tools=${shadow.tools.join(",") || "default"} file=${basename2(shadow.filePath)}`;
7117
+ return `${shadow.enabled ? "enabled" : "disabled"} ${shadow.id} (${shadow.name}) p=${shadow.activationProbability} trigger=${shadow.trigger.join(",")} models=${shadow.activeForModels.join(",")} tools=${shadow.tools.join(",") || "default"} file=${basename2(shadow.filePath)}`;
6938
7118
  }
6939
7119
  function definedOnly(value) {
6940
- return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0));
7120
+ return Object.fromEntries(
7121
+ Object.entries(value).filter(([, item]) => item !== void 0)
7122
+ );
6941
7123
  }
6942
7124
 
6943
7125
  // src/management-tools.ts
6944
7126
  import { Type } from "typebox";
6945
7127
  var ID = Type.String({ pattern: "^[a-z0-9][a-z0-9_-]*$" });
6946
- var THINKING = Type.Union([Type.Literal("minimal"), Type.Literal("low"), Type.Literal("medium"), Type.Literal("high"), Type.Literal("xhigh"), Type.Literal("max")]);
7128
+ var THINKING = Type.Union([
7129
+ Type.Literal("minimal"),
7130
+ Type.Literal("low"),
7131
+ Type.Literal("medium"),
7132
+ Type.Literal("high"),
7133
+ Type.Literal("xhigh"),
7134
+ Type.Literal("max")
7135
+ ]);
7136
+ var TRIGGER = Type.Union([
7137
+ Type.Literal("heartbeat"),
7138
+ Type.Literal("final_response")
7139
+ ]);
6947
7140
  var SHADOW_FIELDS = {
6948
7141
  name: Type.Optional(Type.String()),
6949
7142
  enabled: Type.Optional(Type.Boolean()),
6950
7143
  debug: Type.Optional(Type.Boolean()),
6951
- activation_probability: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
7144
+ activation_probability: Type.Optional(
7145
+ Type.Number({ minimum: 0, maximum: 1 })
7146
+ ),
7147
+ trigger: Type.Optional(Type.Array(TRIGGER)),
6952
7148
  active_for_models: Type.Optional(Type.Array(Type.String())),
6953
7149
  run_with_model: Type.Optional(Type.String()),
6954
7150
  thinking_level: Type.Optional(THINKING),
@@ -6967,80 +7163,160 @@ function registerManagementTools(pi, store, getConfig) {
6967
7163
  pi.registerTool(configWriteTool(store, getConfig));
6968
7164
  }
6969
7165
  function listTool(store) {
6970
- return tool("list_shadows", "List Shadows", "List all valid globally configured Shadow Minds.", Type.Object({}), async () => {
6971
- const shadows = await store.list();
6972
- return textResult(shadows.length ? shadows.map(describeShadow).join("\n") : "No Shadow Minds configured.");
6973
- });
7166
+ return tool(
7167
+ "list_shadows",
7168
+ "List Shadows",
7169
+ "List all valid globally configured Shadow Minds.",
7170
+ Type.Object({}),
7171
+ async () => {
7172
+ const shadows = await store.list();
7173
+ return textResult(
7174
+ shadows.length ? shadows.map(describeShadow).join("\n") : "No Shadow Minds configured."
7175
+ );
7176
+ }
7177
+ );
6974
7178
  }
6975
7179
  function createTool(store) {
6976
- return tool("create_shadow", "Create Shadow", "Create a global Shadow Mind definition after user confirmation.", Type.Object({ id: ID, ...SHADOW_FIELDS, prompt: Type.String() }), async (_id, params, _signal, _update, ctx) => {
6977
- const raw = params;
6978
- if (!await confirm(ctx, "Create Shadow Mind", `Create ${String(raw.id)} in the global registry?`)) return textResult("Cancelled.");
6979
- const shadow = await store.create(toDraft(raw));
6980
- return textResult(`Created ${describeShadow(shadow)}`);
6981
- });
7180
+ return tool(
7181
+ "create_shadow",
7182
+ "Create Shadow",
7183
+ "Create a global Shadow Mind definition after user confirmation.",
7184
+ Type.Object({ id: ID, ...SHADOW_FIELDS, prompt: Type.String() }),
7185
+ async (_id, params, _signal, _update, ctx) => {
7186
+ const raw = params;
7187
+ if (!await confirm(
7188
+ ctx,
7189
+ "Create Shadow Mind",
7190
+ `Create ${String(raw.id)} in the global registry?`
7191
+ ))
7192
+ return textResult("Cancelled.");
7193
+ const shadow = await store.create(toDraft(raw));
7194
+ return textResult(`Created ${describeShadow(shadow)}`);
7195
+ }
7196
+ );
6982
7197
  }
6983
7198
  function updateTool(store) {
6984
- return tool("update_shadow", "Update Shadow", "Update a global Shadow Mind definition after user confirmation. The id is immutable.", Type.Object({ id: ID, ...SHADOW_FIELDS }), async (_id, params, _signal, _update, ctx) => {
6985
- const raw = params;
6986
- if (!await confirm(ctx, "Update Shadow Mind", `Apply changes to ${String(raw.id)}?`)) return textResult("Cancelled.");
6987
- const shadow = await store.update(String(raw.id), toPatch(raw));
6988
- return textResult(`Updated ${describeShadow(shadow)}`);
6989
- });
7199
+ return tool(
7200
+ "update_shadow",
7201
+ "Update Shadow",
7202
+ "Update a global Shadow Mind definition after user confirmation. The id is immutable.",
7203
+ Type.Object({ id: ID, ...SHADOW_FIELDS }),
7204
+ async (_id, params, _signal, _update, ctx) => {
7205
+ const raw = params;
7206
+ if (!await confirm(
7207
+ ctx,
7208
+ "Update Shadow Mind",
7209
+ `Apply changes to ${String(raw.id)}?`
7210
+ ))
7211
+ return textResult("Cancelled.");
7212
+ const shadow = await store.update(String(raw.id), toPatch(raw));
7213
+ return textResult(`Updated ${describeShadow(shadow)}`);
7214
+ }
7215
+ );
6990
7216
  }
6991
7217
  function toggleTool(store, enabled) {
6992
7218
  const action = enabled ? "enable" : "disable";
6993
- return tool(`${action}_shadow`, `${enabled ? "Enable" : "Disable"} Shadow`, `${enabled ? "Enable" : "Disable"} a global Shadow Mind after user confirmation.`, Type.Object({ id: ID }), async (_id, params, _signal, _update, ctx) => {
6994
- const id = String(params.id);
6995
- if (!await confirm(ctx, `${enabled ? "Enable" : "Disable"} Shadow Mind`, `${action} ${id}?`)) return textResult("Cancelled.");
6996
- return textResult(describeShadow(await store.setEnabled(id, enabled)));
6997
- });
7219
+ return tool(
7220
+ `${action}_shadow`,
7221
+ `${enabled ? "Enable" : "Disable"} Shadow`,
7222
+ `${enabled ? "Enable" : "Disable"} a global Shadow Mind after user confirmation.`,
7223
+ Type.Object({ id: ID }),
7224
+ async (_id, params, _signal, _update, ctx) => {
7225
+ const id = String(params.id);
7226
+ if (!await confirm(
7227
+ ctx,
7228
+ `${enabled ? "Enable" : "Disable"} Shadow Mind`,
7229
+ `${action} ${id}?`
7230
+ ))
7231
+ return textResult("Cancelled.");
7232
+ return textResult(describeShadow(await store.setEnabled(id, enabled)));
7233
+ }
7234
+ );
6998
7235
  }
6999
7236
  function deleteTool(store) {
7000
- return tool("delete_shadow", "Delete Shadow", "Delete a Shadow Mind definition after user confirmation. Debug logs are retained.", Type.Object({ id: ID }), async (_id, params, _signal, _update, ctx) => {
7001
- const id = String(params.id);
7002
- if (!await confirm(ctx, "Delete Shadow Mind", `Delete ${id}? Its debug logs will be kept.`)) return textResult("Cancelled.");
7003
- await store.delete(id);
7004
- return textResult(`Deleted ${id}; logs were retained.`);
7005
- });
7237
+ return tool(
7238
+ "delete_shadow",
7239
+ "Delete Shadow",
7240
+ "Delete a Shadow Mind definition after user confirmation. Debug logs are retained.",
7241
+ Type.Object({ id: ID }),
7242
+ async (_id, params, _signal, _update, ctx) => {
7243
+ const id = String(params.id);
7244
+ if (!await confirm(
7245
+ ctx,
7246
+ "Delete Shadow Mind",
7247
+ `Delete ${id}? Its debug logs will be kept.`
7248
+ ))
7249
+ return textResult("Cancelled.");
7250
+ await store.delete(id);
7251
+ return textResult(`Deleted ${id}; logs were retained.`);
7252
+ }
7253
+ );
7006
7254
  }
7007
7255
  function configReadTool(store) {
7008
- return tool("get_shadow_config", "Get Shadow Config", "Read the global Shadow Mind runtime configuration.", Type.Object({}), async () => textResult(await store.readConfig()));
7256
+ return tool(
7257
+ "get_shadow_config",
7258
+ "Get Shadow Config",
7259
+ "Read the global Shadow Mind runtime configuration.",
7260
+ Type.Object({}),
7261
+ async () => textResult(await store.readConfig())
7262
+ );
7009
7263
  }
7010
7264
  function configWriteTool(store, getConfig) {
7011
- return tool("update_shadow_config", "Update Shadow Config", "Update global Shadow Mind runtime configuration after user confirmation.", Type.Object({
7012
- heartbeat_probability: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })),
7013
- max_parallel_shadows: Type.Optional(Type.Integer({ minimum: 1 })),
7014
- default_shadow_timeout_seconds: Type.Optional(Type.Number({ exclusiveMinimum: 0 })),
7015
- headless_drain_timeout_seconds: Type.Optional(Type.Number({ exclusiveMinimum: 0 })),
7016
- result_batch_window_ms: Type.Optional(Type.Integer({ minimum: 0 })),
7017
- default_shadow_model: Type.Optional(Type.String()),
7018
- default_thinking_level: Type.Optional(THINKING),
7019
- random_seed: Type.Optional(Type.Integer({ minimum: 0, maximum: 4294967295 }))
7020
- }), async (_id, params, _signal, _update, ctx) => {
7021
- const current = getConfig();
7022
- const raw = params;
7023
- const next = parseConfig({
7024
- heartbeat_probability: raw.heartbeat_probability ?? current.heartbeatProbability,
7025
- max_parallel_shadows: raw.max_parallel_shadows ?? current.maxParallelShadows,
7026
- default_shadow_timeout_seconds: raw.default_shadow_timeout_seconds ?? current.defaultShadowTimeoutSeconds,
7027
- headless_drain_timeout_seconds: raw.headless_drain_timeout_seconds ?? current.headlessDrainTimeoutSeconds,
7028
- result_batch_window_ms: raw.result_batch_window_ms ?? current.resultBatchWindowMs,
7029
- default_shadow_model: raw.default_shadow_model ?? current.defaultShadowModel,
7030
- default_thinking_level: raw.default_thinking_level ?? current.defaultThinkingLevel,
7031
- random_seed: raw.random_seed ?? current.randomSeed
7032
- });
7033
- if (!await confirm(ctx, "Update Shadow Config", `Apply this config?
7034
- ${JSON.stringify(next, null, 2)}`)) return textResult("Cancelled.");
7035
- await store.writeConfig(next);
7036
- return textResult("Shadow Mind config updated.");
7037
- });
7265
+ return tool(
7266
+ "update_shadow_config",
7267
+ "Update Shadow Config",
7268
+ "Update global Shadow Mind runtime configuration after user confirmation.",
7269
+ Type.Object({
7270
+ heartbeat_probability: Type.Optional(
7271
+ Type.Number({ minimum: 0, maximum: 1 })
7272
+ ),
7273
+ max_parallel_shadows: Type.Optional(Type.Integer({ minimum: 1 })),
7274
+ default_shadow_timeout_seconds: Type.Optional(
7275
+ Type.Number({ exclusiveMinimum: 0 })
7276
+ ),
7277
+ headless_drain_timeout_seconds: Type.Optional(
7278
+ Type.Number({ exclusiveMinimum: 0 })
7279
+ ),
7280
+ result_batch_window_ms: Type.Optional(Type.Integer({ minimum: 0 })),
7281
+ default_shadow_model: Type.Optional(Type.String()),
7282
+ default_thinking_level: Type.Optional(THINKING),
7283
+ random_seed: Type.Optional(
7284
+ Type.Integer({ minimum: 0, maximum: 4294967295 })
7285
+ )
7286
+ }),
7287
+ async (_id, params, _signal, _update, ctx) => {
7288
+ const current = getConfig();
7289
+ const raw = params;
7290
+ const next = parseConfig({
7291
+ heartbeat_probability: raw.heartbeat_probability ?? current.heartbeatProbability,
7292
+ max_parallel_shadows: raw.max_parallel_shadows ?? current.maxParallelShadows,
7293
+ default_shadow_timeout_seconds: raw.default_shadow_timeout_seconds ?? current.defaultShadowTimeoutSeconds,
7294
+ headless_drain_timeout_seconds: raw.headless_drain_timeout_seconds ?? current.headlessDrainTimeoutSeconds,
7295
+ result_batch_window_ms: raw.result_batch_window_ms ?? current.resultBatchWindowMs,
7296
+ default_shadow_model: raw.default_shadow_model ?? current.defaultShadowModel,
7297
+ default_thinking_level: raw.default_thinking_level ?? current.defaultThinkingLevel,
7298
+ random_seed: raw.random_seed ?? current.randomSeed
7299
+ });
7300
+ if (!await confirm(
7301
+ ctx,
7302
+ "Update Shadow Config",
7303
+ `Apply this config?
7304
+ ${JSON.stringify(next, null, 2)}`
7305
+ ))
7306
+ return textResult("Cancelled.");
7307
+ await store.writeConfig(next);
7308
+ return textResult("Shadow Mind config updated.");
7309
+ }
7310
+ );
7038
7311
  }
7039
7312
  function tool(name, label, description, parameters, execute) {
7040
7313
  return { name, label, description, parameters, execute };
7041
7314
  }
7042
7315
  async function confirm(ctx, title, message) {
7043
- if (!ctx.hasUI) throw new Error("This write requires a UI confirmation, but no dialog-capable UI is available.");
7316
+ if (!ctx.hasUI)
7317
+ throw new Error(
7318
+ "This write requires a UI confirmation, but no dialog-capable UI is available."
7319
+ );
7044
7320
  return ctx.ui.confirm(title, message);
7045
7321
  }
7046
7322
  function textResult(text) {
@@ -7055,6 +7331,7 @@ function toPatch(raw) {
7055
7331
  enabled: raw.enabled,
7056
7332
  debug: raw.debug,
7057
7333
  activationProbability: raw.activation_probability,
7334
+ trigger: raw.trigger,
7058
7335
  activeForModels: raw.active_for_models,
7059
7336
  runWithModel: raw.run_with_model,
7060
7337
  thinkingLevel: raw.thinking_level,
@@ -7125,16 +7402,34 @@ function createRandom(seed) {
7125
7402
  function shouldEvaluateHeartbeat(toolResults) {
7126
7403
  return toolResults.length > 0;
7127
7404
  }
7405
+ function shouldEvaluateFinalResponse(messages) {
7406
+ const message = messages.at(-1);
7407
+ if (!message || typeof message !== "object" || message.role !== "assistant")
7408
+ return false;
7409
+ const content = message.content;
7410
+ if (typeof content === "string") return content.trim().length > 0;
7411
+ return Array.isArray(content) && content.some((block) => {
7412
+ if (!block || typeof block !== "object") return false;
7413
+ const value = block;
7414
+ return value.type === "text" && typeof value.text === "string" && value.text.trim().length > 0;
7415
+ });
7416
+ }
7128
7417
  function decideHeartbeat(options) {
7129
7418
  const random = options.random ?? Math.random;
7130
7419
  const heartbeatRoll = random();
7131
7420
  if (heartbeatRoll >= options.heartbeatProbability || options.availableSlots <= 0) {
7132
- return { heartbeatRoll, activated: [], candidates: [], modelFiltered: [], runningExcluded: [] };
7421
+ return {
7422
+ heartbeatRoll,
7423
+ activated: [],
7424
+ candidates: [],
7425
+ modelFiltered: [],
7426
+ runningExcluded: []
7427
+ };
7133
7428
  }
7134
7429
  const modelFiltered = [];
7135
7430
  const runningExcluded = [];
7136
7431
  const rolls = options.shadows.filter((shadow) => {
7137
- if (!shadow.enabled) return false;
7432
+ if (!shadow.enabled || !hasTrigger(shadow, "heartbeat")) return false;
7138
7433
  if (options.activeShadowIds.has(shadow.id)) {
7139
7434
  runningExcluded.push(shadow.id);
7140
7435
  return false;
@@ -7145,20 +7440,55 @@ function decideHeartbeat(options) {
7145
7440
  }
7146
7441
  return true;
7147
7442
  }).map((shadow) => ({ shadow, roll: random() }));
7148
- const hits = rolls.filter(({ shadow, roll }) => roll < shadow.activationProbability);
7149
- const selected = sample(hits, Math.min(options.availableSlots, hits.length), random);
7443
+ const hits = rolls.filter(
7444
+ ({ shadow, roll }) => roll < shadow.activationProbability
7445
+ );
7446
+ const selected = sample(
7447
+ hits,
7448
+ Math.min(options.availableSlots, hits.length),
7449
+ random
7450
+ );
7150
7451
  const selectedIds = new Set(selected.map(({ shadow }) => shadow.id));
7151
7452
  return {
7152
7453
  heartbeatRoll,
7153
7454
  activated: selected,
7154
- candidates: rolls.map(({ shadow, roll }) => ({ shadowId: shadow.id, roll, selected: selectedIds.has(shadow.id) })),
7455
+ candidates: rolls.map(({ shadow, roll }) => ({
7456
+ shadowId: shadow.id,
7457
+ roll,
7458
+ selected: selectedIds.has(shadow.id)
7459
+ })),
7155
7460
  modelFiltered,
7156
7461
  runningExcluded
7157
7462
  };
7158
7463
  }
7464
+ function decideFinalResponse(options) {
7465
+ const modelFiltered = [];
7466
+ const candidates = options.shadows.filter((shadow) => {
7467
+ if (!shadow.enabled || !hasTrigger(shadow, "final_response"))
7468
+ return false;
7469
+ if (!matchesModel(shadow, options.mainModelId)) {
7470
+ modelFiltered.push(shadow.id);
7471
+ return false;
7472
+ }
7473
+ return true;
7474
+ }).map((shadow) => ({ shadow, roll: 0 }));
7475
+ return {
7476
+ activated: candidates,
7477
+ candidates: candidates.map(({ shadow, roll }) => ({
7478
+ shadowId: shadow.id,
7479
+ roll,
7480
+ selected: true
7481
+ })),
7482
+ modelFiltered,
7483
+ runningExcluded: []
7484
+ };
7485
+ }
7159
7486
  function matchesModel(shadow, fullModelId) {
7160
7487
  return shadow.activeForModels.includes("*") || shadow.activeForModels.includes(fullModelId);
7161
7488
  }
7489
+ function hasTrigger(shadow, trigger) {
7490
+ return shadow.trigger.includes(trigger);
7491
+ }
7162
7492
  function sample(values, count, random) {
7163
7493
  const copy = [...values];
7164
7494
  for (let index = copy.length - 1; index > 0; index -= 1) {
@@ -7209,8 +7539,70 @@ import {
7209
7539
  createAgentSession
7210
7540
  } from "@earendil-works/pi-coding-agent";
7211
7541
 
7212
- // src/types.ts
7213
- var DEFAULT_READ_TOOLS = ["read", "grep", "find", "ls"];
7542
+ // src/usage.ts
7543
+ function zeroUsage() {
7544
+ return {
7545
+ requests: 0,
7546
+ input: 0,
7547
+ output: 0,
7548
+ cacheRead: 0,
7549
+ cacheWrite: 0,
7550
+ totalTokens: 0,
7551
+ cost: {
7552
+ input: 0,
7553
+ output: 0,
7554
+ cacheRead: 0,
7555
+ cacheWrite: 0,
7556
+ total: 0
7557
+ }
7558
+ };
7559
+ }
7560
+ function addUsage(left, right) {
7561
+ return {
7562
+ requests: left.requests + right.requests,
7563
+ input: left.input + right.input,
7564
+ output: left.output + right.output,
7565
+ cacheRead: left.cacheRead + right.cacheRead,
7566
+ cacheWrite: left.cacheWrite + right.cacheWrite,
7567
+ totalTokens: left.totalTokens + right.totalTokens,
7568
+ cost: {
7569
+ input: left.cost.input + right.cost.input,
7570
+ output: left.cost.output + right.cost.output,
7571
+ cacheRead: left.cost.cacheRead + right.cost.cacheRead,
7572
+ cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
7573
+ total: left.cost.total + right.cost.total
7574
+ }
7575
+ };
7576
+ }
7577
+ function formatUsageTokens(tokens) {
7578
+ if (tokens < 1e3) return tokens.toLocaleString("en-US");
7579
+ if (tokens < 999950) return `${formatCompact(tokens / 1e3)}k`;
7580
+ return `${formatCompact(tokens / 1e6)}m`;
7581
+ }
7582
+ function formatUsageCost(cost) {
7583
+ if (cost === 0) return "$0";
7584
+ if (cost >= 0.01) return `$${cost.toFixed(2)}`;
7585
+ if (cost >= 1e-3) return `$${cost.toFixed(3)}`;
7586
+ return `$${cost.toFixed(4)}`;
7587
+ }
7588
+ function formatUsageSummary(usage) {
7589
+ return `${usage.requests} requests \xB7 ${formatUsageTokens(usage.totalTokens)} tokens \xB7 API ${formatUsageCost(usage.cost.total)}`;
7590
+ }
7591
+ function formatUsageDetail(scope, usage) {
7592
+ return [
7593
+ `usage ${scope}`,
7594
+ `${usage.requests} requests`,
7595
+ `${formatUsageTokens(usage.totalTokens)} total`,
7596
+ `${formatUsageTokens(usage.input)} input`,
7597
+ `${formatUsageTokens(usage.output)} output`,
7598
+ `${formatUsageTokens(usage.cacheRead)} cache read`,
7599
+ `${formatUsageTokens(usage.cacheWrite)} cache write`,
7600
+ `API ${formatUsageCost(usage.cost.total)}`
7601
+ ].join(" \xB7 ");
7602
+ }
7603
+ function formatCompact(value) {
7604
+ return Number(value.toFixed(1)).toString();
7605
+ }
7214
7606
 
7215
7607
  // src/protocol.ts
7216
7608
  var RUNTIME_PROTOCOL = `You are a Shadow Mind running beside the main agent.
@@ -7374,6 +7766,7 @@ function buildRunResult(options) {
7374
7766
  const { reason, error, durationMs, session, baseMessageCount, missingTools, thinkingLevel } = options;
7375
7767
  const ownMessages = session ? session.messages.slice(baseMessageCount) : [];
7376
7768
  const metrics = toolMetrics(ownMessages);
7769
+ const usage = usageMetrics(ownMessages);
7377
7770
  return {
7378
7771
  reason,
7379
7772
  ...error !== void 0 ? { error } : {},
@@ -7381,6 +7774,7 @@ function buildRunResult(options) {
7381
7774
  toolNames: session?.getActiveToolNames() ?? [],
7382
7775
  missingTools,
7383
7776
  ...metrics,
7777
+ usage,
7384
7778
  ...thinkingLevel !== void 0 ? { thinkingLevel } : {},
7385
7779
  sessionFile: session?.sessionFile
7386
7780
  };
@@ -7455,6 +7849,7 @@ var ShadowRunner = class {
7455
7849
  extensions: base.extensions.filter((extension) => !isSelfExtension(extension.resolvedPath))
7456
7850
  })
7457
7851
  });
7852
+ await resourceLoader.reload();
7458
7853
  const reportTool = createReportTool((content) => {
7459
7854
  if (state.reported || controller.signal.aborted) return;
7460
7855
  state.reported = true;
@@ -7501,11 +7896,33 @@ function toolMetrics(messages) {
7501
7896
  }
7502
7897
  const toolStats = [...stats.values()].sort((a, b) => b.calls - a.calls);
7503
7898
  return {
7504
- toolCalls: toolStats.reduce((sum, stat2) => sum + stat2.calls, 0),
7505
- toolFailures: toolStats.reduce((sum, stat2) => sum + stat2.failures, 0),
7899
+ toolCalls: toolStats.reduce((sum, stat3) => sum + stat3.calls, 0),
7900
+ toolFailures: toolStats.reduce((sum, stat3) => sum + stat3.failures, 0),
7506
7901
  toolStats
7507
7902
  };
7508
7903
  }
7904
+ function usageMetrics(messages) {
7905
+ let aggregate = zeroUsage();
7906
+ for (const message of messages) {
7907
+ if (message.role !== "assistant" || !message.usage) continue;
7908
+ aggregate = addUsage(aggregate, {
7909
+ requests: 1,
7910
+ input: message.usage.input,
7911
+ output: message.usage.output,
7912
+ cacheRead: message.usage.cacheRead,
7913
+ cacheWrite: message.usage.cacheWrite,
7914
+ totalTokens: message.usage.totalTokens,
7915
+ cost: {
7916
+ input: message.usage.cost.input,
7917
+ output: message.usage.cost.output,
7918
+ cacheRead: message.usage.cost.cacheRead,
7919
+ cacheWrite: message.usage.cost.cacheWrite,
7920
+ total: message.usage.cost.total
7921
+ }
7922
+ });
7923
+ }
7924
+ return aggregate;
7925
+ }
7509
7926
  function createReportTool(onReport) {
7510
7927
  return {
7511
7928
  name: "report_to_main",
@@ -7566,7 +7983,7 @@ async function waitForSettled(options) {
7566
7983
  const pollMs = options.pollMs ?? 25;
7567
7984
  const quietMs = options.quietMs ?? 50;
7568
7985
  const now = options.now ?? Date.now;
7569
- const delay = options.delay ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
7986
+ const delay2 = options.delay ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms)));
7570
7987
  const started = now();
7571
7988
  let settledSince;
7572
7989
  while (now() - started < options.timeoutMs) {
@@ -7577,24 +7994,402 @@ async function waitForSettled(options) {
7577
7994
  settledSince = void 0;
7578
7995
  }
7579
7996
  const remaining = options.timeoutMs - (now() - started);
7580
- await delay(Math.min(pollMs, Math.max(1, remaining)));
7997
+ await delay2(Math.min(pollMs, Math.max(1, remaining)));
7581
7998
  }
7582
7999
  return { settled: false, durationMs: now() - started };
7583
8000
  }
7584
8001
 
8002
+ // src/usage-store.ts
8003
+ import { randomUUID as randomUUID2 } from "node:crypto";
8004
+ import { mkdir as mkdir5, readFile as readFile5, rename as rename2, rm as rm2, writeFile as writeFile4 } from "node:fs/promises";
8005
+ import { dirname as dirname3, join as join6 } from "node:path";
8006
+
8007
+ // src/usage-file-lock.ts
8008
+ import { randomUUID } from "node:crypto";
8009
+ import { mkdir as mkdir4, readFile as readFile4, rename, rm, stat as stat2, utimes, writeFile as writeFile3 } from "node:fs/promises";
8010
+ import { join as join5 } from "node:path";
8011
+ var USAGE_LOCK_OWNER_FILE = "owner";
8012
+ var USAGE_LOCK_OPERATION_DIRECTORY = ".operation";
8013
+ async function acquireUsageFileLock(usagePath, timing = {}) {
8014
+ const resolved = resolveTiming(timing);
8015
+ const lockPath = `${usagePath}.lock`;
8016
+ const owner = { token: randomUUID(), pid: process.pid };
8017
+ while (true) {
8018
+ try {
8019
+ await mkdir4(lockPath);
8020
+ } catch (error) {
8021
+ if (error.code !== "EEXIST") throw error;
8022
+ await recoverStaleUsageLock(lockPath, resolved);
8023
+ await delay(resolved.retryMs);
8024
+ continue;
8025
+ }
8026
+ try {
8027
+ await writeOwner(lockPath, owner);
8028
+ } catch (error) {
8029
+ await rm(lockPath, { recursive: true, force: true }).catch(() => void 0);
8030
+ throw error;
8031
+ }
8032
+ return startLease(lockPath, owner, resolved);
8033
+ }
8034
+ }
8035
+ function startLease(lockPath, owner, timing) {
8036
+ let stopped = false;
8037
+ let heartbeat = Promise.resolve();
8038
+ const timer = setInterval(() => {
8039
+ heartbeat = heartbeat.then(async () => {
8040
+ if (stopped) return;
8041
+ const current = await readUsageLockState(lockPath);
8042
+ if (!current || current.owner?.token !== owner.token) {
8043
+ stopped = true;
8044
+ return;
8045
+ }
8046
+ const now = /* @__PURE__ */ new Date();
8047
+ await utimes(join5(lockPath, USAGE_LOCK_OWNER_FILE), now, now);
8048
+ }).catch(() => void 0);
8049
+ }, timing.heartbeatMs);
8050
+ timer.unref();
8051
+ return async () => {
8052
+ stopped = true;
8053
+ clearInterval(timer);
8054
+ await heartbeat;
8055
+ await releaseUsageLock(lockPath, owner.token, timing);
8056
+ };
8057
+ }
8058
+ async function recoverStaleUsageLock(lockPath, timing) {
8059
+ const observed = await readUsageLockState(lockPath);
8060
+ if (!observed || !isRecoverableUsageLock(observed, timing.staleMs)) return;
8061
+ const operationPath = join5(lockPath, USAGE_LOCK_OPERATION_DIRECTORY);
8062
+ if (!await claimUsageLockOperation(operationPath, timing)) return;
8063
+ try {
8064
+ const current = await readUsageLockState(lockPath);
8065
+ if (!current || !sameUsageLock(current, observed) || !isRecoverableUsageLock(current, timing.staleMs)) return;
8066
+ const abandonedPath = `${lockPath}.${randomUUID()}.stale`;
8067
+ try {
8068
+ await rename(lockPath, abandonedPath);
8069
+ } catch (error) {
8070
+ if (error.code === "ENOENT") return;
8071
+ throw error;
8072
+ }
8073
+ await rm(abandonedPath, { recursive: true, force: true });
8074
+ } finally {
8075
+ await rm(operationPath, { recursive: true, force: true }).catch(() => void 0);
8076
+ }
8077
+ }
8078
+ async function releaseUsageLock(lockPath, token, timing) {
8079
+ const operationPath = join5(lockPath, USAGE_LOCK_OPERATION_DIRECTORY);
8080
+ while (true) {
8081
+ const observed = await readUsageLockState(lockPath);
8082
+ if (!observed || observed.owner?.token !== token) return;
8083
+ if (!await claimUsageLockOperation(operationPath, timing)) {
8084
+ await delay(timing.retryMs);
8085
+ continue;
8086
+ }
8087
+ try {
8088
+ const current = await readUsageLockState(lockPath);
8089
+ if (!current || current.owner?.token !== token) return;
8090
+ const releasedPath = `${lockPath}.${token}.released`;
8091
+ try {
8092
+ await rename(lockPath, releasedPath);
8093
+ } catch (error) {
8094
+ if (error.code === "ENOENT") continue;
8095
+ throw error;
8096
+ }
8097
+ await rm(releasedPath, { recursive: true, force: true });
8098
+ return;
8099
+ } finally {
8100
+ await rm(operationPath, { recursive: true, force: true }).catch(() => void 0);
8101
+ }
8102
+ }
8103
+ }
8104
+ async function claimUsageLockOperation(operationPath, timing) {
8105
+ const owner = { token: randomUUID(), pid: process.pid };
8106
+ try {
8107
+ await mkdir4(operationPath);
8108
+ try {
8109
+ await writeOwner(operationPath, owner);
8110
+ } catch (error) {
8111
+ await rm(operationPath, { recursive: true, force: true }).catch(() => void 0);
8112
+ throw error;
8113
+ }
8114
+ return true;
8115
+ } catch (error) {
8116
+ if (error.code !== "EEXIST") {
8117
+ if (error.code === "ENOENT") return false;
8118
+ throw error;
8119
+ }
8120
+ }
8121
+ const observed = await readUsageLockState(operationPath);
8122
+ if (!observed || !isRecoverableUsageLock(observed, timing.staleMs)) return false;
8123
+ const abandonedPath = `${operationPath}.${randomUUID()}.stale`;
8124
+ try {
8125
+ await rename(operationPath, abandonedPath);
8126
+ } catch (error) {
8127
+ if (error.code === "ENOENT") return false;
8128
+ throw error;
8129
+ }
8130
+ await rm(abandonedPath, { recursive: true, force: true });
8131
+ return false;
8132
+ }
8133
+ async function writeOwner(path, owner) {
8134
+ await writeFile3(join5(path, USAGE_LOCK_OWNER_FILE), JSON.stringify(owner), {
8135
+ encoding: "utf8",
8136
+ flag: "wx"
8137
+ });
8138
+ }
8139
+ async function readUsageLockState(lockPath) {
8140
+ let lock;
8141
+ try {
8142
+ lock = await stat2(lockPath);
8143
+ } catch (error) {
8144
+ if (error.code === "ENOENT") return void 0;
8145
+ throw error;
8146
+ }
8147
+ try {
8148
+ const ownerPath = join5(lockPath, USAGE_LOCK_OWNER_FILE);
8149
+ const [raw, ownerFile] = await Promise.all([readFile4(ownerPath, "utf8"), stat2(ownerPath)]);
8150
+ return {
8151
+ owner: parseOwner(raw),
8152
+ mtimeMs: ownerFile.mtimeMs,
8153
+ dev: lock.dev,
8154
+ ino: lock.ino
8155
+ };
8156
+ } catch (error) {
8157
+ if (error.code !== "ENOENT") throw error;
8158
+ return { mtimeMs: lock.mtimeMs, dev: lock.dev, ino: lock.ino };
8159
+ }
8160
+ }
8161
+ function parseOwner(raw) {
8162
+ try {
8163
+ const value = JSON.parse(raw);
8164
+ if (typeof value.token !== "string" || !value.token || !Number.isSafeInteger(value.pid) || value.pid <= 0) return void 0;
8165
+ return { token: value.token, pid: value.pid };
8166
+ } catch {
8167
+ return void 0;
8168
+ }
8169
+ }
8170
+ function isRecoverableUsageLock(lock, staleMs) {
8171
+ return Date.now() - lock.mtimeMs >= staleMs && !isProcessAlive(lock.owner?.pid);
8172
+ }
8173
+ function isProcessAlive(pid) {
8174
+ if (pid === void 0) return false;
8175
+ try {
8176
+ process.kill(pid, 0);
8177
+ return true;
8178
+ } catch (error) {
8179
+ return error.code !== "ESRCH";
8180
+ }
8181
+ }
8182
+ function sameUsageLock(left, right) {
8183
+ return left.owner !== void 0 && right.owner !== void 0 ? left.owner.token === right.owner.token : left.owner === right.owner && left.dev === right.dev && left.ino === right.ino;
8184
+ }
8185
+ function resolveTiming(timing) {
8186
+ return {
8187
+ retryMs: timing.retryMs ?? 25,
8188
+ staleMs: timing.staleMs ?? 3e4,
8189
+ heartbeatMs: timing.heartbeatMs ?? 1e4
8190
+ };
8191
+ }
8192
+ function delay(ms) {
8193
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
8194
+ }
8195
+
8196
+ // src/usage-store.ts
8197
+ var USAGE_DOCUMENT_VERSION = 1;
8198
+ var UsageStore = class {
8199
+ usagePath;
8200
+ lifetime = zeroUsage();
8201
+ pendingUsage = zeroUsage();
8202
+ lastError;
8203
+ initialization;
8204
+ pendingWrite = Promise.resolve();
8205
+ constructor(agentDir) {
8206
+ this.usagePath = join6(agentDir, "shadow-minds", "usage.json");
8207
+ }
8208
+ initialize() {
8209
+ this.initialization ??= this.load();
8210
+ return this.initialization;
8211
+ }
8212
+ get current() {
8213
+ return {
8214
+ ...this.lifetime,
8215
+ cost: { ...this.lifetime.cost }
8216
+ };
8217
+ }
8218
+ get error() {
8219
+ return this.lastError;
8220
+ }
8221
+ add(usage) {
8222
+ this.lifetime = addUsage(this.lifetime, usage);
8223
+ this.pendingUsage = addUsage(this.pendingUsage, usage);
8224
+ return this.enqueueWrite();
8225
+ }
8226
+ async flush() {
8227
+ await this.pendingWrite;
8228
+ if (!isZeroUsage(this.pendingUsage)) await this.enqueueWrite();
8229
+ }
8230
+ enqueueWrite() {
8231
+ this.pendingWrite = this.pendingWrite.then(
8232
+ () => this.writePending(),
8233
+ () => this.writePending()
8234
+ ).then(
8235
+ () => {
8236
+ this.lastError = void 0;
8237
+ },
8238
+ (error) => {
8239
+ this.lastError = error instanceof Error ? error.message : String(error);
8240
+ }
8241
+ );
8242
+ return this.pendingWrite;
8243
+ }
8244
+ async load() {
8245
+ try {
8246
+ const raw = await readFile5(this.usagePath, "utf8");
8247
+ this.lifetime = parseUsageDocument(JSON.parse(raw)).lifetime;
8248
+ this.lastError = void 0;
8249
+ } catch (error) {
8250
+ if (error.code === "ENOENT") {
8251
+ this.lifetime = zeroUsage();
8252
+ this.lastError = void 0;
8253
+ return;
8254
+ }
8255
+ this.lifetime = zeroUsage();
8256
+ this.lastError = error instanceof Error ? error.message : String(error);
8257
+ }
8258
+ }
8259
+ async writePending() {
8260
+ const delta = this.pendingUsage;
8261
+ if (isZeroUsage(delta)) return;
8262
+ await mkdir5(dirname3(this.usagePath), { recursive: true });
8263
+ const releaseLock = await acquireUsageFileLock(this.usagePath);
8264
+ try {
8265
+ const committed = addUsage(await readUsageLifetime(this.usagePath), delta);
8266
+ await writeUsageDocument(this.usagePath, committed);
8267
+ this.pendingUsage = subtractUsage(this.pendingUsage, delta);
8268
+ this.lifetime = addUsage(committed, this.pendingUsage);
8269
+ } finally {
8270
+ await releaseLock();
8271
+ }
8272
+ }
8273
+ };
8274
+ function subtractUsage(left, right) {
8275
+ return {
8276
+ requests: left.requests - right.requests,
8277
+ input: left.input - right.input,
8278
+ output: left.output - right.output,
8279
+ cacheRead: left.cacheRead - right.cacheRead,
8280
+ cacheWrite: left.cacheWrite - right.cacheWrite,
8281
+ totalTokens: left.totalTokens - right.totalTokens,
8282
+ cost: {
8283
+ input: left.cost.input - right.cost.input,
8284
+ output: left.cost.output - right.cost.output,
8285
+ cacheRead: left.cost.cacheRead - right.cost.cacheRead,
8286
+ cacheWrite: left.cost.cacheWrite - right.cost.cacheWrite,
8287
+ total: left.cost.total - right.cost.total
8288
+ }
8289
+ };
8290
+ }
8291
+ function isZeroUsage(usage) {
8292
+ return usage.requests === 0 && usage.input === 0 && usage.output === 0 && usage.cacheRead === 0 && usage.cacheWrite === 0 && usage.totalTokens === 0 && usage.cost.input === 0 && usage.cost.output === 0 && usage.cost.cacheRead === 0 && usage.cost.cacheWrite === 0 && usage.cost.total === 0;
8293
+ }
8294
+ async function readUsageLifetime(usagePath) {
8295
+ try {
8296
+ const raw = await readFile5(usagePath, "utf8");
8297
+ return parseUsageDocument(JSON.parse(raw)).lifetime;
8298
+ } catch (error) {
8299
+ if (error.code === "ENOENT") return zeroUsage();
8300
+ throw error;
8301
+ }
8302
+ }
8303
+ async function writeUsageDocument(usagePath, lifetime) {
8304
+ const temporaryPath = `${usagePath}.${randomUUID2()}.tmp`;
8305
+ try {
8306
+ const document = { version: USAGE_DOCUMENT_VERSION, lifetime };
8307
+ await writeFile4(temporaryPath, `${JSON.stringify(document, null, 2)}
8308
+ `, "utf8");
8309
+ await rename2(temporaryPath, usagePath);
8310
+ } finally {
8311
+ await rm2(temporaryPath, { force: true }).catch(() => void 0);
8312
+ }
8313
+ }
8314
+ function parseUsageDocument(input) {
8315
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
8316
+ throw new Error("usage must be a JSON object");
8317
+ }
8318
+ const value = input;
8319
+ if (value.version !== USAGE_DOCUMENT_VERSION) throw new Error("usage version is unsupported");
8320
+ return { version: USAGE_DOCUMENT_VERSION, lifetime: parseUsage(value.lifetime, "lifetime") };
8321
+ }
8322
+ function parseUsage(input, name) {
8323
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
8324
+ throw new Error(`${name} must be an object`);
8325
+ }
8326
+ const value = input;
8327
+ const cost = value.cost;
8328
+ if (cost === null || typeof cost !== "object" || Array.isArray(cost)) {
8329
+ throw new Error(`${name}.cost must be an object`);
8330
+ }
8331
+ const costValue = cost;
8332
+ return {
8333
+ requests: nonNegativeInteger2(value.requests, `${name}.requests`),
8334
+ input: nonNegativeNumber(value.input, `${name}.input`),
8335
+ output: nonNegativeNumber(value.output, `${name}.output`),
8336
+ cacheRead: nonNegativeNumber(value.cacheRead, `${name}.cacheRead`),
8337
+ cacheWrite: nonNegativeNumber(value.cacheWrite, `${name}.cacheWrite`),
8338
+ totalTokens: nonNegativeNumber(value.totalTokens, `${name}.totalTokens`),
8339
+ cost: {
8340
+ input: nonNegativeNumber(costValue.input, `${name}.cost.input`),
8341
+ output: nonNegativeNumber(costValue.output, `${name}.cost.output`),
8342
+ cacheRead: nonNegativeNumber(costValue.cacheRead, `${name}.cost.cacheRead`),
8343
+ cacheWrite: nonNegativeNumber(costValue.cacheWrite, `${name}.cost.cacheWrite`),
8344
+ total: nonNegativeNumber(costValue.total, `${name}.cost.total`)
8345
+ }
8346
+ };
8347
+ }
8348
+ function nonNegativeNumber(value, name) {
8349
+ if (!isFiniteNumber(value) || value < 0) {
8350
+ throw new Error(`${name} must be a non-negative finite number`);
8351
+ }
8352
+ return value;
8353
+ }
8354
+ function nonNegativeInteger2(value, name) {
8355
+ const number = nonNegativeNumber(value, name);
8356
+ if (!Number.isSafeInteger(number)) throw new Error(`${name} must be a non-negative safe integer`);
8357
+ return number;
8358
+ }
8359
+
7585
8360
  // src/runtime.ts
8361
+ var SESSION_TEARDOWN_TIMEOUT_MS = 1e3;
7586
8362
  var ShadowMindRuntime = class {
7587
8363
  constructor(pi) {
7588
8364
  this.pi = pi;
7589
- this.batcher = new ReportBatcher(this.configStore.current.resultBatchWindowMs, (reports) => this.deliverReports(reports));
8365
+ this.batcher = new ReportBatcher(
8366
+ this.configStore.current.resultBatchWindowMs,
8367
+ (reports) => {
8368
+ this.completionReview.invalidate();
8369
+ void this.deliverReports(reports);
8370
+ }
8371
+ );
7590
8372
  }
7591
8373
  agentDir = getAgentDir();
7592
8374
  configStore = new ConfigStore(this.agentDir);
7593
8375
  registry = new ShadowRegistry(this.agentDir);
7594
- entityStore = new EntityStore(this.registry, this.configStore.configPath);
8376
+ entityStore = new EntityStore(
8377
+ this.registry,
8378
+ this.configStore.configPath
8379
+ );
7595
8380
  runner = new ShadowRunner();
8381
+ usageStore = new UsageStore(this.agentDir);
7596
8382
  active = /* @__PURE__ */ new Map();
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
+ });
7597
8391
  recentEvents = [];
8392
+ recentRuns = [];
7598
8393
  batcher;
7599
8394
  sessionLifetime = new SessionLifetime();
7600
8395
  epoch = 0;
@@ -7603,10 +8398,16 @@ var ShadowMindRuntime = class {
7603
8398
  panelVisible = false;
7604
8399
  latestContext;
7605
8400
  diagnostics = [];
8401
+ sessionUsage = zeroUsage();
7606
8402
  shadowCount = 0;
8403
+ completedWithFinalText = false;
7607
8404
  random = Math.random;
7608
8405
  register() {
7609
- registerManagementTools(this.pi, this.entityStore, () => this.configStore.current);
8406
+ registerManagementTools(
8407
+ this.pi,
8408
+ this.entityStore,
8409
+ () => this.configStore.current
8410
+ );
7610
8411
  this.registerEvents();
7611
8412
  this.registerUi();
7612
8413
  }
@@ -7615,16 +8416,23 @@ var ShadowMindRuntime = class {
7615
8416
  this.sessionLifetime.activate();
7616
8417
  this.latestContext = ctx;
7617
8418
  this.modelCalls = 0;
8419
+ this.completedWithFinalText = false;
8420
+ this.sessionUsage = zeroUsage();
8421
+ this.recentRuns.length = 0;
7618
8422
  await this.configStore.initialize();
8423
+ await this.usageStore.initialize();
7619
8424
  this.random = createRandom(this.configStore.current.randomSeed);
7620
8425
  await this.registry.initialize();
7621
8426
  await this.refresh(ctx);
7622
- this.record("session-config", { randomSeed: this.configStore.current.randomSeed ?? "random" });
8427
+ this.record("session-config", {
8428
+ randomSeed: this.configStore.current.randomSeed ?? "random"
8429
+ });
7623
8430
  this.updateStatus(ctx);
7624
8431
  });
7625
8432
  this.pi.on("input", (event, ctx) => {
7626
8433
  this.latestContext = ctx;
7627
8434
  if (event.source === "extension") return;
8435
+ this.completedWithFinalText = false;
7628
8436
  this.epoch += 1;
7629
8437
  this.abortAll("new-user-input");
7630
8438
  });
@@ -7635,11 +8443,29 @@ var ShadowMindRuntime = class {
7635
8443
  this.pi.on("turn_end", async (event, ctx) => {
7636
8444
  this.latestContext = ctx;
7637
8445
  if (!shouldEvaluateHeartbeat(event.toolResults)) {
7638
- this.record("heartbeat-skipped", { reason: "no-tool-activity", modelCalls: this.modelCalls });
8446
+ this.record("heartbeat-skipped", {
8447
+ reason: "no-tool-activity",
8448
+ modelCalls: this.modelCalls
8449
+ });
7639
8450
  return;
7640
8451
  }
7641
8452
  await this.onHeartbeat(ctx);
7642
8453
  });
8454
+ this.pi.on("agent_end", (event, ctx) => {
8455
+ this.latestContext = ctx;
8456
+ this.completedWithFinalText = shouldEvaluateFinalResponse(event.messages);
8457
+ });
8458
+ this.pi.on("agent_settled", async (_event, ctx) => {
8459
+ this.latestContext = ctx;
8460
+ if (!this.completedWithFinalText) {
8461
+ this.record("final-response-skipped", {
8462
+ reason: "no-final-assistant-text"
8463
+ });
8464
+ return;
8465
+ }
8466
+ this.completedWithFinalText = false;
8467
+ await this.onFinalResponse(ctx);
8468
+ });
7643
8469
  this.pi.on("session_shutdown", async (event, ctx) => {
7644
8470
  this.latestContext = ctx;
7645
8471
  if (event.reason === "quit" && (ctx.mode === "print" || ctx.mode === "json")) {
@@ -7647,6 +8473,14 @@ var ShadowMindRuntime = class {
7647
8473
  }
7648
8474
  this.epoch += 1;
7649
8475
  this.abortAll("session-shutdown");
8476
+ if (this.active.size > 0) {
8477
+ const result = await waitForSettled({
8478
+ timeoutMs: SESSION_TEARDOWN_TIMEOUT_MS,
8479
+ isSettled: () => this.active.size === 0
8480
+ });
8481
+ if (!result.settled) this.active.clear();
8482
+ }
8483
+ await this.usageStore.flush();
7650
8484
  ctx.ui.setStatus("shadow-mind", void 0);
7651
8485
  ctx.ui.setWidget("shadow-mind-panel", void 0);
7652
8486
  this.sessionLifetime.deactivate();
@@ -7672,7 +8506,10 @@ var ShadowMindRuntime = class {
7672
8506
  }
7673
8507
  if (command === "status") {
7674
8508
  await this.refresh(ctx);
7675
- ctx.ui.notify(this.statusLines().join("\n"), this.diagnostics.length ? "warning" : "info");
8509
+ ctx.ui.notify(
8510
+ this.statusLines().join("\n"),
8511
+ this.diagnostics.length ? "warning" : "info"
8512
+ );
7676
8513
  } else if (command === "hide") {
7677
8514
  this.panelVisible = false;
7678
8515
  ctx.ui.setWidget("shadow-mind-panel", void 0);
@@ -7690,25 +8527,35 @@ var ShadowMindRuntime = class {
7690
8527
  this.setPaused(!this.paused, ctx);
7691
8528
  }
7692
8529
  });
7693
- this.pi.registerMessageRenderer("shadow-report", (message, _options, theme) => {
7694
- const content = typeof message.content === "string" ? message.content : "Shadow report";
7695
- const prefix = theme.fg("accent", "\u{1F419} shadow \xB7 ");
7696
- return new Text(`${prefix}${content}`, 0, 0);
7697
- });
8530
+ this.pi.registerMessageRenderer(
8531
+ "shadow-report",
8532
+ (message, _options, theme) => {
8533
+ const content = typeof message.content === "string" ? message.content : "Shadow report";
8534
+ const prefix = theme.fg("accent", "\u{1F419} shadow \xB7 ");
8535
+ return new Text(`${prefix}${content}`, 0, 0);
8536
+ }
8537
+ );
7698
8538
  }
7699
8539
  async onHeartbeat(ctx) {
7700
- await this.refresh(ctx);
8540
+ const snapshot = await this.refresh(ctx);
7701
8541
  if (this.paused || !ctx.model) {
7702
- this.record("heartbeat-skipped", { reason: this.paused ? "paused" : "no-model", modelCalls: this.modelCalls });
8542
+ this.record("heartbeat-skipped", {
8543
+ reason: this.paused ? "paused" : "no-model",
8544
+ modelCalls: this.modelCalls
8545
+ });
7703
8546
  return;
7704
8547
  }
7705
- const snapshot = await this.registry.load();
7706
8548
  const fullModelId = `${ctx.model.provider}/${ctx.model.id}`;
7707
8549
  const decision = decideHeartbeat({
7708
8550
  heartbeatProbability: this.configStore.current.heartbeatProbability,
7709
- availableSlots: Math.max(0, this.configStore.current.maxParallelShadows - this.active.size),
8551
+ availableSlots: Math.max(
8552
+ 0,
8553
+ this.configStore.current.maxParallelShadows - this.active.size
8554
+ ),
7710
8555
  shadows: snapshot.shadows,
7711
- activeShadowIds: new Set([...this.active.values()].map(({ shadow }) => shadow.id)),
8556
+ activeShadowIds: new Set(
8557
+ [...this.active.values()].map(({ shadow }) => shadow.id)
8558
+ ),
7712
8559
  mainModelId: fullModelId,
7713
8560
  random: this.random
7714
8561
  });
@@ -7716,22 +8563,99 @@ var ShadowMindRuntime = class {
7716
8563
  modelCalls: this.modelCalls,
7717
8564
  roll: decision.heartbeatRoll,
7718
8565
  candidates: decision.candidates,
7719
- activated: decision.activated.map(({ shadow, roll }) => ({ id: shadow.id, roll })),
8566
+ activated: decision.activated.map(({ shadow, roll }) => ({
8567
+ id: shadow.id,
8568
+ roll
8569
+ })),
7720
8570
  ...decision.modelFiltered.length ? { modelFiltered: decision.modelFiltered } : {},
7721
8571
  ...decision.runningExcluded.length ? { runningExcluded: decision.runningExcluded } : {}
7722
8572
  });
7723
8573
  if (!decision.activated.length) return;
7724
- const context = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
7725
- const availableTools = new Set(this.pi.getAllTools().map((tool2) => tool2.name));
8574
+ const context = buildSessionContext(
8575
+ ctx.sessionManager.getEntries(),
8576
+ ctx.sessionManager.getLeafId()
8577
+ );
8578
+ const availableTools = new Set(
8579
+ this.pi.getAllTools().map((tool2) => tool2.name)
8580
+ );
7726
8581
  for (const { shadow } of decision.activated) {
7727
- this.launchShadow(ctx, shadow, ctx.model, fullModelId, context, availableTools);
8582
+ this.launchShadow({
8583
+ ctx,
8584
+ shadow,
8585
+ mainModel: ctx.model,
8586
+ fullModelId,
8587
+ context,
8588
+ availableTools
8589
+ });
7728
8590
  }
7729
8591
  }
7730
- launchShadow(ctx, shadow, mainModel, fullModelId, context, availableTools) {
7731
- const runId = randomUUID();
7732
- const runEpoch = this.epoch;
8592
+ async onFinalResponse(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
+ }
8599
+ if (this.paused || !ctx.model) {
8600
+ this.completionReview.cancel(request);
8601
+ this.record("final-response-skipped", {
8602
+ reason: this.paused ? "paused" : "no-model"
8603
+ });
8604
+ return;
8605
+ }
8606
+ const mainModel = ctx.model;
8607
+ const fullModelId = `${mainModel.provider}/${mainModel.id}`;
8608
+ const decision = decideFinalResponse({
8609
+ shadows: snapshot.shadows,
8610
+ mainModelId: fullModelId
8611
+ });
8612
+ this.record("final-response", {
8613
+ candidates: decision.candidates,
8614
+ activated: decision.activated.map(({ shadow }) => shadow.id),
8615
+ ...decision.modelFiltered.length ? { modelFiltered: decision.modelFiltered } : {},
8616
+ ...decision.runningExcluded.length ? { runningExcluded: decision.runningExcluded } : {}
8617
+ });
8618
+ if (!decision.activated.length) {
8619
+ this.completionReview.schedule(request, []);
8620
+ return;
8621
+ }
8622
+ const context = buildSessionContext(
8623
+ ctx.sessionManager.getEntries(),
8624
+ ctx.sessionManager.getLeafId()
8625
+ );
8626
+ const availableTools = new Set(
8627
+ this.pi.getAllTools().map((tool2) => tool2.name)
8628
+ );
8629
+ this.completionReview.schedule(
8630
+ request,
8631
+ decision.activated.map(({ shadow }) => ({
8632
+ epoch: request.epoch,
8633
+ shadowId: shadow.id,
8634
+ ctx,
8635
+ shadow,
8636
+ mainModel,
8637
+ fullModelId,
8638
+ context,
8639
+ availableTools
8640
+ }))
8641
+ );
8642
+ }
8643
+ launchShadow(options) {
8644
+ const {
8645
+ ctx,
8646
+ shadow,
8647
+ mainModel,
8648
+ fullModelId,
8649
+ context,
8650
+ availableTools,
8651
+ completionReview
8652
+ } = options;
8653
+ const runId = randomUUID3();
8654
+ const runEpoch = options.epoch ?? this.epoch;
7733
8655
  const { tools, missing } = resolveShadowTools(shadow.tools, availableTools);
7734
- 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);
7735
8659
  this.record("run-start", {
7736
8660
  runId,
7737
8661
  shadowId: shadow.id,
@@ -7747,15 +8671,22 @@ var ShadowMindRuntime = class {
7747
8671
  cwd: ctx.cwd,
7748
8672
  agentDir: this.agentDir,
7749
8673
  mainSystemPrompt: ctx.getSystemPrompt(),
8674
+ // SAFETY: buildSessionContext returns Pi messages; ShadowRunner treats them as read-only records.
7750
8675
  messages: context.messages,
7751
8676
  mainModel,
7752
8677
  tools,
7753
8678
  resolveModel: (id) => resolveModel(ctx, id),
7754
8679
  modelAuthOk: (model) => ctx.modelRegistry.hasConfiguredAuth(model) || ctx.modelRegistry.isUsingOAuth(model),
7755
8680
  mainThinkingLevel: ctx.thinkingLevel,
7756
- onReport: (report) => this.acceptReport(report)
8681
+ onReport: (report) => {
8682
+ if (completionReview) completionReview.accept(report);
8683
+ else this.acceptReport(report);
8684
+ }
7757
8685
  }).then((result) => this.handleRunEnd(runId, shadow, result)).catch((error) => {
8686
+ const activeRun2 = this.active.get(runId);
7758
8687
  this.active.delete(runId);
8688
+ this.completionReview.slotAvailable();
8689
+ this.completionReview.complete(activeRun2?.completionReview);
7759
8690
  this.record("run-end", {
7760
8691
  runId,
7761
8692
  shadowId: shadow.id,
@@ -7765,9 +8696,29 @@ var ShadowMindRuntime = class {
7765
8696
  });
7766
8697
  }
7767
8698
  handleRunEnd(runId, shadow, result) {
8699
+ const activeRun = this.active.get(runId);
7768
8700
  this.active.delete(runId);
8701
+ this.completionReview.slotAvailable();
8702
+ const persisted = this.usageStore.add(result.usage);
8703
+ if (activeRun?.epoch !== this.epoch) {
8704
+ this.completionReview.complete(activeRun?.completionReview);
8705
+ return;
8706
+ }
8707
+ this.sessionUsage = addUsage(this.sessionUsage, result.usage);
8708
+ this.recentRuns.push({
8709
+ shadowName: shadow.name,
8710
+ completedAt: (/* @__PURE__ */ new Date()).toISOString(),
8711
+ result
8712
+ });
8713
+ if (this.recentRuns.length > 5) this.recentRuns.shift();
7769
8714
  this.record("run-end", { runId, shadowId: shadow.id, ...result });
7770
- if (this.latestContext && this.sessionLifetime.isActive) this.updateStatus(this.latestContext);
8715
+ if (this.latestContext && this.sessionLifetime.isActive)
8716
+ this.updateStatus(this.latestContext);
8717
+ this.completionReview.complete(activeRun.completionReview);
8718
+ void persisted.then(() => {
8719
+ if (this.latestContext && this.sessionLifetime.isActive)
8720
+ this.updateStatus(this.latestContext);
8721
+ });
7771
8722
  }
7772
8723
  acceptReport(report) {
7773
8724
  if (report.epoch !== this.epoch) return;
@@ -7777,15 +8728,26 @@ var ShadowMindRuntime = class {
7777
8728
  const current = reports.filter((report) => report.epoch === this.epoch);
7778
8729
  if (!current.length) return;
7779
8730
  const content = formatReportBatch(current);
7780
- this.record("report-delivered", { runIds: current.map((report) => report.runId), count: current.length });
8731
+ this.record("report-delivered", {
8732
+ runIds: current.map((report) => report.runId),
8733
+ count: current.length
8734
+ });
7781
8735
  const idle = this.latestContext?.isIdle() ?? true;
7782
8736
  this.sessionLifetime.run(() => {
7783
- this.pi.sendMessage({
7784
- customType: "shadow-report",
7785
- content,
7786
- display: true,
7787
- details: { reports: current.map(({ shadowId, runId }) => ({ shadowId, runId })) }
7788
- }, { triggerTurn: true, deliverAs: idle ? "followUp" : "steer" });
8737
+ this.pi.sendMessage(
8738
+ {
8739
+ customType: "shadow-report",
8740
+ content,
8741
+ display: true,
8742
+ details: {
8743
+ reports: current.map(({ shadowId, runId }) => ({
8744
+ shadowId,
8745
+ runId
8746
+ }))
8747
+ }
8748
+ },
8749
+ { triggerTurn: true, deliverAs: idle ? "followUp" : "steer" }
8750
+ );
7789
8751
  });
7790
8752
  }
7791
8753
  async refresh(ctx) {
@@ -7795,57 +8757,94 @@ var ShadowMindRuntime = class {
7795
8757
  this.shadowCount = registry.shadows.length;
7796
8758
  this.diagnostics = [
7797
8759
  ...config.error ? [`config: ${config.error}`] : [],
7798
- ...registry.diagnostics.map((item) => `${item.filePath}: ${item.message}`)
8760
+ ...registry.diagnostics.map(
8761
+ (item) => `${item.filePath}: ${item.message}`
8762
+ )
7799
8763
  ];
7800
8764
  this.updateStatus(ctx);
8765
+ return registry;
7801
8766
  }
7802
8767
  abortAll(reason) {
7803
8768
  this.runner.abortAll();
8769
+ this.completionReview.invalidate();
7804
8770
  this.batcher.clear();
7805
8771
  this.record("runs-aborted", { reason, count: this.active.size });
7806
8772
  }
7807
8773
  async drainHeadless(ctx) {
7808
8774
  if (this.active.size === 0 && !this.batcher.hasPending) return;
7809
8775
  const timeoutMs = this.configStore.current.headlessDrainTimeoutSeconds * 1e3;
7810
- this.record("headless-drain-start", { timeoutMs, active: this.active.size });
8776
+ this.record("headless-drain-start", {
8777
+ timeoutMs,
8778
+ active: this.active.size
8779
+ });
7811
8780
  const result = await waitForSettled({
7812
8781
  timeoutMs,
7813
8782
  isSettled: () => this.active.size === 0 && !this.batcher.hasPending && ctx.isIdle() && !ctx.hasPendingMessages()
7814
8783
  });
7815
- this.record(result.settled ? "headless-drain-complete" : "headless-drain-timeout", {
7816
- durationMs: result.durationMs,
7817
- active: this.active.size
7818
- });
8784
+ this.record(
8785
+ result.settled ? "headless-drain-complete" : "headless-drain-timeout",
8786
+ {
8787
+ durationMs: result.durationMs,
8788
+ active: this.active.size
8789
+ }
8790
+ );
7819
8791
  if (!result.settled) this.abortAll("headless-drain-timeout");
7820
8792
  }
7821
8793
  record(kind, data) {
7822
- const event = { at: (/* @__PURE__ */ new Date()).toISOString(), kind, epoch: this.epoch, data };
8794
+ const event = {
8795
+ at: (/* @__PURE__ */ new Date()).toISOString(),
8796
+ kind,
8797
+ epoch: this.epoch,
8798
+ data
8799
+ };
7823
8800
  this.recentEvents.push(event);
7824
8801
  if (this.recentEvents.length > 20) this.recentEvents.shift();
7825
- this.sessionLifetime.run(() => this.pi.appendEntry("shadow-mind-event", event));
8802
+ this.sessionLifetime.run(
8803
+ () => this.pi.appendEntry("shadow-mind-event", event)
8804
+ );
7826
8805
  }
7827
8806
  setPaused(paused, ctx) {
7828
8807
  this.paused = paused;
7829
8808
  if (paused) this.abortAll("paused");
7830
- ctx.ui.notify(paused ? "Shadow Mind paused" : "Shadow Mind resumed", "info");
8809
+ ctx.ui.notify(
8810
+ paused ? "Shadow Mind paused" : "Shadow Mind resumed",
8811
+ "info"
8812
+ );
7831
8813
  this.updateStatus(ctx);
7832
8814
  }
7833
8815
  updateStatus(ctx) {
7834
8816
  this.sessionLifetime.run(() => {
7835
- const warning = this.diagnostics.length || this.hasRecentRunErrors() ? " !" : "";
7836
- ctx.ui.setStatus("shadow-mind", this.paused ? `\u{1F419} Paused${warning}` : `\u{1F419} ${this.active.size}${warning}`);
7837
- if (this.panelVisible) ctx.ui.setWidget("shadow-mind-panel", this.statusLines(), { placement: "aboveEditor" });
8817
+ const diagnostics = this.usageStore.error ? [...this.diagnostics, `usage: ${this.usageStore.error}`] : this.diagnostics;
8818
+ const warning = diagnostics.length || this.hasRecentRunErrors() ? " !" : "";
8819
+ const usage = `${formatUsageTokens(this.sessionUsage.totalTokens)} \xB7 ${formatUsageCost(this.sessionUsage.cost.total)}`;
8820
+ ctx.ui.setStatus(
8821
+ "shadow-mind",
8822
+ this.paused ? `\u{1F419} Paused \xB7 ${usage}${warning}` : `\u{1F419} ${this.active.size} \xB7 ${usage}${warning}`
8823
+ );
8824
+ if (this.panelVisible)
8825
+ ctx.ui.setWidget("shadow-mind-panel", this.statusLines(), {
8826
+ placement: "aboveEditor"
8827
+ });
7838
8828
  });
7839
8829
  }
7840
8830
  hasRecentRunErrors() {
7841
- return this.recentEvents.slice(-3).some((event) => event.kind === "run-end" && (event.data?.reason === "error" || event.data?.reason === "timeout"));
8831
+ return this.recentEvents.slice(-3).some(
8832
+ (event) => event.kind === "run-end" && (event.data?.reason === "error" || event.data?.reason === "timeout")
8833
+ );
7842
8834
  }
7843
8835
  statusLines() {
7844
8836
  const config = this.configStore.current;
8837
+ const diagnostics = this.usageStore.error ? [...this.diagnostics, `usage: ${this.usageStore.error}`] : this.diagnostics;
7845
8838
  return [
7846
8839
  `\u{1F419} Shadow Mind \xB7 ${this.paused ? "paused" : "active"} \xB7 running ${this.active.size}/${config.maxParallelShadows}`,
7847
8840
  `heartbeat ${formatNumber(config.heartbeatProbability)} \xB7 batch ${config.resultBatchWindowMs}ms \xB7 timeout ${config.defaultShadowTimeoutSeconds}s \xB7 drain ${config.headlessDrainTimeoutSeconds}s \xB7 thinking ${config.defaultThinkingLevel}`,
7848
8841
  `definitions: ${this.shadowCount} valid \xB7 ${this.diagnostics.length} invalid`,
8842
+ formatUsageDetail("session", this.sessionUsage),
8843
+ `usage lifetime \xB7 ${formatUsageSummary(this.usageStore.current)}`,
8844
+ ...this.recentRuns.slice(-3).map(
8845
+ ({ shadowName, completedAt, result }) => `recent run \xB7 ${formatCompletedAt(completedAt)} \xB7 ${shadowName} \xB7 ${result.reason} \xB7 ${formatUsageSummary(result.usage)}`
8846
+ ),
8847
+ ...diagnostics.map((diagnostic) => `diagnostic: ${diagnostic}`),
7849
8848
  ...this.recentEvents.slice(-5).map((event) => {
7850
8849
  const failed = event.kind === "run-end" && (event.data?.reason === "error" || event.data?.reason === "timeout");
7851
8850
  const detail = failed ? ` ${event.data?.error ?? event.data?.reason}` : "";
@@ -7858,11 +8857,22 @@ var ShadowMindRuntime = class {
7858
8857
  function resolveModel(ctx, fullId) {
7859
8858
  const separator = fullId.indexOf("/");
7860
8859
  if (separator <= 0 || separator === fullId.length - 1) return void 0;
7861
- return ctx.modelRegistry.find(fullId.slice(0, separator), fullId.slice(separator + 1));
8860
+ return ctx.modelRegistry.find(
8861
+ fullId.slice(0, separator),
8862
+ fullId.slice(separator + 1)
8863
+ );
7862
8864
  }
7863
8865
  function formatNumber(value) {
7864
8866
  return Number(value.toFixed(3)).toString();
7865
8867
  }
8868
+ function formatCompletedAt(completedAt) {
8869
+ return new Date(completedAt).toLocaleTimeString("en-GB", {
8870
+ hour: "2-digit",
8871
+ minute: "2-digit",
8872
+ second: "2-digit",
8873
+ hourCycle: "h23"
8874
+ });
8875
+ }
7866
8876
 
7867
8877
  // src/index.ts
7868
8878
  function shadowMindExtension(pi) {