switchroom 0.16.22 β†’ 0.16.24

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 (118) hide show
  1. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  2. package/dist/cli/skill-validate-pretool.mjs +2865 -48
  3. package/dist/cli/switchroom.js +338 -92
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +5 -5
  6. package/telegram-plugin/answer-stream.ts +13 -43
  7. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  8. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  9. package/telegram-plugin/card-format.ts +28 -25
  10. package/telegram-plugin/credits-watch.ts +5 -10
  11. package/telegram-plugin/dist/gateway/gateway.js +1670 -1892
  12. package/telegram-plugin/draft-stream.ts +4 -4
  13. package/telegram-plugin/format.ts +101 -662
  14. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  15. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  16. package/telegram-plugin/gateway/approval-card.ts +16 -6
  17. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  18. package/telegram-plugin/gateway/auth-command.ts +74 -74
  19. package/telegram-plugin/gateway/auth-line.ts +5 -15
  20. package/telegram-plugin/gateway/boot-card.ts +20 -22
  21. package/telegram-plugin/gateway/boot-version.ts +3 -2
  22. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  23. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  24. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  25. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  26. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  27. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  28. package/telegram-plugin/gateway/effort-command.ts +17 -17
  29. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  30. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  31. package/telegram-plugin/gateway/gateway.ts +751 -624
  32. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  33. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  34. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  35. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  36. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  37. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  38. package/telegram-plugin/gateway/model-command.ts +25 -25
  39. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  40. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  41. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  42. package/telegram-plugin/issues-card.ts +6 -7
  43. package/telegram-plugin/model-unavailable.ts +8 -12
  44. package/telegram-plugin/operator-events-history.ts +1 -1
  45. package/telegram-plugin/operator-events.ts +24 -28
  46. package/telegram-plugin/package.json +1 -1
  47. package/telegram-plugin/pending-work-progress.ts +36 -36
  48. package/telegram-plugin/permission-title.ts +36 -20
  49. package/telegram-plugin/pty-partial-handler.ts +5 -13
  50. package/telegram-plugin/quota-check.ts +5 -5
  51. package/telegram-plugin/quota-watch.ts +13 -18
  52. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  53. package/telegram-plugin/registry/turns-schema.ts +44 -2
  54. package/telegram-plugin/retry-api-call.ts +15 -7
  55. package/telegram-plugin/rich-send.ts +57 -0
  56. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  57. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  58. package/telegram-plugin/shared/bot-runtime.ts +23 -6
  59. package/telegram-plugin/silence-poke.ts +26 -69
  60. package/telegram-plugin/slot-banner-driver.ts +9 -6
  61. package/telegram-plugin/slot-banner.ts +5 -8
  62. package/telegram-plugin/steering.ts +0 -4
  63. package/telegram-plugin/stream-controller.ts +59 -62
  64. package/telegram-plugin/stream-reply-handler.ts +40 -97
  65. package/telegram-plugin/subagent-watcher.ts +2 -2
  66. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  67. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  68. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  69. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  70. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  71. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  72. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  73. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  74. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  75. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  76. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  77. package/telegram-plugin/tests/card-format.test.ts +6 -4
  78. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  79. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  80. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  81. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  82. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  83. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  84. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  85. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  86. package/telegram-plugin/tests/model-command.test.ts +2 -2
  87. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  88. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  89. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  90. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  91. package/telegram-plugin/tests/permission-title.test.ts +45 -41
  92. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  93. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  94. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  95. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  96. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  97. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  98. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  99. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  100. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  101. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  102. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts β†’ stream-controller-parse-fallback.test.ts} +40 -42
  103. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  104. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  105. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  106. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  107. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  108. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  109. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  110. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  111. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  112. package/telegram-plugin/text-voice-scrub.ts +8 -11
  113. package/telegram-plugin/tool-activity-summary.ts +29 -29
  114. package/telegram-plugin/welcome-text.ts +82 -83
  115. package/telegram-plugin/worker-activity-feed.ts +2 -3
  116. package/telegram-plugin/html-sanitize.ts +0 -244
  117. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  118. 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,
