switchroom 0.19.2 → 0.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +109 -7
  3. package/dist/cli/autoaccept-poll.js +2 -0
  4. package/dist/cli/drive-write-pretool.mjs +2 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +2 -0
  6. package/dist/cli/switchroom.js +404 -245
  7. package/dist/host-control/main.js +1 -1
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +8 -0
  10. package/skills/mental-model-curator/SKILL.md +68 -2
  11. package/telegram-plugin/auth-snapshot-format.ts +104 -12
  12. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  13. package/telegram-plugin/dist/gateway/gateway.js +1194 -794
  14. package/telegram-plugin/dist/server.js +8 -2
  15. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  16. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  17. package/telegram-plugin/gateway/auth-command.ts +138 -5
  18. package/telegram-plugin/gateway/gateway.ts +68 -101
  19. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  20. package/telegram-plugin/gateway/model-command.ts +203 -1
  21. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  22. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  23. package/telegram-plugin/gateway/stream-render.ts +22 -5
  24. package/telegram-plugin/quota-bar-format.ts +60 -12
  25. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  26. package/telegram-plugin/session-tail.ts +27 -3
  27. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  28. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  29. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  30. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
  31. package/telegram-plugin/tests/model-command.test.ts +220 -0
  32. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  35. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  36. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  37. package/vendor/hindsight-memory/README.md +2 -1
  38. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  39. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  41. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  42. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  43. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  44. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  45. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  46. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  47. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  48. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  60. package/vendor/hindsight-memory/settings.json +3 -1
@@ -33,11 +33,17 @@ def strip_channel_envelope(content: str) -> str:
33
33
  This is the Claude Code equivalent of Openclaw's stripMetadataEnvelopes().
34
34
  Extracts the inner text, preserving the actual user message while removing
35
35
  transport metadata that Hindsight doesn't need.
36
+
37
+ A single prompt may carry MORE THAN ONE envelope (e.g. a coalesced
38
+ burst where several inbound messages were concatenated). Since this now
39
+ sits on the live recall-query path (hindsight-leverage PR 1, review
40
+ finding 6), coalesce EVERY envelope's inner text rather than keeping only
41
+ the first and silently dropping everything after the first ``</channel>``.
36
42
  """
37
- # Match <channel ...>content</channel> — extract inner text
38
- match = re.search(r"<channel\b[^>]*>([\s\S]*?)</channel>", content)
39
- if match:
40
- return match.group(1).strip()
43
+ # Match every <channel ...>content</channel> — extract & join inner texts.
44
+ matches = re.findall(r"<channel\b[^>]*>([\s\S]*?)</channel>", content)
45
+ if matches:
46
+ return "\n".join(m.strip() for m in matches if m.strip()).strip()
41
47
  return content
42
48
 
43
49
 
@@ -79,7 +85,14 @@ def compose_recall_query(
79
85
 
80
86
  <latest query>
81
87
  """
82
- latest = latest_query.strip()
88
+ # Switchroom A1 (hindsight-leverage PR 1) — strip the <channel> envelope
89
+ # from the latest query INSIDE the helper as well, so any caller (present
90
+ # or future) gets an envelope-free composed query. The recall.py caller
91
+ # already strips before calling, but keeping the strip here is defence in
92
+ # depth: it guarantees the trailing latest-query segment appended below
93
+ # (and returned on the turns<=1 short-circuit) never carries the raw
94
+ # chat_id/ts/user XML noise into the embedding or the char cap.
95
+ latest = strip_channel_envelope(latest_query).strip()
83
96
  if recall_context_turns <= 1 or not isinstance(messages, list) or not messages:
84
97
  return latest
85
98
 
@@ -236,6 +249,43 @@ def slice_last_turns_by_user_boundary(messages: list, turns: int) -> list:
236
249
  return messages[start_index:]
237
250
 
238
251
 
