switchroom 0.18.6 β 0.18.7
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 +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +269 -56
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +1937 -580
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1071 -130
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +9 -0
|
@@ -126,6 +126,14 @@ import {
|
|
|
126
126
|
} from './permission-timeout.js'
|
|
127
127
|
import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
|
|
128
128
|
import { createPermissionCardStore, type PersistedPermCard } from './permission-card-store.js'
|
|
129
|
+
import { createPendingCardStore, type PersistedApprovalCard } from './pending-card-store.js'
|
|
130
|
+
import {
|
|
131
|
+
buildVaultAccessTimeoutInbound,
|
|
132
|
+
buildVaultSaveTimeoutInbound,
|
|
133
|
+
buildSecretRequestTimeoutInbound,
|
|
134
|
+
buildMentalModelProposeTimeoutInbound,
|
|
135
|
+
} from './approval-timeout-inbound-builders.js'
|
|
136
|
+
import { expirePendingCard, sweepExpiredEntries } from './pending-card-expiry.js'
|
|
129
137
|
import {
|
|
130
138
|
isPermissionRearmEnabled,
|
|
131
139
|
permissionRearmGraceMs,
|
|
@@ -134,6 +142,11 @@ import {
|
|
|
134
142
|
distinctRequestIds,
|
|
135
143
|
} from './permission-rearm.js'
|
|
136
144
|
import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
|
|
145
|
+
import {
|
|
146
|
+
createAlwaysAllowPersistQueue,
|
|
147
|
+
drainAlwaysAllowPersistQueue,
|
|
148
|
+
type AlwaysAllowDrainDeps,
|
|
149
|
+
} from './always-allow-persist-queue.js'
|
|
137
150
|
import {
|
|
138
151
|
renderMissedApprovalsDigest,
|
|
139
152
|
missedApprovalsKeyboard,
|
|
@@ -143,6 +156,8 @@ import {
|
|
|
143
156
|
import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
|
|
144
157
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
145
158
|
import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
|
|
159
|
+
import { formatModelLabel } from '../model-label.js'
|
|
160
|
+
import { createSessionModelSource } from './session-model-source.js'
|
|
146
161
|
import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
|
|
147
162
|
import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
|
|
148
163
|
import { toolLabel } from '../tool-labels.js'
|
|
@@ -396,7 +411,7 @@ import {
|
|
|
396
411
|
_resetHostdEnabledCache,
|
|
397
412
|
} from './hostd-dispatch.js'
|
|
398
413
|
import { formatUpdateStatusLine } from './update-status-line.js'
|
|
399
|
-
import type { HostdRequest } from '../../src/host-control/protocol.js'
|
|
414
|
+
import type { HostdRequest, HostdResponse } from '../../src/host-control/protocol.js'
|
|
400
415
|
import type { AgentAudit } from '../welcome-text.js'
|
|
401
416
|
import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
|
|
402
417
|
import { startWebhookIngestServer } from './webhook-ingest-server.js'
|
|
@@ -530,6 +545,7 @@ import type {
|
|
|
530
545
|
InjectInboundMessage,
|
|
531
546
|
SendOutboundMessage,
|
|
532
547
|
QuotaWallDetectedMessage,
|
|
548
|
+
QueryPendingPermissionMessage,
|
|
533
549
|
PostSkillProposalMessage,
|
|
534
550
|
PermissionEvent,
|
|
535
551
|
RolloutStatusPostMessage,
|
|
@@ -561,6 +577,7 @@ import {
|
|
|
561
577
|
clearCleanShutdownMarker,
|
|
562
578
|
shouldSuppressRecoveryBanner,
|
|
563
579
|
shouldSuppressBootResume,
|
|
580
|
+
parseBootResumeMode,
|
|
564
581
|
resolveShutdownMarker,
|
|
565
582
|
DEFAULT_MAX_AGE_MS as CLEAN_SHUTDOWN_MAX_AGE_MS,
|
|
566
583
|
} from './clean-shutdown-marker.js'
|
|
@@ -677,9 +694,11 @@ import {
|
|
|
677
694
|
import {
|
|
678
695
|
buildResumeInterruptedInbound,
|
|
679
696
|
buildResumeWatchdogReportInbound,
|
|
680
|
-
|
|
697
|
+
buildResumeDeferredReportInbound,
|
|
698
|
+
decideBootResumeKind,
|
|
681
699
|
} from './resume-inbound-builder.js'
|
|
682
|
-
import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
|
|
700
|
+
import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
|
|
701
|
+
import type { InterruptedSubagent } from './resume-inbound-builder.js'
|
|
683
702
|
import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
684
703
|
import {
|
|
685
704
|
resolveSubagentStatusSurface,
|
|
@@ -717,12 +736,127 @@ process.on('beforeExit', () => {
|
|
|
717
736
|
// βββ Env + state dir ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
718
737
|
const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
719
738
|
const permCardStore = createPermissionCardStore(STATE_DIR)
|
|
739
|
+
// Durable store for the four AGENT-INITIATED approval-card families
|
|
740
|
+
// (vault_request_access / vault_request_save / request_secret /
|
|
741
|
+
// mental_model_propose). Persists card METADATA only β never any secret value
|
|
742
|
+
// (see pending-card-store.ts secrets-hygiene note). Restored at boot so a
|
|
743
|
+
// post-restart tap on a still-valid card works like a pre-restart tap, and
|
|
744
|
+
// swept in the pendingStateReaper so an unanswered card wakes the parked agent
|
|
745
|
+
// on TTL instead of leaving it parked forever.
|
|
746
|
+
const pendingCardStore = createPendingCardStore(STATE_DIR)
|
|
720
747
|
// #2862 β missed-approvals re-offer. Persisted list of approvals that
|
|
721
748
|
// TTL-expired while the operator was away; a digest card is posted on the
|
|
722
749
|
// operator's next activity. Kill switch: SWITCHROOM_MISSED_APPROVAL_REOFFER=0.
|
|
723
750
|
const missedApprovalsStore = createMissedApprovalsStore(STATE_DIR)
|
|
724
751
|
const MISSED_APPROVAL_REOFFER_ENABLED =
|
|
725
752
|
process.env.SWITCHROOM_MISSED_APPROVAL_REOFFER !== '0'
|
|
753
|
+
// #2973 pt.2 β durable retry queue for "π Always allow" persists that
|
|
754
|
+
// fail for a retryable reason (stale config view, rate limit, transient
|
|
755
|
+
// hostd error). Drained at boot (below) and on a periodic timer so a
|
|
756
|
+
// gateway restart mid-persist never loses the retry.
|
|
757
|
+
const alwaysAllowPersistQueue = createAlwaysAllowPersistQueue(STATE_DIR)
|
|
758
|
+
|
|
759
|
+
/** Read a hostd `error_envelope`'s structured `retry_after` fix (if
|
|
760
|
+
* present) as a millisecond delay from now. Returns undefined for any
|
|
761
|
+
* other fix kind / missing envelope / unparsable timestamp β callers
|
|
762
|
+
* fall back to plain exponential backoff in that case. */
|
|
763
|
+
function extractRetryAfterMs(resp: HostdResponse): number | undefined {
|
|
764
|
+
const fix = resp.error_envelope?.fix
|
|
765
|
+
if (fix == null || fix.kind !== 'retry_after') return undefined
|
|
766
|
+
const at = Date.parse(fix.retry_at)
|
|
767
|
+
if (Number.isNaN(at)) return undefined
|
|
768
|
+
return Math.max(0, at - Date.now())
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Build a FRESH dependency set for `drainAlwaysAllowPersistQueue` β a new
|
|
773
|
+
* factory call per drain pass, not a cached singleton, so every attempt
|
|
774
|
+
* re-reads config from disk (the #2973 stale-container-side-view failure
|
|
775
|
+
* class is fixed by never reusing a snapshot across retries).
|
|
776
|
+
*/
|
|
777
|
+
function alwaysAllowDrainDeps(): AlwaysAllowDrainDeps {
|
|
778
|
+
return {
|
|
779
|
+
readConfigText: () => {
|
|
780
|
+
const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
|
|
781
|
+
return readFileSync(cfgPath, 'utf8')
|
|
782
|
+
},
|
|
783
|
+
resolveAllowList: (_configText, agentName) => {
|
|
784
|
+
const cfg = loadSwitchroomConfig()
|
|
785
|
+
const rawAgent = cfg.agents?.[agentName]
|
|
786
|
+
if (!rawAgent) return []
|
|
787
|
+
const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
|
|
788
|
+
return (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
|
|
789
|
+
},
|
|
790
|
+
isRulePersisted,
|
|
791
|
+
synthesizeDiff: (agentName, rule, configText) =>
|
|
792
|
+
synthesizeAllowRuleDiff({ agentName, rule, configText }),
|
|
793
|
+
dispatchConfigEdit: async (entry, unifiedDiff) => {
|
|
794
|
+
const req: HostdRequest = {
|
|
795
|
+
v: 1,
|
|
796
|
+
op: 'config_propose_edit',
|
|
797
|
+
request_id: hostdRequestId('gw-always-allow-retry'),
|
|
798
|
+
args: {
|
|
799
|
+
unified_diff: unifiedDiff,
|
|
800
|
+
reason: `Operator 'always allow' retry: ${entry.agentName} can ${entry.grantPhrase}`,
|
|
801
|
+
target_path: '/state/config/switchroom.yaml',
|
|
802
|
+
},
|
|
803
|
+
}
|
|
804
|
+
const resp = await tryHostdDispatch(entry.agentName, req, 720_000)
|
|
805
|
+
if (resp === 'not-configured') {
|
|
806
|
+
return { ok: false as const, error: 'hostd not-configured (retry queue requires host_control.enabled)' }
|
|
807
|
+
}
|
|
808
|
+
if (resp.result === 'completed') return { ok: true as const }
|
|
809
|
+
return {
|
|
810
|
+
ok: false as const,
|
|
811
|
+
error: resp.error ?? `hostd ${resp.result}`,
|
|
812
|
+
retryAfterMs: extractRetryAfterMs(resp),
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
// #2973 pt.3 β loud terminal failure. A card edit doesn't ping the
|
|
816
|
+
// operator (the original permission card was already edited to the
|
|
817
|
+
// "saving durably in backgroundβ¦" interim state); this posts a NEW
|
|
818
|
+
// message instead, via the same operator-event broadcast every other
|
|
819
|
+
// fleet alert uses.
|
|
820
|
+
notifyTerminalFailure: (entry, reason) => {
|
|
821
|
+
emitGatewayOperatorEvent({
|
|
822
|
+
kind: 'always-allow-persist-failed',
|
|
823
|
+
agent: entry.agentName,
|
|
824
|
+
detail: `"${entry.grantPhrase}" (rule \`${entry.rule}\`) β ${reason}`,
|
|
825
|
+
suggestedActions: [],
|
|
826
|
+
firstSeenAt: new Date(),
|
|
827
|
+
})
|
|
828
|
+
},
|
|
829
|
+
log: (line) => process.stderr.write(line),
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** ~10 min between periodic drain passes β frequent enough that a
|
|
834
|
+
* retryable failure (rate limit, transient hostd hiccup) resolves within
|
|
835
|
+
* a reasonable window, infrequent enough to never look like polling. */
|
|
836
|
+
const ALWAYS_ALLOW_DRAIN_INTERVAL_MS = 10 * 60_000
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Boot drain (picks up anything a prior gateway process queued right
|
|
840
|
+
* before a restart β success criterion: "restarting the gateway mid-
|
|
841
|
+
* persist does not lose the queued entry") + a periodic timer thereafter.
|
|
842
|
+
* Called once at gateway startup. Every pass is independently
|
|
843
|
+
* fault-tolerant β `drainAlwaysAllowPersistQueue` never throws.
|
|
844
|
+
*/
|
|
845
|
+
function scheduleAlwaysAllowPersistDrain(): void {
|
|
846
|
+
void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
|
|
847
|
+
process.stderr.write(
|
|
848
|
+
`telegram gateway: always-allow-persist-queue boot drain failed: ${(err as Error).message}\n`,
|
|
849
|
+
)
|
|
850
|
+
})
|
|
851
|
+
const timer = setInterval(() => {
|
|
852
|
+
void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
|
|
853
|
+
process.stderr.write(
|
|
854
|
+
`telegram gateway: always-allow-persist-queue periodic drain failed: ${(err as Error).message}\n`,
|
|
855
|
+
)
|
|
856
|
+
})
|
|
857
|
+
}, ALWAYS_ALLOW_DRAIN_INTERVAL_MS)
|
|
858
|
+
timer.unref?.()
|
|
859
|
+
}
|
|
726
860
|
const ACCESS_FILE = join(STATE_DIR, 'access.json')
|
|
727
861
|
const APPROVED_DIR = join(STATE_DIR, 'approved')
|
|
728
862
|
const ENV_FILE = join(STATE_DIR, '.env')
|
|
@@ -1383,71 +1517,124 @@ try {
|
|
|
1383
1517
|
const pending = findLatestTurnIfInterrupted(turnsDb)
|
|
1384
1518
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
1385
1519
|
if (pending != null && selfAgent) {
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1388
|
-
//
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
//
|
|
1520
|
+
// Boot-resume policy (2026-07, superseding #2585). The block only runs
|
|
1521
|
+
// when `pending` exists β i.e. there IS genuinely in-flight work. The
|
|
1522
|
+
// product decision is that a DELIBERATE restart must not silently drop
|
|
1523
|
+
// that work: `session_continuity.boot_resume` (SWITCHROOM_BOOT_RESUME,
|
|
1524
|
+
// default 'in-flight') resumes it even after a clean shutdown. #2585's
|
|
1525
|
+
// quota-saving posture survives only as the opt-in 'never' mode β and
|
|
1526
|
+
// even then we deliver a passive REPORT, never silence, because the user
|
|
1527
|
+
// must always be told when in-flight work stopped.
|
|
1392
1528
|
//
|
|
1393
1529
|
// NOTE: GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH is defined lower in this file
|
|
1394
1530
|
// (module-init order); we compute the path inline here using the same
|
|
1395
1531
|
// formula so we can read it at boot-resume time.
|
|
1396
|
-
// SWITCHROOM_BOOT_RESUME_ALWAYS=1
|
|
1397
|
-
// unconditional resume
|
|
1532
|
+
// SWITCHROOM_BOOT_RESUME_ALWAYS=1 remains a back-compat escape hatch that
|
|
1533
|
+
// forces unconditional resume regardless of mode.
|
|
1398
1534
|
const bootResumeMarkerPath =
|
|
1399
1535
|
process.env.SWITCHROOM_GATEWAY_CLEAN_SHUTDOWN_MARKER ?? join(STATE_DIR, 'clean-shutdown.json')
|
|
1400
1536
|
const bootResumeCleanMarker = readCleanShutdownMarker(bootResumeMarkerPath)
|
|
1401
1537
|
const bootResumeForceAlways = process.env.SWITCHROOM_BOOT_RESUME_ALWAYS === '1'
|
|
1538
|
+
const bootResumeMode = parseBootResumeMode(process.env.SWITCHROOM_BOOT_RESUME)
|
|
1402
1539
|
const bootResumeSuppressed = shouldSuppressBootResume(bootResumeCleanMarker, Date.now(), {
|
|
1403
1540
|
forceAlways: bootResumeForceAlways,
|
|
1541
|
+
mode: bootResumeMode,
|
|
1542
|
+
})
|
|
1543
|
+
|
|
1544
|
+
// 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
|
|
1545
|
+
// interrupted work older than RESUME_MAX_AGE_MS β selectResumeBuilder
|
|
1546
|
+
// downgrades a stale 'resume' to the passive 'report'. Env override
|
|
1547
|
+
// SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
|
|
1548
|
+
const RESUME_MAX_AGE_MS = (() => {
|
|
1549
|
+
const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
|
|
1550
|
+
return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
|
|
1551
|
+
})()
|
|
1552
|
+
|
|
1553
|
+
// Decide the inbound kind (pure β see decideBootResumeKind). Precedence:
|
|
1554
|
+
// 1. loop-guard β 'defer-loop' (never a second resume of a resume)
|
|
1555
|
+
// 2. suppressed β 'defer-suppressed' (boot_resume: never; notice, not silence)
|
|
1556
|
+
// 3. otherwise β selectResumeBuilder (resume | report | none)
|
|
1557
|
+
const bootResumeKind = decideBootResumeKind({
|
|
1558
|
+
pending,
|
|
1559
|
+
suppressed: bootResumeSuppressed,
|
|
1560
|
+
ageMs: Math.max(0, Date.now() - pending.started_at),
|
|
1561
|
+
maxAgeMs: RESUME_MAX_AGE_MS,
|
|
1404
1562
|
})
|
|
1405
|
-
|
|
1563
|
+
|
|
1564
|
+
// Sub-agents that were still in flight (running / stalled β non-terminal)
|
|
1565
|
+
// when the turn was killed. Read HERE, at module top, BEFORE the
|
|
1566
|
+
// subagent-watcher's boot scan + reaper run: the watcher never deletes
|
|
1567
|
+
// these rows (it only flips runningβstalled and marks files historical
|
|
1568
|
+
// in-memory), and this accessor already includes 'stalled', so the data
|
|
1569
|
+
// survives either ordering β but reading pre-watcher keeps it simplest.
|
|
1570
|
+
// Threaded into ALL the builders below (resume, watchdog report, and the
|
|
1571
|
+
// deferred report) so the session gets an explicit killed-workers block β
|
|
1572
|
+
// it must not declare the task done on ghost workers, and even a
|
|
1573
|
+
// suppressed/loop-guarded resume must still NAME the deaths.
|
|
1574
|
+
let interruptedSubagents: InterruptedSubagent[] = []
|
|
1575
|
+
try {
|
|
1576
|
+
interruptedSubagents = listNonTerminalSubagentsForTurn(turnsDb, pending.turn_key).map(
|
|
1577
|
+
(s) => ({ agentType: s.agent_type, description: s.description, status: s.status }),
|
|
1578
|
+
)
|
|
1579
|
+
} catch (err) {
|
|
1406
1580
|
process.stderr.write(
|
|
1407
|
-
`telegram gateway: boot-resume
|
|
1408
|
-
`${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
|
|
1409
|
-
`) β unclean exits still resume turnKey=${pending.turn_key}\n`,
|
|
1581
|
+
`telegram gateway: boot-resume subagent lookup failed (${(err as Error).message}) β continuing without worker list\n`,
|
|
1410
1582
|
)
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
const RESUME_MAX_AGE_MS = (() => {
|
|
1418
|
-
const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
|
|
1419
|
-
return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
|
|
1420
|
-
})()
|
|
1421
|
-
const kind = selectResumeBuilder(pending.ended_via, {
|
|
1422
|
-
ageMs: Math.max(0, Date.now() - pending.started_at),
|
|
1423
|
-
maxAgeMs: RESUME_MAX_AGE_MS,
|
|
1424
|
-
})
|
|
1425
|
-
if (kind === 'resume') {
|
|
1426
|
-
bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending }) }
|
|
1427
|
-
} else if (kind === 'report') {
|
|
1428
|
-
// idleMs: this boot's measured marker age if it just classified this
|
|
1429
|
-
// turn; otherwise recover it from the persisted interrupt_reason (a
|
|
1430
|
-
// later boot, marker already swept); else fall back to total runtime.
|
|
1431
|
-
let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
|
|
1432
|
-
if (idleMs == null && pending.interrupt_reason) {
|
|
1433
|
-
try {
|
|
1434
|
-
const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
|
|
1435
|
-
if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
|
|
1436
|
-
} catch { /* malformed snapshot β fall through */ }
|
|
1437
|
-
}
|
|
1438
|
-
if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
|
|
1439
|
-
bootResumeInbound = {
|
|
1440
|
-
agent: selfAgent,
|
|
1441
|
-
msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs }),
|
|
1442
|
-
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (bootResumeKind === 'resume') {
|
|
1586
|
+
bootResumeInbound = {
|
|
1587
|
+
agent: selfAgent,
|
|
1588
|
+
msg: buildResumeInterruptedInbound({ turn: pending, subagents: interruptedSubagents }),
|
|
1443
1589
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1590
|
+
} else if (bootResumeKind === 'report') {
|
|
1591
|
+
// idleMs: this boot's measured marker age if it just classified this
|
|
1592
|
+
// turn; otherwise recover it from the persisted interrupt_reason (a
|
|
1593
|
+
// later boot, marker already swept); else fall back to total runtime.
|
|
1594
|
+
let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
|
|
1595
|
+
if (idleMs == null && pending.interrupt_reason) {
|
|
1596
|
+
try {
|
|
1597
|
+
const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
|
|
1598
|
+
if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
|
|
1599
|
+
} catch { /* malformed snapshot β fall through */ }
|
|
1600
|
+
}
|
|
1601
|
+
if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
|
|
1602
|
+
bootResumeInbound = {
|
|
1603
|
+
agent: selfAgent,
|
|
1604
|
+
msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs, subagents: interruptedSubagents }),
|
|
1605
|
+
}
|
|
1606
|
+
} else if (bootResumeKind === 'defer-loop' || bootResumeKind === 'defer-suppressed') {
|
|
1607
|
+
// Passive deferred-report: work was in flight but we decline to
|
|
1608
|
+
// auto-resume (loop-guard, or boot_resume:never). Silence is never
|
|
1609
|
+
// acceptable here β tell the user what was in flight and ask.
|
|
1610
|
+
bootResumeInbound = {
|
|
1611
|
+
agent: selfAgent,
|
|
1612
|
+
msg: buildResumeDeferredReportInbound({
|
|
1613
|
+
turn: pending,
|
|
1614
|
+
reason: bootResumeKind === 'defer-loop' ? 'loop-guard' : 'clean-restart-suppressed',
|
|
1615
|
+
subagents: interruptedSubagents,
|
|
1616
|
+
}),
|
|
1449
1617
|
}
|
|
1450
1618
|
}
|
|
1619
|
+
|
|
1620
|
+
if (bootResumeKind === 'defer-suppressed') {
|
|
1621
|
+
process.stderr.write(
|
|
1622
|
+
`telegram gateway: boot-resume suppressed (clean shutdown` +
|
|
1623
|
+
`${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
|
|
1624
|
+
`, mode=${bootResumeMode}) β passive report delivered for turnKey=${pending.turn_key}\n`,
|
|
1625
|
+
)
|
|
1626
|
+
} else if (bootResumeKind === 'defer-loop') {
|
|
1627
|
+
process.stderr.write(
|
|
1628
|
+
`telegram gateway: boot-resume loop-guard tripped (interrupted turn was itself a resume) ` +
|
|
1629
|
+
`β passive report delivered instead of re-resuming turnKey=${pending.turn_key}\n`,
|
|
1630
|
+
)
|
|
1631
|
+
}
|
|
1632
|
+
if (bootResumeInbound != null) {
|
|
1633
|
+
process.stderr.write(
|
|
1634
|
+
`telegram gateway: boot-resume queued kind=${bootResumeKind} mode=${bootResumeMode} ` +
|
|
1635
|
+
`turnKey=${pending.turn_key} endedVia=${pending.ended_via ?? 'open'} chat=${pending.chat_id}\n`,
|
|
1636
|
+
)
|
|
1637
|
+
}
|
|
1451
1638
|
}
|
|
1452
1639
|
|
|
1453
1640
|
// Diagnostic env file (one-shot, sourced by start.sh) β kept for the
|
|
@@ -2273,6 +2460,110 @@ function deliverResumeSyntheticOrBuffer(agent: string, inbound: InboundMessage):
|
|
|
2273
2460
|
return delivered
|
|
2274
2461
|
}
|
|
2275
2462
|
|
|
2463
|
+
/** Outcome of routing an agent-authored button tap through the turn-safe
|
|
2464
|
+
* delivery machinery. `buffered-mid-turn` and `delivered` both mean "the tap
|
|
2465
|
+
* will be actioned" (the mid-turn case flushes on turn-complete); only
|
|
2466
|
+
* `buffered-bridge-offline` needs the user-facing "agent is restarting" notice. */
|
|
2467
|
+
type ButtonTapDeliveryOutcome = 'delivered' | 'buffered-mid-turn' | 'buffered-bridge-offline'
|
|
2468
|
+
|
|
2469
|
+
/**
|
|
2470
|
+
* Deliver an agent-authored inline-keyboard button tap (`agent:` callback_data)
|
|
2471
|
+
* through the SAME turn-safe machinery as a normal Telegram inbound, instead of
|
|
2472
|
+
* the old raw `sendToAgent` + buffer-only-on-bridge-miss.
|
|
2473
|
+
*
|
|
2474
|
+
* THE BUG (#271 button path, verified 2026-07): the `agent:` callback handler
|
|
2475
|
+
* delivered the synthesized tap inbound with a bare `ipcServer.sendToAgent`,
|
|
2476
|
+
* marked busy, and buffered ONLY when the bridge was offline. It never ran the
|
|
2477
|
+
* #1556 turn gate β so a tap landing WHILE a turn is in flight fired the MCP
|
|
2478
|
+
* channel notification mid-turn, typed into the CLI composer, and stranded there
|
|
2479
|
+
* (the lawgpt/marko wedge). It also skipped the pre-send composer clear and the
|
|
2480
|
+
* deliver-until-acked tracking, so a stranded tap was never sweep-redelivered.
|
|
2481
|
+
*
|
|
2482
|
+
* Fix: reuse the resume-synthetic turn gate (mid-turn β `buffer-until-idle`, the
|
|
2483
|
+
* turn-complete hook + idle-drain flush it the instant claude goes idle), the
|
|
2484
|
+
* pre-send composer clear, and the delivery-confirm tracking β exactly like the
|
|
2485
|
+
* `handleInbound` fresh-turn path. A button tap carries no `meta.source` and a
|
|
2486
|
+
* non-empty body, so `shouldTrackDelivery` enrols it; we additionally require a
|
|
2487
|
+
* real `meta.message_id` so the `enqueue` ack has something to match (else the
|
|
2488
|
+
* never-drop sweep would storm). The tap UX is unchanged β the ack toast, the
|
|
2489
|
+
* single-use keyboard strip, and the bridge-offline spool + restart notice all
|
|
2490
|
+
* stay at the call site; only delivery timing/safety changes.
|
|
2491
|
+
*/
|
|
2492
|
+
async function deliverButtonTapInbound(
|
|
2493
|
+
agent: string,
|
|
2494
|
+
inbound: InboundMessage,
|
|
2495
|
+
): Promise<ButtonTapDeliveryOutcome> {
|
|
2496
|
+
// #1556 turn gate β same authoritative "is a turn in flight?" read the
|
|
2497
|
+
// resume-synthetic path uses. Mid-turn β hold in the pending-inbound buffer;
|
|
2498
|
+
// the turn-complete hook + idle-drain timer flush it when claude goes idle,
|
|
2499
|
+
// where it lands cleanly as a fresh turn instead of stranding in the composer.
|
|
2500
|
+
const { decision, reserve } = reserveInboundDelivery({
|
|
2501
|
+
turnInFlight: turnInFlightForGate(),
|
|
2502
|
+
isSteering: false,
|
|
2503
|
+
isInterrupt: false,
|
|
2504
|
+
})
|
|
2505
|
+
if (decision === 'buffer-until-idle') {
|
|
2506
|
+
pendingInboundBuffer.push(agent, inbound)
|
|
2507
|
+
return 'buffered-mid-turn'
|
|
2508
|
+
}
|
|
2509
|
+
// #2917 per-chat FIFO: reserve the chat's busy key SYNCHRONOUSLY β before the
|
|
2510
|
+
// composer-clear await below β so a concurrent same-chat inbound reaching the
|
|
2511
|
+
// live gate observes this in-flight delivery and buffers behind it. Released
|
|
2512
|
+
// in lockstep below if the send misses (bridge offline).
|
|
2513
|
+
let reservedBusyKey: string | null = null
|
|
2514
|
+
if (reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED) {
|
|
2515
|
+
reservedBusyKey = markClaudeBusyForInbound(inbound)
|
|
2516
|
+
}
|
|
2517
|
+
// Pre-send composer clear (the marko wedge) β wipe stale typed-ahead / ghost
|
|
2518
|
+
// text so the channel notification lands at a clean line and auto-submits.
|
|
2519
|
+
// Soft-fail by contract: a clear failure must NEVER block delivery.
|
|
2520
|
+
if (agent) {
|
|
2521
|
+
try {
|
|
2522
|
+
const { clearAgentComposer } = await import('../../src/agents/tmux.js')
|
|
2523
|
+
const cleared = clearAgentComposer({ agentName: agent })
|
|
2524
|
+
if ('error' in cleared) {
|
|
2525
|
+
process.stderr.write(
|
|
2526
|
+
`telegram gateway: button-tap pre-send composer-clear soft-failed agent=${agent}: ${cleared.error} β delivering anyway\n`,
|
|
2527
|
+
)
|
|
2528
|
+
}
|
|
2529
|
+
} catch (err) {
|
|
2530
|
+
process.stderr.write(
|
|
2531
|
+
`telegram gateway: button-tap pre-send composer-clear threw agent=${agent}: ${(err as Error).message} β delivering anyway\n`,
|
|
2532
|
+
)
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
const delivered = ipcServer.sendToAgent(agent, inbound)
|
|
2536
|
+
if (delivered) {
|
|
2537
|
+
const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inbound)
|
|
2538
|
+
// Track until claude acks via `enqueue` so the deliver-until-acked sweep
|
|
2539
|
+
// re-delivers a tap stranded in the composer. Only when we have a real
|
|
2540
|
+
// message_id to match the ack against β otherwise the never-drop loop storms.
|
|
2541
|
+
if (
|
|
2542
|
+
DELIVERY_CONFIRM_ENABLED &&
|
|
2543
|
+
inbound.meta?.message_id != null &&
|
|
2544
|
+
inbound.meta.message_id !== '' &&
|
|
2545
|
+
shouldTrackDelivery({
|
|
2546
|
+
isSteering: false,
|
|
2547
|
+
isInterrupt: false,
|
|
2548
|
+
hasSource: inbound.meta?.source != null,
|
|
2549
|
+
effectiveText: inbound.text,
|
|
2550
|
+
})
|
|
2551
|
+
) {
|
|
2552
|
+
trackDelivery(deliveryQueue, busyKey, inbound, Date.now(), String(inbound.messageId))
|
|
2553
|
+
}
|
|
2554
|
+
return 'delivered'
|
|
2555
|
+
}
|
|
2556
|
+
// Bridge offline: release the synchronous reservation in lockstep (else the
|
|
2557
|
+
// orphaned busy key gates every later inbound into the buffer), then spool the
|
|
2558
|
+
// tap so it replays on reconnect β same behaviour as before this fix.
|
|
2559
|
+
if (reservedBusyKey != null) {
|
|
2560
|
+
claudeBusyKeys.delete(reservedBusyKey)
|
|
2561
|
+
claudeBusyKeySince.delete(reservedBusyKey)
|
|
2562
|
+
}
|
|
2563
|
+
pendingInboundBuffer.push(agent, inbound)
|
|
2564
|
+
return 'buffered-bridge-offline'
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2276
2567
|
const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
|
|
2277
2568
|
|
|
2278
2569
|
// βββ Proactive context compaction (session.max_context_tokens) ββββββββββ
|
|
@@ -2502,6 +2793,13 @@ type CurrentTurn = {
|
|
|
2502
2793
|
// resume protocol uses this to decide "did the previous turn actually
|
|
2503
2794
|
// finish a reply, or was it interrupted before commit?".
|
|
2504
2795
|
lastAssistantDone: boolean
|
|
2796
|
+
// Live model in use for THIS turn, sourced from the main transcript's
|
|
2797
|
+
// `message.model` (the exact resolved model per API call) via the session-tail
|
|
2798
|
+
// `model` event β never from config or launch-time state. Updated on change;
|
|
2799
|
+
// undefined until the turn's first assistant line lands. Rendered onto the
|
|
2800
|
+
// activity/liveness card header's metrics line (e.g. "2m Β· 14 tools Β· opus 4.8")
|
|
2801
|
+
// and preferred by /status's buildAgentMetadata over the in-memory override.
|
|
2802
|
+
currentModel?: string
|
|
2505
2803
|
// Phase 1 of #332: count of tool_use events in the current turn, for
|
|
2506
2804
|
// the tool_call_count column in the turns registry.
|
|
2507
2805
|
toolCallCount: number
|
|
@@ -2627,6 +2925,16 @@ type CurrentTurn = {
|
|
|
2627
2925
|
// is never written and this is exactly the old singleton.
|
|
2628
2926
|
let currentTurn: CurrentTurn | null = null
|
|
2629
2927
|
const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
|
|
2928
|
+
// Freshness-aware /status session-model source. Two writers: the session-tail
|
|
2929
|
+
// `model` event (each assistant line's `message.model` β ground truth for the
|
|
2930
|
+
// last API call, survives between turns) and the #2982 /model override (set
|
|
2931
|
+
// the instant a switch is confirmed β the ONLY truthful source in the
|
|
2932
|
+
// idle-after-switch window, before the next assistant line lands). Every write
|
|
2933
|
+
// is seq-stamped and `resolve()` prefers the NEWER observation, so neither
|
|
2934
|
+
// source can go stale behind the other (session-model-source.ts, pinned by
|
|
2935
|
+
// tests/session-model-source.test.ts). buildAgentMetadata reads resolve();
|
|
2936
|
+
// the /model command paths write via setOverride.
|
|
2937
|
+
const sessionModelSource = createSessionModelSource()
|
|
2630
2938
|
// Captures the most-recently-started turn's sessionChatId. Unlike currentTurn,
|
|
2631
2939
|
// this is NOT cleared by the silence poke (firePoke/clearTurnStarted). It lets
|
|
2632
2940
|
// the Bug B fallback in executeReply route to the correct chat even when the
|
|
@@ -5236,17 +5544,26 @@ interface PendingVaultRequestSave {
|
|
|
5236
5544
|
why?: string
|
|
5237
5545
|
/** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
|
|
5238
5546
|
staged_at: number
|
|
5547
|
+
/** Set on entries RESTORED from disk after a gateway restart. The staged
|
|
5548
|
+
* secret `value` is held in memory only (never persisted β secrets
|
|
5549
|
+
* hygiene), so a restored entry has an empty value and cannot complete the
|
|
5550
|
+
* write. A Save tap on such a card degrades gracefully: it tells the agent
|
|
5551
|
+
* the value was lost to a restart instead of writing an empty secret. */
|
|
5552
|
+
restoredWithoutValue?: boolean
|
|
5239
5553
|
}
|
|
5240
5554
|
const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
|
|
5241
5555
|
// Gateway-side reap window for a staged vault-save card. Tracks the operator
|
|
5242
5556
|
// approval-card lifetime (config-driven, 60-min default) so the reap never
|
|
5243
5557
|
// races ahead of the card the operator is still looking at.
|
|
5244
5558
|
const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
|
|
5245
|
-
function sweepPendingVaultRequestSaves(): void {
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5559
|
+
function sweepPendingVaultRequestSaves(now = Date.now()): void {
|
|
5560
|
+
sweepExpiredEntries(
|
|
5561
|
+
pendingVaultRequestSaves,
|
|
5562
|
+
(v, n) => v.staged_at < n - VAULT_REQUEST_SAVE_TTL_MS,
|
|
5563
|
+
expireVaultSaveCard,
|
|
5564
|
+
now,
|
|
5565
|
+
cardExpiryLog,
|
|
5566
|
+
)
|
|
5250
5567
|
}
|
|
5251
5568
|
|
|
5252
5569
|
/**
|
|
@@ -5289,11 +5606,14 @@ const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
|
|
|
5289
5606
|
// Gateway-side reap window for a staged vault-access card. Tracks the operator
|
|
5290
5607
|
// approval-card lifetime (config-driven, 60-min default) β see approvalTtlMs.
|
|
5291
5608
|
const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
|
|
5292
|
-
function sweepPendingVaultRequestAccesses(): void {
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5609
|
+
function sweepPendingVaultRequestAccesses(now = Date.now()): void {
|
|
5610
|
+
sweepExpiredEntries(
|
|
5611
|
+
pendingVaultRequestAccesses,
|
|
5612
|
+
(v, n) => v.staged_at < n - VAULT_REQUEST_ACCESS_TTL_MS,
|
|
5613
|
+
expireVaultAccessCard,
|
|
5614
|
+
now,
|
|
5615
|
+
cardExpiryLog,
|
|
5616
|
+
)
|
|
5297
5617
|
}
|
|
5298
5618
|
|
|
5299
5619
|
/**
|
|
@@ -5325,23 +5645,14 @@ const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
|
|
|
5325
5645
|
// posted card's keyboard away, so a stale card left in the chat can't be tapped
|
|
5326
5646
|
// into a "Card expired" answer β the operator sees the β expiry inline instead.
|
|
5327
5647
|
// Best-effort: card edits are fire-and-forget (the entry is removed regardless).
|
|
5328
|
-
function sweepPendingMentalModelProposes(): void {
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
v.chat_id,
|
|
5337
|
-
v.card_message_id,
|
|
5338
|
-
richMessage('β _This mental-model proposal card expired. Ask the agent to re-propose if it still stands._'),
|
|
5339
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
5340
|
-
)
|
|
5341
|
-
.catch(() => {})
|
|
5342
|
-
}
|
|
5343
|
-
}
|
|
5344
|
-
}
|
|
5648
|
+
function sweepPendingMentalModelProposes(now = Date.now()): void {
|
|
5649
|
+
sweepExpiredEntries(
|
|
5650
|
+
pendingMentalModelProposes,
|
|
5651
|
+
(v, n) => v.staged_at < n - MENTAL_MODEL_PROPOSE_TTL_MS,
|
|
5652
|
+
expireMentalModelProposeCard,
|
|
5653
|
+
now,
|
|
5654
|
+
cardExpiryLog,
|
|
5655
|
+
)
|
|
5345
5656
|
}
|
|
5346
5657
|
|
|
5347
5658
|
// Sliding-window rate limit for mental-model proposals: at most
|
|
@@ -5578,6 +5889,296 @@ function isAutoFallbackCooldownActive(_agentName: string, now: number): boolean
|
|
|
5578
5889
|
}
|
|
5579
5890
|
}
|
|
5580
5891
|
|
|
5892
|
+
// ββ Agent-initiated approval-card TTL expiry β wake the parked agent ββββββββ
|
|
5893
|
+
//
|
|
5894
|
+
// The four agent-initiated approval-card families (vault_request_access /
|
|
5895
|
+
// vault_request_save / request_secret / mental_model_propose) each park the
|
|
5896
|
+
// requesting agent (it ends its turn to wait for the operator's tap). Before
|
|
5897
|
+
// this, an unanswered card that TTL-expired left the agent parked FOREVER: the
|
|
5898
|
+
// lazy sweep just deleted the in-memory entry and nothing woke the agent. Each
|
|
5899
|
+
// expire* helper mirrors the permission-card timeout path (#2411 / #2862) by
|
|
5900
|
+
// routing through the pure `expirePendingCard` core (pending-card-expiry.ts),
|
|
5901
|
+
// whose ordering + fault-isolation contract is behaviorally pinned by
|
|
5902
|
+
// pending-card-expiry.test.ts:
|
|
5903
|
+
// 1. drop the in-memory entry AND its durable store record FIRST (single-
|
|
5904
|
+
// shot β a second tick can never double-fire the wake),
|
|
5905
|
+
// 2. edit the card to a β expired state + strip its keyboard (best-effort),
|
|
5906
|
+
// 3. record it in missedApprovalsStore BEFORE delivering, so a throwing
|
|
5907
|
+
// deliver can't lose the re-offer for the operator's return,
|
|
5908
|
+
// 4. inject a TIMEOUT-outcome synthetic inbound (turn-gated via
|
|
5909
|
+
// deliverResumeSyntheticOrBuffer, guarded β a half-dead IPC socket that
|
|
5910
|
+
// throws on write is contained, never escaping the reaper's setInterval
|
|
5911
|
+
// callback into an uncaughtException gateway shutdown).
|
|
5912
|
+
// Called from BOTH the lazy sweeps (on next stage) and the pendingStateReaper
|
|
5913
|
+
// (every 60s β the authoritative timer so an idle gateway still wakes agents).
|
|
5914
|
+
|
|
5915
|
+
async function editCardExpired(chatId: string, messageId: number | undefined, body: string): Promise<void> {
|
|
5916
|
+
if (messageId == null) return
|
|
5917
|
+
await lockedBot.api
|
|
5918
|
+
// allow-raw-bot-api: message-id-targeted edit (no thread to lose); best-effort card-expiry strip from the reaper (no grammy ctx). Dropping reply_markup strips the stale keyboard atomically with the text edit.
|
|
5919
|
+
.editMessageText(chatId, messageId, richMessage(body), { reply_markup: { inline_keyboard: [] } })
|
|
5920
|
+
.catch(() => {})
|
|
5921
|
+
}
|
|
5922
|
+
|
|
5923
|
+
function recordMissedApproval(opts: {
|
|
5924
|
+
stageId: string
|
|
5925
|
+
toolName: string
|
|
5926
|
+
action: string
|
|
5927
|
+
chatId: string
|
|
5928
|
+
threadId?: number
|
|
5929
|
+
now: number
|
|
5930
|
+
}): void {
|
|
5931
|
+
if (!MISSED_APPROVAL_REOFFER_ENABLED) return
|
|
5932
|
+
missedApprovalsStore.add({
|
|
5933
|
+
requestId: opts.stageId,
|
|
5934
|
+
toolName: opts.toolName,
|
|
5935
|
+
action: opts.action,
|
|
5936
|
+
chatId: opts.chatId,
|
|
5937
|
+
threadId: opts.threadId ?? null,
|
|
5938
|
+
timedOutAt: opts.now,
|
|
5939
|
+
})
|
|
5940
|
+
}
|
|
5941
|
+
|
|
5942
|
+
const cardExpiryLog = (msg: string): void => {
|
|
5943
|
+
process.stderr.write(`telegram gateway: ${msg}\n`)
|
|
5944
|
+
}
|
|
5945
|
+
|
|
5946
|
+
function expireVaultAccessCard(stageId: string, v: PendingVaultRequestAccess, now: number): void {
|
|
5947
|
+
const timeoutMinutes = Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)
|
|
5948
|
+
const { delivered } = expirePendingCard({
|
|
5949
|
+
remove: () => {
|
|
5950
|
+
pendingVaultRequestAccesses.delete(stageId)
|
|
5951
|
+
pendingCardStore.remove(stageId)
|
|
5952
|
+
},
|
|
5953
|
+
editCard: () => void editCardExpired(
|
|
5954
|
+
v.chat_id,
|
|
5955
|
+
v.card_message_id,
|
|
5956
|
+
`β _This vault access request for \`${escapeHtmlForTg(v.key)}\` timed out before you tapped. Ask **${escapeHtmlForTg(v.agent)}** to re-request if it still stands._`,
|
|
5957
|
+
),
|
|
5958
|
+
buildInbound: () => buildVaultAccessTimeoutInbound({
|
|
5959
|
+
agent: v.agent,
|
|
5960
|
+
chatId: v.chat_id,
|
|
5961
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
5962
|
+
stageId,
|
|
5963
|
+
timeoutMinutes,
|
|
5964
|
+
key: v.key,
|
|
5965
|
+
scope: v.scope,
|
|
5966
|
+
}),
|
|
5967
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
5968
|
+
recordMiss: () => recordMissedApproval({
|
|
5969
|
+
stageId,
|
|
5970
|
+
toolName: 'vault_request_access',
|
|
5971
|
+
action: `grant ${v.agent} ${v.scope} access to \`${v.key}\``,
|
|
5972
|
+
chatId: v.chat_id,
|
|
5973
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
5974
|
+
now,
|
|
5975
|
+
}),
|
|
5976
|
+
log: cardExpiryLog,
|
|
5977
|
+
})
|
|
5978
|
+
process.stderr.write(
|
|
5979
|
+
`telegram gateway: vault_request_access TTL expired β wake agent=${v.agent} ` +
|
|
5980
|
+
`key=${v.key} stage=${stageId} delivered=${delivered}\n`,
|
|
5981
|
+
)
|
|
5982
|
+
}
|
|
5983
|
+
|
|
5984
|
+
function expireVaultSaveCard(stageId: string, v: PendingVaultRequestSave, now: number): void {
|
|
5985
|
+
const timeoutMinutes = Math.round(VAULT_REQUEST_SAVE_TTL_MS / 60000)
|
|
5986
|
+
const { delivered } = expirePendingCard({
|
|
5987
|
+
remove: () => {
|
|
5988
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
5989
|
+
pendingCardStore.remove(stageId)
|
|
5990
|
+
},
|
|
5991
|
+
editCard: () => void editCardExpired(
|
|
5992
|
+
v.chat_id,
|
|
5993
|
+
v.card_message_id,
|
|
5994
|
+
`β _This vault-save card for \`${escapeHtmlForTg(v.key)}\` timed out before you tapped. The secret was NOT stored. Ask **${escapeHtmlForTg(v.agent)}** to re-issue if you still want to save._`,
|
|
5995
|
+
),
|
|
5996
|
+
buildInbound: () => buildVaultSaveTimeoutInbound({
|
|
5997
|
+
agent: v.agent,
|
|
5998
|
+
chatId: v.chat_id,
|
|
5999
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6000
|
+
stageId,
|
|
6001
|
+
timeoutMinutes,
|
|
6002
|
+
key: v.key,
|
|
6003
|
+
}),
|
|
6004
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
6005
|
+
recordMiss: () => recordMissedApproval({
|
|
6006
|
+
stageId,
|
|
6007
|
+
toolName: 'vault_request_save',
|
|
6008
|
+
action: `save the secret \`${v.key}\` for ${v.agent}`,
|
|
6009
|
+
chatId: v.chat_id,
|
|
6010
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6011
|
+
now,
|
|
6012
|
+
}),
|
|
6013
|
+
log: cardExpiryLog,
|
|
6014
|
+
})
|
|
6015
|
+
process.stderr.write(
|
|
6016
|
+
`telegram gateway: vault_request_save TTL expired β wake agent=${v.agent} ` +
|
|
6017
|
+
`key=${v.key} stage=${stageId} delivered=${delivered}\n`,
|
|
6018
|
+
)
|
|
6019
|
+
}
|
|
6020
|
+
|
|
6021
|
+
function expireSecretRequestCard(stageId: string, v: PendingSecretRequest, now: number): void {
|
|
6022
|
+
const timeoutMinutes = Math.round(PENDING_SECRET_REQUEST_TTL_MS / 60000)
|
|
6023
|
+
const { delivered } = expirePendingCard({
|
|
6024
|
+
remove: () => {
|
|
6025
|
+
pendingSecretRequests.delete(stageId)
|
|
6026
|
+
pendingCardStore.remove(stageId)
|
|
6027
|
+
},
|
|
6028
|
+
editCard: () => void editCardExpired(
|
|
6029
|
+
v.chat_id,
|
|
6030
|
+
v.card_message_id,
|
|
6031
|
+
`β _This secret-request card for \`${escapeHtmlForTg(v.key)}\` timed out before you provided it. Ask **${escapeHtmlForTg(v.agent)}** to re-request if it still needs the value._`,
|
|
6032
|
+
),
|
|
6033
|
+
buildInbound: () => buildSecretRequestTimeoutInbound({
|
|
6034
|
+
agent: v.agent,
|
|
6035
|
+
chatId: v.chat_id,
|
|
6036
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6037
|
+
stageId,
|
|
6038
|
+
timeoutMinutes,
|
|
6039
|
+
key: v.key,
|
|
6040
|
+
}),
|
|
6041
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
6042
|
+
recordMiss: () => recordMissedApproval({
|
|
6043
|
+
stageId,
|
|
6044
|
+
toolName: 'request_secret',
|
|
6045
|
+
action: `provide the secret \`${v.key}\` for ${v.agent}`,
|
|
6046
|
+
chatId: v.chat_id,
|
|
6047
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6048
|
+
now,
|
|
6049
|
+
}),
|
|
6050
|
+
log: cardExpiryLog,
|
|
6051
|
+
})
|
|
6052
|
+
process.stderr.write(
|
|
6053
|
+
`telegram gateway: request_secret TTL expired β wake agent=${v.agent} ` +
|
|
6054
|
+
`key=${v.key} stage=${stageId} delivered=${delivered}\n`,
|
|
6055
|
+
)
|
|
6056
|
+
}
|
|
6057
|
+
|
|
6058
|
+
function expireMentalModelProposeCard(stageId: string, v: PendingMentalModelPropose, now: number): void {
|
|
6059
|
+
const timeoutMinutes = Math.round(MENTAL_MODEL_PROPOSE_TTL_MS / 60000)
|
|
6060
|
+
const { delivered } = expirePendingCard({
|
|
6061
|
+
remove: () => {
|
|
6062
|
+
pendingMentalModelProposes.delete(stageId)
|
|
6063
|
+
pendingCardStore.remove(stageId)
|
|
6064
|
+
},
|
|
6065
|
+
editCard: () => void editCardExpired(
|
|
6066
|
+
v.chat_id,
|
|
6067
|
+
v.card_message_id,
|
|
6068
|
+
`β _This mental-model proposal card for \`${escapeHtmlForTg(v.spec.name)}\` timed out before you tapped. Ask **${escapeHtmlForTg(v.agent)}** to re-propose if it still stands._`,
|
|
6069
|
+
),
|
|
6070
|
+
buildInbound: () => buildMentalModelProposeTimeoutInbound({
|
|
6071
|
+
agent: v.agent,
|
|
6072
|
+
chatId: v.chat_id,
|
|
6073
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6074
|
+
stageId,
|
|
6075
|
+
timeoutMinutes,
|
|
6076
|
+
name: v.spec.name,
|
|
6077
|
+
}),
|
|
6078
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
6079
|
+
recordMiss: () => recordMissedApproval({
|
|
6080
|
+
stageId,
|
|
6081
|
+
toolName: 'mental_model_propose',
|
|
6082
|
+
action: `declare the mental model \`${v.spec.name}\` for ${v.agent}`,
|
|
6083
|
+
chatId: v.chat_id,
|
|
6084
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6085
|
+
now,
|
|
6086
|
+
}),
|
|
6087
|
+
log: cardExpiryLog,
|
|
6088
|
+
})
|
|
6089
|
+
process.stderr.write(
|
|
6090
|
+
`telegram gateway: mental_model_propose TTL expired β wake agent=${v.agent} ` +
|
|
6091
|
+
`name=${v.spec.name} stage=${stageId} delivered=${delivered}\n`,
|
|
6092
|
+
)
|
|
6093
|
+
}
|
|
6094
|
+
|
|
6095
|
+
// Run all four agent-initiated approval-card expiry sweeps. Called from the
|
|
6096
|
+
// pendingStateReaper (the authoritative 60s timer). Each family sweep is
|
|
6097
|
+
// per-entry guarded via sweepExpiredEntries, so one throwing expiry (dead IPC
|
|
6098
|
+
// socket, store IO error) can't skip the remaining entries or families.
|
|
6099
|
+
function sweepExpiredApprovalCards(now: number): void {
|
|
6100
|
+
sweepPendingVaultRequestAccesses(now)
|
|
6101
|
+
sweepPendingVaultRequestSaves(now)
|
|
6102
|
+
sweepPendingMentalModelProposes(now)
|
|
6103
|
+
sweepSecretRequests(now)
|
|
6104
|
+
}
|
|
6105
|
+
|
|
6106
|
+
// Boot restore: repopulate the four in-memory approval-card maps from the
|
|
6107
|
+
// durable store so a post-restart tap on a still-valid card resolves normally
|
|
6108
|
+
// (approve β grant + synthetic; deny β denial synthetic) instead of hitting
|
|
6109
|
+
// the "Card expired" tombstone. Entries already past their TTL are left for
|
|
6110
|
+
// the reaper's next tick, which wakes the parked agent via the timeout path.
|
|
6111
|
+
// vault_request_save entries restore WITHOUT their staged value (never
|
|
6112
|
+
// persisted) and are flagged `restoredWithoutValue` so a Save tap degrades
|
|
6113
|
+
// gracefully rather than writing an empty secret.
|
|
6114
|
+
function restorePendingApprovalCards(): number {
|
|
6115
|
+
let restored = 0
|
|
6116
|
+
for (const e of pendingCardStore.loadAll()) {
|
|
6117
|
+
try {
|
|
6118
|
+
if (e.family === 'vault_request_access') {
|
|
6119
|
+
pendingVaultRequestAccesses.set(e.stageId, {
|
|
6120
|
+
agent: e.agent,
|
|
6121
|
+
chat_id: e.chatId,
|
|
6122
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6123
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6124
|
+
key: e.key,
|
|
6125
|
+
scope: e.scope,
|
|
6126
|
+
...(e.reason != null ? { reason: e.reason } : {}),
|
|
6127
|
+
ttl_seconds: e.ttlSeconds,
|
|
6128
|
+
staged_at: e.stagedAt,
|
|
6129
|
+
})
|
|
6130
|
+
restored++
|
|
6131
|
+
} else if (e.family === 'vault_request_save') {
|
|
6132
|
+
pendingVaultRequestSaves.set(e.stageId, {
|
|
6133
|
+
agent: e.agent,
|
|
6134
|
+
chat_id: e.chatId,
|
|
6135
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6136
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6137
|
+
key: e.key,
|
|
6138
|
+
kind: e.kind,
|
|
6139
|
+
value: '', // never persisted β secrets hygiene
|
|
6140
|
+
...(e.why != null ? { why: e.why } : {}),
|
|
6141
|
+
staged_at: e.stagedAt,
|
|
6142
|
+
restoredWithoutValue: true,
|
|
6143
|
+
})
|
|
6144
|
+
restored++
|
|
6145
|
+
} else if (e.family === 'request_secret') {
|
|
6146
|
+
pendingSecretRequests.set(e.stageId, {
|
|
6147
|
+
agent: e.agent,
|
|
6148
|
+
chat_id: e.chatId,
|
|
6149
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6150
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6151
|
+
key: e.key,
|
|
6152
|
+
...(e.reason != null ? { reason: e.reason } : {}),
|
|
6153
|
+
staged_at: e.stagedAt,
|
|
6154
|
+
})
|
|
6155
|
+
restored++
|
|
6156
|
+
} else if (e.family === 'mental_model_propose') {
|
|
6157
|
+
pendingMentalModelProposes.set(e.stageId, {
|
|
6158
|
+
agent: e.agent,
|
|
6159
|
+
chat_id: e.chatId,
|
|
6160
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6161
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6162
|
+
spec: e.spec,
|
|
6163
|
+
...(e.reason != null ? { reason: e.reason } : {}),
|
|
6164
|
+
staged_at: e.stagedAt,
|
|
6165
|
+
})
|
|
6166
|
+
restored++
|
|
6167
|
+
}
|
|
6168
|
+
} catch (err) {
|
|
6169
|
+
process.stderr.write(
|
|
6170
|
+
`telegram gateway: pending-card restore skipped a malformed entry: ${(err as Error).message}\n`,
|
|
6171
|
+
)
|
|
6172
|
+
}
|
|
6173
|
+
}
|
|
6174
|
+
if (restored > 0) {
|
|
6175
|
+
process.stderr.write(
|
|
6176
|
+
`telegram gateway: restored ${restored} pending approval card(s) from prior gateway session\n`,
|
|
6177
|
+
)
|
|
6178
|
+
}
|
|
6179
|
+
return restored
|
|
6180
|
+
}
|
|
6181
|
+
|
|
5581
6182
|
// 60-second sweep drops anything past its documented TTL.
|
|
5582
6183
|
const pendingStateReaper = setInterval(() => {
|
|
5583
6184
|
const now = Date.now()
|
|
@@ -5711,6 +6312,23 @@ const pendingStateReaper = setInterval(() => {
|
|
|
5711
6312
|
for (const [k, v] of deferredSecrets) {
|
|
5712
6313
|
if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
|
|
5713
6314
|
}
|
|
6315
|
+
// Agent-initiated approval cards (vault_request_access / vault_request_save /
|
|
6316
|
+
// request_secret / mental_model_propose): expire past-TTL entries and WAKE
|
|
6317
|
+
// the parked agent (timeout synthetic + missed-approvals re-offer). This is
|
|
6318
|
+
// the authoritative timer β before this the only expiry path was a lazy
|
|
6319
|
+
// sweep on the NEXT stage, so an agent that ended its turn to wait on one of
|
|
6320
|
+
// these cards could sit parked forever if no further request ever staged.
|
|
6321
|
+
// try/catch matches the sibling sweepStaleTurnActiveMarker guard: an escaped
|
|
6322
|
+
// throw inside this setInterval callback would reach uncaughtException and
|
|
6323
|
+
// take the WHOLE gateway down (per-entry faults are already contained inside
|
|
6324
|
+
// sweepExpiredEntries/expirePendingCard; this is the outer belt).
|
|
6325
|
+
try {
|
|
6326
|
+
sweepExpiredApprovalCards(now)
|
|
6327
|
+
} catch (err) {
|
|
6328
|
+
process.stderr.write(
|
|
6329
|
+
`telegram gateway: approval-card expiry sweep failed: ${(err as Error).message}\n`,
|
|
6330
|
+
)
|
|
6331
|
+
}
|
|
5714
6332
|
// #550: sweep a stale turn-active marker. Defence-in-depth for the
|
|
5715
6333
|
// case where neither the turn_end arm nor onTurnComplete fired (SDK
|
|
5716
6334
|
// killed before the JSONL turn_duration record, compaction window,
|
|
@@ -7396,6 +8014,19 @@ function trackRedeliveredInbound(merged: InboundMessage): void {
|
|
|
7396
8014
|
) {
|
|
7397
8015
|
return
|
|
7398
8016
|
}
|
|
8017
|
+
// Button-tap anti-storm guard β mirrors the immediate-delivery path in
|
|
8018
|
+
// deliverButtonTapInbound. A tap synthesized without a source message
|
|
8019
|
+
// (`cbMessageId == null` β `messageId: 0`, no meta.message_id) has no id
|
|
8020
|
+
// the `enqueue` ack can ever match, so enrolling it would make the
|
|
8021
|
+
// never-drop sweep re-deliver it until TTL. The immediate path skips
|
|
8022
|
+
// tracking for such taps; a tap that buffered mid-turn and flushed through
|
|
8023
|
+
// here must be skipped identically (asymmetry = a storm on one path only).
|
|
8024
|
+
if (
|
|
8025
|
+
merged.meta?.button_callback === 'true' &&
|
|
8026
|
+
(merged.meta.message_id == null || merged.meta.message_id === '')
|
|
8027
|
+
) {
|
|
8028
|
+
return
|
|
8029
|
+
}
|
|
7399
8030
|
const key = chatKey(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null)
|
|
7400
8031
|
trackDelivery(
|
|
7401
8032
|
deliveryQueue,
|
|
@@ -9261,6 +9892,42 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
9261
9892
|
void fireFleetAutoFallback(msg.agentName, untilMs)
|
|
9262
9893
|
},
|
|
9263
9894
|
|
|
9895
|
+
// Issue #2971 β read-only wedge-watchdog probe: is there a live pending
|
|
9896
|
+
// permission request (Telegram approval card) for this agent right now?
|
|
9897
|
+
// Sourced directly from `pendingPermissions` β no mutation, no card
|
|
9898
|
+
// posting, just a snapshot read. The watchdog uses this to decide whether
|
|
9899
|
+
// to Esc a shape-persistent permission-prompt TUI or defer to the card /
|
|
9900
|
+
// the #2724 TTL reaper. Always answered synchronously and on the SAME
|
|
9901
|
+
// connection so the watchdog's short (~2s) budget can resolve promptly.
|
|
9902
|
+
onQueryPendingPermission(client: IpcClient, msg: QueryPendingPermissionMessage) {
|
|
9903
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
9904
|
+
if (self && msg.agentName !== self) {
|
|
9905
|
+
process.stderr.write(
|
|
9906
|
+
`telegram gateway: query_pending_permission rejected β agent mismatch (${msg.agentName} != ${self})\n`,
|
|
9907
|
+
)
|
|
9908
|
+
try {
|
|
9909
|
+
client.send({ type: 'pending_permission_status', correlationId: msg.correlationId, pending: false })
|
|
9910
|
+
} catch { /* best effort */ }
|
|
9911
|
+
return
|
|
9912
|
+
}
|
|
9913
|
+
// Any LIVE entry answers the question β this gateway serves exactly one
|
|
9914
|
+
// agent, so `pendingPermissions` is already scoped to `msg.agentName`.
|
|
9915
|
+
const [requestId] = pendingPermissions.keys()
|
|
9916
|
+
const pending = pendingPermissions.size > 0
|
|
9917
|
+
try {
|
|
9918
|
+
client.send({
|
|
9919
|
+
type: 'pending_permission_status',
|
|
9920
|
+
correlationId: msg.correlationId,
|
|
9921
|
+
pending,
|
|
9922
|
+
...(pending && requestId ? { requestId } : {}),
|
|
9923
|
+
})
|
|
9924
|
+
} catch (err) {
|
|
9925
|
+
process.stderr.write(
|
|
9926
|
+
`telegram gateway: query_pending_permission reply failed: ${(err as Error).message}\n`,
|
|
9927
|
+
)
|
|
9928
|
+
}
|
|
9929
|
+
},
|
|
9930
|
+
|
|
9264
9931
|
// #2670 one-tap self-improvement β persist a skill-improvement proposal and
|
|
9265
9932
|
// post its Approve/Dismiss card. The store transition + apply-injection on
|
|
9266
9933
|
// Approve are owned by handleSkillProposalCallback (so a gateway restart
|
|
@@ -11748,6 +12415,21 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
|
|
|
11748
12415
|
{ threadId, chat_id, verb: 'vault_request_save.card' },
|
|
11749
12416
|
)
|
|
11750
12417
|
pending.card_message_id = sent.message_id
|
|
12418
|
+
// Persist card METADATA (never the staged `value` β secrets hygiene) so a
|
|
12419
|
+
// gateway restart doesn't strand the parked agent. A restored Save tap can't
|
|
12420
|
+
// complete (value is gone) and degrades to a "value lost to restart" wake-up.
|
|
12421
|
+
pendingCardStore.add({
|
|
12422
|
+
family: 'vault_request_save',
|
|
12423
|
+
stageId,
|
|
12424
|
+
agent: pending.agent,
|
|
12425
|
+
chatId: pending.chat_id,
|
|
12426
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
12427
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
12428
|
+
key: pending.key,
|
|
12429
|
+
kind: pending.kind,
|
|
12430
|
+
...(pending.why != null ? { why: pending.why } : {}),
|
|
12431
|
+
stagedAt: pending.staged_at,
|
|
12432
|
+
})
|
|
11751
12433
|
|
|
11752
12434
|
return {
|
|
11753
12435
|
content: [
|
|
@@ -11791,11 +12473,17 @@ const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
|
|
|
11791
12473
|
const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
|
|
11792
12474
|
const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
|
|
11793
12475
|
|
|
11794
|
-
function sweepSecretRequests(): void {
|
|
11795
|
-
|
|
11796
|
-
|
|
11797
|
-
|
|
11798
|
-
|
|
12476
|
+
function sweepSecretRequests(now = Date.now()): void {
|
|
12477
|
+
sweepExpiredEntries(
|
|
12478
|
+
pendingSecretRequests,
|
|
12479
|
+
(v, n) => n - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS,
|
|
12480
|
+
expireSecretRequestCard,
|
|
12481
|
+
now,
|
|
12482
|
+
cardExpiryLog,
|
|
12483
|
+
)
|
|
12484
|
+
// armedSecretCaptures is a TRANSIENT post-tap window (never persisted): it's
|
|
12485
|
+
// only set after the operator taps [Provide securely], and the request is
|
|
12486
|
+
// no longer parked-on-a-card. Just drop stale ones β no wake needed.
|
|
11799
12487
|
for (const [k, v] of armedSecretCaptures) {
|
|
11800
12488
|
if (now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) armedSecretCaptures.delete(k)
|
|
11801
12489
|
}
|
|
@@ -11845,7 +12533,10 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
|
|
|
11845
12533
|
// Dedupe: one open request per (chat, key). Drop any prior stage for
|
|
11846
12534
|
// the same target so the operator never sees stacked cards.
|
|
11847
12535
|
for (const [sid, p] of pendingSecretRequests) {
|
|
11848
|
-
if (p.chat_id === chat_id && p.key === key)
|
|
12536
|
+
if (p.chat_id === chat_id && p.key === key) {
|
|
12537
|
+
pendingSecretRequests.delete(sid)
|
|
12538
|
+
pendingCardStore.remove(sid)
|
|
12539
|
+
}
|
|
11849
12540
|
}
|
|
11850
12541
|
|
|
11851
12542
|
const stageId = randomBytes(4).toString('hex')
|
|
@@ -11867,6 +12558,21 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
|
|
|
11867
12558
|
{ threadId, chat_id, verb: 'request_secret.card' },
|
|
11868
12559
|
)
|
|
11869
12560
|
pending.card_message_id = sent.message_id
|
|
12561
|
+
// Persist card metadata so a gateway restart doesn't strand the parked
|
|
12562
|
+
// agent. request_secret holds NO value at staging time (the value arrives
|
|
12563
|
+
// after the operator taps [Provide securely]), so nothing sensitive lands
|
|
12564
|
+
// on disk here.
|
|
12565
|
+
pendingCardStore.add({
|
|
12566
|
+
family: 'request_secret',
|
|
12567
|
+
stageId,
|
|
12568
|
+
agent: pending.agent,
|
|
12569
|
+
chatId: pending.chat_id,
|
|
12570
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
12571
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
12572
|
+
key: pending.key,
|
|
12573
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
12574
|
+
stagedAt: pending.staged_at,
|
|
12575
|
+
})
|
|
11870
12576
|
|
|
11871
12577
|
return {
|
|
11872
12578
|
content: [
|
|
@@ -11915,6 +12621,7 @@ async function captureProvidedSecret(
|
|
|
11915
12621
|
armedSecretCaptures.delete(chat_id)
|
|
11916
12622
|
const pending = pendingSecretRequests.get(armed.stageId)
|
|
11917
12623
|
pendingSecretRequests.delete(armed.stageId)
|
|
12624
|
+
pendingCardStore.remove(armed.stageId)
|
|
11918
12625
|
|
|
11919
12626
|
// Delete the raw message FIRST β surfaces a warning if it fails.
|
|
11920
12627
|
if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'provided secret value')
|
|
@@ -12030,6 +12737,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
|
|
|
12030
12737
|
|
|
12031
12738
|
if (action === 'decline') {
|
|
12032
12739
|
pendingSecretRequests.delete(stageId)
|
|
12740
|
+
pendingCardStore.remove(stageId)
|
|
12033
12741
|
armedSecretCaptures.delete(pending.chat_id)
|
|
12034
12742
|
await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
|
|
12035
12743
|
if (pending.card_message_id != null) {
|
|
@@ -12197,12 +12905,27 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
|
|
|
12197
12905
|
{ threadId, chat_id, verb: 'vault_request_access.card' },
|
|
12198
12906
|
)
|
|
12199
12907
|
pending.card_message_id = sent.message_id
|
|
12908
|
+
// Persist card metadata (no secret material β this flow stages only the ACL
|
|
12909
|
+
// request) so a gateway restart doesn't strand the parked agent.
|
|
12910
|
+
pendingCardStore.add({
|
|
12911
|
+
family: 'vault_request_access',
|
|
12912
|
+
stageId,
|
|
12913
|
+
agent: pending.agent,
|
|
12914
|
+
chatId: pending.chat_id,
|
|
12915
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
12916
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
12917
|
+
key: pending.key,
|
|
12918
|
+
scope: pending.scope,
|
|
12919
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
12920
|
+
ttlSeconds: pending.ttl_seconds,
|
|
12921
|
+
stagedAt: pending.staged_at,
|
|
12922
|
+
})
|
|
12200
12923
|
|
|
12201
12924
|
return {
|
|
12202
12925
|
content: [
|
|
12203
12926
|
{
|
|
12204
12927
|
type: 'text',
|
|
12205
|
-
text: `vault_request_access: card sent (stage_id=${stageId}, key=${key}, scope=${scopeRaw}). Wait for the operator to tap Approve or Deny β do not retry the vault read until you see a confirmation message. If the card times out (
|
|
12928
|
+
text: `vault_request_access: card sent (stage_id=${stageId}, key=${key}, scope=${scopeRaw}). Wait for the operator to tap Approve or Deny β do not retry the vault read until you see a confirmation message. If the card times out (${Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)} min) you can re-request.`,
|
|
12206
12929
|
},
|
|
12207
12930
|
],
|
|
12208
12931
|
}
|
|
@@ -12366,6 +13089,19 @@ async function executeMentalModelPropose(args: Record<string, unknown>): Promise
|
|
|
12366
13089
|
{ threadId, chat_id, verb: 'mental_model_propose.card' },
|
|
12367
13090
|
)
|
|
12368
13091
|
pending.card_message_id = sent.message_id
|
|
13092
|
+
// Persist card metadata (the proposed DECLARATION is not secret material) so
|
|
13093
|
+
// a gateway restart doesn't strand the parked agent.
|
|
13094
|
+
pendingCardStore.add({
|
|
13095
|
+
family: 'mental_model_propose',
|
|
13096
|
+
stageId,
|
|
13097
|
+
agent: pending.agent,
|
|
13098
|
+
chatId: pending.chat_id,
|
|
13099
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
13100
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
13101
|
+
spec: pending.spec,
|
|
13102
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
13103
|
+
stagedAt: pending.staged_at,
|
|
13104
|
+
})
|
|
12369
13105
|
// Only count a proposal against the rate budget once its card actually
|
|
12370
13106
|
// posted (validation errors / dupes don't consume the budget).
|
|
12371
13107
|
mentalModelProposeTimes.push(Date.now())
|
|
@@ -12771,6 +13507,7 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
|
|
|
12771
13507
|
elapsedMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
|
|
12772
13508
|
toolCount: turn.labeledToolCount,
|
|
12773
13509
|
state: final ? 'done' : 'running',
|
|
13510
|
+
model: turn.currentModel,
|
|
12774
13511
|
}
|
|
12775
13512
|
return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
|
|
12776
13513
|
}
|
|
@@ -13088,6 +13825,7 @@ function openLivenessFeedIfDue(turn: CurrentTurn): void {
|
|
|
13088
13825
|
const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Workingβ¦']
|
|
13089
13826
|
const livenessHeader: SessionActivityHeader = {
|
|
13090
13827
|
label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
|
|
13828
|
+
model: turn.currentModel,
|
|
13091
13829
|
}
|
|
13092
13830
|
// Liveness card is a single "step" whose start is the turn start, so `age`
|
|
13093
13831
|
// IS the step's own elapsed. formatStepSuffix keeps the `β` line timer-free
|
|
@@ -13218,6 +13956,7 @@ function feedHeartbeatTick(): void {
|
|
|
13218
13956
|
const age = Date.now() - turn.startedAt
|
|
13219
13957
|
const livenessHeader: SessionActivityHeader = {
|
|
13220
13958
|
label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
|
|
13959
|
+
model: turn.currentModel,
|
|
13221
13960
|
}
|
|
13222
13961
|
const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working in backgroundβ¦']
|
|
13223
13962
|
// `subagentAt` is the worker's last ADVANCE β the current step's start β
|
|
@@ -13407,6 +14146,7 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
13407
14146
|
const livenessElapsed = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
|
|
13408
14147
|
const livenessHeader: SessionActivityHeader = {
|
|
13409
14148
|
label: 'Agent', elapsedMs: livenessElapsed, toolCount: turn.labeledToolCount, state: 'done',
|
|
14149
|
+
model: turn.currentModel,
|
|
13410
14150
|
}
|
|
13411
14151
|
finalHtml = renderActivityFeedWithNested(['Workingβ¦'], [], true, '', undefined, livenessHeader)
|
|
13412
14152
|
}
|
|
@@ -13897,6 +14637,22 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13897
14637
|
return
|
|
13898
14638
|
}
|
|
13899
14639
|
case 'dequeue': return
|
|
14640
|
+
case 'model': {
|
|
14641
|
+
// Live model capture for the main turn. The session-tail projection
|
|
14642
|
+
// already filtered sentinels (`<synthetic>` compaction lines), so any
|
|
14643
|
+
// value reaching here is a real resolved model id. Record it on the turn
|
|
14644
|
+
// (update-on-change) so the activity/liveness card header and /status
|
|
14645
|
+
// render the model actually serving this turn's API calls β transcript-
|
|
14646
|
+
// sourced, never config. Also note it on the freshness-aware session-model
|
|
14647
|
+
// source so a /status query between turns still reflects the last model
|
|
14648
|
+
// (and a fresh assistant line reclaims the source from a /model override).
|
|
14649
|
+
const turn = currentTurn
|
|
14650
|
+
if (turn != null) {
|
|
14651
|
+
turn.currentModel = ev.model
|
|
14652
|
+
}
|
|
14653
|
+
sessionModelSource.noteTranscriptModel(ev.model)
|
|
14654
|
+
return
|
|
14655
|
+
}
|
|
13900
14656
|
case 'thinking': {
|
|
13901
14657
|
// #1067: snapshot the turn atom at handler entry. Even though this
|
|
13902
14658
|
// handler is sync, the principle is uniform across all event arms
|
|
@@ -18463,13 +19219,12 @@ function buildAgentAudit(agentName: string): AgentAudit | undefined {
|
|
|
18463
19219
|
// broker's fleet-wide `ListStateData` payload via
|
|
18464
19220
|
// `buildAuthSummaryFromBroker`, with billingType pulled from the
|
|
18465
19221
|
// agent's `.claude.json` (the broker doesn't track plan tier).
|
|
18466
|
-
|
|
18467
|
-
|
|
18468
|
-
|
|
18469
|
-
|
|
18470
|
-
|
|
18471
|
-
|
|
18472
|
-
let activeSessionModelOverride: string | null = null
|
|
19222
|
+
// The live session-model override set by the `/model` picker (session-only)
|
|
19223
|
+
// lives on `sessionModelSource` (setOverride/getOverride, declared beside the
|
|
19224
|
+
// currentTurn globals). Held in gateway memory so it clears on restart, the
|
|
19225
|
+
// same point at which claude's session reverts to the configured model β
|
|
19226
|
+
// keeping `/status` honest without a persisted store. `resolve()` arbitrates
|
|
19227
|
+
// freshness against the transcript-observed model (#2982 idle-switch window).
|
|
18473
19228
|
|
|
18474
19229
|
async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
|
|
18475
19230
|
type AgentListResp = {
|
|
@@ -18499,7 +19254,18 @@ async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
|
|
|
18499
19254
|
return {
|
|
18500
19255
|
agentName,
|
|
18501
19256
|
model: a?.model ?? null,
|
|
18502
|
-
|
|
19257
|
+
// The FRESHEST session-model observation wins (session-model-source.ts):
|
|
19258
|
+
// the transcript's `message.model` (ground truth for the last API call)
|
|
19259
|
+
// vs the #2982 /model override (the only truthful source in the idle-
|
|
19260
|
+
// after-switch window, before the next assistant line). Both rendered
|
|
19261
|
+
// through formatModelLabel for a consistent short form; an override value
|
|
19262
|
+
// that isn't model-shaped (an already-friendly "Opus 4.8" label) passes
|
|
19263
|
+
// through verbatim. Never sourced from config.
|
|
19264
|
+
sessionModel: (() => {
|
|
19265
|
+
const resolved = sessionModelSource.resolve()
|
|
19266
|
+
if (resolved == null) return null
|
|
19267
|
+
return formatModelLabel(resolved.model) ?? resolved.model
|
|
19268
|
+
})(),
|
|
18503
19269
|
extendsProfile: (a?.extends ?? a?.template) ?? null,
|
|
18504
19270
|
topicName: a?.topic_name ?? null,
|
|
18505
19271
|
topicEmoji: a?.topic_emoji ?? null,
|
|
@@ -18730,7 +19496,7 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18730
19496
|
},
|
|
18731
19497
|
escapeHtml: escapeHtmlForTg,
|
|
18732
19498
|
preBlock,
|
|
18733
|
-
getActiveSessionModel: () =>
|
|
19499
|
+
getActiveSessionModel: () => sessionModelSource.getOverride(),
|
|
18734
19500
|
/**
|
|
18735
19501
|
* Graceful restart for sr-* β Claude model switch. Same mechanism as
|
|
18736
19502
|
* the /restart command: writes a restart marker (so the post-restart
|
|
@@ -18740,9 +19506,18 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18740
19506
|
scheduleRestart: async (reason: string) => {
|
|
18741
19507
|
const name = getMyAgentName()
|
|
18742
19508
|
// Debounce: mirror the /restart command's 15 s guard to prevent
|
|
18743
|
-
// double-dispatch on a rapid double-tap of /model <claude-alias>.
|
|
19509
|
+
// double-dispatch on a rapid double-tap of /model <claude-alias>. This
|
|
19510
|
+
// path previously `return`ed silently β indistinguishable from a
|
|
19511
|
+
// successful dispatch β so a model switch caught in the window was a
|
|
19512
|
+
// silent no-op while the caller reported success. THROW a tagged error
|
|
19513
|
+
// so scheduleModelRelaunch can react (keep the in-flight restart's carrier,
|
|
19514
|
+
// tell the operator honestly) instead of falsely claiming the switch stuck.
|
|
18744
19515
|
const existing = readRestartMarker()
|
|
18745
|
-
if (existing && Date.now() - existing.ts < 15_000)
|
|
19516
|
+
if (existing && Date.now() - existing.ts < 15_000) {
|
|
19517
|
+
const e = new Error('a restart is already in flight β try again in ~15s')
|
|
19518
|
+
;(e as { code?: string }).code = 'restart_in_flight'
|
|
19519
|
+
throw e
|
|
19520
|
+
}
|
|
18746
19521
|
if (restartCtx) {
|
|
18747
19522
|
writeRestartMarker({
|
|
18748
19523
|
chat_id: restartCtx.chatId,
|
|
@@ -18792,9 +19567,25 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18792
19567
|
if (!agentDir) throw new Error('agent dir unresolvable β cannot write session-model carrier')
|
|
18793
19568
|
// Carrier: single line, token + newline, no quoting (start.sh strips
|
|
18794
19569
|
// whitespace and shape-gates). One-shot β consumed on the next boot.
|
|
19570
|
+
const prevOverride = sessionModelSource.getOverride()
|
|
18795
19571
|
writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
|
|
18796
|
-
|
|
18797
|
-
|
|
19572
|
+
sessionModelSource.setOverride(model)
|
|
19573
|
+
try {
|
|
19574
|
+
await deps.scheduleRestart(reason)
|
|
19575
|
+
} catch (err) {
|
|
19576
|
+
const carrierPath = join(agentDir, '.session-model-override')
|
|
19577
|
+
// A restart already in flight OWNS the carrier we just wrote β it will
|
|
19578
|
+
// consume our token at boot, so the switch is queued, not lost: keep the
|
|
19579
|
+
// carrier + override and let the caller tell the operator "~15s". Any
|
|
19580
|
+
// OTHER dispatch failure means no restart is coming, so roll BOTH back β
|
|
19581
|
+
// a lingering carrier/override would lie to /status and mis-launch the
|
|
19582
|
+
// NEXT ordinary restart.
|
|
19583
|
+
if ((err as { code?: string })?.code !== 'restart_in_flight') {
|
|
19584
|
+
try { rmSync(carrierPath, { force: true }) } catch { /* best-effort */ }
|
|
19585
|
+
sessionModelSource.setOverride(prevOverride)
|
|
19586
|
+
}
|
|
19587
|
+
throw err
|
|
19588
|
+
}
|
|
18798
19589
|
},
|
|
18799
19590
|
}
|
|
18800
19591
|
return deps
|
|
@@ -18823,6 +19614,15 @@ bot.command('model', async ctx => {
|
|
|
18823
19614
|
return
|
|
18824
19615
|
}
|
|
18825
19616
|
const reply = await handleModelCommand(parsed, deps)
|
|
19617
|
+
// Record a POSITIVELY-CONFIRMED typed switch so /status reflects what's
|
|
19618
|
+
// actually running β the SAME in-memory override the menu callback path sets
|
|
19619
|
+
// (buildAgentMetadata resolves it via sessionModelSource). Only
|
|
19620
|
+
// set on the confirmed inject path; the sr-*/relaunch paths already set the
|
|
19621
|
+
// override inside scheduleModelRelaunch, and an unverified switch carries no
|
|
19622
|
+
// selectedModel so /status is never lied to.
|
|
19623
|
+
if (reply.selectedModel) {
|
|
19624
|
+
sessionModelSource.setOverride(reply.selectedModel)
|
|
19625
|
+
}
|
|
18826
19626
|
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
18827
19627
|
})
|
|
18828
19628
|
|
|
@@ -21118,6 +21918,7 @@ async function performVaultAccessApproval(
|
|
|
21118
21918
|
const visible = await listViaBroker()
|
|
21119
21919
|
if (visible !== null && visible.includes(pending.key)) {
|
|
21120
21920
|
pendingVaultRequestAccesses.delete(stageId)
|
|
21921
|
+
pendingCardStore.remove(stageId)
|
|
21121
21922
|
if (pending.card_message_id != null) {
|
|
21122
21923
|
await ctx.api
|
|
21123
21924
|
.editMessageText(
|
|
@@ -21216,6 +22017,7 @@ async function performVaultAccessApproval(
|
|
|
21216
22017
|
// the agent to re-issue, or the broker error message will tell
|
|
21217
22018
|
// them the next step.
|
|
21218
22019
|
pendingVaultRequestAccesses.delete(stageId)
|
|
22020
|
+
pendingCardStore.remove(stageId)
|
|
21219
22021
|
if (pending.card_message_id != null) {
|
|
21220
22022
|
await ctx.api
|
|
21221
22023
|
.editMessageText(
|
|
@@ -21247,6 +22049,7 @@ async function performVaultAccessApproval(
|
|
|
21247
22049
|
}
|
|
21248
22050
|
|
|
21249
22051
|
pendingVaultRequestAccesses.delete(stageId)
|
|
22052
|
+
pendingCardStore.remove(stageId)
|
|
21250
22053
|
if (pending.card_message_id != null) {
|
|
21251
22054
|
const days = Math.round(pending.ttl_seconds / 86400)
|
|
21252
22055
|
const footer =
|
|
@@ -21455,23 +22258,17 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
|
|
|
21455
22258
|
// this, a card left untapped past its TTL is still resolvable if no fresh
|
|
21456
22259
|
// proposal has run the sweep β an operator could approve a stale proposal.
|
|
21457
22260
|
if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
|
|
21458
|
-
|
|
21459
|
-
|
|
21460
|
-
|
|
21461
|
-
|
|
21462
|
-
|
|
21463
|
-
pending.chat_id,
|
|
21464
|
-
pending.card_message_id,
|
|
21465
|
-
richMessage('β _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
|
|
21466
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
21467
|
-
)
|
|
21468
|
-
.catch(() => {})
|
|
21469
|
-
}
|
|
22261
|
+
// Expired between post and tap: route through the shared expiry path so the
|
|
22262
|
+
// parked agent is WOKEN (timeout synthetic + missed-approvals re-offer) and
|
|
22263
|
+
// the durable store entry is cleared β not just a silent map delete.
|
|
22264
|
+
expireMentalModelProposeCard(stageId, pending, Date.now())
|
|
22265
|
+
await ctx.answerCallbackQuery({ text: 'Card expired β the agent was notified.' }).catch(() => {})
|
|
21470
22266
|
return
|
|
21471
22267
|
}
|
|
21472
22268
|
// Single-shot: remove the pending entry immediately so a double-tap can't
|
|
21473
22269
|
// resolve twice.
|
|
21474
22270
|
pendingMentalModelProposes.delete(stageId)
|
|
22271
|
+
pendingCardStore.remove(stageId)
|
|
21475
22272
|
|
|
21476
22273
|
const proposal: MentalModelPendingProposal = {
|
|
21477
22274
|
agent: pending.agent,
|
|
@@ -21613,6 +22410,7 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
21613
22410
|
|
|
21614
22411
|
if (action === 'deny') {
|
|
21615
22412
|
pendingVaultRequestAccesses.delete(stageId)
|
|
22413
|
+
pendingCardStore.remove(stageId)
|
|
21616
22414
|
await ctx.answerCallbackQuery({ text: 'π« Denied' }).catch(() => {})
|
|
21617
22415
|
if (pending.card_message_id != null) {
|
|
21618
22416
|
await ctx.api
|
|
@@ -21835,6 +22633,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21835
22633
|
|
|
21836
22634
|
if (action === 'discard') {
|
|
21837
22635
|
pendingVaultRequestSaves.delete(stageId)
|
|
22636
|
+
pendingCardStore.remove(stageId)
|
|
21838
22637
|
await ctx.answerCallbackQuery({ text: 'π« Discarded' }).catch(() => {})
|
|
21839
22638
|
if (pending.card_message_id != null) {
|
|
21840
22639
|
await ctx.api
|
|
@@ -21905,6 +22704,43 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21905
22704
|
// stale "spinning" state on the button while we run the write.
|
|
21906
22705
|
await ctx.answerCallbackQuery({ text: 'β³ Savingβ¦' }).catch(() => {})
|
|
21907
22706
|
|
|
22707
|
+
// Restored-after-restart guard: the staged secret VALUE is held in gateway
|
|
22708
|
+
// memory only and is never persisted (secrets hygiene). If this card was
|
|
22709
|
+
// restored from disk after a gateway restart, the value is gone β we CANNOT
|
|
22710
|
+
// complete the write. Degrade gracefully: strip the card, wake the agent
|
|
22711
|
+
// with a save-failed (value-lost) synthetic so it re-requests, and stop.
|
|
22712
|
+
if (pending.restoredWithoutValue || pending.value.length === 0) {
|
|
22713
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
22714
|
+
pendingCardStore.remove(stageId)
|
|
22715
|
+
if (pending.card_message_id != null) {
|
|
22716
|
+
await ctx.api
|
|
22717
|
+
.editMessageText(
|
|
22718
|
+
pending.chat_id,
|
|
22719
|
+
pending.card_message_id,
|
|
22720
|
+
richMessage(`β οΈ _The staged value for \`${escapeHtmlForTg(pending.key)}\` was lost to a gateway restart β nothing was saved. Ask **${escapeHtmlForTg(pending.agent)}** to re-issue \`vault_request_save\` if you still want to store it._`),
|
|
22721
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
22722
|
+
)
|
|
22723
|
+
.catch(() => {})
|
|
22724
|
+
}
|
|
22725
|
+
const lostInbound = buildVaultSaveFailedInbound({
|
|
22726
|
+
ctx: {
|
|
22727
|
+
agent: pending.agent,
|
|
22728
|
+
key: pending.key,
|
|
22729
|
+
chat_id: pending.chat_id,
|
|
22730
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
22731
|
+
},
|
|
22732
|
+
stageId,
|
|
22733
|
+
operatorId: senderId,
|
|
22734
|
+
reason: 'staged value lost to a gateway restart β re-request the save',
|
|
22735
|
+
})
|
|
22736
|
+
const lDelivered = deliverResumeSyntheticOrBuffer(pending.agent, lostInbound)
|
|
22737
|
+
process.stderr.write(
|
|
22738
|
+
`telegram gateway: vault_request_save value lost to restart β wake agent=${pending.agent} ` +
|
|
22739
|
+
`key=${pending.key} stage=${stageId} delivered=${lDelivered}\n`,
|
|
22740
|
+
)
|
|
22741
|
+
return
|
|
22742
|
+
}
|
|
22743
|
+
|
|
21908
22744
|
// #1115 follow-up: the save-approve flow now mirrors the access-
|
|
21909
22745
|
// approve flow under telegram-id mode β broker `put` accepts
|
|
21910
22746
|
// `attest_via_posture: true` (server.ts:1448-1500), so the
|
|
@@ -21940,6 +22776,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21940
22776
|
.catch(() => {})
|
|
21941
22777
|
}
|
|
21942
22778
|
pendingVaultRequestSaves.delete(stageId)
|
|
22779
|
+
pendingCardStore.remove(stageId)
|
|
21943
22780
|
return
|
|
21944
22781
|
}
|
|
21945
22782
|
// defaultVaultWrite spawns `switchroom vault set <key>` with the
|
|
@@ -21971,6 +22808,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21971
22808
|
// retry by re-invoking the same MCP tool, but the value will be
|
|
21972
22809
|
// re-staged with a new ID. Drop the current stage.
|
|
21973
22810
|
pendingVaultRequestSaves.delete(stageId)
|
|
22811
|
+
pendingCardStore.remove(stageId)
|
|
21974
22812
|
// Wake the waiting agent with the failure (symmetric with the
|
|
21975
22813
|
// success/discard paths) so it doesn't assume vault:<key> exists.
|
|
21976
22814
|
const failReason =
|
|
@@ -21996,6 +22834,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21996
22834
|
|
|
21997
22835
|
// Success β mask the value in the card for visual confirmation.
|
|
21998
22836
|
pendingVaultRequestSaves.delete(stageId)
|
|
22837
|
+
pendingCardStore.remove(stageId)
|
|
21999
22838
|
if (pending.card_message_id != null) {
|
|
22000
22839
|
await ctx.api
|
|
22001
22840
|
.editMessageText(
|
|
@@ -23921,13 +24760,13 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23921
24760
|
}
|
|
23922
24761
|
const didInterimSrEdit = false
|
|
23923
24762
|
try {
|
|
23924
|
-
const prevSessionModel =
|
|
24763
|
+
const prevSessionModel = sessionModelSource.getOverride()
|
|
23925
24764
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
23926
24765
|
// Record a successful session switch so /status reflects what's
|
|
23927
24766
|
// actually running. In-memory only β clears when the gateway (and thus
|
|
23928
24767
|
// claude's session) restarts, exactly matching the session-only scope.
|
|
23929
24768
|
if (outcome.selectedModel) {
|
|
23930
|
-
|
|
24769
|
+
sessionModelSource.setOverride(outcome.selectedModel)
|
|
23931
24770
|
}
|
|
23932
24771
|
// toastOnly: leave the menu untouched β but only if we haven't already
|
|
23933
24772
|
// cleared its buttons with the interim sr-* edit. If we have, fall
|
|
@@ -23949,6 +24788,25 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23949
24788
|
{ reply_markup: { inline_keyboard: [] } },
|
|
23950
24789
|
)
|
|
23951
24790
|
.catch(() => {})
|
|
24791
|
+
// Carry the requested Claude model across the restart via the SAME
|
|
24792
|
+
// `.session-model-override` carrier a Claude β sr-* switch uses β otherwise
|
|
24793
|
+
// boot launches the CONFIGURED default and the tapped model is silently
|
|
24794
|
+
// dropped. `selectedModelToken` is a real `claude --model` token (alias or
|
|
24795
|
+
// full claude-* id); a "Default"-row tap yields no token β boot the
|
|
24796
|
+
// configured default (correct). start.sh's LiteLLM-down guard only drops
|
|
24797
|
+
// sr-* overrides, so a Claude token is never dropped.
|
|
24798
|
+
{
|
|
24799
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
24800
|
+
const token = outcome.selectedModelToken
|
|
24801
|
+
if (agentDir && token) {
|
|
24802
|
+
try {
|
|
24803
|
+
writeFileSync(join(agentDir, '.session-model-override'), `${token}\n`, 'utf8')
|
|
24804
|
+
sessionModelSource.setOverride(token)
|
|
24805
|
+
} catch (e) {
|
|
24806
|
+
process.stderr.write(`telegram gateway: sr-to-claude carrier write failed: ${(e as Error)?.message ?? String(e)}\n`)
|
|
24807
|
+
}
|
|
24808
|
+
}
|
|
24809
|
+
}
|
|
23952
24810
|
// Write the restart marker so the post-restart boot card edits into this chat.
|
|
23953
24811
|
writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
|
|
23954
24812
|
stampUserRestartReason('user: sr-to-claude model switch (menu)')
|
|
@@ -24490,17 +25348,17 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24490
25348
|
process.stderr.write(
|
|
24491
25349
|
`telegram gateway: button_callback chatId=${cbChatId} user=${ctx.from.id} data=${JSON.stringify(agentCb.raw)} btnText=${JSON.stringify(buttonText ?? null)}\n`,
|
|
24492
25350
|
)
|
|
24493
|
-
//
|
|
24494
|
-
// normal
|
|
24495
|
-
//
|
|
24496
|
-
//
|
|
24497
|
-
//
|
|
24498
|
-
//
|
|
25351
|
+
// #271 turn-safety: route the tap through the SAME turn-safe delivery
|
|
25352
|
+
// machinery as a normal inbound (deliverButtonTapInbound) instead of a raw
|
|
25353
|
+
// sendToAgent. A tap landing mid-turn now buffers until idle (never strands
|
|
25354
|
+
// in the composer, #1556), a delivered tap is composer-cleared first and
|
|
25355
|
+
// tracked so the redelivery sweep rescues a strand, and a bridge-offline tap
|
|
25356
|
+
// still spools + shows the restart notice below (unchanged UX). The old raw
|
|
25357
|
+
// path (sendToAgent β pendingInboundBuffer, drained by onClientRegistered)
|
|
25358
|
+
// fixed only the bridge-mid-reconnect drop; it never gated on turn state.
|
|
24499
25359
|
const selfAgentBtn = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
24500
|
-
const
|
|
24501
|
-
if (
|
|
24502
|
-
if (!btnDelivered) {
|
|
24503
|
-
pendingInboundBuffer.push(selfAgentBtn, inboundMsg)
|
|
25360
|
+
const btnOutcome = await deliverButtonTapInbound(selfAgentBtn, inboundMsg)
|
|
25361
|
+
if (btnOutcome === 'buffered-bridge-offline') {
|
|
24504
25362
|
// No registered bridge β the agent's mid-restart. Tell the user
|
|
24505
25363
|
// so they don't think the button silently swallowed their tap;
|
|
24506
25364
|
// the tap is genuinely buffered now and replays on reconnect.
|
|
@@ -24729,6 +25587,36 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24729
25587
|
process.stderr.write(
|
|
24730
25588
|
`telegram gateway: always-allow hostd FAILED: ${failReason} (request_id=${request_id})\n`,
|
|
24731
25589
|
)
|
|
25590
|
+
// #2973 pt.2 β enqueue for the durable retry queue UNLESS the
|
|
25591
|
+
// failure is non-retryable (config edits locked β retrying
|
|
25592
|
+
// won't help until the operator flips the flag; that case
|
|
25593
|
+
// keeps today's honest "did NOT save" card only). Everything
|
|
25594
|
+
// else (stale config view, transient hostd error, rate limit)
|
|
25595
|
+
// gets picked up by the boot/periodic drain instead of quietly
|
|
25596
|
+
// requiring the operator to notice and re-tap.
|
|
25597
|
+
if (!editLockHint) {
|
|
25598
|
+
try {
|
|
25599
|
+
await alwaysAllowPersistQueue.enqueue({
|
|
25600
|
+
agentName,
|
|
25601
|
+
rule: chosen.rule,
|
|
25602
|
+
grantPhrase,
|
|
25603
|
+
chatId: ctx.chat?.id != null ? String(ctx.chat.id) : undefined,
|
|
25604
|
+
threadId: (ctx.callbackQuery?.message as { message_thread_id?: number } | undefined)?.message_thread_id,
|
|
25605
|
+
error: failReason,
|
|
25606
|
+
})
|
|
25607
|
+
} catch (enqueueErr) {
|
|
25608
|
+
// The retry queue's own write failed (disk full, perms, β¦) β
|
|
25609
|
+
// don't pretend this landed. Fold it into the operator-facing
|
|
25610
|
+
// failReason so the "did NOT save" card is honest about the
|
|
25611
|
+
// retry mechanism ALSO having failed, not just the original
|
|
25612
|
+
// dispatch (#2973 adversarial review pt.2).
|
|
25613
|
+
const enqueueMsg = (enqueueErr as Error).message
|
|
25614
|
+
process.stderr.write(
|
|
25615
|
+
`telegram gateway: always-allow enqueue for retry FAILED: ${enqueueMsg} (request_id=${request_id})\n`,
|
|
25616
|
+
)
|
|
25617
|
+
failReason = `${failReason} (retry queue also failed to persist: ${enqueueMsg})`
|
|
25618
|
+
}
|
|
25619
|
+
}
|
|
24732
25620
|
}
|
|
24733
25621
|
}
|
|
24734
25622
|
|
|
@@ -26619,6 +27507,12 @@ void (async () => {
|
|
|
26619
27507
|
}
|
|
26620
27508
|
}
|
|
26621
27509
|
|
|
27510
|
+
// #2973 pt.2 β drain any always-allow persists left queued by a
|
|
27511
|
+
// prior gateway process (e.g. one that restarted mid-persist),
|
|
27512
|
+
// then keep draining periodically for the rest of this process's
|
|
27513
|
+
// lifetime.
|
|
27514
|
+
scheduleAlwaysAllowPersistDrain()
|
|
27515
|
+
|
|
26622
27516
|
void registerSwitchroomBotCommands().catch(() => {})
|
|
26623
27517
|
|
|
26624
27518
|
// #613 fix: pre-warm the chatAvailableReactions cache for every
|
|
@@ -26732,6 +27626,18 @@ void (async () => {
|
|
|
26732
27626
|
}
|
|
26733
27627
|
}
|
|
26734
27628
|
|
|
27629
|
+
// Restore the four agent-initiated approval-card families from the
|
|
27630
|
+
// durable store so a post-restart tap on a still-valid card resolves
|
|
27631
|
+
// normally instead of hitting the "Card expired" tombstone (and an
|
|
27632
|
+
// already-expired entry gets woken by the reaper's next tick).
|
|
27633
|
+
try {
|
|
27634
|
+
restorePendingApprovalCards()
|
|
27635
|
+
} catch (err) {
|
|
27636
|
+
process.stderr.write(
|
|
27637
|
+
`telegram gateway: pending approval-card restore failed: ${(err as Error).message}\n`,
|
|
27638
|
+
)
|
|
27639
|
+
}
|
|
27640
|
+
|
|
26735
27641
|
// Boot-time pin sweep
|
|
26736
27642
|
try {
|
|
26737
27643
|
const bootAccess = loadAccess()
|
|
@@ -26961,8 +27867,9 @@ void (async () => {
|
|
|
26961
27867
|
// a phantom session override.
|
|
26962
27868
|
return resolveMainModel(raw ?? undefined)
|
|
26963
27869
|
})()
|
|
26964
|
-
|
|
26965
|
-
launched.length > 0 && launched !== configured ? launched : null
|
|
27870
|
+
sessionModelSource.setOverride(
|
|
27871
|
+
launched.length > 0 && launched !== configured ? launched : null,
|
|
27872
|
+
)
|
|
26966
27873
|
} catch { /* leave override as-is on a bad read */ }
|
|
26967
27874
|
}
|
|
26968
27875
|
|
|
@@ -27276,7 +28183,7 @@ void (async () => {
|
|
|
27276
28183
|
// Gated to background completions: foreground sub-agents
|
|
27277
28184
|
// need nothing here, and 'orphan' is a stale historical-at-
|
|
27278
28185
|
// boot row, not a fresh completion the user is waiting on.
|
|
27279
|
-
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
|
|
28186
|
+
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
|
|
27280
28187
|
// Reaction promotion: if the parent turn already ended
|
|
27281
28188
|
// with this (or another) worker still running, its π was
|
|
27282
28189
|
// deferred (held on βοΈ/β‘). Now that a worker finished,
|
|
@@ -27307,13 +28214,32 @@ void (async () => {
|
|
|
27307
28214
|
// (worker-feed-dispatch.ts, pinned by its test). Best-effort:
|
|
27308
28215
|
// a DB hiccup keeps the watcher's generic label rather than
|
|
27309
28216
|
// throwing out of the terminal handler.
|
|
27310
|
-
let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
|
|
28217
|
+
let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description, entryBackground)
|
|
27311
28218
|
if (turnsDb != null) {
|
|
27312
28219
|
try {
|
|
27313
|
-
dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
|
|
28220
|
+
dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description, entryBackground)
|
|
27314
28221
|
} catch { /* best-effort */ }
|
|
27315
28222
|
}
|
|
27316
|
-
|
|
28223
|
+
let isBackground = dispatch.isBackground
|
|
28224
|
+
// Fix #1(+#2): the registry row never linked AND the watcher
|
|
28225
|
+
// entry's own cached background flag was never observed
|
|
28226
|
+
// either (both `resolveWorkerFeedDispatch` fallbacks came up
|
|
28227
|
+
// empty) β this is the "DB row is unlinked" bug's worst
|
|
28228
|
+
// case. A finished worker with actual narrative result text
|
|
28229
|
+
// is far more likely a dropped background handback than a
|
|
28230
|
+
// legitimate foreground no-op (a foreground sub-agent's
|
|
28231
|
+
// result returns inline as the Task tool result β the
|
|
28232
|
+
// gateway wouldn't otherwise need to route anything here).
|
|
28233
|
+
// Degrade to background so the result is delivered instead
|
|
28234
|
+
// of silently lost. Idempotency: this only flips the
|
|
28235
|
+
// dispatch classification for THIS single onFinish call β
|
|
28236
|
+
// all the existing dedup/idempotency guards below
|
|
28237
|
+
// (decideSubagentHandback's spool key, completionNotified,
|
|
28238
|
+
// etc.) still apply unchanged, so this cannot cause a
|
|
28239
|
+
// double-handback.
|
|
28240
|
+
if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
|
|
28241
|
+
isBackground = true
|
|
28242
|
+
}
|
|
27317
28243
|
// NESTED (depth-2+) worker terminal: its live status surfaced
|
|
27318
28244
|
// via the worker feed (see onProgress), so finalize that card
|
|
27319
28245
|
// cleanly β never leave it frozen mid-"β step". But NO user
|
|
@@ -27330,6 +28256,10 @@ void (async () => {
|
|
|
27330
28256
|
latestSummary: resultText,
|
|
27331
28257
|
elapsedMs: durationMs,
|
|
27332
28258
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
28259
|
+
// Persisted (registry) model β the last one the watcher
|
|
28260
|
+
// recorded from the worker's transcript β so the terminal
|
|
28261
|
+
// card keeps the model tag even with no live entry.
|
|
28262
|
+
model: dispatch.feedModel ?? undefined,
|
|
27333
28263
|
})
|
|
27334
28264
|
reconcileWorkerPin(agentId, null, false)
|
|
27335
28265
|
}
|
|
@@ -27407,6 +28337,7 @@ void (async () => {
|
|
|
27407
28337
|
latestSummary: resultText,
|
|
27408
28338
|
elapsedMs: durationMs,
|
|
27409
28339
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
28340
|
+
model: dispatch.feedModel ?? undefined,
|
|
27410
28341
|
})
|
|
27411
28342
|
// Status-pin: worker done β drop its pin.
|
|
27412
28343
|
reconcileWorkerPin(agentId, null, false)
|
|
@@ -27426,6 +28357,7 @@ void (async () => {
|
|
|
27426
28357
|
latestSummary: resultText,
|
|
27427
28358
|
elapsedMs: durationMs,
|
|
27428
28359
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
28360
|
+
model: dispatch.feedModel ?? undefined,
|
|
27429
28361
|
})
|
|
27430
28362
|
// Status-pin: worker done β drop its pin.
|
|
27431
28363
|
reconcileWorkerPin(agentId, null, false)
|
|
@@ -27512,7 +28444,7 @@ void (async () => {
|
|
|
27512
28444
|
// suppresses stale-after-restart delivery (a 4-h-old
|
|
27513
28445
|
// "still working (5m)" would be a lie). Sweep on handback
|
|
27514
28446
|
// lives in the `onFinish` block just above.
|
|
27515
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
|
|
28447
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
|
|
27516
28448
|
let fleetChatId = ''
|
|
27517
28449
|
try {
|
|
27518
28450
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
@@ -27546,6 +28478,13 @@ void (async () => {
|
|
|
27546
28478
|
// never grew past "startingβ¦" (the frozen-card symptom). The
|
|
27547
28479
|
// foreground nest path below already used this precedence.
|
|
27548
28480
|
const stepLine = (progressLine != null && progressLine.length > 0) ? progressLine : latestSummary
|
|
28481
|
+
// Live model for the worker card: prefer the transcript-sourced
|
|
28482
|
+
// model on the entry (threaded via onProgress) and fall back to
|
|
28483
|
+
// the dispatch-time model persisted on the registry row
|
|
28484
|
+
// (tool_input.model) until the worker's first assistant line
|
|
28485
|
+
// lands. Undefined when neither is known β the card omits it,
|
|
28486
|
+
// never guessing from config.
|
|
28487
|
+
const feedModel = model ?? dispatch.feedModel ?? undefined
|
|
27549
28488
|
if (!isBackground) {
|
|
27550
28489
|
// Model A β a foreground sub-agent runs inside the parent's
|
|
27551
28490
|
// turn, so its live narrative nests under the parent's
|
|
@@ -27585,6 +28524,7 @@ void (async () => {
|
|
|
27585
28524
|
latestSummary: stepLine,
|
|
27586
28525
|
elapsedMs,
|
|
27587
28526
|
state: 'running',
|
|
28527
|
+
model: feedModel,
|
|
27588
28528
|
},
|
|
27589
28529
|
wk.threadId,
|
|
27590
28530
|
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|
|
@@ -27726,6 +28666,7 @@ void (async () => {
|
|
|
27726
28666
|
latestSummary: stepLine,
|
|
27727
28667
|
elapsedMs,
|
|
27728
28668
|
state: 'running',
|
|
28669
|
+
model: feedModel,
|
|
27729
28670
|
},
|
|
27730
28671
|
wk.threadId,
|
|
27731
28672
|
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|