switchroom 0.19.4 → 0.19.5
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/auth-broker/index.js +7 -3
- package/dist/cli/autoaccept-poll.js +8 -2
- package/dist/cli/switchroom.js +20 -5
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +67 -4
- package/telegram-plugin/dist/gateway/gateway.js +524 -293
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +97 -255
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
- package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
- package/telegram-plugin/render/line-start-guard.ts +76 -4
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
- package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
- package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
- package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
- package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
- package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
- package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
- package/telegram-plugin/tests/silent-end.test.ts +60 -5
- package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
|
@@ -243,6 +243,7 @@ import {
|
|
|
243
243
|
distinctRequestIds,
|
|
244
244
|
} from './permission-rearm.js'
|
|
245
245
|
import { sweepPermissionTtl } from './permission-ttl-sweep.js'
|
|
246
|
+
import { hangStalenessMs } from './hang-restart-decision.js'
|
|
246
247
|
import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
|
|
247
248
|
import {
|
|
248
249
|
createAlwaysAllowPersistQueue,
|
|
@@ -437,6 +438,7 @@ const REPLY_TO_TEXT_MAX = 200
|
|
|
437
438
|
// (`silentEndFallbackText`, imported above) so the transport-boundary
|
|
438
439
|
// tests exercise the real string — see PR #2892.
|
|
439
440
|
import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
441
|
+
import { formatSwitchroomOutput, stripAnsi, escapeHtmlForTg, preBlock, getCommandArgs, hasDemoFlag, assertSafeAgentName, formatAuthOutputForTelegram, buildAuthUrlKeyboard, buildDeferredSecretKeyboard, renderVaultOpFailure, statusIcon, renderAuthCodeOutcome, buildDoctorScopeKeyboard, formatDoctorReport } from './command-format.js'
|
|
440
442
|
import { richMessage } from '../rich-send.js'
|
|
441
443
|
import { decideRedeliver, decideRedeliverCapture } from './redelivery-decision.js'
|
|
442
444
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
@@ -898,6 +900,7 @@ import {
|
|
|
898
900
|
TURN_ACTIVE_HARD_TTL_MS,
|
|
899
901
|
TURN_ACTIVE_IDLE_SWEEP_MS,
|
|
900
902
|
} from './turn-active-marker.js'
|
|
903
|
+
import { startGatewayHeartbeat } from './gateway-heartbeat.js'
|
|
901
904
|
import {
|
|
902
905
|
VERSION,
|
|
903
906
|
COMMIT_SHA,
|
|
@@ -953,7 +956,7 @@ import {
|
|
|
953
956
|
import { findAgentProcessInContainer } from './boot-probes.js'
|
|
954
957
|
import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
|
|
955
958
|
import type { InterruptedSubagent } from './resume-inbound-builder.js'
|
|
956
|
-
import { resolveWorkerFeedDispatch, handleWorkerResume, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
959
|
+
import { resolveWorkerFeedDispatch, decideWorkerFeedDestination, handleWorkerResume, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
957
960
|
import {
|
|
958
961
|
resolveSubagentStatusSurface,
|
|
959
962
|
isOrphanSubagentStatusEnabled,
|
|
@@ -2180,6 +2183,29 @@ const WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS = (() => {
|
|
|
2180
2183
|
})()
|
|
2181
2184
|
const workerFeedOwnerDmFallbackLogged = new Set<string>()
|
|
2182
2185
|
|
|
2186
|
+
// Once-per-agent owner-DM-fallback routing line (bounded FIFO set). Shared by
|
|
2187
|
+
// `resolveWorkerFeedChat` and the origin-defer paint path so both log alike.
|
|
2188
|
+
function noteWorkerFeedOwnerDmFallback(agentId: string): void {
|
|
2189
|
+
if (workerFeedOwnerDmFallbackLogged.has(agentId)) return
|
|
2190
|
+
workerFeedOwnerDmFallbackLogged.add(agentId)
|
|
2191
|
+
if (workerFeedOwnerDmFallbackLogged.size > WORKER_FEED_FALLBACK_LOG_CAP) {
|
|
2192
|
+
const oldest = workerFeedOwnerDmFallbackLogged.values().next().value
|
|
2193
|
+
if (oldest != null) workerFeedOwnerDmFallbackLogged.delete(oldest)
|
|
2194
|
+
}
|
|
2195
|
+
process.stderr.write(
|
|
2196
|
+
`telegram gateway: worker-feed origin unresolved agent=${agentId} — routing card to owner DM\n`,
|
|
2197
|
+
)
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// Per-agent consecutive origin-race deferrals (rationale in
|
|
2201
|
+
// `decideWorkerFeedOriginDefer`, worker-feed-dispatch.ts). Bounds the wait for
|
|
2202
|
+
// the `jsonl_agent_id` backfill; deleted on link, card-exists, and finish/drop
|
|
2203
|
+
// so the map stays bounded.
|
|
2204
|
+
const workerFeedOriginDeferrals = new Map<string, number>()
|
|
2205
|
+
// Watcher ticks ~1/s and the backfill retries ~every 3s, so ~10 ticks is a
|
|
2206
|
+
// comfortable ceiling on the race window before painting anyway.
|
|
2207
|
+
const WORKER_FEED_ORIGIN_DEFER_MAX = 10
|
|
2208
|
+
|
|
2183
2209
|
/**
|
|
2184
2210
|
* Resolve a worker-feed destination chat with a guaranteed last resort.
|
|
2185
2211
|
*
|
|
@@ -2202,28 +2228,20 @@ const workerFeedOwnerDmFallbackLogged = new Set<string>()
|
|
|
2202
2228
|
function resolveWorkerFeedChat(
|
|
2203
2229
|
agentId: string,
|
|
2204
2230
|
fleetChatId: string,
|
|
2231
|
+
// Origin sources threadId; when origin is null the caller can pass the
|
|
2232
|
+
// fallback chat's sibling forum-topic id so a misrouted card still lands
|
|
2233
|
+
// in the origin topic instead of General (#3458 exhausted-defer paint).
|
|
2234
|
+
fallbackThreadId?: number,
|
|
2205
2235
|
): { chatId: string; threadId?: number } {
|
|
2206
2236
|
const origin = resolveSubagentOriginChat(agentId)
|
|
2207
2237
|
if (origin != null && origin.chatId.length > 0) return origin
|
|
2208
|
-
if (fleetChatId.length > 0) return { chatId: fleetChatId }
|
|
2238
|
+
if (fleetChatId.length > 0) return { chatId: fleetChatId, threadId: fallbackThreadId }
|
|
2209
2239
|
const ownerDm = loadAccess().allowFrom[0] ?? ''
|
|
2210
2240
|
if (origin == null && fleetChatId.length === 0 && ownerDm.length > 0) {
|
|
2211
|
-
//
|
|
2212
|
-
|
|
2213
|
-
// owner DM. Logged ONCE per agent so the misroute is auditable
|
|
2214
|
-
// without spamming every tick (the watcher drives onProgress ~1/s).
|
|
2215
|
-
if (!workerFeedOwnerDmFallbackLogged.has(agentId)) {
|
|
2216
|
-
workerFeedOwnerDmFallbackLogged.add(agentId)
|
|
2217
|
-
if (workerFeedOwnerDmFallbackLogged.size > WORKER_FEED_FALLBACK_LOG_CAP) {
|
|
2218
|
-
const oldest = workerFeedOwnerDmFallbackLogged.values().next().value
|
|
2219
|
-
if (oldest != null) workerFeedOwnerDmFallbackLogged.delete(oldest)
|
|
2220
|
-
}
|
|
2221
|
-
process.stderr.write(
|
|
2222
|
-
`telegram gateway: worker-feed origin unresolved agent=${agentId} — routing card to owner DM\n`,
|
|
2223
|
-
)
|
|
2224
|
-
}
|
|
2241
|
+
// Origin unresolved and no fleet chat — the card lands in the owner DM.
|
|
2242
|
+
noteWorkerFeedOwnerDmFallback(agentId)
|
|
2225
2243
|
}
|
|
2226
|
-
return { chatId: ownerDm, threadId: origin?.threadId }
|
|
2244
|
+
return { chatId: ownerDm, threadId: origin?.threadId ?? fallbackThreadId }
|
|
2227
2245
|
}
|
|
2228
2246
|
|
|
2229
2247
|
// ─── Periodic history reaper (#1073) ──────────────────────────────────────
|
|
@@ -2311,6 +2329,13 @@ function checkApprovals(): void {
|
|
|
2311
2329
|
}
|
|
2312
2330
|
}
|
|
2313
2331
|
if (isGatewayMain && !STATIC) setInterval(checkApprovals, 5000).unref()
|
|
2332
|
+
// Gateway liveness heartbeat — touches `<STATE_DIR>/gateway-heartbeat` while
|
|
2333
|
+
// the gateway lives so the silent-end Stop hook's single-writer election can
|
|
2334
|
+
// confirm the gateway is alive (and WILL run its turn_end delivery) before
|
|
2335
|
+
// electing to allow a turn to end without re-prompting. Allowing into a dead
|
|
2336
|
+
// gateway would drop the answer — the one outcome worse than a duplicate. See
|
|
2337
|
+
// gateway/gateway-heartbeat.ts + hooks/silent-end-scan.mjs.
|
|
2338
|
+
if (isGatewayMain && !STATIC) startGatewayHeartbeat(STATE_DIR)
|
|
2314
2339
|
// Idle auto-clear: check wall-clock idle every minute; maybeIdleClear no-ops
|
|
2315
2340
|
// when disabled ('0s'), mid-turn, or already cleared this idle period. The
|
|
2316
2341
|
// `let` state + maybeIdleClear are hoisted/initialized before this fires.
|
|
@@ -9674,6 +9699,15 @@ function gatewayLivenessWiringDeps() {
|
|
|
9674
9699
|
trackRedeliveredInbound,
|
|
9675
9700
|
closeActivityLane,
|
|
9676
9701
|
closeProgressLane,
|
|
9702
|
+
// Stage B: escalate a mid-tool + marker-stale fallback to a real restart.
|
|
9703
|
+
// No cooldown (see hang-restart-decision.ts § STORM GUARD): the ask-first
|
|
9704
|
+
// resume_watchdog_timeout path is the sole, sufficient storm guard.
|
|
9705
|
+
hangRestart: {
|
|
9706
|
+
stalenessThresholdMs: hangStalenessMs(),
|
|
9707
|
+
request: (reason: string, idleMs: number) => {
|
|
9708
|
+
triggerSelfRestart(getMyAgentName(), `hang-watchdog:${reason} idle=${Math.round(idleMs)}ms`)
|
|
9709
|
+
},
|
|
9710
|
+
},
|
|
9677
9711
|
}
|
|
9678
9712
|
}
|
|
9679
9713
|
export type LivenessWiringDeps = ReturnType<typeof gatewayLivenessWiringDeps>
|
|
@@ -15654,25 +15688,6 @@ function switchroomExecCombined(args: string[], timeoutMs = 15000): string {
|
|
|
15654
15688
|
})
|
|
15655
15689
|
}
|
|
15656
15690
|
|
|
15657
|
-
// Default truncation budget for CLI output bound for Telegram. The rich-message
|
|
15658
|
-
// wire cap is RICH_MESSAGE_MAX_CHARS (32768) post-#2669, not the legacy 4096
|
|
15659
|
-
// plain-text limit. Mirrors shared/bot-runtime.ts formatSwitchroomOutput.
|
|
15660
|
-
function formatSwitchroomOutput(output: string, maxLen = RICH_MESSAGE_MAX_CHARS): string {
|
|
15661
|
-
const trimmed = output.trim()
|
|
15662
|
-
if (trimmed.length <= maxLen) return trimmed
|
|
15663
|
-
return trimmed.slice(0, maxLen - 20) + '\n... (truncated)'
|
|
15664
|
-
}
|
|
15665
|
-
|
|
15666
|
-
function stripAnsi(text: string): string {
|
|
15667
|
-
return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
|
15668
|
-
}
|
|
15669
|
-
|
|
15670
|
-
// #2669: escape GFM-markdown specials in dynamic values interpolated into
|
|
15671
|
-
// rich-message bodies (kept under the legacy name to avoid churn).
|
|
15672
|
-
function escapeHtmlForTg(text: string): string {
|
|
15673
|
-
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
15674
|
-
}
|
|
15675
|
-
|
|
15676
15691
|
// #789 — button-choice-confirmation ("✅ You chose: X") annotation state.
|
|
15677
15692
|
//
|
|
15678
15693
|
// Two once-per-process warning dedupe sets keyed by agent slug: one for the
|
|
@@ -15684,11 +15699,6 @@ function escapeHtmlForTg(text: string): string {
|
|
|
15684
15699
|
const buttonConfirmParseModeWarned = new Set<string>()
|
|
15685
15700
|
const buttonConfirmSingleUseWarned = new Set<string>()
|
|
15686
15701
|
|
|
15687
|
-
// Wrap CLI/command output in a fenced code block (content is literal there).
|
|
15688
|
-
function preBlock(text: string): string {
|
|
15689
|
-
return '```\n' + text.replace(/```/g, '```') + '\n```'
|
|
15690
|
-
}
|
|
15691
|
-
|
|
15692
15702
|
type SwitchroomReplyMarkup =
|
|
15693
15703
|
| InlineKeyboard
|
|
15694
15704
|
| { force_reply: true; input_field_placeholder?: string; selective?: boolean }
|
|
@@ -15785,36 +15795,6 @@ async function deleteSensitiveMessage(
|
|
|
15785
15795
|
}
|
|
15786
15796
|
}
|
|
15787
15797
|
|
|
15788
|
-
function getCommandArgs(ctx: Context): string {
|
|
15789
|
-
const fromMatch = typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
|
15790
|
-
if (fromMatch) return fromMatch
|
|
15791
|
-
const text = (ctx.msg as { text?: string } | undefined)?.text ?? (ctx.message as { text?: string } | undefined)?.text ?? ''
|
|
15792
|
-
const m = text.match(/^\/\S+\s+([\s\S]*)$/)
|
|
15793
|
-
return m ? m[1].trim() : ''
|
|
15794
|
-
}
|
|
15795
|
-
|
|
15796
|
-
/**
|
|
15797
|
-
* True when a slash command's argument string carries a trailing `demo`
|
|
15798
|
-
* token — the per-command PII-mask modifier for screen recordings
|
|
15799
|
-
* (`/usage demo`, `/auth demo`, `/status demo`, `/whoami demo`). Matches
|
|
15800
|
-
* `demo` as the last whitespace-delimited token, case-insensitively, so
|
|
15801
|
-
* `/auth show demo` and `/usage demo` both flip the flag while a label
|
|
15802
|
-
* literally named `demo-foo` does not.
|
|
15803
|
-
*/
|
|
15804
|
-
function hasDemoFlag(args: string): boolean {
|
|
15805
|
-
return /(?:^|\s)demo$/i.test(args.trim())
|
|
15806
|
-
}
|
|
15807
|
-
|
|
15808
|
-
/** Validate that a string looks like a safe agent/resource name.
|
|
15809
|
-
* Agent names should be alphanumeric with hyphens/underscores only.
|
|
15810
|
-
* This prevents shell metacharacter injection even though both exec
|
|
15811
|
-
* functions already handle quoting. Defense in depth. */
|
|
15812
|
-
function assertSafeAgentName(name: string): void {
|
|
15813
|
-
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name) && name !== 'all') {
|
|
15814
|
-
throw new Error(`invalid agent name: ${name}`)
|
|
15815
|
-
}
|
|
15816
|
-
}
|
|
15817
|
-
|
|
15818
15798
|
/**
|
|
15819
15799
|
* Expand the special `all` keyword into the real agent list for restart-
|
|
15820
15800
|
* driving. The CLI handles its own expansion for the YAML mutation; this
|
|
@@ -16389,114 +16369,6 @@ function scheduleGrantRestart(
|
|
|
16389
16369
|
* if the inline button ever fails to render (old client, unusual scope)
|
|
16390
16370
|
* the user still has a copy-paste-able URL.
|
|
16391
16371
|
*/
|
|
16392
|
-
function formatAuthOutputForTelegram(output: string): { text: string; url: string | null } {
|
|
16393
|
-
const trimmed = stripAnsi(output).trim()
|
|
16394
|
-
const url = trimmed.match(/https:\/\/\S+/)?.[0] ?? null
|
|
16395
|
-
const lines = trimmed.split(/\n+/).map(l => l.trim()).filter(Boolean)
|
|
16396
|
-
if (!url) return { text: preBlock(formatSwitchroomOutput(trimmed)), url: null }
|
|
16397
|
-
// Drop the `switchroom auth code ...` and `switchroom auth cancel ...`
|
|
16398
|
-
// CLI hints. In Telegram the user never types those — they just reply
|
|
16399
|
-
// with the code (intercepted by the pendingReauthFlows flow above) or
|
|
16400
|
-
// tap the inline button. Surfacing shell syntax is confusing noise on
|
|
16401
|
-
// a phone.
|
|
16402
|
-
const body = lines.filter(line => {
|
|
16403
|
-
if (line === url) return false
|
|
16404
|
-
if (line.startsWith('switchroom auth code')) return false
|
|
16405
|
-
if (line.startsWith('switchroom auth cancel')) return false
|
|
16406
|
-
if (line.startsWith("Use 'tmux attach")) return false
|
|
16407
|
-
if (line.startsWith('After Claude shows you a browser code')) return false
|
|
16408
|
-
if (line.startsWith('Then finish with:')) return false
|
|
16409
|
-
if (line.startsWith('Cancel with:')) return false
|
|
16410
|
-
return true
|
|
16411
|
-
})
|
|
16412
|
-
const rendered = body.map(line => {
|
|
16413
|
-
if (line.startsWith('Started Claude auth') || line.startsWith('Auth session already running')) return `**${escapeHtmlForTg(line)}**`
|
|
16414
|
-
if (line.startsWith('Open this URL')) return `_${escapeHtmlForTg(line)}_`
|
|
16415
|
-
return escapeHtmlForTg(line)
|
|
16416
|
-
})
|
|
16417
|
-
// Mobile-native post-script. Two paths depending on which Anthropic
|
|
16418
|
-
// account the user wants to authorize:
|
|
16419
|
-
//
|
|
16420
|
-
// (a) Button: 🔐 Open Claude auth — opens in Telegram's in-app
|
|
16421
|
-
// browser (WebView) on most mobile clients. WebView has its
|
|
16422
|
-
// own cookie jar, separate from the user's main browser. Fine
|
|
16423
|
-
// when the WebView is already signed into the intended Claude
|
|
16424
|
-
// account; wrong when it's signed into a different one.
|
|
16425
|
-
//
|
|
16426
|
-
// (b) Long-press the URL text at the bottom of this message — every
|
|
16427
|
-
// mobile Telegram client exposes "Copy Link" / "Open in
|
|
16428
|
-
// Browser" / "Open in Chrome" on long-press. That's the
|
|
16429
|
-
// escape hatch when you need to land in your main browser
|
|
16430
|
-
// where you control which account is signed in.
|
|
16431
|
-
//
|
|
16432
|
-
// Why not a copy_text button? We tried. Telegram's CopyTextButton.text
|
|
16433
|
-
// field caps at 256 chars and OAuth URLs run ~320–340 chars. Result
|
|
16434
|
-
// was BUTTON_COPY_TEXT_INVALID. The long-press-the-URL path achieves
|
|
16435
|
-
// the same outcome with no API constraint. See PR #30.
|
|
16436
|
-
rendered.push(
|
|
16437
|
-
'',
|
|
16438
|
-
'👇 Tap **🔐 Open Claude auth** below, then **reply with the browser code**.',
|
|
16439
|
-
'',
|
|
16440
|
-
'_Wrong Anthropic account getting authorized? Long-press the URL below and choose "Copy Link" or "Open in Browser" — lands in your main browser where the right account is signed in, bypassing Telegram\'s in-app browser cookies._',
|
|
16441
|
-
'',
|
|
16442
|
-
url,
|
|
16443
|
-
)
|
|
16444
|
-
return { text: rendered.join('\n'), url }
|
|
16445
|
-
}
|
|
16446
|
-
|
|
16447
|
-
/**
|
|
16448
|
-
* Build the inline keyboard shown under an auth-flow response that has
|
|
16449
|
-
* an OAuth URL. Single button:
|
|
16450
|
-
*
|
|
16451
|
-
* [🔐 Open Claude auth] — `url` button. On mobile Telegram clients
|
|
16452
|
-
* this typically opens in the app's in-app
|
|
16453
|
-
* browser (WebView).
|
|
16454
|
-
*
|
|
16455
|
-
* We previously tried adding a `[📋 Copy URL]` button using Telegram's
|
|
16456
|
-
* Bot API 7.7 `copy_text` type but it capped at 256 chars for the
|
|
16457
|
-
* copyable text. OAuth URLs (~320–340 chars) exceed that and produce
|
|
16458
|
-
* `BUTTON_COPY_TEXT_INVALID`. Instead, the message body renders the
|
|
16459
|
-
* URL as a tappable link; users long-press the URL text to get native
|
|
16460
|
-
* "Copy Link" / "Open in Browser" actions, bypassing the WebView.
|
|
16461
|
-
*
|
|
16462
|
-
* Defense in depth: this function's output is validated against
|
|
16463
|
-
* Telegram's real field-length constraints in
|
|
16464
|
-
* `telegram-plugin/tests/auth-url-keyboard-constraints.test.ts` so
|
|
16465
|
-
* future changes that breach a limit fail loudly at CI time rather
|
|
16466
|
-
* than silently in production.
|
|
16467
|
-
*/
|
|
16468
|
-
function buildAuthUrlKeyboard(authorizeUrl: string): InlineKeyboard {
|
|
16469
|
-
return new InlineKeyboard().url('🔐 Open Claude auth', authorizeUrl)
|
|
16470
|
-
}
|
|
16471
|
-
|
|
16472
|
-
/**
|
|
16473
|
-
* Issue #44: inline keyboard offering a one-tap unlock-and-save flow for
|
|
16474
|
-
* a deferred secret. The two buttons fire `vd:` callback_data which the
|
|
16475
|
-
* dispatcher in `bot.on('callback_query:data')` routes to
|
|
16476
|
-
* `handleVaultDeferCallback`.
|
|
16477
|
-
*
|
|
16478
|
-
* `vd:unlock:<deferKey>` → prompt for passphrase, then auto-write the
|
|
16479
|
-
* held secret. Replaces the legacy six-step
|
|
16480
|
-
* "/vault list → re-paste" flow.
|
|
16481
|
-
* `vd:cancel:<deferKey>` → discard the deferred secret without saving.
|
|
16482
|
-
*
|
|
16483
|
-
* `deferKey` is `<chat_id>:<message_id>` (the same key as
|
|
16484
|
-
* `deferredSecrets.set()`). Telegram limits callback_data to 64 bytes;
|
|
16485
|
-
* the prefix + key fits well within that on any realistic chat id.
|
|
16486
|
-
*/
|
|
16487
|
-
function buildDeferredSecretKeyboard(deferKey: string): InlineKeyboard {
|
|
16488
|
-
const unlockData = `vd:unlock:${deferKey}`
|
|
16489
|
-
const cancelData = `vd:cancel:${deferKey}`
|
|
16490
|
-
if (unlockData.length > 64 || cancelData.length > 64) {
|
|
16491
|
-
process.stderr.write(
|
|
16492
|
-
`telegram gateway: callback_data overflow — deferKey=${deferKey} unlockLen=${unlockData.length} cancelLen=${cancelData.length}\n`,
|
|
16493
|
-
)
|
|
16494
|
-
throw new Error(`callback_data overflow: deferKey too long (${deferKey.length} chars)`)
|
|
16495
|
-
}
|
|
16496
|
-
return new InlineKeyboard()
|
|
16497
|
-
.text('🔓 Unlock vault & save', unlockData)
|
|
16498
|
-
.text('🗑 Discard', cancelData)
|
|
16499
|
-
}
|
|
16500
16372
|
|
|
16501
16373
|
async function runSwitchroomAuthCommand(ctx: Context, args: string[], label: string): Promise<void> {
|
|
16502
16374
|
try {
|
|
@@ -16560,27 +16432,6 @@ function runVaultCli(args: string[], passphrase: string, stdinValue?: string): {
|
|
|
16560
16432
|
}
|
|
16561
16433
|
}
|
|
16562
16434
|
|
|
16563
|
-
/**
|
|
16564
|
-
* Render a vault-CLI failure as Telegram HTML. Routes recognised P0a
|
|
16565
|
-
* stderr markers (VAULT-SANDBOX-CONTEXT / VAULT-NEEDS-APPROVAL /
|
|
16566
|
-
* VAULT-BROKER-UNREACHABLE / VAULT-BROKER-DENIED) through the structured
|
|
16567
|
-
* renderer; falls back to a raw pre-block for anything else.
|
|
16568
|
-
*/
|
|
16569
|
-
function renderVaultOpFailure(
|
|
16570
|
-
verbLabel: 'list' | 'get' | 'set' | 'delete',
|
|
16571
|
-
cliOutput: string,
|
|
16572
|
-
key: string | undefined,
|
|
16573
|
-
): string {
|
|
16574
|
-
const parsed = parseVaultCliError(cliOutput)
|
|
16575
|
-
// Map the gateway-internal op label onto the renderer's verb. 'delete'
|
|
16576
|
-
// surfaces in the host hint as `switchroom vault remove <key>` (the
|
|
16577
|
-
// canonical CLI name); 'list' has no key.
|
|
16578
|
-
const verb = verbLabel === 'delete' ? 'remove' : verbLabel
|
|
16579
|
-
const rendered = renderVaultCliError(parsed, { verb, key })
|
|
16580
|
-
if (rendered.suppressRaw) return rendered.html
|
|
16581
|
-
return `**vault ${verbLabel} failed:**\n${preBlock(cliOutput)}`
|
|
16582
|
-
}
|
|
16583
|
-
|
|
16584
16435
|
async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' | 'set' | 'delete', key: string | undefined, passphrase: string, setValue: string | undefined): Promise<void> {
|
|
16585
16436
|
if (op === 'list') {
|
|
16586
16437
|
const r = runVaultCli(['list'], passphrase)
|
|
@@ -16713,34 +16564,6 @@ function switchroomExecJson<T = unknown>(args: string[]): T | null {
|
|
|
16713
16564
|
} catch { return null }
|
|
16714
16565
|
}
|
|
16715
16566
|
|
|
16716
|
-
function statusIcon(status: string): string {
|
|
16717
|
-
if (status === 'active' || status === 'running') return '🟢'
|
|
16718
|
-
if (status === 'inactive' || status === 'stopped' || status === 'dead') return '🔴'
|
|
16719
|
-
if (status === 'failed') return '⚠️'
|
|
16720
|
-
return '⚪'
|
|
16721
|
-
}
|
|
16722
|
-
|
|
16723
|
-
/**
|
|
16724
|
-
* Render an `AuthCodeOutcome` as a user-facing Telegram HTML string.
|
|
16725
|
-
* Returns null when the outcome is not present or is `success` (caller
|
|
16726
|
-
* can handle success via the existing text path).
|
|
16727
|
-
*/
|
|
16728
|
-
function renderAuthCodeOutcome(outcome: AuthCodeOutcome | null | undefined): string | null {
|
|
16729
|
-
if (!outcome || outcome.kind === 'success') return null
|
|
16730
|
-
const tail = outcome.paneTailText
|
|
16731
|
-
? `\n_${escapeHtmlForTg(outcome.paneTailText)}_`
|
|
16732
|
-
: ''
|
|
16733
|
-
switch (outcome.kind) {
|
|
16734
|
-
case 'invalid-code':
|
|
16735
|
-
case 'expired-code':
|
|
16736
|
-
return `Code rejected by Claude — tap **Restart flow** for a fresh URL.${tail}`
|
|
16737
|
-
case 'pane-not-ready':
|
|
16738
|
-
return `Auth pane not ready — tap **Retry**.`
|
|
16739
|
-
case 'timeout':
|
|
16740
|
-
return `Still waiting after 2 min — tap **Retry** or check \`switchroom auth list\`.${tail}`
|
|
16741
|
-
}
|
|
16742
|
-
}
|
|
16743
|
-
|
|
16744
16567
|
interface AuthCodeJsonResult {
|
|
16745
16568
|
completed: boolean
|
|
16746
16569
|
tokenSaved: boolean
|
|
@@ -19212,29 +19035,6 @@ let cardToolHandlers!: ReturnType<typeof createCardToolHandlers>
|
|
|
19212
19035
|
|
|
19213
19036
|
|
|
19214
19037
|
|
|
19215
|
-
// Two-button scope picker shown to admin agents (when hostd is
|
|
19216
|
-
// reachable) so the operator can run doctor for the WHOLE FLEET
|
|
19217
|
-
// (host-side via hostd — has the docker socket) or just THIS agent
|
|
19218
|
-
// (in-container, degraded). callback_data is tiny (`dr:fleet` /
|
|
19219
|
-
// `dr:self`) — well within Telegram's 64-byte limit.
|
|
19220
|
-
function buildDoctorScopeKeyboard(): InlineKeyboard {
|
|
19221
|
-
return new InlineKeyboard()
|
|
19222
|
-
.text('🩺 Whole fleet', 'dr:fleet')
|
|
19223
|
-
.text('🩺 This agent', 'dr:self')
|
|
19224
|
-
}
|
|
19225
|
-
|
|
19226
|
-
// Shared report prettifier: ANSI-strip + status-glyph swap + pre block.
|
|
19227
|
-
// Identical rendering for the in-container and the hostd fleet report.
|
|
19228
|
-
function formatDoctorReport(raw: string): string {
|
|
19229
|
-
const trimmed = stripAnsi(raw).trim()
|
|
19230
|
-
if (!trimmed) return 'doctor: no output'
|
|
19231
|
-
const pretty = trimmed
|
|
19232
|
-
.replace(/^( *)✓ /gm, '$1🟢 ')
|
|
19233
|
-
.replace(/^( *)✗ /gm, '$1🔴 ')
|
|
19234
|
-
.replace(/^( *)! /gm, '$1🟡 ')
|
|
19235
|
-
return preBlock(formatSwitchroomOutput(pretty))
|
|
19236
|
-
}
|
|
19237
|
-
|
|
19238
19038
|
// In-container `switchroom doctor` — this agent's own (degraded: no
|
|
19239
19039
|
// docker socket) view. The original /doctor behaviour, unchanged.
|
|
19240
19040
|
async function renderSelfDoctor(ctx: Context): Promise<void> {
|
|
@@ -24397,6 +24197,9 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24397
24197
|
// live worker, collapses the shared card to its terminal summary
|
|
24398
24198
|
// and unpins it — closing the immortal/unpinned/buried-card leak.
|
|
24399
24199
|
onTerminalCleanup: (agentId) => {
|
|
24200
|
+
// A worker that terminated while still unlinked never links —
|
|
24201
|
+
// clear its origin-race defer state so the map can't leak.
|
|
24202
|
+
workerFeedOriginDeferrals.delete(agentId)
|
|
24400
24203
|
try {
|
|
24401
24204
|
void workerActivityFeed?.terminate(agentId)
|
|
24402
24205
|
} catch (err) {
|
|
@@ -24406,6 +24209,8 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24406
24209
|
}
|
|
24407
24210
|
},
|
|
24408
24211
|
onFinish: ({ agentId, outcome, description, resultText, toolCount, totalTokens, durationMs, background: entryBackground }) => {
|
|
24212
|
+
// Clear origin-race defer state — the worker is terminal.
|
|
24213
|
+
workerFeedOriginDeferrals.delete(agentId)
|
|
24409
24214
|
// Reaction promotion: if the parent turn already ended
|
|
24410
24215
|
// with this (or another) worker still running, its 👍 was
|
|
24411
24216
|
// deferred (held on ✍️/⚡). Now that a worker finished,
|
|
@@ -24890,11 +24695,48 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24890
24695
|
// resolved (the pinned-card fleet that used to carry the chat
|
|
24891
24696
|
// is gone — see resolveSubagentOriginChat).
|
|
24892
24697
|
if (workerFeedEnabled) {
|
|
24893
|
-
|
|
24894
|
-
|
|
24698
|
+
// Origin-race defer + destination (decideWorkerFeedDestination,
|
|
24699
|
+
// worker-feed-dispatch.ts): the first tick of a fresh sub-agent
|
|
24700
|
+
// can beat the async jsonl_agent_id backfill that links its row
|
|
24701
|
+
// to the origin turn — origin resolves null and the card would be
|
|
24702
|
+
// CREATED in the owner DM, staying visible even after the origin
|
|
24703
|
+
// (supergroup + forum topic) resolves ~3s later. Defer CARD
|
|
24704
|
+
// CREATION until linked; the watcher re-fires and paints in the
|
|
24705
|
+
// right chat+topic. The FULL decision (defer-or-paint AND the
|
|
24706
|
+
// chat/thread resolution `resolveWorkerFeedChat` did) is now one
|
|
24707
|
+
// pure, unit-tested function; the gateway keeps only the defer-map
|
|
24708
|
+
// bookkeeping, the two audit logs, and the feed.update.
|
|
24709
|
+
const dest = decideWorkerFeedDestination({
|
|
24710
|
+
origin: resolveSubagentOriginChat(agentId),
|
|
24711
|
+
cardExists: workerActivityFeed?.has(agentId) === true,
|
|
24712
|
+
priorDeferrals: workerFeedOriginDeferrals.get(agentId) ?? 0,
|
|
24713
|
+
maxDeferrals: WORKER_FEED_ORIGIN_DEFER_MAX,
|
|
24714
|
+
fleetChatId,
|
|
24715
|
+
stampChatId: stampTurn?.sessionChatId,
|
|
24716
|
+
stampThreadId: stampTurn?.sessionThreadId,
|
|
24717
|
+
ownerDm: loadAccess().allowFrom[0] ?? '',
|
|
24718
|
+
})
|
|
24719
|
+
if (dest.action === 'defer') {
|
|
24720
|
+
// No card created this tick; the watcher re-fires and paints
|
|
24721
|
+
// once the jsonl_agent_id backfill links the row to its origin.
|
|
24722
|
+
workerFeedOriginDeferrals.set(agentId, dest.deferrals)
|
|
24723
|
+
return
|
|
24724
|
+
}
|
|
24725
|
+
// Painting: clear defer state so the map can't leak.
|
|
24726
|
+
workerFeedOriginDeferrals.delete(agentId)
|
|
24727
|
+
if (dest.exhausted) {
|
|
24728
|
+
// Bounded-defer exhausted — backfill never linked (history
|
|
24729
|
+
// disabled / row reaped / ancestor never stamped). Paint anyway
|
|
24730
|
+
// so active work stays visible (universal-liveness contract).
|
|
24731
|
+
process.stderr.write(
|
|
24732
|
+
`telegram gateway: worker-feed origin backfill never linked agent=${agentId} after ${dest.deferrals} deferrals — painting card\n`,
|
|
24733
|
+
)
|
|
24734
|
+
}
|
|
24735
|
+
// Card fell to the owner DM — log the misroute once per agent.
|
|
24736
|
+
if (dest.ownerDmFallback) noteWorkerFeedOwnerDmFallback(agentId)
|
|
24895
24737
|
void workerActivityFeed?.update(
|
|
24896
24738
|
agentId,
|
|
24897
|
-
|
|
24739
|
+
dest.chatId,
|
|
24898
24740
|
{
|
|
24899
24741
|
description: dispatch.feedDescription,
|
|
24900
24742
|
lastTool,
|
|
@@ -24908,7 +24750,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24908
24750
|
model: feedModel,
|
|
24909
24751
|
totalTokens,
|
|
24910
24752
|
},
|
|
24911
|
-
|
|
24753
|
+
dest.threadId,
|
|
24912
24754
|
)
|
|
24913
24755
|
// #3207: the feed pins the group's shared message itself when
|
|
24914
24756
|
// it first paints (reconcilePin → `wk:group:<feedKey>`); no
|