switchroom 0.19.32 → 0.19.34

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.19.32",
4
+ "version": "0.19.34",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -21972,6 +21972,7 @@ var init_schema = __esm(() => {
21972
21972
  }).describe("Per-operation LLM override. Every field optional; an unset field (or " + "an omitted op block) inherits the global `hindsight.llm.*`, which is " + "already the engine's fallback \u2014 switchroom emits only the vars set.");
21973
21973
  HindsightConfigSchema = exports_external.object({
21974
21974
  gpu: exports_external.boolean().optional().describe("Force GPU passthrough for the hindsight container on (`true`) or off " + "(`false`), overriding host autodetection in BOTH directions. Absent " + "(the default) \u2192 autodetect from the persisted host-capabilities " + "verdict (`~/.switchroom/host-capabilities.json`), which enables " + "`--gpus all` only when that file proves BOTH a GPU and the nvidia " + "container toolkit. Set `true` when that verdict is wrong or unreadable " + "and you know the host has a working toolkit \u2014 switchroom cannot verify " + "it for you, and `docker run --gpus all` hard-fails container create on " + "a host without one. Set `false` to pin the container to CPU on a GPU " + "host. This is also the declarative opt-out for the recreate-time GPU " + "drop guard (`switchroom memory setup --recreate` refuses to silently " + "turn a GPU container into a CPU one). `--gpu`/`--no-gpu` on `memory " + "setup` override this for a single run."),
21975
+ cp_access_key: exports_external.string().min(1).optional().describe("Access key for the hindsight control-plane DASHBOARD (upstream " + "`HINDSIGHT_CP_ACCESS_KEY`, port 9999). Literal or \u2014 strongly preferred " + "\u2014 a `vault:` reference such as `vault:hindsight_cp_access_key`, read " + "through the broker at container-launch time. This is the ONLY thing " + "that arms the dashboard's login: upstream's middleware short-circuits " + "to `next()` when the var is unset, so an unset key means the dashboard " + "has no authentication at all, not weak authentication. Because of that, " + "leaving it unset is FAIL-CLOSED: switchroom pins " + "`HINDSIGHT_CP_HOSTNAME=127.0.0.1` so the loginless dashboard is " + "reachable only from the host, and warns. Set this to serve the " + "dashboard on the LAN/tailnet."),
21975
21976
  llm: exports_external.object({
21976
21977
  provider: exports_external.string().min(1).optional().describe("Hindsight LLM provider (upstream `HINDSIGHT_API_LLM_PROVIDER`). " + "Defaults to `claude-code` (subscription-honest, broker-fed OAuth). " + "Any litellm-routable provider the upstream image supports is valid. " + "Serves as the GLOBAL default for every op absent a per-op override."),
21977
21978
  model: exports_external.string().min(1).optional().describe("Hindsight LLM model (upstream `HINDSIGHT_API_LLM_MODEL`). Defaults " + "to HINDSIGHT_DEFAULT_MODEL. Any model your LiteLLM proxy can route " + "is valid, e.g. `openrouter/z-ai/glm-5.2` when routing through the " + "fleet proxy. With provider=claude-code this value is ALSO exported " + "as `ANTHROPIC_MODEL` to the claude subprocess. Serves as the GLOBAL " + "default for every op absent a per-op override."),
@@ -44732,7 +44733,7 @@ async function handleVoiceMessage(ctx, deps) {
44732
44733
  }
44733
44734
 
44734
44735
  // gateway/status-pin-store.ts
44735
- var SNAPSHOT_VERSIONS = new Set([1, 2, 3, 4]);
44736
+ var SNAPSHOT_VERSION = 4;
44736
44737
  function isPinRow(x) {
44737
44738
  if (x == null || typeof x !== "object")
44738
44739
  return false;
@@ -44757,12 +44758,12 @@ function loadStatusPins(path, fs2) {
44757
44758
  if (parsed == null || typeof parsed !== "object")
44758
44759
  return [];
44759
44760
  const env = parsed;
44760
- if (typeof env.v !== "number" || !SNAPSHOT_VERSIONS.has(env.v) || !Array.isArray(env.pins))
44761
+ if (!Number.isInteger(env.v) || env.v < 1 || !Array.isArray(env.pins))
44761
44762
  return [];
44762
44763
  return env.pins.filter(isPinRow);
44763
44764
  }
44764
44765
  function persistStatusPins(path, fs2, snapshot, log = (l) => process.stderr.write(l)) {
44765
- const env = { v: 4, pins: [...snapshot] };
44766
+ const env = { v: SNAPSHOT_VERSION, pins: [...snapshot] };
44766
44767
  const tmp = path + ".tmp";
44767
44768
  try {
44768
44769
  fs2.writeFileSync(tmp, JSON.stringify(env));
@@ -84574,12 +84575,12 @@ init_paths();
84574
84575
  var _warnedReads2 = new Set;
84575
84576
 
84576
84577
  // ../src/setup/hindsight-pg-defaults.ts
84577
- var HINDSIGHT_PG_MEM_LIMIT_MIB_FOR_DERIVATION = 8 * 1024;
84578
+ var HINDSIGHT_PG_MEM_LIMIT_MIB_FOR_DERIVATION = 16 * 1024;
84578
84579
  var HINDSIGHT_PG_APP_ANON_MIB = 2560;
84579
84580
  var HINDSIGHT_PG_PAGE_CACHE_FLOOR_MIB = 2048;
84580
84581
  var HINDSIGHT_PG_SHARED_BUFFERS_BUDGET_MIB = HINDSIGHT_PG_MEM_LIMIT_MIB_FOR_DERIVATION - HINDSIGHT_PG_APP_ANON_MIB - HINDSIGHT_PG_PAGE_CACHE_FLOOR_MIB;
84581
- var HINDSIGHT_PG_DEFAULT_SHARED_BUFFERS_MIB = 1536;
84582
- var HINDSIGHT_PG_DEFAULT_EFFECTIVE_CACHE_SIZE_MIB = 4096;
84582
+ var HINDSIGHT_PG_DEFAULT_SHARED_BUFFERS_MIB = 3072;
84583
+ var HINDSIGHT_PG_DEFAULT_EFFECTIVE_CACHE_SIZE_MIB = 7168;
84583
84584
  function pgMib(mib) {
84584
84585
  return `${mib}MB`;
84585
84586
  }
@@ -84752,9 +84753,21 @@ var HINDSIGHT_RETAIN_CLIENT_DEADLINE_S = hindsightRetainClientTimeoutSeconds() +
84752
84753
  var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8888/health",timeout=4).getcode()==200 else 1)';
84753
84754
  var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
84754
84755
  var DOCKER_PROBE_TIMEOUT_MS = 60 * 1000;
84756
+ var HINDSIGHT_CP_UNAUTHENTICATED_BIND_ADDR = "127.0.0.1";
84757
+ var HINDSIGHT_CP_NO_ACCESS_KEY_WARNING = "hindsight: no `hindsight.cp_access_key` configured \u2014 the control-plane " + "dashboard has NO login (the access-key middleware is inert when " + "HINDSIGHT_CP_ACCESS_KEY is unset), so it is pinned to " + `${HINDSIGHT_CP_UNAUTHENTICATED_BIND_ADDR} and will NOT be reachable from ` + "the LAN or tailnet. Set `hindsight.cp_access_key: vault:<key>` in " + "switchroom.yaml to arm the login and open it up.";
84755
84758
 
84756
84759
  // ../src/memory/hindsight.ts
84757
- var DEFAULT_RETAIN_MISSION = `Extract durable facts that will still be true and useful weeks from now: user preferences and standing rules, ongoing projects and recurring commitments, technical and architectural decisions with their rationale, and people/tool relationships. A preference revealed by a request is durable \u2014 record the preference (what the user likes, wants, or always does), not the request itself.
84760
+ var DEFAULT_RETAIN_MISSION = `Extract durable facts that will still be true and useful weeks from now, once this session is forgotten.
84761
+ ` + `
84762
+ ` + `DURABILITY GATE. Emit a candidate ONLY if it is one of these five classes:
84763
+ ` + `- PREFERENCE \u2014 what the user likes, wants, or always does; a standing rule or correction.
84764
+ ` + `- DECISION \u2014 a settled choice that changes how future work is done, including a choice NOT to do something. A decision about the mechanics of the CURRENT task (which worker to dispatch, which branch to rebase, which PR to merge now, what to do next) is process narration, not a durable decision \u2014 drop it unless it establishes a standing rule or permanently changes a system.
84765
+ ` + `- FINDING \u2014 a root cause, a measurement, or verified behaviour of a system. Include the number.
84766
+ ` + `- OUTCOME \u2014 a completed result that changed the world: what shipped, what a thing turned out to be. Not the act of shipping it.
84767
+ ` + `- RELATIONSHIP \u2014 who a person is, what a project or tool is, and how they connect.
84768
+ ` + `If a candidate fits none of the five, it is not a memory. Drop it; do not reword it into one.
84769
+ ` + `
84770
+ ` + `A preference revealed by a request is durable \u2014 record the preference (what the user likes, wants, or always does), not the request itself.
84758
84771
  ` + `
84759
84772
  ` + `A TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this
84760
84773
  ` + `candidate a file path, a command/process/agent/session id, a temp directory, or
@@ -84771,6 +84784,9 @@ var DEFAULT_RETAIN_MISSION = `Extract durable facts that will still be true and
84771
84784
  ` + `- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.
84772
84785
  ` + `- Agent tool-use traces or narration of what the assistant did (e.g. "the
84773
84786
  ` + ` assistant used X to query Y", "ran a search", "sent the message").
84787
+ ` + `- The act of delegating, dispatching, spawning, launching, steering or merging
84788
+ ` + ` work \u2014 including when it succeeded. "X was dispatched and completed" is the
84789
+ ` + ` session describing itself. Record only what the work LEARNED or CHANGED.
84774
84790
  ` + `- In-flight workflow/process narration (a sub-task started, paused, or is still
84775
84791
  ` + ` running) \u2014 retain the outcome only once the task completes or a decision is made.
84776
84792
  ` + `- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.
@@ -84793,8 +84809,12 @@ var DEFAULT_RETAIN_MISSION = `Extract durable facts that will still be true and
84793
84809
  ` + ` the fact is explicitly dated, in which case record it as a dated observation.
84794
84810
  ` + `- Greetings, acknowledgements, and routine operational chatter.
84795
84811
  ` + `
84812
+ ` + `Write each fact so it stands alone: name the thing, the number, and the date. A
84813
+ ` + `sentence that only makes sense while reading this transcript is not durable.
84814
+ ` + `
84796
84815
  ` + `If a candidate fact matches an exclusion, drop it rather than rewording it. If
84797
- ` + "nothing durable remains, return an empty facts list.";
84816
+ ` + `nothing durable remains, return an empty facts list.
84817
+ `;
84798
84818
  var SUPERSEDED_RETAIN_MISSIONS = [
84799
84819
  "Extract technical decisions, architectural choices, user preferences, project context, and people/tool relationships. Ignore routine greetings and transient operational details.",
84800
84820
  "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise.",
@@ -84841,7 +84861,96 @@ var SUPERSEDED_RETAIN_MISSIONS = [
84841
84861
  ` + `- Greetings, acknowledgements, and routine operational chatter.
84842
84862
  ` + `
84843
84863
  ` + `If a candidate fact matches an exclusion, drop it rather than rewording it. If
84844
- ` + "nothing durable remains, return an empty facts list."
84864
+ ` + "nothing durable remains, return an empty facts list.",
84865
+ `Extract durable facts that will still be true and useful weeks from now: user preferences and standing rules, ongoing projects and recurring commitments, technical and architectural decisions with their rationale, and people/tool relationships. A preference revealed by a request is durable \u2014 record the preference (what the user likes, wants, or always does), not the request itself.
84866
+ ` + `
84867
+ ` + `A TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this
84868
+ ` + `candidate a file path, a command/process/agent/session id, a temp directory, or
84869
+ ` + `the location where some output was written? If yes, drop it \u2014 it is transcript
84870
+ ` + `exhaust, not memory.
84871
+ ` + `
84872
+ ` + `NEVER extract:
84873
+ ` + `- Tool results verbatim or paraphrased. Concretely, never produce a fact whose
84874
+ ` + ` text resembles any of these: "File created successfully at /path/to/file",
84875
+ ` + ` "A background command with ID bctz4yskm is running, and its output will be
84876
+ ` + ` written to /tmp/...", "Async agent a745598ba84e71df1 was launched successfully
84877
+ ` + ` and is running in the background", "User executed a Bash command to sleep for
84878
+ ` + ` 200 seconds", "The assistant used grep to locate 'truncateSync' in src/foo.ts".
84879
+ ` + `- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.
84880
+ ` + `- Agent tool-use traces or narration of what the assistant did (e.g. "the
84881
+ ` + ` assistant used X to query Y", "ran a search", "sent the message").
84882
+ ` + `- In-flight workflow/process narration (a sub-task started, paused, or is still
84883
+ ` + ` running) \u2014 retain the outcome only once the task completes or a decision is made.
84884
+ ` + `- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.
84885
+ ` + `- Slash commands the user typed and their effects (e.g. "User issued /clear to
84886
+ ` + ` reset assistant state").
84887
+ ` + `- Hindsight's own errors, retries, backlogs, or internal state \u2014 the memory
84888
+ ` + ` system's self-reports are not memories.
84889
+ ` + `- Restatements of the user's current request or the task in progress.
84890
+ ` + `- Volatile state written as a timeless assertion. A version, count, size,
84891
+ ` + ` backlog, status, or any "X is running Y" / "X is at Y" / "X is currently Y"
84892
+ ` + ` claim is true only at the instant it was said. Concretely, never produce a
84893
+ ` + ` fact whose text resembles any of these: "Switchroom fleet is running image
84894
+ ` + ` version v0.18.19", "The switchroom repo is at /path/to/fleet, version
84895
+ ` + ` v0.19.5", "Bank overlord has 43155 pending consolidations", "The build is
84896
+ ` + ` currently green". If the claim is worth keeping, put the date INSIDE the
84897
+ ` + ` fact text ("As of 2026-07-19 the fleet was running v0.18.19"); if you
84898
+ ` + ` cannot date it, drop it. An undated one is recalled forever as though it
84899
+ ` + ` were still true, which is worse than not remembering it at all.
84900
+ ` + `- Transient state (unread counts, build status, what is running right now) unless
84901
+ ` + ` the fact is explicitly dated, in which case record it as a dated observation.
84902
+ ` + `- Greetings, acknowledgements, and routine operational chatter.
84903
+ ` + `
84904
+ ` + `If a candidate fact matches an exclusion, drop it rather than rewording it. If
84905
+ ` + "nothing durable remains, return an empty facts list.",
84906
+ `Extract durable facts that will still be true and useful weeks from now, once this session is forgotten.
84907
+ ` + `
84908
+ ` + `DURABILITY GATE. Emit a candidate ONLY if it is one of these five classes:
84909
+ ` + `- PREFERENCE \u2014 what the user likes, wants, or always does; a standing rule or correction.
84910
+ ` + `- DECISION \u2014 a settled choice that changes how future work is done, including a choice NOT to do something. A decision about the mechanics of the CURRENT task (which worker to dispatch, which branch to rebase, which PR to merge now, what to do next) is process narration, not a durable decision \u2014 drop it unless it establishes a standing rule or permanently changes a system.
84911
+ ` + `- FINDING \u2014 a root cause, a measurement, or verified behaviour of a system. Include the number.
84912
+ ` + `- OUTCOME \u2014 a completed result that changed the world: what shipped, what a thing turned out to be. Not the act of shipping it.
84913
+ ` + `- RELATIONSHIP \u2014 who a person is, what a project or tool is, and how they connect.
84914
+ ` + `If a candidate fits none of the five, it is not a memory. Drop it; do not reword it into one.
84915
+ ` + `
84916
+ ` + `A preference revealed by a request is durable \u2014 record the preference (what the user likes, wants, or always does), not the request itself.
84917
+ ` + `
84918
+ ` + `A TOOL RESULT IS NOT A FACT. Before extracting, ask: is the subject of this
84919
+ ` + `candidate a file path, a command/process/agent/session id, a temp directory, or
84920
+ ` + `the location where some output was written? If yes, drop it \u2014 it is transcript
84921
+ ` + `exhaust, not memory.
84922
+ ` + `
84923
+ ` + `NEVER extract:
84924
+ ` + `- Tool results verbatim or paraphrased. Concretely, never produce a fact whose
84925
+ ` + ` text resembles any of these: "File created successfully at /path/to/file",
84926
+ ` + ` "A background command with ID bctz4yskm is running, and its output will be
84927
+ ` + ` written to /tmp/...", "Async agent a745598ba84e71df1 was launched successfully
84928
+ ` + ` and is running in the background", "User executed a Bash command to sleep for
84929
+ ` + ` 200 seconds", "The assistant used grep to locate 'truncateSync' in src/foo.ts".
84930
+ ` + `- Anything mentioning a path under /tmp, a scratchpad directory, or a .tmp file.
84931
+ ` + `- Agent tool-use traces or narration of what the assistant did (e.g. "the
84932
+ ` + ` assistant used X to query Y", "ran a search", "sent the message").
84933
+ ` + `- The act of delegating, dispatching, spawning, launching, steering or merging
84934
+ ` + ` work \u2014 including when it succeeded. "X was dispatched and completed" is the
84935
+ ` + ` session describing itself. Record only what the work LEARNED or CHANGED.
84936
+ ` + `- In-flight workflow/process narration (a sub-task started, paused, or is still
84937
+ ` + ` running) \u2014 retain the outcome only once the task completes or a decision is made.
84938
+ ` + `- Operation, request, batch, agent, command or session IDs, UUIDs, hashes, or error codes.
84939
+ ` + `- Slash commands the user typed and their effects (e.g. "User issued /clear to
84940
+ ` + ` reset assistant state").
84941
+ ` + `- Hindsight's own errors, retries, backlogs, or internal state \u2014 the memory
84942
+ ` + ` system's self-reports are not memories.
84943
+ ` + `- Restatements of the user's current request or the task in progress.
84944
+ ` + `- Transient state (unread counts, build status, what is running right now) unless
84945
+ ` + ` the fact is explicitly dated, in which case record it as a dated observation.
84946
+ ` + `- Greetings, acknowledgements, and routine operational chatter.
84947
+ ` + `
84948
+ ` + `Write each fact so it stands alone: name the thing, the number, and the date. A
84949
+ ` + `sentence that only makes sense while reading this transcript is not durable.
84950
+ ` + `
84951
+ ` + `If a candidate fact matches an exclusion, drop it rather than rewording it. If
84952
+ ` + `nothing durable remains, return an empty facts list.
84953
+ `
84845
84954
  ];
84846
84955
  var DEFAULT_OBSERVATIONS_MISSION = `Synthesise durable, standing knowledge about the people, projects, and systems this agent works with: preferences and standing rules, roles and relationships, skills and recurring patterns, technical and operational decisions with their rationale, and the state of long-running work once it lands.
84847
84956
  ` + `
@@ -86523,7 +86632,7 @@ function shouldSweepChatAtBoot(chatId) {
86523
86632
 
86524
86633
  // gateway/stale-pin-sweep-store.ts
86525
86634
  var SWEEP_MAX_ATTEMPTS = 8;
86526
- var SWEEP_ENVELOPE_VERSIONS = new Set([1]);
86635
+ var SWEEP_ENVELOPE_VERSION = 1;
86527
86636
  function sweepTargetKey(chatId, threadId) {
86528
86637
  return `${chatId}:${threadId ?? "-"}`;
86529
86638
  }
@@ -86551,13 +86660,13 @@ function loadSweepCursors(path2, fs2) {
86551
86660
  if (parsed == null || typeof parsed !== "object")
86552
86661
  return [];
86553
86662
  const env = parsed;
86554
- if (typeof env.v !== "number" || !SWEEP_ENVELOPE_VERSIONS.has(env.v) || !Array.isArray(env.cursors)) {
86663
+ if (!Number.isInteger(env.v) || env.v < 1 || !Array.isArray(env.cursors)) {
86555
86664
  return [];
86556
86665
  }
86557
86666
  return env.cursors.filter(isCursorRow);
86558
86667
  }
86559
86668
  function persistSweepCursors(path2, fs2, cursors, log = (l) => process.stderr.write(l)) {
86560
- const env = { v: 1, cursors: [...cursors] };
86669
+ const env = { v: SWEEP_ENVELOPE_VERSION, cursors: [...cursors] };
86561
86670
  try {
86562
86671
  fs2.writeFileSync(path2, JSON.stringify(env));
86563
86672
  } catch (err) {
@@ -86978,11 +87087,13 @@ function createStalePinSweeper(deps) {
86978
87087
  const repinUnpinLoop = async (target, kind, cursor) => {
86979
87088
  const gate = gateFor(kind);
86980
87089
  let popped = 0;
87090
+ let issued = 0;
86981
87091
  let consecutiveFloods = 0;
86982
87092
  let lastRetryAfter = null;
86983
87093
  let noProgressAtSameRetryAfter = 0;
86984
87094
  let lastObservedTop = null;
86985
87095
  let unverifiedPop = false;
87096
+ const out = (r) => ({ ...r, issued });
86986
87097
  const creditPop = () => {
86987
87098
  popped++;
86988
87099
  cursor.popped++;
@@ -86991,29 +87102,29 @@ function createStalePinSweeper(deps) {
86991
87102
  };
86992
87103
  for (let i = 0;i < gate.maxPopsPerChatPerSweep; i++) {
86993
87104
  if (circuitOpen) {
86994
- return { status: "aborted-circuit-breaker", popped, detail: "breaker open" };
87105
+ return out({ status: "aborted-circuit-breaker", popped, detail: "breaker open" });
86995
87106
  }
86996
87107
  const observed = await readTopVerified(target.chatId);
86997
87108
  if (observed == null) {
86998
- return { status: "incomplete", popped, detail: "pin-stack read unverifiable" };
87109
+ return out({ status: "incomplete", popped, detail: "pin-stack read unverifiable" });
86999
87110
  }
87000
87111
  if (unverifiedPop) {
87001
87112
  if (observed.top != null && observed.top === lastObservedTop) {
87002
- return {
87113
+ return out({
87003
87114
  status: "incomplete",
87004
87115
  popped,
87005
87116
  detail: `no progress: message ${observed.top} still on top after a repin+unpin`
87006
- };
87117
+ });
87007
87118
  }
87008
87119
  creditPop();
87009
87120
  unverifiedPop = false;
87010
87121
  }
87011
87122
  if (observed.top == null) {
87012
- return { status: "drained", popped };
87123
+ return out({ status: "drained", popped });
87013
87124
  }
87014
87125
  lastObservedTop = observed.top;
87015
87126
  if (!await awaitWriteSlot(kind)) {
87016
- return { status: "deferred-budget", popped, detail: "per-minute pin-op budget spent" };
87127
+ return out({ status: "deferred-budget", popped, detail: "per-minute pin-op budget spent" });
87017
87128
  }
87018
87129
  try {
87019
87130
  await deps.pinSilent(target.chatId, observed.top);
@@ -87021,10 +87132,10 @@ function createStalePinSweeper(deps) {
87021
87132
  const d = classify(err);
87022
87133
  if (d.kind === "breaker") {
87023
87134
  circuitOpen = true;
87024
- return { status: "aborted-circuit-breaker", popped, detail: d.detail };
87135
+ return out({ status: "aborted-circuit-breaker", popped, detail: d.detail });
87025
87136
  }
87026
87137
  if (d.kind === "rights")
87027
- return { status: "skipped-no-rights", popped };
87138
+ return out({ status: "skipped-no-rights", popped });
87028
87139
  if (d.kind === "flood") {
87029
87140
  consecutiveFloods++;
87030
87141
  if (lastRetryAfter === d.seconds)
@@ -87033,19 +87144,19 @@ function createStalePinSweeper(deps) {
87033
87144
  noProgressAtSameRetryAfter = 1;
87034
87145
  lastRetryAfter = d.seconds;
87035
87146
  if (consecutiveFloods >= MAX_CONSECUTIVE_FLOOD_WAITS || noProgressAtSameRetryAfter >= MAX_IDENTICAL_RETRY_AFTER_NO_PROGRESS) {
87036
- return {
87147
+ return out({
87037
87148
  status: "deferred-flood",
87038
87149
  popped,
87039
87150
  detail: `flood: ${consecutiveFloods} consecutive 429s, retry_after=${d.seconds}s`
87040
- };
87151
+ });
87041
87152
  }
87042
87153
  await deps.sleep(d.seconds * 1000 * Math.pow(FLOOD_BACKOFF_FACTOR, consecutiveFloods - 1));
87043
87154
  continue;
87044
87155
  }
87045
- return { status: "error", popped, detail: d.detail };
87156
+ return out({ status: "error", popped, detail: d.detail });
87046
87157
  }
87047
87158
  if (!await awaitWriteSlot(kind)) {
87048
- return { status: "deferred-budget", popped, detail: "per-minute pin-op budget spent" };
87159
+ return out({ status: "deferred-budget", popped, detail: "per-minute pin-op budget spent" });
87049
87160
  }
87050
87161
  try {
87051
87162
  await deps.unpin(target.chatId, observed.top);
@@ -87053,12 +87164,12 @@ function createStalePinSweeper(deps) {
87053
87164
  const d = classify(err);
87054
87165
  if (d.kind === "breaker") {
87055
87166
  circuitOpen = true;
87056
- return { status: "aborted-circuit-breaker", popped, detail: d.detail };
87167
+ return out({ status: "aborted-circuit-breaker", popped, detail: d.detail });
87057
87168
  }
87058
87169
  if (d.kind === "rights")
87059
- return { status: "skipped-no-rights", popped };
87170
+ return out({ status: "skipped-no-rights", popped });
87060
87171
  if (isNothingToUnpinError(err))
87061
- return { status: "drained", popped };
87172
+ return out({ status: "drained", popped });
87062
87173
  if (d.kind === "flood") {
87063
87174
  consecutiveFloods++;
87064
87175
  if (lastRetryAfter === d.seconds)
@@ -87067,30 +87178,31 @@ function createStalePinSweeper(deps) {
87067
87178
  noProgressAtSameRetryAfter = 1;
87068
87179
  lastRetryAfter = d.seconds;
87069
87180
  if (consecutiveFloods >= MAX_CONSECUTIVE_FLOOD_WAITS || noProgressAtSameRetryAfter >= MAX_IDENTICAL_RETRY_AFTER_NO_PROGRESS) {
87070
- return {
87181
+ return out({
87071
87182
  status: "deferred-flood",
87072
87183
  popped,
87073
87184
  detail: `flood: ${consecutiveFloods} consecutive 429s, retry_after=${d.seconds}s`
87074
- };
87185
+ });
87075
87186
  }
87076
87187
  await deps.sleep(d.seconds * 1000 * Math.pow(FLOOD_BACKOFF_FACTOR, consecutiveFloods - 1));
87077
87188
  continue;
87078
87189
  }
87079
- return { status: "error", popped, detail: d.detail };
87190
+ return out({ status: "error", popped, detail: d.detail });
87080
87191
  }
87081
87192
  consecutiveFloods = 0;
87193
+ issued++;
87082
87194
  unverifiedPop = true;
87083
87195
  }
87084
87196
  const finalRead = await readTopVerified(target.chatId);
87085
87197
  if (unverifiedPop && finalRead != null && finalRead.top !== lastObservedTop)
87086
87198
  creditPop();
87087
87199
  if (finalRead != null && finalRead.top == null)
87088
- return { status: "drained", popped };
87089
- return {
87200
+ return out({ status: "drained", popped });
87201
+ return out({
87090
87202
  status: "deferred-budget",
87091
87203
  popped,
87092
87204
  detail: `hit the ${gate.maxPopsPerChatPerSweep}-pop cap for this chat; yielding to next boot`
87093
- };
87205
+ });
87094
87206
  };
87095
87207
  const targetedUnpinDrain = async (target, kind, cursor, ids) => {
87096
87208
  const alreadyDone = new Set(cursor.doneIds ?? []);
@@ -87187,10 +87299,21 @@ function createStalePinSweeper(deps) {
87187
87299
  const forumTopicDrain = async (target, cursor) => {
87188
87300
  const threadId = target.threadId;
87189
87301
  if (threadId == null) {
87190
- return { status: "skipped-nothing-recorded", popped: 0, detail: "no message_thread_id" };
87302
+ return {
87303
+ status: "skipped-nothing-recorded",
87304
+ popped: 0,
87305
+ issued: 0,
87306
+ detail: "no message_thread_id"
87307
+ };
87191
87308
  }
87309
+ const before = await readTopVerified(target.chatId);
87192
87310
  if (!await awaitWriteSlot("forum-topic")) {
87193
- return { status: "deferred-budget", popped: 0, detail: "per-minute pin-op budget spent" };
87311
+ return {
87312
+ status: "deferred-budget",
87313
+ popped: 0,
87314
+ issued: 0,
87315
+ detail: "per-minute pin-op budget spent"
87316
+ };
87194
87317
  }
87195
87318
  try {
87196
87319
  await deps.unpinAllForumTopicMessages(target.chatId, threadId);
@@ -87198,39 +87321,59 @@ function createStalePinSweeper(deps) {
87198
87321
  const d = classify(err);
87199
87322
  if (d.kind === "breaker") {
87200
87323
  circuitOpen = true;
87201
- return { status: "aborted-circuit-breaker", popped: 0, detail: d.detail };
87324
+ return { status: "aborted-circuit-breaker", popped: 0, issued: 0, detail: d.detail };
87202
87325
  }
87203
87326
  if (d.kind === "rights")
87204
- return { status: "skipped-no-rights", popped: 0 };
87327
+ return { status: "skipped-no-rights", popped: 0, issued: 0 };
87205
87328
  if (d.kind === "flood") {
87206
87329
  return {
87207
87330
  status: "deferred-flood",
87208
87331
  popped: 0,
87332
+ issued: 0,
87209
87333
  detail: `unpinAllForumTopicMessages pegged at retry_after=${d.seconds}s; not looping`
87210
87334
  };
87211
87335
  }
87212
- return { status: "error", popped: 0, detail: d.detail };
87336
+ return { status: "error", popped: 0, issued: 0, detail: d.detail };
87213
87337
  }
87214
- cursor.popped++;
87215
- cursor.updatedAt = deps.now();
87216
- commit(cursor);
87217
87338
  const observed = await readTopVerified(target.chatId);
87218
- if (observed != null && observed.top == null)
87219
- return { status: "drained", popped: 1 };
87220
- return { status: "drained", popped: 1, detail: "topic drained; chat-wide stack may hold other topics" };
87339
+ const topMoved = before != null && observed != null && observed.top !== before.top ? 1 : 0;
87340
+ if (topMoved > 0) {
87341
+ cursor.popped += topMoved;
87342
+ cursor.updatedAt = deps.now();
87343
+ commit(cursor);
87344
+ }
87345
+ if (observed != null && observed.top == null) {
87346
+ return { status: "drained", popped: topMoved, issued: 1 };
87347
+ }
87348
+ return {
87349
+ status: "drained",
87350
+ popped: topMoved,
87351
+ issued: 1,
87352
+ detail: "topic drained; chat-wide stack may hold other topics"
87353
+ };
87221
87354
  };
87222
87355
  const sweepOnce = async (target) => {
87223
87356
  if (!deps.eligible())
87224
- return { status: "skipped-not-eligible", popped: 0 };
87357
+ return { status: "skipped-not-eligible", popped: 0, issued: 0 };
87225
87358
  if (circuitOpen) {
87226
- return { status: "aborted-circuit-breaker", popped: 0, detail: "breaker already open" };
87359
+ return {
87360
+ status: "aborted-circuit-breaker",
87361
+ popped: 0,
87362
+ issued: 0,
87363
+ detail: "breaker already open"
87364
+ };
87227
87365
  }
87228
87366
  const kind = classifyChatForSweep(target);
87229
87367
  const cursor = loadCursor(target, kind);
87230
87368
  if (cursor.done)
87231
- return { status: "already-drained", popped: 0 };
87369
+ return { status: "already-drained", popped: 0, issued: 0 };
87232
87370
  if (cursor.attempts >= SWEEP_MAX_ATTEMPTS) {
87233
- return { status: "forfeited", popped: 0, detail: `${cursor.attempts} attempts exhausted` };
87371
+ return {
87372
+ status: "forfeited",
87373
+ popped: 0,
87374
+ issued: 0,
87375
+ detail: `${cursor.attempts} attempts exhausted`
87376
+ };
87234
87377
  }
87235
87378
  if (kind !== "dm") {
87236
87379
  let allowed = false;
@@ -87244,7 +87387,7 @@ function createStalePinSweeper(deps) {
87244
87387
  cursor.lastStatus = "skipped-no-rights";
87245
87388
  cursor.updatedAt = deps.now();
87246
87389
  commit(cursor);
87247
- return { status: "skipped-no-rights", popped: 0 };
87390
+ return { status: "skipped-no-rights", popped: 0, issued: 0 };
87248
87391
  }
87249
87392
  }
87250
87393
  cursor.attempts++;
@@ -87273,7 +87416,7 @@ function createStalePinSweeper(deps) {
87273
87416
  } : await targetedUnpinDrain(target, kind, cursor, ids);
87274
87417
  }
87275
87418
  } catch (err) {
87276
- result = { status: "error", popped: 0, detail: err.message };
87419
+ result = { status: "error", popped: 0, issued: 0, detail: err.message };
87277
87420
  }
87278
87421
  if (result.status === "drained" || result.status === "skipped-nothing-recorded") {
87279
87422
  cursor.done = true;
@@ -87306,10 +87449,12 @@ function createStalePinSweeper(deps) {
87306
87449
  const running = inFlight.get(key);
87307
87450
  if (running != null)
87308
87451
  return running;
87309
- if (!deps.eligible())
87310
- return Promise.resolve({ status: "skipped-not-eligible", popped: 0 });
87311
- if (attempted.has(key))
87312
- return Promise.resolve({ status: "already-attempted", popped: 0 });
87452
+ if (!deps.eligible()) {
87453
+ return Promise.resolve({ status: "skipped-not-eligible", popped: 0, issued: 0 });
87454
+ }
87455
+ if (attempted.has(key)) {
87456
+ return Promise.resolve({ status: "already-attempted", popped: 0, issued: 0 });
87457
+ }
87313
87458
  attempted.add(key);
87314
87459
  const p = sweepOnce(target).finally(() => inFlight.delete(key));
87315
87460
  inFlight.set(key, p);
@@ -90214,7 +90359,7 @@ function createCapturedResumeDispatcher(ports) {
90214
90359
 
90215
90360
  // gateway/status-pin-store.ts
90216
90361
  var BOOT_UNPIN_MAX_ATTEMPTS = 5;
90217
- var SNAPSHOT_VERSIONS2 = new Set([1, 2, 3, 4]);
90362
+ var SNAPSHOT_VERSION2 = 4;
90218
90363
  function isPinRow2(x) {
90219
90364
  if (x == null || typeof x !== "object")
90220
90365
  return false;
@@ -90239,12 +90384,12 @@ function loadStatusPins2(path3, fs3) {
90239
90384
  if (parsed == null || typeof parsed !== "object")
90240
90385
  return [];
90241
90386
  const env = parsed;
90242
- if (typeof env.v !== "number" || !SNAPSHOT_VERSIONS2.has(env.v) || !Array.isArray(env.pins))
90387
+ if (!Number.isInteger(env.v) || env.v < 1 || !Array.isArray(env.pins))
90243
90388
  return [];
90244
90389
  return env.pins.filter(isPinRow2);
90245
90390
  }
90246
90391
  function persistStatusPins2(path3, fs3, snapshot, log = (l) => process.stderr.write(l)) {
90247
- const env = { v: 4, pins: [...snapshot] };
90392
+ const env = { v: SNAPSHOT_VERSION2, pins: [...snapshot] };
90248
90393
  const tmp = path3 + ".tmp";
90249
90394
  try {
90250
90395
  fs3.writeFileSync(tmp, JSON.stringify(env));
@@ -96545,6 +96690,12 @@ function parseAuditLine(line) {
96545
96690
  entry.m = o.m;
96546
96691
  if (typeof o.prior_pin === "string")
96547
96692
  entry.prior_pin = o.prior_pin;
96693
+ if (Array.isArray(o.prior_pin_observed) && o.prior_pin_observed.every((v) => typeof v === "string")) {
96694
+ entry.prior_pin_observed = o.prior_pin_observed;
96695
+ }
96696
+ if (typeof o.prior_pin_source === "string") {
96697
+ entry.prior_pin_source = o.prior_pin_source;
96698
+ }
96548
96699
  return entry;
96549
96700
  }
96550
96701
  function filterEntries(entries, filters) {
@@ -99169,10 +99320,10 @@ function startOutboxSweep(deps) {
99169
99320
  }
99170
99321
 
99171
99322
  // ../src/build-info.ts
99172
- var VERSION2 = "0.19.32";
99173
- var COMMIT_SHA = "5ccce0a7";
99174
- var COMMIT_DATE = "2026-07-28T23:48:52Z";
99175
- var LATEST_PR = 3962;
99323
+ var VERSION2 = "0.19.34";
99324
+ var COMMIT_SHA = "73a1b218";
99325
+ var COMMIT_DATE = "2026-07-29T05:33:45Z";
99326
+ var LATEST_PR = 3981;
99176
99327
  var COMMITS_AHEAD_OF_TAG = 0;
99177
99328
 
99178
99329
  // gateway/boot-version.ts
@@ -102,12 +102,20 @@ export interface SweepStoreFsSeam {
102
102
  existsSync: (path: string) => boolean
103
103
  }
104
104
 
105
+ /**
106
+ * Envelope. Same bump rule as `status-pin-store.ts` (#3957): every field added
107
+ * after v1 is OPTIONAL, and the reader is version-TOLERANT so a row written by
108
+ * a newer build survives a downgrade rather than being discarded. Discarding is
109
+ * not "do nothing" for an obligation ledger — it forgets an obligation, which
110
+ * is precisely the orphaned-pin failure this module exists to close.
111
+ */
105
112
  interface SweepEnvelope {
106
- v: 1
113
+ v: number
107
114
  cursors: SweepCursor[]
108
115
  }
109
116
 
110
- const SWEEP_ENVELOPE_VERSIONS = new Set([1])
117
+ /** The version THIS build writes. Only the writer is pinned; see above. */
118
+ const SWEEP_ENVELOPE_VERSION = 1
111
119
 
112
120
  /** Stable identity of a sweep target: chat plus topic (topic-less = `-`). */
113
121
  export function sweepTargetKey(chatId: string, threadId?: number): string {
@@ -153,11 +161,9 @@ export function loadSweepCursors(path: string, fs: SweepStoreFsSeam): SweepCurso
153
161
  }
154
162
  if (parsed == null || typeof parsed !== 'object') return []
155
163
  const env = parsed as Record<string, unknown>
156
- if (
157
- typeof env.v !== 'number' ||
158
- !SWEEP_ENVELOPE_VERSIONS.has(env.v) ||
159
- !Array.isArray(env.cursors)
160
- ) {
164
+ // Version-TOLERANT, structurally strict — see {@link SweepEnvelope}. An
165
+ // unknown `v` still yields the cursor rows that validate.
166
+ if (!Number.isInteger(env.v) || (env.v as number) < 1 || !Array.isArray(env.cursors)) {
161
167
  return []
162
168
  }
163
169
  return env.cursors.filter(isCursorRow)
@@ -174,7 +180,7 @@ export function persistSweepCursors(
174
180
  cursors: readonly SweepCursor[],
175
181
  log: (line: string) => void = (l) => process.stderr.write(l),
176
182
  ): void {
177
- const env: SweepEnvelope = { v: 1, cursors: [...cursors] }
183
+ const env: SweepEnvelope = { v: SWEEP_ENVELOPE_VERSION, cursors: [...cursors] }
178
184
  try {
179
185
  fs.writeFileSync(path, JSON.stringify(env))
180
186
  } catch (err) {