252
+ # ---------------------------------------------------------------------------
253
+ # Sidechain (sub-agent transcript) detection
254
+ # ---------------------------------------------------------------------------
255
+
256
+
257
+ def transcript_first_line_is_sidechain(path: str) -> bool:
258
+ """True when the first JSON line of ``path`` carries ``isSidechain: true``.
259
+
260
+ Switchroom hindsight-leverage PR5. Claude Code writes sub-agent (Task-tool)
261
+ transcripts as separate ``.jsonl`` files under
262
+ ``<project>/<session>/subagents/agent-<agent_id>.jsonl`` whose every line
263
+ carries ``isSidechain: true``. This shared predicate lets BOTH the
264
+ SubagentStop retain (which resolves + retains these deliberately, tagged
265
+ ``sidechain`` + volume-gated) AND the boot reconciler / any transcript
266
+ sweeper (which must NOT treat a sidechain as a pseudo-session and re-retain
267
+ it untagged, at full recall weight, bypassing the volume gate) recognise a
268
+ sidechain file from its first line alone — a cheap single-line read. Any
269
+ read/parse error is treated as "not a sidechain" (fail-open: a
270
+ genuinely-unreadable file is skipped elsewhere by its empty transcript).
271
+ """
272
+ import json
273
+
274
+ try:
275
+ with open(path, encoding="utf-8") as f:
276
+ for line in f:
277
+ line = line.strip()
278
+ if not line:
279
+ continue
280
+ try:
281
+ return json.loads(line).get("isSidechain") is True
282
+ except json.JSONDecodeError:
283
+ return False
284
+ except OSError:
285
+ return False
286
+ return False
287
+
288
+
239
289
  # ---------------------------------------------------------------------------
240
290
  # Memory formatting (recall results → context string)
241
291
  # ---------------------------------------------------------------------------
@@ -18,8 +18,11 @@ recall path; a directive-fetch failure must not kill the recall block.
18
18
 
19
19
  import re
20
20
  import sys
21
+ import time
21
22
  from typing import Optional
22
23
 
24
+ from .state import list_state_names, read_state, remove_state, write_state
25
+
23
26
  # Sanity cap on how many directives we ever inject into the prompt. Banks
24
27
  # with more active directives than this are pathological; truncate with a
25
28
  # footer so the agent knows there are more.
@@ -29,25 +32,56 @@ MAX_DIRECTIVES = 15
29
32
  # UserPromptSubmit critical path — we cannot block it for long.
30
33
  DIRECTIVES_TIMEOUT_SECONDS = 2
31
34
 
35
+ # --- Directives-list cache (switchroom hindsight-leverage A4) -----------------
36
+ #
37
+ # `list_directives` runs on the recall (UserPromptSubmit) critical path every
38
+ # non-skipped turn — a fresh 2s-timeout HTTP round-trip whose result changes
39
+ # only when a directive is created/updated/deleted (rare). We cache the fetched
40
+ # list in the plugin state dir with a short TTL so the common no-write turn
41
+ # skips the round-trip, while bounding staleness:
42
+ # * In-session writes: directive_verify.py (Stop hook) deletes the cache when
43
+ # the just-ended turn contains a create/update/delete_directive tool_use, so
44
+ # the very next recall re-fetches — the new state is visible in turn N+1.
45
+ # * Cross-process writes (another session, operator CLI) have no invalidation
46
+ # channel and rely on TTL alone → at most TTL seconds stale.
47
+ #
48
+ # Invalidation blind spots (both fall back to TTL, ≤ TTL stale — acceptable):
49
+ # * Sub-agent / sidechain directive writes: the create_directive tool_use is
50
+ # in the SIDECHAIN transcript, not the parent's, so the parent's Stop hook
51
+ # (which reads the parent transcript) never sees it. PR 5 (SubagentStop)
52
+ # is where sidechain awareness lands.
53
+ # * Bash / operator-CLI directive writes (`switchroom` or a curl) produce no
54
+ # tool_use in any transcript, so there is nothing for the Stop hook to
55
+ # detect.
56
+ #
57
+ # Rollback: set the TTL to 0 (HINDSIGHT_DIRECTIVES_CACHE_TTL_SECONDS=0) to
58
+ # disable the cache entirely — every turn fetches live, as before A4.
59
+ DIRECTIVES_CACHE_TTL_SECONDS = 120
32
60
 
