switchroom 0.19.26 → 0.19.28

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 (92) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +60 -2
  3. package/dist/auth-broker/index.js +244 -13
  4. package/dist/cli/autoaccept-poll.js +225 -17
  5. package/dist/cli/notion-write-pretool.mjs +60 -2
  6. package/dist/cli/switchroom.js +2843 -1220
  7. package/dist/host-control/main.js +245 -14
  8. package/dist/vault/approvals/kernel-server.js +242 -13
  9. package/dist/vault/broker/server.js +242 -13
  10. package/package.json +7 -2
  11. package/profiles/_base/cron-session.sh.hbs +8 -0
  12. package/profiles/_base/start.sh.hbs +175 -15
  13. package/telegram-plugin/card-layout.ts +328 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +94 -1
  15. package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
  16. package/telegram-plugin/dist/server.js +97 -1
  17. package/telegram-plugin/edit-flood-fuse.ts +841 -57
  18. package/telegram-plugin/flood-429-ledger.ts +526 -0
  19. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  20. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  21. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  22. package/telegram-plugin/gateway/gateway.ts +67 -70
  23. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  24. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  25. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  26. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  27. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  28. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  29. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  30. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  31. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  32. package/telegram-plugin/llm-error-present.ts +61 -2
  33. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  34. package/telegram-plugin/model-unavailable.ts +8 -0
  35. package/telegram-plugin/operator-events.ts +110 -5
  36. package/telegram-plugin/outbound-class.ts +81 -0
  37. package/telegram-plugin/provider-credit.ts +237 -0
  38. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  39. package/telegram-plugin/send-gate.ts +24 -2
  40. package/telegram-plugin/status-no-truncate.ts +10 -48
  41. package/telegram-plugin/status-pin-driver.ts +33 -45
  42. package/telegram-plugin/status-pin.ts +18 -1
  43. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  44. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  45. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  46. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  47. package/telegram-plugin/tests/card-variants.ts +366 -0
  48. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
  49. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  50. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  51. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  52. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  53. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  54. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  55. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  56. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  57. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  58. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  59. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  60. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  61. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  62. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  63. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  64. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  65. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  66. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  67. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  68. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  69. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  70. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  71. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  74. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  75. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  76. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  77. package/telegram-plugin/tool-activity-summary.ts +239 -365
  78. package/telegram-plugin/uat/assertions.ts +22 -11
  79. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  80. package/telegram-plugin/worker-activity-feed.ts +105 -47
  81. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  82. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  83. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  84. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  85. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  86. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  90. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  91. package/vendor/hindsight-memory/settings.json +1 -1
  92. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -475,11 +475,25 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
475
475
  # mechanism — it runs because the container is up, needs no LLM turn of
476
476
  # its own, and cannot be prompted away.
477
477
  #
478
- # SERIALISED. `flock -n` on a per-agent lockfile makes overlapping runs
479
- # impossible: a run that overshoots the interval causes the next tick to
480
- # SKIP, never to stack. The lock path is deliberately the one the
481
- # out-of-band recovery tooling also takes, so a manual sweep and this
482
- # loop mutually exclude instead of double-processing an entry.
478
+ # SERIALISED, BUT NOT BY THIS FILE. `drain_pending.py` takes an
479
+ # exclusive `fcntl.flock` on `$HOME/.hindsight/drain-pending.lock` for
480
+ # the whole of `drain()` and does nothing at all if another drain holds
481
+ # it. That is where the guarantee has to live: the queue has three
482
+ # independent drain paths — the SessionStart hook (which imports
483
+ # `drain` in-process, and boots while this loop may be mid-run), this
484
+ # sidecar, and the `docker exec … drain_pending.py --backlog` that
485
+ # `switchroom doctor` documents for operators — and a lock wrapped
486
+ # around only ONE of them serialises nothing. Two drains on one queue
487
+ # means the same entry POSTed twice, i.e. ~168s of a 4-slot fleet-wide
488
+ # lane spent twice on one memory.
489
+ #
490
+ # DO NOT WRAP THE CALL BELOW IN `flock`. It would take the very lock
491
+ # the python then asks for, and the drain would skip every tick,
492
+ # silently, forever. (This is also why the lock file is NOT the
493
+ # `drain.lock` the interim host cron wraps its `docker exec` in — with
494
+ # distinct names that wrapper stays merely redundant instead of
495
+ # starving the drain it launched.) `tests/hindsight-drain-sidecar.test.ts`
496
+ # fails if a `flock` reappears here.
483
497
  #
484
498
  # LANE-RESPECTING. Retain extraction is served by a small fixed pool of
485
499
  # LLM lanes (4 on this fleet, 2 boxes x 2 slots) SHARED with live
@@ -516,6 +530,22 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
516
530
  # already queued ABOVE the current bound are recovered by re-splitting
