switchroom 0.21.7 → 0.21.9

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 (59) hide show
  1. package/bin/tmp-reaper.sh +234 -0
  2. package/dist/agent-scheduler/index.js +1 -1
  3. package/dist/auth-broker/index.js +2 -2
  4. package/dist/cli/notion-write-pretool.mjs +1 -1
  5. package/dist/cli/switchroom.js +3520 -2782
  6. package/dist/host-control/main.js +177 -13
  7. package/dist/vault/approvals/kernel-server.js +2 -2
  8. package/dist/vault/broker/server.js +2 -2
  9. package/package.json +6 -5
  10. package/profiles/_base/start.sh.hbs +115 -0
  11. package/profiles/_shared/local-time.md.hbs +6 -0
  12. package/profiles/default/CLAUDE.md.hbs +0 -12
  13. package/skills/switchroom-architecture/telegram.md +12 -10
  14. package/skills/switchroom-cli/SKILL.md +1 -1
  15. package/telegram-plugin/README.md +3 -1
  16. package/telegram-plugin/dist/gateway/gateway.js +1164 -547
  17. package/telegram-plugin/format.ts +12 -4
  18. package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
  19. package/telegram-plugin/gateway/approval-hold.ts +32 -1
  20. package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
  21. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
  23. package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
  24. package/telegram-plugin/gateway/gateway.ts +12 -10
  25. package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
  26. package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
  27. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
  28. package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
  29. package/telegram-plugin/package.json +1 -1
  30. package/telegram-plugin/render/code-segments.ts +38 -4
  31. package/telegram-plugin/render/dollar-math-guard.ts +16 -1
  32. package/telegram-plugin/render/ir.ts +53 -3
  33. package/telegram-plugin/render/parse.ts +73 -14
  34. package/telegram-plugin/render/render.ts +53 -15
  35. package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
  36. package/telegram-plugin/rich-send.ts +22 -7
  37. package/telegram-plugin/shared/bot-runtime.ts +3 -2
  38. package/telegram-plugin/telegraph.ts +6 -4
  39. package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
  40. package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
  41. package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
  42. package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
  43. package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
  44. package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
  45. package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
  46. package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
  47. package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
  48. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
  49. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
  50. package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
  51. package/telegram-plugin/tests/render/parse.test.ts +30 -5
  52. package/telegram-plugin/tests/render/render.test.ts +9 -4
  53. package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
  54. package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
  55. package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
  56. package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
  57. package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
  58. package/telegram-plugin/tests/telegraph.test.ts +1 -1
  59. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
