@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
@@ -15,6 +15,13 @@
15
15
  *
16
16
  * Stripping is therefore structural (projection), with `stripSecretMaterial` as a final
17
17
  * defense-in-depth pass mirroring the server's own scan.
18
+ *
19
+ * The two directions do NOT share one gate. "What may leave this machine" and "what may
20
+ * a remote peer write into the trusted profile config" are different questions, and the
21
+ * outbound tree is a wrong — dangerously permissive — answer to the second: it names
22
+ * `mcpServers.*.command`, `plugins`, `providers.*.baseUrl` and `yolo` as contract-owned,
23
+ * because pushing them is harmless. Pulls are therefore gated on `INBOUND_CONTRACT`, the
24
+ * outbound tree minus `INBOUND_DENIED_PATHS`.
18
25
  */
19
26
  /** `true` = the whole value is contract-owned; an object = recurse per key; `'*'` = record wildcard. */
20
27
  export type ContractNode = true | {
@@ -23,6 +30,17 @@ export type ContractNode = true | {
23
30
  /** Namespace → the contract tree over the `config.json` top-level keys it owns. */
24
31
  export declare const CLOUD_SYNC_CONTRACT: Readonly<Record<string, ContractNode>>;
25
32
  export declare const CLOUD_SYNC_NAMESPACES: string[];
33
+ /**
34
+ * Every inbound-denied path must still resolve against the outbound contract.
35
+ *
36
+ * A denylist that silently stops matching is worse than no denylist: it reads
37
+ * as protection while granting the field. Renaming `mcpServers.*.command` in
38
+ * the contract without updating the entry here must break the build, not
39
+ * quietly re-open remote code execution.
40
+ */
41
+ export declare function assertInboundDenyListResolves(): void;
42
+ /** Exposed for tests and diagnostics — the effective pull-side allow tree. */
43
+ export declare function inboundContractFor(namespace: string): ContractNode | undefined;
26
44
  /** Server-side schema versions, sent in every push envelope. */
27
45
  export declare const NAMESPACE_SCHEMA_VERSIONS: Readonly<Record<string, number>>;
28
46
  /**
@@ -1,4 +1,4 @@
1
- export { applyNamespacePayload, buildNamespacePayloads, CLOUD_SYNC_CONTRACT, CLOUD_SYNC_NAMESPACES, LOCAL_ONLY_TOP_LEVEL, NAMESPACE_SCHEMA_VERSIONS, stripSecretMaterial, } from './cloud-config-sync/sanitize.js';
1
+ export { applyNamespacePayload, assertInboundDenyListResolves, buildNamespacePayloads, CLOUD_SYNC_CONTRACT, CLOUD_SYNC_NAMESPACES, inboundContractFor, LOCAL_ONLY_TOP_LEVEL, NAMESPACE_SCHEMA_VERSIONS, stripSecretMaterial, } from './cloud-config-sync/sanitize.js';
2
2
  /**
3
3
  * CloudConfigSync — synchronizes the profile config with my.wrongstack.com over the
4
4
  * namespaced sync API v1 (server repo: docs/CLIENT_SYNC_CONTRACT.md).
@@ -1710,6 +1710,24 @@ function resolveWstackPaths(opts) {
1710
1710
  }
1711
1711
 
1712
1712
  // src/security/secret-scrubber.ts
1713
+ var JSON_CREDENTIAL_KEY_ANCHORS = [
1714
+ 'Key"',
1715
+ 'key"',
1716
+ 'KEY"',
1717
+ 'token"',
1718
+ 'Token"',
1719
+ 'TOKEN"',
1720
+ 'secret"',
1721
+ 'Secret"',
1722
+ 'SECRET"',
1723
+ 'password"',
1724
+ 'Password"',
1725
+ 'PASSWORD"',
1726
+ 'authorization"',
1727
+ 'Authorization"',
1728
+ 'bearer"',
1729
+ 'Bearer"'
1730
+ ];
1713
1731
  var PATTERNS = [
1714
1732
  // Anchored at the start where possible so partial matches inside larger
1715
1733
  // strings don't trigger false positives.
@@ -1812,6 +1830,30 @@ var PATTERNS = [
1812
1830
  regex: /(^|\s)([A-Z_]{4,}(?:KEY|TOKEN|SECRET|PASSWORD|PWD))\s*[:=]\s*['"]?([A-Za-z0-9_/+=-]{20,512})['"]?(?=\s|$)/g,
1813
1831
  anchor: ["KEY", "TOKEN", "SECRET", "PASSWORD", "PWD"]
1814
1832
  },
1833
+ {
1834
+ type: "json_credential_key",
1835
+ // The JSON counterpart to `high_entropy_env`, and the pattern that the
1836
+ // now-deleted `JSON_KEY_ANCHORS` list was written for. Without it those
1837
+ // anchors only widened the cheap pre-scan — `hasCredentialAnchors` said
1838
+ // "this text may hold a secret", every pattern then declined to match, and
1839
+ // the value went out verbatim. `high_entropy_env` cannot cover these: it
1840
+ // requires an UPPERCASE unquoted key (`API_KEY=…`), so `{"apiKey":"…"}`
1841
+ // never matched.
1842
+ //
1843
+ // Tool results are routinely serialised as JSON, and a credential with no
1844
+ // recognisable prefix (Azure, self-hosted gateways, Anthropic/Codex OAuth)
1845
+ // has no other pattern that can catch it — this is the only thing standing
1846
+ // between such a value and the session JSONL, chronicle, HQ broadcast and
1847
+ // the model's own context.
1848
+ //
1849
+ // The key may carry a prefix (`"anthropicApiKey"`), but the credential word
1850
+ // must END the key: `"tokenCount"` and `"maxTokens"` do not match, because
1851
+ // the closing quote has to follow the word immediately.
1852
+ // Value floor of 8 chars keeps enum-ish values (`"authorization":"none"`)
1853
+ // out. Capture groups: 1=key + punctuation, 2=value, 3=closing quote.
1854
+ 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,
1855
+ anchor: JSON_CREDENTIAL_KEY_ANCHORS
1856
+ },
1815
1857
  // ── Ported from packages/plugins credential-patterns.ts (WS-034) ─────────
1816
1858
  // The plugin runtime carried 37 patterns while this scrubber — the one that
1817
1859
  // guards session JSONL, chronicle, HQ broadcast, WebUI events and the auth
@@ -1894,9 +1936,12 @@ var PATTERNS = [
1894
1936
  anchor: "GOCSPX-"
1895
1937
  }
1896
1938
  ];
1897
- var SIMPLE_PATTERNS = PATTERNS.filter((p) => p.type !== "high_entropy_env");
1939
+ var SIMPLE_PATTERNS = PATTERNS.filter(
1940
+ (p) => p.type !== "high_entropy_env" && p.type !== "json_credential_key"
1941
+ );
1898
1942
  var COMBINED_REGEX = new RegExp(SIMPLE_PATTERNS.map((p) => `(${p.regex.source})`).join("|"), "g");
1899
1943
  var HIGH_ENTROPY_REGEX = PATTERNS.find((p) => p.type === "high_entropy_env").regex;
1944
+ var JSON_CREDENTIAL_REGEX = PATTERNS.find((p) => p.type === "json_credential_key").regex;
1900
1945
  var COMBINED_REPLACEMENTS = SIMPLE_PATTERNS.map((p) => `[REDACTED:${p.type}]`);
1901
1946
  var SCRUB_CHUNK_BYTES = 64 * 1024;
1902
1947
  var SCRUB_OVERLAP_BYTES = 1024;
@@ -1907,20 +1952,7 @@ var PATTERN_ANCHORS = [
1907
1952
  )
1908
1953
  )
1909
1954
  ];
1910
- var JSON_KEY_ANCHORS = [
1911
- '"apiKey"',
1912
- '"api_key"',
1913
- '"token"',
1914
- '"secret"',
1915
- '"password"',
1916
- '"authorization"',
1917
- '"bearer"',
1918
- '"private_key"',
1919
- '"access_token"',
1920
- '"refresh_token"',
1921
- '"client_secret"'
1922
- ];
1923
- var ALL_ANCHORS = [...PATTERN_ANCHORS, ...JSON_KEY_ANCHORS];
1955
+ var ALL_ANCHORS = PATTERN_ANCHORS;
1924
1956
  function hasCredentialAnchors(text) {
1925
1957
  for (const anchor of ALL_ANCHORS) {
1926
1958
  if (text.includes(anchor)) return true;
@@ -1969,6 +2001,9 @@ var DefaultSecretScrubber = class {
1969
2001
  out = out.replace(HIGH_ENTROPY_REGEX, (_match, lead, key, _value) => {
1970
2002
  return `${lead}${key}=[REDACTED:high_entropy_env]`;
1971
2003
  });
2004
+ out = out.replace(JSON_CREDENTIAL_REGEX, (_match, keyPrefix, _value, closingQuote) => {
2005
+ return `${keyPrefix}[REDACTED:json_credential_key]${closingQuote}`;
2006
+ });
1972
2007
  return out;
1973
2008
  }
1974
2009
  /**
@@ -2427,8 +2462,8 @@ var SessionRegistry = class {
2427
2462
  if (id !== entry.sessionId) delete registry[id];
2428
2463
  continue;
2429
2464
  }
2430
- const heartbeatAge = now - new Date(existing.lastHeartbeatAt).getTime();
2431
- if (heartbeatAge > PID_CHECK_AFTER_MS && !pidAlive2(existing.pid)) {
2465
+ const heartbeatAt = Date.parse(existing.lastHeartbeatAt);
2466
+ if (!Number.isFinite(heartbeatAt) || now - heartbeatAt > PID_CHECK_AFTER_MS && !pidAlive2(existing.pid)) {
2432
2467
  delete registry[id];
2433
2468
  }
2434
2469
  }
@@ -3446,6 +3481,156 @@ var CLOUD_SYNC_CONTRACT = {
3446
3481
  "extensions.plugins": EXTENSIONS_PLUGINS_TREE
3447
3482
  };
3448
3483
  var CLOUD_SYNC_NAMESPACES = Object.keys(CLOUD_SYNC_CONTRACT);
3484
+ var INBOUND_DENIED_PATHS = [
3485
+ // ── Code execution ──────────────────────────────────────────────────────
3486
+ {
3487
+ namespace: "mcp.servers",
3488
+ path: "mcpServers.*.command",
3489
+ reason: "Executable spawned for a stdio MCP server."
3490
+ },
3491
+ {
3492
+ namespace: "mcp.servers",
3493
+ path: "mcpServers.*.args",
3494
+ reason: "Argv for that executable."
3495
+ },
3496
+ {
3497
+ namespace: "mcp.servers",
3498
+ path: "mcpServers.*.transport",
3499
+ reason: "Switching transport to stdio selects the spawning code path."
3500
+ },
3501
+ {
3502
+ namespace: "extensions.plugins",
3503
+ path: "plugins",
3504
+ reason: "Plugin list is resolved and `await import`ed."
3505
+ },
3506
+ // `extensions` is deliberately NOT denied: it is per-plugin settings read via
3507
+ // `ConfigStore.getExtension(name)`, not a loader list, so it grants no import.
3508
+ // Syncing it is the feature (`extensions.telegram.notifyChatId` and friends).
3509
+ // Residual risk accepted: a plugin whose own settings include a URL can have
3510
+ // that URL rewritten by the portal. Constrain that in the plugin's
3511
+ // `configSchema`, which is where the loader validates it.
3512
+ // ── Credential redirection / exfiltration ───────────────────────────────
3513
+ {
3514
+ namespace: "providers.catalog",
3515
+ path: "providers.*.baseUrl",
3516
+ reason: "Repoints the provider endpoint; reinjectLocalSecrets keeps the local apiKey, so the real key follows the redirect."
3517
+ },
3518
+ {
3519
+ namespace: "providers.catalog",
3520
+ path: "providers.*.envVars",
3521
+ reason: "Chooses which environment variable is read for the key."
3522
+ },
3523
+ {
3524
+ namespace: "providers.catalog",
3525
+ path: "providers.*.activeKey",
3526
+ reason: "Selects which stored key is sent."
3527
+ },
3528
+ {
3529
+ namespace: "mcp.servers",
3530
+ path: "mcpServers.*.url",
3531
+ reason: "Remote MCP endpoint; receives whatever the transport carries."
3532
+ },
3533
+ {
3534
+ namespace: "mcp.servers",
3535
+ path: "mcpServers.*.envVars",
3536
+ reason: "Names of environment variables forwarded to the server process."
3537
+ },
3538
+ // ── Operator-owned safety switches ──────────────────────────────────────
3539
+ {
3540
+ namespace: "mcp.servers",
3541
+ path: "mcpServers.*.permission",
3542
+ reason: "Approval requirement for that server\u2019s tools."
3543
+ },
3544
+ { namespace: "core.runtime", path: "yolo", reason: "Disables every permission prompt." },
3545
+ {
3546
+ namespace: "core.runtime",
3547
+ path: "features.allowOutsideProjectRoot",
3548
+ reason: "Short-circuits project-root containment and the symlink realpath check."
3549
+ },
3550
+ {
3551
+ namespace: "core.runtime",
3552
+ path: "tools.restrictToProjectRoot",
3553
+ reason: "The other half of the filesystem confinement switch."
3554
+ },
3555
+ {
3556
+ namespace: "core.runtime",
3557
+ path: "features.developerMode",
3558
+ reason: "Loosens guardrails; an operator opt-in, not a synced preference."
3559
+ },
3560
+ {
3561
+ namespace: "core.runtime",
3562
+ path: "tools.disabledTools",
3563
+ reason: "Could re-enable a tool the operator deliberately switched off."
3564
+ },
3565
+ {
3566
+ namespace: "ui.preferences",
3567
+ path: "autonomy.defaultMode",
3568
+ reason: "Autonomy is user-owned, never remote-owned."
3569
+ },
3570
+ {
3571
+ namespace: "ui.preferences",
3572
+ path: "autonomy.yolo",
3573
+ reason: "Alias for the denied top-level `yolo`, and it wins over the user setting."
3574
+ },
3575
+ {
3576
+ namespace: "ui.preferences",
3577
+ path: "launch.autonomy",
3578
+ reason: "Launch-time autonomy mode; same user-owned boundary."
3579
+ },
3580
+ {
3581
+ namespace: "models.routing",
3582
+ path: "brain.mode",
3583
+ reason: "Selects the policy/LLM/human decision ladder."
3584
+ },
3585
+ {
3586
+ namespace: "models.routing",
3587
+ path: "brain.maxAutoRisk",
3588
+ reason: "The risk ceiling below which actions proceed without asking."
3589
+ }
3590
+ ];
3591
+ function contractHasPath(tree, segments) {
3592
+ if (segments.length === 0) return true;
3593
+ if (tree === true) return false;
3594
+ const [head, ...rest] = segments;
3595
+ if (head === void 0 || !Object.hasOwn(tree, head)) return false;
3596
+ const child = tree[head];
3597
+ return child === void 0 ? false : contractHasPath(child, rest);
3598
+ }
3599
+ function pruneContractPath(tree, segments) {
3600
+ if (tree === true || segments.length === 0) return tree;
3601
+ const [head, ...rest] = segments;
3602
+ if (head === void 0 || !Object.hasOwn(tree, head)) return tree;
3603
+ const next = { ...tree };
3604
+ if (rest.length === 0) {
3605
+ delete next[head];
3606
+ return next;
3607
+ }
3608
+ const child = next[head];
3609
+ if (child === void 0) return tree;
3610
+ next[head] = pruneContractPath(child, rest);
3611
+ return next;
3612
+ }
3613
+ function assertInboundDenyListResolves() {
3614
+ const unresolved = INBOUND_DENIED_PATHS.filter((entry) => {
3615
+ const tree = CLOUD_SYNC_CONTRACT[entry.namespace];
3616
+ return tree === void 0 || !contractHasPath(tree, entry.path.split("."));
3617
+ });
3618
+ if (unresolved.length > 0) {
3619
+ throw new Error(
3620
+ "INBOUND_DENIED_PATHS entr(ies) no longer resolve against CLOUD_SYNC_CONTRACT \u2014 a rename would silently re-open them: " + unresolved.map((entry) => `${entry.namespace}:${entry.path}`).join(", ")
3621
+ );
3622
+ }
3623
+ }
3624
+ var INBOUND_CONTRACT = (() => {
3625
+ assertInboundDenyListResolves();
3626
+ const out = { ...CLOUD_SYNC_CONTRACT };
3627
+ for (const entry of INBOUND_DENIED_PATHS) {
3628
+ const tree = out[entry.namespace];
3629
+ if (tree === void 0) continue;
3630
+ out[entry.namespace] = pruneContractPath(tree, entry.path.split("."));
3631
+ }
3632
+ return out;
3633
+ })();
3449
3634
  var NAMESPACE_SCHEMA_VERSIONS = {
3450
3635
  "core.runtime": 1,
3451
3636
  "ui.preferences": 1,
@@ -3547,7 +3732,7 @@ function mergeAtContract(local, incoming, tree) {
3547
3732
  return base;
3548
3733
  }
3549
3734
  function applyNamespacePayload(config, namespace, payload) {
3550
- const tree = CLOUD_SYNC_CONTRACT[namespace];
3735
+ const tree = INBOUND_CONTRACT[namespace];
3551
3736
  if (!tree || tree === true) return config;
3552
3737
  const next = { ...config };
3553
3738
  for (const [key, incoming] of Object.entries(payload)) {
@@ -4978,6 +5163,10 @@ var IN_PROJECT_ALLOWED_KEYS = /* @__PURE__ */ new Set([
4978
5163
  "fallbackModels",
4979
5164
  "fallbackBridge",
4980
5165
  "fallbackProfiles",
5166
+ // The profile SELECTOR. No broader than its siblings: a repo that can write
5167
+ // `fallbackModels` and `fallbackProfiles` already controls the chain outright,
5168
+ // and this one can only name a profile the user already defined.
5169
+ "fallbackProfile",
4981
5170
  "favoriteModels",
4982
5171
  "favoriteModelsOnly",
4983
5172
  "modelAvailabilitySchedule",
@@ -5025,6 +5214,10 @@ var KNOWN_DENIED_IN_PROJECT = [
5025
5214
  {
5026
5215
  key: "git",
5027
5216
  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)."
5217
+ },
5218
+ {
5219
+ key: "fallbackMaxLastResortCandidates",
5220
+ 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."
5028
5221
  }
5029
5222
  ];
5030
5223
  var KNOWN_CONFIG_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
@@ -5045,11 +5238,13 @@ var KNOWN_CONFIG_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
5045
5238
  "fallbackModels",
5046
5239
  "fallbackBridge",
5047
5240
  "fallbackProfiles",
5241
+ "fallbackProfile",
5048
5242
  "favoriteModels",
5049
5243
  "favoriteModelsOnly",
5050
5244
  "modelAvailabilitySchedule",
5051
5245
  "fallbackAuto",
5052
5246
  "fallbackStickiness",
5247
+ "fallbackMaxLastResortCandidates",
5053
5248
  "hooks",
5054
5249
  "plugins",
5055
5250
  "pluginManager",
@@ -5135,6 +5330,17 @@ var IN_PROJECT_DENIED_PATHS = [
5135
5330
  // operator owns, not the checked-out repository.
5136
5331
  path: "tools.kanbanGovernance",
5137
5332
  reason: "Repo-committed config could disable a Kanban governance gate the operator switched on, letting product mutations run outside any managed card."
5333
+ },
5334
+ {
5335
+ // The bridge spawn path resolves the CLI entry by walking UP from the
5336
+ // project root, so a repo that ships its own `packages/cli/dist/index.js`
5337
+ // gets that file spawned with `process.execPath` on WebUI boot — no
5338
+ // prompt, no banner. Turning the feature on is therefore equivalent to
5339
+ // arbitrary code execution for a hostile checkout, which makes this an
5340
+ // operator-owned switch and never a repo-owned one.
5341
+ // See discover-mailbox-bridge.ts:findWorkspaceCliEntry.
5342
+ path: "features.mailboxBridge",
5343
+ 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."
5138
5344
  }
5139
5345
  ];
5140
5346
  function deleteNestedPath(target, path34) {
@@ -8767,6 +8973,15 @@ async function readProviderSnapshot(configPath, vault, warn) {
8767
8973
  snapshot.fallbackBridge = decrypted.fallbackBridge.trim();
8768
8974
  }
8769
8975
  if (decrypted.fallbackProfiles) snapshot.fallbackProfiles = decrypted.fallbackProfiles;
8976
+ if (typeof decrypted.fallbackProfile === "string" && decrypted.fallbackProfile.trim()) {
8977
+ snapshot.fallbackProfile = decrypted.fallbackProfile.trim();
8978
+ }
8979
+ if (decrypted.fallbackStickiness && typeof decrypted.fallbackStickiness === "object") {
8980
+ snapshot.fallbackStickiness = decrypted.fallbackStickiness;
8981
+ }
8982
+ if (typeof decrypted.fallbackMaxLastResortCandidates === "number" && Number.isFinite(decrypted.fallbackMaxLastResortCandidates)) {
8983
+ snapshot.fallbackMaxLastResortCandidates = decrypted.fallbackMaxLastResortCandidates;
8984
+ }
8770
8985
  if (Array.isArray(decrypted.favoriteModels)) snapshot.favoriteModels = decrypted.favoriteModels;
8771
8986
  if (typeof decrypted.favoriteModelsOnly === "boolean")
8772
8987
  snapshot.favoriteModelsOnly = decrypted.favoriteModelsOnly;
@@ -8785,10 +9000,13 @@ function serializeSnapshot(s) {
8785
9000
  fallbackModels: s.fallbackModels ?? null,
8786
9001
  fallbackBridge: s.fallbackBridge ?? null,
8787
9002
  fallbackProfiles: s.fallbackProfiles ?? null,
9003
+ fallbackProfile: s.fallbackProfile ?? null,
8788
9004
  favoriteModels: s.favoriteModels ?? null,
8789
9005
  favoriteModelsOnly: s.favoriteModelsOnly ?? null,
8790
9006
  modelMatrix: s.modelMatrix ?? null,
8791
9007
  fallbackAuto: s.fallbackAuto ?? null,
9008
+ fallbackStickiness: s.fallbackStickiness ?? null,
9009
+ fallbackMaxLastResortCandidates: s.fallbackMaxLastResortCandidates ?? null,
8792
9010
  modelAvailabilitySchedule: s.modelAvailabilitySchedule ?? null
8793
9011
  });
8794
9012
  }
@@ -11491,6 +11709,44 @@ import * as fsp11 from "node:fs/promises";
11491
11709
  import * as path27 from "node:path";
11492
11710
  import { createInterface as createInterface2 } from "node:readline";
11493
11711
 
11712
+ // src/storage/session-writer-scrubber.ts
11713
+ function scrubSessionWriterEvent(event, secretScrubber) {
11714
+ const persistMessage = (message) => {
11715
+ const { _estTokens: _ignored, ...persisted } = message;
11716
+ return {
11717
+ ...persisted,
11718
+ content: typeof persisted.content === "string" ? secretScrubber?.scrub(persisted.content) ?? persisted.content : secretScrubber?.scrubObject(persisted.content) ?? persisted.content
11719
+ };
11720
+ };
11721
+ if (event.type === "context_snapshot" || event.type === "messages_replaced") {
11722
+ return { ...event, messages: event.messages.map(persistMessage) };
11723
+ }
11724
+ if (event.type === "message_appended" || event.type === "message_updated") {
11725
+ return { ...event, message: persistMessage(event.message) };
11726
+ }
11727
+ if (!secretScrubber) return event;
11728
+ if (event.type === "user_input") {
11729
+ return {
11730
+ ...event,
11731
+ content: typeof event.content === "string" ? secretScrubber.scrub(event.content) : secretScrubber.scrubObject(event.content)
11732
+ };
11733
+ }
11734
+ if (event.type === "llm_response") {
11735
+ return { ...event, content: secretScrubber.scrubObject(event.content) };
11736
+ }
11737
+ if (event.type === "file_snapshot") {
11738
+ return {
11739
+ ...event,
11740
+ files: event.files.map((f) => ({
11741
+ ...f,
11742
+ before: f.before !== null ? secretScrubber.scrub(f.before) : null,
11743
+ after: f.after !== null ? secretScrubber.scrub(f.after) : null
11744
+ }))
11745
+ };
11746
+ }
11747
+ return event;
11748
+ }
11749
+
11494
11750
  // src/storage/session-writer-truncate.ts
11495
11751
  import * as fsp10 from "node:fs/promises";
11496
11752
  var CHUNK_SIZE = 65536;
@@ -11635,45 +11891,11 @@ async function rewriteSessionToCheckpoint(filePath, checkpointByteOffset) {
11635
11891
  }
11636
11892
  }
