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
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Pure, injectable core for expiring an agent-initiated approval card
3
+ * (vault_request_access / vault_request_save / request_secret /
4
+ * mental_model_propose) whose TTL elapsed with no operator tap.
5
+ *
6
+ * Extracted from gateway.ts so the ORDERING and FAULT-ISOLATION contract is
7
+ * unit-testable behaviorally (pending-card-expiry.test.ts), not just pinned by
8
+ * source-text regex:
9
+ *
10
+ * 1. remove() FIRST — the in-memory map entry + durable store record are
11
+ * dropped before anything else, so the expiry is single-shot: a second
12
+ * reaper tick (or a concurrent lazy sweep) can never double-fire the
13
+ * synthetic wake for the same card.
14
+ * 2. editCard() — best-effort ⌛ card strip; a Telegram failure never blocks
15
+ * the wake.
16
+ * 3. recordMiss() — the missed-approvals re-offer entry is written BEFORE
17
+ * the deliver attempt, so a throwing IPC socket can't lose the re-offer:
18
+ * even if the wake never lands, the operator's return re-surfaces it.
19
+ * 4. deliver() — the timeout synthetic, wrapped in try/catch. A half-dead
20
+ * client socket that throws on write is contained here: the error is
21
+ * logged, `delivered: false` is returned, and the caller's sweep loop
22
+ * continues to the remaining entries/families.
23
+ *
24
+ * Every step is individually guarded — one failing dependency never skips the
25
+ * later steps or escapes to the caller (the reaper's setInterval callback,
26
+ * where an escaped throw would take the whole gateway down via
27
+ * uncaughtException).
28
+ */
29
+
30
+ import type { InboundMessage } from './ipc-protocol.js'
31
+
32
+ export interface ExpireCardDeps {
33
+ /** Drop the in-memory map entry AND the durable store record. Runs first. */
34
+ remove: () => void
35
+ /** Best-effort ⌛ card edit (strip keyboard). Failures are swallowed. */
36
+ editCard: () => void
37
+ /** Build the timeout synthetic inbound for this card's family. */
38
+ buildInbound: () => InboundMessage
39
+ /** Inject the synthetic (turn-safe gate). May throw on a dead socket. */
40
+ deliver: (inbound: InboundMessage) => boolean
41
+ /** Record the missed-approvals re-offer entry. Runs BEFORE deliver. */
42
+ recordMiss: () => void
43
+ /** Error sink (stderr in production). */
44
+ log: (msg: string) => void
45
+ }
46
+
47
+ export interface ExpireCardResult {
48
+ delivered: boolean
49
+ }
50
+
51
+ export function expirePendingCard(deps: ExpireCardDeps): ExpireCardResult {
52
+ // 1. Single-shot: entry gone before any fallible side effect.
53
+ deps.remove()
54
+ // 2. Card strip is cosmetic — never let it block the wake.
55
+ try {
56
+ deps.editCard()
57
+ } catch (err) {
58
+ deps.log(`card-expiry: card edit failed: ${(err as Error).message}`)
59
+ }
60
+ // 3. Re-offer entry BEFORE the deliver attempt so a throwing deliver can't
61
+ // lose it (the operator's return still re-surfaces the missed card).
62
+ try {
63
+ deps.recordMiss()
64
+ } catch (err) {
65
+ deps.log(`card-expiry: missed-approval record failed: ${(err as Error).message}`)
66
+ }
67
+ // 4. The wake itself — contained so one dead socket doesn't skip the
68
+ // remaining entries in the caller's sweep loop.
69
+ let delivered = false
70
+ try {
71
+ delivered = deps.deliver(deps.buildInbound())
72
+ } catch (err) {
73
+ deps.log(`card-expiry: timeout synthetic delivery failed: ${(err as Error).message}`)
74
+ }
75
+ return { delivered }
76
+ }
77
+
78
+ /**
79
+ * Sweep one pending-card map: expire every entry past its TTL via `expire`,
80
+ * guarding each entry so one throwing expiry can't skip the rest of the map
81
+ * (or, at the caller, the remaining families).
82
+ */
83
+ export function sweepExpiredEntries<T>(
84
+ map: Map<string, T>,
85
+ isExpired: (value: T, now: number) => boolean,
86
+ expire: (stageId: string, value: T, now: number) => void,
87
+ now: number,
88
+ log: (msg: string) => void,
89
+ ): void {
90
+ for (const [k, v] of map) {
91
+ if (!isExpired(v, now)) continue
92
+ try {
93
+ expire(k, v, now)
94
+ } catch (err) {
95
+ log(`card-expiry: expire threw for stage=${k}: ${(err as Error).message}`)
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Persistence store for in-flight AGENT-INITIATED approval cards that park
3
+ * the requesting agent until the operator taps: `vault_request_access`,
4
+ * `vault_request_save`, `request_secret`, and `mental_model_propose`.
5
+ *
6
+ * Problem (mirror of the permission-card `permission-card-store.ts` bug): the
7
+ * gateway holds each staged request in an in-memory Map only
8
+ * (`pendingVaultRequestAccesses`, `pendingVaultRequestSaves`,
9
+ * `pendingSecretRequests`, `pendingMentalModelProposes`). When the gateway
10
+ * restarts (crash OR container restart), every entry is lost. The card in
11
+ * Telegram keeps its live inline keyboard, so when the operator taps it later
12
+ * they hit the "Card expired — ask the agent to re-request" tombstone: no
13
+ * grant, no injected inbound, and the agent that ended its turn to WAIT on the
14
+ * card stays parked forever.
15
+ *
16
+ * Fix: persist the METADATA for every posted card to a JSON file in STATE_DIR.
17
+ * On each resolution (tap / TTL-expire) remove the entry. On gateway boot,
18
+ * restore surviving entries into the in-memory maps so a post-restart tap on a
19
+ * still-valid (unexpired) card works exactly like a pre-restart tap.
20
+ *
21
+ * SECRETS HYGIENE — LOAD-BEARING: this file is written to disk unencrypted.
22
+ * It MUST NOT carry any secret VALUE. `vault_request_save` stages a secret
23
+ * value in gateway memory; only its key/metadata is persisted here (never the
24
+ * value), so a restored save card is re-associated with a tap but cannot
25
+ * complete the write — the caller degrades gracefully (tells the agent the
26
+ * value was lost to a restart). `request_secret` never holds a value at
27
+ * staging time (the value arrives after the tap), so its metadata is safe to
28
+ * persist. The "no value field" invariant is enforced by the record types
29
+ * below (PersistedVaultSaveCard has no `value` member, so a callsite can't
30
+ * compile one in) and pinned by the on-disk sentinel test in
31
+ * pending-card-store.test.ts.
32
+ *
33
+ * File format: JSON array of PersistedApprovalCard objects. Written
34
+ * synchronously (mode 0o600) to avoid interleaving on concurrent card posts;
35
+ * production rate is a handful of cards, so the file stays tiny.
36
+ */
37
+
38
+ import { readFileSync, writeFileSync, unlinkSync, chmodSync } from 'node:fs'
39
+ import { join } from 'node:path'
40
+
41
+ /** The four agent-initiated approval-card families we persist. */
42
+ export type ApprovalCardFamily =
43
+ | 'vault_request_access'
44
+ | 'vault_request_save'
45
+ | 'request_secret'
46
+ | 'mental_model_propose'
47
+
48
+ interface BasePersistedCard {
49
+ family: ApprovalCardFamily
50
+ /** The staging id embedded in the card's callback_data (dedup + re-associate). */
51
+ stageId: string
52
+ /** Agent that requested (process.env.SWITCHROOM_AGENT_NAME). */
53
+ agent: string
54
+ /** Chat the card was rendered into; edited on tap / expiry. */
55
+ chatId: string
56
+ /** Card message id (filled after the card is sent). */
57
+ cardMessageId?: number
58
+ /** Forum topic the agent was working in, if any. */
59
+ threadId?: number
60
+ /** Unix-ms staging timestamp — the TTL clock. Preserved across restart. */
61
+ stagedAt: number
62
+ }
63
+
64
+ export interface PersistedVaultAccessCard extends BasePersistedCard {
65
+ family: 'vault_request_access'
66
+ key: string
67
+ scope: 'read' | 'write'
68
+ reason?: string
69
+ ttlSeconds: number
70
+ }
71
+
72
+ export interface PersistedVaultSaveCard extends BasePersistedCard {
73
+ family: 'vault_request_save'
74
+ key: string
75
+ kind: 'string' | 'binary'
76
+ why?: string
77
+ // NOTE: NO `value` — the staged secret never touches disk.
78
+ }
79
+
80
+ export interface PersistedSecretRequestCard extends BasePersistedCard {
81
+ family: 'request_secret'
82
+ key: string
83
+ reason?: string
84
+ }
85
+
86
+ export interface PersistedMentalModelCard extends BasePersistedCard {
87
+ family: 'mental_model_propose'
88
+ spec: {
89
+ name: string
90
+ source_query: string
91
+ refresh_after_consolidation?: boolean
92
+ max_tokens?: number
93
+ }
94
+ reason?: string
95
+ }
96
+
97
+ export type PersistedApprovalCard =
98
+ | PersistedVaultAccessCard
99
+ | PersistedVaultSaveCard
100
+ | PersistedSecretRequestCard
101
+ | PersistedMentalModelCard
102
+
103
+ export interface PendingCardStore {
104
+ /** Record a newly-posted card. Idempotent on stageId (replaces in place). */
105
+ add(entry: PersistedApprovalCard): void
106
+ /** Remove the entry for this stageId (resolved — tap or TTL). */
107
+ remove(stageId: string): void
108
+ /** All persisted entries (for boot-time restore). */
109
+ loadAll(): PersistedApprovalCard[]
110
+ /** Delete the backing file entirely. */
111
+ clear(): void
112
+ }
113
+
114
+ export function createPendingCardStore(stateDir: string): PendingCardStore {
115
+ const filePath = join(stateDir, 'pending-approval-cards.json')
116
+
117
+ function read(): PersistedApprovalCard[] {
118
+ try {
119
+ const raw = readFileSync(filePath, 'utf-8')
120
+ const parsed = JSON.parse(raw)
121
+ return Array.isArray(parsed) ? (parsed as PersistedApprovalCard[]) : []
122
+ } catch {
123
+ return []
124
+ }
125
+ }
126
+
127
+ function write(entries: PersistedApprovalCard[]): void {
128
+ try {
129
+ writeFileSync(filePath, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 })
130
+ // `mode` only applies when writeFileSync CREATES the file; an existing
131
+ // file keeps its prior perms. Re-assert 0600 on every write so the file
132
+ // can never stay laxer than intended.
133
+ chmodSync(filePath, 0o600)
134
+ } catch (err) {
135
+ process.stderr.write(
136
+ `telegram gateway: pending-card-store write failed: ${(err as Error).message}\n`,
137
+ )
138
+ }
139
+ }
140
+
141
+ return {
142
+ add(entry) {
143
+ const entries = read()
144
+ const idx = entries.findIndex(e => e.stageId === entry.stageId)
145
+ if (idx >= 0) {
146
+ entries[idx] = entry
147
+ } else {
148
+ entries.push(entry)
149
+ }
150
+ write(entries)
151
+ },
152
+
153
+ remove(stageId) {
154
+ const entries = read()
155
+ const filtered = entries.filter(e => e.stageId !== stageId)
156
+ if (filtered.length !== entries.length) {
157
+ write(filtered)
158
+ }
159
+ },
160
+
161
+ loadAll() {
162
+ return read()
163
+ },
164
+
165
+ clear() {
166
+ try {
167
+ unlinkSync(filePath)
168
+ } catch {
169
+ // File may not exist — that's fine.
170
+ }
171
+ },
172
+ }
173
+ }
@@ -150,9 +150,19 @@ export function redeliverBufferedInbound(
150
150
  * approvals, subagent handbacks, warmup, reaction triggers) all tag a
151
151
  * `meta.source`; the user-message inbound built in gateway.ts sets none.
152
152
  * Restricting to source-less inbounds keeps merge-on-drain away from the
153
- * #1150 wake-up class entirely. */
153
+ * #1150 wake-up class entirely.
154
+ *
155
+ * Button taps (#271, `meta.button_callback`) are ALSO excluded even though
156
+ * they carry no `meta.source`: `mergeRun` keeps only the anchor (last)
157
+ * message's meta, so a tap merged with an adjacent buffered user text would
158
+ * silently drop its `button_callback_data`/`button_text` whenever the text
159
+ * is last — the agent would see the `[user tapped button: …]` line without
160
+ * the machine-readable payload. Taps deliver individually. */
154
161
  function isMergeableUserInbound(msg: InboundMessage): boolean {
155
- return msg.type === 'inbound' && (msg.meta == null || msg.meta.source == null)
162
+ return (
163
+ msg.type === 'inbound' &&
164
+ (msg.meta == null || (msg.meta.source == null && msg.meta.button_callback == null))
165
+ )
156
166
  }
157
167
 
158
168
  function inboundHasMedia(msg: InboundMessage): boolean {
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Boot-time-only, chat-scoped resolution of a raw Telegram id/username into
3
+ * a human `person_id` (e.g. "Lisa") for display in the `<channel>` tag's
4
+ * `user` attribute.
5
+ *
6
+ * Design (converged after adversarial review — see the PR description for
7
+ * the tradeoffs, do not re-litigate here):
8
+ *
9
+ * - No MCP tool. Not callable by agents — display-only, gateway-internal.
10
+ * - No hot-reload. `PersonDirectory` is built ONCE at gateway boot from
11
+ * the static in-memory `people.json` the scaffold projected from
12
+ * `switchroom.yaml`'s `users:` block. A config change requires an agent
13
+ * restart to take effect — this file never re-reads anything.
14
+ * - `access.json` (the fail-CLOSED allow-list) is a completely separate
15
+ * concern and is never touched here. This feature is fail-OPEN: an
16
+ * unresolved id/username falls back to today's behavior (the caller
17
+ * keeps using the raw id/username) — it never blocks or denies
18
+ * anything.
19
+ * - Chat-scoped: a resolved name is only ever returned for a chat/group
20
+ * the person is actually a member of. In a DM the chat IS the sender,
21
+ * so resolution always applies. In a group, resolution only applies if
22
+ * the sender's id or username is explicitly present in that group's
23
+ * `allowFrom` (read from the EXISTING `access.json`/`loadAccess()`
24
+ * data — no new membership source). An empty/unset group `allowFrom`
25
+ * means membership can't be positively confirmed, so we conservatively
26
+ * do NOT resolve (the raw id/username is shown instead) — a name safe
27
+ * in a 1:1 DM could be a bigger leak in a shared group.
28
+ * - Per-entry validation: a malformed `users:` entry (duplicate
29
+ * `person_id` claimed by two different keys, empty/invalid
30
+ * `person_id`, no `telegram_ids`) drops ONLY that one entry — it never
31
+ * blanks resolution for the whole fleet. `buildPersonDirectory` never
32
+ * throws; every failure mode is reported via its `dropped` return
33
+ * value instead, so the caller can route it through the existing
34
+ * fleet-alert path (`emitGatewayOperatorEvent` in gateway.ts) at
35
+ * low/config severity — this must never page like a real outage.
36
+ *
37
+ * Fix note (accepted soft mitigation — see docs/configuration.md): there is
38
+ * no automated enforcement that a configured `person_id` stays safe to show
39
+ * if a group's membership changes AFTER the entry is written. This is an
40
+ * operator-discipline convention, not a closed gap. Low severity today —
41
+ * only two `person_id`s are configured fleet-wide.
42
+ */
43
+
44
+ export interface RawPersonEntry {
45
+ /** The `users:` map key (e.g. "lisa") — carried through for alert text. */
46
+ key: string
47
+ person_id: string
48
+ telegram_ids: string[]
49
+ }
50
+
51
+ export interface PersonDirectoryEntry {
52
+ key: string
53
+ personId: string
54
+ /** Normalized (lowercased, no leading "@") telegram ids/usernames. */
55
+ telegramKeys: string[]
56
+ }
57
+
58
+ export interface PersonDirectory {
59
+ /** normalized telegram id/username -> directory entry */
60
+ byTelegramKey: Record<string, PersonDirectoryEntry>
61
+ }
62
+
63
+ export interface DroppedPersonEntry {
64
+ key: string
65
+ reason: string
66
+ /**
67
+ * Name-scrubbed reason CLASS — the same rejection without the embedded
68
+ * `person_id` human-name value or the colliding telegram id. Used to
69
+ * build the broadcast `alertDetail` so a config-warning card (which
70
+ * `emitGatewayOperatorEvent` sends to EVERY `allowFrom` chat, including
71
+ * group chats where the named person may NOT be a member) never surfaces
72
+ * a human name that the display path (`resolvePersonName`) deliberately
73
+ * chat-scopes. The verbose `reason` is still carried for the operator's
74
+ * own stderr `logLine` (private, not broadcast).
75
+ */
76
+ reasonClass: string
77
+ }
78
+
79
+ export interface BuildPersonDirectoryResult {
80
+ directory: PersonDirectory
81
+ dropped: DroppedPersonEntry[]
82
+ }
83
+
84
+ /** Normalize a telegram id or @username for keying: trim, strip a leading
85
+ * "@", lowercase (Telegram usernames are case-insensitive). */
86
+ function normalizeTelegramKey(raw: string): string {
87
+ return raw.trim().replace(/^@/, '').toLowerCase()
88
+ }
89
+
90
+ /**
91
+ * Validate and dedupe raw `users:` `person_id` entries into a lookup
92
+ * directory. Never throws — every rejection is reported via `dropped`
93
+ * instead, per the fail-open / drop-only-that-entry design.
94
+ *
95
+ * Drop reasons:
96
+ * - missing/blank `key` or `person_id`
97
+ * - `telegram_ids` missing or empty (nothing to key the entry by)
98
+ * - `person_id` already claimed by an earlier (different) entry key —
99
+ * first-declared entry wins, the later duplicate is dropped whole
100
+ * (not just the colliding id) so the alert is unambiguous about which
101
+ * entry lost
102
+ * - a `telegram_id`/username already claimed by an earlier (different)
103
+ * entry key — same first-declared-wins convention: the later duplicate
104
+ * is dropped whole so the alert is unambiguous about which entry lost
105
+ * and we never silently last-write-wins a numeric id into the wrong
106
+ * person's name
107
+ */
108
+ export function buildPersonDirectory(entries: readonly RawPersonEntry[]): BuildPersonDirectoryResult {
109
+ const byTelegramKey: Record<string, PersonDirectoryEntry> = {}
110
+ const dropped: DroppedPersonEntry[] = []
111
+ const personIdOwner = new Map<string, string>() // person_id (lowercased) -> owning entry key
112
+ const telegramKeyOwner = new Map<string, string>() // normalized telegram id/username -> owning entry key
113
+
114
+ for (const raw of entries) {
115
+ const key = typeof raw.key === 'string' ? raw.key.trim() : ''
116
+ const personId = typeof raw.person_id === 'string' ? raw.person_id.trim() : ''
117
+
118
+ if (key.length === 0) {
119
+ dropped.push({ key: raw.key || '(unknown)', reason: 'missing users: map key', reasonClass: 'missing users: map key' })
120
+ continue
121
+ }
122
+ if (personId.length === 0) {
123
+ dropped.push({ key, reason: 'empty or missing person_id', reasonClass: 'empty or missing person_id' })
124
+ continue
125
+ }
126
+ if (!Array.isArray(raw.telegram_ids) || raw.telegram_ids.length === 0) {
127
+ dropped.push({ key, reason: 'no telegram_ids to resolve against', reasonClass: 'no telegram_ids to resolve against' })
128
+ continue
129
+ }
130
+
131
+ const personIdLower = personId.toLowerCase()
132
+ const existingOwner = personIdOwner.get(personIdLower)
133
+ if (existingOwner != null && existingOwner !== key) {
134
+ dropped.push({
135
+ key,
136
+ // Verbose reason (stderr logLine only — private to the operator):
137
+ // names the person_id value + the owning config key.
138
+ reason: `duplicate person_id "${personId}" already claimed by users.${existingOwner}`,
139
+ // reasonClass (broadcast alertDetail): scrubs the human-name
140
+ // person_id value; keeps the entry key + collision class. A
141
+ // config-warning card fans out to every allowFrom chat, so the
142
+ // name must not travel further than the display path allows.
143
+ reasonClass: `duplicate person_id already claimed by users.${existingOwner}`,
144
+ })
145
+ continue
146
+ }
147
+ personIdOwner.set(personIdLower, key)
148
+
149
+ const telegramKeys = [...new Set(raw.telegram_ids.map(normalizeTelegramKey).filter((k) => k.length > 0))]
150
+ if (telegramKeys.length === 0) {
151
+ dropped.push({ key, reason: 'telegram_ids contained no usable id/username', reasonClass: 'telegram_ids contained no usable id/username' })
152
+ personIdOwner.delete(personIdLower)
153
+ continue
154
+ }
155
+
156
+ const collidingTelegramKey = telegramKeys.find((tk) => {
157
+ const existingTkOwner = telegramKeyOwner.get(tk)
158
+ return existingTkOwner != null && existingTkOwner !== key
159
+ })
160
+ if (collidingTelegramKey != null) {
161
+ const existingTkOwner = telegramKeyOwner.get(collidingTelegramKey)
162
+ dropped.push({
163
+ key,
164
+ reason: `duplicate telegram_id "${collidingTelegramKey}" already claimed by users.${existingTkOwner}`,
165
+ // Scrub the colliding telegram id/username from the broadcast
166
+ // reason; keep the owning config key (operator-chosen slug).
167
+ reasonClass: `duplicate telegram_id already claimed by users.${existingTkOwner}`,
168
+ })
169
+ personIdOwner.delete(personIdLower)
170
+ continue
171
+ }
172
+
173
+ const entry: PersonDirectoryEntry = { key, personId, telegramKeys }
174
+ for (const tk of telegramKeys) {
175
+ telegramKeyOwner.set(tk, key)
176
+ byTelegramKey[tk] = entry
177
+ }
178
+ }
179
+
180
+ return { directory: { byTelegramKey }, dropped }
181
+ }
182
+
183
+ export interface PersonDirectoryBootResult {
184
+ directory: PersonDirectory
185
+ /** Non-null when the caller should route an alert through the fleet
186
+ * alert path (`emitGatewayOperatorEvent`, kind: 'config-warning'). */
187
+ alertDetail: string | null
188
+ /** Always present — one human-readable line for stderr. */
189
+ logLine: string
190
+ }
191
+
192
+ /**
193
+ * Orchestrate the ONE-TIME boot-time check (requirement: boot-time-only,
194
+ * NOT periodic — call this exactly once, at gateway boot, never on a
195
+ * timer/interval). Extracted as a pure(ish) function — taking the file
196
+ * read as an injected dependency and RETURNING what to log/alert rather
197
+ * than performing the I/O itself — so it's unit-testable without booting
198
+ * the real gateway process.
199
+ *
200
+ * Dead-man's-switch: wraps the whole check in try/catch. If anything
201
+ * throws before completing (including `readEntries` itself), that failure
202
+ * is ALSO surfaced via `alertDetail` — mirrors a past incident where
203
+ * `access.json` validation failed silently in the wrong (fail-open)
204
+ * direction, undetected for hours. `PERSON_DIRECTORY` falls back to an
205
+ * empty directory on crash (fail-open: raw ids/usernames keep showing).
206
+ */
207
+ export function runPersonDirectoryBootCheck(readEntries: () => RawPersonEntry[]): PersonDirectoryBootResult {
208
+ try {
209
+ const rawEntries = readEntries()
210
+ const { directory, dropped } = buildPersonDirectory(rawEntries)
211
+
212
+ if (dropped.length > 0) {
213
+ // Broadcast-safe summary for `alertDetail` (fans out to EVERY
214
+ // allowFrom chat via emitGatewayOperatorEvent, including group chats
215
+ // where the named person may not be a member): built from
216
+ // `reasonClass`, which scrubs the embedded person_id human-name
217
+ // value and the colliding telegram id. The verbose `reason`
218
+ // (names the values) is kept for the operator's own stderr
219
+ // `logLine` — private, never broadcast.
220
+ const alertSummary = dropped.map((d) => `${d.key} (${d.reasonClass})`).join('; ')
221
+ const logSummary = dropped.map((d) => `${d.key} (${d.reason})`).join('; ')
222
+ const plural = dropped.length === 1 ? 'y' : 'ies'
223
+ return {
224
+ directory,
225
+ alertDetail: `person_id: dropped ${dropped.length} malformed users: entr${plural} at boot — ${alertSummary}`,
226
+ logLine: `telegram gateway: person_id boot validation dropped ${dropped.length} entr${plural}: ${logSummary}`,
227
+ }
228
+ }
229
+
230
+ return {
231
+ directory,
232
+ alertDetail: null,
233
+ logLine: rawEntries.length > 0
234
+ ? `telegram gateway: person_id boot validation ok — ${Object.keys(directory.byTelegramKey).length} telegram id/username(s) resolved`
235
+ : `telegram gateway: person_id boot validation ok — no person_id entries configured`,
236
+ }
237
+ } catch (err) {
238
+ const msg = err instanceof Error ? err.message : String(err)
239
+ return {
240
+ directory: { byTelegramKey: {} },
241
+ // Crash alertDetail scrubs the raw error message too — it can
242
+ // carry a filesystem path or other host detail that shouldn't fan
243
+ // out to every allowFrom chat. The full message stays in logLine.
244
+ alertDetail: `person_id boot validation crashed — name resolution disabled this boot (fail-open, raw ids/usernames will show); see gateway stderr for detail`,
245
+ logLine: `telegram gateway: person_id boot validation CRASHED (name resolution disabled this boot, raw ids will show — fail-open): ${msg}`,
246
+ }
247
+ }
248
+ }
249
+
250
+ export interface ResolvePersonOptions {
251
+ telegramId: string
252
+ username?: string | undefined
253
+ isDm: boolean
254
+ /** That chat's/group's configured allowFrom, if any (from access.json). */
255
+ groupAllowFrom?: readonly string[] | undefined
256
+ }
257
+
258
+ /**
259
+ * Resolve a sender's display name for THIS chat, chat-scoped per the
260
+ * module doc above. Returns undefined (fall back to raw id/username) when
261
+ * unresolved OR when membership in a group chat can't be positively
262
+ * confirmed.
263
+ */
264
+ export function resolvePersonName(directory: PersonDirectory, opts: ResolvePersonOptions): string | undefined {
265
+ const idKey = normalizeTelegramKey(opts.telegramId)
266
+ const usernameKey = opts.username ? normalizeTelegramKey(opts.username) : undefined
267
+
268
+ const entry = directory.byTelegramKey[idKey] ?? (usernameKey ? directory.byTelegramKey[usernameKey] : undefined)
269
+ if (!entry) return undefined
270
+
271
+ // DM: the chat IS the sender, so resolution always applies.
272
+ if (opts.isDm) return entry.personId
273
+
274
+ // Group: only resolve if the sender is explicitly present in that
275
+ // chat's allowFrom (the existing membership source) — conservative
276
+ // fallback (undefined) otherwise, since we can't positively confirm
277
+ // membership from an empty/unset list.
278
+ const allowFrom = opts.groupAllowFrom ?? []
279
+ const allowFromNormalized = allowFrom.map(normalizeTelegramKey)
280
+ const memberConfirmed =
281
+ allowFromNormalized.includes(idKey) || (usernameKey != null && allowFromNormalized.includes(usernameKey))
282
+ return memberConfirmed ? entry.personId : undefined
283
+ }
284
+
285
+ /**
286
+ * Fail-open call-site wrapper around `resolvePersonName`, for use at the
287
+ * `handleInbound` call site (gateway.ts). This feature's whole design
288
+ * point is "never blocks or denies anything" (module doc above) — a throw
289
+ * from resolution must fall back to the raw id/username, not abort message
290
+ * handling. Mirrors the same defensive pattern already used for
291
+ * `readPeopleFile` (gateway.ts) and `runPersonDirectoryBootCheck` (this
292
+ * file): catch, fall back, never propagate.
293
+ */
294
+ export function safeResolvePersonName(
295
+ directory: PersonDirectory,
296
+ opts: ResolvePersonOptions,
297
+ rawFallback: string,
298
+ ): string {
299
+ try {
300
+ return resolvePersonName(directory, opts) ?? rawFallback
301
+ } catch {
302
+ return rawFallback
303
+ }
304
+ }