@@ -0,0 +1,274 @@
1
+ /**
2
+ * The single registry of "this inbound carries an approval OUTCOME".
3
+ *
4
+ * An approval outcome is a synthetic inbound that reports the RESULT of an
5
+ * operator decision the agent is blocked on: a vault grant approved/denied,
6
+ * a secret provided/declined, a mental-model proposal applied, a button tap.
7
+ * It is structurally different from an ordinary chat message in one way that
8
+ * matters to the inbound buffer: **the operator cannot resend it.** If a
9
+ * `vault_grant_approved` is dropped, telling the user "please resend that
10
+ * message" is meaningless — the tap already happened, the grant already
11
+ * exists, and the agent waits forever for a wake-up that will never come.
12
+ *
13
+ * That asymmetry is why cap eviction in `pending-inbound-buffer.ts` may not
14
+ * pick its victim blindly. An ordinary user message is resendable; an
15
+ * approval outcome is not. So the buffer evicts the oldest NON-outcome first
16
+ * and only touches an outcome when there is genuinely nothing else to drop.
17
+ *
18
+ * Membership is deliberately a closed literal set rather than a prefix/regex
19
+ * match on `meta.source`: a new source string must be classified by a human
20
+ * (or an agent reading this comment), not silently inherit protection from a
21
+ * naming coincidence. The set is exhaustive against `telegram-plugin/gateway/`
22
+ * as of this commit.
23
+ *
24
+ * No imports beyond the inbound type. The set, the predicate and the notice
25
+ * builder are pure, so the buffer hot path can import them freely;
26
+ * `createApprovalOutcomeDropNotifier` is the one stateful export (the gateway's
27
+ * re-entrancy latch) and lives here rather than inline in `gateway.ts` so the
28
+ * production wiring is the thing tests drive, not a copy of it.
29
+ */
30
+
31
+ import type { InboundMessage } from './ipc-protocol.js'
32
+
33
+ /**
34
+ * `meta.source` values that identify an inbound as an approval outcome.
35
+ *
36
+ * Grepped exhaustively from `telegram-plugin/gateway/` (both quote styles —
37
+ * the `mental_model_proposal_*` builders use double quotes, which is how an
38
+ * earlier single-quote-only survey missed them).
39
+ *
40
+ * Deliberately EXCLUDED, with reasons:
41
+ * - `missed_approval_retry` — carries no verdict. It ASKS the agent to
42
+ * re-attempt actions that timed out (`missed-approvals-card.ts:117-161`),
43
+ * and the operator can tap "retry" again. Resendable ⇒ ordinary.
44
+ * - `obligation_represent` / `subagent_handback` / `subagent_progress` /
45
+ * `resume_*` / `warmup` / `reaction` / `bridge_dead_restart` — internal
46
+ * nudges and lifecycle wake-ups, all regenerated by their own sweeps.
47
+ *
48
+ * `eval_case_applied` / `eval_case_rejected` / `eval_case_apply_failed` are
49
+ * listed AHEAD of the branch that introduces them. Listing an unused source
50
+ * is inert (nothing ever carries it), and it removes any ordering constraint
51
+ * between the two changes — the alternative is a window where eval-case
52
+ * verdicts are evictable.
53
+ *
54
+ * `eval_case_suppressed` is protected for the SAME reason, and it is the one
55
+ * member that is not a verdict card at all. It is the gateway's own notice
56
+ * that a re-proposed eval case was dropped against a live dismissal — the
57
+ * agent's turn is blocked on it and NO sweep regenerates it (it fires exactly
58
+ * once, at the moment of suppression, and the suppression path posts no card).
59
+ * Unprotected it would be tier-1's preferred victim, so a cap overflow would
60
+ * restore the silent block the notice exists to close.
61
+ */
62
+ export const APPROVAL_OUTCOME_SOURCES: ReadonlySet<string> = Object.freeze(
63
+ new Set([
64
+ // vault grant decisions (#1150 — the original dropped-wake-up class)
65
+ 'vault_grant_approved',
66
+ 'vault_grant_denied',
67
+ 'vault_grant_timeout',
68
+ // vault save decisions
69
+ 'vault_save_completed',
70
+ 'vault_save_discarded',
71
+ 'vault_save_failed',
72
+ 'vault_save_timeout',
73
+ // secret-request decisions
74
+ 'secret_provided',
75
+ 'secret_declined',
76
+ 'secret_provide_failed',
77
+ 'secret_request_timeout',
78
+ // mental-model proposal decisions
79
+ 'mental_model_proposal_applied',
80
+ 'mental_model_proposal_denied',
81
+ 'mental_model_proposal_failed',
82
+ 'mental_model_propose_timeout',
83
+ // skill proposal decision
84
+ 'skill_proposal_apply',
85
+ // eval-case proposal decisions (forward-listed; see doc comment)
86
+ 'eval_case_applied',
87
+ 'eval_case_rejected',
88
+ 'eval_case_apply_failed',
89
+ // #4664's suppressed-proposal notice. Not itself a tap, but it reports the
90
+ // standing effect of one the operator ALREADY made (the Dismiss that
91
+ // recorded the rejection), and it is the only thing that ends the agent's
92
+ // wait — see the doc comment above.
93
+ 'eval_case_suppressed',
94
+ ]),
95
+ ) as ReadonlySet<string>
96
+
97
+ /**
98
+ * `meta.source` stamped on the synthetic notice the gateway pushes when an
99
+ * approval outcome had to be dropped anyway (every buffered entry was an
100
+ * outcome). Deliberately NOT a member of `APPROVAL_OUTCOME_SOURCES`: the
101
+ * notice must be the PREFERRED victim of the next overflow, which is what
102
+ * structurally bounds the notice→evict→notice recursion.
103
+ */
104
+ export const APPROVAL_OUTCOME_DROPPED_SOURCE = 'approval_outcome_dropped'
105
+
106
+ /**
107
+ * True when `msg` reports the result of an operator decision.
108
+ *
109
+ * TWO conditions, both required — a button tap has NO `meta.source` at all.
110
+ * `agent-button-callback-handler.ts:123-133` stamps only
111
+ * `meta.button_callback = 'true'` (plus `button_callback_data` / `button_text`),
112
+ * so a source-only check silently leaves every agent-card tap evictable.
113
+ */
114
+ export function isApprovalOutcome(msg: InboundMessage): boolean {
115
+ const meta = msg.meta
116
+ if (meta == null) return false
117
+ if (meta.source != null && APPROVAL_OUTCOME_SOURCES.has(meta.source)) return true
118
+ return meta.button_callback === 'true'
119
+ }
120
+
121
+ /** Human label for an outcome in logs / notices. Taps carry no source. */
122
+ export function approvalOutcomeLabel(msg: InboundMessage): string {
123
+ const src = msg.meta?.source
124
+ if (src != null && src !== '') return src
125
+ if (msg.meta?.button_callback === 'true') return 'button_callback'
126
+ return '-'
127
+ }
128
+
129
+ /**
130
+ * Build the synthetic inbound that tells the agent an approval outcome was
131
+ * dropped, so it stops waiting on a wake-up that is never arriving. Pure.
132
+ *
133
+ * It names the dropped source(s) rather than paraphrasing, because the agent's
134
+ * next move depends on which one it was (a dropped `vault_grant_approved`
135
+ * means "retry the vault read and see if the grant exists"; a dropped
136
+ * `secret_declined` means "the operator said no").
137
+ */
138
+ export function buildApprovalOutcomeDroppedInbound(opts: {
139
+ agent: string
140
+ chatId: string
141
+ threadId?: number
142
+ sources: string[]
143
+ nowMs?: number
144
+ }): InboundMessage {
145
+ const ts = opts.nowMs ?? Date.now()
146
+ const list = opts.sources.length > 0 ? opts.sources.join(', ') : 'unknown'
147
+ const plural = opts.sources.length === 1 ? 'an approval outcome' : 'approval outcomes'
148
+ const msg: InboundMessage = {
149
+ type: 'inbound',
150
+ chatId: opts.chatId,
151
+ ...(opts.threadId != null ? { threadId: opts.threadId } : {}),
152
+ messageId: ts,
153
+ user: 'gateway',
154
+ userId: 0,
155
+ ts,
156
+ text:
157
+ `⚠️ Your inbound buffer overflowed while every queued message was ${plural}, ` +
158
+ `so the oldest had to be dropped: ${list}. Do NOT keep waiting on it — ` +
159
+ `re-check the underlying state directly (e.g. retry the vault read to see ` +
160
+ `whether the grant exists) and tell the operator plainly what was lost.`,
161
+ meta: {
162
+ source: APPROVAL_OUTCOME_DROPPED_SOURCE,
163
+ agent: opts.agent,
164
+ ...(opts.threadId != null ? { message_thread_id: String(opts.threadId) } : {}),
165
+ dropped_sources: list,
166
+ dropped_count: String(opts.sources.length),
167
+ },
168
+ }
169
+ return msg
170
+ }
171
+
172
+ /** Max follow-up notice hops per burst. See `createApprovalOutcomeDropNotifier`. */
173
+ export const APPROVAL_OUTCOME_NOTICE_HOPS = 2
174
+
175
+ /**
176
+ * Build the `onEvictCritical` handler the gateway wires into the pending
177
+ * inbound buffer: log at a distinct greppable tag, then enqueue ONE synthetic
178
+ * notice naming every outcome dropped since the last notice.
179
+ *
180
+ * Two properties this exists to guarantee, both of which a hand-inlined
181
+ * version in `gateway.ts` would get wrong:
182
+ *
183
+ * - **Never pushes inside the `push` frame.** `onEvictCritical` is invoked
184
+ * from the middle of `push`, on a queue sitting at cap. Pushing there
185
+ * recurses. Every enqueue goes through `queueMicrotask`.
186
+ * - **Terminates.** Deferring alone does not stop recursion, it only turns a
187
+ * stack overflow into an unbounded microtask loop. Termination is
188
+ * structural: the notice carries `approval_outcome_dropped`, which is NOT a
189
+ * member of `APPROVAL_OUTCOME_SOURCES`, so the moment one is resident it
190
+ * becomes the PREFERRED eviction victim and no further critical eviction can
191
+ * fire until it drains. The `scheduled` latch collapses a burst into one
192
+ * notice, and `hops` is a hard budget on top of the structural bound so a
193
+ * future change to victim selection degrades into a truncated chain rather
194
+ * than a spin.
195
+ *
196
+ * KNOWN COST, accepted deliberately: in the pathological state (every one of
197
+ * the 32 slots is a fresh approval outcome) the notice's own push evicts one
198
+ * MORE outcome to make room for itself. So that state costs two dropped
199
+ * outcomes instead of one. The exchange is that the agent is TOLD — the
200
+ * alternative is 32 resident outcomes and an agent blocked forever on a
201
+ * wake-up that was silently discarded. Reaching this state at all requires 32
202
+ * simultaneously-undelivered operator decisions for one agent.
203
+ */
204
+ export function createApprovalOutcomeDropNotifier(opts: {
205
+ /** Enqueue the notice. Wired to the buffer's own `push`. */
206
+ push: (agent: string, msg: InboundMessage) => void
207
+ log?: (line: string) => void
208
+ /** Defer seam. Defaults to `queueMicrotask`. */
209
+ defer?: (fn: () => void) => void
210
+ nowMs?: () => number
211
+ hops?: number
212
+ }): (agent: string, evicted: InboundMessage) => void {
213
+ const log = opts.log ?? ((line: string) => process.stderr.write(line))
214
+ const defer = opts.defer ?? ((fn: () => void) => queueMicrotask(fn))
215
+ const hops = opts.hops ?? APPROVAL_OUTCOME_NOTICE_HOPS
216
+ /** agent → labels of outcomes dropped since the last notice was built. */
217
+ const pending = new Map<string, string[]>()
218
+ /** agent → a notice enqueue is already scheduled (the re-entrancy latch). */
219
+ const scheduled = new Set<string>()
220
+
221
+ return (agent, evicted) => {
222
+ const label = approvalOutcomeLabel(evicted)
223
+ log(
224
+ `telegram gateway: APPROVAL-OUTCOME-DROPPED agent=${agent} source=${label} ` +
225
+ `chat=${evicted.chatId} ts=${evicted.ts}\n`,
226
+ )
227
+ const list = pending.get(agent) ?? []
228
+ list.push(label)
229
+ pending.set(agent, list)
230
+ if (scheduled.has(agent)) return
231
+ scheduled.add(agent)
232
+ const chatId = evicted.chatId
233
+ const threadId = evicted.threadId
234
+
235
+ const flush = (budget: number): void => {
236
+ // CUMULATIVE, and deliberately not cleared before the push: the notice we
237
+ // are about to enqueue can itself be the entry a later overflow evicts
238
+ // (it is the preferred victim, by design). If each hop named only the
239
+ // drops since the last one, the surviving notice would omit the earliest
240
+ // dropped source — the one the agent is most likely to be blocked on. So
241
+ // every hop restates the whole burst and the list is cleared only when
242
+ // the chain ends.
243
+ const sources = [...(pending.get(agent) ?? [])]
244
+ const before = sources.length
245
+ try {
246
+ opts.push(
247
+ agent,
248
+ buildApprovalOutcomeDroppedInbound({
249
+ agent,
250
+ chatId,
251
+ ...(threadId != null ? { threadId } : {}),
252
+ sources,
253
+ ...(opts.nowMs != null ? { nowMs: opts.nowMs() } : {}),
254
+ }),
255
+ )
256
+ } catch (e) {
257
+ log(`telegram gateway: APPROVAL-OUTCOME-DROPPED notice push failed: ${String(e)}\n`)
258
+ } finally {
259
+ scheduled.delete(agent)
260
+ }
261
+ // The push above ran against a queue at cap, so it may itself have
262
+ // evicted one more outcome (re-entering this handler with the latch still
263
+ // set, which recorded the label but scheduled nothing). A GROWN list is
264
+ // the signal that happened. Re-state, once more, then stop.
265
+ if (budget > 0 && (pending.get(agent)?.length ?? 0) > before) {
266
+ scheduled.add(agent)
267
+ defer(() => flush(budget - 1))
268
+ } else {
269
+ pending.delete(agent)
270
+ }
271
+ }
272
+ defer(() => flush(hops))
273
+ }
274
+ }
@@ -228,16 +228,28 @@ export function writeBridgeDeadEscalationMarker(
228
228
  /**
229
229
  * Read + consume the escalation marker at boot. Returns the marker only
230
230
  * when it is fresh (< maxAgeMs); a stale or malformed marker is cleared
231
- * and ignored. The file is ALWAYS removed — the cause note must surface
232
- * on exactly the boot that follows the escalation, never a later one.
231
+ * and ignored. Whenever this function runs the file IS removed — the cause
232
+ * note must surface on exactly the boot that follows the escalation, never
233
+ * a later one.
233
234
  *
234
- * Known race window (accepted, documented per review): the marker is
235
- * written by gateway boot N and consumed by whichever gateway boots NEXT.
236
- * Normally that is the post-container-restart gateway (the SIGTERM to
237
- * PID 1 fires ~1.5s after the write and takes the whole container down).
238
- * But if the escalating gateway PROCESS dies and its supervisor relaunches
239
- * a new gateway inside the same container before the SIGTERM lands, that
240
- * interim gateway consumes the marker instead — the cause note is then
235
+ * Since #4641 the caller does not always run: the gateway's boot-resume
236
+ * block `break`s before reaching this call when the per-container-boot
237
+ * generation token says only the GATEWAY respawned. That narrows — and for
238
+ * the common shape closes — the race documented below, so it is an
239
+ * improvement rather than a hole; the marker is left on disk for the boot
240
+ * that genuinely follows the container restart. See "What `break
241
+ * bootResumeInit` skips" in agent-process-liveness.ts.
242
+ *
243
+ * Known race window (accepted, documented per review; largely closed by the
244
+ * #4641 guard above): the marker is written by gateway boot N and consumed
245
+ * by whichever gateway boots NEXT and reaches this call. Normally that is
246
+ * the post-container-restart gateway (the SIGTERM to PID 1 fires ~1.5s
247
+ * after the write and takes the whole container down). But if the
248
+ * escalating gateway PROCESS dies and its supervisor relaunches a new
249
+ * gateway inside the same container before the SIGTERM lands, and that
250
+ * interim gateway's boot block is NOT suppressed by the generation token
251
+ * (i.e. no gateway in this generation had completed its boot resume yet),
252
+ * the interim gateway consumes the marker instead — the cause note is then
241
253
  * surfaced (or dropped with the interim process) one boot early, and the
242
254
  * post-restart boot sees no marker. Consequences are bounded and safe:
243
255
  * the honesty note may be lost for one incident (the loud supervisor-log
@@ -75,6 +75,11 @@ import {
75
75
  getEvalCaseProposal,
76
76
  setEvalCaseProposalStatus,
77
77
  } from '../../src/self-improve/eval-case-proposals.js'
78
+ import {
79
+ buildEvalCaseAppliedInbound,
80
+ buildEvalCaseRejectedInbound,
81
+ buildEvalCaseApplyFailedInbound,
82
+ } from './eval-case-proposal-inbound-builders.js'
78
83
  import { maskToken } from '../secret-detect/mask.js'
79
84
  import {
80
85
  defaultVaultWrite,
@@ -464,6 +469,19 @@ export interface CallbackQueryHandlersDeps {
464
469
  brokerList?: typeof realListViaBroker
465
470
  brokerListGrants?: typeof realListGrantsViaBroker
466
471
  brokerVaultTokenFilePath?: typeof realVaultTokenFilePath
472
+
473
+ /**
474
+ * Run the DETERMINISTIC eval-case applier for an approved proposal. Returns
475
+ * `ok` plus the applier's combined output tail (both go on the card footer
476
+ * and into the outcome inbound the agent is woken with).
477
+ *
478
+ * Injectable for the same reason as the broker seams above: production leaves
479
+ * it unset and gets the real `switchroom self-improve apply-eval-case`
480
+ * `execFileSync`, but a unit test cannot shell out to a real CLI, so the
481
+ * approve path's two branches (applied / apply-failed) were untestable while
482
+ * this was a bare static import.
483
+ */
484
+ runEvalCaseApply?: (id: string) => { ok: boolean; out: string }
467
485
  }