11637
11893
 
11638
- // src/storage/session-writer-scrubber.ts
11639
- function scrubSessionWriterEvent(event, secretScrubber) {
11640
- const persistMessage = (message) => {
11641
- const { _estTokens: _ignored, ...persisted } = message;
11642
- return {
11643
- ...persisted,
11644
- content: typeof persisted.content === "string" ? secretScrubber?.scrub(persisted.content) ?? persisted.content : secretScrubber?.scrubObject(persisted.content) ?? persisted.content
11645
- };
11646
- };
11647
- if (event.type === "context_snapshot" || event.type === "messages_replaced") {
11648
- return { ...event, messages: event.messages.map(persistMessage) };
11649
- }
11650
- if (event.type === "message_appended" || event.type === "message_updated") {
11651
- return { ...event, message: persistMessage(event.message) };
11652
- }
11653
- if (!secretScrubber) return event;
11654
- if (event.type === "user_input") {
11655
- return {
11656
- ...event,
11657
- content: typeof event.content === "string" ? secretScrubber.scrub(event.content) : secretScrubber.scrubObject(event.content)
11658
- };
11659
- }
11660
- if (event.type === "llm_response") {
11661
- return { ...event, content: secretScrubber.scrubObject(event.content) };
11662
- }
11663
- if (event.type === "file_snapshot") {
11664
- return {
11665
- ...event,
11666
- files: event.files.map((f) => ({
11667
- ...f,
11668
- before: f.before !== null ? secretScrubber.scrub(f.before) : null,
11669
- after: f.after !== null ? secretScrubber.scrub(f.after) : null
11670
- }))
11671
- };
11672
- }
11673
- return event;
11674
- }
11675
-
11676
11894
  // src/storage/file-session-writer.ts
