switchroom 0.16.23 → 0.16.27

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 (138) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +82 -82
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  7. package/dist/cli/skill-validate-pretool.mjs +2936 -119
  8. package/dist/cli/switchroom.js +804 -465
  9. package/dist/host-control/main.js +169 -163
  10. package/dist/vault/approvals/kernel-server.js +82 -82
  11. package/dist/vault/broker/server.js +83 -83
  12. package/package.json +4 -4
  13. package/telegram-plugin/answer-stream.ts +20 -49
  14. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  15. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  16. package/telegram-plugin/bridge/bridge.ts +1 -1
  17. package/telegram-plugin/card-format.ts +28 -25
  18. package/telegram-plugin/credits-watch.ts +5 -10
  19. package/telegram-plugin/dist/bridge/bridge.js +113 -113
  20. package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
  21. package/telegram-plugin/dist/server.js +161 -161
  22. package/telegram-plugin/draft-stream.ts +4 -4
  23. package/telegram-plugin/format.ts +427 -680
  24. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  25. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  26. package/telegram-plugin/gateway/approval-card.ts +16 -6
  27. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  28. package/telegram-plugin/gateway/auth-command.ts +74 -74
  29. package/telegram-plugin/gateway/auth-line.ts +5 -15
  30. package/telegram-plugin/gateway/boot-card.ts +20 -22
  31. package/telegram-plugin/gateway/boot-version.ts +3 -2
  32. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  33. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  34. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  35. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  36. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  37. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  38. package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
  39. package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
  40. package/telegram-plugin/gateway/effort-command.ts +17 -17
  41. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  42. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  43. package/telegram-plugin/gateway/gateway.ts +881 -633
  44. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  45. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  46. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  47. package/telegram-plugin/gateway/ipc-server.ts +39 -6
  48. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  49. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  50. package/telegram-plugin/gateway/model-command.ts +25 -25
  51. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  52. package/telegram-plugin/gateway/permission-timeout.ts +76 -0
  53. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  54. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  55. package/telegram-plugin/issues-card.ts +6 -7
  56. package/telegram-plugin/model-unavailable.ts +8 -12
  57. package/telegram-plugin/operator-events-history.ts +1 -1
  58. package/telegram-plugin/operator-events.ts +24 -28
  59. package/telegram-plugin/package.json +1 -1
  60. package/telegram-plugin/pending-work-progress.ts +36 -36
  61. package/telegram-plugin/permission-title.ts +39 -20
  62. package/telegram-plugin/pty-partial-handler.ts +5 -13
  63. package/telegram-plugin/quota-check.ts +5 -5
  64. package/telegram-plugin/quota-watch.ts +13 -18
  65. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  66. package/telegram-plugin/registry/turns-schema.ts +43 -3
  67. package/telegram-plugin/retry-api-call.ts +42 -7
  68. package/telegram-plugin/rich-send.ts +86 -0
  69. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  70. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  71. package/telegram-plugin/shared/bot-runtime.ts +29 -7
  72. package/telegram-plugin/silence-poke.ts +26 -69
  73. package/telegram-plugin/silent-reply-anchor.ts +9 -2
  74. package/telegram-plugin/slot-banner-driver.ts +9 -6
  75. package/telegram-plugin/slot-banner.ts +5 -8
  76. package/telegram-plugin/status-no-truncate.ts +11 -5
  77. package/telegram-plugin/steering.ts +0 -4
  78. package/telegram-plugin/stream-controller.ts +59 -62
  79. package/telegram-plugin/stream-reply-handler.ts +49 -98
  80. package/telegram-plugin/subagent-watcher.ts +2 -2
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  82. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  83. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  84. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  87. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  88. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  89. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  90. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  91. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  92. package/telegram-plugin/tests/card-format.test.ts +6 -4
  93. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  94. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  95. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  96. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  97. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  98. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  99. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
  100. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  101. package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
  102. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  103. package/telegram-plugin/tests/model-command.test.ts +2 -2
  104. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  105. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  106. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  107. package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
  108. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  109. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
  110. package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +88 -41
  112. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  113. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  114. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  115. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  116. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  117. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  118. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  119. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  120. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  121. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  122. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  123. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  124. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  125. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  126. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  127. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  128. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  129. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  130. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  131. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  132. package/telegram-plugin/text-voice-scrub.ts +8 -11
  133. package/telegram-plugin/tool-activity-summary.ts +29 -29
  134. package/telegram-plugin/welcome-text.ts +82 -83
  135. package/telegram-plugin/worker-activity-feed.ts +2 -3
  136. package/telegram-plugin/html-sanitize.ts +0 -244
  137. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  138. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -41,8 +41,10 @@ function makeDeps(overrides: Partial<InjectDeps> = {}): {
41
41
  },
