switchroom 0.19.17 → 0.19.19

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 (91) hide show
  1. package/bin/run-hook.sh +148 -0
  2. package/bin/workspace-dynamic-hook.sh +147 -38
  3. package/dist/agent-scheduler/index.js +13 -4
  4. package/dist/auth-broker/index.js +32 -5
  5. package/dist/cli/drive-write-pretool.mjs +48 -5
  6. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  7. package/dist/cli/notion-write-pretool.mjs +13 -4
  8. package/dist/cli/switchroom.js +10614 -8104
  9. package/dist/host-control/main.js +12849 -11446
  10. package/dist/vault/approvals/kernel-server.js +90 -12
  11. package/dist/vault/broker/server.js +277 -94
  12. package/package.json +5 -3
  13. package/profiles/_base/start.sh.hbs +69 -5
  14. package/profiles/coding/CLAUDE.md.hbs +1 -1
  15. package/profiles/default/CLAUDE.md.hbs +3 -3
  16. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  17. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  18. package/skills/mental-model-curator/SKILL.md +8 -6
  19. package/telegram-plugin/bridge/bridge.ts +25 -19
  20. package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +28 -20
  22. package/telegram-plugin/dist/gateway/gateway.js +2077 -1087
  23. package/telegram-plugin/dist/server.js +32 -20
  24. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  25. package/telegram-plugin/gateway/boot-card.ts +5 -1
  26. package/telegram-plugin/gateway/boot-probes.ts +113 -0
  27. package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
  28. package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
  29. package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
  30. package/telegram-plugin/gateway/gateway.ts +43 -1
  31. package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
  32. package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
  33. package/telegram-plugin/gateway/ipc-server.ts +13 -0
  34. package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
  35. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  36. package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
  37. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  38. package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
  39. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  40. package/telegram-plugin/gateway/store-file.ts +244 -0
  41. package/telegram-plugin/gateway/stream-render.ts +24 -5
  42. package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
  43. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  44. package/telegram-plugin/registry/turns-schema.test.ts +8 -3
  45. package/telegram-plugin/registry/turns-schema.ts +40 -12
  46. package/telegram-plugin/runtime-metrics.ts +14 -0
  47. package/telegram-plugin/silence-poke.ts +138 -0
  48. package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
  49. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  50. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
  51. package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
  52. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
  53. package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
  54. package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
  55. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
  56. package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
  57. package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
  58. package/telegram-plugin/tests/registry-turns.test.ts +13 -0
  59. package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
  60. package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
  61. package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
  62. package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
  63. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  64. package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  66. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  67. package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
  68. package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
  69. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  70. package/telegram-plugin/tts-normalize.ts +6 -4
  71. package/telegram-plugin/voice-normalize-text.ts +168 -11
  72. package/telegram-plugin/worker-activity-feed.ts +51 -1
  73. package/vendor/hindsight-memory/CHANGELOG.md +73 -0
  74. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  75. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  76. package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
  77. package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
  78. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  79. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +257 -12
  81. package/vendor/hindsight-memory/scripts/retain.py +12 -6
  82. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  84. package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
  85. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  86. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
  87. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  89. package/vendor/hindsight-memory/settings.json +1 -1
  90. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  91. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
package/bin/run-hook.sh CHANGED
@@ -16,6 +16,8 @@
16
16
  # claude on stdout still work.
17
17
  # - Stderr is teed: visible in journald as before AND captured to a
18
18
  # buffer so we can attach the tail to the issue detail.
19
+ # - On EVERY invocation, success or failure: append one JSON line
20
+ # carrying `duration_ms` to the hook-timing log (#3564).
19
21
  # - On exit 0: auto-resolve any prior unresolved issue with the same
20
22
  # fingerprint. So a transient flake clears itself on next success.
21
23
  # - On non-zero exit: record an issue with severity=error, code = the
@@ -27,6 +29,45 @@
27
29
  #
28
30
  # The wrapper exits with the original command's exit code, so claude
29
31
  # code's hook contract (block / allow / non-zero behaviour) is preserved.
32
+ #
33
+ # ── Timing log (#3564) ──────────────────────────────────────────────────
34
+ #
35
+ # Before this, the wrapper logged ONLY failures — no timestamps, no
36
+ # durations. That is why `secret-guard-pretool` could sit at ~145ms per
37
+ # tool call for months (#3543) and `workspace-dynamic-hook` at ~825ms-1.1s
38
+ # per message (#3546) without producing a single log line: neither was
39
+ # FAILING, merely slow, and slow-but-successful was invisible.
40
+ #
41
+ # So every invocation now appends one JSON line to
42
+ # ${TELEGRAM_STATE_DIR}/hook-timings-<Ddd>.log (Mon..Sun)
43
+ # shaped as:
44
+ # {"ts":"...","date":"YYYY-MM-DD","source":"hook:x","code":"x.mjs",
45
+ # "duration_ms":142,"status":0}
46
+ # so `grep duration_ms` answers any future hook-cost question.
47
+ #
48
+ # Cost discipline — the wrapper's own overhead was measured at 0-2ms and
49
+ # must stay there, so the whole timing path is FORK-FREE:
50
+ # - `EPOCHREALTIME` (bash 5 builtin) for start/end, integer arithmetic
51
+ # for the delta. No `date`, no `stat`, no subshell.
52
+ # - The file name embeds the weekday, giving a self-truncating 7-day
53
+ # ring: on the first write of a new day the existing file's first line
54
+ # carries last week's date, so we truncate instead of appending. The
55
+ # staleness check is a builtin `read` from the file — measured
56
+ # 0.024ms/append vs 0.81ms for a single `stat` fork, which is why
57
+ # size-based rotation was rejected here.
58
+ # Measured net cost of `log_timing` (2000-iteration in-shell loop, a busy
59
+ # build host): 0.40-0.78ms per invocation, dominated by the append's
60
+ # open/write/close. That fits inside the stated 0-2ms wrapper budget with
61
+ # room to spare; a `date` + `stat` implementation would not.
62
+ #
63
+ # Env:
64
+ # SWITCHROOM_HOOK_TIMING=0 disable timing log entirely
65
+ # SWITCHROOM_HOOK_TIMING_DIR override the log directory
66
+ # SWITCHROOM_HOOK_TIMING_MIN_MS only log invocations at/above this
67
+ # duration (default 0 = log everything;
68
+ # the whole failure mode here is that
69
+ # slow-but-successful is invisible, so
70
+ # the default must not filter).
30
71
 
