switchroom 0.18.3 → 0.18.7

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 (156) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +386 -128
  6. package/dist/host-control/main.js +4 -2
  7. package/dist/vault/approvals/kernel-server.js +3 -1
  8. package/dist/vault/broker/server.js +38 -8
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-release/SKILL.md +78 -0
  14. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
  17. package/telegram-plugin/dist/server.js +24 -0
  18. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  19. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  20. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  21. package/telegram-plugin/gateway/gateway.ts +1331 -151
  22. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  23. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  24. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  25. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  27. package/telegram-plugin/gateway/model-command.ts +212 -51
  28. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  29. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  31. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  32. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  33. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  34. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  36. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  37. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  38. package/telegram-plugin/model-label.ts +69 -0
  39. package/telegram-plugin/operator-events.ts +45 -0
  40. package/telegram-plugin/pending-work-progress.ts +42 -7
  41. package/telegram-plugin/permission-diff.ts +128 -0
  42. package/telegram-plugin/quota-bar-format.ts +360 -0
  43. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  44. package/telegram-plugin/registry/subagents.test.ts +90 -0
  45. package/telegram-plugin/session-tail.ts +28 -0
  46. package/telegram-plugin/silent-end.ts +49 -4
  47. package/telegram-plugin/subagent-watcher.ts +249 -46
  48. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  49. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  51. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  52. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  53. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  54. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  55. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  56. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  57. package/telegram-plugin/tests/model-command.test.ts +202 -42
  58. package/telegram-plugin/tests/model-label.test.ts +64 -0
  59. package/telegram-plugin/tests/operator-events.test.ts +17 -0
  60. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  61. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  62. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  63. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  64. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  65. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  66. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  68. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  69. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  70. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  71. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  72. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  73. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  74. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  75. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  76. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  77. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  78. package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
  79. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  80. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  81. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  82. package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
  83. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  84. package/telegram-plugin/tool-activity-summary.ts +22 -2
  85. package/telegram-plugin/typing-wrap.ts +72 -25
  86. package/telegram-plugin/worker-activity-feed.ts +229 -15
  87. package/profiles/default/CLAUDE.md +0 -116
  88. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  89. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -156,6 +156,73 @@ function locateAllowLine(
156
156
  return null;
157
157
  }
158
158
 
