pi-advisor-flow 0.9.2-dev.3 → 0.9.2-dev.4

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/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ### Fixed
10
+
11
+ - Prevented settings saves from overwriting malformed `advisor.json` files and rolled back runtime settings when persistence fails.
12
+ - Kept trusted project preferences inside the trusted project during file replacement races and isolated Advisor safety state between sessions.
13
+ - Stopped discarded streaming renders from retaining repaint timers, made failed manual submissions retryable, and bounded unterminated-secret redaction.
14
+ - Preserved `=` in model arguments, enforced UTF-8 byte caps on reattached advice, deduplicated withheld image paths, and avoided false zero-cost totals for incomplete provider usage.
15
+
9
16
  ### Changed
10
17
 
11
18
  - Pi 0.87.x and 0.99.x hosts are no longer supported; Pi 1.0.x is now the supported Pi host and the peer range is `^1.0.0`.
package/dist/index.js CHANGED
@@ -826,7 +826,9 @@ var parseArgs = (args) => {
826
826
  let nextAdvisor = advisorRef;
827
827
  let nextContextMaxChars = contextMaxCharsRef;
828
828
  for (const token of args.trim().split(ARGUMENT_WHITESPACE).filter(Boolean)) {
829
- const [key, value] = token.split("=");
829
+ const separator = token.indexOf("=");
830
+ const key = separator === -1 ? token : token.slice(0, separator);
831
+ const value = separator === -1 ? undefined : token.slice(separator + 1);
830
832
  if (key === "executor" && value) {
831
833
  nextExecutor = value;
832
834
  }
@@ -971,6 +973,21 @@ var readExistingConfig = (path) => {
971
973
  return {};
972
974
  }
973
975
  };
976
+ var readConfigForSave = (path) => {
977
+ if (!existsSync(path)) {
978
+ return {};
979
+ }
980
+ let parsed;
981
+ try {
982
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
983
+ } catch (error) {
984
+ throw new Error(`Cannot save Advisor configuration at ${path}: existing JSON is malformed; fix advisor.json before saving.`, { cause: error });
985
+ }
986
+ if (!isRecord(parsed)) {
987
+ throw new Error(`Cannot save Advisor configuration at ${path}: configuration must be a JSON object.`);
988
+ }
989
+ return parsed;
990
+ };
974
991
  var shouldPersistConfigKey = (key, persistAdvisor, persistExecutor) => (key !== "advisor" || persistAdvisor) && (key !== "executor" || persistExecutor);
975
992
  var applyChangedConfigValues = (existing, current, changedKeys, persistAdvisor, persistExecutor) => {
976
993
  const dropped = new Set;
@@ -1052,13 +1069,13 @@ var saveConfig = (_ctx, options = {}) => {
1052
1069
  const path = join(getAgentDir(), "advisor.json");
1053
1070
  const persistAdvisor = options.persistAdvisor ?? true;
1054
1071
  const persistExecutor = options.persistExecutor ?? true;
1055
- const existing = readExistingConfig(path);
1056
1072
  const current = currentConfigState();
1057
1073
  const baseline = loadedConfigPath === path ? loadedConfigState : undefined;
1058
1074
  const changedKeys = baseline ? SAVED_CONFIG_KEYS.filter((key) => !sameConfigValue(current[key], baseline[key])) : [...SAVED_CONFIG_KEYS];
1059
1075
  if (changedKeys.length === 0) {
1060
1076
  return path;
1061
1077
  }
1078
+ const existing = readConfigForSave(path);
1062
1079
  const data = applyChangedConfigValues(existing, current, changedKeys, persistAdvisor, persistExecutor);
1063
1080
  writeFileSync(path, `${JSON.stringify(data, null, 2)}
1064
1081
  `);
@@ -1076,7 +1093,7 @@ var saveConfig = (_ctx, options = {}) => {
1076
1093
  };
1077
1094
  var saveGlobalOutcomeLogging = (enabled) => {
1078
1095
  const path = join(getAgentDir(), "advisor.json");
1079
- const existing = readExistingConfig(path);
1096
+ const existing = readConfigForSave(path);
1080
1097
  writeFileSync(path, `${JSON.stringify({ ...existing, advisorOutcomeLogging: enabled }, null, 2)}
1081
1098
  `);
1082
1099
  resetConfigCache();
@@ -1505,7 +1522,7 @@ var REDACTION_MARKER = "[REDACTED SECRET]";
1505
1522
  var PEM_BEGIN_PATTERN = /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----/giu;
1506
1523
  var PEM_END_PATTERN = /-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/iu;
1507
1524
  var CREDENTIAL_NAME = String.raw`(?:api[_-]?key|token|secret|password|passwd)`;
1508
- var SECRET_ASSIGNMENT_PATTERN = new RegExp(String.raw`(?:(?:"(?:[a-z0-9]+[_-])*?${CREDENTIAL_NAME}"|'(?:[a-z0-9]+[_-])*?${CREDENTIAL_NAME}')|(?:\b|_)${CREDENTIAL_NAME})\s*[:=]\s*(?:"(?:\\.|[^"])*(?:"|$)|'(?:\\.|[^'])*(?:'|$)|[^\s"'&,;)}\]]+)`, "giu");
1525
+ var SECRET_ASSIGNMENT_PATTERN = new RegExp(String.raw`(?:(?:"(?:[a-z0-9]+[_-])*?${CREDENTIAL_NAME}"|'(?:[a-z0-9]+[_-])*?${CREDENTIAL_NAME}')|(?:\b|_)${CREDENTIAL_NAME})[ \t]*[:=][ \t]*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|"(?:\\.|[^"\r\n])*(?=\r?\n|$)|'(?:\\.|[^'\r\n])*(?=\r?\n|$)|[^\s"'&,;)}\]]+)`, "giu");
1509
1526
  var SECRET_PATTERNS = [
1510
1527
  /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/giu,
1511
1528
  /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/giu,
@@ -2408,7 +2425,9 @@ var readImageAttachment = async (root, name, mimeType, remaining) => {
2408
2425
  }
2409
2426
  };
2410
2427
  var readImageFiles = async (cwd, requested, enabled, kind, remainingBytes, remainingCount) => {
2411
- const names = Array.isArray(requested) ? requested.filter((path) => isString(path) && imageFileCandidate(path)) : [];
2428
+ const names = [
2429
+ ...new Set(Array.isArray(requested) ? requested.filter((path) => isString(path) && imageFileCandidate(path)) : [])
2430
+ ];
2412
2431
  if (!enabled || !names.length) {
2413
2432
  return { images: [], omitted: names.length };
2414
2433
  }
@@ -2445,6 +2464,7 @@ var readImageFiles = async (cwd, requested, enabled, kind, remainingBytes, remai
2445
2464
  };
2446
2465
 
2447
2466
  // src/preferences.ts
2467
+ import { constants as constants3 } from "node:fs";
2448
2468
  import { lstat as lstat3, open as open3, realpath as realpath3 } from "node:fs/promises";
2449
2469
  import { join as join2, relative as relative2 } from "node:path";
2450
2470
  var PREFERENCES_MAX_BYTES = 8 * 1024;
@@ -2468,8 +2488,13 @@ var readProjectPreferences = async (ctx, maxBytes = PREFERENCES_MAX_BYTES, redac
2468
2488
  if (!inside(root, resolved)) {
2469
2489
  return;
2470
2490
  }
2471
- const file = await open3(resolved, "r");
2491
+ const flags = constants3.O_NOFOLLOW ? constants3.O_RDONLY + constants3.O_NOFOLLOW : constants3.O_RDONLY;
2492
+ const file = await open3(resolved, flags);
2472
2493
  try {
2494
+ const opened = await file.stat();
2495
+ if (!opened.isFile() || opened.dev !== stats.dev || opened.ino !== stats.ino) {
2496
+ return;
2497
+ }
2473
2498
  const buffer = Buffer.alloc(maxBytes + 1);
2474
2499
  const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
2475
2500
  const source = buffer.subarray(0, bytesRead).toString("utf-8");
@@ -3100,6 +3125,10 @@ var buildScoutManifest = (ctx, options = {}) => {
3100
3125
  var finite = (value) => isNumber(value) && Number.isFinite(value) && value >= 0 ? value : undefined;
3101
3126
  var add = (left, right) => left === undefined || right === undefined ? left ?? right : left + right;
3102
3127
  var costFields = ["input", "output", "cacheRead", "cacheWrite", "total"];
3128
+ var hasPartialProviderCost = (snapshot, usage) => {
3129
+ const cost = isRecordOf(usage.cost) ? usage.cost : undefined;
3130
+ return cost !== undefined && snapshot.cost === undefined && costFields.some((field) => finite(cost[field]) !== undefined);
3131
+ };
3103
3132
  var snapshotAdvisorUsage = (usage) => {
3104
3133
  if (!isRecordOf(usage)) {
3105
3134
  return;
@@ -3117,18 +3146,29 @@ var snapshotAdvisorUsage = (usage) => {
3117
3146
  return Object.values(snapshot).some((value) => value !== undefined) || hasCostField ? snapshot : undefined;
3118
3147
  };
3119
3148
  var advisorUsageCost = (usage) => snapshotAdvisorUsage(usage)?.cost;
3120
- var advisorUsageForPi = (usage) => {
3149
+ var completeUsageSnapshot = (usage) => {
3121
3150
  const snapshot = snapshotAdvisorUsage(usage);
3122
3151
  if (!(snapshot && isRecordOf(usage))) {
3123
3152
  return;
3124
3153
  }
3125
- const cost = isRecordOf(usage.cost) ? usage.cost : undefined;
3154
+ if (hasPartialProviderCost(snapshot, usage)) {
3155
+ return;
3156
+ }
3157
+ return { snapshot, usage };
3158
+ };
3159
+ var advisorUsageForPi = (usage) => {
3160
+ const complete = completeUsageSnapshot(usage);
3161
+ if (!complete) {
3162
+ return;
3163
+ }
3164
+ const { snapshot } = complete;
3165
+ const cost = isRecordOf(complete.usage.cost) ? complete.usage.cost : undefined;
3126
3166
  const input = snapshot.input ?? 0;
3127
3167
  const output = snapshot.output ?? 0;
3128
3168
  const cacheRead = snapshot.cacheRead ?? 0;
3129
3169
  const cacheWrite = snapshot.cacheWrite ?? 0;
3130
- const cacheWrite1h = finite(usage.cacheWrite1h);
3131
- const reasoning = finite(usage.reasoning);
3170
+ const cacheWrite1h = finite(complete.usage.cacheWrite1h);
3171
+ const reasoning = finite(complete.usage.reasoning);
3132
3172
  const piUsage = {
3133
3173
  cacheRead,
3134
3174
  cacheWrite,
@@ -4659,6 +4699,16 @@ class AdvisorSessionState {
4659
4699
 
4660
4700
  // src/tools/session.ts
4661
4701
  var advisorSessionState = new AdvisorSessionState;
4702
+ var sessionStates = new WeakMap;
4703
+ var sessionStateFor = (owner) => {
4704
+ const existing = sessionStates.get(owner);
4705
+ if (existing) {
4706
+ return existing;
4707
+ }
4708
+ const state = new AdvisorSessionState;
4709
+ sessionStates.set(owner, state);
4710
+ return state;
4711
+ };
4662
4712
 
4663
4713
  // src/commands/runtime.ts
4664
4714
  var notify = (ctx, message, level) => {
@@ -4690,7 +4740,7 @@ class CommandRuntime {
4690
4740
  lastSameModelDisabled;
4691
4741
  constructor(pi, dependencies = {}) {
4692
4742
  this.pi = pi;
4693
- this.advisorSessionState = dependencies.sessionState ?? advisorSessionState;
4743
+ this.advisorSessionState = dependencies.sessionState ?? sessionStateFor(pi);
4694
4744
  this.herdrActivity = dependencies.herdrActivity ?? herdrAdvisorActivity;
4695
4745
  this.scoutStatus = dependencies.statusManager ?? new ScoutStatusManager(false);
4696
4746
  this.requestAdvisor = dependencies.consult ?? ((ctx, question, signal, onChunk, onScout, gitContext) => consultAdvisor(ctx, question, signal, onChunk, "manual", gitContext, undefined, undefined, undefined, onScout));
@@ -5032,7 +5082,8 @@ class ManualAdvisorDialog {
5032
5082
  focusTarget = "editor";
5033
5083
  state = {
5034
5084
  completed: false,
5035
- focused: false
5085
+ focused: false,
5086
+ submitting: false
5036
5087
  };
5037
5088
  get focused() {
5038
5089
  return this.state.focused;
@@ -5204,17 +5255,22 @@ class ManualAdvisorDialog {
5204
5255
  return this.options.keybindings.matches(keyData, action) || matchesKey(keyData, fallback);
5205
5256
  }
5206
5257
  submit(message = this.editor.getText()) {
5207
- if (this.state.completed) {
5258
+ if (this.state.completed || this.state.submitting) {
5208
5259
  return;
5209
5260
  }
5210
- this.state.completed = true;
5261
+ this.state.submitting = true;
5211
5262
  const request = {
5212
5263
  gitContext: this.gitLevels[this.gitIndex] ?? "off"
5213
5264
  };
5214
5265
  if (message) {
5215
5266
  request.message = message;
5216
5267
  }
5217
- this.options.onSubmit(request);
5268
+ try {
5269
+ this.options.onSubmit(request);
5270
+ this.state.completed = true;
5271
+ } finally {
5272
+ this.state.submitting = false;
5273
+ }
5218
5274
  }
5219
5275
  cancel() {
5220
5276
  if (this.state.completed) {
@@ -5855,7 +5911,7 @@ var screenConsultation = (ctx, session, options, deps = {}) => {
5855
5911
  decision: "skip",
5856
5912
  kind: "repeat",
5857
5913
  reason: "already answered earlier in this session",
5858
- reattachedAdvice: reattached.slice(0, REATTACHED_ADVICE_CAP_BYTES)
5914
+ reattachedAdvice: capUtf8Bytes(reattached, REATTACHED_ADVICE_CAP_BYTES)
5859
5915
  });
5860
5916
  }
5861
5917
  if (!advisorJevFilterEnabledRef) {
@@ -7368,13 +7424,19 @@ var applyAdvisorSettings = (settings) => {
7368
7424
  applyDisclosureSettings(settings);
7369
7425
  };
7370
7426
  var saveAdvisorSettings = (ctx, settings) => {
7371
- applyAdvisorSettings(settings);
7372
- const persisted = getPersistedModelRefs();
7373
- saveConfig(ctx, {
7374
- persistAdvisor: Boolean(persisted.advisor),
7375
- persistExecutor: Boolean(persisted.executor)
7376
- });
7377
- saveGlobalOutcomeLogging(settings.outcomeLogging ?? false);
7427
+ const previous = getAdvisorSettings();
7428
+ try {
7429
+ applyAdvisorSettings(settings);
7430
+ const persisted = getPersistedModelRefs();
7431
+ saveConfig(ctx, {
7432
+ persistAdvisor: Boolean(persisted.advisor),
7433
+ persistExecutor: Boolean(persisted.executor)
7434
+ });
7435
+ saveGlobalOutcomeLogging(settings.outcomeLogging ?? false);
7436
+ } catch (error) {
7437
+ applyAdvisorSettings(previous);
7438
+ throw error;
7439
+ }
7378
7440
  };
7379
7441
 
7380
7442
  // src/commands/settings-commands.ts
@@ -7753,11 +7815,23 @@ import { Box as Box3, Markdown as Markdown4, Spacer, Text as Text5 } from "@eare
7753
7815
  var advisorResultDetails = (result) => result.details;
7754
7816
  var syncRenderPhase = (context, phase) => {
7755
7817
  if (context.state.phase !== phase && context.state.timerId) {
7756
- clearInterval(context.state.timerId);
7818
+ clearTimeout(context.state.timerId);
7757
7819
  context.state.timerId = undefined;
7758
7820
  }
7759
7821
  context.state.phase = phase;
7760
7822
  };
7823
+ var scheduleRender = (context) => {
7824
+ const timer = setTimeout(() => {
7825
+ if (context.state.timerId !== timer) {
7826
+ return;
7827
+ }
7828
+ context.state.timerId = undefined;
7829
+ try {
7830
+ context.invalidate();
7831
+ } catch {}
7832
+ }, 80);
7833
+ context.state.timerId = timer;
7834
+ };
7761
7835
  var formatByteSize = (bytes) => {
7762
7836
  if (bytes >= 1024 ** 3) {
7763
7837
  return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
@@ -7796,14 +7870,7 @@ var renderPartialAdvisorResult = (box, result, expanded, theme, context) => {
7796
7870
  const scoutActive = scout?.status === "calling" || scout?.status === "streaming";
7797
7871
  syncRenderPhase(context, scoutActive ? "scout" : "advisor");
7798
7872
  if (!context.state.timerId) {
7799
- context.state.timerId = setInterval(() => {
7800
- try {
7801
- context.invalidate();
7802
- } catch {
7803
- clearInterval(context.state.timerId);
7804
- context.state.timerId = undefined;
7805
- }
7806
- }, 80);
7873
+ scheduleRender(context);
7807
7874
  }
7808
7875
  if (scout) {
7809
7876
  renderScoutDetails(box, scout, expanded, theme);
@@ -7846,7 +7913,7 @@ var finalResultLines = (details, advice, theme) => {
7846
7913
  var renderFinalAdvisorResult = (box, result, expanded, theme, context) => {
7847
7914
  syncRenderPhase(context, "final");
7848
7915
  if (context.state.timerId) {
7849
- clearInterval(context.state.timerId);
7916
+ clearTimeout(context.state.timerId);
7850
7917
  context.state.timerId = undefined;
7851
7918
  }
7852
7919
  const details = advisorResultDetails(result);
@@ -8487,7 +8554,7 @@ var registerToolRenderers = (pi) => {
8487
8554
  };
8488
8555
 
8489
8556
  // src/tools/registration.ts
8490
- var registerAdvisorTool = (pi, session = advisorSessionState, dependencies = {}) => {
8557
+ var registerAdvisorTool = (pi, session = sessionStateFor(pi), dependencies = {}) => {
8491
8558
  const registration = {
8492
8559
  appendOutcome: dependencies.appendOutcome ?? appendOutcome,
8493
8560
  consult: dependencies.consult ?? consultAdvisor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-advisor-flow",
3
- "version": "0.9.2-dev.3",
3
+ "version": "0.9.2-dev.4",
4
4
  "description": "Advanced Executor/Advisor flow for Pi, fully configurable and extendable.",
5
5
  "keywords": [
6
6
  "advisor",
@@ -12,7 +12,7 @@ import {
12
12
  sameModelAdvisorNotice,
13
13
  } from "../tools/model-access.ts";
14
14
  import { ScoutStatusManager } from "../tools/scout-status.ts";
15
- import { advisorSessionState as defaultAdvisorSessionState } from "../tools/session.ts";
15
+ import { sessionStateFor } from "../tools/session.ts";
16
16
  import { uiAction } from "../ui-guard.ts";
17
17
  import type {
18
18
  CommandDependencies,
@@ -60,8 +60,7 @@ class CommandRuntime implements CommandRuntimeContract {
60
60
 
61
61
  constructor(pi: ExtensionAPI, dependencies: CommandDependencies = {}) {
62
62
  this.pi = pi;
63
- this.advisorSessionState =
64
- dependencies.sessionState ?? defaultAdvisorSessionState;
63
+ this.advisorSessionState = dependencies.sessionState ?? sessionStateFor(pi);
65
64
  this.herdrActivity = dependencies.herdrActivity ?? herdrAdvisorActivity;
66
65
  this.scoutStatus =
67
66
  dependencies.statusManager ?? new ScoutStatusManager(false);
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import {
4
+ getAdvisorSettings,
4
5
  getPersistedModelRefs,
5
6
  setAdvisorAutoLoopGateRef,
6
7
  setAdvisorBlockOnBlockedRef,
@@ -140,11 +141,17 @@ export const saveAdvisorSettings = (
140
141
  ctx: ExtensionContext,
141
142
  settings: AdvisorSettings
142
143
  ) => {
143
- applyAdvisorSettings(settings);
144
- const persisted = getPersistedModelRefs();
145
- saveConfig(ctx, {
146
- persistAdvisor: Boolean(persisted.advisor),
147
- persistExecutor: Boolean(persisted.executor),
148
- });
149
- saveGlobalOutcomeLogging(settings.outcomeLogging ?? false);
144
+ const previous = getAdvisorSettings();
145
+ try {
146
+ applyAdvisorSettings(settings);
147
+ const persisted = getPersistedModelRefs();
148
+ saveConfig(ctx, {
149
+ persistAdvisor: Boolean(persisted.advisor),
150
+ persistExecutor: Boolean(persisted.executor),
151
+ });
152
+ saveGlobalOutcomeLogging(settings.outcomeLogging ?? false);
153
+ } catch (error) {
154
+ applyAdvisorSettings(previous);
155
+ throw error;
156
+ }
150
157
  };
@@ -16,7 +16,9 @@ export const parseArgs = (args: string): string | undefined => {
16
16
  let nextAdvisor = advisorRef;
17
17
  let nextContextMaxChars = contextMaxCharsRef;
18
18
  for (const token of args.trim().split(ARGUMENT_WHITESPACE).filter(Boolean)) {
19
- const [key, value] = token.split("=");
19
+ const separator = token.indexOf("=");
20
+ const key = separator === -1 ? token : token.slice(0, separator);
21
+ const value = separator === -1 ? undefined : token.slice(separator + 1);
20
22
  if (key === "executor" && value) {
21
23
  nextExecutor = value;
22
24
  }
@@ -52,6 +52,27 @@ export const readExistingConfig = (path: string): RecordValue => {
52
52
  }
53
53
  };
54
54
 
55
+ const readConfigForSave = (path: string): RecordValue => {
56
+ if (!existsSync(path)) {
57
+ return {};
58
+ }
59
+ let parsed: unknown;
60
+ try {
61
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
62
+ } catch (error) {
63
+ throw new Error(
64
+ `Cannot save Advisor configuration at ${path}: existing JSON is malformed; fix advisor.json before saving.`,
65
+ { cause: error }
66
+ );
67
+ }
68
+ if (!isRecord(parsed)) {
69
+ throw new Error(
70
+ `Cannot save Advisor configuration at ${path}: configuration must be a JSON object.`
71
+ );
72
+ }
73
+ return parsed;
74
+ };
75
+
55
76
  const shouldPersistConfigKey = (
56
77
  key: SavedConfigKey,
57
78
  persistAdvisor: boolean,
@@ -197,7 +218,6 @@ export const saveConfig = (
197
218
  const path = join(getAgentDir(), "advisor.json");
198
219
  const persistAdvisor = options.persistAdvisor ?? true;
199
220
  const persistExecutor = options.persistExecutor ?? true;
200
- const existing = readExistingConfig(path);
201
221
  const current = currentConfigState();
202
222
  const baseline = loadedConfigPath === path ? loadedConfigState : undefined;
203
223
  const changedKeys = baseline
@@ -208,6 +228,7 @@ export const saveConfig = (
208
228
  if (changedKeys.length === 0) {
209
229
  return path;
210
230
  }
231
+ const existing = readConfigForSave(path);
211
232
  const data = applyChangedConfigValues(
212
233
  existing,
213
234
  current,
@@ -240,7 +261,7 @@ export const saveConfig = (
240
261
  /** Outcome logging is deliberately written only to the global Pi configuration. */
241
262
  export const saveGlobalOutcomeLogging = (enabled: boolean) => {
242
263
  const path = join(getAgentDir(), "advisor.json");
243
- const existing = readExistingConfig(path);
264
+ const existing = readConfigForSave(path);
244
265
  writeFileSync(
245
266
  path,
246
267
  `${JSON.stringify({ ...existing, advisorOutcomeLogging: enabled }, null, 2)}\n`
@@ -72,9 +72,13 @@ export const readImageFiles = async (
72
72
  remainingBytes: number,
73
73
  remainingCount: number
74
74
  ): Promise<{ images: ImageAttachment[]; omitted: number }> => {
75
- const names = Array.isArray(requested)
76
- ? requested.filter((path) => isString(path) && imageFileCandidate(path))
77
- : [];
75
+ const names = [
76
+ ...new Set(
77
+ Array.isArray(requested)
78
+ ? requested.filter((path) => isString(path) && imageFileCandidate(path))
79
+ : []
80
+ ),
81
+ ];
78
82
  if (!enabled || !names.length) {
79
83
  return { images: [], omitted: names.length };
80
84
  }
@@ -1,3 +1,4 @@
1
+ import { constants } from "node:fs";
1
2
  import { lstat, open, realpath } from "node:fs/promises";
2
3
  import { join, relative } from "node:path";
3
4
 
@@ -38,8 +39,19 @@ export const readProjectPreferences = async (
38
39
  if (!inside(root, resolved)) {
39
40
  return;
40
41
  }
41
- const file = await open(resolved, "r");
42
+ const flags = constants.O_NOFOLLOW
43
+ ? constants.O_RDONLY + constants.O_NOFOLLOW
44
+ : constants.O_RDONLY;
45
+ const file = await open(resolved, flags);
42
46
  try {
47
+ const opened = await file.stat();
48
+ if (
49
+ !opened.isFile() ||
50
+ opened.dev !== stats.dev ||
51
+ opened.ino !== stats.ino
52
+ ) {
53
+ return;
54
+ }
43
55
  const buffer = Buffer.alloc(maxBytes + 1);
44
56
  const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
45
57
  const source = buffer.subarray(0, bytesRead).toString("utf-8");
package/src/redaction.ts CHANGED
@@ -5,7 +5,7 @@ const PEM_BEGIN_PATTERN = /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----/giu;
5
5
  const PEM_END_PATTERN = /-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/iu;
6
6
  const CREDENTIAL_NAME = String.raw`(?:api[_-]?key|token|secret|password|passwd)`;
7
7
  const SECRET_ASSIGNMENT_PATTERN = new RegExp(
8
- String.raw`(?:(?:"(?:[a-z0-9]+[_-])*?${CREDENTIAL_NAME}"|'(?:[a-z0-9]+[_-])*?${CREDENTIAL_NAME}')|(?:\b|_)${CREDENTIAL_NAME})\s*[:=]\s*(?:"(?:\\.|[^"])*(?:"|$)|'(?:\\.|[^'])*(?:'|$)|[^\s"'&,;)}\]]+)`,
8
+ String.raw`(?:(?:"(?:[a-z0-9]+[_-])*?${CREDENTIAL_NAME}"|'(?:[a-z0-9]+[_-])*?${CREDENTIAL_NAME}')|(?:\b|_)${CREDENTIAL_NAME})[ \t]*[:=][ \t]*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|"(?:\\.|[^"\r\n])*(?=\r?\n|$)|'(?:\\.|[^'\r\n])*(?=\r?\n|$)|[^\s"'&,;)}\]]+)`,
9
9
  "giu"
10
10
  );
11
11
  const SECRET_PATTERNS = [
@@ -8,6 +8,7 @@ import {
8
8
  advisorJevFilterSkipConfidenceRef,
9
9
  isSimpleMode,
10
10
  } from "../config/state.ts";
11
+ import { capUtf8Bytes } from "../content-utils.ts";
11
12
  import { JevFailure, jevClientFromCredentials } from "../jev/client.ts";
12
13
  import { consumePlaintextKeyWarning } from "../jev/key-store.ts";
13
14
  import {
@@ -187,7 +188,7 @@ export const screenConsultation = (
187
188
  decision: "skip",
188
189
  kind: "repeat",
189
190
  reason: "already answered earlier in this session",
190
- reattachedAdvice: reattached.slice(0, REATTACHED_ADVICE_CAP_BYTES),
191
+ reattachedAdvice: capUtf8Bytes(reattached, REATTACHED_ADVICE_CAP_BYTES),
191
192
  });
192
193
  }
193
194
  if (!advisorJevFilterEnabledRef) {
@@ -12,7 +12,7 @@ import { registerToolLifecycle } from "./register-lifecycle.ts";
12
12
  import { registerOutcomeTool } from "./register-outcome.ts";
13
13
  import { registerToolRenderers } from "./register-renderers.ts";
14
14
  import { ScoutStatusManager } from "./scout-status.ts";
15
- import { advisorSessionState } from "./session.ts";
15
+ import { sessionStateFor } from "./session.ts";
16
16
  import type {
17
17
  ToolRegistrationContext,
18
18
  ToolRegistrationDependencies,
@@ -20,7 +20,7 @@ import type {
20
20
 
21
21
  export const registerAdvisorTool = (
22
22
  pi: ExtensionAPI,
23
- session: AdvisorSessionState = advisorSessionState,
23
+ session: AdvisorSessionState = sessionStateFor(pi),
24
24
  dependencies: ToolRegistrationDependencies = {}
25
25
  ): void => {
26
26
  const registration: ToolRegistrationContext = {
@@ -24,12 +24,27 @@ const advisorResultDetails = (result: AgentToolResult<AdvisorToolDetails>) =>
24
24
 
25
25
  const syncRenderPhase = (context: AdvisorToolContext, phase: string) => {
26
26
  if (context.state.phase !== phase && context.state.timerId) {
27
- clearInterval(context.state.timerId);
27
+ clearTimeout(context.state.timerId);
28
28
  context.state.timerId = undefined;
29
29
  }
30
30
  context.state.phase = phase;
31
31
  };
32
32
 
33
+ const scheduleRender = (context: AdvisorToolContext) => {
34
+ const timer = setTimeout(() => {
35
+ if (context.state.timerId !== timer) {
36
+ return;
37
+ }
38
+ context.state.timerId = undefined;
39
+ try {
40
+ context.invalidate();
41
+ } catch {
42
+ // The render slot may have been discarded before the repaint fired.
43
+ }
44
+ }, 80);
45
+ context.state.timerId = timer;
46
+ };
47
+
33
48
  const formatByteSize = (bytes: number): string => {
34
49
  if (bytes >= 1024 ** 3) {
35
50
  return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
@@ -100,15 +115,7 @@ const renderPartialAdvisorResult = (
100
115
  scout?.status === "calling" || scout?.status === "streaming";
101
116
  syncRenderPhase(context, scoutActive ? "scout" : "advisor");
102
117
  if (!context.state.timerId) {
103
- context.state.timerId = setInterval(() => {
104
- try {
105
- context.invalidate();
106
- } catch {
107
- // Renderer context is gone; stop repainting and drop the timer.
108
- clearInterval(context.state.timerId);
109
- context.state.timerId = undefined;
110
- }
111
- }, 80);
118
+ scheduleRender(context);
112
119
  }
113
120
  if (scout) {
114
121
  renderScoutDetails(box, scout, expanded, theme);
@@ -177,7 +184,7 @@ const renderFinalAdvisorResult = (
177
184
  ) => {
178
185
  syncRenderPhase(context, "final");
179
186
  if (context.state.timerId) {
180
- clearInterval(context.state.timerId);
187
+ clearTimeout(context.state.timerId);
181
188
  context.state.timerId = undefined;
182
189
  }
183
190
  const details = advisorResultDetails(result);
@@ -1,3 +1,17 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
1
3
  import { AdvisorSessionState } from "../session-state.ts";
2
4
 
3
5
  export const advisorSessionState = new AdvisorSessionState();
6
+
7
+ const sessionStates = new WeakMap<object, AdvisorSessionState>();
8
+
9
+ export const sessionStateFor = (owner: ExtensionAPI): AdvisorSessionState => {
10
+ const existing = sessionStates.get(owner);
11
+ if (existing) {
12
+ return existing;
13
+ }
14
+ const state = new AdvisorSessionState();
15
+ sessionStates.set(owner, state);
16
+ return state;
17
+ };
@@ -24,6 +24,7 @@ const TUI_INPUT_TAB: keyof Keybindings = ["tui", "input", "tab"].join(
24
24
  interface ManualDialogState {
25
25
  completed: boolean;
26
26
  focused: boolean;
27
+ submitting: boolean;
27
28
  }
28
29
 
29
30
  const isShiftTab = (keyData: string): boolean =>
@@ -40,6 +41,7 @@ export class ManualAdvisorDialog implements Component, Focusable {
40
41
  private readonly state: ManualDialogState = {
41
42
  completed: false,
42
43
  focused: false,
44
+ submitting: false,
43
45
  };
44
46
 
45
47
  get focused(): boolean {
@@ -273,17 +275,22 @@ export class ManualAdvisorDialog implements Component, Focusable {
273
275
  }
274
276
 
275
277
  private submit(message = this.editor.getText()): void {
276
- if (this.state.completed) {
278
+ if (this.state.completed || this.state.submitting) {
277
279
  return;
278
280
  }
279
- this.state.completed = true;
281
+ this.state.submitting = true;
280
282
  const request: ManualAdvisorRequest = {
281
283
  gitContext: this.gitLevels[this.gitIndex] ?? "off",
282
284
  };
283
285
  if (message) {
284
286
  request.message = message;
285
287
  }
286
- this.options.onSubmit(request);
288
+ try {
289
+ this.options.onSubmit(request);
290
+ this.state.completed = true;
291
+ } finally {
292
+ this.state.submitting = false;
293
+ }
287
294
  }
288
295
 
289
296
  private cancel(): void {
package/src/usage.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { Usage } from "@earendil-works/pi-ai/compat";
2
2
 
3
3
  import { isNumber, isRecordOf } from "./content-utils.ts";
4
- import type { JsonValue } from "./content-utils.ts";
4
+ import type { JsonValue, RecordValue } from "./content-utils.ts";
5
5
 
6
6
  /** Normalized usage returned by an Advisor or Scout provider response. */
7
7
  export interface AdvisorUsageSnapshot {
@@ -33,6 +33,18 @@ const add = (left: number | undefined, right: number | undefined) =>
33
33
 
34
34
  const costFields = ["input", "output", "cacheRead", "cacheWrite", "total"];
35
35
 
36
+ const hasPartialProviderCost = (
37
+ snapshot: AdvisorUsageSnapshot,
38
+ usage: RecordValue
39
+ ) => {
40
+ const cost = isRecordOf(usage.cost) ? usage.cost : undefined;
41
+ return (
42
+ cost !== undefined &&
43
+ snapshot.cost === undefined &&
44
+ costFields.some((field) => finite(cost[field]) !== undefined)
45
+ );
46
+ };
47
+
36
48
  /** Extracts provider-agnostic usage fields without trusting provider metadata. */
37
49
  export const snapshotAdvisorUsage = <Input>(
38
50
  usage: Input
@@ -62,23 +74,39 @@ export const snapshotAdvisorUsage = <Input>(
62
74
  export const advisorUsageCost = <Input>(usage: Input): number | undefined =>
63
75
  snapshotAdvisorUsage(usage)?.cost;
64
76
 
77
+ const completeUsageSnapshot = <Input>(
78
+ usage: Input
79
+ ): { snapshot: AdvisorUsageSnapshot; usage: RecordValue } | undefined => {
80
+ const snapshot = snapshotAdvisorUsage(usage);
81
+ if (!(snapshot && isRecordOf(usage))) {
82
+ return;
83
+ }
84
+ if (hasPartialProviderCost(snapshot, usage)) {
85
+ return;
86
+ }
87
+ return { snapshot, usage };
88
+ };
89
+
65
90
  /**
66
91
  * Converts supported provider usage to Pi's complete nested-tool usage shape.
67
92
  * Missing fields become zero only at this Pi API boundary; absent usage remains
68
93
  * undefined so an unavailable request is never presented as a zero-cost call.
69
94
  */
70
95
  export const advisorUsageForPi = <Input>(usage: Input): Usage | undefined => {
71
- const snapshot = snapshotAdvisorUsage(usage);
72
- if (!(snapshot && isRecordOf(usage))) {
96
+ const complete = completeUsageSnapshot(usage);
97
+ if (!complete) {
73
98
  return undefined;
74
99
  }
75
- const cost = isRecordOf(usage.cost) ? usage.cost : undefined;
100
+ const { snapshot } = complete;
101
+ const cost = isRecordOf(complete.usage.cost)
102
+ ? complete.usage.cost
103
+ : undefined;
76
104
  const input = snapshot.input ?? 0;
77
105
  const output = snapshot.output ?? 0;
78
106
  const cacheRead = snapshot.cacheRead ?? 0;
79
107
  const cacheWrite = snapshot.cacheWrite ?? 0;
80
- const cacheWrite1h = finite(usage.cacheWrite1h);
81
- const reasoning = finite(usage.reasoning);
108
+ const cacheWrite1h = finite(complete.usage.cacheWrite1h);
109
+ const reasoning = finite(complete.usage.reasoning);
82
110
  const piUsage: Usage = {
83
111
  cacheRead,
84
112
  cacheWrite,