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.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2936 -119
- package/dist/cli/switchroom.js +804 -465
- package/dist/host-control/main.js +169 -163
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +4 -4
- package/telegram-plugin/answer-stream.ts +20 -49
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +113 -113
- package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
- package/telegram-plugin/dist/server.js +161 -161
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +427 -680
- 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/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- 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 +881 -633
- 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 +39 -6
- 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/permission-timeout.ts +76 -0
- 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 +39 -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 +43 -3
- package/telegram-plugin/retry-api-call.ts +42 -7
- package/telegram-plugin/rich-send.ts +86 -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 +29 -7
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +49 -98
- 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/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- 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/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +88 -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,
|
|
@@ -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
|
|
226
|
-
//
|
|
227
|
-
//
|
|
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 —
|
|
266
|
-
//
|
|
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 >
|
|
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
|
|
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
|
|
@@ -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.'
|