159
+ /** Internal: a located multi-line flow list (`allow:` then `[` on a
160
+ * subsequent line, one entry per line, closing `]` on its own line — the
161
+ * shape clerk's config uses, `switchroom.yaml:507-514`). */
162
+ interface MultilineFlowList {
163
+ /** 0-based index of the line containing the opening `[`. */
164
+ openIdx: number;
165
+ /** 0-based index of the line containing the matching closing `]`. */
166
+ closeIdx: number;
167
+ /** Indentation to use for a newly-inserted entry line. */
168
+ itemIndent: number;
169
+ /** 0-based index of the last existing entry line, or -1 if the list is
170
+ * empty (`[` immediately followed by `]`, possibly across lines). */
171
+ lastEntryIdx: number;
172
+ }
173
+
174
+ /**
175
+ * Scan forward from `scanStart` (the line right after `allow:` when its
176
+ * inline remainder is empty) for a flow list whose opening `[` is on its
177
+ * OWN subsequent line (issue #2973 — clerk's exact shape). Tracks bracket
178
+ * depth char-by-char so a matching `]` is found even several lines down.
179
+ * Returns null if the first non-blank/comment line doesn't open a flow
180
+ * list, or the list is unterminated within the block.
181
+ */
182
+ function locateMultilineFlowList(
183
+ lines: string[],
184
+ scanStart: number,
185
+ blockEnd: number,
186
+ ): MultilineFlowList | null {
187
+ let i = scanStart;
188
+ while (i < blockEnd && isBlankOrComment(lines[i]!)) i++;
189
+ if (i >= blockEnd) return null;
190
+ const firstLine = lines[i]!;
191
+ if (!firstLine.trim().startsWith("[")) return null;
192
+ const openIdx = i;
193
+
194
+ let depth = 0;
195
+ let closeIdx = -1;
196
+ for (let j = openIdx; j < blockEnd && closeIdx === -1; j++) {
197
+ const line = lines[j]!;
198
+ for (const ch of line) {
199
+ if (ch === "[") depth++;
200
+ else if (ch === "]") {
201
+ depth--;
202
+ if (depth === 0) {
203
+ closeIdx = j;
204
+ break;
205
+ }
206
+ }
207
+ }
208
+ }
209
+ if (closeIdx === -1) return null; // unterminated — caller falls back.
210
+
211
+ // Entry lines: strictly between the open and close bracket lines,
212
+ // one per line (the observed shape). Lines that carry entries AND a
213
+ // bracket on the same line (e.g. inline `[ all,`) aren't split out
214
+ // here — they're handled upstream by the single-line flow-list case.
215
+ let lastEntryIdx = -1;
216
+ for (let j = openIdx + 1; j < closeIdx; j++) {
217
+ if (isBlankOrComment(lines[j]!)) continue;
218
+ lastEntryIdx = j;
219
+ }
220
+ const itemIndent =
221
+ lastEntryIdx !== -1 ? indentOf(lines[lastEntryIdx]!) : indentOf(firstLine) + 2;
222
+
223
+ return { openIdx, closeIdx, itemIndent, lastEntryIdx };
224
+ }
225
+
159
226
  /**
160
227
  * Build a unified-diff hunk from a contiguous slice of the original
161
228
  * file. `lines` is the whole file split on `\n`. The hunk replaces the
@@ -282,6 +349,45 @@ export function synthesizeAllowRuleDiff(
282
349
  return wrapDiff(hunk);
283
350
  }
284
351
 
352
+ // Case (a2): multi-line flow list — `allow:` has an EMPTY inline
353
+ // remainder, but the `[` opens on a subsequent line and entries run one
354
+ // per line down to a closing `]` on its own line (clerk's exact shape,
355
+ // issue #2973). Must be checked before the block-sequence fallback below
356
+ // — that fallback would otherwise misread the `[`/entries/`]` lines as
357
+ // block-sequence context and insert a `- <rule>` line ABOVE the `[`,
358
+ // corrupting the YAML (`E_YAML_UNSAFE_CONSTRUCT`).
359
+ if (inlineTrimmed.length === 0) {
360
+ const flow = locateMultilineFlowList(lines, allow.idx + 1, block.agentBlockEnd);
361
+ if (flow) {
362
+ if (flow.lastEntryIdx === -1) {
363
+ // Empty multi-line list (`[` immediately followed by `]`) — insert
364
+ // the first entry right after the opening bracket line.
365
+ const insertAt = flow.openIdx + 1;
366
+ const added = [`${" ".repeat(flow.itemIndent)}${rule}`];
367
+ const hunk = buildHunk(lines, insertAt, insertAt, [], added);
368
+ return wrapDiff(hunk);
369
+ }
370
+ // Ensure the current last entry carries a trailing comma (YAML flow
371
+ // sequences accept it, and it's required here since we're adding a
372
+ // sibling entry on its own line), then append the new entry —
373
+ // replacing the single last-entry line with both lines.
374
+ const lastLine = lines[flow.lastEntryIdx]!;
375
+ const lastTrimmedEnd = lastLine.replace(/\s+$/, "");
376
+ const fixedLast = lastTrimmedEnd.endsWith(",")
377
+ ? lastTrimmedEnd
378
+ : `${lastTrimmedEnd},`;
379
+ const itemLine = `${" ".repeat(flow.itemIndent)}${rule}`;
380
+ const hunk = buildHunk(
381
+ lines,
382
+ flow.lastEntryIdx,
383
+ flow.lastEntryIdx + 1,
384
+ [lastLine],
385
+ [fixedLast, itemLine],
386
+ );
387
+ return wrapDiff(hunk);
388
+ }
389
+ }
390
+
285
391
  // Case (b): block sequence. Find the last `- ` entry at indent
286
392
  // allow.indent + 2 and insert a new entry after it.
287
393
  const itemIndent = allow.indent + 2;
@@ -348,6 +454,14 @@ export function extractAddedAllowRule(unifiedDiff: string): string | null {
348
454
  })
349
455
  .filter((x): x is string => x !== null);
350
456
  if (items.length === 1) return items[0]!;
457
+ // Multi-line flow-list first-entry insert (list was empty): a single
458
+ // bare `+` line with no `- ` prefix — the plain entry text itself,
459
+ // possibly comma-suffixed.
460
+ if (items.length === 0 && plus.length === 1) {
461
+ const bare = plus[0]!.trim();
462
+ const stripped = bare.endsWith(",") ? bare.slice(0, -1).trim() : bare;
463
+ return stripped.length > 0 ? stripped : null;
464
+ }
351
465
  return null;
352
466
  }
353
467
 
@@ -368,6 +482,20 @@ export function extractAddedAllowRule(unifiedDiff: string): string | null {
368
482
  return added;
369
483
  }
370
484
 
485
+ // Multi-line flow-list append: one `-` line (the previous last entry)
486
+ // replaced by TWO `+` lines — that same entry now comma-terminated,
487
+ // plus the new entry on its own line.
488
+ if (minus.length === 1 && plus.length === 2) {
489
+ const orig = minus[0]!.trim();
490
+ const first = plus[0]!.trim();
491
+ const second = plus[1]!.trim();
492
+ const origStripped = orig.endsWith(",") ? orig.slice(0, -1).trim() : orig;
493
+ const firstStripped = first.endsWith(",") ? first.slice(0, -1).trim() : first;
494
+ if (firstStripped !== origStripped) return null;
495
+ const added = second.endsWith(",") ? second.slice(0, -1).trim() : second;
496
+ return added.length > 0 ? added : null;
497
+ }
498
+
371
499
  return null;
372
500
  }
373
501
 
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Quota-bar block — a compact per-account ASCII-bar rendering of the 5-hour
3
+ * and 7-day utilization windows, for a live-refreshing Telegram card.
4
+ *
5
+ * JTBD: "at a glance, how much headroom does each account have, and how far
6
+ * through its reset window are we" — denser than the `/auth` table (Format 2,
7
+ * `auth-snapshot-format.ts`), meant for a small always-visible strip rather
8
+ * than a full snapshot.
9
+ *
10
+ * Locked output shape (operator-confirmed via live Telegram iteration —
11
+ * do not reformat without re-confirming):
12
+ *
13
+ * ```
14
+ * - **you@example.com** (active)
15
+ * - 🟢 5h `[┃░░░░░░░░░] 0% / 4h20m left`
16
+ * - 🟡 7d `[████┃█░░░░] 47% / 3d1h left`
17
+ * - **alice@example.com** (exhausted)
18
+ * - 🟢 5h `[┃░░░░░░░░░] 0% / resets now`
19
+ * - 🔴 7d `[██████┃███] 100% / 2d16h left`
20
+ * ```
21
+ *
22
+ * Rules:
23
+ * 1. GFM `- ` bullet marker on EVERY line, including the account title
24
+ * line — a tight one-item-per-line list, no blank lines between rows
25
+ * (a no-bullet hard-break variant was tried live and rejected as
26
+ * "worse" — bullets are the final mechanism).
27
+ * 2. Title line: `- **<email>** (<status>)`, status derived from data
28
+ * (`active` | `exhausted` | `idle` — see `accountStatus`).
29
+ * 3. One row per window (5h, 7d): `- <dot> <window> \`[<bar>] <pct>% /
30
+ * <time-left>\``.
31
+ * 4. Bar is 10 cells of `█` (filled, proportional to utilization) /
32
+ * `░` (empty), with a single `┃` "pace" tick marking how far through
33
+ * the reset window we currently are. The tick ALWAYS renders at its
34
+ * computed position, overriding whatever fill character is there —
35
+ * even at 100% utilization — since the pace signal must stay visible
36
+ * (operator-confirmed; see `buildBar`).
37
+ */
38
+
39
+ import type { QuotaUtilization } from './quota-check.js';
40
+ import { refillNormalizedUtils, isProbeThin } from '../src/auth/quota.js';
41
+ import type { AccountState, ListStateData } from '../src/auth/broker/client.js';
42
+ import { reviveLastQuota, recommendation, type AccountSnapshot } from './auth-snapshot-format.js';
43
+ import { escapeMarkdown } from './card-format.js';
44
+ import { maskEmail } from './demo-mask.js';
45
+
46
+ // ── dot thresholds ───────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Per-row status dot, purely a function of THAT window's own utilization
50
+ * percentage (not the account's overall exhausted flag — an exhausted
51
+ * account can still show a green 5h row if that window is fresh).
52
+ *
53
+ * - < 50% → 🟢 healthy
54
+ * - 50-89% → 🟡 getting close
55
+ * - >= 90% → 🔴 at/near the wall
56
+ */
57
+ export function pickDot(pct: number): '🟢' | '🟡' | '🔴' {
58
+ const clamped = Math.max(0, Math.min(100, pct));
59
+ if (clamped >= 90) return '🔴';
60
+ if (clamped >= 50) return '🟡';
61
+ return '🟢';
62
+ }
63
+
64
+ // ── time-left formatting ─────────────────────────────────────────────
65
+
66
+ /**
67
+ * Compact time-left string for the quota-bar row: `4h20m left`,
68
+ * `3d1h left`, `45m left`, or `resets now` when the reset has already
69
+ * passed (or is unknown — no reset timestamp to count down to).
70
+ *
71
+ * Deliberately more compact than `formatRelative` in
72
+ * `auth-snapshot-format.ts` (no space between the number and unit) to
73
+ * keep the fixed-width code-span row from wrapping on a phone screen.
74
+ */
75
+ export function formatTimeLeft(target: Date | null, now: Date = new Date()): string {
76
+ if (!target) return 'resets now';
77
+ const deltaMs = target.getTime() - now.getTime();
78
+ // A malformed reset timestamp (invalid Date → NaN delta) must not leak a
79
+ // `"NaNm left"` string; treat a non-finite delta as "resets now".
80
+ if (!Number.isFinite(deltaMs) || deltaMs <= 0) return 'resets now';
81
+ const totalMin = Math.round(deltaMs / 60_000);
82
+ if (totalMin < 60) return `${totalMin}m left`;
83
+ const totalHours = Math.floor(totalMin / 60);
84
+ const m = totalMin % 60;
85
+ if (totalHours < 24) return `${totalHours}h${m}m left`;
86
+ const d = Math.floor(totalHours / 24);
87
+ const h = totalHours % 24;
88
+ return h > 0 ? `${d}d${h}h left` : `${d}d left`;
89
+ }
90
+
91
+ // ── pace (elapsed-through-window) fraction ───────────────────────────
92
+
93
+ const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
94
+ const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
95
+
96
+ /**
97
+ * How far through the reset window we currently are, as a 0..1 fraction,
98
+ * derived from the time LEFT to reset and the window's nominal duration
99
+ * (we're never told the window's start time, only its end/reset — so this
100
+ * is an approximation, not an exact elapsed-time read).
101
+ *
102
+ * - no reset timestamp, or reset already passed → 0 (treat as a freshly
103
+ * started window rather than claiming we're at the very end of one we
104
+ * have no data for)
105
+ * - otherwise → `1 - timeLeftMs / windowDurationMs`, clamped to [0, 1]
106
+ */
107
+ export function elapsedFraction(
108
+ target: Date | null,
109
+ windowDurationMs: number,
110
+ now: Date = new Date(),
111
+ ): number {
112
+ if (!target) return 0;
113
+ const timeLeftMs = target.getTime() - now.getTime();
114
+ // A malformed reset timestamp (invalid Date → NaN delta) must not corrupt
115
+ // the tick placement; treat a non-finite delta as a freshly-started window.
116
+ if (!Number.isFinite(timeLeftMs) || timeLeftMs <= 0) return 0;
117
+ return Math.max(0, Math.min(1, 1 - timeLeftMs / windowDurationMs));
118
+ }
119
+
120
+ // ── bar rendering ─────────────────────────────────────────────────────
121
+
122
+ const BAR_WIDTH = 10;
123
+
124
+ /**
125
+ * Build the 10-cell `[█…░…]` bar body (no surrounding brackets — callers
126
+ * add those) for one window row.
127
+ *
128
+ * - `fillCount = round(pct / 100 * 10)` cells are `█` from the left.
129
+ * - a single `┃` pace tick is placed at
130
+ * `round(elapsedFrac * (width - 1))`, and ALWAYS overwrites whatever
131
+ * cell is there — including a filled `█` cell, even at 100% utilization
132
+ * (rule 4 above; the pace signal must always be visible).
133
+ */
134
+ export function buildBar(pct: number, elapsedFrac: number): string {
135
+ const clampedPct = Math.max(0, Math.min(100, pct));
136
+ const fillCount = Math.round((clampedPct / 100) * BAR_WIDTH);
137
+ const cells: string[] = new Array(BAR_WIDTH).fill('░');
138
+ for (let i = 0; i < fillCount; i++) cells[i] = '█';
139
+ const tickIndex = Math.max(
140
+ 0,
141
+ Math.min(BAR_WIDTH - 1, Math.round(elapsedFrac * (BAR_WIDTH - 1))),
142
+ );
143
+ cells[tickIndex] = '┃';
144
+ return cells.join('');
145
+ }
146
+
147
+ // ── account status (title-line suffix) ───────────────────────────────
148
+
149
+ export type QuotaBarAccountStatus = 'active' | 'exhausted' | 'idle';
150
+
151
+ /**
152
+ * Title-line status word. `active` wins over `exhausted` (the fleet's
153
+ * pinned account is reported as active even if the broker also flags it
154
+ * exhausted — matches the locked example where the operator wants to know
155
+ * WHICH account is live first, and its health second, from the two window
156
+ * rows underneath). Otherwise: `exhausted` if the broker's own flag says
157
+ * so, else `idle` (present, healthy, just not the current pick).
158
+ */
159
+ export function accountStatus(isActive: boolean, exhausted: boolean): QuotaBarAccountStatus {
160
+ if (isActive) return 'active';
161
+ if (exhausted) return 'exhausted';
162
+ return 'idle';
163
+ }
164
+
165
+ // ── row / block assembly ─────────────────────────────────────────────
166
+
167
+ /** One `- <dot> <window> \`[<bar>] <pct>% / <time-left>\`` line. */
168
+ export function formatWindowRow(
169
+ window: '5h' | '7d',
170
+ pct: number,
171
+ resetAt: Date | null,
172
+ now: Date = new Date(),
173
+ ): string {
174
+ const windowMs = window === '5h' ? FIVE_HOUR_MS : SEVEN_DAY_MS;
175
+ const dot = pickDot(pct);
176
+ const bar = buildBar(pct, elapsedFraction(resetAt, windowMs, now));
177
+ const pctStr = `${Math.round(Math.max(0, Math.min(100, pct)))}%`;
178
+ const timeLeft = formatTimeLeft(resetAt, now);
179
+ return `- ${dot} ${window} \`[${bar}] ${pctStr} / ${timeLeft}\``;
180
+ }
181
+
182
+ /** One account's title line + its two window rows (5h then 7d). */
183
+ export function renderQuotaBarAccount(
184
+ label: string,
185
+ isActive: boolean,
186
+ exhausted: boolean,
187
+ quota: QuotaUtilization | null,
188
+ now: Date = new Date(),
189
+ demo = false,
190
+ ): string[] {
191
+ const status = accountStatus(isActive, exhausted);
192
+ // Title line wraps `label` in GFM `**bold**`, NOT a code span — so this
193
+ // needs `escapeMarkdown` (backslash-escapes *, _, [, ], etc.), not
194
+ // `codeSpanSafe` (which only defuses backticks and is only correct
195
+ // inside literal `code spans` — see format.ts). Using codeSpanSafe here
196
+ // was a bug: a label containing e.g. `**` or `[x](url)` would break the
197
+ // bold run or inject a markdown link into the card.
198
+ const displayLabel = demo ? maskEmail(label) : label;
199
+ const lines: string[] = [`- **${escapeMarkdown(displayLabel)}** (${status})`];
200
+ if (!quota || isProbeThin(quota)) {
201
+ // Data-quality gap. A failed / thin probe carries NO real utilization
202
+ // signal, so it must NOT render as a healthy 🟢 0% bar — that's
203
+ // indistinguishable from a fresh account with full headroom, which was
204
+ // the #2959 review's blocking bug. Emit a distinct ⚠️ warning row per
205
+ // window with a "no data" label (thin vs failed) instead of a fake bar,
206
+ // keeping two rows so the block stays shape-stable.
207
+ const reason = !quota ? 'no data — probe failed' : 'no data — thin probe';
208
+ lines.push(`- ⚠️ 5h \`${reason}\``);
209
+ lines.push(`- ⚠️ 7d \`${reason}\``);
210
+ return lines;
211
+ }
212
+ const norm = refillNormalizedUtils(quota, now);
213
+ lines.push(formatWindowRow('5h', norm.fiveHourUtilizationPct, quota.fiveHourResetAt, now));
214
+ lines.push(formatWindowRow('7d', norm.sevenDayUtilizationPct, quota.sevenDayResetAt, now));
215
+ return lines;
216
+ }
217
+
218
+ export interface QuotaBarRenderOpts {
219
+ now?: Date;
220
+ /** Demo mode (the `/usage demo` suffix) — masks account-email labels. */
221
+ demo?: boolean;
222
+ }
223
+
224
+ /**
225
+ * Relative-age stamp for the freshness footer: "0s ago", "3m ago". Measured
226
+ * against `now` so tests with an injected clock get deterministic output.
227
+ * (Mirrors `formatAgeStamp` in auth-snapshot-format.ts, which is not exported.)
228
+ */
229
+ function formatAgeStamp(atMs: number, now: Date = new Date()): string {
230
+ const ageSec = Math.max(0, Math.round((now.getTime() - atMs) / 1000));
231
+ return ageSec < 60 ? `${ageSec}s ago` : `${Math.round(ageSec / 60)}m ago`;
232
+ }
233
+
234
+ export interface UsageCardRenderOpts extends QuotaBarRenderOpts {
235
+ /**
236
+ * The probe-on-open attempted a live refresh but it FAILED (or hit the TTL),
237
+ * so the card is served off the durable cache. When set, the footer shows an
238
+ * explicit "⚠ cached Nm ago" warning (age from this `capturedAt`) instead of
239
+ * a false live stamp. Takes precedence over `liveProbedAtMs`. Restores the
240
+ * signal `renderAuthSnapshotFormat2` carried before /usage went bar-only.
241
+ */
242
+ staleCachedAtMs?: number;
243
+ /** Timestamp of the most recent live probe; renders "Live · refreshed Nm
244
+ * ago" when no stale-cache marker applies. Omit to render a bare "Live".
245
+ * Do NOT set this when the probe failed and no live data was obtained —
246
+ * set `probeFailed: true` instead so the footer doesn't claim "Live". */
247
+ liveProbedAtMs?: number;
248
+ /**
249
+ * True when the live probe returned no usable data for ANY account (the
250
+ * probe threw / timed out / returned zero rows, AND nothing was served
251
+ * from cache either). The footer then renders an explicit `⚠ probe failed
252
+ * — no live data` instead of a false "Live" stamp. Subscription-honesty:
253
+ * the /usage card exists to tell the operator the truth about quota state,
254
+ * so a footer that says "Live" while every row shows "⚠️ no data" is the
255
+ * exact lie this flag closes. Takes precedence over `liveProbedAtMs`;
256
+ * `staleCachedAtMs` (cache-served data) still takes precedence over this
257
+ * because in that case there IS real data, just stale.
258
+ */
259
+ probeFailed?: boolean;
260
+ }
261
+
262
+ /**
263
+ * Render the full multi-account quota-bar block from `AccountSnapshot[]`
264
+ * (the same shape `auth-snapshot-format.ts` builds — reuse
265
+ * `buildSnapshotsFromCachedState` / `buildSnapshotsFromState` /
266
+ * `quotaBarSnapshotsFromListState` to get one).
267
+ *
268
+ * Additionally needs each account's `exhausted` flag (not carried on
269
+ * `AccountSnapshot`), passed as a parallel lookup keyed by label.
270
+ */
271
+ export function renderQuotaBarBlock(
272
+ snapshots: AccountSnapshot[],
273
+ exhaustedByLabel: ReadonlyMap<string, boolean>,
274
+ opts: QuotaBarRenderOpts = {},
275
+ ): string {
276
+ const now = opts.now ?? new Date();
277
+ const demo = opts.demo ?? false;
278
+ const lines: string[] = [];
279
+ for (const snap of snapshots) {
280
+ const exhausted = exhaustedByLabel.get(snap.label) ?? false;
281
+ lines.push(
282
+ ...renderQuotaBarAccount(snap.label, snap.isActive, exhausted, snap.quota, now, demo),
283
+ );
284
+ }
285
+ return lines.join('\n');
286
+ }
287
+
288
+ /**
289
+ * Convenience one-shot: build the quota-bar block directly from the shape
290
+ * `switchroom auth list --json` prints (`ListStateData`), using each
291
+ * account's cached `last_quota` (no live probe — same cache path
292
+ * `buildSnapshotsFromCachedState` uses). This is what the CLI script
293
+ * entrypoint (`scripts/print-quota-bar.ts`) calls.
294
+ */
295
+ export function renderQuotaBarBlockFromListState(
296
+ state: ListStateData,
297
+ opts: QuotaBarRenderOpts = {},
298
+ ): string {
299
+ const now = opts.now ?? new Date();
300
+ const exhaustedByLabel = new Map<string, boolean>(
301
+ state.accounts.map((a: AccountState) => [a.label, a.exhausted]),
302
+ );
303
+ const snapshots: AccountSnapshot[] = state.accounts.map((acc: AccountState) => ({
304
+ label: acc.label,
305
+ isActive: acc.label === state.active,
306
+ quota: reviveLastQuota(acc.last_quota ?? null),
307
+ quotaError: acc.last_quota ? undefined : 'no cached quota (no probe since broker start)',
308
+ expiresAtMs: acc.expiresAt,
309
+ capturedAtMs: acc.last_quota?.capturedAt,
310
+ }));
311
+ return renderQuotaBarBlock(snapshots, exhaustedByLabel, { now });
312
+ }
313
+
314
+ /**
315
+ * The live `/usage` card — the compact quota-bar block, and ONLY the bar
316
+ * block. `/usage` used to append the full Format 2 health-grouped table
317
+ * (`renderAuthSnapshotFormat2`) underneath; the operator asked for the bar
318
+ * card alone (2026-07-10) since the two views were redundant and the table
319
+ * doubled the message length. Every field the table carried per-account
320
+ * (5h/7d utilization + reset) has an equivalent in the bar rows — the `pct%
321
+ * / <time-left>` segment of each window row is the reset info, just
322
+ * relative instead of absolute. `opts.now` is shared with the bar block;
323
+ * `opts.demo` masks account-email labels in the title line the same way the
324
+ * table used to.
325
+ *
326
+ * Footer: after the bar rows, two footer lines are appended —
327
+ * 1. the synthesized cross-account "switch now" verdict (`recommendation`,
328
+ * shared with the /auth snapshot) — the single most actionable line,
329
+ * dropped when /usage went bar-only and restored here (#2959 review).
330
+ * 2. a freshness marker — `⚠ cached Nm ago` when the data was served stale
331
+ * from cache (`staleCachedAtMs`), else `Live · refreshed Nm ago` /
332
+ * `Live` — so a cache-served card is never mistaken for a live one.
333
+ */
334
+ export function renderUsageCard(
335
+ snapshots: AccountSnapshot[],
336
+ exhaustedByLabel: ReadonlyMap<string, boolean>,
337
+ opts: UsageCardRenderOpts = {},
338
+ ): string {
339
+ const now = opts.now ?? new Date();
340
+ const demo = opts.demo ?? false;
341
+ const bar = renderQuotaBarBlock(snapshots, exhaustedByLabel, { now, demo });
342
+ const lines = [bar];
343
+ // Actionable cross-account verdict — restored from renderAuthSnapshotFormat2.
344
+ lines.push(`_${recommendation(snapshots, now, demo)}_`);
345
+ // Freshness signal: stale-cache warning takes precedence over a live stamp,
346
+ // which takes precedence over an explicit probe-failed marker (no live data
347
+ // AND no cache — the card is showing "⚠️ no data" rows, so "Live" would be
348
+ // a lie). The probeFailed branch is the honesty backstop: without it, a
349
+ // total probe failure rendered a bare `_Live_` footer next to no-data rows.
350
+ if (opts.staleCachedAtMs != null) {
351
+ lines.push(`_⚠ cached ${formatAgeStamp(opts.staleCachedAtMs, now)}_`);
352
+ } else if (opts.liveProbedAtMs != null) {
353
+ lines.push(`_Live · refreshed ${formatAgeStamp(opts.liveProbedAtMs, now)}_`);
354
+ } else if (opts.probeFailed) {
355
+ lines.push('_⚠ probe failed — no live data_');
356
+ } else {
357
+ lines.push('_Live_');
358
+ }
359
+ return lines.join('\n');
360
+ }
@@ -116,6 +116,16 @@ export interface Subagent {
116
116
  * chain instead.
117
117
  */
118
118
  parent_agent_id: string | null
119
+ /**
120
+ * Live model the sub-agent is running, as a raw resolved model id (e.g.
121
+ * `claude-opus-4-8`, `sr-glm-5`). Seeded at dispatch from the Agent tool's
122
+ * `tool_input.model` (first-paint fallback, written by the pretool hook), then
123
+ * updated on change by the watcher from the worker's own transcript
124
+ * `message.model` (transcript wins). Persisted so boot-replay / handback cards
125
+ * can render the model even with no live watcher entry. NULL when never
126
+ * observed — the card omits the model rather than guessing from config.
127
+ */
128
+ model: string | null
119
129
  }