31
72
  set -u
32
73
 
@@ -82,9 +123,15 @@ STATE_DIR="${TELEGRAM_STATE_DIR:-}"
82
123
  STDERR_TMP="$(mktemp -t run-hook-stderr.XXXXXX 2>/dev/null || mktemp)"
83
124
  trap 'rm -f "$STDERR_TMP" 2>/dev/null || true' EXIT
84
125
 
126
+ # Fork-free monotonic-ish start stamp. EPOCHREALTIME is a bash 5 builtin;
127
+ # on bash 4 it is unset and timing degrades to a silent no-op.
128
+ TIMING_START="${EPOCHREALTIME:-}"
129
+
85
130
  "$COMMAND" "$@" 2>"$STDERR_TMP"
86
131
  STATUS=$?
87
132
 
133
+ TIMING_END="${EPOCHREALTIME:-}"
134
+
88
135
  # Replay captured stderr to our own stderr now that the command has
89
136
  # fully exited — preserves journald visibility without the streaming
90
137
  # property (which hooks don't need; they're short-lived).
@@ -96,6 +143,102 @@ emit_warn() {
96
143
  echo "run-hook.sh: $1" >&2
97
144
  }
98
145
 
146
+ # ── Timing log (#3564) ────────────────────────────────────────────────────
147
+ #
148
+ # Everything below is deliberately FORK-FREE: no command substitution (which
149
+ # forks a subshell), no `date`, no `stat`. Helpers return values by assigning
150
+ # to a caller-visible global rather than via `$(...)`.
151
+
152
+ # Split EPOCHREALTIME ("1753412345.123456") into whole milliseconds.
153
+ # LC_NUMERIC can make the separator a comma, so accept either.
154
+ # Sets `_EPOCH_MS`; returns 1 (and leaves _EPOCH_MS empty) on a bad shape.
155
+ _EPOCH_MS=""
156
+ _epoch_to_ms() {
157
+ local raw="$1" secs usecs
158
+ _EPOCH_MS=""
159
+ case "$raw" in
160
+ *[.,]*) secs="${raw%%[.,]*}"; usecs="${raw#*[.,]}" ;;
161
+ *) secs="$raw"; usecs="0" ;;
162
+ esac
163
+ case "$secs" in
164
+ ''|*[!0-9]*) return 1 ;;
165
+ esac
166
+ case "$usecs" in
167
+ ''|*[!0-9]*) return 1 ;;
168
+ esac
169
+ # Pad/truncate the fractional part to exactly 6 digits, then force base 10
170
+ # so a leading zero is not read as octal.
171
+ usecs="${usecs}000000"
172
+ usecs="${usecs:0:6}"
173
+ _EPOCH_MS=$(( secs * 1000 + (10#$usecs) / 1000 ))
174
+ return 0
175
+ }
176
+
177
+ # JSON-escape into `_JSON_ESCAPED`. Hook sources and command basenames are
178
+ # operator-authored and tame, but a backslash or quote in one must not be
179
+ # able to emit a malformed line (or inject extra fields) into a log that
180
+ # other tooling parses.
181
+ _JSON_ESCAPED=""
182
+ _json_escape() {
183
+ local s="$1"
184
+ s="${s//\\/\\\\}"
185
+ s="${s//\"/\\\"}"
186
+ _JSON_ESCAPED="$s"
187
+ }
188
+
189
+ log_timing() {
190
+ [ "${SWITCHROOM_HOOK_TIMING:-1}" = "0" ] && return 0
191
+ [ -n "$TIMING_START" ] || return 0
192
+ [ -n "$TIMING_END" ] || return 0
193
+
194
+ local timing_dir="${SWITCHROOM_HOOK_TIMING_DIR:-$STATE_DIR}"
195
+ [ -n "$timing_dir" ] || return 0
196
+ [ -d "$timing_dir" ] || return 0
197
+
198
+ local start_ms end_ms duration_ms
199
+ _epoch_to_ms "$TIMING_START" || return 0
200
+ start_ms="$_EPOCH_MS"
201
+ _epoch_to_ms "$TIMING_END" || return 0
202
+ end_ms="$_EPOCH_MS"
203
+ duration_ms=$(( end_ms - start_ms ))
204
+ # A clock step backwards must not emit a negative duration.
205
+ [ "$duration_ms" -lt 0 ] && duration_ms=0
206
+
207
+ local min_ms="${SWITCHROOM_HOOK_TIMING_MIN_MS:-0}"
208
+ case "$min_ms" in
209
+ ''|*[!0-9]*) min_ms=0 ;;
210
+ esac
211
+ [ "$duration_ms" -lt "$min_ms" ] && return 0
212
+
213
+ # Builtin date formatting — no `date` fork.
214
+ local today dow ts
215
+ printf -v today '%(%Y-%m-%d)T' -1
216
+ printf -v dow '%(%a)T' -1
217
+ printf -v ts '%(%Y-%m-%dT%H:%M:%S%z)T' -1
218
+
219
+ local logfile="${timing_dir}/hook-timings-${dow}.log"
220
+
221
+ # 7-day self-truncating ring: the weekday-named file is either today's or
222
+ # exactly a week stale, so if its first line does not carry today's date,
223
+ # reset it. `read` from a file is a builtin — no fork.
224
+ if [ -s "$logfile" ]; then
225
+ local first=''
226
+ read -r first < "$logfile" 2>/dev/null || first=''
227
+ case "$first" in
228
+ *"\"date\":\"${today}\""*) : ;;
229
+ *) : > "$logfile" 2>/dev/null || return 0 ;;
230
+ esac
231
+ fi
232
+
233
+ local esc_source esc_code
234
+ _json_escape "$SOURCE"; esc_source="$_JSON_ESCAPED"
235
+ _json_escape "$CODE"; esc_code="$_JSON_ESCAPED"
236
+
237
+ printf '{"ts":"%s","date":"%s","source":"%s","code":"%s","duration_ms":%s,"status":%s}\n' \
238
+ "$ts" "$today" "$esc_source" "$esc_code" "$duration_ms" "$STATUS" \
239
+ >> "$logfile" 2>/dev/null || true
240
+ }
241
+
99
242
  # When RUN_HOOK_DEBUG=1 is set, drop the stderr redirect on the