42
42
  getAgentName: () => 'gymbro',
43
43
  getArgs: () => '/cost',
44
- escapeHtml: (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
45
- preBlock: (s) => `<pre>${s}</pre>`,
44
+ // #2669: production now passes the markdown escaper + fenced-block helper
45
+ // (matches gateway.ts's escapeHtmlForTg / preBlock).
46
+ escapeHtml: (s) => s.replace(/([\\`*_~=\[\]|])/g, '\\$1'),
47
+ preBlock: (s) => '```\n' + s + '\n```',
46
48
  ...overrides,
47
49
  }
48
50
  return { deps, replies }
@@ -112,15 +114,15 @@ describe('handleInjectCommand — outcome=ok', () => {
112
114
  expect(replies).toHaveLength(1)
113
115
  expect(replies[0].opts?.accent).toBe('done')
114
116
  expect(replies[0].text).toContain('✅')
115
- expect(replies[0].text).toContain('<code>/cost</code>')
116
- expect(replies[0].text).toContain('<pre>Total cost: $0.42</pre>')
117
+ expect(replies[0].text).toContain('`/cost`')
118
+ expect(replies[0].text).toContain('```\nTotal cost: $0.42\n```')
117
119
  })
118
120
 
119
- it('appends <i>truncated</i> when output truncated', async () => {
121
+ it('appends _truncated_ when output truncated', async () => {
120
122
  const inject = vi.fn().mockResolvedValue(okResult('/cost', 'a lot of text', true))
121
123
  const { deps, replies } = makeDeps({ getArgs: () => '/cost', inject })
122
124
  await handleInjectCommand(fakeCtx(), deps)
123
- expect(replies[0].text).toContain('<i>truncated</i>')
125
+ expect(replies[0].text).toContain('_truncated_')
124
126
  expect(replies[0].opts?.accent).toBe('done')
125
127
  })
126
128
 
@@ -138,9 +140,9 @@ describe('handleInjectCommand — outcome=ok_no_output', () => {
138
140
  const { deps, replies } = makeDeps({ getArgs: () => '/compact', inject })
139
141
  await handleInjectCommand(fakeCtx(), deps)
140
142
  expect(replies[0].opts?.accent).toBe('done')
141
- expect(replies[0].text).toContain('<code>/compact</code>')
143
+ expect(replies[0].text).toContain('`/compact`')
142
144
  expect(replies[0].text).toContain('compaction runs silently')
143
- expect(replies[0].text).not.toContain('<pre>')
145
+ expect(replies[0].text).not.toContain('```')
144
146
  })
145
147
 
146
148
  it('warns with accent=issue when expectsOutput=true and capture empty (/cost)', async () => {
@@ -157,10 +159,10 @@ describe('handleInjectCommand — outcome=ok_no_output', () => {
157
159
  const { deps, replies } = makeDeps({ getArgs: () => '/clear', inject })
158
160
  await handleInjectCommand(fakeCtx(), deps)
159
161
  expect(replies[0].opts?.accent).toBe('done')
160
- expect(replies[0].text).toContain('<code>/clear</code>')
162
+ expect(replies[0].text).toContain('`/clear`')
161
163
  expect(replies[0].text).toContain('context cleared')
162
164
  expect(replies[0].text).not.toContain('empty capture')
163
- expect(replies[0].text).not.toContain('<pre>')
165
+ expect(replies[0].text).not.toContain('```')
164
166
  })
165
167
  })
166
168
 
@@ -186,7 +188,7 @@ describe('handleInjectCommand — outcome=failed', () => {
186
188
  const { deps, replies } = makeDeps({ getArgs: () => '/login', inject })
187
189
  await handleInjectCommand(fakeCtx(), deps)
188
190
  expect(replies[0].opts?.accent).toBe('issue')
189
- expect(replies[0].text).toContain('<code>/login</code>')
191
+ expect(replies[0].text).toContain('`/login`')
190
192
  expect(replies[0].text).toContain('blocked: would mutate auth state')
191
193
  })
192
194
 
@@ -201,7 +203,7 @@ describe('handleInjectCommand — outcome=failed', () => {
201
203
  expect(replies[0].text).toContain('experimental.legacy_pty')
202
204
  })
203
205
 
204
- it('tmux_failed: surfaces escaped error message', async () => {
206
+ it('tmux_failed: surfaces the error message (< > literal in markdown, #2669)', async () => {
205
207
  const inject = vi
206
208
  .fn()
207
209
  .mockResolvedValue(failedResult('/cost', 'tmux_failed', 'connection <refused>'))
@@ -209,7 +211,7 @@ describe('handleInjectCommand — outcome=failed', () => {
209
211
  await handleInjectCommand(fakeCtx(), deps)
210
212
  expect(replies[0].opts?.accent).toBe('issue')
211
213
  expect(replies[0].text).toContain('tmux send-keys failed')
212
- expect(replies[0].text).toContain('connection &lt;refused&gt;')
214
+ expect(replies[0].text).toContain('connection <refused>')
213
215
  })
214
216
 
215
217
  it('thrown InjectError from inject() is normalized to outcome=failed', async () => {
@@ -57,13 +57,13 @@ function shapeReply(
57
57
  slashCommand: string,
58
58
  deps: Pick<InjectDeps, 'escapeHtml' | 'preBlock' | 'formatOutput'>,
59
59
  ): { body: string; accent: InjectAccent } {
60
- const verbHtml = `<code>${deps.escapeHtml(result.command || slashCommand)}</code>`
60
+ const verbHtml = `\`${deps.escapeHtml(result.command || slashCommand)}\``
61
61
 
62
62
  if (result.outcome === 'ok') {
63
63
  const formatted = deps.formatOutput ? deps.formatOutput(result.output) : result.output
64
64
  let body = `${verbHtml}\n${deps.preBlock(formatted)}`
65
65
  if (result.diagnostic === 'truncated_output' || result.truncated) {
66
- body += '\n<i>truncated</i>'
66
+ body += '\n_truncated_'
67
67
  }
68
68
  return { body, accent: 'done' }
69
69
  }
@@ -105,13 +105,13 @@ function shapeReply(
105
105
  }
106
106
  if (code === 'session_missing') {
107
107
  return {
108
- body: 'tmux session not found — agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.',
108
+ body: 'tmux session not found — agent must be running under the tmux supervisor (the default). Remove \`experimental.legacy_pty: true\` if set.',
109
109
  accent: 'issue',
110
110
  }
111
111
  }
112
112
  if (code === 'invalid') {
113
113
  return {
114
- body: 'usage: <code>/inject /&lt;command&gt;</code>',
114
+ body: 'usage: \`/inject /<command>\`',
115
115
  accent: 'issue',
116
116
  }
117
117
  }
@@ -140,7 +140,7 @@ export async function handleInjectCommand(ctx: Context, deps: InjectDeps): Promi
140
140
  const allow = [...INJECT_COMMANDS.keys()].sort().join(', ')
141
141
  await deps.reply(
142
142
  ctx,
143
- `Usage: <code>/inject &lt;slashCommand&gt;</code>\nAllowed: <code>${deps.escapeHtml(allow)}</code>`,
143
+ `Usage: \`/inject <slashCommand>\`\nAllowed: \`${deps.escapeHtml(allow)}\``,
144
144
  { html: true },
145
145
  )
146
146
  return
@@ -462,6 +462,37 @@ export interface SendOutboundMessage {
462
462
  parseMode?: "html" | "text";
463
463
  }
464
464
 
465
+ /**
466
+ * #2670 (one-tap self-improvement) — post a skill-improvement proposal as
467
+ * a one-tap Approve/Dismiss card. Sent by the weekly skill-synthesis flow
468
+ * (`switchroom self-improve propose-skill`) after it drafts a candidate
469
+ * personal skill. The gateway PERSISTS the proposal (full draft bundle) to
470
+ * the self-improve proposal store and posts the card to the agent's own
471
+ * chat. The Approve tap is handled gateway-side: it injects a
472
+ * `skill_proposal_apply` turn so the agent writes the stored draft through
473
+ * the personal-skill pipeline (secret-scan runs; no self-escalation).
474
+ *
475
+ * Trust model identical to send_outbound / inject_inbound: per-agent
476
+ * socket, agentName validated, chat fenced to the agent's own chat.
477
+ */
478
+ export interface PostSkillProposalMessage {
479
+ type: "post_skill_proposal";
480
+ agentName: string;
481
+ /** Agent's own chat id (fenced server-side). */
482
+ chatId: string;
483
+ threadId?: number;
484
+ /** Target personal-skill slug (without the `personal-` prefix). */
485
+ skillSlug: string;
486
+ /** True = new skill; false = edit an existing personal skill. */
487
+ isNew: boolean;
488
+ /** One-line lesson the skill captures. */
489
+ lesson: string;
490
+ /** Short evidence line (e.g. "seen across 3 sessions"). */
491
+ evidence: string;
492
+ /** Full drafted skill bundle (SKILL.md + optional files). */
493
+ draft: Record<string, string>;
494
+ }
495
+
465
496
  export type ClientToGateway =
466
497
  | RegisterMessage
467
498
  | ToolCallMessage
@@ -478,4 +509,5 @@ export type ClientToGateway =
478
509
  | RequestConfigApprovalMessage
479
510
  | RequestConfigFinalizeMessage
480
511
  | QuotaWallDetectedMessage
481
- | SendOutboundMessage;
512
+ | SendOutboundMessage
513
+ | PostSkillProposalMessage;
@@ -6,6 +6,7 @@ import type {
6
6
  InjectInboundMessage,
7
7
  SendOutboundMessage,
8
8
  QuotaWallDetectedMessage,
9
+ PostSkillProposalMessage,
9
10
  OperatorEventForward,
10
11
  PermissionRequestForward,
11
12
  PtyPartialForward,
@@ -19,6 +20,7 @@ import type {
19
20
  ToolCallMessage,
20
21
  ToolCallResult,
21
22
  } from "./ipc-protocol.js";
23
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
22
24
 
23
25
  export interface IpcServerOptions {
24
26
  socketPath: string;
@@ -63,6 +65,13 @@ export interface IpcServerOptions {
63
65
  * don't run failover simply ignore it.
64
66
  */
65
67
  onQuotaWallDetected?: (client: IpcClient, msg: QuotaWallDetectedMessage) => void;
68
+ /**
69
+ * #2670 one-tap self-improvement — persist a skill-improvement proposal
70
+ * and post its Approve/Dismiss card. Handler persists the draft bundle to
71
+ * the self-improve proposal store and posts the card to the agent's own
72
+ * chat. Optional; gateways that don't surface proposals ignore it.
73
+ */
74
+ onPostSkillProposal?: (client: IpcClient, msg: PostSkillProposalMessage) => void;
66
75
  /**
67
76
  * RFC E §4.2 Cut 2 — Drive-write PreToolUse hook asks the gateway
68
77
  * to register a kernel approval request + post a diff-preview
@@ -222,9 +231,11 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
222
231
  case "pty_partial":
223
232
  // Extracted reply text from PTY-tail. May be empty (the extractor
224
233
  // returns empty strings for "no text yet" snapshots — gateway
225
- // handler dedups on lastPtyPreviewByChat). Capped at 8192 to
226
- // give some headroom over Telegram's 4096-char wire limit while
227
- // still bounding buffer growth from a runaway extractor.
234
+ // handler dedups on lastPtyPreviewByChat). Capped at 8192 — this is a
235
+ // preview-buffer bound on the PTY tail, not the outbound wire cap (which
236
+ // is now RICH_MESSAGE_MAX_CHARS / 32768 on the rich path post-#2669) —
237
+ // sized to bound buffer growth from a runaway extractor while still
238
+ // carrying a useful preview.
228
239
  return typeof m.text === "string"
229
240
  && (m.text as string).length <= 8192;
230
241
  case "update_placeholder":
@@ -262,15 +273,33 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
262
273
  if (typeof m.agentName !== "string"
263
274
  || !AGENT_NAME_RE.test(m.agentName as string)) return false;
264
275
  if (typeof m.chatId !== "string" || (m.chatId as string).length === 0) return false;
265
- // text non-empty and bounded — Telegram caps a message at 4096 chars;
266
- // reject over-long here (defense in depth against a malformed payload).
276
+ // text non-empty and bounded — the send_outbound handler posts via
277
+ // sendRichMessage (rich path), whose wire cap is RICH_MESSAGE_MAX_CHARS
278
+ // (32768) post-#2669, not the legacy 4096 plain-text limit. Reject
279
+ // over-long here (defense in depth against a malformed payload).
267
280
  if (typeof m.text !== "string" || (m.text as string).length === 0
268
- || (m.text as string).length > 4096) return false;
281
+ || (m.text as string).length > RICH_MESSAGE_MAX_CHARS) return false;
269
282
  if (m.threadId !== undefined
270
283
  && (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
271
284
  if (m.parseMode !== undefined && m.parseMode !== "html" && m.parseMode !== "text") return false;
272
285
  return true;
273
286
  }
287
+ case "post_skill_proposal": {
288
+ // #2670 one-tap self-improvement — validate the wire shape; the
289
+ // gateway handler fences chatId to the agent's own chat.
290
+ if (typeof m.agentName !== "string"
291
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
292
+ if (typeof m.chatId !== "string" || (m.chatId as string).length === 0) return false;
293
+ if (typeof m.skillSlug !== "string" || (m.skillSlug as string).length === 0) return false;
294
+ if (typeof m.isNew !== "boolean") return false;
295
+ if (typeof m.lesson !== "string" || (m.lesson as string).length === 0) return false;
296
+ if (typeof m.evidence !== "string") return false;
297
+ if (typeof m.draft !== "object" || m.draft === null || Array.isArray(m.draft)) return false;
298
+ if (typeof (m.draft as Record<string, unknown>)["SKILL.md"] !== "string") return false;
299
+ if (m.threadId !== undefined
300
+ && (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
301
+ return true;
302
+ }
274
303
  case "quota_wall_detected": {
275
304
  // wedge-watchdog detected the /rate-limit-options weekly-quota menu.
276
305
  if (typeof m.agentName !== "string"
@@ -372,6 +401,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
372
401
  onInjectInbound,
373
402
  onSendOutbound,
374
403
  onQuotaWallDetected,
404
+ onPostSkillProposal,
375
405
  onRequestDriveApproval,
376
406
  onRequestMs365Approval,
377
407
  onRequestConfigApproval,
@@ -483,6 +513,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
483
513
  case "send_outbound":
484
514
  if (onSendOutbound) onSendOutbound(client, msg as SendOutboundMessage);
485
515
  break;
516
+ case "post_skill_proposal":
517
+ if (onPostSkillProposal) onPostSkillProposal(client, msg as PostSkillProposalMessage);
518
+ break;
486
519
  case "quota_wall_detected":
487
520
  if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
488
521
  break;
@@ -27,30 +27,32 @@ export const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql'
27
27
  /** The two operator-action reasons a Linear 401 can't self-heal. */
28
28
  export type LinearAuthDeadReason = 'no_bundle' | 'revoked'
29
29
 
30
- /** Minimal HTML-escape (Telegram parse_mode: 'HTML'). Kept local so the
31
- * message builder is self-contained + unit-testable without reaching into a
32
- * gateway-only escaper (the bug that shipped the first cut of this alert). */
33
- function escapeHtmlMin(s: string): string {
34
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
30
+ /** Minimal GFM-markdown escape (#2669). Kept local so the message builder is
31
+ * self-contained + unit-testable without reaching into a gateway-only
32
+ * escaper (the bug that shipped the first cut of this alert). */
33
+ function escapeMarkdownMin(s: string): string {
34
+ return s.replace(/([\\`*_~=\[\]|])/g, '\\$1')
35
35
  }
36
36
 
37
37
  /**
38
- * Build the operator-facing Telegram alert (HTML) for an un-healable Linear
39
- * auth failure. Pure + self-escaping so it can be unit-tested directly. The
40
- * gateway's `notifyLinearAuthDead` only handles dedup + transport.
38
+ * Build the operator-facing Telegram alert (GFM markdown) for an un-healable
39
+ * Linear auth failure. Pure + self-escaping so it can be unit-tested directly.
40
+ * The gateway's `notifyLinearAuthDead` only handles dedup + transport.
41
41
  */
42
42
  export function buildLinearAuthDeadMessage(agent: string, reason: LinearAuthDeadReason): string {
43
- const a = escapeHtmlMin(agent)
43
+ // Inside `code` spans the agent slug is literal (no escaping); in prose it
44
+ // is markdown-escaped.
45
+ const aEsc = escapeMarkdownMin(agent)
44
46
  const why =
45
47
  reason === 'no_bundle'
46
- ? `no refresh credentials are stored (<code>linear/${a}/oauth</code> is missing), so its daily-expiring token can't renew`
48
+ ? `no refresh credentials are stored (\`linear/${agent}/oauth\` is missing), so its daily-expiring token can't renew`
47
49
  : `its Linear refresh token was revoked`
48
50
  return (
49
- `🔑 <b>Linear auth needs you</b>\n` +
50
- `<b>${a}</b> can't reach Linear — ${why}. ` +
51
+ `🔑 **Linear auth needs you**\n` +
52
+ `**${aEsc}** can't reach Linear — ${why}. ` +
51
53
  `Its access token will keep failing until you re-authorize.\n\n` +
52
- `Re-auth (actor=app) then run <code>switchroom linear-agent setup --agent ${a} ` +
53
- `--token … --refresh-token … --client-id … --client-secret …</code> on the host, ` +
54
+ `Re-auth (actor=app) then run \`switchroom linear-agent setup --agent ${agent} ` +
55
+ `--token … --refresh-token … --client-id … --client-secret …\` on the host, ` +
54
56
  `or ask me to walk you through it.`
55
57
  )
56
58
  }
@@ -130,7 +130,7 @@ export async function runLinearAgentSetup(
130
130
  if (action === 'authorize_url') {
131
131
  const url = buildLinearAuthorizeUrl({ clientId, redirectUri })
132
132
  return text(
133
- `Open this URL in a browser to authorize <b>${agent}</b> as a Linear app actor (actor=app):\n\n${url}\n\n` +
133
+ `Open this URL in a browser to authorize **${agent}** as a Linear app actor (actor=app):\n\n${url}\n\n` +
134
134
  `After you approve, Linear redirects to ${redirectUri}?code=… (it may show a blank/error page — that's fine). ` +
135
135
  `Copy the code value from the URL bar, then run linear_agent_setup with action "complete", the same client_id + redirect_uri, ` +
136
136
  `your client_secret, and that code.`,
@@ -141,17 +141,17 @@ export interface ModelCommandReply {
141
141
  }
142
142
 
143
143
  const PERSIST_NOTE =
144
- '<i>Session-only — lasts until restart. To persist, set <code>model:</code> in switchroom.yaml and restart.</i>'
144
+ '_Session-only — lasts until restart. To persist, set \`model:\` in switchroom.yaml and restart._'
145
145
 
146
146
  function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
147
- const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `<code>${a}</code>`).join(' · ')
147
+ const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' · ')
148
148
  const lines: string[] = []
149
149
  if (reason) lines.push(`⚠️ ${deps.escapeHtml(reason)}`)
150
150
  lines.push(
151
- '<b>/model</b> — show or switch the Claude model',
152
- '<code>/model</code> — show the configured model',
153
- `<code>/model &lt;name&gt;</code> — switch the live session (${MODEL_ALIASES.map(a => `<code>${a}</code>`).join(' · ')} or a full model id)`,
154
- `<i>OpenRouter shortcuts:</i> ${srAliasExamples}`,
151
+ '**/model** — show or switch the Claude model',
152
+ '\`/model\` — show the configured model',
153
+ `\`/model <name>\` — switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' · ')} or a full model id)`,
154
+ `_OpenRouter shortcuts:_ ${srAliasExamples}`,
155
155
  PERSIST_NOTE,
156
156
  )
157
157
  return { text: lines.join('\n'), html: true }
@@ -166,14 +166,14 @@ export async function handleModelCommand(
166
166
  if (parsed.kind === 'show') {
167
167
  const configured = deps.getConfiguredModel()
168
168
  const shown = configured && configured.length > 0 ? configured : 'default'
169
- const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `<code>/model ${a}</code>`).join(' · ')
169
+ const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`/model ${a}\``).join(' · ')
170
170
  return {
171
171
  text: [
172
- `<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`,
173
- `Configured: <code>${deps.escapeHtml(shown)}</code>`,
174
- `Switch the live session: ${MODEL_ALIASES.map(a => `<code>/model ${a}</code>`).join(' · ')}`,
172
+ `**Model — ${deps.escapeHtml(deps.getAgentName())}**`,
173
+ `Configured: \`${deps.escapeHtml(shown)}\``,
174
+ `Switch the live session: ${MODEL_ALIASES.map(a => `\`/model ${a}\``).join(' · ')}`,
175
175
  `OpenRouter shortcuts: ${srAliasExamples}`,
176
- 'or <code>/model &lt;full-model-id&gt;</code>',
176
+ 'or \`/model <full-model-id>\`',
177
177
  PERSIST_NOTE,
178
178
  ].join('\n'),
179
179
  html: true,
@@ -207,14 +207,14 @@ export async function handleModelCommand(
207
207
  }
208
208
  return {
209
209
  text: [
210
- `Switching from <code>${deps.escapeHtml(currentSession)}</code> back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
210
+ `Switching from \`${deps.escapeHtml(currentSession)}\` back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
211
211
  PERSIST_NOTE,
212
212
  ].join('\n'),
213
213
  html: true,
214
214
  }
215
215
  }
216
216
 
217
- const verbHtml = `<code>/model ${deps.escapeHtml(model)}</code>`
217
+ const verbHtml = `\`/model ${deps.escapeHtml(model)}\``
218
218
  let result: InjectResult
219
219
  try {
220
220
  result = await deps.inject(deps.getAgentName(), `/model ${model}`)
@@ -231,7 +231,7 @@ export async function handleModelCommand(
231
231
  text: [
232
232
  `${verbHtml}`,
233
233
  deps.preBlock(result.output),
234
- ...(result.truncated ? ['<i>truncated</i>'] : []),
234
+ ...(result.truncated ? ['_truncated_'] : []),
235
235
  PERSIST_NOTE,
236
236
  ].join('\n'),
237
237
  html: true,
@@ -241,7 +241,7 @@ export async function handleModelCommand(
241
241
  if (result.outcome === 'ok_no_output') {
242
242
  return {
243
243
  text: [
244
- `${verbHtml} — sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active model.`,
244
+ `${verbHtml} — sent, but no response captured. The agent may be mid-turn; check \`/inject /status\` to confirm the active model.`,
245
245
  PERSIST_NOTE,
246
246
  ].join('\n'),
247
247
  html: true,
@@ -252,7 +252,7 @@ export async function handleModelCommand(
252
252
  if (result.errorCode === 'session_missing') {
253
253
  return {
254
254
  text:
255
- '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.',
255
+ '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove \`experimental.legacy_pty: true\` if set.',
256
256
  html: true,
257
257
  }
258
258
  }
@@ -447,7 +447,7 @@ export async function buildModelMenu(
447
447
  // with the discovery failure surfaced.
448
448
  const v1 = await handleModelCommand({ kind: 'show' }, deps)
449
449
  return {
450
- text: [`<i>(picker unavailable: ${deps.escapeHtml(discovered.reason)})</i>`, v1.text].join('\n'),
450
+ text: [`_(picker unavailable: ${deps.escapeHtml(discovered.reason)})_`, v1.text].join('\n'),
451
451
  html: true,
452
452
  }
453
453
  }
@@ -462,18 +462,18 @@ export async function buildModelMenu(
462
462
  const { claude: claudeOptions } = classifyDiscoveredOptions(discovered.options)
463
463
  const srOptions: ModelPickerOption[] = srNames.map((name, i) => ({ index: i, label: name, detail: '', current: false }))
464
464
  const current = claudeOptions.find((o) => o.current)
465
- const lines: string[] = [`<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`]
465
+ const lines: string[] = [`**Model — ${deps.escapeHtml(deps.getAgentName())}**`]
466
466
  if (discovered.dismissFailed) {
467
- lines.push('⚠️ <i>The picker may still be open on the agent pane — check it before switching.</i>')
467
+ lines.push('⚠️ _The picker may still be open on the agent pane — check it before switching._')
468
468
  }
469
469
  if (current) {
470
470
  const detail = current.detail ? ` · ${deps.escapeHtml(current.detail)}` : ''
471
- lines.push(`Default (new sessions): <b>${deps.escapeHtml(current.label)}</b>${detail}`)
471
+ lines.push(`Default (new sessions): **${deps.escapeHtml(current.label)}**${detail}`)
472
472
  } else {
473
- lines.push('Default (new sessions): <i>unknown (no ✔ row in picker)</i>')
473
+ lines.push('Default (new sessions): _unknown (no ✔ row in picker)_')
474
474
  }
475
475
  if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
476
- lines.push('', 'Tap a model to switch the <b>live session</b>:')
476
+ lines.push('', 'Tap a model to switch the **live session**:')
477
477
  if (srOptions.length > 0) {
478
478
  lines.push('Claude models use your Max/Pro subscription. 🌐 models are billed separately via OpenRouter.')
479
479
  }
@@ -548,7 +548,7 @@ export async function handleModelMenuCallback(
548
548
  const msg = err instanceof Error ? err.message : String(err)
549
549
  return {
550
550
  answer: 'Switch failed',
551
- reply: await menuWithBanner(deps, `❌ Switch to <b>${deps.escapeHtml(srName)}</b> failed: ${deps.escapeHtml(msg)}`),
551
+ reply: await menuWithBanner(deps, `❌ Switch to **${deps.escapeHtml(srName)}** failed: ${deps.escapeHtml(msg)}`),
552
552
  }
553
553
  }
554
554
  if (srResult.outcome === 'ok') {
@@ -572,7 +572,7 @@ export async function handleModelMenuCallback(
572
572
  answer: 'Switch failed',
573
573
  reply: await menuWithBanner(
574
574
  deps,
575
- `❌ Switch to <b>${deps.escapeHtml(srFriendlyLabel(srName))}</b> failed — agent may be mid-turn`,
575
+ `❌ Switch to **${deps.escapeHtml(srFriendlyLabel(srName))}** failed — agent may be mid-turn`,
576
576
  ),
577
577
  }
578
578
  }
@@ -628,7 +628,7 @@ export async function handleModelMenuCallback(
628
628
  answer: 'Switch failed — see the menu',
629
629
  reply: await menuWithBanner(
630
630
  deps,
631
- `❌ Switch to <b>${deps.escapeHtml(target.label)}</b> failed: ${deps.escapeHtml(result.reason)}`,
631
+ `❌ Switch to **${deps.escapeHtml(target.label)}** failed: ${deps.escapeHtml(result.reason)}`,
632
632
  ),
633
633
  }
634
634
  }
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * Shared "render-and-fit" helper for approval cards that wrap
3
- * user-supplied content in HTML framing. (#1762 / #1767)
3
+ * user-supplied content in markdown framing. (#1762 / #1767 / #2669)
4
4
  *
5
- * Telegram's `sendMessage` caps the body at 4096 chars and we render
6
- * with `parse_mode=HTML`. Worst-case escape inflates raw content up
7
- * to 5x (`&` → `&amp;`), so a naive raw-input cap is unsafe — the
8
- * post-escape body can blow past the limit and `sendMessage` then
9
- * returns a generic 400 that surfaces upstream as a silent
10
- * `E_DENIED`.
5
+ * Telegram's rich-message body caps at 32768 chars. Content shipped
6
+ * inside a fenced code block is literal (no escape inflation), but the
7
+ * surrounding framing still counts toward the cap — so a naive raw-input
8
+ * cap can blow past the limit and the send then returns a generic 400
9
+ * that surfaces upstream as a silent `E_DENIED`.
11
10
  *
12
11
  * This helper binary-searches the largest prefix of the RAW content
13
12
  * whose rendered body still fits under `cap`, snaps to the last
@@ -68,3 +68,79 @@ export function isRecentTimeoutDuplicate(
68
68
  const at = timeouts.get(sig)
69
69
  return at != null && now - at <= windowMs
70
70
  }
71
+
72
+ // ─── Bug 2 — per-tool TTL + timed-out card hygiene + stale-tap honesty ──────
73
+
74
+ /** Default operator approval-card lifetime. */
75
+ export const PERMISSION_TTL_MS = 10 * 60_000
76
+
77
+ /**
78
+ * hostd gated fleet-mutation verbs (rollout / update_apply / agent_* /
79
+ * config_propose_edit) surface an OPERATOR approval card and demand a
80
+ * human-scale decision window — 10 min is too tight when the operator is
81
+ * mid-task. The `mcp__hostd__*` family gets 30 min; everything else keeps
82
+ * the 10-min default.
83
+ */
84
+ export const HOSTD_PERMISSION_TTL_MS = 30 * 60_000
85
+
86
+ /** Per-tool approval-card TTL. */
87
+ export function ttlForTool(toolName: string | undefined): number {
88
+ return toolName && toolName.startsWith('mcp__hostd__')
89
+ ? HOSTD_PERMISSION_TTL_MS
90
+ : PERMISSION_TTL_MS
91
+ }
92
+
93
+ /** Suffix appended to a card body when its approval window times out. */
94
+ export const TIMED_OUT_FOOTER = '\n\n⏱ Timed out — re-request to act'
95
+
96
+ export interface PermissionCardRef {
97
+ chatId: string
98
+ messageId: number
99
+ }
100
+
101
+ export interface TimedOutCardEdit {
102
+ chatId: string
103
+ messageId: number
104
+ /** New body text (original card body + the timed-out footer). */
105
+ text: string
106
+ /**
107
+ * Always true: the edit MUST drop the inline keyboard (omit reply_markup)
108
+ * so the stale Allow/Deny buttons are no longer tappable — the core of
109
+ * Bug 2 fix #1. A stale tappable Approve dispatches a verdict for an
110
+ * already-resolved request_id, which Claude Code ignores → "operator
111
+ * approved but work never continued".
112
+ */
113
+ stripKeyboard: true
114
+ }
115
+
116
+ /**
117
+ * Build the (pure) list of card edits the reaper applies on TTL auto-deny:
118
+ * re-render each recorded card's original body with the timed-out footer and
119
+ * mark it for keyboard-strip. One entry per card (a permission may have been
120
+ * broadcast to several operator surfaces).
121
+ */
122
+ export function buildTimedOutCardEdits(
123
+ cardText: string,
124
+ cards: readonly PermissionCardRef[],
125
+ ): TimedOutCardEdit[] {
126
+ return cards.map(({ chatId, messageId }) => ({
127
+ chatId,
128
+ messageId,
129
+ text: `${cardText}${TIMED_OUT_FOOTER}`,
130
+ stripKeyboard: true,
131
+ }))
132
+ }
133
+
134
+ /**
135
+ * A tap on a permission card is STALE when no pending entry exists for its
136
+ * request_id — the reaper already auto-denied + deleted it on TTL. A stale
137
+ * tap must NOT dispatch a verdict (the dead id is ignored by Claude Code);
138
+ * the operator instead gets an honest "already resolved" notice.
139
+ */
140
+ export function isStaleTap(hasPending: boolean): boolean {
141
+ return !hasPending
142
+ }
143
+
144
+ /** Operator-facing notice shown when a stale (timed-out) card is tapped. */
145
+ export const STALE_TAP_NOTICE =
146
+ 'This request already resolved (timed out) — ask again to act.'