120
130
 
121
131
  export interface RecordSubagentStartArgs {
@@ -207,7 +217,8 @@ const SUBAGENTS_SCHEMA_SQL = `
207
217
  status TEXT NOT NULL,
208
218
  result_summary TEXT,
209
219
  jsonl_agent_id TEXT,
210
- parent_agent_id TEXT
220
+ parent_agent_id TEXT,
221
+ model TEXT
211
222
  );
212
223
  CREATE INDEX IF NOT EXISTS subagents_turn ON subagents(parent_turn_key);
213
224
  CREATE INDEX IF NOT EXISTS subagents_status ON subagents(status);
@@ -240,6 +251,12 @@ export function applySubagentsSchema(db: SqliteDatabase): void {
240
251
  if (!hasParentAgentId) {
241
252
  db.exec('ALTER TABLE subagents ADD COLUMN parent_agent_id TEXT')
242
253
  }
254
+ // Idempotent migration for DBs created before the live-model column existed
255
+ // (progress-card live model — see the Subagent.model doc).
256
+ const hasModel = cols.some((c) => c.name === 'model')
257
+ if (!hasModel) {
258
+ db.exec('ALTER TABLE subagents ADD COLUMN model TEXT')
259
+ }
243
260
  // Always (re-)apply the index. `IF NOT EXISTS` makes this a no-op when it
244
261
  // already exists. Splitting it from SUBAGENTS_SCHEMA_SQL is what fixes the
245
262
  // pre-existing-table failure mode — by the time we reach this line, the
@@ -316,6 +333,7 @@ interface RawSubagentRow {
316
333
  result_summary: string | null
317
334
  jsonl_agent_id: string | null
318
335
  parent_agent_id?: string | null
336
+ model?: string | null
319
337
  }
320
338
 
321
339
  function mapSubagentRow(row: RawSubagentRow): Subagent {
@@ -333,6 +351,7 @@ function mapSubagentRow(row: RawSubagentRow): Subagent {
333
351
  result_summary: row.result_summary,
334
352
  jsonl_agent_id: row.jsonl_agent_id,
335
353
  parent_agent_id: row.parent_agent_id ?? null,
354
+ model: row.model ?? null,
336
355
  }
337
356
  }
338
357
 
@@ -566,6 +585,29 @@ export function bumpSubagentActivity(db: SqliteDatabase, args: BumpSubagentActiv
566
585
  `).run(args.ts, args.id)