100
243
  # issues-CLI invocations so an operator debugging a broken record-path
101
244
  # sees the actual cause in journald instead of just the generic
@@ -228,6 +371,11 @@ resolve_success() {
228
371
  fi
229
372
  }
230
373
 
374
+ # Timing is emitted on EVERY path, including the degraded no-CLI path below
375
+ # — it writes a file directly and does not need the switchroom CLI, so hook
376
+ # cost stays observable even when issue tracking is off (#3564).
377
+ log_timing
378
+
231
379
  if [ -z "$SWITCHROOM_CLI" ]; then
232
380
  # Degraded path. Emit a single warning so the operator knows visibility
233
381
  # is off, but don't change the hook's exit semantics.
@@ -66,24 +66,48 @@ mkdir -p "$CACHE_DIR" 2>/dev/null || true
66
66
  # Date-keyed cache filename: when the calendar day rolls over, the
67
67
  # `today's daily` file path the renderer reads changes (the template
68
68
  # embeds different filenames in its output), so we invalidate the cache
69
- # at midnight UTC by varying the filename. Yesterday's file lingers
70
- # harmlessly until the next sweep.
71
- CACHE_DATE="$(date -u +%Y-%m-%d)"
69
+ # at midnight by varying the filename.
70
+ #
71
+ # Prior days' files are never cleaned up — there is no sweeper for this
72
+ # directory anywhere in the repo. That is deliberate and cheap, not an
73
+ # oversight: three small files per agent per day (~1100/year), a hash
74
+ # and a signature of 65 bytes each plus a body bounded by
75
+ # DEFAULT_DYNAMIC_TOTAL_MAX_CHARS. If that ever needs reclaiming it
76
+ # should be a real sweeper, not a comment claiming one exists.
77
+ #
78
+ # LOCAL time, not UTC — this MUST match the renderer. See
79
+ # `dailyMemoryRelativePath` in src/agents/workspace.ts, which derives
80
+ # today's daily-memory path from getFullYear/getMonth/getDate (local)
81
+ # on purpose: UTC would roll "today" over at early-morning for UTC+10
82
+ # hosts. When the hook used `date -u` here, every day from 00:00 until
83
+ # UTC midnight local the renderer's "today" file was absent from the
84
+ # hook's watch set, so writes to it did not invalidate the cache and a
85
+ # stale body was served.
86
+ CACHE_DATE="$(date +%Y-%m-%d)"
72
87
  CACHE_FILE="$CACHE_DIR/workspace-dynamic.${CACHE_DATE}.hash"
73
88
  BODY_FILE="$CACHE_DIR/workspace-dynamic.${CACHE_DATE}.body"
89
+ SIG_FILE="$CACHE_DIR/workspace-dynamic.${CACHE_DATE}.srcsig"
74
90
 
75
- # Mtime-fast-skip: if BODY_FILE exists AND is newer than every workspace
76
- # source the renderer reads, we can emit the cached body and skip the
91
+ # Source-signature fast-skip: if BODY_FILE exists AND the signature of
92
+ # the workspace source *set* is byte-identical to the signature recorded
93
+ # when that body was produced, we can emit the cached body and skip the
77
94
  # ~800ms `switchroom workspace render` invocation entirely. The renderer
78
95
  # reads MEMORY.md, today's daily, yesterday's daily — see
79
96
  # `loadDynamicBootstrapFiles` in src/agents/workspace.ts.
80
97
  #
