switchroom 0.19.4 → 0.19.6
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 +585 -302
- package/telegram-plugin/flushed-turn-supersede.ts +43 -7
- package/telegram-plugin/gateway/command-format.ts +253 -0
- package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
- package/telegram-plugin/gateway/gateway.ts +128 -259
- package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
- package/telegram-plugin/gateway/session-model-file.ts +13 -0
- package/telegram-plugin/gateway/stream-render.ts +18 -1
- package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
- 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/reply-owner-resolve.ts +43 -7
- package/telegram-plugin/rich-send.ts +8 -1
- package/telegram-plugin/tests/command-format.test.ts +212 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -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/narrative-lane-golden.test.ts +2 -1
- 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/reply-owner-resolve.test.ts +74 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
- 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/stream-render-golden.test.ts +2 -1
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
- 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'
|
|
@@ -483,8 +485,11 @@ import {
|
|
|
483
485
|
} from '../turn-flush-safety.js'
|
|
484
486
|
import {
|
|
485
487
|
resolveReplyOwnerTurnId,
|
|
488
|
+
resolveReplyOwnerTier,
|
|
489
|
+
type ReplyOwnerTier,
|
|
486
490
|
type AnswerDeliveredLatch,
|
|
487
491
|
} from '../reply-owner-resolve.js'
|
|
492
|
+
import { SubagentHandbackMarker } from './subagent-handback-marker.js'
|
|
488
493
|
// PR A — deterministic answer-ready quiescence flush (late-delivery fix).
|
|
489
494
|
import {
|
|
490
495
|
AnswerReadyFlushController,
|
|
@@ -898,6 +903,7 @@ import {
|
|
|
898
903
|
TURN_ACTIVE_HARD_TTL_MS,
|
|
899
904
|
TURN_ACTIVE_IDLE_SWEEP_MS,
|
|
900
905
|
} from './turn-active-marker.js'
|
|
906
|
+
import { startGatewayHeartbeat } from './gateway-heartbeat.js'
|
|
901
907
|
import {
|
|
902
908
|
VERSION,
|
|
903
909
|
COMMIT_SHA,
|
|
@@ -953,7 +959,7 @@ import {
|
|
|
953
959
|
import { findAgentProcessInContainer } from './boot-probes.js'
|
|
954
960
|
import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
|
|
955
961
|
import type { InterruptedSubagent } from './resume-inbound-builder.js'
|
|
956
|
-
import { resolveWorkerFeedDispatch, handleWorkerResume, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
962
|
+
import { resolveWorkerFeedDispatch, decideWorkerFeedDestination, handleWorkerResume, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
957
963
|
import {
|
|
958
964
|
resolveSubagentStatusSurface,
|
|
959
965
|
isOrphanSubagentStatusEnabled,
|
|
@@ -2180,6 +2186,29 @@ const WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS = (() => {
|
|
|
2180
2186
|
})()
|
|
2181
2187
|
const workerFeedOwnerDmFallbackLogged = new Set<string>()
|
|
2182
2188
|
|
|
2189
|
+
// Once-per-agent owner-DM-fallback routing line (bounded FIFO set). Shared by
|
|
2190
|
+
// `resolveWorkerFeedChat` and the origin-defer paint path so both log alike.
|
|
2191
|
+
function noteWorkerFeedOwnerDmFallback(agentId: string): void {
|
|
2192
|
+
if (workerFeedOwnerDmFallbackLogged.has(agentId)) return
|
|
2193
|
+
workerFeedOwnerDmFallbackLogged.add(agentId)
|
|
2194
|
+
if (workerFeedOwnerDmFallbackLogged.size > WORKER_FEED_FALLBACK_LOG_CAP) {
|
|
2195
|
+
const oldest = workerFeedOwnerDmFallbackLogged.values().next().value
|
|
2196
|
+
if (oldest != null) workerFeedOwnerDmFallbackLogged.delete(oldest)
|
|
2197
|
+
}
|
|
2198
|
+
process.stderr.write(
|
|
2199
|
+
`telegram gateway: worker-feed origin unresolved agent=${agentId} — routing card to owner DM\n`,
|
|
2200
|
+
)
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// Per-agent consecutive origin-race deferrals (rationale in
|
|
2204
|
+
// `decideWorkerFeedOriginDefer`, worker-feed-dispatch.ts). Bounds the wait for
|
|
2205
|
+
// the `jsonl_agent_id` backfill; deleted on link, card-exists, and finish/drop
|
|
2206
|
+
// so the map stays bounded.
|
|
2207
|
+
const workerFeedOriginDeferrals = new Map<string, number>()
|
|
2208
|
+
// Watcher ticks ~1/s and the backfill retries ~every 3s, so ~10 ticks is a
|
|
2209
|
+
// comfortable ceiling on the race window before painting anyway.
|
|
2210
|
+
const WORKER_FEED_ORIGIN_DEFER_MAX = 10
|
|
2211
|
+
|
|
2183
2212
|
/**
|
|
2184
2213
|
* Resolve a worker-feed destination chat with a guaranteed last resort.
|
|
2185
2214
|
*
|
|
@@ -2202,28 +2231,20 @@ const workerFeedOwnerDmFallbackLogged = new Set<string>()
|
|
|
2202
2231
|
function resolveWorkerFeedChat(
|
|
2203
2232
|
agentId: string,
|
|
2204
2233
|
fleetChatId: string,
|
|
2234
|
+
// Origin sources threadId; when origin is null the caller can pass the
|
|
2235
|
+
// fallback chat's sibling forum-topic id so a misrouted card still lands
|
|
2236
|
+
// in the origin topic instead of General (#3458 exhausted-defer paint).
|
|
2237
|
+
fallbackThreadId?: number,
|
|
2205
2238
|
): { chatId: string; threadId?: number } {
|
|
2206
2239
|
const origin = resolveSubagentOriginChat(agentId)
|
|
2207
2240
|
if (origin != null && origin.chatId.length > 0) return origin
|
|
2208
|
-
if (fleetChatId.length > 0) return { chatId: fleetChatId }
|
|
2241
|
+
if (fleetChatId.length > 0) return { chatId: fleetChatId, threadId: fallbackThreadId }
|
|
2209
2242
|
const ownerDm = loadAccess().allowFrom[0] ?? ''
|
|
2210
2243
|
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
|
-
}
|
|
2244
|
+
// Origin unresolved and no fleet chat — the card lands in the owner DM.
|
|
2245
|
+
noteWorkerFeedOwnerDmFallback(agentId)
|
|
2225
2246
|
}
|
|
2226
|
-
return { chatId: ownerDm, threadId: origin?.threadId }
|
|
2247
|
+
return { chatId: ownerDm, threadId: origin?.threadId ?? fallbackThreadId }
|
|
2227
2248
|
}
|
|
2228
2249
|
|
|
2229
2250
|
// ─── Periodic history reaper (#1073) ──────────────────────────────────────
|
|
@@ -2311,6 +2332,13 @@ function checkApprovals(): void {
|
|
|
2311
2332
|
}
|
|
2312
2333
|
}
|
|
2313
2334
|
if (isGatewayMain && !STATIC) setInterval(checkApprovals, 5000).unref()
|
|
2335
|
+
// Gateway liveness heartbeat — touches `<STATE_DIR>/gateway-heartbeat` while
|
|
2336
|
+
// the gateway lives so the silent-end Stop hook's single-writer election can
|
|
2337
|
+
// confirm the gateway is alive (and WILL run its turn_end delivery) before
|
|
2338
|
+
// electing to allow a turn to end without re-prompting. Allowing into a dead
|
|
2339
|
+
// gateway would drop the answer — the one outcome worse than a duplicate. See
|
|
2340
|
+
// gateway/gateway-heartbeat.ts + hooks/silent-end-scan.mjs.
|
|
2341
|
+
if (isGatewayMain && !STATIC) startGatewayHeartbeat(STATE_DIR)
|
|
2314
2342
|
// Idle auto-clear: check wall-clock idle every minute; maybeIdleClear no-ops
|
|
2315
2343
|
// when disabled ('0s'), mid-turn, or already cleared this idle period. The
|
|
2316
2344
|
// `let` state + maybeIdleClear are hoisted/initialized before this fires.
|
|
@@ -3860,6 +3888,14 @@ const recentTurnsById = new Map<string, CurrentTurn>()
|
|
|
3860
3888
|
// real message_id the framework stamped, never a model-asserted thread.
|
|
3861
3889
|
// Evicted in lock-step with recentTurnsById so it can't outgrow it.
|
|
3862
3890
|
const recentTurnIdBySourceMessageId = new Map<number, string>()
|
|
3891
|
+
|
|
3892
|
+
// fix/backstop-duplicate-reply — per-chat marker of the most recent
|
|
3893
|
+
// gateway-synthesized `subagent_handback` enqueue (logic in
|
|
3894
|
+
// subagent-handback-marker.ts; extracted per the gateway anti-inflation ratchet).
|
|
3895
|
+
const subagentHandbackMarker = new SubagentHandbackMarker()
|
|
3896
|
+
const getLastSubagentHandbackAt = (chatId: string): number | null =>
|
|
3897
|
+
subagentHandbackMarker.lastAt(chatId)
|
|
3898
|
+
|
|
3863
3899
|
function rememberRecentTurn(turn: CurrentTurn): void {
|
|
3864
3900
|
recentTurnsById.set(turn.turnId, turn)
|
|
3865
3901
|
if (turn.sourceMessageId != null) {
|
|
@@ -3984,7 +4020,7 @@ function resolveReplyOwnerTurn(
|
|
|
3984
4020
|
liveTurn: CurrentTurn | null,
|
|
3985
4021
|
chatId: string,
|
|
3986
4022
|
args: Record<string, unknown>,
|
|
3987
|
-
): CurrentTurn | null {
|
|
4023
|
+
): { turn: CurrentTurn | null; tier: ReplyOwnerTier } {
|
|
3988
4024
|
const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
3989
4025
|
const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
|
|
3990
4026
|
const latestEnded = findLatestEndedTurnForChat(chatId)
|
|
@@ -4001,15 +4037,23 @@ function resolveReplyOwnerTurn(
|
|
|
4001
4037
|
// fabricate one.
|
|
4002
4038
|
const latestEndedAgeMs =
|
|
4003
4039
|
latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
|
|
4004
|
-
const
|
|
4040
|
+
const candidates = {
|
|
4005
4041
|
liveTurnId: liveTurn?.turnId ?? null,
|
|
4006
4042
|
originTurnId: origin?.turnId ?? null,
|
|
4007
4043
|
quotedTurnId: quoted?.turnId ?? null,
|
|
4008
4044
|
latestEndedTurnId: latestEnded?.turnId ?? null,
|
|
4009
4045
|
latestEndedAgeMs,
|
|
4010
4046
|
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
4011
|
-
}
|
|
4012
|
-
|
|
4047
|
+
}
|
|
4048
|
+
// #3429 — the WINNING tier travels with the turn. A positive tier
|
|
4049
|
+
// (live/origin/quoted) means the reply is this turn's own answer and the
|
|
4050
|
+
// supersede fires regardless of text; the ambiguous `latest-ended` fallback
|
|
4051
|
+
// keeps the content gate (it cannot tell a late own-reply from an async
|
|
4052
|
+
// sub-agent handback). Both derive from the SAME candidates, so the id and the
|
|
4053
|
+
// tier can never disagree.
|
|
4054
|
+
const tier = resolveReplyOwnerTier(candidates)
|
|
4055
|
+
const winnerId = resolveReplyOwnerTurnId(candidates)
|
|
4056
|
+
return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier }
|
|
4013
4057
|
}
|
|
4014
4058
|
|
|
4015
4059
|
/**
|
|
@@ -9674,6 +9718,15 @@ function gatewayLivenessWiringDeps() {
|
|
|
9674
9718
|
trackRedeliveredInbound,
|
|
9675
9719
|
closeActivityLane,
|
|
9676
9720
|
closeProgressLane,
|
|
9721
|
+
// Stage B: escalate a mid-tool + marker-stale fallback to a real restart.
|
|
9722
|
+
// No cooldown (see hang-restart-decision.ts § STORM GUARD): the ask-first
|
|
9723
|
+
// resume_watchdog_timeout path is the sole, sufficient storm guard.
|
|
9724
|
+
hangRestart: {
|
|
9725
|
+
stalenessThresholdMs: hangStalenessMs(),
|
|
9726
|
+
request: (reason: string, idleMs: number) => {
|
|
9727
|
+
triggerSelfRestart(getMyAgentName(), `hang-watchdog:${reason} idle=${Math.round(idleMs)}ms`)
|
|
9728
|
+
},
|
|
9729
|
+
},
|
|
9677
9730
|
}
|
|
9678
9731
|
}
|
|
9679
9732
|
export type LivenessWiringDeps = ReturnType<typeof gatewayLivenessWiringDeps>
|
|
@@ -9928,6 +9981,10 @@ const pendingInboundBuffer = createPendingInboundBuffer({
|
|
|
9928
9981
|
{ chat_id: chat, verb: 'inbound-buffer-eviction' },
|
|
9929
9982
|
)
|
|
9930
9983
|
},
|
|
9984
|
+
// fix/backstop-duplicate-reply MUST-FIX 2 — stamp the handback marker at the
|
|
9985
|
+
// enqueue chokepoint so a boot-replayed handback (not just the live synthesis
|
|
9986
|
+
// push) populates it. Every `subagent_handback` push funnels through here.
|
|
9987
|
+
onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts),
|
|
9931
9988
|
})
|
|
9932
9989
|
|
|
9933
9990
|
// PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
|
|
@@ -12533,6 +12590,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
|
|
|
12533
12590
|
resolveAnswerThreadWithLog,
|
|
12534
12591
|
resolveThreadId,
|
|
12535
12592
|
getLatestInboundMessageId,
|
|
12593
|
+
getLastSubagentHandbackAt,
|
|
12536
12594
|
recordOutbound,
|
|
12537
12595
|
emissionAuthorityFor,
|
|
12538
12596
|
clearActivitySummary,
|
|
@@ -15654,25 +15712,6 @@ function switchroomExecCombined(args: string[], timeoutMs = 15000): string {
|
|
|
15654
15712
|
})
|
|
15655
15713
|
}
|
|
15656
15714
|
|
|
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
15715
|
// #789 — button-choice-confirmation ("✅ You chose: X") annotation state.
|
|
15677
15716
|
//
|
|
15678
15717
|
// Two once-per-process warning dedupe sets keyed by agent slug: one for the
|
|
@@ -15684,11 +15723,6 @@ function escapeHtmlForTg(text: string): string {
|
|
|
15684
15723
|
const buttonConfirmParseModeWarned = new Set<string>()
|
|
15685
15724
|
const buttonConfirmSingleUseWarned = new Set<string>()
|
|
15686
15725
|
|
|
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
15726
|
type SwitchroomReplyMarkup =
|
|
15693
15727
|
| InlineKeyboard
|
|
15694
15728
|
| { force_reply: true; input_field_placeholder?: string; selective?: boolean }
|
|
@@ -15785,36 +15819,6 @@ async function deleteSensitiveMessage(
|
|
|
15785
15819
|
}
|
|
15786
15820
|
}
|
|
15787
15821
|
|
|
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
15822
|
/**
|
|
15819
15823
|
* Expand the special `all` keyword into the real agent list for restart-
|
|
15820
15824
|
* driving. The CLI handles its own expansion for the YAML mutation; this
|
|
@@ -16389,114 +16393,6 @@ function scheduleGrantRestart(
|
|
|
16389
16393
|
* if the inline button ever fails to render (old client, unusual scope)
|
|
16390
16394
|
* the user still has a copy-paste-able URL.
|
|
16391
16395
|
*/
|
|
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
16396
|
|
|
16501
16397
|
async function runSwitchroomAuthCommand(ctx: Context, args: string[], label: string): Promise<void> {
|
|
16502
16398
|
try {
|
|
@@ -16560,27 +16456,6 @@ function runVaultCli(args: string[], passphrase: string, stdinValue?: string): {
|
|
|
16560
16456
|
}
|
|
16561
16457
|
}
|
|
16562
16458
|
|
|
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
16459
|
async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' | 'set' | 'delete', key: string | undefined, passphrase: string, setValue: string | undefined): Promise<void> {
|
|
16585
16460
|
if (op === 'list') {
|
|
16586
16461
|
const r = runVaultCli(['list'], passphrase)
|
|
@@ -16713,34 +16588,6 @@ function switchroomExecJson<T = unknown>(args: string[]): T | null {
|
|
|
16713
16588
|
} catch { return null }
|
|
16714
16589
|
}
|
|
16715
16590
|
|
|
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
16591
|
interface AuthCodeJsonResult {
|
|
16745
16592
|
completed: boolean
|
|
16746
16593
|
tokenSaved: boolean
|
|
@@ -19212,29 +19059,6 @@ let cardToolHandlers!: ReturnType<typeof createCardToolHandlers>
|
|
|
19212
19059
|
|
|
19213
19060
|
|
|
19214
19061
|
|
|
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
19062
|
// In-container `switchroom doctor` — this agent's own (degraded: no
|
|
19239
19063
|
// docker socket) view. The original /doctor behaviour, unchanged.
|
|
19240
19064
|
async function renderSelfDoctor(ctx: Context): Promise<void> {
|
|
@@ -24397,6 +24221,9 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24397
24221
|
// live worker, collapses the shared card to its terminal summary
|
|
24398
24222
|
// and unpins it — closing the immortal/unpinned/buried-card leak.
|
|
24399
24223
|
onTerminalCleanup: (agentId) => {
|
|
24224
|
+
// A worker that terminated while still unlinked never links —
|
|
24225
|
+
// clear its origin-race defer state so the map can't leak.
|
|
24226
|
+
workerFeedOriginDeferrals.delete(agentId)
|
|
24400
24227
|
try {
|
|
24401
24228
|
void workerActivityFeed?.terminate(agentId)
|
|
24402
24229
|
} catch (err) {
|
|
@@ -24406,6 +24233,8 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24406
24233
|
}
|
|
24407
24234
|
},
|
|
24408
24235
|
onFinish: ({ agentId, outcome, description, resultText, toolCount, totalTokens, durationMs, background: entryBackground }) => {
|
|
24236
|
+
// Clear origin-race defer state — the worker is terminal.
|
|
24237
|
+
workerFeedOriginDeferrals.delete(agentId)
|
|
24409
24238
|
// Reaction promotion: if the parent turn already ended
|
|
24410
24239
|
// with this (or another) worker still running, its 👍 was
|
|
24411
24240
|
// deferred (held on ✍️/⚡). Now that a worker finished,
|
|
@@ -24653,6 +24482,9 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24653
24482
|
// The drain only releases at an idle prompt (no active
|
|
24654
24483
|
// turn), so the handback always lands as a clean fresh
|
|
24655
24484
|
// turn and never races a turn-in-flight composer (#1556).
|
|
24485
|
+
// The handback marker is stamped inside pendingInboundBuffer.push
|
|
24486
|
+
// (the enqueue chokepoint) so BOTH this live synthesis push and
|
|
24487
|
+
// the boot-replay re-push populate it — see MUST-FIX 2.
|
|
24656
24488
|
pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', decision.inbound)
|
|
24657
24489
|
process.stderr.write(
|
|
24658
24490
|
`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}\n`,
|
|
@@ -24890,11 +24722,48 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24890
24722
|
// resolved (the pinned-card fleet that used to carry the chat
|
|
24891
24723
|
// is gone — see resolveSubagentOriginChat).
|
|
24892
24724
|
if (workerFeedEnabled) {
|
|
24893
|
-
|
|
24894
|
-
|
|
24725
|
+
// Origin-race defer + destination (decideWorkerFeedDestination,
|
|
24726
|
+
// worker-feed-dispatch.ts): the first tick of a fresh sub-agent
|
|
24727
|
+
// can beat the async jsonl_agent_id backfill that links its row
|
|
24728
|
+
// to the origin turn — origin resolves null and the card would be
|
|
24729
|
+
// CREATED in the owner DM, staying visible even after the origin
|
|
24730
|
+
// (supergroup + forum topic) resolves ~3s later. Defer CARD
|
|
24731
|
+
// CREATION until linked; the watcher re-fires and paints in the
|
|
24732
|
+
// right chat+topic. The FULL decision (defer-or-paint AND the
|
|
24733
|
+
// chat/thread resolution `resolveWorkerFeedChat` did) is now one
|
|
24734
|
+
// pure, unit-tested function; the gateway keeps only the defer-map
|
|
24735
|
+
// bookkeeping, the two audit logs, and the feed.update.
|
|
24736
|
+
const dest = decideWorkerFeedDestination({
|
|
24737
|
+
origin: resolveSubagentOriginChat(agentId),
|
|
24738
|
+
cardExists: workerActivityFeed?.has(agentId) === true,
|
|
24739
|
+
priorDeferrals: workerFeedOriginDeferrals.get(agentId) ?? 0,
|
|
24740
|
+
maxDeferrals: WORKER_FEED_ORIGIN_DEFER_MAX,
|
|
24741
|
+
fleetChatId,
|
|
24742
|
+
stampChatId: stampTurn?.sessionChatId,
|
|
24743
|
+
stampThreadId: stampTurn?.sessionThreadId,
|
|
24744
|
+
ownerDm: loadAccess().allowFrom[0] ?? '',
|
|
24745
|
+
})
|
|
24746
|
+
if (dest.action === 'defer') {
|
|
24747
|
+
// No card created this tick; the watcher re-fires and paints
|
|
24748
|
+
// once the jsonl_agent_id backfill links the row to its origin.
|
|
24749
|
+
workerFeedOriginDeferrals.set(agentId, dest.deferrals)
|
|
24750
|
+
return
|
|
24751
|
+
}
|
|
24752
|
+
// Painting: clear defer state so the map can't leak.
|
|
24753
|
+
workerFeedOriginDeferrals.delete(agentId)
|
|
24754
|
+
if (dest.exhausted) {
|
|
24755
|
+
// Bounded-defer exhausted — backfill never linked (history
|
|
24756
|
+
// disabled / row reaped / ancestor never stamped). Paint anyway
|
|
24757
|
+
// so active work stays visible (universal-liveness contract).
|
|
24758
|
+
process.stderr.write(
|
|
24759
|
+
`telegram gateway: worker-feed origin backfill never linked agent=${agentId} after ${dest.deferrals} deferrals — painting card\n`,
|
|
24760
|
+
)
|
|
24761
|
+
}
|
|
24762
|
+
// Card fell to the owner DM — log the misroute once per agent.
|
|
24763
|
+
if (dest.ownerDmFallback) noteWorkerFeedOwnerDmFallback(agentId)
|
|
24895
24764
|
void workerActivityFeed?.update(
|
|
24896
24765
|
agentId,
|
|
24897
|
-
|
|
24766
|
+
dest.chatId,
|
|
24898
24767
|
{
|
|
24899
24768
|
description: dispatch.feedDescription,
|
|
24900
24769
|
lastTool,
|
|
@@ -24908,7 +24777,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
24908
24777
|
model: feedModel,
|
|
24909
24778
|
totalTokens,
|
|
24910
24779
|
},
|
|
24911
|
-
|
|
24780
|
+
dest.threadId,
|
|
24912
24781
|
)
|
|
24913
24782
|
// #3207: the feed pins the group's shared message itself when
|
|
24914
24783
|
// it first paints (reconcilePin → `wk:group:<feedKey>`); no
|