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.
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2865 -48
- package/dist/cli/switchroom.js +338 -92
- package/dist/host-control/main.js +1 -1
- package/package.json +5 -5
- package/telegram-plugin/answer-stream.ts +13 -43
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/gateway/gateway.js +1670 -1892
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +101 -662
- package/telegram-plugin/gateway/approval-callback.ts +2 -3
- package/telegram-plugin/gateway/approval-card.test.ts +17 -4
- package/telegram-plugin/gateway/approval-card.ts +16 -6
- package/telegram-plugin/gateway/approvals-commands.ts +18 -24
- package/telegram-plugin/gateway/auth-command.ts +74 -74
- package/telegram-plugin/gateway/auth-line.ts +5 -15
- package/telegram-plugin/gateway/boot-card.ts +20 -22
- package/telegram-plugin/gateway/boot-version.ts +3 -2
- package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
- package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
- package/telegram-plugin/gateway/config-snapshot.ts +9 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
- package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
- package/telegram-plugin/gateway/effort-command.ts +17 -17
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
- package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
- package/telegram-plugin/gateway/gateway.ts +751 -624
- package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
- package/telegram-plugin/gateway/inject-handler.ts +5 -5
- package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/linear-activity.ts +16 -14
- package/telegram-plugin/gateway/linear-setup.ts +1 -1
- package/telegram-plugin/gateway/model-command.ts +25 -25
- package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
- package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
- package/telegram-plugin/issues-card.ts +6 -7
- package/telegram-plugin/model-unavailable.ts +8 -12
- package/telegram-plugin/operator-events-history.ts +1 -1
- package/telegram-plugin/operator-events.ts +24 -28
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/pending-work-progress.ts +36 -36
- package/telegram-plugin/permission-title.ts +36 -20
- package/telegram-plugin/pty-partial-handler.ts +5 -13
- package/telegram-plugin/quota-check.ts +5 -5
- package/telegram-plugin/quota-watch.ts +13 -18
- package/telegram-plugin/recent-outbound-dedup.ts +5 -5
- package/telegram-plugin/registry/turns-schema.ts +44 -2
- package/telegram-plugin/retry-api-call.ts +15 -7
- package/telegram-plugin/rich-send.ts +57 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
- package/telegram-plugin/secret-detect/vault-error.ts +29 -22
- package/telegram-plugin/shared/bot-runtime.ts +23 -6
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +40 -97
- package/telegram-plugin/subagent-watcher.ts +2 -2
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
- package/telegram-plugin/tests/answer-stream.test.ts +54 -63
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
- package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bot-api.harness.ts +23 -1
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
- package/telegram-plugin/tests/card-format.test.ts +6 -4
- package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
- package/telegram-plugin/tests/credits-watch.test.ts +5 -5
- package/telegram-plugin/tests/fake-bot-api.ts +58 -4
- package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
- package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
- package/telegram-plugin/tests/operator-events.test.ts +7 -9
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-title.test.ts +45 -41
- package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
- package/telegram-plugin/tests/quota-check.test.ts +3 -3
- package/telegram-plugin/tests/quota-watch.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
- package/telegram-plugin/tests/silence-poke.test.ts +75 -112
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
- package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
- package/telegram-plugin/tests/slot-banner.test.ts +9 -6
- package/telegram-plugin/tests/status-accent.test.ts +29 -32
- package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts β stream-controller-parse-fallback.test.ts} +40 -42
- package/telegram-plugin/tests/stream-controller.test.ts +63 -52
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
- package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
- package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
- package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
- package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
- package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
- package/telegram-plugin/tests/welcome-text.test.ts +72 -65
- package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
- package/telegram-plugin/text-voice-scrub.ts +8 -11
- package/telegram-plugin/tool-activity-summary.ts +29 -29
- package/telegram-plugin/welcome-text.ts +82 -83
- package/telegram-plugin/worker-activity-feed.ts +2 -3
- package/telegram-plugin/html-sanitize.ts +0 -244
- package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
- 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
|
-
|
|
45
|
-
|
|
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('
|
|
116
|
-
expect(replies[0].text).toContain('
|
|
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
|
|
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('
|
|
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('
|
|
143
|
+
expect(replies[0].text).toContain('`/compact`')
|
|
142
144
|
expect(replies[0].text).toContain('compaction runs silently')
|
|
143
|
-
expect(replies[0].text).not.toContain('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
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
|
|
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 =
|
|
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 += '\
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
function
|
|
34
|
-
return s.replace(
|
|
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 (
|
|
39
|
-
* auth failure. Pure + self-escaping so it can be unit-tested directly.
|
|
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
|
-
|
|
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 (
|
|
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
|
-
`π
|
|
50
|
-
|
|
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
|
|
53
|
-
`--token β¦ --refresh-token β¦ --client-id β¦ --client-secret
|
|
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
|
|
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
|
-
'
|
|
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 =>
|
|
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
|
-
'
|
|
152
|
-
'
|
|
153
|
-
|
|
154
|
-
|
|
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 =>
|
|
169
|
+
const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`/model ${a}\``).join(' Β· ')
|
|
170
170
|
return {
|
|
171
171
|
text: [
|
|
172
|
-
|
|
173
|
-
`Configured:
|
|
174
|
-
`Switch the live session: ${MODEL_ALIASES.map(a =>
|
|
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
|
|
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
|
|
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 =
|
|
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 ? ['
|
|
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
|
|
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
|
|
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: [
|
|
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[] = [
|
|
465
|
+
const lines: string[] = [`**Model β ${deps.escapeHtml(deps.getAgentName())}**`]
|
|
466
466
|
if (discovered.dismissFailed) {
|
|
467
|
-
lines.push('β οΈ
|
|
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):
|
|
471
|
+
lines.push(`Default (new sessions): **${deps.escapeHtml(current.label)}**${detail}`)
|
|
472
472
|
} else {
|
|
473
|
-
lines.push('Default (new sessions):
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3
|
+
* user-supplied content in markdown framing. (#1762 / #1767 / #2669)
|
|
4
4
|
*
|
|
5
|
-
* Telegram's
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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, '&')
|
|
44
|
+
.replace(/</g, '<')
|
|
45
|
+
.replace(/>/g, '>')
|
|
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
|
+
}
|