517
531
  # them, not by waiting longer. An operator who still wants a longer
518
532
  # deadline moves HINDSIGHT_RETAIN_CLIENT_DEADLINE_S, which moves both.
533
+ # THE ROWS OUTLIVE THE STOPGAP THAT PRODUCED THEM; the serialisation in
534
+ # it does not. That stopgap is the interim host cron named above, whose
535
+ # outer `flock` on `drain.lock` serialised only that cron against
536
+ # itself, while the SessionStart hook's in-process drain and a bare
537
+ # `docker exec` walked straight past it — the widening to all three is
538
+ # the whole point of moving the lock inside `drain_pending.py`. What
539
+ # the rows measure is one drain completing (or not) inside a deadline,
540
+ # which is a property of the drain and not of who serialised it, so
541
+ # they still stand. They are not a licence to reinstate the wrapper.
542
+ #
543
+ # UNATTENDED, SO BOUNDED PER ENTRY. `drain_pending` parks an entry past
544
+ # its attempt ceiling (default 20) instead of retrying it forever —
545
+ # without that, one permanently-failing entry on a 3600s backlog budget
546
+ # would burn most of this agent's wall-clock, and a share of the 4
547
+ # lanes, indefinitely and with nobody watching. Parked entries stay
548
+ # queued and are still reconciled for free; `--force` replays them.
519
549
  #
520
550
  # Failure is never terminal: the drain exits non-zero only when it
521
551
  # promoted entries to `.dead`, and the loop below ignores the status
@@ -524,13 +554,24 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
524
554
  # (those are exported later, in the inner pass) — only $HOME, to find
525
555
  # the queue.
526
556
  #
557
+ # DOCKER RUNTIME ONLY. This whole block is inside the
558
+ # `[ "$SWITCHROOM_RUNTIME" = "docker" ]` branch that ends at the `exec
559
+ # tmux` below, so an agent on the legacy systemd path gets NO drain
560
+ # sidecar and keeps the boot-only SessionStart behaviour. The fleet is
561
+ # entirely docker; if systemd ever comes back, this needs an equivalent
562
+ # timer, and until then the honest statement is that it is unsupported
563
+ # there rather than silently absent.
564
+ #
527
565
  # Kill switch: SWITCHROOM_HINDSIGHT_DRAIN=0 at the container level.
528
- # Interval: SWITCHROOM_HINDSIGHT_DRAIN_INTERVAL_S (default 900s).
566
+ # Interval: SWITCHROOM_HINDSIGHT_DRAIN_INTERVAL_S (default 900s). That
567
+ # is a COOLDOWN BETWEEN RUNS, not a fixed period — the loop is strictly
568
+ # sequential, so the true tick-to-tick spacing is one drain's duration
569
+ # (up to the 3600s backlog budget) plus the interval. Deliberate: a
570
+ # fixed period would give a long, lane-heavy drain no cooldown at all.
529
571
  _hs_drain_script="{{agentDir}}/.claude/plugins/hindsight-memory/scripts/drain_pending.py"
530
- if [ "$SWITCHROOM_HINDSIGHT_DRAIN" != "0" ] \
572
+ if [ "${SWITCHROOM_HINDSIGHT_DRAIN:-1}" != "0" ] \
531
573
  && [ -f "$_hs_drain_script" ] \
532
- && command -v python3 >/dev/null 2>&1 \
533
- && command -v flock >/dev/null 2>&1; then
574
+ && command -v python3 >/dev/null 2>&1; then
534
575
  _switchroom_hindsight_drain_loop() {
535
576
  local _script="$1"
536
577
  local _interval="${SWITCHROOM_HINDSIGHT_DRAIN_INTERVAL_S:-900}"
@@ -538,24 +579,38 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
538
579
  # against the LLM lane. 60s is the floor.
539
580
  case "$_interval" in ''|*[!0-9]*) _interval=900 ;; esac
540
581
  [ "$_interval" -lt 60 ] && _interval=60
541
- local _lock="$HOME/.hindsight/drain.lock"
542
- mkdir -p "$HOME/.hindsight" 2>/dev/null || true
543
582
  # Deterministic per-agent phase offset (see the note above).
544
583
  local _offset=0
545
584
  if command -v cksum >/dev/null 2>&1; then
546
585
  _offset=$(( $(printf '%s' "{{name}}" | cksum | cut -d' ' -f1) % _interval ))
547
586
  fi
548
- echo "[hindsight-drain] starting: interval=${_interval}s offset=${_offset}s lock=${_lock}"
587
+ echo "[hindsight-drain] starting: interval=${_interval}s (cooldown between runs, not a fixed period) offset=${_offset}s serialised-by=drain_pending.py"
549
588
  sleep "$_offset"
550
589
  while true; do
