@wrongstack/core 0.306.2 → 0.306.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.
Files changed (41) hide show
  1. package/dist/coordination/index.js +184 -86
  2. package/dist/coordination/mail-tools.d.ts +1 -1
  3. package/dist/coordination/mailbox-project-server.js +38 -16
  4. package/dist/coordination/sqlite-mailbox.d.ts +1 -0
  5. package/dist/core/conversation-state.d.ts +1 -2
  6. package/dist/core/fallback-model.d.ts +19 -1
  7. package/dist/core/fallback-profile-manager.d.ts +21 -3
  8. package/dist/core/index.d.ts +1 -1
  9. package/dist/core/index.js +152 -46
  10. package/dist/defaults/index.js +347 -139
  11. package/dist/execution/auto-compaction-middleware.d.ts +64 -0
  12. package/dist/execution/compaction-core.d.ts +4 -0
  13. package/dist/execution/index.d.ts +1 -1
  14. package/dist/execution/index.js +234 -91
  15. package/dist/execution/retry-policy.d.ts +27 -0
  16. package/dist/hq/index.js +50 -15
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +642 -184
  19. package/dist/infrastructure/index.js +21 -0
  20. package/dist/plugin/index.js +195 -19
  21. package/dist/security/index.js +52 -16
  22. package/dist/security/kanban-boundary.d.ts +3 -1
  23. package/dist/session-catalog/index.js +50 -15
  24. package/dist/session-catalog/project-server.js +50 -15
  25. package/dist/storage/cloud-config-sync/sanitize.d.ts +18 -0
  26. package/dist/storage/cloud-config-sync.d.ts +1 -1
  27. package/dist/storage/index.js +326 -71
  28. package/dist/storage/provider-config-watcher.d.ts +9 -0
  29. package/dist/storage/session-store/strict-empty-check.d.ts +7 -0
  30. package/dist/storage/session-store.d.ts +1 -0
  31. package/dist/tools/index.d.ts +1 -1
  32. package/dist/tools/index.js +83 -22
  33. package/dist/types/blocks.d.ts +9 -0
  34. package/dist/types/config/root.d.ts +14 -0
  35. package/dist/types/session.d.ts +7 -0
  36. package/instructions/agents/code-reviewer.md +3 -0
  37. package/instructions/coordination/subagent-baseline.md +10 -1
  38. package/instructions/system-lite.md +8 -5
  39. package/instructions/system-pro.md +26 -0
  40. package/instructions/system.md +21 -0
  41. package/package.json +3 -3
@@ -9828,6 +9828,24 @@ import {
9828
9828
  } from "@wrongstack/persistence";
9829
9829
 
9830
9830
  // src/security/secret-scrubber.ts
9831
+ var JSON_CREDENTIAL_KEY_ANCHORS = [
9832
+ 'Key"',
9833
+ 'key"',
9834
+ 'KEY"',
9835
+ 'token"',
9836
+ 'Token"',
9837
+ 'TOKEN"',
9838
+ 'secret"',
9839
+ 'Secret"',
9840
+ 'SECRET"',
9841
+ 'password"',
9842
+ 'Password"',
9843
+ 'PASSWORD"',
9844
+ 'authorization"',
9845
+ 'Authorization"',
9846
+ 'bearer"',
9847
+ 'Bearer"'
9848
+ ];
9831
9849
  var PATTERNS = [
9832
9850
  // Anchored at the start where possible so partial matches inside larger
9833
9851
  // strings don't trigger false positives.
@@ -9930,6 +9948,30 @@ var PATTERNS = [
9930
9948
  regex: /(^|\s)([A-Z_]{4,}(?:KEY|TOKEN|SECRET|PASSWORD|PWD))\s*[:=]\s*['"]?([A-Za-z0-9_/+=-]{20,512})['"]?(?=\s|$)/g,
9931
9949
  anchor: ["KEY", "TOKEN", "SECRET", "PASSWORD", "PWD"]
9932
9950
  },
9951
+ {
9952
+ type: "json_credential_key",
9953
+ // The JSON counterpart to `high_entropy_env`, and the pattern that the
9954
+ // now-deleted `JSON_KEY_ANCHORS` list was written for. Without it those
9955
+ // anchors only widened the cheap pre-scan — `hasCredentialAnchors` said
9956
+ // "this text may hold a secret", every pattern then declined to match, and
9957
+ // the value went out verbatim. `high_entropy_env` cannot cover these: it
9958
+ // requires an UPPERCASE unquoted key (`API_KEY=…`), so `{"apiKey":"…"}`
9959
+ // never matched.
9960
+ //
9961
+ // Tool results are routinely serialised as JSON, and a credential with no
9962
+ // recognisable prefix (Azure, self-hosted gateways, Anthropic/Codex OAuth)
9963
+ // has no other pattern that can catch it — this is the only thing standing
9964
+ // between such a value and the session JSONL, chronicle, HQ broadcast and
9965
+ // the model's own context.
9966
+ //
9967
+ // The key may carry a prefix (`"anthropicApiKey"`), but the credential word
9968
+ // must END the key: `"tokenCount"` and `"maxTokens"` do not match, because
9969
+ // the closing quote has to follow the word immediately.
9970
+ // Value floor of 8 chars keeps enum-ish values (`"authorization":"none"`)
9971
+ // out. Capture groups: 1=key + punctuation, 2=value, 3=closing quote.
9972
+ regex: /("[A-Za-z0-9_]*(?:apiKey|api_key|token|secret|password|authorization|bearer|private_key|access_token|refresh_token|client_secret)"\s*:\s*")([^"\\]{8,512})(")/gi,
9973
+ anchor: JSON_CREDENTIAL_KEY_ANCHORS
9974
+ },
9933
9975
  // ── Ported from packages/plugins credential-patterns.ts (WS-034) ─────────
9934
9976
  // The plugin runtime carried 37 patterns while this scrubber — the one that
9935
9977
  // guards session JSONL, chronicle, HQ broadcast, WebUI events and the auth
@@ -10012,9 +10054,12 @@ var PATTERNS = [
10012
10054
  anchor: "GOCSPX-"
10013
10055
  }
10014
10056
  ];
10015
- var SIMPLE_PATTERNS = PATTERNS.filter((p) => p.type !== "high_entropy_env");
10057
+ var SIMPLE_PATTERNS = PATTERNS.filter(
10058
+ (p) => p.type !== "high_entropy_env" && p.type !== "json_credential_key"
10059
+ );
10016
10060
  var COMBINED_REGEX = new RegExp(SIMPLE_PATTERNS.map((p) => `(${p.regex.source})`).join("|"), "g");
10017
10061
  var HIGH_ENTROPY_REGEX = PATTERNS.find((p) => p.type === "high_entropy_env").regex;
10062
+ var JSON_CREDENTIAL_REGEX = PATTERNS.find((p) => p.type === "json_credential_key").regex;
10018
10063
  var COMBINED_REPLACEMENTS = SIMPLE_PATTERNS.map((p) => `[REDACTED:${p.type}]`);
10019
10064
  var SCRUB_CHUNK_BYTES = 64 * 1024;
10020
10065
  var SCRUB_OVERLAP_BYTES = 1024;
@@ -10025,20 +10070,7 @@ var PATTERN_ANCHORS = [
10025
10070
  )
10026
10071
  )