81
- # Skip semantics: a source file that doesn't exist contributes a "very
82
- # old" mtime (epoch-0 via stat fallback), which never invalidates the
83
- # cache. A source file that's been updated since BODY_FILE's mtime
84
- # triggers a fresh render. Forensics measured this fast-path saving
85
- # ~825ms on the common case (chat turns where MEMORY hasn't changed
86
- # since the last turn).
98
+ # The signature covers each source's name + mtime + size, and records
99
+ # missing files explicitly. A max-mtime comparison (the previous
100
+ # approach) could not see a *deletion*: a missing source contributed
101
+ # mtime 0, which never exceeded the body's mtime, so removing MEMORY.md
102
+ # served the cached body forever. It also lost any source modified
103
+ # between the render and the body write (the write-during-render race):
104
+ # such a source ended up older than the body and became invisible
105
+ # indefinitely. Capturing the signature *before* invoking the renderer
106
+ # closes both: anything that changes during or after the render window
107
+ # yields a different signature on the next turn.
108
+ #
109
+ # Forensics measured this fast-path saving ~825ms on the common case
110
+ # (chat turns where no source has changed since the last turn).
87
111
  #
88
112
  # Resolve the agent's workspace dir. Switchroom uses
89
113
  # `~/.switchroom/agents/<name>/workspace/` by default. We avoid invoking
@@ -95,25 +119,63 @@ BODY_FILE="$CACHE_DIR/workspace-dynamic.${CACHE_DATE}.body"
95
119
  AGENT_DIR="${SWITCHROOM_AGENT_DIR:-$HOME/.switchroom/agents/$AGENT_NAME}"
96
120
  WS_DIR="$AGENT_DIR/workspace"
97
121
  TODAY_FILE="$WS_DIR/memory/${CACHE_DATE}.md"
98
- YESTERDAY_DATE="$(date -u -d 'yesterday' +%Y-%m-%d 2>/dev/null || echo "")"
122
+ # Local-time "yesterday", matching addDays(now, -1) in workspace.ts.
123
+ YESTERDAY_DATE="$(date -d 'yesterday' +%Y-%m-%d 2>/dev/null || echo "")"
99
124
  YESTERDAY_FILE="$WS_DIR/memory/${YESTERDAY_DATE}.md"
100
125
 
101
- if [ -f "$BODY_FILE" ]; then
102
- # Compare BODY_FILE mtime against every source. If any source is newer
103
- # the cache is stale; fall through. If all sources are older (or
104
- # missing), emit the cache and exit.
105
- body_mtime=$(stat -c '%Y' "$BODY_FILE" 2>/dev/null || echo 0)
106
- newest_src_mtime=0
107
- for src in "$WS_DIR/MEMORY.md" "$TODAY_FILE" "$YESTERDAY_FILE"; do
108
- if [ -f "$src" ]; then
109
- src_mtime=$(stat -c '%Y' "$src" 2>/dev/null || echo 0)
110
- if [ "$src_mtime" -gt "$newest_src_mtime" ]; then
111
- newest_src_mtime="$src_mtime"
126
+ # Signature of the source set: name + mtime + size for each source the
127
+ # renderer reads, with missing files recorded explicitly so a deletion
128
+ # changes the signature.
129
+ #
130
+ # `%.9Y` (nanosecond mtime), NOT `%Y`. With whole-second mtimes, a
131
+ # content change that lands in the same second as the previous one and
132
+ # happens to keep the same file size produces an identical signature, so
133
+ # the edit is served stale indefinitely — until some later change moves
134
+ # the second or the size. That is strictly worse than the max-mtime
135
+ # comparison this replaced, which fell through on an equal-second
136
+ # collision (`-gt` is false when equal) and re-rendered. Nanosecond
137
+ # resolution closes it. If a filesystem reports a zero nanosecond field
138
+ # the signature degrades to second precision, i.e. exactly the old
139
+ # behaviour, never worse.
140
+ _ws_source_signature() {
141
+ local src meta
142
+ {
143
+ for src in "$WS_DIR/MEMORY.md" "$TODAY_FILE" "$YESTERDAY_FILE"; do
144
+ if [ -f "$src" ]; then
145
+ meta=$(stat -c '%.9Y:%s' "$src" 2>/dev/null || echo "stat-error")
146
+ printf '%s\t%s\n' "$src" "$meta"
147
+ else
148
+ printf '%s\tMISSING\n' "$src"
112
149
  fi
113
- fi
114
- done
115
- if [ "$body_mtime" -gt "$newest_src_mtime" ]; then
116
- # Fast path: every source is older than the cached body.
150
+ done
151
+ } | sha256sum 2>/dev/null | cut -d' ' -f1
152
+ }
153
+
154
+ # Captured BEFORE the renderer runs, so a source mutated during the
155
+ # render window is not falsely credited to this body.
156
+ SRC_SIG="$(_ws_source_signature)"
157
+
158
+ # Is the cached body intact? CACHE_FILE holds sha256 of the render output
159
+ # with no trailing newline; BODY_FILE holds that same output plus one
160
+ # trailing newline (or is empty for an empty render). Command substitution
161
+ # strips trailing newlines, so this reproduces the recorded hash exactly in
162
+ # both cases. Without it a body truncated by a crash mid-write is served to
163
+ # the model verbatim and pinned into the fast path forever, because the
164
+ # signature only vouches for the *sources*, never for the body. One sha256
165
+ # over at most DEFAULT_DYNAMIC_TOTAL_MAX_CHARS is negligible against the
166
+ # ~825ms render it guards.
167
+ _ws_body_intact() {
168
+ local recorded actual
169
+ recorded=$(head -1 "$CACHE_FILE" 2>/dev/null || echo "")
170
+ [ -n "$recorded" ] || return 1
171
+ actual=$(printf '%s' "$(cat "$BODY_FILE" 2>/dev/null)" | sha256sum 2>/dev/null | cut -d' ' -f1)
172
+ [ -n "$actual" ] && [ "$actual" = "$recorded" ]
173
+ }
174
+
175
+ if [ -f "$BODY_FILE" ] && [ -f "$SIG_FILE" ] && [ -n "$SRC_SIG" ]; then
176
+ RECORDED_SIG=$(head -1 "$SIG_FILE" 2>/dev/null || echo "")
177
+ if [ "$RECORDED_SIG" = "$SRC_SIG" ] && _ws_body_intact; then
178
+ # Fast path: the source set is unchanged since this body was made.
117
179
  # In inject-on-change mode, also check the session-state file — if the