551
- # -n: if a drain (ours or an operator sweep) already holds the
552
- # lock, skip this tick entirely instead of queueing behind it.
553
- flock -n "$_lock" python3 "$_script" --backlog || true
590
+ # No `flock` here, by design — drain_pending.py locks itself, and a
591
+ # wrapper here would hold the lock it needs. See the note above.
592
+ python3 "$_script" --backlog || true
554
593
  sleep "$_interval"
555
594
  done
556
595
  }
557
596
  _switchroom_supervise hindsight-drain /var/log/switchroom/hindsight-drain.log \
558
597
  _switchroom_hindsight_drain_loop "$_hs_drain_script" &
598
+ else
599
+ # NEVER FAIL SILENTLY. Without this branch a failed precondition left no
600
+ # log line and no logfile at all, so "is the drain running?" was
601
+ # unanswerable from the logs — indistinguishable from a drain that was
602
+ # running and finding nothing to do.
603
+ if [ "${SWITCHROOM_HINDSIGHT_DRAIN:-1}" = "0" ]; then
604
+ _hs_drain_why="disabled by SWITCHROOM_HINDSIGHT_DRAIN=0"
605
+ elif [ ! -f "$_hs_drain_script" ]; then
606
+ _hs_drain_why="no drain script at $_hs_drain_script (hindsight plugin not installed, or an interrupted \`switchroom apply\`)"
607
+ else
608
+ _hs_drain_why="python3 is not on PATH in this image"
609
+ fi
610
+ _hs_drain_msg="[hindsight-drain] NOT STARTED: ${_hs_drain_why}. The memory queue will only be drained at session boot, which cannot clear a backlog."
611
+ echo "$_hs_drain_msg"
612
+ mkdir -p /var/log/switchroom 2>/dev/null || true
613
+ echo "$_hs_drain_msg" >> /var/log/switchroom/hindsight-drain.log 2>/dev/null || true
559
614
  fi
560
615
  {{/if}}
561
616
 
@@ -594,6 +649,30 @@ if [ ! -e "$HOME/.switchroom-config" ] || [ -L "$HOME/.switchroom-config" ]; the
594
649
  fi
595
650
  {{/if}}
596
651
 
652
+ # --- Commit attribution: global git hooks path ---------------------------
653
+ #
654
+ # Every commit an agent makes is AUTHORED by the operator's GitHub account
655
+ # (deliberate, and staying that way), which leaves `git log` unable to say
656
+ # WHICH agent and WHICH model produced a change. The image ships a
657
+ # `prepare-commit-msg` hook that stamps `Switchroom-Agent:` /
658
+ # `Switchroom-Model:` trailers from $SWITCHROOM_AGENT_NAME and the launched
659
+ # model — see bin/git-agent-attribution-hook.sh for the full rationale
660
+ # (including why prepare-commit-msg, which `--no-verify` does NOT bypass).
661
+ #
662
+ # Set GLOBALLY, not per-repo: an agent clones repos on demand and a per-repo
663
+ # install would mean every new clone starts out unattributed until someone
664
+ # remembered to run a setup step. Repo-local `core.hooksPath` (husky et al)
665
+ # still wins, and the hook chains to any displaced `.git/hooks` copy.
666
+ #
667
+ # Idempotent, and non-fatal: attribution must never be the reason a boot
668
+ # fails. `git config --global` writes $HOME/.gitconfig, which lives on the
669
+ # /state/agent bind mount and so persists — re-asserting it each boot is what
670
+ # repairs an agent whose config was hand-edited.
671
+ if command -v git >/dev/null 2>&1 && [ -x /opt/switchroom/git-hooks/prepare-commit-msg ]; then
672
+ git config --global core.hooksPath /opt/switchroom/git-hooks 2>/dev/null \
673
+ || echo "attribution: WARNING — could not set core.hooksPath; commits from this agent will not carry Switchroom-Agent/Switchroom-Model trailers and will fail the repo's attribution lint" >&2
674
+ fi
675
+
597
676
  export NVM_DIR="$HOME/.nvm"