10027
10072
  ];
10028
- var JSON_KEY_ANCHORS = [
10029
- '"apiKey"',
10030
- '"api_key"',
10031
- '"token"',
10032
- '"secret"',
10033
- '"password"',
10034
- '"authorization"',
10035
- '"bearer"',
10036
- '"private_key"',
10037
- '"access_token"',
10038
- '"refresh_token"',
10039
- '"client_secret"'
10040
- ];
10041
- var ALL_ANCHORS = [...PATTERN_ANCHORS, ...JSON_KEY_ANCHORS];
10073
+ var ALL_ANCHORS = PATTERN_ANCHORS;
10042
10074
  function hasCredentialAnchors(text) {
10043
10075
  for (const anchor of ALL_ANCHORS) {
10044
10076
  if (text.includes(anchor)) return true;
@@ -10087,6 +10119,9 @@ var DefaultSecretScrubber = class {
10087
10119
  out = out.replace(HIGH_ENTROPY_REGEX, (_match, lead, key, _value) => {
10088
10120
  return `${lead}${key}=[REDACTED:high_entropy_env]`;
10089
10121
  });
10122
+ out = out.replace(JSON_CREDENTIAL_REGEX, (_match, keyPrefix, _value, closingQuote) => {
10123
+ return `${keyPrefix}[REDACTED:json_credential_key]${closingQuote}`;
10124
+ });
10090
10125
  return out;
10091
10126
  }
10092
10127
  /**
@@ -11017,6 +11052,7 @@ function isEmptyMessage(msg) {
11017
11052
  }
11018
11053
 
11019
11054
  // src/core/context.ts
11055
+ import { realpathSync } from "node:fs";
11020
11056
  import * as path13 from "node:path";
11021
11057
 
11022
11058
  // src/utils/tool-wire-compact.ts
@@ -11446,12 +11482,15 @@ var ConversationState = class {
11446
11482
  * cap determines the starting index for the sum but does not gate it.
11447
11483
  */
11448
11484
  overflowCount(arr) {
11449
- let drop = Context.MAX_MESSAGES > 0 ? Math.max(0, arr.length - Context.MAX_MESSAGES) : 0;
11450
- if (Context.MAX_MESSAGE_TOKENS <= 0) return this.protocolSafeDropCount(arr, drop);
11485
+ const contextClass = this.ctx.constructor;
11486
+ const maxMessages = contextClass.MAX_MESSAGES;
11487
+ const maxMessageTokens = contextClass.MAX_MESSAGE_TOKENS;
11488
+ let drop = maxMessages > 0 ? Math.max(0, arr.length - maxMessages) : 0;
11489
+ if (maxMessageTokens <= 0) return this.protocolSafeDropCount(arr, drop);
11451
11490
  let total = 0;
11452
11491
  for (let i = drop; i < arr.length; i++) total += arr[i]?._estTokens ?? 0;
11453
- if (total <= Context.MAX_MESSAGE_TOKENS) return this.protocolSafeDropCount(arr, drop);
11454
- while (drop < arr.length - 1 && total > Context.MAX_MESSAGE_TOKENS) {
11492
+ if (total <= maxMessageTokens) return this.protocolSafeDropCount(arr, drop);
11493
+ while (drop < arr.length - 1 && total > maxMessageTokens) {
11455
11494
  total -= arr[drop]?._estTokens ?? 0;
11456
11495
  drop++;
11457
11496
  }
@@ -12274,6 +12313,19 @@ var Context = class _Context {
12274
12313
  if (rel.startsWith("..") || path13.isAbsolute(rel)) {
12275
12314
  throw new Error(`Working directory "${resolved}" is outside project root "${root}"`);
12276
12315
  }
12316
+ let realTarget = resolved;
12317
+ let realRoot = root;
12318
+ try {
12319
+ realTarget = realpathSync.native(resolved);
12320
+ realRoot = realpathSync.native(root);
12321
+ } catch {
12322
+ }
12323
+ const realRel = path13.relative(realRoot, realTarget);
12324
+ if (realRel.startsWith("..") || path13.isAbsolute(realRel)) {
12325
+ throw new Error(
12326
+ `Working directory "${resolved}" resolves to "${realTarget}", outside project root "${realRoot}"`
12327
+ );
12328
+ }
12277
12329
  }
12278
12330
  const old = this.workingDir;
12279
12331
  this.workingDir = resolved;
@@ -14005,6 +14057,44 @@ function userInputTitle(content) {
14005
14057
  return sessionContentPreview(content, 60);
14006
14058
  }
14007
14059
 
14060
+ // src/storage/session-writer-scrubber.ts
14061
+ function scrubSessionWriterEvent(event, secretScrubber) {
14062
+ const persistMessage = (message) => {
14063
+ const { _estTokens: _ignored, ...persisted } = message;
14064
+ return {
14065
+ ...persisted,
14066
+ content: typeof persisted.content === "string" ? secretScrubber?.scrub(persisted.content) ?? persisted.content : secretScrubber?.scrubObject(persisted.content) ?? persisted.content
14067
+ };
14068
+ };
14069
+ if (event.type === "context_snapshot" || event.type === "messages_replaced") {
14070
+ return { ...event, messages: event.messages.map(persistMessage) };
14071
+ }
14072
+ if (event.type === "message_appended" || event.type === "message_updated") {
14073
+ return { ...event, message: persistMessage(event.message) };
14074
+ }
14075
+ if (!secretScrubber) return event;
14076
+ if (event.type === "user_input") {
14077
+ return {
14078
+ ...event,
14079
+ content: typeof event.content === "string" ? secretScrubber.scrub(event.content) : secretScrubber.scrubObject(event.content)
14080
+ };
14081
+ }
14082
+ if (event.type === "llm_response") {
14083
+ return { ...event, content: secretScrubber.scrubObject(event.content) };
14084
+ }
14085
+ if (event.type === "file_snapshot") {
14086
+ return {
14087
+ ...event,
14088
+ files: event.files.map((f) => ({
14089
+ ...f,
14090
+ before: f.before !== null ? secretScrubber.scrub(f.before) : null,
14091
+ after: f.after !== null ? secretScrubber.scrub(f.after) : null
14092
+ }))
14093
+ };
14094
+ }
14095
+ return event;
14096
+ }
14097
+
14008
14098
  // src/storage/session-writer-truncate.ts
14009
14099
  import * as fsp6 from "node:fs/promises";
14010
14100
  var CHUNK_SIZE = 65536;
@@ -14149,45 +14239,11 @@ async function rewriteSessionToCheckpoint(filePath, checkpointByteOffset) {
14149
14239
  }
14150
14240
  }
14151
14241
 
14152
- // src/storage/session-writer-scrubber.ts
14153
- function scrubSessionWriterEvent(event, secretScrubber) {
14154
- const persistMessage = (message) => {
14155
- const { _estTokens: _ignored, ...persisted } = message;
14156
- return {
14157
- ...persisted,
14158
- content: typeof persisted.content === "string" ? secretScrubber?.scrub(persisted.content) ?? persisted.content : secretScrubber?.scrubObject(persisted.content) ?? persisted.content
14159
- };
14160
- };
14161
- if (event.type === "context_snapshot" || event.type === "messages_replaced") {
14162
- return { ...event, messages: event.messages.map(persistMessage) };
14163
- }
14164
- if (event.type === "message_appended" || event.type === "message_updated") {
14165
- return { ...event, message: persistMessage(event.message) };
14166
- }
14167
- if (!secretScrubber) return event;
14168
- if (event.type === "user_input") {
14169
- return {
14170
- ...event,
14171
- content: typeof event.content === "string" ? secretScrubber.scrub(event.content) : secretScrubber.scrubObject(event.content)
14172
- };
14173
- }
14174
- if (event.type === "llm_response") {
14175
- return { ...event, content: secretScrubber.scrubObject(event.content) };
14176
- }
14177
- if (event.type === "file_snapshot") {
14178
- return {
14179
- ...event,
14180
- files: event.files.map((f) => ({
14181
- ...f,
14182
- before: f.before !== null ? secretScrubber.scrub(f.before) : null,
14183
- after: f.after !== null ? secretScrubber.scrub(f.after) : null
14184
- }))
14185
- };
14186
- }
14187
- return event;
14188
- }
14189
-
14190
14242
  // src/storage/file-session-writer.ts
14243
+ function isClosedHandleError(err) {
14244
+ const code = err?.code;
14245
+ return code === "EBADF" || code === "ERR_CLOSED_RESOURCE" || code === "ERR_INVALID_HANDLE";
14246
+ }
14191
14247
  var FileSessionWriter = class _FileSessionWriter {
14192
14248
  constructor(id, handle, startedAt, meta, events, opts = {}, traceId) {
14193
14249
  this.id = id;
@@ -14359,8 +14415,7 @@ var FileSessionWriter = class _FileSessionWriter {
14359
14415
  try {
14360
14416
  return await this.handle.appendFile(data, "utf8");
14361
14417
  } catch (err) {
14362
- const nodeErr = err;
14363
- if (nodeErr?.code === "EBADF") {
14418
+ if (isClosedHandleError(err)) {
14364
14419
  this.handle = await fsp7.open(this.filePath, "a", 384);
14365
14420
  return await this.handle.appendFile(data, "utf8");
14366
14421
  }
@@ -14400,8 +14455,8 @@ var FileSessionWriter = class _FileSessionWriter {
14400
14455
  bufferSynchronousEvent(event) {
14401
14456
  if (this.closed) return;
14402
14457
  void this.ensureInit();
14403
- this.observeForSummary(event);
14404
- const appendEvent = event.type === "file_snapshot" ? scrubSessionWriterEvent(event, this.secretScrubber) : event;
14458
+ const appendEvent = scrubSessionWriterEvent(event, this.secretScrubber);
14459
+ this.observeForSummary(appendEvent);
14405
14460
  try {
14406
14461
  this._onAppend?.(appendEvent);
14407
14462
  } catch {
@@ -14554,8 +14609,7 @@ var FileSessionWriter = class _FileSessionWriter {
14554
14609
  try {
14555
14610
  await this.handle.datasync();
14556
14611
  } catch (err) {
14557
- const nodeErr = err;
14558
- if (nodeErr?.code === "EBADF") {
14612
+ if (isClosedHandleError(err)) {
14559
14613
  this.handle = await fsp7.open(this.filePath, "a", 384);
14560
14614
  return;
14561
14615
  }
@@ -14792,6 +14846,7 @@ var FileSessionWriter = class _FileSessionWriter {
14792
14846
  return this.closePromise;
14793
14847
  }
14794
14848
  async doClose() {
14849
+ await this.ensureInit();
14795
14850
  if (this.pendingFileSnapshots.length > 0) {
14796
14851
  await this.writeFileSnapshot(this.activePromptIndex ?? 0, [...this.pendingFileSnapshots]);
14797
14852
  this.pendingFileSnapshots = [];
@@ -14807,8 +14862,7 @@ var FileSessionWriter = class _FileSessionWriter {
14807
14862
  try {
14808
14863
  await this.handle.datasync();
14809
14864
  } catch (err) {
14810
- const nodeErr = err;
14811
- if (nodeErr?.code !== "EBADF") throw err;
14865
+ if (!isClosedHandleError(err)) throw err;
14812
14866
  }
14813
14867
  const endedAt = (/* @__PURE__ */ new Date()).toISOString();
14814
14868
  const observedActivityMs = Date.parse(this.lastActivityAt);
@@ -16524,9 +16578,40 @@ async function readOrBuildShardManifestEntry(opts) {
16524
16578
  return entry;
16525
16579
  }
16526
16580
 
16527
- // src/storage/session-store/summary-builder.ts
16581
+ // src/storage/session-store/strict-empty-check.ts
16528
16582
  import { createReadStream as createReadStream3 } from "node:fs";
16529
16583
  import { createInterface as createInterface3 } from "node:readline";
16584
+ var EMPTY_SESSION_EVENT_TYPES = /* @__PURE__ */ new Set(["session_start", "session_resumed", "session_end"]);
16585
+ async function isStrictlyEmptySessionFile(file) {
16586
+ const input = createReadStream3(file, { encoding: "utf8" });
16587
+ const lines = createInterface3({ input, crlfDelay: Infinity });
16588
+ let sawSessionStart = false;
16589
+ try {
16590
+ for await (const line of lines) {
16591
+ if (!line.trim()) continue;
16592
+ let event;
16593
+ try {
16594
+ event = JSON.parse(line);
16595
+ } catch {
16596
+ return false;
16597
+ }
16598
+ if (event === null || typeof event !== "object" || Array.isArray(event)) return false;
16599
+ const type = event.type;
16600
+ if (typeof type !== "string" || !EMPTY_SESSION_EVENT_TYPES.has(type)) return false;
16601
+ if (type === "session_start") sawSessionStart = true;
16602
+ }
16603
+ } catch {
16604
+ return false;
16605
+ } finally {
16606
+ lines.close();
16607
+ input.destroy();
16608
+ }
16609
+ return sawSessionStart;
16610
+ }
16611
+
16612
+ // src/storage/session-store/summary-builder.ts
16613
+ import { createReadStream as createReadStream4 } from "node:fs";
16614
+ import { createInterface as createInterface4 } from "node:readline";
16530
16615
  async function summarizeSessionFile(opts) {
16531
16616
  return summarizeSessionEventSequence({
16532
16617
  id: opts.id,
@@ -16643,8 +16728,8 @@ async function summarizeSessionEventSequence(opts) {
16643
16728
  }
16644
16729
  }
16645
16730
  async function* iterateSessionEvents(file, secretScrubber) {
16646
- const stream = createReadStream3(file, { encoding: "utf8" });
16647
- const lines = createInterface3({ input: stream, crlfDelay: Infinity });
16731
+ const stream = createReadStream4(file, { encoding: "utf8" });
16732
+ const lines = createInterface4({ input: stream, crlfDelay: Infinity });
16648
16733
  try {
16649
16734
  for await (const line of lines) {
16650
16735
  if (!line.trim()) continue;
@@ -17540,6 +17625,10 @@ var DefaultSessionStore = class _DefaultSessionStore {
17540
17625
  await deleteSessionArtifacts({ rootDir: this.dir, id, jsonlPath });
17541
17626
  await this.writeTombstone(id);
17542
17627
  }
17628
+ async isEmpty(id) {
17629
+ const canonicalId = await this.resolveId(id);
17630
+ return isStrictlyEmptySessionFile(this.sessionPath(canonicalId, ".jsonl"));
17631
+ }
17543
17632
  async delete(id) {
17544
17633
  if (this.catalogClient) {
17545
17634
  const canonical = await this.resolveId(id);
@@ -21469,6 +21558,12 @@ function findExchangeStart(messages, userIndex) {
21469
21558
 
21470
21559
  // src/execution/auto-compaction-middleware.ts
21471
21560
  var LEVEL_RANK = { warn: 0, soft: 1, hard: 2 };
21561
+ function pressureLevelFor(load, thresholds) {
21562
+ if (load >= thresholds.hard) return "hard";
21563
+ if (load >= thresholds.soft) return "soft";
21564
+ if (load >= thresholds.warn) return "warn";
21565
+ return null;
21566
+ }
21472
21567
  var MAX_DIGEST_LOG_CHARS = 4e3;
21473
21568
  function truncateDigest(digest) {
21474
21569
  if (digest.length <= MAX_DIGEST_LOG_CHARS) return digest;
@@ -21514,8 +21609,19 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
21514
21609
  * 1 / 2.5 = 0.4.
21515
21610
  */
21516
21611
  static GUARD_GATE_LOAD = 0.4;
21612
+ /**
21613
+ * How much the context must grow between two history-rewriting hygiene
21614
+ * passes, as a fraction of the available input window and as an absolute
21615
+ * floor. Every pass rewrites already-transmitted messages, which forces the
21616
+ * provider to re-cache the whole prompt; spacing the passes out is what lets
21617
+ * the conversation prefix stay cached for the turns in between.
21618
+ */
21619
+ static HYGIENE_GROWTH_RATIO = 0.15;
21620
+ static HYGIENE_MIN_GROWTH_TOKENS = 2e4;
21517
21621
  /** Tracks the most recent no-op attempt so we can avoid re-firing per turn. */
21518
21622
  lastNoopAttempt = null;
21623
+ /** Context size at the last hygiene pass; anchors the growth interval. */
21624
+ lastHygieneTokens = null;
21519
21625
  /**
21520
21626
  * Cached token estimate from the last handler() invocation. When the
21521
21627
  * message count and tool count haven't changed since the last estimate
@@ -21575,55 +21681,9 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
21575
21681
  handler() {
21576
21682
  return async (ctx, next) => {
21577
21683
  if (!this._enabled) return next(ctx);
21578
- const rawHygiene = eliseAcknowledgedToolResults(ctx.messages, {
21579
- maxRetainedTokens: this.resolveToolResultRetention(ctx)
21580
- });
21581
- const receiptHygiene = collapseAcknowledgedToolReceipts(rawHygiene.messages, {
21582
- maxPairs: this.resolveToolReceiptRetention(ctx)
21583
- });
21584
- if (rawHygiene.changed || receiptHygiene.changed) {
21585
- ctx.state.replaceMessages(receiptHygiene.messages);
21586
- ctx.clearFileTracking();
21587
- this.invalidateTokenCaches(ctx);
21588
- }
21589
- const msgCount = ctx.messages.length;
21590
- const toolCount = (ctx.tools ?? []).length;
21591
- const revision = ctx.state?.revision ?? -1;
21592
- let tokens;
21593
- const anchorAt = typeof ctx.meta?.["realAnchorMsgCount"] === "number" ? ctx.meta["realAnchorMsgCount"] : void 0;
21594
- const anchored = realAnchoredInputTokens(ctx.messages, ctx.lastRealInputTokens, anchorAt);
21595
- if (anchored !== null) {
21596
- tokens = anchored;
21597
- } else if (this._estimator) {
21598
- tokens = this._estimator(ctx);
21599
- } else if (msgCount === this._cachedMsgCount && toolCount === this._cachedToolCount && revision === this._cachedRevision && ctx.systemPrompt === this._cachedSystemRef && ctx.tools === this._cachedToolsRef && this._cachedTokens >= 0) {
21600
- tokens = this._cachedTokens;
21601
- } else if (this.tryStashedTokens(ctx, msgCount, toolCount, revision) !== null) {
21602
- const stashed = this.tryStashedTokens(ctx, msgCount, toolCount, revision);
21603
- const cal = getCalibrationState(`${ctx.provider?.id ?? "unknown"}/${ctx.model}`);
21604
- tokens = cal.calibrated ? Math.round(stashed * Math.min(1.5, Math.max(0.5, cal.ratio))) : stashed;
21605
- this._cachedTokens = tokens;
21606
- this._cachedMsgCount = msgCount;
21607
- this._cachedToolCount = toolCount;
21608
- this._cachedRevision = revision;
21609
- this._cachedSystemRef = ctx.systemPrompt;
21610
- this._cachedToolsRef = ctx.tools;
21611
- } else {
21612
- tokens = estimateRequestTokensCalibrated(
21613
- ctx.messages,
21614
- ctx.systemPrompt,
21615
- ctx.tools ?? [],
21616
- `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
21617
- ).total;
21618
- this._cachedTokens = tokens;
21619
- this._cachedMsgCount = msgCount;
21620
- this._cachedToolCount = toolCount;
21621
- this._cachedRevision = revision;
21622
- this._cachedSystemRef = ctx.systemPrompt;
21623
- this._cachedToolsRef = ctx.tools;
21624
- }
21684
+ let tokens = this.estimateContextTokens(ctx);
21625
21685
  const runtimeMaxContext = effectiveMaxContext(ctx, this._maxContext);
21626
- const budget = computeContextWindowBudget(ctx, tokens, runtimeMaxContext);
21686
+ let budget = computeContextWindowBudget(ctx, tokens, runtimeMaxContext);
21627
21687
  const calibratedLoad = budget.load;
21628
21688
  const policy = this.policyProvider?.(ctx);
21629
21689
  const thresholds = policy?.thresholds ?? {
@@ -21637,22 +21697,27 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
21637
21697
  });
21638
21698
  const aggressiveOn = policy?.aggressiveOn ?? this.aggressiveOn;
21639
21699
  const targetLoad = normalizeTargetLoad(policy?.targetLoad, adaptiveThresholds);
21640
- let load = calibratedLoad;
21641
- if (calibratedLoad >= _AutoCompactionMiddleware.GUARD_GATE_LOAD) {
21642
- const guardTotal = estimateRequestTokensUpperBound(
21643
- ctx.messages,
21644
- ctx.systemPrompt,
21645
- ctx.tools ?? [],
21646
- `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
21647
- ).total;
21648
- const guardLoad = guardTotal / budget.availableInputTokens;
21649
- if (guardLoad > load) load = guardLoad;
21650
- }
21651
- const level = load >= adaptiveThresholds.hard ? "hard" : load >= adaptiveThresholds.soft ? "soft" : load >= adaptiveThresholds.warn ? "warn" : null;
21700
+ let load = this.applySendGuard(ctx, calibratedLoad, budget.availableInputTokens);
21701
+ let level = pressureLevelFor(load, adaptiveThresholds);
21652
21702
  if (!level) {
21653
21703
  this.lastNoopAttempt = null;
21654
21704
  return next(ctx);
21655
21705
  }
21706
+ if (this.shouldRunHygiene(level, tokens, budget.availableInputTokens)) {
21707
+ const changed = this.runHistoryHygiene(ctx);
21708
+ tokens = changed ? this.estimateContextTokens(ctx) : tokens;
21709
+ this.lastHygieneTokens = tokens;
21710
+ if (changed) {
21711
+ budget = computeContextWindowBudget(ctx, tokens, runtimeMaxContext);
21712
+ load = this.applySendGuard(ctx, budget.load, budget.availableInputTokens);
21713
+ const relevelled = pressureLevelFor(load, adaptiveThresholds);
21714
+ if (!relevelled) {
21715
+ this.lastNoopAttempt = null;
21716
+ return next(ctx);
21717
+ }
21718
+ level = relevelled;
21719
+ }
21720
+ }
21656
21721
  if (this.shouldSkipNoopRetry(level, tokens)) {
21657
21722
  return next(ctx);
21658
21723
  }
@@ -21669,6 +21734,123 @@ var AutoCompactionMiddleware = class _AutoCompactionMiddleware {
21669
21734
  return next(ctx);
21670
21735
  };
21671
21736
  }
21737
+ /**
21738
+ * Full-request token total for the current context.
21739
+ *
21740
+ * Reuses the last estimate when the context hasn't grown since the previous
21741
+ * check — common in autonomous idle loops. The cached value is invalidated
21742
+ * whenever messages or tools change.
21743
+ *
21744
+ * IMPORTANT: the cache is only valid for the deterministic
21745
+ * `estimateRequestTokensCalibrated` path (messages+system+tools → fixed
21746
+ * output). When a custom `_estimator` is provided (e.g. in tests with a
21747
+ * mutable closure, or a dynamic policy provider), always call it fresh — the
21748
+ * estimator owns its own semantics and the middleware cannot safely cache its
21749
+ * result across calls.
21750
+ */
21751
+ estimateContextTokens(ctx) {
21752
+ const msgCount = ctx.messages.length;
21753
+ const toolCount = (ctx.tools ?? []).length;
21754
+ const revision = ctx.state?.revision ?? -1;
21755
+ const anchorAt = typeof ctx.meta?.["realAnchorMsgCount"] === "number" ? ctx.meta["realAnchorMsgCount"] : void 0;
21756
+ const anchored = realAnchoredInputTokens(ctx.messages, ctx.lastRealInputTokens, anchorAt);
21757
+ if (anchored !== null) return anchored;
21758
+ if (this._estimator) return this._estimator(ctx);
21759
+ if (msgCount === this._cachedMsgCount && toolCount === this._cachedToolCount && revision === this._cachedRevision && ctx.systemPrompt === this._cachedSystemRef && ctx.tools === this._cachedToolsRef && this._cachedTokens >= 0) {
21760
+ return this._cachedTokens;
21761
+ }
21762
+ const stashed = this.tryStashedTokens(ctx, msgCount, toolCount, revision);
21763
+ let tokens;
21764
+ if (stashed !== null) {
21765
+ const cal = getCalibrationState(`${ctx.provider?.id ?? "unknown"}/${ctx.model}`);
21766
+ tokens = cal.calibrated ? Math.round(stashed * Math.min(1.5, Math.max(0.5, cal.ratio))) : stashed;
21767
+ } else {
21768
+ tokens = estimateRequestTokensCalibrated(
21769
+ ctx.messages,
21770
+ ctx.systemPrompt,
21771
+ ctx.tools ?? [],
21772
+ `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
21773
+ ).total;
21774
+ }
21775
+ this._cachedTokens = tokens;
21776
+ this._cachedMsgCount = msgCount;
21777
+ this._cachedToolCount = toolCount;
21778
+ this._cachedRevision = revision;
21779
+ this._cachedSystemRef = ctx.systemPrompt;
21780
+ this._cachedToolsRef = ctx.tools;
21781
+ return tokens;
21782
+ }
21783
+ /**
21784
+ * Never-undercount send guard.
21785
+ *
21786
+ * The calibrated estimate can under-count dense content (CJK, base64,
21787
+ * minified) by >1.5×, which would let an over-limit request slip past the
21788
+ * thresholds and reach the provider. Once the calibrated load is high enough
21789
+ * that even the max density factor (2.5×) *could* overflow (load ≥ 1/2.5 =
21790
+ * 0.4), re-check with the upper-bound estimator and escalate to whichever
21791
+ * load is larger. Below 0.4 an overflow is arithmetically impossible, so the
21792
+ * extra scan is skipped.
21793
+ */
21794
+ applySendGuard(ctx, calibratedLoad, availableInputTokens) {
21795
+ if (calibratedLoad < _AutoCompactionMiddleware.GUARD_GATE_LOAD) return calibratedLoad;
21796
+ const guardTotal = estimateRequestTokensUpperBound(
21797
+ ctx.messages,
21798
+ ctx.systemPrompt,
21799
+ ctx.tools ?? [],
21800
+ `${ctx.provider?.id ?? "unknown"}/${ctx.model}`
21801
+ ).total;
21802
+ const guardLoad = guardTotal / availableInputTokens;
21803
+ return guardLoad > calibratedLoad ? guardLoad : calibratedLoad;
21804
+ }
21805
+ /**
21806
+ * Rewrite acknowledged tool protocol in place.
21807
+ *
21808
+ * Tool results are protocol inputs for the immediately following model
21809
+ * response, not unlimited durable prompt memory. Once a later assistant
21810
+ * message proves the provider consumed them, keep a mode-sized raw window and
21811
+ * replace the rest with semantic head/tail receipts, then fold fully-aged
21812
+ * pairs into the history digest.
21813
+ *
21814
+ * Every one of those edits touches a message the provider has already seen,
21815
+ * so each call costs a full prompt re-cache — it belongs behind
21816
+ * {@link shouldRunHygiene}, never on the per-turn path. Content staleness is
21817
+ * not a reason to bypass that gate: the edit/replace tools guard against
21818
+ * acting on an out-of-date read with their own mtime + sha-256 check.
21819
+ *
21820
+ * @returns whether the conversation was rewritten.
21821
+ */
21822
+ runHistoryHygiene(ctx) {
21823
+ const raw = eliseAcknowledgedToolResults(ctx.messages, {
21824
+ maxRetainedTokens: this.resolveToolResultRetention(ctx)
21825
+ });
21826
+ const receipts = collapseAcknowledgedToolReceipts(raw.messages, {
21827
+ maxPairs: this.resolveToolReceiptRetention(ctx)
21828
+ });
21829
+ if (!raw.changed && !receipts.changed) return false;
21830
+ ctx.state.replaceMessages(receipts.messages);
21831
+ ctx.clearFileTracking();
21832
+ this.invalidateTokenCaches(ctx);
21833
+ return true;
21834
+ }
21835
+ /**
21836
+ * Whether the history-rewriting hygiene pass may run this turn.
21837
+ *
21838
+ * Hard pressure always runs it — staying under the window outranks caching.
21839
+ * Otherwise it runs at most once per growth interval, so the conversation
21840
+ * stays append-only (and therefore cacheable by the provider) in between.
21841
+ */
21842
+ shouldRunHygiene(level, tokens, availableInputTokens) {
21843
+ if (level === "hard") return true;
21844
+ const last = this.lastHygieneTokens;
21845
+ if (last === null) return true;
21846
+ const anchor = Math.min(last, tokens);
21847
+ this.lastHygieneTokens = anchor;
21848
+ const interval = Math.max(
21849
+ _AutoCompactionMiddleware.HYGIENE_MIN_GROWTH_TOKENS,
21850
+ Math.floor(availableInputTokens * _AutoCompactionMiddleware.HYGIENE_GROWTH_RATIO)
21851
+ );
21852
+ return tokens - anchor >= interval;
21853
+ }
21672
21854
  /**
21673
21855
  * H1: try to read a pre-computed token total from `ctx.lastRequestTokens`
21674
21856
  * (set by the agent loop's pre-flight or its restash in emitContextPct).
@@ -26765,26 +26947,29 @@ var DefaultProviderRunner = class {
26765
26947
  // src/execution/retry-policy.ts
26766
26948
  import { randomInt } from "node:crypto";
26767
26949
  var MAX_RETRY_AFTER_MS = 6e4;
26950
+ var MODEL_RETRIES = 3;
26768
26951
  var MAX_ATTEMPTS_BY_KIND = {
26769
- rate_limit: 5,
26952
+ rate_limit: MODEL_RETRIES,
26953
+ overloaded: MODEL_RETRIES,
26954
+ server: MODEL_RETRIES,
26955
+ timeout: MODEL_RETRIES,
26956
+ network: MODEL_RETRIES,
26957
+ stream_hang: MODEL_RETRIES,
26770
26958
  quota_exhausted: 0,
26771
- stream_hang: 2,
26772
- // proxy-level timeout — retrying 5x wastes ~40s before fallback kicks in
26773
- overloaded: 3,
26774
- server: 3,
26775
- timeout: 2,
26776
- network: 2,
26777
26959
  auth: 0,
26778
26960
  invalid_request: 0,
26779
26961
  context_overflow: 0,
26780
26962
  content_filter: 0,
26781
26963
  unknown: 0
26782
26964
  };
26965
+ var FAILOVER_RETRY_AFTER_MS = 15e3;
26783
26966
  var DefaultRetryPolicy = class {
26784
26967
  shouldRetry(err, attempt) {
26785
26968
  const isProviderErr = err instanceof ProviderError || ProviderError.isProviderError(err);
26786
26969
  if (isProviderErr) {
26787
26970
  if (!err.retryable) return false;
26971
+ const hint = retryAfterMsFromError(err);
26972
+ if (hint !== void 0 && hint >= FAILOVER_RETRY_AFTER_MS) return false;
26788
26973
  return attempt < this.maxAttempts(err);
26789
26974
  }
26790
26975
  const msg = err.message ?? "";
@@ -28244,7 +28429,8 @@ async function evaluateToolKanbanBoundary(tool, input, ctx, options = {}) {
28244
28429
  decision: "block",
28245
28430
  reason: "Active card is not implementation-ready: " + readiness.issues.map((issue) => issue.message).join(" | "),
28246
28431
  boardId: board.id,
28247
- taskId: task.id
28432
+ taskId: task.id,
28433
+ readinessIssues: readiness.issues
28248
28434
  };
28249
28435
  }
28250
28436
  if (task.lifecycle?.currentStage !== "running" || task.assignment?.status !== "running") {
@@ -29035,7 +29221,8 @@ ${errorDetails}`,
29035
29221
  type: "tool_result",
29036
29222
  tool_use_id: use.id,
29037
29223
  content: `Tool "${tool.name}" blocked by Kanban boundary. ${boundary.reason ?? ""}`.trim(),
29038
- is_error: true
29224
+ is_error: true,
29225
+ _kanbanBoundary: boundary
29039
29226
  };
29040
29227
  budget = this.budgetForString(result.content, budget);
29041
29228
  return { result, tool, durationMs: Date.now() - start };
@@ -32070,7 +32257,7 @@ var AutoApprovePermissionPolicy = class _AutoApprovePermissionPolicy {
32070
32257
  };
32071
32258
 
32072
32259
  // src/security/permission-helpers.ts
32073
- import { realpathSync } from "node:fs";
32260
+ import { realpathSync as realpathSync2 } from "node:fs";
32074
32261
  import * as path38 from "node:path";
32075
32262
  function matchesTrust(patterns, subject) {
32076
32263
  return patterns.includes(subject) || matchAny(patterns, subject);
@@ -32155,7 +32342,7 @@ function realpathOfNearestExisting(p) {
32155
32342
  const tail = [];
32156
32343
  for (; ; ) {
32157
32344
  try {
32158
- return tail.length === 0 ? realpathSync(probe) : path38.join(realpathSync(probe), ...tail);
32345
+ return tail.length === 0 ? realpathSync2(probe) : path38.join(realpathSync2(probe), ...tail);
32159
32346
  } catch {
32160
32347
  const parent = path38.dirname(probe);
32161
32348
  if (parent === probe) return p;
@@ -33918,6 +34105,10 @@ var IN_PROJECT_ALLOWED_KEYS = /* @__PURE__ */ new Set([
33918
34105
  "fallbackModels",
33919
34106
  "fallbackBridge",
33920
34107
  "fallbackProfiles",
34108
+ // The profile SELECTOR. No broader than its siblings: a repo that can write
34109
+ // `fallbackModels` and `fallbackProfiles` already controls the chain outright,
34110
+ // and this one can only name a profile the user already defined.
34111
+ "fallbackProfile",
33921
34112
  "favoriteModels",
33922
34113
  "favoriteModelsOnly",
33923
34114
  "modelAvailabilitySchedule",
@@ -33965,6 +34156,10 @@ var KNOWN_DENIED_IN_PROJECT = [
33965
34156
  {
33966
34157
  key: "git",
33967
34158
  reason: "Carries git.identity (GIT_AUTHOR_*/GIT_COMMITTER_* injection): a repo-committed config could spoof the author identity written into the victim's commit history (impersonation)."
34159
+ },
34160
+ {
34161
+ key: "fallbackMaxLastResortCandidates",
34162
+ reason: "Bounds how many of the user's OTHER configured providers may be swept in as last-resort failover. Setting it to 0 from a repo-committed config would silently strip that depth during an outage. It was already stripped in practice (absent from the allow-list) but was missing from the key registry, so this gate never checked it."
33968
34163
  }
33969
34164
  ];
33970
34165
  var KNOWN_CONFIG_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
@@ -33985,11 +34180,13 @@ var KNOWN_CONFIG_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
33985
34180
  "fallbackModels",
33986
34181
  "fallbackBridge",
33987
34182
  "fallbackProfiles",
34183
+ "fallbackProfile",
33988
34184
  "favoriteModels",
33989
34185
  "favoriteModelsOnly",
33990
34186
  "modelAvailabilitySchedule",
33991
34187
  "fallbackAuto",
33992
34188
  "fallbackStickiness",
34189
+ "fallbackMaxLastResortCandidates",
33993
34190
  "hooks",
33994
34191
  "plugins",
33995
34192
  "pluginManager",
@@ -34075,6 +34272,17 @@ var IN_PROJECT_DENIED_PATHS = [
34075
34272
  // operator owns, not the checked-out repository.
34076
34273
  path: "tools.kanbanGovernance",
34077
34274
  reason: "Repo-committed config could disable a Kanban governance gate the operator switched on, letting product mutations run outside any managed card."
34275
+ },
34276
+ {
34277
+ // The bridge spawn path resolves the CLI entry by walking UP from the
34278
+ // project root, so a repo that ships its own `packages/cli/dist/index.js`
34279
+ // gets that file spawned with `process.execPath` on WebUI boot — no
34280
+ // prompt, no banner. Turning the feature on is therefore equivalent to
34281
+ // arbitrary code execution for a hostile checkout, which makes this an
34282
+ // operator-owned switch and never a repo-owned one.
34283
+ // See discover-mailbox-bridge.ts:findWorkspaceCliEntry.
34284
+ path: "features.mailboxBridge",
34285
+ reason: "Enables the mailbox bridge, whose CLI-entry resolution walks up from the project root \u2014 a repo-supplied packages/cli/dist/index.js would be spawned on WebUI boot."
34078
34286
  }
34079
34287
  ];
34080
34288
  function deleteNestedPath(target, path45) {