11895
+ function isClosedHandleError(err) {
11896
+ const code = err?.code;
11897
+ return code === "EBADF" || code === "ERR_CLOSED_RESOURCE" || code === "ERR_INVALID_HANDLE";
11898
+ }
11677
11899
  var FileSessionWriter = class _FileSessionWriter {
11678
11900
  constructor(id, handle, startedAt, meta, events, opts = {}, traceId) {
11679
11901
  this.id = id;
@@ -11845,8 +12067,7 @@ var FileSessionWriter = class _FileSessionWriter {
11845
12067
  try {
11846
12068
  return await this.handle.appendFile(data, "utf8");
11847
12069
  } catch (err) {
11848
- const nodeErr = err;
11849
- if (nodeErr?.code === "EBADF") {
12070
+ if (isClosedHandleError(err)) {
11850
12071
  this.handle = await fsp11.open(this.filePath, "a", 384);
11851
12072
  return await this.handle.appendFile(data, "utf8");
11852
12073
  }
@@ -11886,8 +12107,8 @@ var FileSessionWriter = class _FileSessionWriter {
11886
12107
  bufferSynchronousEvent(event) {
11887
12108
  if (this.closed) return;
11888
12109
  void this.ensureInit();
11889
- this.observeForSummary(event);
11890
- const appendEvent = event.type === "file_snapshot" ? scrubSessionWriterEvent(event, this.secretScrubber) : event;
12110
+ const appendEvent = scrubSessionWriterEvent(event, this.secretScrubber);
12111
+ this.observeForSummary(appendEvent);
11891
12112
  try {
11892
12113
  this._onAppend?.(appendEvent);
11893
12114
  } catch {
@@ -12040,8 +12261,7 @@ var FileSessionWriter = class _FileSessionWriter {
12040
12261
  try {
12041
12262
  await this.handle.datasync();
12042
12263
  } catch (err) {
12043
- const nodeErr = err;
12044
- if (nodeErr?.code === "EBADF") {
12264
+ if (isClosedHandleError(err)) {
12045
12265
  this.handle = await fsp11.open(this.filePath, "a", 384);
12046
12266
  return;
12047
12267
  }
@@ -12278,6 +12498,7 @@ var FileSessionWriter = class _FileSessionWriter {
12278
12498
  return this.closePromise;
12279
12499
  }
12280
12500
  async doClose() {
12501
+ await this.ensureInit();
12281
12502
  if (this.pendingFileSnapshots.length > 0) {
12282
12503
  await this.writeFileSnapshot(this.activePromptIndex ?? 0, [...this.pendingFileSnapshots]);
12283
12504
  this.pendingFileSnapshots = [];
@@ -12293,8 +12514,7 @@ var FileSessionWriter = class _FileSessionWriter {
12293
12514
  try {
12294
12515
  await this.handle.datasync();
12295
12516
  } catch (err) {
12296
- const nodeErr = err;
12297
- if (nodeErr?.code !== "EBADF") throw err;
12517
+ if (!isClosedHandleError(err)) throw err;
12298
12518
  }
12299
12519
  const endedAt = (/* @__PURE__ */ new Date()).toISOString();
12300
12520
  const observedActivityMs = Date.parse(this.lastActivityAt);
@@ -13494,9 +13714,40 @@ async function readOrBuildShardManifestEntry(opts) {
13494
13714
  return entry;
13495
13715
  }
13496
13716
 
13497
- // src/storage/session-store/summary-builder.ts
13717
+ // src/storage/session-store/strict-empty-check.ts
13498
13718
  import { createReadStream as createReadStream4 } from "node:fs";
13499
13719
  import { createInterface as createInterface4 } from "node:readline";
13720
+ var EMPTY_SESSION_EVENT_TYPES = /* @__PURE__ */ new Set(["session_start", "session_resumed", "session_end"]);
13721
+ async function isStrictlyEmptySessionFile(file) {
13722
+ const input = createReadStream4(file, { encoding: "utf8" });
13723
+ const lines = createInterface4({ input, crlfDelay: Infinity });
13724
+ let sawSessionStart = false;
13725
+ try {
13726
+ for await (const line of lines) {
13727
+ if (!line.trim()) continue;
13728
+ let event;
13729
+ try {
13730
+ event = JSON.parse(line);
13731
+ } catch {
13732
+ return false;
13733
+ }
13734
+ if (event === null || typeof event !== "object" || Array.isArray(event)) return false;
13735
+ const type = event.type;
13736
+ if (typeof type !== "string" || !EMPTY_SESSION_EVENT_TYPES.has(type)) return false;
13737
+ if (type === "session_start") sawSessionStart = true;
13738
+ }
13739
+ } catch {
13740
+ return false;
13741
+ } finally {
13742
+ lines.close();
13743
+ input.destroy();
13744
+ }
13745
+ return sawSessionStart;
13746
+ }
13747
+
13748
+ // src/storage/session-store/summary-builder.ts
13749
+ import { createReadStream as createReadStream5 } from "node:fs";
13750
+ import { createInterface as createInterface5 } from "node:readline";
13500
13751
  async function summarizeSessionFile(opts) {
13501
13752
  return summarizeSessionEventSequence({
13502
13753
  id: opts.id,
@@ -13613,8 +13864,8 @@ async function summarizeSessionEventSequence(opts) {
13613
13864
  }
13614
13865
  }
13615
13866
  async function* iterateSessionEvents(file, secretScrubber) {
13616
- const stream = createReadStream4(file, { encoding: "utf8" });
13617
- const lines = createInterface4({ input: stream, crlfDelay: Infinity });
13867
+ const stream = createReadStream5(file, { encoding: "utf8" });
13868
+ const lines = createInterface5({ input: stream, crlfDelay: Infinity });
13618
13869
  try {
13619
13870
  for await (const line of lines) {
13620
13871
  if (!line.trim()) continue;
@@ -14510,6 +14761,10 @@ var DefaultSessionStore = class _DefaultSessionStore {
14510
14761
  await deleteSessionArtifacts({ rootDir: this.dir, id, jsonlPath });
14511
14762
  await this.writeTombstone(id);
14512
14763
  }
14764
+ async isEmpty(id) {
14765
+ const canonicalId = await this.resolveId(id);
14766
+ return isStrictlyEmptySessionFile(this.sessionPath(canonicalId, ".jsonl"));
14767
+ }
14513
14768
  async delete(id) {
14514
14769
  if (this.catalogClient) {
14515
14770
  const canonical = await this.resolveId(id);
@@ -14722,10 +14977,10 @@ async function applyRewindToConversation(opts) {
14722
14977
  }
14723
14978
 
14724
14979
  // src/storage/session-rewinder.ts
14725
- import { createReadStream as createReadStream5 } from "node:fs";
14980
+ import { createReadStream as createReadStream6 } from "node:fs";
14726
14981
  import * as fsp20 from "node:fs/promises";
14727
14982
  import * as path33 from "node:path";
14728
- import { createInterface as createInterface5 } from "node:readline";
14983
+ import { createInterface as createInterface6 } from "node:readline";
14729
14984
  var DefaultSessionRewinder = class {
14730
14985
  constructor(sessionsDir, projectRoot) {
14731
14986
  this.sessionsDir = sessionsDir;
@@ -14737,8 +14992,8 @@ var DefaultSessionRewinder = class {
14737
14992
  return sessionScopedPath(this.sessionsDir, sessionId, ".jsonl");
14738
14993
  }
14739
14994
  async *readEvents(file) {
14740
- const stream = createReadStream5(file, { encoding: "utf8" });
14741
- const lines = createInterface5({ input: stream, crlfDelay: Infinity });
14995
+ const stream = createReadStream6(file, { encoding: "utf8" });
14996
+ const lines = createInterface6({ input: stream, crlfDelay: Infinity });
14742
14997
  try {
14743
14998
  for await (const line of lines) {
14744
14999
  if (!line.trim()) continue;
@@ -24,10 +24,19 @@ export interface ProviderConfigSnapshot {
24
24
  fallbackModels?: string[];
25
25
  fallbackBridge?: string;
26
26
  fallbackProfiles?: Record<string, string[]>;
27
+ /** Selected named profile (Config.fallbackProfile). */
28
+ fallbackProfile?: string;
27
29
  favoriteModels?: string[];
28
30
  favoriteModelsOnly?: boolean;
29
31
  modelMatrix?: Record<string, unknown>;
30
32
  fallbackAuto?: boolean;
33
+ /** Primary-probe cooldown / sticky-dwell tuning (Config.fallbackStickiness). */
34
+ fallbackStickiness?: {
35
+ primaryProbeInterval?: number;
36
+ stickyFallbackTurns?: number;
37
+ };
38
+ /** Last-resort append cap (Config.fallbackMaxLastResortCandidates). */
39
+ fallbackMaxLastResortCandidates?: number;
31
40
  modelAvailabilitySchedule?: import('../core/model-availability-calendar.js').ModelBlackoutRule[];
32
41
  }
33
42
  export interface WatchProviderConfigOptions {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Return true only for a well-formed journal containing lifecycle envelope
3
+ * events and nothing else. Parsing fails closed so a damaged or newer journal
4
+ * can never be mistaken for an empty session and deleted.
5
+ */
6
+ export declare function isStrictlyEmptySessionFile(file: string): Promise<boolean>;
7
+ //# sourceMappingURL=strict-empty-check.d.ts.map
@@ -205,6 +205,7 @@ export declare class DefaultSessionStore implements SessionStore {
205
205
  * to the caller so prune() can report it.
206
206
  */
207
207
  private deleteSession;
208
+ isEmpty(id: string): Promise<boolean>;
208
209
  delete(id: string): Promise<void>;
209
210
  rename(id: string, name: string): Promise<SessionSummary>;
210
211
  prune(maxAgeDays?: number): Promise<number>;
@@ -1,5 +1,5 @@
1
1
  export { COUNCIL_TOOL_NAME, type CouncilToolInput, type CreateCouncilToolOptions, createCouncilTool, MAX_COUNCIL_CONTEXT_CHARS, MAX_COUNCIL_QUESTION_CHARS, MAX_COUNCIL_TOOL_OPTIONS, } from './council-tool.js';
2
- export { AGENT_MODEL_ASSIGN_TOOL_NAME, createFallbackManageTools, FALLBACK_CHAIN_MANAGE_TOOL_NAME, FALLBACK_PROFILE_MANAGE_TOOL_NAME, FAVORITE_MANAGE_TOOL_NAME, type FallbackManageToolOptions, LEADER_MODEL_SET_TOOL_NAME, PROVIDER_KEY_SET_TOOL_NAME, PROVIDER_MANAGE_TOOL_NAME, SYSTEM_CONFIG_VIEW_TOOL_NAME, } from './fallback-manage-tools.js';
2
+ export { AGENT_MODEL_ASSIGN_TOOL_NAME, createFallbackManageTools, FALLBACK_CHAIN_MANAGE_TOOL_NAME, FALLBACK_PROFILE_MANAGE_TOOL_NAME, FAVORITE_MANAGE_TOOL_NAME, type FallbackManageToolOptions, LEADER_MODEL_SET_TOOL_NAME, PROVIDER_KEY_SET_TOOL_NAME, PROVIDER_MANAGE_TOOL_NAME, SYSTEM_CONFIG_VIEW_TOOL_NAME, validateProviderBaseUrl, } from './fallback-manage-tools.js';
3
3
  export { createMcpControlTool, type MCPRegistryHandle } from './mcp-control.js';
4
4
  export { createMcpUseTool } from './mcp-use.js';
5
5
  export { type CreateOneShotLLMToolOptions, createOneShotLLMTool, ONE_SHOT_LLM_TOOL_NAME, } from './one-shot-llm-tool.js';