598
677
  [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
599
678
  export PATH="$HOME/.bun/bin:$PATH"
@@ -825,6 +904,78 @@ export HINDSIGHT_RECALL_REQUEST_TIMEOUT_SECONDS={{hindsightRecallRequestTimeoutS
825
904
  # Observe `injected_own_bank_count` via `switchroom memory recall-log <agent>`.
826
905
  export HINDSIGHT_RECALL_OWN_BANK_MIN_SLOTS={{hindsightRecallOwnBankMinSlots}}
827
906
  export HINDSIGHT_RECALL_ADDITIONAL_BANK_MIN_SLOTS={{hindsightRecallAdditionalBankMinSlots}}
907
+ # Absolute relevance floor on an injected memory's engine score (#3837).
908
+ # 0 = OFF, and 0 is what switchroom ships: at 0 recall.py drops nothing and the
909
+ # injected set is exactly what it was before #3837.
910
+ # It exists for the measured failure where the agent's own bank times out and
911
+ # recall injects side-bank residue under the "relevant memories" banner —
912
+ # 98.4% of degraded turns carry a best injected score below 0.01 against 28.4%
913
+ # of healthy ones, and the agent cannot tell noise from recall. SCOPE decides
914
+ # which turns a non-zero floor binds on: `degraded` (default) only when the own
915
+ # bank timed out / was unreachable, `all` on every turn. `all` is NOT a
916
+ # recommended fleet default — an unconditional 0.01 floor empties ~28% of
917
+ # HEALTHY recalls (#3761), because `scores.final` is not calibrated across
918
+ # queries. EXPORTED UNCONDITIONALLY for the #3774 reason above: a stale
919
+ # hand-edited ~/.hindsight/claude-code.json must not be able to switch a recall
920
+ # filter ON that switchroom ships OFF.
921
+ # Observe `dropped_below_min_score` via `switchroom memory recall-log <agent>`.
922
+ export HINDSIGHT_RECALL_MIN_SCORE={{hindsightRecallMinScore}}
923
+ export HINDSIGHT_RECALL_MIN_SCORE_SCOPE={{hindsightRecallMinScoreScope}}
924
+ # ── The remaining recall knobs (#3841) ───────────────────────────────────────
925
+ # Everything else `recall.py` reads out of the plugin config, resolved from
926
+ # `memory.recall.*` in switchroom.yaml and exported UNCONDITIONALLY for the
927
+ # #3774 reason spelled out above: `~/.hindsight/claude-code.json` loads AFTER
928
+ # the plugin's settings.json and survives an apply, so a knob switchroom does
929
+ # not export is a knob a stale hand-edit owns. Every default below is the value
930
+ # the fleet already ran before this block existed — an operator who sets none of
931
+ # these gets byte-identical recall — and each is pinned against the vendored
932
+ # plugin by tests/scaffold.recall-passthrough.test.ts so the two cannot drift.
933
+ # Structural values are single-quoted JSON; `_cast_env` parses them and, unlike
934
+ # an empty export, actually ASSIGNS (an empty one is skipped and hands authority
935
+ # straight back to claude-code.json).
936
+ #
937
+ # How hard Hindsight searches: low (vector only, ~1-2s) | mid (+LLM rerank,
938
+ # ~5s measured) | high. Switchroom ships `low` — mid was the second-largest
939
+ # contributor to perceived dead air after model TTFT.
940
+ export HINDSIGHT_RECALL_BUDGET={{hindsightRecallPass.budget}}
941
+ # Token budget for the injected block. The count cap (…MAX_MEMORIES) is the
942
+ # other bound and the tighter of the two wins.
943
+ export HINDSIGHT_RECALL_MAX_TOKENS={{hindsightRecallPass.maxTokens}}
944
+ # Bias toward the synthesized `observation` tier, backfilling slots freed by
945
+ # superseded raw facts. On by default (memory.recall.prefer_observations).
946
+ export HINDSIGHT_RECALL_PREFER_OBSERVATIONS={{hindsightRecallPass.preferObservations}}
947
+ # Recent human turns composed into the query, and the transcript roles they may
948
+ # be drawn from. 2 turns so a bare follow-up ("and the port?") embeds with its
949
+ # antecedent instead of recalling on the pronoun alone.
950
+ export HINDSIGHT_RECALL_CONTEXT_TURNS={{hindsightRecallPass.contextTurns}}
951
+ export HINDSIGHT_RECALL_ROLES={{{hindsightRecallPass.rolesQ}}}
952
+ # Character bound on the composed query, applied BEFORE …QUERY_MAX_TOKENS
953
+ # shapes it; truncation keeps the latest turn and drops oldest context first.
954
+ export HINDSIGHT_RECALL_MAX_QUERY_CHARS={{hindsightRecallPass.maxQueryChars}}
955
+ # Tail bytes read from the session transcript for that composition — the read
956
+ # stays O(1) on a session log that can grow to many MB. 0 = whole file.
957
+ export HINDSIGHT_RECALL_TRANSCRIPT_TAIL_BYTES={{hindsightRecallPass.transcriptTailBytes}}
958
+ # The banner above injected memories. The agent reads this line as the
959
+ # instruction for how to treat the block, so it is behaviour, not cosmetics.
960
+ export HINDSIGHT_RECALL_PROMPT_PREAMBLE={{{hindsightRecallPass.promptPreambleQ}}}
961
+ # Tag filters (upstream 962140eef port). Dormant by default and exported at
962
+ # their no-op values — `[]` / `{}` fold to None in recall.py, so nothing is
963
+ # filtered unless an operator sets memory.recall.tags / .tag_groups.
964
+ export HINDSIGHT_RECALL_TAGS={{{hindsightRecallPass.tagsQ}}}
965
+ export HINDSIGHT_RECALL_TAGS_MATCH={{hindsightRecallPass.tagsMatch}}
966
+ export HINDSIGHT_RECALL_TAG_GROUPS={{{hindsightRecallPass.tagGroupsQ}}}
967
+ export HINDSIGHT_RECALL_ADDITIONAL_BANK_FILTERS={{{hindsightRecallPass.additionalBankFiltersQ}}}
968
+ # Per-tag score multipliers — a DOWN-RANK, never a drop. Carries switchroom's
969
+ # `sidechain: 0.8` seed (delegated sub-agent memories rank just under
970
+ # first-party ones), merged with memory.recall.tag_weights.
971
+ export HINDSIGHT_RECALL_TAG_WEIGHTS={{{hindsightRecallPass.tagWeightsQ}}}
972
+ # Bounded transcript-grep fallback: only when EVERY bank returned zero AND no
973
+ # bank hit its deadline. Covers the window between an abrupt kill and the next
974
+ # boot reconciliation, where the fact layer never heard about the lost turns.
975
+ export HINDSIGHT_RECALL_TRANSCRIPT_FALLBACK={{hindsightRecallPass.transcriptFallback}}
976
+ # Parallel multi-bank fan-out: total latency is the SLOWEST slot, not the SUM.
977
+ # false is the serial rollback lever.
978
+ export HINDSIGHT_RECALL_PARALLEL={{hindsightRecallPass.parallel}}
828
979
  # Recall fact types (memory.recall.types cascade). Switchroom default is
829
980
  # world,experience,observation (the synthesized `observation` tier is ON
830
981
  # by default, set in the plugin settings.json). Export only when the
@@ -1893,6 +2044,15 @@ esac
1893
2044
  # welcome card honest. Overwrite (not consumed) — the gateway reads it at boot.
1894
2045
  printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.active-session-model" 2>/dev/null || true
1895
2046
 
2047
+ # Commit-attribution carrier for the model half of the trailer pair. Exported
2048
+ # here, from the SAME $_EFFECTIVE_MODEL the session is about to launch on, so a
2049
+ # `/model` override is reflected in the trailer rather than the configured
2050
+ # default. `exec claude` below inherits it, and so does every shell claude
2051
+ # spawns — which is where `git commit` actually runs.
2052
+ # Consumer: bin/git-agent-attribution-hook.sh (falls back to the static
2053
+ # SWITCHROOM_AGENT_MODEL that compose.ts writes, then to "unknown").
2054
+ export SWITCHROOM_SESSION_MODEL="$_EFFECTIVE_MODEL"
2055
+
1896
2056
  # --- Routing-mode observability (.routing-mode, 2026-07-17 boot-race incident) ---
1897
2057
  #
1898
2058
  # Deterministic record of WHERE this boot actually landed, written immediately
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Card layout core — the ONE place a status/progress card is composed.
3
+ *
4
+ * Every card the gateway paints (🤖 agent, 🛠 single worker, 🛠 WORKERS
5
+ * combined, and their overflow/terminal variants) is expressed as a
6
+ * {@link CardSpec}: fixed `chrome` lines, N `sections` (each an optional
7
+ * header plus a windowed `✓`/`→` step trail), and fixed `footer` lines. The
8
+ * renderers in `tool-activity-summary.ts` / `worker-activity-feed.ts` are
9
+ * CONFIGURATIONS of this module, not parallel implementations.
10
+ *
11
+ * Two things used to exist twice and agreed only by hand (#3844):
12
+ *
13
+ * 1. **Header composition.** The combined card built its own row header
14
+ * instead of going through `renderActivityHeader`, so the metrics run
15
+ * (`{elapsed} · {n} tools · {t} tok · {model}`) was written out in two
16
+ * places. It is now composed exactly once, by {@link metricsRun}, which
17
+ * the agent header, the worker header, the combined row header and the
18
+ * combined glance line all read.
19
+ * 2. **Char-budget enforcement.** The combined card carried its own
20
+ * row-shrinking loop instead of the status card's `fitCardToBudget`.
21
+ * There is now exactly one budget enforcer — {@link fitCardToBudget} —
22
+ * a generic "render at progressively deeper shrink levels, take the
23
+ * first that fits" loop. Each card supplies what a shrink level MEANS
24
+ * (drop the oldest bullet / drop the newest row); nothing else compares
25
+ * a rendered card against `STATUS_CARD_CHAR_BUDGET`.
26
+ *
27
+ * Nothing in here knows about workers, turns, or Telegram transport — it is
28
+ * pure string composition, so a card variant can be rendered (and asserted)
29
+ * without any of the feed machinery.
30
+ */
31
+
32
+ import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
33
+ import { formatModelLabel } from './model-label.js'
34
+ import { STATUS_CARD_CHAR_BUDGET, STATUS_LINE_MAX, STATUS_ROLLING_LINES } from './status-no-truncate.js'
35
+
36
+ // ─── Metrics composition (the single header vocabulary) ─────────────────────
37
+
38
+ /** Format elapsed milliseconds for display in the activity header (e.g. "12s", "2m05s"). */
39
+ export function formatFeedElapsed(ms: number): string {
40
+ const s = Math.floor(ms / 1000)
41
+ if (s < 60) return `${s}s`
42
+ const m = Math.floor(s / 60)
43
+ return `${m}m${(s % 60).toString().padStart(2, '0')}s`
44
+ }
45
+
46
+ /**
47
+ * Compact token-count formatter for the activity card's metrics line:
48
+ * <1000 → raw ("940")
49
+ * ≥1000, <1e6 → one-decimal k ("12.4k", "1.0k")
50
+ * ≥1e6 → one-decimal M ("1.2M")
51
+ * Negative / non-finite inputs clamp to "0". The caller appends " tok".
52
+ */
53
+ export function formatTokenCount(n: number): string {
54
+ if (!Number.isFinite(n) || n <= 0) return '0'
55
+ if (n < 1000) return String(Math.floor(n))
56
+ if (n < 1_000_000) {
57
+ // Round to the displayed 1-decimal k FIRST: inputs in [999_950, 999_999]
58
+ // round to "1000.0k", which must promote into the M branch rather than
59
+ // render a nonsense "1000.0k". Fall through when the rounded k reaches 1000.
60
+ const k = Number((n / 1000).toFixed(1))
61
+ if (k < 1000) return `${k.toFixed(1)}k`
62
+ }
63
+ return `${(n / 1_000_000).toFixed(1)}M`
64
+ }
65
+
66
+ /**
67
+ * The ` · {N} tok` metrics segment, or '' when there are no tokens to show.
68
+ * A 0 / undefined total (a worker that emitted no usage — e.g. a non-Claude
69
+ * transcript) OMITS the segment entirely so the line stays clean; every card
70
+ * that shows tokens reads this one predicate so they never diverge.
71
+ */
72
+ export function tokenSegment(totalTokens: number | undefined): string {
73
+ if (totalTokens == null || totalTokens <= 0) return ''
74
+ return ` · ${formatTokenCount(totalTokens)} tok`
75
+ }
76
+
77
+ /** `tool` / `tools`, pluralised once for every surface that prints a tool count. */
78
+ export function toolWord(n: number): string {
79
+ return n === 1 ? 'tool' : 'tools'
80
+ }
81
+
82
+ /** Terminal/running state a card (or a row on it) can report. */
83
+ export type CardState = 'running' | 'done' | 'failed' | 'incomplete'
84
+
85
+ /**
86
+ * The metrics RUN — the dot-separated `{elapsed} · {n} tools · {t} tok · {model}`
87
+ * text that every card header carries, WITHOUT the italic wrapper (callers own
88
+ * the wrapper because they place the leading separator differently).
89
+ *
90
+ * This is the single composition point the audit (#3844) found forked: the
91
+ * combined card's row header spelled the same run out by hand, so a change to
92
+ * (say) token placement had to be made twice and agreed only by inspection.
93
+ *
94
+ * `running` puts elapsed FIRST (it is the live fact); a terminal state leads
95
+ * with the state word and moves elapsed to the tail, so a failed or reaped
96
+ * worker never reads as done.
97
+ */
98
+ export function metricsRun(
99
+ elapsedMs: number,
100
+ toolCount: number,
101
+ state: CardState,
102
+ totalTokens?: number,
103
+ model?: string,
104
+ ): string {
105
+ const elapsed = formatFeedElapsed(elapsedMs)
106
+ const tokPart = tokenSegment(totalTokens)
107
+ const modelLabel = formatModelLabel(model)
108
+ const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
109
+ return state === 'running'
110
+ ? `${elapsed} · ${toolCount} ${toolWord(toolCount)}${tokPart}${modelPart}`
111
+ : `${state} · ${toolCount} ${toolWord(toolCount)}${tokPart} · ${elapsed}${modelPart}`
112
+ }
113
+
114
+ /**
115
+ * Line 1 of a card: `<emoji> <b>LABEL</b> · <i>description</i>` (description
116
+ * optional). Shared by the agent card, the worker card and the static
117
+ * superseded notice, so the card's type chrome is composed in one place.
118
+ */
119
+ export function renderCardTitleLine(emoji: string, label: string, description: string): string {
120
+ const descPart = description.length > 0 ? ` · _${escapeMarkdown(description)}_` : ''
121
+ return `${emoji} **${escapeMarkdown(label)}**${descPart}`
122
+ }
123
+
124
+ /**
125
+ * Render a two-line header for an activity card.
126
+ *
127
+ * Line 1: `<emoji> <b>Label</b> · <i>description</i>` (description optional)
128
+ * Line 2: the metrics run, italicised.
129
+ *
130
+ * Returns a two-element array of ready Telegram markdown lines (no trailing
131
+ * newline).
132
+ */
133
+ export function renderActivityHeader(
134
+ emoji: string,
135
+ label: string,
136
+ description: string,
137
+ elapsedMs: number,
138
+ toolCount: number,
139
+ state: CardState,
140
+ model?: string,
141
+ totalTokens?: number,
142
+ ): [string, string] {
143
+ return [
144
+ renderCardTitleLine(emoji, label, description),
145
+ `_${metricsRun(elapsedMs, toolCount, state, totalTokens, model)}_`,
146
+ ]
147
+ }
148
+
149
+ // ─── Per-line truncation pipeline ───────────────────────────────────────────
150
+ //
151
+ // Per RAW line, in this EXACT order:
152
+ // 1. stripMarkdown(raw)
153
+ // 2. .replace(/\s+/g, ' ').trim()
154
+ // 3. truncate(_, STATUS_LINE_MAX)
155
+ // 4. escapeMarkdown(_) ← escape is ALWAYS the last per-line op.
156
+ // Escaping last is load-bearing: clipping an already-escaped string can split
157
+ // a markdown escape (\* → \), which renders wrong.
158
+
159
+ /** Clean + clip + escape a single raw step line. Returns ready-to-wrap markdown. */
160
+ export function escapeStepLine(raw: string): string {
161
+ const cleaned = stripMarkdown(raw).replace(/\s+/g, ' ').trim()
162
+ return escapeMarkdown(truncate(cleaned, STATUS_LINE_MAX))
163
+ }
164
+
165
+ /** The cleaned (unescaped) form of a raw line — the input to `truncate`. */
166
+ export function cleanStepLine(raw: string): string {
167
+ return stripMarkdown(raw).replace(/\s+/g, ' ').trim()
168
+ }
169
+
170
+ // ─── Card spec ──────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * How a FINISHED step bullet is drawn.
174
+ * 'struck' — `~~_✓ step_~~`, the normal card body.
175
+ * 'plain' — `_step_`, used by the char-budget shrink levels where the
176
+ * strike/✓ chrome is spent bytes on a card already over the wire
177
+ * limit.
178
+ */
179
+ export type DoneStyle = 'struck' | 'plain'
180
+
181
+ /** One section of a card: an optional header plus a windowed step trail. */
182
+ export interface CardSection {
183
+ /** Ready lines emitted above this section's steps (the combined row header). */
184
+ header?: string[]
185
+ /** ALREADY-ESCAPED step lines, oldest → newest. */
186
+ steps: string[]
187
+ /** Trailing steps shown; the remainder collapses to a `+N earlier…` marker. */
188
+ window: number
189
+ /** When true every step renders done — no live `→` bullet. */
190
+ allDone?: boolean
191
+ /** Heartbeat suffix rendered INSIDE the newest in-progress bullet. */
192
+ liveSuffix?: string
193
+ /**
194
+ * Literal prefix on EVERY line this section emits (marker, bullets,
195
+ * placeholder), OUTSIDE the markdown spans so it never lands inside an
196
+ * emphasis run.
197
+ */
198
+ indent?: string
199
+ /**
200
+ * Token before a done step and inside the overflow marker. `'✓ '` on a normal
201
+ * body; `''` for the `↳` nested-child block, whose `↳` glyph already marks it.
202
+ */
203
+ doneMark?: string
204
+ /** See {@link DoneStyle}. Default 'struck'. */
205
+ doneStyle?: DoneStyle
206
+ /** Emitted (with `indent`) when this section has no steps at all. */
207
+ placeholder?: string
208
+ }
209
+
210
+ /** A whole card, as fixed chrome + sections + fixed footer. */
211
+ export interface CardSpec {
212
+ /** Lines above every section — the card header, or the combined glance line. */
213
+ chrome?: string[]
214
+ sections: CardSection[]
215
+ /** Lines below every section — `✓ N steps`, the result block, the spill line. */
216
+ footer?: string[]
217
+ }
218
+
219
+ /**
220
+ * Emit one section's lines into `out`. This is the single `✓`/`→` bullet
221
+ * emitter: every step line on every card comes through here.
222
+ */
223
+ export function emitSection(out: string[], section: CardSection): void {
224
+ const indent = section.indent ?? ''
225
+ const doneMark = section.doneMark ?? '✓ '
226
+ const doneStyle = section.doneStyle ?? 'struck'
227
+ const allDone = section.allDone === true
228
+ const liveSuffix = section.liveSuffix ?? ''
229
+ if (section.header != null) out.push(...section.header)
230
+ if (section.steps.length === 0) {
231
+ if (section.placeholder != null) out.push(`${indent}${section.placeholder}`)
232
+ return
233
+ }
234
+ const shown = section.steps.slice(-Math.max(1, section.window))
235
+ const hidden = section.steps.length - shown.length
236
+ if (hidden > 0) out.push(`${indent}_${doneMark}+${hidden} earlier…_`)
237
+ const lastIdx = shown.length - 1
238
+ shown.forEach((s, i) => {
239
+ if (!allDone && i === lastIdx) {
240
+ out.push(`${indent}**→ ${s}${liveSuffix}**`)
241
+ return
242
+ }
243
+ out.push(doneStyle === 'plain' ? `${indent}_${s}_` : `${indent}~~_${doneMark}${s}_~~`)
244
+ })
245
+ }
246
+
247
+ /**
248
+ * Shared step-feed emitter (back-compat signature). Appends `✓`/`→` bullet
249
+ * lines to `out` for the given ALREADY-ESCAPED step strings. Thin wrapper over
250
+ * {@link emitSection}.
251
+ */
252
+ export function renderStepFeed(
253
+ out: string[],
254
+ steps: string[],
255
+ allDone: boolean,
256
+ liveSuffix = '',
257
+ window: number = STATUS_ROLLING_LINES,
258
+ indent = '',
259
+ ): void {
260
+ emitSection(out, { steps, window, allDone, liveSuffix, indent })
261
+ }
262
+
263
+ /** Flatten a spec to its ordered card lines. */
264
+ export function cardSpecLines(spec: CardSpec): string[] {
265
+ const out: string[] = [...(spec.chrome ?? [])]
266
+ for (const section of spec.sections) emitSection(out, section)
267
+ out.push(...(spec.footer ?? []))
268
+ return out
269
+ }
270
+
271
+ /**
272
+ * Join card lines with GFM hard breaks so the styled prose lines don't collapse
273
+ * onto one visual line in the rich-message renderer — see `stackCardLines`.
274
+ *
275
+ * `collapseSafe` is unconditional here: every card this module renders is a
276
+ * PINNED surface, and Telegram's pinned bar shows a pinned message collapsed to
277
+ * one line with the newlines dropped and nothing substituted (#3666).
278
+ */
279
+ export function stackCard(lines: string[]): string {
280
+ return stackCardLines(lines, { collapseSafe: true })
281
+ }
282
+
283
+ /** Render a spec straight to wire text, with no budget enforcement. */
284
+ export function renderCardSpec(spec: CardSpec): string {
285
+ return stackCard(cardSpecLines(spec))
286
+ }
287
+
288
+ // ─── The one char-budget enforcer ───────────────────────────────────────────
289
+
290
+ /** One shrink level's rendered candidate. */
291
+ export interface CardCandidate {
292
+ spec: CardSpec
293
+ /**
294
+ * An additional card-specific ceiling this candidate violates (the combined
295
+ * card's total body-line budget). A candidate that fits the char budget but
296
+ * sets this is still rejected, so both ceilings are enforced by one loop.
297
+ */
298
+ overflow?: boolean
299
+ }
300
+
301
+ /**
302
+ * THE char-budget backstop — the only place a rendered card is measured against
303
+ * `STATUS_CARD_CHAR_BUDGET`.
304
+ *
305
+ * `build(level)` renders the card at progressively deeper shrink levels
306
+ * (level 0 = the full card). The first level whose rendered text fits the
307
+ * budget and clears its own `overflow` flag wins; if even `maxLevel` does not
308
+ * fit, that deepest render is returned — a card is always emitted, never
309
+ * dropped.
310
+ *
311
+ * What a level MEANS is the caller's business and is genuinely card-specific:
312
+ * the status card drops its oldest bullet (and, at its deepest level, truncates
313
+ * an oversized newest bullet), while the combined card drops its newest worker
314
+ * row into `+M more working…`. The MECHANISM — render, measure, accept or go
315
+ * deeper — exists only here.
316
+ */
317
+ export function fitCardToBudget(
318
+ build: (level: number) => CardCandidate,
319
+ maxLevel: number,
320
+ ): string {
321
+ let text = ''
322
+ for (let level = 0; level <= maxLevel; level++) {
323
+ const candidate = build(level)
324
+ text = renderCardSpec(candidate.spec)
325
+ if (candidate.overflow !== true && text.length <= STATUS_CARD_CHAR_BUDGET) return text
326
+ }
327
+ return text
328
+ }