118
180
  # session_id matches the last-emitted session AND the hash matches, we
119
181
  # can suppress entirely (model already has this content in context).
@@ -155,11 +217,25 @@ fi
155
217
  # <50ms; 3s is generous headroom.
156
218
  WS_DYNAMIC=$(timeout 3 switchroom workspace render "$AGENT_NAME" --dynamic --warning-mode off 2>/dev/null || true)
157
219
 
158
- # Empty render emit nothing AND do NOT cache the empty body. Caching an
159
- # empty body would re-emit empty forever even after MEMORY comes back online,
160
- # defeating the whole purpose of the hook.
161
- if [ -z "$WS_DYNAMIC" ]; then
162
- exit 0
220
+ # An empty render IS cached. It used to `exit 0` here without writing the
221
+ # cache, on the theory that "caching an empty body would re-emit empty
222
+ # forever even after MEMORY comes back online" — that justification is
223
+ # obsolete: the fast-skip re-derives the source signature every turn, so a
224
+ # MEMORY.md that later appears (or any daily-memory write) changes the
225
+ # signature and forces a fresh render. Not caching it meant agents whose
226
+ # render is empty never had a cached body, the fast-skip never engaged, and
227
+ # they paid the full ~825ms-1.1s render on every single message to produce
228
+ # nothing.
229
+ #
230
+ # BODY_OUT is the exact byte string emitted on both the fresh and the cached
231
+ # path, so `cat "$BODY_FILE"` is byte-identical to a fresh emit (Anthropic's
232
+ # prompt cache is keyed on byte equality). Empty render → empty body file →
233
+ # empty stdout, not a bare newline.
234
+ if [ -n "$WS_DYNAMIC" ]; then
235
+ BODY_OUT="$WS_DYNAMIC
236
+ "
237
+ else
238
+ BODY_OUT=""
163
239
  fi
164
240
 
165
241
  # Content-addressed dedupe sidecar. Anthropic's prompt cache is keyed on
@@ -197,7 +273,41 @@ _ws_record_session_state() {
197
273
  fi
198
274
  }
199
275
 
276
+ # Write a cache file atomically: fill a per-PID temp file, then rename it
277
+ # into place. A plain `>` redirect truncates first, so a concurrent hook
278
+ # process can observe (and cache, and serve) a half-written body. rename(2)
279
+ # is atomic within a filesystem, so a reader sees either the whole old file
280
+ # or the whole new one.
281
+ _ws_write_atomic() {
282
+ local dest="$1" content="$2" tmp="$1.tmp.$$"
283
+ if printf '%s' "$content" > "$tmp" 2>/dev/null; then
284
+ mv -f "$tmp" "$dest" 2>/dev/null || rm -f "$tmp" 2>/dev/null || true
285
+ else
286
+ rm -f "$tmp" 2>/dev/null || true
287
+ fi
288
+ }
289
+
290
+ # Publish the body and the pre-render source signature that vouches for it.
291
+ # Body first, signature second, both atomic: the signature is what arms the
292
+ # fast path, so it must never become visible before the body it describes.
293
+ # The signature is only written once the body file actually exists, so a
294
+ # failed body write can never leave a "fresh" signature pointing at a stale
295
+ # body.
296
+ _ws_publish() {
297
+ _ws_write_atomic "$BODY_FILE" "$BODY_OUT"
298
+ if [ -n "$SRC_SIG" ] && [ -f "$BODY_FILE" ]; then
299
+ _ws_write_atomic "$SIG_FILE" "$SRC_SIG
300
+ "
301
+ fi
302
+ }
303
+
200
304
  if [ -n "$NEW_HASH" ] && [ "$NEW_HASH" = "$OLD_HASH" ] && [ -f "$BODY_FILE" ]; then
305
+ # Rewrite the body unconditionally, even though the hash says it is
306
+ # unchanged. NEW_HASH and OLD_HASH both derive from CACHE_FILE and the
307
+ # fresh render — neither validates BODY_FILE — so a body truncated by a
308
+ # crash mid-write would otherwise be cat'd to the model AND pinned into
309
+ # the fast path by the signature we are about to stamp on it.
310
+ _ws_publish
201
311
  # Content unchanged since last render. In inject-on-change mode, check if
202
312
  # we already injected this content in the current session — if so, suppress.
203
313
  if [ "$INJECT_ON_CHANGE" != "0" ] && [ -n "$SESSION_ID" ]; then