@@ -63,6 +64,13 @@ export interface IpcServerOptions {
63
64
  * don't run failover simply ignore it.
64
65
  */
65
66
  onQuotaWallDetected?: (client: IpcClient, msg: QuotaWallDetectedMessage) => void;
67
+ /**
68
+ * #2670 one-tap self-improvement β€” persist a skill-improvement proposal
69
+ * and post its Approve/Dismiss card. Handler persists the draft bundle to
70
+ * the self-improve proposal store and posts the card to the agent's own
71
+ * chat. Optional; gateways that don't surface proposals ignore it.
72
+ */
73
+ onPostSkillProposal?: (client: IpcClient, msg: PostSkillProposalMessage) => void;
66
74
  /**
67
75
  * RFC E Β§4.2 Cut 2 β€” Drive-write PreToolUse hook asks the gateway
68
76
  * to register a kernel approval request + post a diff-preview
@@ -271,6 +279,22 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
271
279
  if (m.parseMode !== undefined && m.parseMode !== "html" && m.parseMode !== "text") return false;
272
280
  return true;
273
281
  }
282
+ case "post_skill_proposal": {
283
+ // #2670 one-tap self-improvement β€” validate the wire shape; the
284
+ // gateway handler fences chatId to the agent's own chat.
285
+ if (typeof m.agentName !== "string"
286
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
287
+ if (typeof m.chatId !== "string" || (m.chatId as string).length === 0) return false;
288
+ if (typeof m.skillSlug !== "string" || (m.skillSlug as string).length === 0) return false;
289
+ if (typeof m.isNew !== "boolean") return false;
290
+ if (typeof m.lesson !== "string" || (m.lesson as string).length === 0) return false;
291
+ if (typeof m.evidence !== "string") return false;
292
+ if (typeof m.draft !== "object" || m.draft === null || Array.isArray(m.draft)) return false;
293
+ if (typeof (m.draft as Record<string, unknown>)["SKILL.md"] !== "string") return false;
294
+ if (m.threadId !== undefined
295
+ && (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
296
+ return true;
297
+ }
274
298
  case "quota_wall_detected": {
275
299
  // wedge-watchdog detected the /rate-limit-options weekly-quota menu.
276
300
  if (typeof m.agentName !== "string"
@@ -372,6 +396,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
372
396
  onInjectInbound,
373
397
  onSendOutbound,
374
398
  onQuotaWallDetected,
399
+ onPostSkillProposal,
375
400
  onRequestDriveApproval,
376
401
  onRequestMs365Approval,
377
402
  onRequestConfigApproval,
@@ -483,6 +508,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
483
508
  case "send_outbound":
484
509
  if (onSendOutbound) onSendOutbound(client, msg as SendOutboundMessage);
485
510
  break;
511
+ case "post_skill_proposal":
512
+ if (onPostSkillProposal) onPostSkillProposal(client, msg as PostSkillProposalMessage);
513
+ break;
486
514
  case "quota_wall_detected":
487
515
  if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
488
516
  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
@@ -0,0 +1,167 @@
1
+ /**
2
+ * One-tap skill-improvement proposal card (#2670, "one-tap
3
+ * self-improvement").
4
+ *
5
+ * The weekly skill-synthesis cron drafts a proposed personal skill and
6
+ * persists it (full draft bundle) in the self-improve proposal store
7
+ * (`src/self-improve/skill-proposals.ts`). This module renders that
8
+ * proposal as a Telegram Approve / Dismiss card and builds the synthetic
9
+ * inbound the gateway injects when the operator taps Approve β€” instructing
10
+ * the live agent to APPLY the stored draft through the existing
11
+ * `skill_init_personal` / `skill_edit_personal` write pipeline (so the
12
+ * merged `scanBundleForSecrets` gate runs; the agent never self-applies β€”
13
+ * the operator tap is the authorization, satisfying `no-self-escalation`).
14
+ *
15
+ * Pure builders, kept out of gateway.ts so the card text + callback shape
16
+ * + inbound shape are pinned by tests independent of the bot plumbing.
17
+ *
18
+ * callback_data shape (must fit Telegram's 64-byte limit):
19
+ * skprop:approve:<id>
20
+ * skprop:deny:<id>
21
+ * where <id> is the proposal's UUID from the store.
22
+ */
23
+
24
+ import type { InboundMessage } from './ipc-protocol.js'
25
+
26
+ export const SKILL_PROPOSAL_CALLBACK_PREFIX = 'skprop:'
27
+
28
+ /** Narrow shape of a stored proposal the card/inbound builders need.
29
+ * Mirrors SkillProposal in src/self-improve/skill-proposals.ts without
30
+ * importing across the tsconfig boundary. */
31
+ export interface SkillProposalView {
32
+ id: string
33
+ skill_slug: string
34
+ is_new: boolean
35
+ lesson: string
36
+ evidence: string
37
+ /** SKILL.md body, for the optional "show full draft" / step preview. */
38
+ skill_md?: string
39
+ }
40
+
41
+ function escapeHtml(s: string): string {
42
+ return s
43
+ .replace(/&/g, '&amp;')
44
+ .replace(/</g, '&lt;')
45
+ .replace(/>/g, '&gt;')
46
+ }
47
+
48
+ /** A short bullet preview of the drafted procedure (first few non-empty,
49
+ * non-frontmatter lines of SKILL.md), capped for the card. */
50
+ export function previewSteps(skillMd: string | undefined, maxLines = 5): string[] {
51
+ if (!skillMd) return []
52
+ // Drop YAML frontmatter block if present.
53
+ let body = skillMd
54
+ if (body.startsWith('---')) {
55
+ const end = body.indexOf('\n---', 3)
56
+ if (end >= 0) body = body.slice(body.indexOf('\n', end + 1) + 1)
57
+ }
58
+ const out: string[] = []
59
+ for (const raw of body.split('\n')) {
60
+ const line = raw.trim()
61
+ if (!line || line.startsWith('#')) continue
62
+ out.push(line.replace(/^[-*\d.)\s]+/, '').slice(0, 120))
63
+ if (out.length >= maxLines) break
64
+ }
65
+ return out
66
+ }
67
+
68
+ /** Render the proposal card text (HTML parse mode). */
69
+ export function renderSkillProposalCard(p: SkillProposalView): string {
70
+ const verb = p.is_new ? 'New personal skill' : 'Update to personal skill'
71
+ const lines: string[] = []
72
+ lines.push(`🧩 <b>Skill improvement proposed</b>`)
73
+ lines.push('')
74
+ lines.push(`<b>${escapeHtml(verb)}:</b> <code>${escapeHtml(p.skill_slug)}</code>`)
75
+ lines.push(`<b>Lesson:</b> ${escapeHtml(p.lesson)}`)
76
+ if (p.evidence) lines.push(`<i>${escapeHtml(p.evidence)}</i>`)
77
+ const steps = previewSteps(p.skill_md)
78
+ if (steps.length > 0) {
79
+ lines.push('')
80
+ lines.push('<b>Draft procedure:</b>')
81
+ for (const s of steps) lines.push(`β€’ ${escapeHtml(s)}`)
82
+ }
83
+ lines.push('')
84
+ lines.push(
85
+ '<i>Tap Add to apply it (runs through the secret-scan write pipeline). ' +
86
+ 'Tap Dismiss and it won’t be proposed again.</i>',
87
+ )
88
+ return lines.join('\n')
89
+ }
90
+
91
+ /** Inline keyboard for the proposal card. */
92
+ export function skillProposalKeyboard(id: string): {
93
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
94
+ } {
95
+ return {
96
+ inline_keyboard: [
97
+ [
98
+ { text: 'βœ… Add skill', callback_data: `${SKILL_PROPOSAL_CALLBACK_PREFIX}approve:${id}` },
99
+ { text: '🚫 Dismiss', callback_data: `${SKILL_PROPOSAL_CALLBACK_PREFIX}deny:${id}` },
100
+ ],
101
+ ],
102
+ }
103
+ }
104
+
105
+ /** Parse a `skprop:` callback into { action, id }, or null if not ours. */
106
+ export function parseSkillProposalCallback(
107
+ data: string,
108
+ ): { action: 'approve' | 'deny'; id: string } | null {
109
+ if (!data.startsWith(SKILL_PROPOSAL_CALLBACK_PREFIX)) return null
110
+ const rest = data.slice(SKILL_PROPOSAL_CALLBACK_PREFIX.length)
111
+ const idx = rest.indexOf(':')
112
+ if (idx < 0) return null
113
+ const action = rest.slice(0, idx)
114
+ const id = rest.slice(idx + 1)
115
+ if ((action !== 'approve' && action !== 'deny') || id.length === 0) return null
116
+ return { action, id }
117
+ }
118
+
119
+ export interface SkillProposalInboundContext {
120
+ agent: string
121
+ chat_id: string
122
+ threadId?: number
123
+ }
124
+
125
+ /**
126
+ * Build the synthetic inbound injected when the operator taps Approve.
127
+ * It instructs the live agent to apply the stored draft via the personal-
128
+ * skill write pipeline β€” the agent never self-applies; this tap is the
129
+ * authorization. `meta.source = "skill_proposal_apply"` so the bridge
130
+ * renders it as `<channel source="skill_proposal_apply">`.
131
+ */
132
+ export function buildSkillProposalApplyInbound(opts: {
133
+ ctx: SkillProposalInboundContext
134
+ proposalId: string
135
+ skillSlug: string
136
+ isNew: boolean
137
+ operatorId: string
138
+ nowMs?: number
139
+ }): InboundMessage {
140
+ const ts = opts.nowMs ?? Date.now()
141
+ const tool = opts.isNew ? 'skill_init_personal' : 'skill_edit_personal'
142
+ return {
143
+ type: 'inbound',
144
+ chatId: opts.ctx.chat_id,
145
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
146
+ messageId: ts,
147
+ user: 'skill-synthesis',
148
+ userId: 0,
149
+ ts,
150
+ text:
151
+ `βœ… Operator approved your proposed skill \`${opts.skillSlug}\` ` +
152
+ `(proposal ${opts.proposalId}). Apply it now: read the stored draft ` +
153
+ `from the self-improve proposal store and call \`${tool}\` with that ` +
154
+ `exact draft bundle. Do NOT re-draft or add anything from this ` +
155
+ `conversation β€” write the stored bundle verbatim so the secret-scan ` +
156
+ `write pipeline validates it. Confirm to the operator when it lands.`,
157
+ meta: {
158
+ source: 'skill_proposal_apply',
159
+ agent: opts.ctx.agent,
160
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
161
+ proposal_id: opts.proposalId,
162
+ skill_slug: opts.skillSlug,
163
+ is_new: String(opts.isNew),
164
+ operator_id: opts.operatorId,
165
+ },
166
+ }
167
+ }