33
- def fetch_active_directives(client, bank_id: str, timeout: int = DIRECTIVES_TIMEOUT_SECONDS) -> list:
34
- """Fetch active directives for a bank, sorted by priority (highest first).
61
+ # All directive cache files share this basename prefix so they can be
62
+ # enumerated for bulk (bank-agnostic) invalidation.
63
+ _CACHE_PREFIX = "directives_cache."
35
64
 
36
- Args:
37
- client: A HindsightClient instance with a list_directives method.
38
- bank_id: The bank to fetch directives from.
39
- timeout: HTTP timeout in seconds.
40
65
 
41
- Returns:
42
- A list of directive dicts (each with id, name, content, priority,
43
- tags, ...), sorted by priority descending. On any failure returns
44
- an empty list and logs a single warn line to stderr — never raises.
66
+ def _cache_name(bank_id: str) -> str:
67
+ """State-file name for a bank's cached directive list."""
68
+ return f"{_CACHE_PREFIX}{bank_id}.json"
69
+
70
+
71
+ def _fetch_directives_with_status(client, bank_id: str, timeout: int) -> tuple:
72
+ """Fetch + normalize active directives, reporting fetch success.
73
+
74
+ Returns ``(ok, directives)``. ``ok`` is False only on a genuine fetch
75
+ FAILURE (HTTP error, non-dict response) — a bank that simply has no
76
+ directives returns ``(True, [])``. Callers use ``ok`` to avoid caching a
77
+ transient failure's empty result (which would mask real directives for a
78
+ whole TTL window). Never raises; logs a single warn line on failure.
45
79
  """
46
80
  try:
47
81
  response = client.list_directives(bank_id=bank_id, active_only=True, timeout=timeout)
48
82
  except Exception as e:
49
83
  print(f"[Hindsight] list_directives failed for bank '{bank_id}': {e}", file=sys.stderr)
50
- return []
84
+ return False, []
51
85
 
52
86
  if not isinstance(response, dict):