567
586
  }
568
587
 
588
+ export interface RecordSubagentModelArgs {
589
+ id: string
590
+ /** Raw resolved model id (e.g. `claude-opus-4-8`). Callers pass only
591
+ * non-sentinel, non-empty values — the watcher filters at the projection. */
592
+ model: string
593
+ }
594
+
595
+ /**
596
+ * Persist the live model on a subagent row (update-on-change, like
597
+ * last_activity_at). Written by the watcher whenever it observes a NEW model on
598
+ * the worker's transcript, so a later boot-replay / handback card renders the
599
+ * model even with no live in-memory entry. Unconditional UPDATE by `id`; no-ops
600
+ * gracefully if the row is not found. Idempotent — the caller only calls it on
601
+ * an actual change, but a repeat write is harmless.
602
+ */
603
+ export function recordSubagentModel(db: SqliteDatabase, args: RecordSubagentModelArgs): void {
604
+ db.prepare(`
605
+ UPDATE subagents
606
+ SET model = ?
607
+ WHERE id = ?
608
+ `).run(args.model, args.id)
609
+ }
610
+
569
611
  /**
570
612
  * Return all subagents, optionally filtered by status, ordered by
571
613
  * started_at DESC. Intended for the REST API endpoint
@@ -590,6 +632,43 @@ export function listSubagents(
590
632
  return rows.map(mapSubagentRow)
591
633
  }
592
634
 
635
+ /**
636
+ * List the sub-agents of a given parent turn that had NOT reached a terminal
637
+ * state (`completed` / `failed`) — i.e. `running` or `stalled`. Ordered by
638
+ * `started_at ASC` (dispatch order) so the resume inbound lists them the way
639
+ * they were spawned.
640
+ *
641
+ * This is the boot-resume accessor: when a turn is interrupted mid-flight, its
642
+ * in-flight workers were killed with it, so the resumed session needs to know
643
+ * which ones didn't finish to re-dispatch them. Deliberately includes
644
+ * `stalled` alongside `running` — a row the reaper flipped to `stalled` (1h
645
+ * TTL, JSONL linkage missing) still never completed, so it belongs in the
646
+ * "these died, re-dispatch if still needed" list. Only genuine terminals are
647
+ * excluded. This also makes the read robust to boot ordering: even if the
648
+ * watcher's reaper transitions a row to `stalled` before this runs, the row is
649
+ * still surfaced rather than dropped.
650
+ *
651
+ * Known gap: rows with NULL parent_turn_key are silently omitted — the
652
+ * INSERT-time stamp can be missing (no turn-active marker at dispatch, e.g.
653
+ * nested workers) and the watcher's async backfill may not have run before the
654
+ * killing restart. Those workers won't appear in the resume inbound; the
655
+ * wake-audit orphan-check (switchroom-runtime skill) is the backstop.
656
+ */
657
+ export function listNonTerminalSubagentsForTurn(
658
+ db: SqliteDatabase,
659
+ parentTurnKey: string,
660
+ ): Subagent[] {
661
+ const rows = db
662
+ .prepare(`
663
+ SELECT * FROM subagents
664
+ WHERE parent_turn_key = ?
665
+ AND status NOT IN ('completed', 'failed')
666
+ ORDER BY started_at ASC
667
+ `)
668
+ .all(parentTurnKey) as RawSubagentRow[]
669
+ return rows.map(mapSubagentRow)
670
+ }
671
+
593
672
  /**
594
673
  * Retrieve a single subagent row by id. Returns null if not found.
595
674
  * Useful in tests and for callers that need to inspect current state.