switchroom 0.20.10 → 0.20.12
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 +68 -3
- package/dist/auth-broker/index.js +211 -46
- package/dist/cli/notion-write-pretool.mjs +68 -3
- package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
- package/dist/cli/self-improve-stop.mjs +889 -7
- package/dist/cli/skill-validate-pretool.mjs +82 -3
- package/dist/cli/switchroom.js +5063 -2961
- package/dist/host-control/main.js +93 -27
- package/dist/vault/approvals/kernel-server.js +92 -26
- package/dist/vault/broker/server.js +92 -26
- package/examples/personal-google-workspace-mcp/compose.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +15 -22
- package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
- package/profiles/_shared/dev-protocol.md.hbs +1 -1
- package/profiles/_shared/execution-discipline.md.hbs +4 -4
- package/profiles/_shared/vault-protocol.md.hbs +2 -18
- package/profiles/default/CLAUDE.md.hbs +3 -5
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/skills/switchroom-cli/SKILL.md +0 -1
- package/telegram-plugin/README.md +2 -11
- package/telegram-plugin/auto-fallback-fleet.ts +37 -2
- package/telegram-plugin/bridge/bridge.ts +0 -12
- package/telegram-plugin/chat-lock.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +0 -12
- package/telegram-plugin/dist/gateway/gateway.js +1454 -976
- package/telegram-plugin/dist/server.js +0 -12
- package/telegram-plugin/fallback-card-collapse.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +11 -1
- package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
- package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
- package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
- package/telegram-plugin/gateway/gateway.ts +109 -149
- package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
- package/telegram-plugin/gateway/ipc-server.ts +28 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +6 -1
- package/telegram-plugin/gateway/narrative-lane.ts +33 -2
- package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
- package/telegram-plugin/gateway/privacy-reset.ts +87 -0
- package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
- package/telegram-plugin/gateway/privacy-state.ts +206 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
- package/telegram-plugin/gateway/stale-pin-sweep.ts +52 -35
- package/telegram-plugin/gateway/status-pin-store.ts +10 -9
- package/telegram-plugin/gateway/stream-render.ts +4 -4
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
- package/telegram-plugin/gateway/turn-record-status.ts +32 -1
- package/telegram-plugin/hooks/hooks.json +13 -12
- package/telegram-plugin/hooks/narration-classify.mjs +1 -2
- package/telegram-plugin/hooks/silent-end-scan.mjs +1 -1
- package/telegram-plugin/slot-banner-driver.ts +42 -5
- package/telegram-plugin/status-pin.ts +2 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +8 -2
- package/telegram-plugin/tests/framework-fallback-duration-guard.test.ts +125 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
- package/telegram-plugin/tests/pin-message-tool-retired.test.ts +64 -0
- package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +38 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +40 -1
- package/telegram-plugin/throttle-tier.ts +12 -3
- package/telegram-plugin/turn-flush-safety.ts +97 -0
- package/telegram-plugin/worker-activity-feed.ts +1 -1
- package/vendor/hindsight-memory/scripts/recall.py +140 -0
- package/vendor/hindsight-memory/scripts/retain.py +306 -0
- package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
- package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_latency_instrumentation.py +277 -0
- package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
|
@@ -433,7 +433,8 @@ import {
|
|
|
433
433
|
import { createThrottleTierRunner } from './throttle-tier-wiring.js'
|
|
434
434
|
import { parseLitellmNoticeWindowMs } from '../litellm-local-notice.js'
|
|
435
435
|
import { createLitellmLocalNoticeRunner, decideRateLimitedSurface } from './litellm-local-notice-wiring.js'
|
|
436
|
-
import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice,
|
|
436
|
+
import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, type FallbackFailureNoticeState } from '../auto-fallback-fleet.js'
|
|
437
|
+
import { resetFleetFallbackNoticeCooldowns, shouldSendAllBlockedNotice, shouldSendStrictPinnedNotice } from './fleet-fallback-notice-cooldown.js'
|
|
437
438
|
import { startRestartWatchdog } from './restart-watchdog.js'
|
|
438
439
|
import { createAccessStore } from './access-store.js'
|
|
439
440
|
|
|
@@ -626,6 +627,13 @@ import { readTurnUsages } from '../../src/agents/perf.js'
|
|
|
626
627
|
import { buildContextOccupancy, writeContextOccupancySnapshot } from './context-occupancy.js'
|
|
627
628
|
import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
|
|
628
629
|
import { IdleTracker, idleDurationToMs, DEFAULT_IDLE_CLEAR_MS } from './idle-clear.js'
|
|
630
|
+
import {
|
|
631
|
+
openPrivateInterval,
|
|
632
|
+
closePrivateInterval,
|
|
633
|
+
PRIVATE_ON_REPLY,
|
|
634
|
+
PUBLIC_REPLY,
|
|
635
|
+
} from './privacy-state.js'
|
|
636
|
+
import { makePrivacyResetForNewSession, isContinueRestoreBoot } from './privacy-reset.js'
|
|
629
637
|
import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
|
|
630
638
|
import {
|
|
631
639
|
tryHostdDispatch,
|
|
@@ -769,13 +777,9 @@ import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
|
769
777
|
import { dispatchEffects } from './inbound-delivery-machine-dispatch.js'
|
|
770
778
|
import { maybeFireWarmup } from './prefix-warmup.js'
|
|
771
779
|
import {
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
} from './
|
|
775
|
-
import {
|
|
776
|
-
enqueueProposal as enqueueSkillProposal,
|
|
777
|
-
isSuppressed as isSkillProposalSuppressed,
|
|
778
|
-
} from '../../src/self-improve/skill-proposals.js'
|
|
780
|
+
handlePostSkillProposal,
|
|
781
|
+
handlePostEvalCaseProposal,
|
|
782
|
+
} from './self-improve-proposal-wiring.js'
|
|
779
783
|
import { decideSubagentHandback } from './subagent-handback-inbound-builder.js'
|
|
780
784
|
import {
|
|
781
785
|
decideSubagentProgress,
|
|
@@ -803,6 +807,7 @@ import type {
|
|
|
803
807
|
QueryPendingPermissionMessage,
|
|
804
808
|
CheckPreApprovedMessage,
|
|
805
809
|
PostSkillProposalMessage,
|
|
810
|
+
PostEvalCaseProposalMessage,
|
|
806
811
|
PermissionEvent,
|
|
807
812
|
RolloutStatusPostMessage,
|
|
808
813
|
RolloutStatusEditMessage,
|
|
@@ -5097,6 +5102,10 @@ function maybeIdleClear(): void {
|
|
|
5097
5102
|
`telegram gateway: idle /clear suppressed for ${agentName} ` +
|
|
5098
5103
|
`(activity in check-to-send gap)\n`,
|
|
5099
5104
|
);
|
|
5105
|
+
} else {
|
|
5106
|
+
// The idle /clear fired → new logical session; reset privacy to public.
|
|
5107
|
+
const idleChat = loadAccess().allowFrom[0];
|
|
5108
|
+
if (idleChat) resetPrivacyForNewSession(String(idleChat), undefined);
|
|
5100
5109
|
}
|
|
5101
5110
|
})
|
|
5102
5111
|
.catch((err: unknown) => {
|
|
@@ -5600,6 +5609,11 @@ const swallowingApiCall = createSwallowingRetryApiCall(
|
|
|
5600
5609
|
robustApiCall,
|
|
5601
5610
|
(line) => process.stderr.write(line),
|
|
5602
5611
|
)
|
|
5612
|
+
// Privacy (#private-mode): reset to public on a genuine new session (boot / /clear); loud only on a private→public transition. See privacy-reset.ts.
|
|
5613
|
+
const resetPrivacyForNewSession = makePrivacyResetForNewSession((chatId, threadId, text) =>
|
|
5614
|
+
void swallowingApiCall(
|
|
5615
|
+
() => lockedBot.api.sendMessage(chatId, text, threadId != null ? { message_thread_id: threadId, disable_notification: false } : { disable_notification: false }),
|
|
5616
|
+
{ chat_id: chatId, verb: 'privacy-reset-alert', priorityClass: 'critical' }))
|
|
5603
5617
|
|
|
5604
5618
|
/**
|
|
5605
5619
|
* The ONE seam every `setMessageReaction` in this gateway goes through (#3155).
|
|
@@ -8356,8 +8370,7 @@ const statusPinClaims = new Map<string, StatusPinClaim>()
|
|
|
8356
8370
|
// Rights-aware negative cache (#3024): chats where an auto status-pin attempt
|
|
8357
8371
|
// failed with the permanent "not enough rights to manage pinned messages" 400.
|
|
8358
8372
|
// Per-process only — a restart clears it so a later-granted pin right re-enables
|
|
8359
|
-
// auto-pin.
|
|
8360
|
-
// this cache (it always attempts and surfaces the error to the agent).
|
|
8373
|
+
// auto-pin.
|
|
8361
8374
|
const statusPinRightsCache = new PinRightsCache()
|
|
8362
8375
|
|
|
8363
8376
|
// Durable snapshot of the pin claim set on the persistent per-agent volume
|
|
@@ -8452,21 +8465,16 @@ const BANNER_PIN_KEY = 'banner:owner'
|
|
|
8452
8465
|
// run → no-op). The boot-cleanup gate below is widened to cover this.
|
|
8453
8466
|
const bannerPinPersistEnabled = !STATIC
|
|
8454
8467
|
|
|
8455
|
-
// `
|
|
8456
|
-
//
|
|
8457
|
-
//
|
|
8458
|
-
//
|
|
8459
|
-
//
|
|
8460
|
-
//
|
|
8461
|
-
//
|
|
8468
|
+
// LEGACY `tool:` pin drain (#3001; the `pin_message` MCP tool was retired in
|
|
8469
|
+
// #4452). No new `tool:<chatId>:<messageId>` rows are ever written now that the
|
|
8470
|
+
// tool is gone, but rows an earlier build persisted may still sit in the shared
|
|
8471
|
+
// status-pins.json with a TTL (`expiresAt`). This flag keeps the boot cleanup /
|
|
8472
|
+
// stale-pin sweep aware of those legacy rows so they are still expired and
|
|
8473
|
+
// drained correctly on upgrade — an unexpired legacy row is preserved across
|
|
8474
|
+
// boots (never reaped as if it were a work-scoped card), then unpinned once its
|
|
8475
|
+
// TTL lapses, exactly as before. Independent of PIN_STATUS_WHILE_WORKING.
|
|
8476
|
+
// Self-clears within one TTL window since nothing writes new tool rows.
|
|
8462
8477
|
const toolPinPersistEnabled = !STATIC
|
|
8463
|
-
// 7 days: generous — an agent-pinned message the operator still cares about
|
|
8464
|
-
// after a week has usually been re-pinned or acted on; anything older is the
|
|
8465
|
-
// stale-pin long tail this issue exists to clear. Override for tuning.
|
|
8466
|
-
const TOOL_PIN_TTL_MS = (() => {
|
|
8467
|
-
const v = Number(process.env.SWITCHROOM_TOOL_PIN_TTL_MS)
|
|
8468
|
-
return Number.isFinite(v) && v > 0 ? v : 7 * 24 * 60 * 60_000
|
|
8469
|
-
})()
|
|
8470
8478
|
|
|
8471
8479
|
// Persist (or drop) the slot-banner's pin row into the shared store. Routes
|
|
8472
8480
|
// through mutateStatusPinRow: a read-modify-write for ONLY the banner:owner key,
|
|
@@ -8936,8 +8944,7 @@ async function reconcileStatusPin(
|
|
|
8936
8944
|
// "not enough rights to manage pinned messages" 400 in a supergroup took the
|
|
8937
8945
|
// whole gateway down (marko, 2026-07-01). Auto status-pin is cosmetic; it
|
|
8938
8946
|
// must NEVER be able to crash the gateway. Any throw here is logged and
|
|
8939
|
-
// absorbed.
|
|
8940
|
-
// as a normal tool-error — that path is `executePinMessage`, not this one.)
|
|
8947
|
+
// absorbed.
|
|
8941
8948
|
try {
|
|
8942
8949
|
// Serialize per pinKey (F2): reconcileStatusPinInner reads `prev` from the
|
|
8943
8950
|
// in-memory claim map at its top, so overlapping same-key reconciles must
|
|
@@ -9058,6 +9065,11 @@ const stalePinSweeper: StalePinSweeper = createGatewayStalePinSweeper({
|
|
|
9058
9065
|
? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
|
|
9059
9066
|
: [],
|
|
9060
9067
|
eligible: () => stalePinSweepEligible,
|
|
9068
|
+
// Share the live path's per-process pin-rights negative cache so the sweep
|
|
9069
|
+
// and executePinLeg agree on rights-less chats (D3): the sweep skips a chat
|
|
9070
|
+
// the live path already proved rights-less, and feeds its own reactive
|
|
9071
|
+
// discoveries back so the live path skips too.
|
|
9072
|
+
rightsCache: statusPinRightsCache,
|
|
9061
9073
|
store: { path: STALE_PIN_SWEEP_STORE_PATH, fs: sweepStoreFs },
|
|
9062
9074
|
// Per-deployment override only. UNSET (the normal case) means "take the
|
|
9063
9075
|
// standing policy" — UNPIN_ALL_FORUM_TOPIC_ENABLED in stale-pin-sweep.ts,
|
|
@@ -11592,69 +11604,19 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
11592
11604
|
// onQuotaWallDetected so it doesn't fall inside the source slice that
|
|
11593
11605
|
// send-outbound-wiring.test.ts takes between onSendOutbound and
|
|
11594
11606
|
// onQuotaWallDetected.
|
|
11607
|
+
// Thin delegate — body lives in self-improve-proposal-wiring.ts to keep the
|
|
11608
|
+
// gateway line ratchet flat. Placed AFTER onQuotaWallDetected so it doesn't
|
|
11609
|
+
// fall inside the source slice send-outbound-wiring.test.ts takes between
|
|
11610
|
+
// onSendOutbound and onQuotaWallDetected.
|
|
11595
11611
|
onPostSkillProposal(_client: IpcClient, msg: PostSkillProposalMessage) {
|
|
11596
|
-
|
|
11597
|
-
|
|
11598
|
-
|
|
11599
|
-
|
|
11600
|
-
|
|
11601
|
-
|
|
11602
|
-
|
|
11603
|
-
|
|
11604
|
-
assertAllowedChat(msg.chatId)
|
|
11605
|
-
} catch (err) {
|
|
11606
|
-
process.stderr.write(
|
|
11607
|
-
`telegram gateway: post_skill_proposal rejected — ${(err as Error).message}\n`,
|
|
11608
|
-
)
|
|
11609
|
-
return
|
|
11610
|
-
}
|
|
11611
|
-
const stateDir = process.env.TELEGRAM_STATE_DIR
|
|
11612
|
-
if (stateDir == null || stateDir.length === 0) {
|
|
11613
|
-
process.stderr.write(`telegram gateway: post_skill_proposal: TELEGRAM_STATE_DIR unset, skipping\n`)
|
|
11614
|
-
return
|
|
11615
|
-
}
|
|
11616
|
-
// Dedup against still-live rejection fingerprints — never re-surface a
|
|
11617
|
-
// proposal the operator already dismissed.
|
|
11618
|
-
if (isSkillProposalSuppressed(stateDir, {
|
|
11619
|
-
lesson: msg.lesson,
|
|
11620
|
-
draft: msg.draft,
|
|
11621
|
-
skill_slug: msg.skillSlug,
|
|
11622
|
-
})) {
|
|
11623
|
-
process.stderr.write(
|
|
11624
|
-
`telegram gateway: post_skill_proposal suppressed (rejected before) slug=${msg.skillSlug}\n`,
|
|
11625
|
-
)
|
|
11626
|
-
return
|
|
11627
|
-
}
|
|
11628
|
-
const proposal = enqueueSkillProposal(stateDir, {
|
|
11629
|
-
skill_slug: msg.skillSlug,
|
|
11630
|
-
is_new: msg.isNew,
|
|
11631
|
-
lesson: msg.lesson,
|
|
11632
|
-
draft: msg.draft,
|
|
11633
|
-
evidence: msg.evidence,
|
|
11634
|
-
chat_id: Number(msg.chatId),
|
|
11635
|
-
})
|
|
11636
|
-
const cardText = renderSkillProposalCard({
|
|
11637
|
-
id: proposal.id,
|
|
11638
|
-
skill_slug: proposal.skill_slug,
|
|
11639
|
-
is_new: proposal.is_new,
|
|
11640
|
-
lesson: proposal.lesson,
|
|
11641
|
-
evidence: proposal.evidence,
|
|
11642
|
-
skill_md: proposal.draft['SKILL.md'],
|
|
11643
|
-
})
|
|
11644
|
-
const threadId = msg.threadId
|
|
11645
|
-
void swallowingApiCall(
|
|
11646
|
-
() =>
|
|
11647
|
-
bot.api.sendMessage(msg.chatId, cardText, {
|
|
11648
|
-
parse_mode: 'HTML',
|
|
11649
|
-
reply_markup: skillProposalKeyboard(proposal.id),
|
|
11650
|
-
...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
|
|
11651
|
-
}),
|
|
11652
|
-
{ chat_id: msg.chatId, verb: 'skill-proposal-card', ...(threadId != null ? { threadId } : {}) },
|
|
11653
|
-
)
|
|
11654
|
-
process.stderr.write(
|
|
11655
|
-
`telegram gateway: post_skill_proposal agent=${msg.agentName} chat=${msg.chatId} ` +
|
|
11656
|
-
`proposal=${proposal.id} slug=${proposal.skill_slug} new=${proposal.is_new}\n`,
|
|
11657
|
-
)
|
|
11612
|
+
handlePostSkillProposal(msg, { bot, assertAllowedChat, swallowingApiCall })
|
|
11613
|
+
},
|
|
11614
|
+
|
|
11615
|
+
// RFC amendment §"corrections as eval cases" — thin delegate; the
|
|
11616
|
+
// DETERMINISTIC applier runs on Approve in handleEvalCaseProposalCallback,
|
|
11617
|
+
// NOT a model turn. Body in self-improve-proposal-wiring.ts.
|
|
11618
|
+
onPostEvalCaseProposal(_client: IpcClient, msg: PostEvalCaseProposalMessage) {
|
|
11619
|
+
handlePostEvalCaseProposal(msg, { bot, assertAllowedChat, swallowingApiCall })
|
|
11658
11620
|
},
|
|
11659
11621
|
|
|
11660
11622
|
// Buzz Phase 2b: the duplex peer's advisory publish outcome — no-op unless the hub mirror booted.
|
|
@@ -11839,7 +11801,7 @@ if (isGatewayMain && !STATIC) {
|
|
|
11839
11801
|
* bridge from calling arbitrary functions by name. */
|
|
11840
11802
|
const ALLOWED_TOOLS = new Set([
|
|
11841
11803
|
'reply', 'progress_update', 'react', 'download_attachment',
|
|
11842
|
-
'edit_message', 'send_typing', '
|
|
11804
|
+
'edit_message', 'send_typing', 'delete_message',
|
|
11843
11805
|
'forward_message', 'get_recent_messages',
|
|
11844
11806
|
'send_checklist', 'update_checklist',
|
|
11845
11807
|
'ask_user',
|
|
@@ -11875,8 +11837,6 @@ async function executeToolCall(
|
|
|
11875
11837
|
return executeEditMessage(args)
|
|
11876
11838
|
case 'send_typing':
|
|
11877
11839
|
return executeSendTyping(args)
|
|
11878
|
-
case 'pin_message':
|
|
11879
|
-
return executePinMessage(args)
|
|
11880
11840
|
case 'delete_message':
|
|
11881
11841
|
return executeDeleteMessage(args)
|
|
11882
11842
|
case 'forward_message':
|
|
@@ -13226,44 +13186,6 @@ async function executeSendTyping(args: Record<string, unknown>): Promise<unknown
|
|
|
13226
13186
|
return { content: [{ type: 'text', text: `${action} indicator sent (auto-refreshes every 4s, stops after 30s or next reply)` }] }
|
|
13227
13187
|
}
|
|
13228
13188
|
|
|
13229
|
-
async function executePinMessage(args: Record<string, unknown>): Promise<unknown> {
|
|
13230
|
-
if (!args.chat_id) throw new Error('pin_message: chat_id is required')
|
|
13231
|
-
if (!args.message_id) throw new Error('pin_message: message_id is required')
|
|
13232
|
-
const pinChatId = String(args.chat_id ?? '')
|
|
13233
|
-
assertAllowedChat(pinChatId)
|
|
13234
|
-
// #1075: wrap through robustApiCall so flood-wait / transient network
|
|
13235
|
-
// errors are retried. THREAD_NOT_FOUND on a stale topic surfaces to the
|
|
13236
|
-
// agent as a tool-error — pinning a vanished message is genuinely a
|
|
13237
|
-
// failure the agent should see.
|
|
13238
|
-
const pinMsgId = Number(args.message_id)
|
|
13239
|
-
await robustApiCall(
|
|
13240
|
-
() => lockedBot.api.pinChatMessage(pinChatId, pinMsgId), // allow-raw-pin: MCP `pin_message` tool — an explicit, agent-requested pin of an arbitrary message, not a progress surface with a claim.
|
|
13241
|
-
{ chat_id: pinChatId, verb: 'pin_message' },
|
|
13242
|
-
)
|
|
13243
|
-
// An explicit pin succeeded here, so the bot demonstrably HAS pin rights in
|
|
13244
|
-
// this chat now — clear any auto-pin negative-cache entry (#3024) so the auto
|
|
13245
|
-
// status-pin path resumes immediately rather than waiting for a restart. The
|
|
13246
|
-
// explicit tool itself never consults the cache; a failure above still
|
|
13247
|
-
// surfaces to the agent as a normal tool error (robustApiCall rethrows).
|
|
13248
|
-
statusPinRightsCache.clear(pinChatId)
|
|
13249
|
-
// #3001: register the tool pin in the shared status-pin store under a
|
|
13250
|
-
// `tool:` key so it is no longer fire-and-forget. Unlike work-scoped
|
|
13251
|
-
// fg:/wk: rows a tool pin has no "work finished" event, so a restart does
|
|
13252
|
-
// NOT reset it — boot cleanup keeps the row until its TTL, then unpins the
|
|
13253
|
-
// (likely long-forgotten) message. Best-effort fire-and-forget: a store
|
|
13254
|
-
// failure must never fail the tool call the pin already landed for.
|
|
13255
|
-
if (toolPinPersistEnabled) {
|
|
13256
|
-
const toolPinKey = `tool:${pinChatId}:${pinMsgId}`
|
|
13257
|
-
void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, { // allow-raw-pin-store: records the TTL-scoped `tool:` row for the explicit pin above so the boot sweep can expire it.
|
|
13258
|
-
pinKey: toolPinKey,
|
|
13259
|
-
chatId: pinChatId,
|
|
13260
|
-
messageId: pinMsgId,
|
|
13261
|
-
expiresAt: Date.now() + TOOL_PIN_TTL_MS,
|
|
13262
|
-
})
|
|
13263
|
-
}
|
|
13264
|
-
return { content: [{ type: 'text', text: `pinned message ${args.message_id}` }] }
|
|
13265
|
-
}
|
|
13266
|
-
|
|
13267
13189
|
async function executeDeleteMessage(args: Record<string, unknown>): Promise<unknown> {
|
|
13268
13190
|
if (!args.chat_id) throw new Error('delete_message: chat_id is required')
|
|
13269
13191
|
if (!args.message_id) throw new Error('delete_message: message_id is required')
|
|
@@ -17683,6 +17605,10 @@ async function refreshPinnedBanner(reason: string): Promise<void> {
|
|
|
17683
17605
|
onError: (phase, err) => {
|
|
17684
17606
|
process.stderr.write(`telegram gateway: banner ${phase} failed (${reason}): ${err}\n`)
|
|
17685
17607
|
},
|
|
17608
|
+
// Share the one per-process pin-rights negative cache with the status-pin
|
|
17609
|
+
// path and the stale-pin sweep (D5): the banner skips a chat already known
|
|
17610
|
+
// rights-less and records/clears its own pin-verb discoveries there.
|
|
17611
|
+
rightsCache: statusPinRightsCache,
|
|
17686
17612
|
// Durable pin persistence into the SHARED status-pin store (distinct
|
|
17687
17613
|
// `banner:` pinKey). persist-BEFORE-pin ordering: pending() lands before
|
|
17688
17614
|
// the pinChatMessage call so a crash in that window is recoverable by the
|
|
@@ -17908,16 +17834,10 @@ const litellmLocalNoticeRunner = createLitellmLocalNoticeRunner({
|
|
|
17908
17834
|
*/
|
|
17909
17835
|
let fallbackFailureNoticeState: FallbackFailureNoticeState = { lastSentAtMs: 0 }
|
|
17910
17836
|
|
|
17911
|
-
|
|
17912
|
-
|
|
17913
|
-
|
|
17914
|
-
|
|
17915
|
-
* quota_wall_detected re-trigger would otherwise re-broadcast the identical card
|
|
17916
|
-
* every minute for the life of the wall. This bounds it to one card per window.
|
|
17917
|
-
* Reset on a successful swap so a fresh all-blocked after a recovery (a real new
|
|
17918
|
-
* transition) is not stale-suppressed.
|
|
17919
|
-
*/
|
|
17920
|
-
let fallbackAllBlockedNoticeState: FallbackAllBlockedNoticeState = { lastSentAtMs: 0 }
|
|
17837
|
+
// Per-gateway cooldown gates for the two NO-OP fleet-fallback outcomes
|
|
17838
|
+
// (all-blocked and strict-pinned) live in ./fleet-fallback-notice-cooldown.ts,
|
|
17839
|
+
// which owns their state and stderr suppress-logs (switchroom#4442, extracted to
|
|
17840
|
+
// keep the inline notice logic out of the line-ratchet-guarded gateway.ts).
|
|
17921
17841
|
|
|
17922
17842
|
function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): void {
|
|
17923
17843
|
if (process.env.SWITCHROOM_FLEET_FALLBACK_FAILURE_NOTICE === '0') return
|
|
@@ -18167,7 +18087,11 @@ async function doFireFleetAutoFallback(
|
|
|
18167
18087
|
// weekly-capped account is never re-probed (and re-wedged) within the
|
|
18168
18088
|
// broker's ~5h default.
|
|
18169
18089
|
const r = await client.markExhausted(untilMs)
|
|
18170
|
-
return {
|
|
18090
|
+
return {
|
|
18091
|
+
rolledTo: r.rolledTo ?? null,
|
|
18092
|
+
rolled: r.rolled,
|
|
18093
|
+
callerPinnedStrict: r.caller_pinned_strict ?? false,
|
|
18094
|
+
}
|
|
18171
18095
|
},
|
|
18172
18096
|
triggerAgent,
|
|
18173
18097
|
tz,
|
|
@@ -18193,7 +18117,11 @@ async function doFireFleetAutoFallback(
|
|
|
18193
18117
|
// cooldown; a successful swap resets the window so a later (genuinely new)
|
|
18194
18118
|
// all-blocked still emits promptly.
|
|
18195
18119
|
if (outcome.kind === 'switched') {
|
|
18196
|
-
|
|
18120
|
+
resetFleetFallbackNoticeCooldowns()
|
|
18121
|
+
} else if (outcome.kind === 'strict-pinned') {
|
|
18122
|
+
// Same cooldown pattern as all-blocked (below): a no-op outcome the
|
|
18123
|
+
// ~60s wall re-trigger would otherwise re-broadcast every minute.
|
|
18124
|
+
if (!shouldSendStrictPinnedNotice(triggerAgent)) return false
|
|
18197
18125
|
} else if (outcome.kind === 'all-blocked') {
|
|
18198
18126
|
// ── Second recovery tier: MODEL-TIER downgrade (precedence A) ──────────
|
|
18199
18127
|
// Account-swap just came back all-blocked (no account still serves the
|
|
@@ -18210,14 +18138,7 @@ async function doFireFleetAutoFallback(
|
|
|
18210
18138
|
// card — a restart is coming that replays the interrupted turn.
|
|
18211
18139
|
return false
|
|
18212
18140
|
}
|
|
18213
|
-
|
|
18214
|
-
if (!verdict.send) {
|
|
18215
|
-
process.stderr.write(
|
|
18216
|
-
`telegram gateway: [fleet-fallback] all-blocked card suppressed (cooldown) agent=${triggerAgent}\n`,
|
|
18217
|
-
)
|
|
18218
|
-
return false
|
|
18219
|
-
}
|
|
18220
|
-
fallbackAllBlockedNoticeState = verdict.next
|
|
18141
|
+
if (!shouldSendAllBlockedNotice(triggerAgent)) return false
|
|
18221
18142
|
}
|
|
18222
18143
|
// Post the announcement to every authorized chat. Mirrors the
|
|
18223
18144
|
// operator-event broadcast pattern (line ~2290) — DM-only opts
|
|
@@ -19849,6 +19770,22 @@ bot.command('compact', async ctx => {
|
|
|
19849
19770
|
})
|
|
19850
19771
|
bot.command('clear', async ctx => {
|
|
19851
19772
|
await handleInjectCommand(ctx, buildInjectDeps({ open: true, fixedVerb: '/clear' }))
|
|
19773
|
+
// A /clear starts a new logical session → reset privacy to public.
|
|
19774
|
+
const clearChatId = String(ctx.chat!.id)
|
|
19775
|
+
resetPrivacyForNewSession(clearChatId, resolveThreadId(clearChatId, ctx.message?.message_thread_id))
|
|
19776
|
+
})
|
|
19777
|
+
// Per-operator session privacy controls. NOT admin verbs (deliberately kept
|
|
19778
|
+
// out of ADMIN_COMMAND_NAMES) — they gate memory writing for THIS session, so
|
|
19779
|
+
// they share the plain per-command isAuthorizedSender gate like inject/clear.
|
|
19780
|
+
bot.command('private', async ctx => {
|
|
19781
|
+
if (!isAuthorizedSender(ctx)) return
|
|
19782
|
+
openPrivateInterval()
|
|
19783
|
+
await switchroomReply(ctx, PRIVATE_ON_REPLY)
|
|
19784
|
+
})
|
|
19785
|
+
bot.command('public', async ctx => {
|
|
19786
|
+
if (!isAuthorizedSender(ctx)) return
|
|
19787
|
+
closePrivateInterval()
|
|
19788
|
+
await switchroomReply(ctx, PUBLIC_REPLY)
|
|
19852
19789
|
})
|
|
19853
19790
|
// /model and /effort extracted to bot-commands-model-effort.ts
|
|
19854
19791
|
// (switchroom#2996 P6 bot.command drain). Helper registration keeps grammy
|
|
@@ -21749,6 +21686,15 @@ bot.on('callback_query:data', async ctx => {
|
|
|
21749
21686
|
return
|
|
21750
21687
|
}
|
|
21751
21688
|
|
|
21689
|
+
// RFC amendment §"corrections as eval cases": one-tap eval-case card.
|
|
21690
|
+
// evcase:approve:<id> — run the DETERMINISTIC apply-eval-case applier
|
|
21691
|
+
// (byte-exact write; NOT a model turn)
|
|
21692
|
+
// evcase:deny:<id> — dismiss + mark the proposal rejected
|
|
21693
|
+
if (data.startsWith('evcase:')) {
|
|
21694
|
+
await callbackQueryHandlers.handleEvalCaseProposalCallback(ctx, data)
|
|
21695
|
+
return
|
|
21696
|
+
}
|
|
21697
|
+
|
|
21752
21698
|
// #2862: missed-approvals re-offer digest.
|
|
21753
21699
|
// missre:retry:<id> — inject a synthetic inbound asking the agent to
|
|
21754
21700
|
// re-attempt (re-raises a fresh approval card)
|
|
@@ -23527,6 +23473,8 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23527
23473
|
// Only when we KNOW it was a /model switch (reason) AND we will send the
|
|
23528
23474
|
// confirmation (marker chat captured); otherwise the boot card fires
|
|
23529
23475
|
// normally. Version/quota remain available via /status.
|
|
23476
|
+
// Privacy: genuine FRESH boot (cold/crash/planned/model-switch) → reset to public, reusing boot-card's chat. Suppressed on a --continue/auto transcript-restore (state must persist) and never wired to bridge-reconnect.
|
|
23477
|
+
if (target && !isContinueRestoreBoot(resolveAgentDirFromEnv())) resetPrivacyForNewSession(target.chatId, target.threadId)
|
|
23530
23478
|
const suppressBootCardForModelSwitch =
|
|
23531
23479
|
modelSwitchReason != null && modelSwitchMarkerChat != null
|
|
23532
23480
|
if (target && suppressBootCardForModelSwitch) {
|
|
@@ -23879,6 +23827,14 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23879
23827
|
const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_MAX_ROWS)
|
|
23880
23828
|
return Number.isInteger(raw) && raw > 0 ? raw : undefined
|
|
23881
23829
|
})()
|
|
23830
|
+
// First-paint hold for a prose-silent sub-agent card. Unset /
|
|
23831
|
+
// non-positive → the feed's built-in default (4000ms). Operator
|
|
23832
|
+
// override (SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS) lets first
|
|
23833
|
+
// paint be retuned/reverted (e.g. back to 8000) without a redeploy.
|
|
23834
|
+
const workerFeedFirstPaintMs = (() => {
|
|
23835
|
+
const raw = Number(process.env.SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS)
|
|
23836
|
+
return Number.isInteger(raw) && raw > 0 ? raw : undefined
|
|
23837
|
+
})()
|
|
23882
23838
|
// Model A — foreground sub-agent nesting in the parent's live
|
|
23883
23839
|
// activity draft. ON by default; this edits the SAME activity-
|
|
23884
23840
|
// summary message the tool_label feed already owns (not the
|
|
@@ -23952,6 +23908,10 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23952
23908
|
// channels.telegram.worker_feed.max_rows via the config cascade
|
|
23953
23909
|
// (scaffold emits SWITCHROOM_TG_WORKER_FEED_MAX_ROWS); unset → 8.
|
|
23954
23910
|
maxRows: workerFeedMaxRows,
|
|
23911
|
+
// First-paint hold for a prose-silent worker's initial card.
|
|
23912
|
+
// Unset → the feed's built-in default (4000ms); the operator
|
|
23913
|
+
// override SWITCHROOM_TG_WORKER_FEED_FIRST_PAINT_MS retunes it.
|
|
23914
|
+
firstPaintMinMs: workerFeedFirstPaintMs,
|
|
23955
23915
|
// Backstop TTL for the feed's stale-row reaper, DERIVED in code
|
|
23956
23916
|
// from the watcher's effective in-flight terminal cap (same env /
|
|
23957
23917
|
// default the watcher itself resolves) plus a margin — NOT a
|
|
@@ -573,6 +573,48 @@ export interface PostSkillProposalMessage {
|
|
|
573
573
|
evidence: string;
|
|
574
574
|
/** Full drafted skill bundle (SKILL.md + optional files). */
|
|
575
575
|
draft: Record<string, string>;
|
|
576
|
+
/**
|
|
577
|
+
* Provenance of the proposal — `"skill-synthesis"` (the weekly cron) or
|
|
578
|
+
* `"failure-synthesis"` (a skill drafted from an observed failure). Absent
|
|
579
|
+
* ⇒ the store defaults it to `"skill-synthesis"` (back-compat). Provenance
|
|
580
|
+
* is orthogonal to tier routing.
|
|
581
|
+
*/
|
|
582
|
+
origin?: "skill-synthesis" | "failure-synthesis";
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* `switchroom self-improve add-eval-case` asks the caller agent's gateway to
|
|
587
|
+
* persist an eval-case proposal and post a one-tap Approve/Dismiss card (RFC
|
|
588
|
+
* amendment §"corrections as eval cases"). Unlike a skill proposal, the
|
|
589
|
+
* Approve tap does NOT inject a model turn — the gateway runs the
|
|
590
|
+
* DETERMINISTIC `apply-eval-case` applier so the case lands byte-exact.
|
|
591
|
+
*
|
|
592
|
+
* Trust model identical to post_skill_proposal: per-agent socket, agentName
|
|
593
|
+
* validated, chat fenced to the agent's own chat.
|
|
594
|
+
*/
|
|
595
|
+
export interface PostEvalCaseProposalMessage {
|
|
596
|
+
type: "post_eval_case_proposal";
|
|
597
|
+
agentName: string;
|
|
598
|
+
/** Agent's own chat id (fenced server-side). */
|
|
599
|
+
chatId: string;
|
|
600
|
+
threadId?: number;
|
|
601
|
+
/** Target skill slug. */
|
|
602
|
+
skillSlug: string;
|
|
603
|
+
/** Absolute path to the owned skill bundle dir (resolved by the CLI). */
|
|
604
|
+
skillDir: string;
|
|
605
|
+
/** The eval case to append (prompt + optional expected/expectations/etc). */
|
|
606
|
+
case: {
|
|
607
|
+
prompt: string;
|
|
608
|
+
expected_output?: string;
|
|
609
|
+
files?: string[];
|
|
610
|
+
expectations?: string[];
|
|
611
|
+
source?: string;
|
|
612
|
+
id?: string;
|
|
613
|
+
};
|
|
614
|
+
/** Prompt fingerprint (dedup + provenance). */
|
|
615
|
+
fingerprint: string;
|
|
616
|
+
/** Route the case to the held-out sink instead of evals.json. */
|
|
617
|
+
heldOut: boolean;
|
|
576
618
|
}
|
|
577
619
|
|
|
578
620
|
/**
|
|
@@ -766,6 +808,7 @@ export type ClientToGateway =
|
|
|
766
808
|
| QuotaWallDetectedMessage
|
|
767
809
|
| SendOutboundMessage
|
|
768
810
|
| PostSkillProposalMessage
|
|
811
|
+
| PostEvalCaseProposalMessage
|
|
769
812
|
| RolloutStatusPostMessage
|
|
770
813
|
| RolloutStatusEditMessage
|
|
771
814
|
| QueryPendingPermissionMessage
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
QueryPendingPermissionMessage,
|
|
10
10
|
CheckPreApprovedMessage,
|
|
11
11
|
PostSkillProposalMessage,
|
|
12
|
+
PostEvalCaseProposalMessage,
|
|
12
13
|
OperatorEventForward,
|
|
13
14
|
PermissionRequestForward,
|
|
14
15
|
PtyPartialForward,
|
|
@@ -101,6 +102,12 @@ export interface IpcServerOptions {
|
|
|
101
102
|
* chat. Optional; gateways that don't surface proposals ignore it.
|
|
102
103
|
*/
|
|
103
104
|
onPostSkillProposal?: (client: IpcClient, msg: PostSkillProposalMessage) => void;
|
|
105
|
+
/**
|
|
106
|
+
* RFC amendment §"corrections as eval cases" — `add-eval-case` asks the
|
|
107
|
+
* gateway to persist an eval-case proposal and post its one-tap card.
|
|
108
|
+
* Optional; gateways that don't surface proposals ignore it.
|
|
109
|
+
*/
|
|
110
|
+
onPostEvalCaseProposal?: (client: IpcClient, msg: PostEvalCaseProposalMessage) => void;
|
|
104
111
|
/**
|
|
105
112
|
* RFC E §4.2 Cut 2 — Drive-write PreToolUse hook asks the gateway
|
|
106
113
|
* to register a kernel approval request + post a diff-preview
|
|
@@ -379,6 +386,23 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
379
386
|
&& (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
|
|
380
387
|
return true;
|
|
381
388
|
}
|
|
389
|
+
case "post_eval_case_proposal": {
|
|
390
|
+
// RFC amendment §"corrections as eval cases" — validate the wire shape;
|
|
391
|
+
// the gateway handler fences chatId to the agent's own chat.
|
|
392
|
+
if (typeof m.agentName !== "string"
|
|
393
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
394
|
+
if (typeof m.chatId !== "string" || (m.chatId as string).length === 0) return false;
|
|
395
|
+
if (typeof m.skillSlug !== "string" || (m.skillSlug as string).length === 0) return false;
|
|
396
|
+
if (typeof m.skillDir !== "string" || (m.skillDir as string).length === 0) return false;
|
|
397
|
+
if (typeof m.fingerprint !== "string" || (m.fingerprint as string).length === 0) return false;
|
|
398
|
+
if (typeof m.heldOut !== "boolean") return false;
|
|
399
|
+
if (typeof m.case !== "object" || m.case === null || Array.isArray(m.case)) return false;
|
|
400
|
+
if (typeof (m.case as Record<string, unknown>).prompt !== "string"
|
|
401
|
+
|| ((m.case as Record<string, unknown>).prompt as string).length === 0) return false;
|
|
402
|
+
if (m.threadId !== undefined
|
|
403
|
+
&& (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
382
406
|
case "quota_wall_detected": {
|
|
383
407
|
// wedge-watchdog detected the /rate-limit-options weekly-quota menu.
|
|
384
408
|
if (typeof m.agentName !== "string"
|
|
@@ -565,6 +589,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
565
589
|
onQueryPendingPermission,
|
|
566
590
|
onCheckPreApproved,
|
|
567
591
|
onPostSkillProposal,
|
|
592
|
+
onPostEvalCaseProposal,
|
|
568
593
|
onRequestDriveApproval,
|
|
569
594
|
onRequestMs365Approval,
|
|
570
595
|
onRequestConfigApproval,
|
|
@@ -733,6 +758,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
733
758
|
case "post_skill_proposal":
|
|
734
759
|
if (onPostSkillProposal) onPostSkillProposal(client, msg as PostSkillProposalMessage);
|
|
735
760
|
break;
|
|
761
|
+
case "post_eval_case_proposal":
|
|
762
|
+
if (onPostEvalCaseProposal) onPostEvalCaseProposal(client, msg as PostEvalCaseProposalMessage);
|
|
763
|
+
break;
|
|
736
764
|
case "quota_wall_detected":
|
|
737
765
|
if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
|
|
738
766
|
break;
|
|
@@ -21,6 +21,7 @@ import * as silencePoke from '../silence-poke.js'
|
|
|
21
21
|
import * as signalTracker from '../turn-signal-tracker.js'
|
|
22
22
|
import * as pendingProgress from '../pending-work-progress.js'
|
|
23
23
|
import { emitRuntimeMetric } from '../runtime-metrics.js'
|
|
24
|
+
import { computeTurnDurationMs } from './turn-record-status.js'
|
|
24
25
|
import { logStreamingEvent } from '../streaming-metrics.js'
|
|
25
26
|
import { clearSilentEndState } from '../silent-end.js'
|
|
26
27
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
@@ -377,7 +378,11 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
|
|
|
377
378
|
turnMatchesFallback && wedgedTurn != null && turnLiveForItsTopic(wedgedTurn)
|
|
378
379
|
const turnStartedAt = activeTurnStartedAt.get(fbKey)
|
|
379
380
|
if (turnStartedAt != null) {
|
|
380
|
-
|
|
381
|
+
// Guard shared with buildTurnRecord + the stream-render turn_ended paths:
|
|
382
|
+
// a 0 / bogus start must never emit `Date.now() - 0` (an absolute epoch
|
|
383
|
+
// value) as a duration. This path previously did a bare subtraction and
|
|
384
|
+
// poisoned the turn_ended dataset when `activeTurnStartedAt` held 0.
|
|
385
|
+
const turnDurationMs = computeTurnDurationMs(turnStartedAt, Date.now())
|
|
381
386
|
const outboundMetrics = signalTracker.getOutboundMetrics(fbKey)
|
|
382
387
|
emitRuntimeMetric({
|
|
383
388
|
kind: 'turn_ended',
|
|
@@ -56,7 +56,7 @@ import {
|
|
|
56
56
|
appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
|
|
57
57
|
} from '../tool-activity-summary.js'
|
|
58
58
|
import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
|
|
59
|
-
import { isSilentSentinelCardOutcome } from '../turn-flush-safety.js'
|
|
59
|
+
import { isHollowGhostCardOutcome, isSilentSentinelCardOutcome } from '../turn-flush-safety.js'
|
|
60
60
|
import { clearActivityCardRecord, writeActivityCardRecord } from './activity-card-store.js'
|
|
61
61
|
import { chatKeyWithSuffix } from './chat-key.js'
|
|
62
62
|
import {
|
|
@@ -455,10 +455,18 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
|
455
455
|
// stays in view when the feed scrolls past. Keyed to the same
|
|
456
456
|
// status-key the canonical turn-end (purgeReactionTracking) unpins.
|
|
457
457
|
// Fire-and-forget; the single-owner reconcile keeps state consistent.
|
|
458
|
+
// Pass `thread` as the 4th arg so the persisted row keeps the forum
|
|
459
|
+
// topic (D4): without it a forum-topic foreground card orphaned by a
|
|
460
|
+
// crash lands a threadId-less row, and the sweep cannot aim
|
|
461
|
+
// `unpinAllForumTopicMessages` at the right topic. Mirrors the worker
|
|
462
|
+
// feed's `reconcilePin` (gateway.ts). It does NOT change the pinKey —
|
|
463
|
+
// `statusKey(chat, thread)` already folds the topic into the key; the
|
|
464
|
+
// 4th arg only threads the topic into the claim + durable row.
|
|
458
465
|
void reconcileStatusPin(
|
|
459
466
|
`fg:${statusKey(chat, thread)}`,
|
|
460
467
|
chat,
|
|
461
468
|
{ pinned: true, messageId: sent.message_id },
|
|
469
|
+
thread,
|
|
462
470
|
)
|
|
463
471
|
} else {
|
|
464
472
|
const id = turn.activityMessageId
|
|
@@ -900,7 +908,30 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
|
|
|
900
908
|
capturedText: turn.capturedText,
|
|
901
909
|
finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
|
|
902
910
|
})
|
|
903
|
-
|
|
911
|
+
// #45 — hollow-ghost suppression. The sibling to the #4348 sentinel gate:
|
|
912
|
+
// a turn that ended having done ZERO surfaced tool work, never called
|
|
913
|
+
// reply, delivered no final answer, surfaced NO narration, and emitted no
|
|
914
|
+
// captured/reply text adopted an activity card at turn start that never
|
|
915
|
+
// got any content — the contentless `🤖 Agent · done · 0 tools · Ns`
|
|
916
|
+
// record Ken reported (a card opened by the liveness timer that stayed
|
|
917
|
+
// empty). The sentinel gate can't catch it (there is no NO_REPLY/
|
|
918
|
+
// HEARTBEAT_OK text to match; the flush classifies it `empty-text`, not
|
|
919
|
+
// `silent-marker`), so DELETE the empty card here instead of finalizing
|
|
920
|
+
// it. Deterministic (pure predicate over already-tracked turn fields) and
|
|
921
|
+
// normal-case-safe: any surfaced tool step, any rendered narrative line
|
|
922
|
+
// (`mirrorLines` — content the user actually saw), any reply, any captured
|
|
923
|
+
// text, or a delivered answer keeps the card. The `finalHtmlOverride`
|
|
924
|
+
// finalize path is only taken by the foreground handoff-clear (which fires
|
|
925
|
+
// on a delivered final answer), so this gate never contends with it.
|
|
926
|
+
const hollowGhostTurn = isHollowGhostCardOutcome({
|
|
927
|
+
replyCalled: turn.replyCalled,
|
|
928
|
+
labeledToolCount: turn.labeledToolCount,
|
|
929
|
+
mirrorLines: turn.mirrorLines,
|
|
930
|
+
capturedText: turn.capturedText,
|
|
931
|
+
lastReplyText: turn.lastReplyText,
|
|
932
|
+
finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
|
|
933
|
+
})
|
|
934
|
+
if (CLEAR_STATUS_ON_COMPLETION || silentSentinelTurn || hollowGhostTurn) {
|
|
904
935
|
try {
|
|
905
936
|
await robustApiCall(
|
|
906
937
|
() => bot.api.deleteMessage(chat, id),
|