53
87
  print(
@@ -55,20 +89,123 @@ def fetch_active_directives(client, bank_id: str, timeout: int = DIRECTIVES_TIME
55
89
  f"{type(response).__name__}",
56
90
  file=sys.stderr,
57
91
  )
58
- return []
92
+ return False, []
59
93
 
60
94
  items = response.get("items")
61
95
  if not isinstance(items, list):
62
96
  # Empty / malformed response — quiet success, no warn (banks with
63
- # no directives are normal).
64
- return []
97
+ # no directives are normal). Cacheable.
98
+ return True, []
65
99
 
66
100
  # Filter to dicts only, then sort by priority descending. Treat missing
67
101
  # priority as 0 so malformed entries sink to the bottom rather than
68
102
  # crashing.
69
103
  valid = [d for d in items if isinstance(d, dict)]
70
104
  valid.sort(key=lambda d: d.get("priority", 0), reverse=True)
71
- return valid
105
+ return True, valid
106
+
107
+
108
+ def fetch_active_directives(client, bank_id: str, timeout: int = DIRECTIVES_TIMEOUT_SECONDS) -> list:
109
+ """Fetch active directives for a bank, sorted by priority (highest first).
110
+
111
+ Args:
112
+ client: A HindsightClient instance with a list_directives method.
113
+ bank_id: The bank to fetch directives from.
114
+ timeout: HTTP timeout in seconds.
115
+
116
+ Returns:
117
+ A list of directive dicts (each with id, name, content, priority,
118
+ tags, ...), sorted by priority descending. On any failure returns
119
+ an empty list and logs a single warn line to stderr — never raises.
120
+ """
121
+ _ok, directives = _fetch_directives_with_status(client, bank_id, timeout)
122
+ return directives
123
+
124
+
125
+ def _read_cache(bank_id: str) -> Optional[dict]:
126
+ """Read + validate a bank's cache envelope. Returns None on miss/corruption.
127
+
128
+ A corrupted or wrong-shaped cache (bad JSON handled by read_state; here we
129
+ additionally reject a non-dict envelope, a non-numeric timestamp, or a
130
+ non-list directive payload) is treated as a MISS so the caller falls back to
131
+ a live fetch rather than injecting garbage.
132
+
133
+ Also rejects an envelope whose stored ``bank_id`` does not match the
134
+ requested one: ``_safe_filename`` can collapse two distinct bank ids onto a
135
+ single cache file, and serving bank A's directives for bank B would leak
136
+ rules across banks. The embedded ``bank_id`` is the authoritative key.
137
+ """
138
+ raw = read_state(_cache_name(bank_id), None)
139
+ if not isinstance(raw, dict):
140
+ return None
141
+ if raw.get("bank_id") != bank_id:
142
+ return None
143
+ ts = raw.get("ts")
144
+ directives = raw.get("directives")
145
+ if not isinstance(ts, (int, float)) or isinstance(ts, bool):
146
+ return None
147
+ if not isinstance(directives, list):
148
+ return None
149
+ return raw
150
+
151
+
152
+ def fetch_active_directives_cached(
153
+ client,
154
+ bank_id: str,
155
+ ttl_seconds: int = DIRECTIVES_CACHE_TTL_SECONDS,
156
+ timeout: int = DIRECTIVES_TIMEOUT_SECONDS,
157
+ now: Optional[float] = None,
158
+ ) -> list:
159
+ """Cached wrapper around :func:`fetch_active_directives`.
160
+
161
+ On a fresh cache hit (age < ``ttl_seconds``) returns the cached list WITHOUT
162
+ an HTTP call. On a miss / expiry / corrupted cache, fetches live and, when
163
+ the fetch SUCCEEDED, writes the cache. A failed fetch is never cached, so a
164
+ transient error can't mask real directives for a TTL window.
165
+
166
+ ``ttl_seconds <= 0`` disables the cache (always live-fetch, never write) —
167
+ the A4 rollback lever.
168
+
169
+ Args:
170
+ now: Injectable current epoch seconds, for deterministic tests.
171
+ """
172
+ if now is None:
173
+ now = time.time()
174
+
175
+ caching = isinstance(ttl_seconds, (int, float)) and ttl_seconds > 0
176
+
177
+ if caching:
178
+ cached = _read_cache(bank_id)
179
+ if cached is not None:
180
+ age = now - cached["ts"]
181
+ # A negative age means the stored timestamp is in the FUTURE
182
+ # (wall-clock step-back / a doctored envelope) — treat it as
183
+ # expired rather than "fresh forever", so a clock correction can't
184
+ # pin a stale cache.
185
+ if 0 <= age < ttl_seconds:
186
+ return cached["directives"]
187
+
188
+ ok, directives = _fetch_directives_with_status(client, bank_id, timeout)
189
+
190
+ if caching and ok:
191
+ write_state(_cache_name(bank_id), {"ts": now, "bank_id": bank_id, "directives": directives})
192
+
193
+ return directives
194
+
195
+
196
+ def invalidate_directives_cache(bank_id: Optional[str] = None) -> None:
197
+ """Delete the directives cache so the next recall re-fetches live.
198
+
199
+ With ``bank_id`` set, removes just that bank's cache file. With no argument
200
+ (the Stop-hook invalidation path, which does not resolve the bank), removes
201
+ EVERY directive cache file — directive writes are rare, so a bank-agnostic
202
+ sweep is cheap and robust. Best-effort; never raises.
203
+ """
204
+ if bank_id is not None:
205
+ remove_state(_cache_name(bank_id))
206
+ return
207
+ for name in list_state_names(_CACHE_PREFIX):
208
+ remove_state(name)
72
209
 
73
210
 
74
211
  def format_active_directives_block(directives: list, max_directives: int = MAX_DIRECTIVES) -> Optional[str]:
@@ -0,0 +1,142 @@
1
+ """Deadline-bounded parallel fan-out for the recall critical path.
2
+
3
+ Switchroom hindsight-leverage A3 (parallel multi-bank recall). The recall
4
+ hook (``recall.py``) queries the agent's own bank, every additional bank
5
+ (profile / shared / sender), and the active-directives list. Serially, the
6
+ critical-path latency is the SUM of those round-trips — a heavy agent with two
7
+ extra banks plus directives can serialise four 2-8s calls and breach the 12s
8
+ UserPromptSubmit hook ceiling, which drops recall entirely for that turn.
9
+
10
+ This runs each labelled task in its OWN daemon thread and waits only until a
11
+ single SHARED deadline (the hook ceiling minus a headroom margin). Key
12
+ properties, all enforced by mechanism rather than convention:
13
+
14
+ * **Daemon threads.** Every worker is a daemon, so a thread still blocked on
15
+ a socket read when the deadline elapses can NEVER keep the interpreter (and
16
+ therefore the hook) alive past the ceiling — the process exits and the
17
+ kernel reaps the socket. ``recall.py``'s ``__main__`` additionally calls
18
+ ``os._exit(0)`` after flushing stdout as a belt-and-suspenders against any
19
+ non-daemon thread a client library might spawn.
20
+
21
+ * **One shared deadline.** Total wait is bounded by ``deadline_seconds`` from
22
+ the moment ``run_parallel`` is entered — not per-task — so N slow banks
23
+ cost the deadline ONCE, not N times.
24
+
25
+ * **Completion is observed, not assumed.** Each slot records whether its
26
+ thread finished before we stopped waiting (``completed``), its return value
27
+ or exception, and its wall-clock ``elapsed_ms``. A slot still running at the
28
+ deadline is ``completed=False`` with ``elapsed_ms`` pinned to the deadline —
29
+ the caller maps that to ``timed_out`` for telemetry.
30
+
31
+ Stdlib-only (threading + time); no third-party deps, importable under the
32
+ plugin's ``python3 -m unittest`` harness.
33
+ """
34
+
35
+ import threading
36
+ import time
37
+
38
+
39
+ class SlotResult:
40
+ """Outcome of one labelled task run under the shared deadline.
41
+
42
+ Attributes:
43
+ label: the task's key in the ``tasks`` mapping.
44
+ value: the callable's return value, or None if it raised / did not
45
+ finish before the deadline.
46
+ error: the exception the callable raised, or None. A slot that hit
47
+ the deadline mid-flight has ``error=None`` and
48
+ ``completed=False`` (it never got to raise or return).
49
+ completed: True iff the worker thread finished (returned or raised)
50
+ before ``run_parallel`` stopped waiting on it. False means
51
+ the shared deadline elapsed first.
52
+ elapsed_ms: wall-clock ms the slot took. For a completed slot this is
53
+ its real duration; for a deadline-abandoned slot it is the
54
+ time from fan-out start to the deadline.
55
+ """
56
+
57
+ __slots__ = ("label", "value", "error", "completed", "elapsed_ms")
58
+
59
+ def __init__(self, label):
60
+ self.label = label
61
+ self.value = None
62
+ self.error = None
63
+ self.completed = False
64
+ self.elapsed_ms = None
65
+
66
+
67
+ def _runner(slot, fn, start):
68
+ """Worker body: run the task, capturing value/exception and duration.
69
+
70
+ Catches ``BaseException`` deliberately for slot isolation: a slot runs in
71
+ its own daemon thread, and a task failure of ANY kind — including
72
+ non-``Exception`` subclasses like ``KeyboardInterrupt`` /
73
+ ``SystemExit`` — must be captured into ``slot.error`` rather than escaping
74
+ the thread. An escaped exception would let the slot die silently and the
75
+ deadline join would then see no value AND no recorded error. (``socket.timeout``
76
+ is itself just an ``OSError`` subclass, i.e. an ordinary ``Exception``; the
77
+ broad catch is about never letting any slot failure leak out of the thread.)
78
+ """
79
+ try:
80
+ slot.value = fn()
81
+ except BaseException as e: # noqa: BLE001 - deliberate: isolate the slot
82
+ slot.error = e
83
+ finally:
84
+ slot.elapsed_ms = int((time.monotonic() - start) * 1000)
85
+
86
+
87
+ def run_parallel(tasks, deadline_seconds):
88
+ """Run ``{label: callable}`` concurrently under one shared deadline.
89
+
90
+ Args:
91
+ tasks: mapping of label -> zero-arg callable. Each is invoked once in
92
+ its own daemon thread. Insertion order is preserved in the returned
93
+ mapping (Python dicts are ordered) so the caller can emit per-slot
94
+ telemetry deterministically regardless of completion order.
95
+ deadline_seconds: total wall-clock budget for ALL tasks combined,
96
+ measured from entry. Non-positive values run every task with a
97
+ zero join (each slot is recorded as not-completed unless it was
98
+ already instantaneous) — a degenerate "give up immediately" mode.
99
+
100
+ Returns:
101
+ ``dict[label] -> SlotResult`` in the same order as ``tasks``. Never
102
+ raises for a task failure — a raising task surfaces via
103
+ ``SlotResult.error``.
104
+ """
105
+ start = time.monotonic()
106
+ pairs = [] # (SlotResult, Thread) in task order
107
+ for label, fn in tasks.items():
108
+ slot = SlotResult(label)
109
+ thread = threading.Thread(
110
+ target=_runner,
111
+ args=(slot, fn, start),
112
+ daemon=True,
113
+ name=f"recall-slot-{label}",
114
+ )
115
+ pairs.append((slot, thread))
116
+
117
+ for _slot, thread in pairs:
118
+ thread.start()
119
+
120
+ # Join each thread, but never wait past the SHARED deadline. Because the
121
+ # remaining budget is recomputed from ``start`` on every iteration, the
122
+ # total time spent here is bounded by ``deadline_seconds`` even with many
123
+ # slow slots — a fast early slot leaves more budget for later ones, and
124
+ # once the budget is exhausted every subsequent join is a non-blocking
125
+ # ``is_alive`` check.
126
+ for _slot, thread in pairs:
127
+ remaining = deadline_seconds - (time.monotonic() - start)
128
+ if remaining > 0:
129
+ thread.join(remaining)
130
+
131
+ # Classify each slot as completed (thread finished) or deadline-abandoned.
132
+ now = time.monotonic()
133
+ results = {}
134
+ for slot, thread in pairs:
135
+ if thread.is_alive():
136
+ slot.completed = False
137
+ if slot.elapsed_ms is None:
138
+ slot.elapsed_ms = int((now - start) * 1000)
139
+ else:
140
+ slot.completed = True
141
+ results[slot.label] = slot
142
+ return results
@@ -82,6 +82,37 @@ def write_state(name: str, data):
82
82
  pass
83
83
 
84
84
 
85
+ def remove_state(name: str) -> None:
86
+ """Delete a state file if it exists. Best-effort; never raises.
87
+
88
+ Name is sanitized through the same path-traversal guard as read/write, so
89
+ callers cannot escape the state directory.
90
+ """
91
+ try:
92
+ path = _state_file(name)
93
+ except ValueError:
94
+ return
95
+ try:
96
+ os.remove(path)
97
+ except OSError:
98
+ pass
99
+
100
+
101
+ def list_state_names(prefix: str = "") -> list:
102
+ """List state-file basenames in the state dir, optionally by prefix.
103
+
104
+ Returns [] on any error. Used to enumerate a family of cache files (e.g.
105
+ per-bank directive caches) for bulk invalidation.
106
+ """
107
+ try:
108
+ names = os.listdir(_state_dir())
109
+ except OSError:
110
+ return []
111
+ if prefix:
112
+ return [n for n in names if n.startswith(prefix)]
113
+ return names
114
+
115
+
85
116
  def get_turn_count(session_id: str) -> int:
86
117
  """Get the current turn count for a session."""
87
118
  turns = read_state("turns.json", {})