468
486
 
469
487
  // Freshness throttle for the /auth dashboard ↻ refresh button — one live
@@ -625,6 +643,27 @@ export function createCallbackQueryHandlers(deps: CallbackQueryHandlersDeps) {
625
643
  const listViaBroker = deps.brokerList ?? realListViaBroker
626
644
  const listGrantsViaBroker = deps.brokerListGrants ?? realListGrantsViaBroker
627
645
  const vaultTokenFilePath = deps.brokerVaultTokenFilePath ?? realVaultTokenFilePath
646
+ // Eval-case applier seam. The default body is verbatim the execFileSync that
647
+ // used to sit inline in handleEvalCaseProposalCallback.
648
+ const runEvalCaseApply =
649
+ deps.runEvalCaseApply ??
650
+ ((id: string): { ok: boolean; out: string } => {
651
+ const cli = process.env.SWITCHROOM_CLI_PATH ?? 'switchroom'
652
+ try {
653
+ const out = execFileSync(
654
+ cli,
655
+ ['self-improve', 'apply-eval-case', '--id', id],
656
+ { encoding: 'utf8', timeout: 15000, env: process.env },
657
+ ).trim()
658
+ return { ok: true, out }
659
+ } catch (err) {
660
+ const e = err as { stdout?: string; stderr?: string; message?: string }
661
+ return {
662
+ ok: false,
663
+ out: [e.stdout, e.stderr, e.message].filter(Boolean).join('\n').trim(),
664
+ }
665
+ }
666
+ })
628
667
 
629
668
  /**
630
669
  * Handle a callback_query from an auth dashboard button. Parses the
@@ -1377,8 +1416,31 @@ async function handleEvalCaseProposalCallback(ctx: Context, data: string): Promi
1377
1416
  return
1378
1417
  }
1379
1418
 
1419
+ // Same idiom as the skill handler above: the card's own chat/topic is where
1420
+ // the proposing agent was working, so the outcome inbound resumes it there.
1421
+ const cbChatId = String(ctx.chat?.id ?? ctx.from?.id ?? '')
1422
+ const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id)
1423
+ const inboundCtx = {
1424
+ agent,
1425
+ chat_id: cbChatId,
1426
+ ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
1427
+ }
1428
+
1380
1429
  if (parsed.action === 'deny') {
1381
1430
  setEvalCaseProposalStatus(stateDir, parsed.id, 'rejected')
1431
+ // The agent is TOLD it was dismissed. The skill handler stays silent here;
1432
+ // silence is the defect — a dismissed proposal left the agent waiting on a
1433
+ // wake-up that never came.
1434
+ const denied = deliverResumeSyntheticOrBuffer(
1435
+ agent,
1436
+ buildEvalCaseRejectedInbound({
1437
+ ctx: inboundCtx,
1438
+ proposalId: proposal.id,
1439
+ skillSlug: proposal.skill_slug,
1440
+ heldOut: proposal.held_out,
1441
+ operatorId: senderId,
1442
+ }),
1443
+ )
1382
1444
  await ctx.answerCallbackQuery({ text: '🚫 Dismissed.' }).catch(() => {})
1383
1445
  if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
1384
1446
  await ctx
@@ -1388,6 +1450,10 @@ async function handleEvalCaseProposalCallback(ctx: Context, data: string): Promi
1388
1450
  )
1389
1451
  .catch(() => {})
1390
1452
  }
1453
+ process.stderr.write(
1454
+ `telegram gateway: eval_case_rejected agent=${agent} proposal=${proposal.id} ` +
1455
+ `slug=${proposal.skill_slug} delivered=${denied}\n`,
1456
+ )
1391
1457
  return
1392
1458
  }
1393
1459
 
@@ -1395,20 +1461,7 @@ async function handleEvalCaseProposalCallback(ctx: Context, data: string): Promi
1395
1461
  setEvalCaseProposalStatus(stateDir, parsed.id, 'approved')
1396
1462
  await ctx.answerCallbackQuery({ text: '✅ Adding the eval case…' }).catch(() => {})
1397
1463
 
1398
- const cli = process.env.SWITCHROOM_CLI_PATH ?? 'switchroom'
1399
- let applyOk = true
1400
- let applyOut = ''
1401
- try {
1402
- applyOut = execFileSync(
1403
- cli,
1404
- ['self-improve', 'apply-eval-case', '--id', parsed.id],
1405
- { encoding: 'utf8', timeout: 15000, env: process.env },
1406
- ).trim()
1407
- } catch (err) {
1408
- applyOk = false
1409
- const e = err as { stdout?: string; stderr?: string; message?: string }
1410
- applyOut = [e.stdout, e.stderr, e.message].filter(Boolean).join('\n').trim()
1411
- }
1464
+ const { ok: applyOk, out: applyOut } = runEvalCaseApply(parsed.id)
1412
1465
 
1413
1466
  const footer = applyOk
1414
1467
  ? '✅ <i>Added as a regression test.</i>'
@@ -1421,9 +1474,28 @@ async function handleEvalCaseProposalCallback(ctx: Context, data: string): Promi
1421
1474
  )
1422
1475
  .catch(() => {})
1423
1476
  }
1477
+ // Tell the agent the OUTCOME, not just that a tap happened — applied and
1478
+ // apply-failed are different instructions (resume vs. don't assume it exists).
1479
+ const outcomeInbound = applyOk
1480
+ ? buildEvalCaseAppliedInbound({
1481
+ ctx: inboundCtx,
1482
+ proposalId: proposal.id,
1483
+ skillSlug: proposal.skill_slug,
1484
+ heldOut: proposal.held_out,
1485
+ operatorId: senderId,
1486
+ })
1487
+ : buildEvalCaseApplyFailedInbound({
1488
+ ctx: inboundCtx,
1489
+ proposalId: proposal.id,
1490
+ skillSlug: proposal.skill_slug,
1491
+ heldOut: proposal.held_out,
1492
+ operatorId: senderId,
1493
+ applyOut,
1494
+ })
1495
+ const delivered = deliverResumeSyntheticOrBuffer(agent, outcomeInbound)
1424
1496
  process.stderr.write(
1425
1497
  `telegram gateway: eval_case_apply agent=${agent} proposal=${proposal.id} ` +
1426
- `slug=${proposal.skill_slug} ok=${applyOk}\n`,
1498
+ `slug=${proposal.skill_slug} ok=${applyOk} delivered=${delivered}\n`,
1427
1499
  )
1428
1500
  }
1429
1501
 
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Pure builders for the synthetic inbounds the gateway injects after the
3
+ * operator taps Approve / Dismiss on a self-improve EVAL-CASE proposal card
4
+ * (RFC amendment §"corrections as eval cases"). Mirrors
5
+ * `mental-model-propose-inbound-builders.ts`.
6
+ *
7
+ * WHY THIS MODULE EXISTS AT ALL: `handleEvalCaseProposalCallback` was a copy of
8
+ * `handleSkillProposalCallback` that dropped the
9
+ * `deliverResumeSyntheticOrBuffer` line. Both of its exits — dismiss and
10
+ * approve — edited the card and returned, so the PROPOSING AGENT was never
11
+ * told the outcome. It had been steered to end its turn cleanly and wait for a
12
+ * wake-up that no code path ever sent: every candidate second delivery path is
13
+ * empty (the self-improve Stop hook reads eval integrity baselines, not
14
+ * proposal status; `switchroom self-improve eval-case propose` is
15
+ * fire-and-forget and returns `ok:true` for POSTING THE CARD, never for the
16
+ * outcome). These three builders are the inbound half of that fix.
17
+ *
18
+ * The shape is load-bearing — `meta.source` is what the bridge keys on to
19
+ * render `<channel source="eval_case_applied">` / `eval_case_rejected` /
20
+ * `eval_case_apply_failed` blocks, and the
21
+ * `meta.{agent,proposal_id,skill_slug,held_out,operator_id}` fields are the
22
+ * forensic anchor tying the woken turn back to the exact proposal and the
23
+ * operator who decided it.
24
+ *
25
+ * A regression that drops a meta field or changes the source string would
26
+ * silently break the agent's wake-up: the bridge would route it as a generic
27
+ * channel event, the model wouldn't know its proposal resolved, and the
28
+ * conversation would drift back to the silent-block this module fixes. Pinning
29
+ * these against fixture tests is cheaper than catching that downstream.
30
+ */
31
+
32
+ import type { InboundMessage } from './ipc-protocol.js'
33
+
34
+ /** Subset of the stored proposal the builders need. Kept narrow so callers
35
+ * don't have to pass the full `EvalCaseProposal` record. */
36
+ export interface EvalCaseProposalInboundContext {
37
+ agent: string
38
+ /** Telegram chat id where the approval card lived. Used as the inbound's
39
+ * chatId so the synthesized turn stays associated with the originating
40
+ * conversation. */
41
+ chat_id: string
42
+ /** Supergroup forum topic (message_thread_id) the agent was working in when
43
+ * it proposed — so the resumed turn's reply lands back in that topic, not
44
+ * General. Undefined for DM / non-topic proposals. */
45
+ threadId?: number
46
+ }
47
+
48
+ /** The meta every eval-case outcome inbound carries, minus `source`. */
49
+ function commonMeta(opts: {
50
+ ctx: EvalCaseProposalInboundContext
51
+ proposalId: string
52
+ skillSlug: string
53
+ heldOut: boolean
54
+ operatorId: string
55
+ }): Record<string, string> {
56
+ return {
57
+ agent: opts.ctx.agent,
58
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
59
+ proposal_id: opts.proposalId,
60
+ skill_slug: opts.skillSlug,
61
+ held_out: String(opts.heldOut),
62
+ operator_id: opts.operatorId,
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Build the synthetic InboundMessage for an approval whose DETERMINISTIC
68
+ * applier succeeded — the case is now on disk (in the skill's `evals.json`, or
69
+ * the held-out sink when `heldOut`). Nothing is left for the agent to write.
70
+ */
71
+ export function buildEvalCaseAppliedInbound(opts: {
72
+ ctx: EvalCaseProposalInboundContext
73
+ proposalId: string
74
+ skillSlug: string
75
+ heldOut: boolean
76
+ operatorId: string
77
+ nowMs?: number
78
+ }): InboundMessage {
79
+ const ts = opts.nowMs ?? Date.now()
80
+ const sink = opts.heldOut ? 'the held-out sink' : `\`${opts.skillSlug}\`'s \`evals.json\``
81
+ return {
82
+ type: 'inbound',
83
+ chatId: opts.ctx.chat_id,
84
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
85
+ messageId: ts, // synthetic — no Telegram message id exists
86
+ user: 'self-improve',
87
+ userId: 0,
88
+ ts,
89
+ text:
90
+ `✅ Operator approved your proposed eval case for \`${opts.skillSlug}\` ` +
91
+ `(proposal ${opts.proposalId}). The case was applied DETERMINISTICALLY by ` +
92
+ `the gateway and is already written to ${sink} — do NOT write it yourself ` +
93
+ `and do NOT re-propose it. Resume whatever you were doing.`,
94
+ meta: {
95
+ source: 'eval_case_applied',
96
+ ...commonMeta({
97
+ ctx: opts.ctx,
98
+ proposalId: opts.proposalId,
99
+ skillSlug: opts.skillSlug,
100
+ heldOut: opts.heldOut,
101
+ operatorId: opts.operatorId,
102
+ }),
103
+ },
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Build the synthetic InboundMessage for an operator dismissal. NOTHING was
109
+ * written. Steers the agent onward rather than into a re-propose loop.
110
+ *
111
+ * This has no sibling in the skill-proposal handler (which stays silent on
112
+ * dismiss). Silence is the defect being fixed: a dismissed proposal left the
113
+ * agent waiting on a wake-up that never came.
114
+ */
115
+ export function buildEvalCaseRejectedInbound(opts: {
116
+ ctx: EvalCaseProposalInboundContext
117
+ proposalId: string
118
+ skillSlug: string
119
+ heldOut: boolean
120
+ operatorId: string
121
+ nowMs?: number
122
+ }): InboundMessage {
123
+ const ts = opts.nowMs ?? Date.now()
124
+ return {
125
+ type: 'inbound',
126
+ chatId: opts.ctx.chat_id,
127
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
128
+ messageId: ts,
129
+ user: 'self-improve',
130
+ userId: 0,
131
+ ts,
132
+ text:
133
+ `🚫 Operator dismissed your proposed eval case for \`${opts.skillSlug}\` ` +
134
+ `(proposal ${opts.proposalId}). NOTHING was written — no eval case was ` +
135
+ `added. Carry on with the original task without it. Do NOT re-propose the ` +
136
+ `same case without first asking the user.`,
137
+ meta: {
138
+ source: 'eval_case_rejected',
139
+ ...commonMeta({
140
+ ctx: opts.ctx,
141
+ proposalId: opts.proposalId,
142
+ skillSlug: opts.skillSlug,
143
+ heldOut: opts.heldOut,
144
+ operatorId: opts.operatorId,
145
+ }),
146
+ },
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Build the synthetic InboundMessage for an approval whose applier FAILED
152
+ * (`switchroom self-improve apply-eval-case` non-zero, timeout, missing skill
153
+ * dir, a stale/rejected proposal the applier's own status check refused).
154
+ * The operator tapped Approve but the case did NOT land — be honest so the
155
+ * agent doesn't assume the regression test exists.
156
+ *
157
+ * `applyOut` is the applier's combined stdout/stderr tail, truncated so a
158
+ * runaway stack trace can't dominate the woken turn's context.
159
+ */
160
+ export function buildEvalCaseApplyFailedInbound(opts: {
161
+ ctx: EvalCaseProposalInboundContext
162
+ proposalId: string
163
+ skillSlug: string
164
+ heldOut: boolean
165
+ operatorId: string
166
+ applyOut: string
167
+ nowMs?: number
168
+ }): InboundMessage {
169
+ const ts = opts.nowMs ?? Date.now()
170
+ const tail = opts.applyOut.trim().slice(0, 500)
171
+ return {
172
+ type: 'inbound',
173
+ chatId: opts.ctx.chat_id,
174
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
175
+ messageId: ts,
176
+ user: 'self-improve',
177
+ userId: 0,
178
+ ts,
179
+ text:
180
+ `⚠️ The operator approved your proposed eval case for \`${opts.skillSlug}\` ` +
181
+ `(proposal ${opts.proposalId}) but the applier FAILED. The case was NOT ` +
182
+ `written — do NOT assume the regression test exists. Applier output:\n` +
183
+ `${tail.length > 0 ? tail : '(no output)'}\n` +
184
+ `Carry on with the original task; report the failure to the operator if it ` +
185
+ `still matters.`,
186
+ meta: {
187
+ source: 'eval_case_apply_failed',
188
+ ...commonMeta({
189
+ ctx: opts.ctx,
190
+ proposalId: opts.proposalId,
191
+ skillSlug: opts.skillSlug,
192
+ heldOut: opts.heldOut,
193
+ operatorId: opts.operatorId,
194
+ }),
195
+ },
196
+ }
197
+ }