@@ -215,17 +325,16 @@ if [ -n "$NEW_HASH" ] && [ "$NEW_HASH" = "$OLD_HASH" ] && [ -f "$BODY_FILE" ]; t
215
325
  fi
216
326
  cat "$BODY_FILE"
217
327
  else
218
- # Refresh sidecar: write hash + body, then echo body. Touch the body
219
- # file last so the mtime fast-skip path next turn sees a fresh
220
- # mtime (newer than every source we just consumed).
328
+ # Refresh sidecar: write hash + body, then echo body.
221
329
  if [ -n "$NEW_HASH" ]; then
222
- printf '%s\n' "$NEW_HASH" > "$CACHE_FILE" 2>/dev/null || true
223
- printf '%s\n' "$WS_DYNAMIC" > "$BODY_FILE" 2>/dev/null || true
330
+ _ws_write_atomic "$CACHE_FILE" "$NEW_HASH
331
+ "
332
+ _ws_publish
224
333
  if [ "$INJECT_ON_CHANGE" != "0" ] && [ -n "$SESSION_ID" ]; then
225
334
  _ws_record_session_state "$NEW_HASH" "$SESSION_ID"
226
335
  fi
227
336
  fi
228
- printf '%s\n' "$WS_DYNAMIC"
337
+ printf '%s' "$BODY_OUT"
229
338
  fi
230
339
 
231
340
  exit 0
@@ -11107,7 +11107,7 @@ var AgentMemorySchema = exports_external.object({
11107
11107
  recall: exports_external.object({
11108
11108
  max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
11109
11109
  cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
11110
- min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum Jaccard token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "Drops low-relevance matches before the count cap so weak hits " + "don't fill the slot on real queries. 0.0 disables (default — " + "current behaviour). Try 0.100.20 to start; observe the " + "`overlap_dropped` field via `switchroom memory recall-log`."),
11110
+ min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum containment token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "A cheap FLOOR that removes candidates with (near-)zero lexical " + "relationship to the prompt NOT a precision control: at the " + "recommended 0.10 the gate is near-passthrough, and the " + "effective precision control is the engine rerank plus the " + "max_memories head-slice. 0.0 disables (default — current " + "behaviour). Use 0.10; values at or above 0.20 measurably " + "starve recall — on production replay 0.20 leaves ~41.9% of " + "turns with NO memories at all, re-creating the bug #3541 " + "fixed. Observe the `overlap_dropped` field via " + "`switchroom memory recall-log`."),
11111
11111
  types: exports_external.array(exports_external.string()).optional().describe("Hindsight fact types to recall. Switchroom default is " + '["world", "experience", "observation"] — the synthesized ' + "`observation` tier is on by default. Set to " + '["world", "experience"] to opt out of observation-backed ' + "recall for this agent (or fleet-wide under defaults)."),
11112
11112
  additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results — e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with an 8s timeout and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
11113
11113
  sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Per-speaker recall routing: a map of Telegram sender → extra " + "recall bank. When a message arrives, the agent also recalls the " + "speaker's bank (matched by Telegram username — a leading @ is " + "optional — or numeric user_id), merged " + "into its own results — so each trusted user gets their own " + "profile context. Additive recall scoping within the single " + "tenant: never an access boundary (who may drive an agent stays " + "the per-agent user assignment in `access.allowFrom`). Author the " + "banks via `switchroom memory profile`."),
@@ -11419,9 +11419,16 @@ var ReactionDispatchSchema = exports_external.object({
11419
11419
  enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false — " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
11420
11420
  emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) — a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
11421
11421
  }).optional();
11422
- var ReleaseBlock = exports_external.object({
11422
+ var releaseBlockFields = {
11423
11423
  channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
11424
11424
  pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
11425
+ };
11426
+ var ReleaseBlock = exports_external.object(releaseBlockFields).strict().refine((r) => !(r.channel && r.pin), {
11427
+ message: "release.channel and release.pin are mutually exclusive"
11428
+ });
11429
+ var RootReleaseBlock = exports_external.object({
11430
+ ...releaseBlockFields,
11431
+ auto_update: exports_external.boolean().optional().describe("Opt-in unattended fleet auto-update (KEN-131). When true, hostd's " + "release watcher polls the published release version and, on a new " + "release, drives the EXISTING staggered canary rollout " + "(`switchroom rollout --pin vX.Y.Z`) unattended: canary-first, " + "per-agent version assert, durable pin persisted only after the " + "canary is green, abort + operator alert card on canary failure, " + "and compose rollback on a failed apply. Default false — with it " + "unset/false, behaviour is unchanged.")
11425
11432
  }).strict().refine((r) => !(r.channel && r.pin), {
11426
11433
  message: "release.channel and release.pin are mutually exclusive"
11427
11434
  });
@@ -11665,6 +11672,7 @@ var AutoReleaseCheckSchema = exports_external.object({
11665
11672
  enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false — opt-in."),
11666
11673
  interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
11667
11674
  apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
11675
+ notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
11668
11676
  image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
11669
11677
  });
11670
11678
  var HostControlConfigSchema = exports_external.object({
@@ -11707,7 +11715,7 @@ var SwitchroomConfigSchema = exports_external.object({
11707
11715
  timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC'").optional().describe("Global default IANA timezone applied to every agent unless the " + "agent (or its profile) declares its own. See the per-agent " + "timezone field for the full cascade and auto-detection fallback.")
11708
11716
  }),
11709
11717
  telegram: TelegramConfigSchema,
11710
- release: ReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "— mutually exclusive. Per-agent `release` REPLACES this entirely."),
11718
+ release: RootReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "— mutually exclusive. Per-agent `release` REPLACES this entirely " + "(except `auto_update`, which is root-only — a fleet property read by " + "hostd's release watcher, never per-agent)."),
11711
11719
  memory: MemoryBackendConfigSchema.optional(),
11712
11720
  hindsight: HindsightConfigSchema.optional().describe("Fleet-singleton Hindsight (memory backend) configuration. Currently " + "just the LLM knob (provider + model) used for retain/reflect/" + "consolidation. Both fields optional; when unset the container falls " + "back to provider=claude-code + the hard-coded HINDSIGHT_DEFAULT_MODEL " + "so nothing changes for operators who don't set it. Read at container " + "launch by startHindsight() (src/setup/hindsight.ts) — takes effect on " + "the next `switchroom apply` / `memory setup --recreate`."),
11713
11721
  vault: VaultConfigSchema.optional(),
@@ -13398,7 +13406,8 @@ var ApprovalDecisionMetaSchema = exports_external.object({
13398
13406
  ttl_expires_at: exports_external.number().nullable(),
13399
13407
  last_used_at: exports_external.number().nullable(),
13400
13408
  revoked_at: exports_external.number().nullable(),
13401
- revoke_reason: exports_external.string().nullable()
13409
+ revoke_reason: exports_external.string().nullable(),
13410
+ origin: exports_external.enum(["agent", "operator"]).optional()
13402
13411
  });
13403
13412
  var OkApprovalLookupResponseSchema = exports_external.object({
13404
13413
  ok: exports_external.literal(true),
@@ -16749,7 +16749,7 @@ var AgentMemorySchema = exports_external.object({
16749
16749
  recall: exports_external.object({
16750
16750
  max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
16751
16751
  cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
16752
- min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum Jaccard token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "Drops low-relevance matches before the count cap so weak hits " + "don't fill the slot on real queries. 0.0 disables (default — " + "current behaviour). Try 0.100.20 to start; observe the " + "`overlap_dropped` field via `switchroom memory recall-log`."),
16752
+ min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum containment token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "A cheap FLOOR that removes candidates with (near-)zero lexical " + "relationship to the prompt NOT a precision control: at the " + "recommended 0.10 the gate is near-passthrough, and the " + "effective precision control is the engine rerank plus the " + "max_memories head-slice. 0.0 disables (default — current " + "behaviour). Use 0.10; values at or above 0.20 measurably " + "starve recall — on production replay 0.20 leaves ~41.9% of " + "turns with NO memories at all, re-creating the bug #3541 " + "fixed. Observe the `overlap_dropped` field via " + "`switchroom memory recall-log`."),
16753
16753
  types: exports_external.array(exports_external.string()).optional().describe("Hindsight fact types to recall. Switchroom default is " + '["world", "experience", "observation"] — the synthesized ' + "`observation` tier is on by default. Set to " + '["world", "experience"] to opt out of observation-backed ' + "recall for this agent (or fleet-wide under defaults)."),
16754
16754
  additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results — e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with an 8s timeout and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
16755
16755
  sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Per-speaker recall routing: a map of Telegram sender → extra " + "recall bank. When a message arrives, the agent also recalls the " + "speaker's bank (matched by Telegram username — a leading @ is " + "optional — or numeric user_id), merged " + "into its own results — so each trusted user gets their own " + "profile context. Additive recall scoping within the single " + "tenant: never an access boundary (who may drive an agent stays " + "the per-agent user assignment in `access.allowFrom`). Author the " + "banks via `switchroom memory profile`."),
@@ -17061,9 +17061,16 @@ var ReactionDispatchSchema = exports_external.object({
17061
17061
  enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false — " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
17062
17062
  emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) — a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
17063
17063
  }).optional();
17064
- var ReleaseBlock = exports_external.object({
17064
+ var releaseBlockFields = {
17065
17065
  channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
17066
17066
  pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
17067
+ };
17068
+ var ReleaseBlock = exports_external.object(releaseBlockFields).strict().refine((r) => !(r.channel && r.pin), {
17069
+ message: "release.channel and release.pin are mutually exclusive"
17070
+ });
17071
+ var RootReleaseBlock = exports_external.object({
17072
+ ...releaseBlockFields,
17073
+ auto_update: exports_external.boolean().optional().describe("Opt-in unattended fleet auto-update (KEN-131). When true, hostd's " + "release watcher polls the published release version and, on a new " + "release, drives the EXISTING staggered canary rollout " + "(`switchroom rollout --pin vX.Y.Z`) unattended: canary-first, " + "per-agent version assert, durable pin persisted only after the " + "canary is green, abort + operator alert card on canary failure, " + "and compose rollback on a failed apply. Default false — with it " + "unset/false, behaviour is unchanged.")
17067
17074
  }).strict().refine((r) => !(r.channel && r.pin), {
17068
17075
  message: "release.channel and release.pin are mutually exclusive"
17069
17076
  });
@@ -17307,6 +17314,7 @@ var AutoReleaseCheckSchema = exports_external.object({
17307
17314
  enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false — opt-in."),
17308
17315
  interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
17309
17316
  apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
17317
+ notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
17310
17318
  image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
17311
17319
  });
17312
17320
  var HostControlConfigSchema = exports_external.object({
@@ -17349,7 +17357,7 @@ var SwitchroomConfigSchema = exports_external.object({
17349
17357
  timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC'").optional().describe("Global default IANA timezone applied to every agent unless the " + "agent (or its profile) declares its own. See the per-agent " + "timezone field for the full cascade and auto-detection fallback.")
17350
17358
  }),
17351
17359
  telegram: TelegramConfigSchema,
17352
- release: ReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "— mutually exclusive. Per-agent `release` REPLACES this entirely."),
17360
+ release: RootReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "— mutually exclusive. Per-agent `release` REPLACES this entirely " + "(except `auto_update`, which is root-only — a fleet property read by " + "hostd's release watcher, never per-agent)."),
17353
17361
  memory: MemoryBackendConfigSchema.optional(),
17354
17362
  hindsight: HindsightConfigSchema.optional().describe("Fleet-singleton Hindsight (memory backend) configuration. Currently " + "just the LLM knob (provider + model) used for retain/reflect/" + "consolidation. Both fields optional; when unset the container falls " + "back to provider=claude-code + the hard-coded HINDSIGHT_DEFAULT_MODEL " + "so nothing changes for operators who don't set it. Read at container " + "launch by startHindsight() (src/setup/hindsight.ts) — takes effect on " + "the next `switchroom apply` / `memory setup --recreate`."),
17355
17363
  vault: VaultConfigSchema.optional(),
@@ -18355,9 +18363,26 @@ var HINDSIGHT_BROKER_SOCK_VOLUME = `auth-broker-${HINDSIGHT_CONSUMER_NAME}-sock`
18355
18363
  var HINDSIGHT_CREDS_MIRROR_VOLUME = `consumer-creds-${HINDSIGHT_CONSUMER_NAME}`;
18356
18364
  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)';
18357
18365
  var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
18366
+ var DOCKER_PROBE_TIMEOUT_MS = 60 * 1000;
18358
18367
 
18359
18368
  // src/memory/hindsight.ts
18360
- var DEFAULT_RETAIN_MISSION = "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, " + "including in-flight workflow/process narration (a sub-task started, " + "paused, or is still running) only retain the outcome once a task " + "actually completes or a decision is made.";
18369
+ 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 record the preference (what the user likes, wants, or always " + `does), not the request itself.
18370
+
18371
+ ` + `NEVER extract:
18372
+ ` + "- Agent tool-use traces or narration of what the assistant did (e.g. " + `"the assistant used X to query Y", "ran a search", "sent the message").
18373
+ ` + "- In-flight workflow/process narration (a sub-task started, paused, or is " + "still running) — retain the outcome only once the task completes or a " + `decision is made.
18374
+ ` + `- Operation, request, batch or session IDs, UUIDs, hashes, or error codes.
18375
+ ` + "- Hindsight's own errors, retries, backlogs, or internal state — the " + `memory system's self-reports are not memories.
18376
+ ` + `- Restatements of the user's current request or the task in progress.
18377
+ ` + "- Transient state (unread counts, build status, what is running right now) " + "unless the fact is explicitly dated, in which case record it as a dated " + `observation.
18378
+ ` + `- Greetings, acknowledgements, and routine operational chatter.
18379
+
18380
+ ` + "If a candidate fact matches an exclusion, drop it rather than rewording " + "it. If nothing durable remains, return an empty facts list.";
18381
+ var SUPERSEDED_RETAIN_MISSIONS = [
18382
+ "Extract technical decisions, architectural choices, user preferences, project context, and people/tool relationships. Ignore routine greetings and transient operational details.",
18383
+ "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.",
18384
+ "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, " + "including in-flight workflow/process narration (a sub-task started, " + "paused, or is still running) — only retain the outcome once a task " + "actually completes or a decision is made."
18385
+ ];
18361
18386
  var PROFILE_MEMORY_DEFAULTS = {
18362
18387
  "health-coach": {
18363
18388
  disposition: { skepticism: 2, literalism: 2, empathy: 5 },
@@ -18671,7 +18696,8 @@ var ApprovalDecisionMetaSchema = exports_external.object({
18671
18696
  ttl_expires_at: exports_external.number().nullable(),
18672
18697
  last_used_at: exports_external.number().nullable(),
18673
18698
  revoked_at: exports_external.number().nullable(),
18674
- revoke_reason: exports_external.string().nullable()
18699
+ revoke_reason: exports_external.string().nullable(),
18700
+ origin: exports_external.enum(["agent", "operator"]).optional()
18675
18701
  });
18676
18702
  var OkApprovalLookupResponseSchema = exports_external.object({
18677
18703
  ok: exports_external.literal(true),
@@ -18742,6 +18768,7 @@ var materializedDirs = new Set;
18742
18768
  // src/agents/scaffold.ts
18743
18769
  var REPO_ROOT = resolve5(import.meta.dirname, "../..");
18744
18770
  var CLAUDE_MD_YOURS_PLACEHOLDER = "This space is yours. Add per-agent rules, exceptions, or context the " + "Switchroom template doesn't capture. Everything above the marker line is " + "regenerated on every apply; this section is preserved.";
18771
+ var pendingBankOps = new Set;
18745
18772
  var SWITCHROOM_OWNED_SETTINGS_KEYS = new Set([
18746
18773
  "permissions",
18747
18774
  "mcpServers",