switchroom 0.12.19 → 0.12.21
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 +5 -1
- package/dist/auth-broker/index.js +5 -1
- package/dist/cli/switchroom.js +927 -639
- package/dist/host-control/main.js +5 -1
- package/dist/vault/approvals/kernel-server.js +5 -1
- package/dist/vault/broker/server.js +11 -7
- package/package.json +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +63 -23
- package/telegram-plugin/gateway/chat-key.ts +67 -0
- package/telegram-plugin/gateway/gateway.ts +79 -14
- package/telegram-plugin/gateway/turn-state-purge.ts +71 -0
- package/telegram-plugin/pty-partial-handler.ts +4 -1
- package/telegram-plugin/stream-reply-handler.ts +6 -1
- package/telegram-plugin/tests/e2e.test.ts +5 -1
- package/telegram-plugin/tests/races.test.ts +5 -1
- package/telegram-plugin/tests/turn-state-purge.test.ts +109 -0
|
@@ -14026,7 +14026,11 @@ var VaultConfigSchema = exports_external.object({
|
|
|
14026
14026
|
path: ["approvalAuth"]
|
|
14027
14027
|
});
|
|
14028
14028
|
}
|
|
14029
|
-
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
|
|
14029
|
+
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
|
|
14030
|
+
backup: exports_external.object({
|
|
14031
|
+
destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
|
|
14032
|
+
retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
|
|
14033
|
+
}).optional().describe("Configuration for `switchroom vault backup`. Optional — the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located — `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
|
|
14030
14034
|
});
|
|
14031
14035
|
var QuotaConfigSchema = exports_external.object({
|
|
14032
14036
|
weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
|
|
@@ -11285,7 +11285,11 @@ var init_schema = __esm(() => {
|
|
|
11285
11285
|
path: ["approvalAuth"]
|
|
11286
11286
|
});
|
|
11287
11287
|
}
|
|
11288
|
-
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
|
|
11288
|
+
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
|
|
11289
|
+
backup: exports_external.object({
|
|
11290
|
+
destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
|
|
11291
|
+
retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
|
|
11292
|
+
}).optional().describe("Configuration for `switchroom vault backup`. Optional — the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located — `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
|
|
11289
11293
|
});
|
|
11290
11294
|
QuotaConfigSchema = exports_external.object({
|
|
11291
11295
|
weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
|
|
@@ -11285,7 +11285,11 @@ var init_schema = __esm(() => {
|
|
|
11285
11285
|
path: ["approvalAuth"]
|
|
11286
11286
|
});
|
|
11287
11287
|
}
|
|
11288
|
-
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
|
|
11288
|
+
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
|
|
11289
|
+
backup: exports_external.object({
|
|
11290
|
+
destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
|
|
11291
|
+
retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
|
|
11292
|
+
}).optional().describe("Configuration for `switchroom vault backup`. Optional — the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located — `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
|
|
11289
11293
|
});
|
|
11290
11294
|
QuotaConfigSchema = exports_external.object({
|
|
11291
11295
|
weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
|
|
@@ -16031,7 +16035,7 @@ class VaultBroker {
|
|
|
16031
16035
|
chownSync(abs, operatorUid, operatorUid);
|
|
16032
16036
|
} catch {}
|
|
16033
16037
|
const unlockServer = net.createServer((sock) => {
|
|
16034
|
-
this._handleUnlockConnection(sock);
|
|
16038
|
+
this._handleUnlockConnection(sock, true);
|
|
16035
16039
|
});
|
|
16036
16040
|
unlockServer.on("error", (err) => rejectP(err));
|
|
16037
16041
|
unlockServer.listen(unlockAbs, () => {
|
|
@@ -17031,9 +17035,9 @@ class VaultBroker {
|
|
|
17031
17035
|
socket.write(encodeResponse(errorResponse("INTERNAL", msg)));
|
|
17032
17036
|
}
|
|
17033
17037
|
}
|
|
17034
|
-
_handleUnlockConnection(socket) {
|
|
17038
|
+
_handleUnlockConnection(socket, isOperator = false) {
|
|
17035
17039
|
let unlockPeer = null;
|
|
17036
|
-
if (process.platform === "linux") {
|
|
17040
|
+
if (!isOperator && process.platform === "linux") {
|
|
17037
17041
|
unlockPeer = this.testOpts._testIdentify ? this.testOpts._testIdentify(this.unlockSocketPath, socket) : identify(this.unlockSocketPath, socket);
|
|
17038
17042
|
if (unlockPeer === null) {
|
|
17039
17043
|
this.auditLogger.write({
|
|
@@ -17048,9 +17052,9 @@ class VaultBroker {
|
|
|
17048
17052
|
return;
|
|
17049
17053
|
}
|
|
17050
17054
|
}
|
|
17051
|
-
const auditPid = unlockPeer?.pid ?? process.pid;
|
|
17052
|
-
const auditCaller = unlockPeer !== null ? callerFromPeer(unlockPeer) : `pid:${process.pid}`;
|
|
17053
|
-
const auditCgroup = unlockPeer?.systemdUnit ?? undefined;
|
|
17055
|
+
const auditPid = isOperator ? process.pid : unlockPeer?.pid ?? process.pid;
|
|
17056
|
+
const auditCaller = isOperator ? "operator" : unlockPeer !== null ? callerFromPeer(unlockPeer) : `pid:${process.pid}`;
|
|
17057
|
+
const auditCgroup = isOperator ? undefined : unlockPeer?.systemdUnit ?? undefined;
|
|
17054
17058
|
let buffer = "";
|
|
17055
17059
|
socket.on("data", (chunk) => {
|
|
17056
17060
|
buffer += chunk.toString("utf8");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.21",
|
|
4
4
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,12 +26,13 @@
|
|
|
26
26
|
"test:vitest": "vitest run",
|
|
27
27
|
"test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts",
|
|
28
28
|
"test:watch": "vitest",
|
|
29
|
-
"lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs",
|
|
29
|
+
"lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs",
|
|
30
30
|
"lint:tsc": "tsc --noEmit",
|
|
31
31
|
"lint:plugin-references": "node scripts/check-plugin-references.mjs",
|
|
32
32
|
"lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
|
|
33
33
|
"lint:bun-test-imports": "node scripts/check-bun-test-imports.mjs",
|
|
34
34
|
"lint:no-pii": "node scripts/check-no-pii-secrets.mjs",
|
|
35
|
+
"lint:no-broadcast-delivery": "node scripts/check-no-broadcast-delivery.mjs",
|
|
35
36
|
"prepublishOnly": "npm run build && npm run lint && npm test"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
@@ -23929,7 +23929,11 @@ var init_schema = __esm(() => {
|
|
|
23929
23929
|
path: ["approvalAuth"]
|
|
23930
23930
|
});
|
|
23931
23931
|
}
|
|
23932
|
-
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
|
|
23932
|
+
}).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
|
|
23933
|
+
backup: exports_external.object({
|
|
23934
|
+
destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
|
|
23935
|
+
retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
|
|
23936
|
+
}).optional().describe("Configuration for `switchroom vault backup`. Optional \u2014 the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located \u2014 `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
|
|
23933
23937
|
});
|
|
23934
23938
|
QuotaConfigSchema = exports_external.object({
|
|
23935
23939
|
weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
|
|
@@ -31457,7 +31461,8 @@ function createStreamController(cfg) {
|
|
|
31457
31461
|
|
|
31458
31462
|
// pty-partial-handler.ts
|
|
31459
31463
|
function streamKey(chatId, threadId) {
|
|
31460
|
-
|
|
31464
|
+
const t = threadId == null || threadId === 0 ? "_" : String(threadId);
|
|
31465
|
+
return `${chatId}:${t}`;
|
|
31461
31466
|
}
|
|
31462
31467
|
function handlePtyPartialPure(text, state, deps) {
|
|
31463
31468
|
if (state.currentSessionChatId == null) {
|
|
@@ -31543,7 +31548,8 @@ function buildAccentHeader(accent) {
|
|
|
31543
31548
|
}
|
|
31544
31549
|
}
|
|
31545
31550
|
function streamKey2(chatId, threadId, lane, turnKey) {
|
|
31546
|
-
const
|
|
31551
|
+
const t = threadId == null || threadId === 0 ? "_" : String(threadId);
|
|
31552
|
+
const base = `${chatId}:${t}`;
|
|
31547
31553
|
const withLane = lane != null && lane.length > 0 ? `${base}:${lane}` : base;
|
|
31548
31554
|
return turnKey != null && turnKey.length > 0 ? `${withLane}:${turnKey}` : withLane;
|
|
31549
31555
|
}
|
|
@@ -43725,6 +43731,27 @@ function createInboundSpool(opts) {
|
|
|
43725
43731
|
};
|
|
43726
43732
|
}
|
|
43727
43733
|
|
|
43734
|
+
// gateway/turn-state-purge.ts
|
|
43735
|
+
function purgeStaleTurnsForChat(chatId, keys, purger) {
|
|
43736
|
+
if (!chatId)
|
|
43737
|
+
return { purged: [] };
|
|
43738
|
+
const purged = [];
|
|
43739
|
+
const snapshot = [];
|
|
43740
|
+
for (const k of keys)
|
|
43741
|
+
snapshot.push(k);
|
|
43742
|
+
for (const key of snapshot) {
|
|
43743
|
+
const sep3 = key.indexOf(":");
|
|
43744
|
+
if (sep3 < 0)
|
|
43745
|
+
continue;
|
|
43746
|
+
const keyChat = key.slice(0, sep3);
|
|
43747
|
+
if (keyChat !== chatId)
|
|
43748
|
+
continue;
|
|
43749
|
+
purger(key);
|
|
43750
|
+
purged.push(key);
|
|
43751
|
+
}
|
|
43752
|
+
return { purged };
|
|
43753
|
+
}
|
|
43754
|
+
|
|
43728
43755
|
// gateway/inbound-delivery-gate.ts
|
|
43729
43756
|
function decideInboundDelivery(input) {
|
|
43730
43757
|
if (input.turnInFlight && !input.isSteering)
|
|
@@ -43778,6 +43805,15 @@ function createPendingPermissionBuffer(opts = {}) {
|
|
|
43778
43805
|
};
|
|
43779
43806
|
}
|
|
43780
43807
|
|
|
43808
|
+
// gateway/chat-key.ts
|
|
43809
|
+
function chatKey(chatId, threadId) {
|
|
43810
|
+
const t = threadId == null || threadId === 0 ? "_" : String(threadId);
|
|
43811
|
+
return `${chatId}:${t}`;
|
|
43812
|
+
}
|
|
43813
|
+
function chatKeyWithSuffix(chatId, threadId, suffix) {
|
|
43814
|
+
return `${chatKey(chatId, threadId)}:${suffix}`;
|
|
43815
|
+
}
|
|
43816
|
+
|
|
43781
43817
|
// gateway/vault-grant-inbound-builders.ts
|
|
43782
43818
|
function buildVaultGrantApprovedInbound(opts) {
|
|
43783
43819
|
const ts = opts.nowMs ?? Date.now();
|
|
@@ -47090,11 +47126,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
47090
47126
|
}
|
|
47091
47127
|
|
|
47092
47128
|
// ../src/build-info.ts
|
|
47093
|
-
var VERSION = "0.12.
|
|
47094
|
-
var COMMIT_SHA = "
|
|
47095
|
-
var COMMIT_DATE = "2026-05-
|
|
47096
|
-
var LATEST_PR =
|
|
47097
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
47129
|
+
var VERSION = "0.12.21";
|
|
47130
|
+
var COMMIT_SHA = "e32c064";
|
|
47131
|
+
var COMMIT_DATE = "2026-05-20T02:05:49Z";
|
|
47132
|
+
var LATEST_PR = 1572;
|
|
47133
|
+
var COMMITS_AHEAD_OF_TAG = null;
|
|
47098
47134
|
|
|
47099
47135
|
// gateway/boot-version.ts
|
|
47100
47136
|
function formatRelativeAgo(iso) {
|
|
@@ -48037,10 +48073,10 @@ var CONTEXT_EXHAUSTION_COOLDOWN_MS = 600000;
|
|
|
48037
48073
|
var lastContextExhaustionWarningAt = 0;
|
|
48038
48074
|
var pendingPtyPartial = null;
|
|
48039
48075
|
function statusKey(chatId, threadId) {
|
|
48040
|
-
return
|
|
48076
|
+
return chatKey(chatId, threadId);
|
|
48041
48077
|
}
|
|
48042
48078
|
function streamKey3(chatId, threadId) {
|
|
48043
|
-
return
|
|
48079
|
+
return chatKey(chatId, threadId);
|
|
48044
48080
|
}
|
|
48045
48081
|
function purgeReactionTracking(key) {
|
|
48046
48082
|
const msgInfo = activeReactionMsgIds.get(key);
|
|
@@ -48071,6 +48107,12 @@ function purgeReactionTracking(key) {
|
|
|
48071
48107
|
}
|
|
48072
48108
|
}
|
|
48073
48109
|
}
|
|
48110
|
+
function endCurrentTurnAtomic(turn) {
|
|
48111
|
+
if (currentTurn !== turn)
|
|
48112
|
+
return;
|
|
48113
|
+
currentTurn = null;
|
|
48114
|
+
purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId));
|
|
48115
|
+
}
|
|
48074
48116
|
function maybeProactiveCompact() {
|
|
48075
48117
|
if (compactDispatching)
|
|
48076
48118
|
return;
|
|
@@ -48912,6 +48954,7 @@ startTimer({
|
|
|
48912
48954
|
preambleSuppressor.dropNow();
|
|
48913
48955
|
endTurn(fbKey);
|
|
48914
48956
|
purgeReactionTracking(fbKey);
|
|
48957
|
+
const fbExtraPurge = purgeStaleTurnsForChat(fbChatId, activeTurnStartedAt.keys(), purgeReactionTracking);
|
|
48915
48958
|
if (turnMatchesFallback && currentTurn === wedgedTurn)
|
|
48916
48959
|
currentTurn = null;
|
|
48917
48960
|
try {
|
|
@@ -48919,7 +48962,7 @@ startTimer({
|
|
|
48919
48962
|
} catch {}
|
|
48920
48963
|
const fbSelfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
48921
48964
|
const fbRedeliver = redeliverBufferedInbound(pendingInboundBuffer, fbSelfAgent, (m) => ipcServer.sendToAgent(fbSelfAgent, m), inboundSpool);
|
|
48922
|
-
process.stderr.write(`telegram gateway: silence-poke framework-fallback ended wedged turn chat=${fbChatId} thread=${ctx.threadId ?? "-"} silence_ms=${ctx.silenceMs} currentTurn_nulled=${turnMatchesFallback} drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ""}
|
|
48965
|
+
process.stderr.write(`telegram gateway: silence-poke framework-fallback ended wedged turn chat=${fbChatId} thread=${ctx.threadId ?? "-"} silence_ms=${ctx.silenceMs} currentTurn_nulled=${turnMatchesFallback} drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ""}${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ""}
|
|
48923
48966
|
`);
|
|
48924
48967
|
}
|
|
48925
48968
|
});
|
|
@@ -50494,7 +50537,7 @@ function resetOrphanedReplyTimeout() {
|
|
|
50494
50537
|
}
|
|
50495
50538
|
}
|
|
50496
50539
|
function closeActivityLane(chatId, threadId) {
|
|
50497
|
-
const key =
|
|
50540
|
+
const key = chatKeyWithSuffix(chatId, threadId, "activity");
|
|
50498
50541
|
const stream = activeDraftStreams.get(key);
|
|
50499
50542
|
if (stream == null)
|
|
50500
50543
|
return;
|
|
@@ -50503,7 +50546,7 @@ function closeActivityLane(chatId, threadId) {
|
|
|
50503
50546
|
stream.finalize().catch(() => {});
|
|
50504
50547
|
}
|
|
50505
50548
|
function closeProgressLane(chatId, threadId) {
|
|
50506
|
-
const prefix =
|
|
50549
|
+
const prefix = chatKeyWithSuffix(chatId, threadId, "progress");
|
|
50507
50550
|
for (const [key, stream] of activeDraftStreams) {
|
|
50508
50551
|
if (key.startsWith(prefix)) {
|
|
50509
50552
|
activeDraftStreams.delete(key);
|
|
@@ -50543,7 +50586,8 @@ function handleSessionEvent(ev) {
|
|
|
50543
50586
|
currentTurn = next;
|
|
50544
50587
|
preambleSuppressor.reset();
|
|
50545
50588
|
if (turnsDb != null) {
|
|
50546
|
-
const
|
|
50589
|
+
const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null;
|
|
50590
|
+
const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt));
|
|
50547
50591
|
next.registryKey = turnKey;
|
|
50548
50592
|
const userPromptPreview = extractUserPromptPreview(ev.rawContent);
|
|
50549
50593
|
try {
|
|
@@ -50709,8 +50753,7 @@ function handleSessionEvent(ev) {
|
|
|
50709
50753
|
turn.answerStream.stop();
|
|
50710
50754
|
turn.answerStream = null;
|
|
50711
50755
|
}
|
|
50712
|
-
|
|
50713
|
-
currentTurn = null;
|
|
50756
|
+
endCurrentTurnAtomic(turn);
|
|
50714
50757
|
preambleSuppressor.reset();
|
|
50715
50758
|
}
|
|
50716
50759
|
return;
|
|
@@ -50777,7 +50820,7 @@ function handleSessionEvent(ev) {
|
|
|
50777
50820
|
if (flushDecision.kind === "skip" && flushDecision.reason === "silent-marker") {
|
|
50778
50821
|
process.stderr.write(`telegram gateway: silent-turn-suppression: chat=${chatId} turnKey=${turn.startedAt} reason=silent-marker
|
|
50779
50822
|
`);
|
|
50780
|
-
const suppressPrefix =
|
|
50823
|
+
const suppressPrefix = chatKeyWithSuffix(chatId, threadId, "progress");
|
|
50781
50824
|
for (const [key] of activeDraftStreams) {
|
|
50782
50825
|
if (key.startsWith(suppressPrefix)) {
|
|
50783
50826
|
activeDraftStreams.delete(key);
|
|
@@ -50827,8 +50870,7 @@ function handleSessionEvent(ev) {
|
|
|
50827
50870
|
lastPtyPreviewByChat.delete(statusKey(chatId, threadId));
|
|
50828
50871
|
pendingPtyPartial = null;
|
|
50829
50872
|
closeActivityLane(chatId, threadId);
|
|
50830
|
-
|
|
50831
|
-
currentTurn = null;
|
|
50873
|
+
endCurrentTurnAtomic(turn);
|
|
50832
50874
|
preambleSuppressor.dropNow();
|
|
50833
50875
|
return;
|
|
50834
50876
|
}
|
|
@@ -50843,8 +50885,7 @@ function handleSessionEvent(ev) {
|
|
|
50843
50885
|
}) ?? { wasEmitted: false, turnKey: null };
|
|
50844
50886
|
const backstopCardMessageId = cardTakeover.wasEmitted && cardTakeover.turnKey != null ? getPinnedProgressCardMessageId?.(cardTakeover.turnKey) ?? null : null;
|
|
50845
50887
|
const backstopCardTurnKey = cardTakeover.turnKey;
|
|
50846
|
-
|
|
50847
|
-
currentTurn = null;
|
|
50888
|
+
endCurrentTurnAtomic(turn);
|
|
50848
50889
|
preambleSuppressor.dropNow();
|
|
50849
50890
|
{
|
|
50850
50891
|
const tKey = statusKey(chatId, threadId);
|
|
@@ -51009,8 +51050,7 @@ function handleSessionEvent(ev) {
|
|
|
51009
51050
|
}
|
|
51010
51051
|
}
|
|
51011
51052
|
removeTurnActiveMarker(STATE_DIR);
|
|
51012
|
-
|
|
51013
|
-
currentTurn = null;
|
|
51053
|
+
endCurrentTurnAtomic(turn);
|
|
51014
51054
|
return;
|
|
51015
51055
|
}
|
|
51016
51056
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical chat-key construction with branded type.
|
|
3
|
+
*
|
|
4
|
+
* The gateway tracks per-chat state (active turn, draft stream, status
|
|
5
|
+
* reactions, progress throttle, etc.) in `Map<string, ...>` instances
|
|
6
|
+
* keyed by a string derived from `(chatId, threadId | null)`. Multiple
|
|
7
|
+
* callsites historically constructed those keys inline as
|
|
8
|
+
* `` `${chatId}:${threadId ?? '_'}` ``. That worked until PR #1564 hit
|
|
9
|
+
* the silence-poke wedge: an `activeTurnStartedAt` entry survived a
|
|
10
|
+
* turn-end because the entry was set under one rendering of the key
|
|
11
|
+
* (`null` thread → `_`) and the cleanup ran against another
|
|
12
|
+
* (`undefined` thread → `_`, but also `0` thread → `0`, which doesn't
|
|
13
|
+
* coalesce). The fallback's `purgeReactionTracking` cleared one
|
|
14
|
+
* statusKey at a time; sibling keys for the same chat persisted, so
|
|
15
|
+
* `activeTurnStartedAt.size > 0` stayed true forever and every
|
|
16
|
+
* subsequent inbound was held mid-turn.
|
|
17
|
+
*
|
|
18
|
+
* This module makes the bug class hard to reintroduce:
|
|
19
|
+
*
|
|
20
|
+
* 1. `chatKey()` is the canonical constructor for "this chat+thread"
|
|
21
|
+
* keys. It collapses `null`, `undefined`, and `0` thread IDs
|
|
22
|
+
* into the same token (`_`). All sites in `gateway.ts`,
|
|
23
|
+
* `stream-reply-handler.ts`, and `pty-partial-handler.ts` route
|
|
24
|
+
* through it (or mirror its expression inline).
|
|
25
|
+
* 2. The returned `ChatKey` is a branded string — a documentation
|
|
26
|
+
* marker for human readers. `telegram-plugin/` is bun-bundled
|
|
27
|
+
* and NOT in `tsconfig.json`'s `include`; `lint:plugin-references`
|
|
28
|
+
* filters tsc output to reference-class errors only (TS2304/2552/
|
|
29
|
+
* 2722/2561). A future opt-in `Map<ChatKey, T>` migration could
|
|
30
|
+
* provide enforcement teeth, but that requires either bringing
|
|
31
|
+
* `telegram-plugin/` into the strict tsc check or rewriting the
|
|
32
|
+
* lint to fail on TS2345 type-mismatch. Today the brand is a
|
|
33
|
+
* nudge, not a fence.
|
|
34
|
+
*
|
|
35
|
+
* `chatKeyWithSuffix()` extends a ChatKey with a lane suffix
|
|
36
|
+
* (`activity`, `progress`, etc.) — same documentation brand.
|
|
37
|
+
*
|
|
38
|
+
* Non-ChatKey keyspaces (chat:message, chat:user, chat:sender:reason)
|
|
39
|
+
* are deliberately NOT branded — they have different shape and
|
|
40
|
+
* different invariants, see `deferredKey` / `inboundCoalesceKey` /
|
|
41
|
+
* the gate-dedup helper in `gateway.ts`.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export type ChatKey = string & { readonly __brand: 'ChatKey' }
|
|
45
|
+
|
|
46
|
+
export function chatKey(chatId: string, threadId?: number | null): ChatKey {
|
|
47
|
+
const t = threadId == null || threadId === 0 ? '_' : String(threadId)
|
|
48
|
+
return `${chatId}:${t}` as ChatKey
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function chatKeyWithSuffix(
|
|
52
|
+
chatId: string,
|
|
53
|
+
threadId: number | null | undefined,
|
|
54
|
+
suffix: string,
|
|
55
|
+
): ChatKey {
|
|
56
|
+
return `${chatKey(chatId, threadId)}:${suffix}` as ChatKey
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract the chatId portion of a ChatKey. Used by sibling-key sweeps
|
|
61
|
+
* (PR #1564's `purgeStaleTurnsForChat`) that need to enumerate every
|
|
62
|
+
* thread under a single chat.
|
|
63
|
+
*/
|
|
64
|
+
export function chatIdOfChatKey(key: ChatKey): string {
|
|
65
|
+
const idx = key.indexOf(':')
|
|
66
|
+
return idx === -1 ? key : key.slice(0, idx)
|
|
67
|
+
}
|
|
@@ -250,8 +250,10 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
|
250
250
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
251
251
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
252
252
|
import { createInboundSpool } from './inbound-spool.js'
|
|
253
|
+
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
253
254
|
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
254
255
|
import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
256
|
+
import { chatKey, chatKeyWithSuffix } from './chat-key.js'
|
|
255
257
|
import {
|
|
256
258
|
buildVaultGrantApprovedInbound,
|
|
257
259
|
buildVaultGrantDeniedInbound,
|
|
@@ -1025,6 +1027,7 @@ function checkApprovals(): void {
|
|
|
1025
1027
|
try { files = readdirSync(APPROVED_DIR) } catch { return }
|
|
1026
1028
|
for (const senderId of files) {
|
|
1027
1029
|
const file = join(APPROVED_DIR, senderId)
|
|
1030
|
+
// allow-raw-bot-api: Paired! sendMessage to DM senderId; no thread_id, cannot trigger THREAD_NOT_FOUND
|
|
1028
1031
|
void bot.api.sendMessage(senderId, "Paired! Say hi to Claude.").then(
|
|
1029
1032
|
() => rmSync(file, { force: true }),
|
|
1030
1033
|
err => {
|
|
@@ -1247,12 +1250,18 @@ let lastContextExhaustionWarningAt = 0
|
|
|
1247
1250
|
|
|
1248
1251
|
let pendingPtyPartial: string | null = null
|
|
1249
1252
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1253
|
+
// statusKey + streamKey are thin re-exports of the canonical chatKey()
|
|
1254
|
+
// constructor (see telegram-plugin/gateway/chat-key.ts). chatKey()
|
|
1255
|
+
// collapses 0/null/undefined thread IDs to the same token (`_`),
|
|
1256
|
+
// closing the sibling-key class behind #1564 (where one site set the
|
|
1257
|
+
// entry under `null → _` and another cleared under `0 → 0`, leaving
|
|
1258
|
+
// activeTurnStartedAt non-empty forever).
|
|
1259
|
+
function statusKey(chatId: string, threadId?: number | null): string {
|
|
1260
|
+
return chatKey(chatId, threadId)
|
|
1252
1261
|
}
|
|
1253
1262
|
|
|
1254
|
-
function streamKey(chatId: string, threadId?: number): string {
|
|
1255
|
-
return
|
|
1263
|
+
function streamKey(chatId: string, threadId?: number | null): string {
|
|
1264
|
+
return chatKey(chatId, threadId)
|
|
1256
1265
|
}
|
|
1257
1266
|
|
|
1258
1267
|
function purgeReactionTracking(key: string): void {
|
|
@@ -1318,6 +1327,32 @@ function purgeReactionTracking(key: string): void {
|
|
|
1318
1327
|
}
|
|
1319
1328
|
}
|
|
1320
1329
|
|
|
1330
|
+
/**
|
|
1331
|
+
* Atomic null-and-purge for a wedged turn. Every site that ends a
|
|
1332
|
+
* turn by nulling `currentTurn` MUST also clear the turn's statusKey
|
|
1333
|
+
* from `activeTurnStartedAt` — else a dangling entry survives and
|
|
1334
|
+
* `#1556`'s turn-gate holds every new inbound mid-turn forever
|
|
1335
|
+
* (gymbro / klanker held-mid-turn symptom, 2026-05-20).
|
|
1336
|
+
*
|
|
1337
|
+
* Pre-this, three turn-end paths (silent-marker / turn-flush /
|
|
1338
|
+
* `turn_end`) nulled `currentTurn` on code-paths whose
|
|
1339
|
+
* `purgeReactionTracking` calls weren't reached on every branch,
|
|
1340
|
+
* leaving sibling entries under the turn's statusKey that the
|
|
1341
|
+
* silence-poke framework-fallback's `purgeReactionTracking(fbKey)`
|
|
1342
|
+
* couldn't catch (different key shape). The fallback now also sweeps
|
|
1343
|
+
* siblings for `fbChatId` (`turn-state-purge.ts`) as defense-in-depth,
|
|
1344
|
+
* but THIS helper closes the leak at origin: null and purge are
|
|
1345
|
+
* inseparable at every call site.
|
|
1346
|
+
*
|
|
1347
|
+
* Idempotent: a second purge is a no-op `.delete()` on a key already
|
|
1348
|
+
* gone — handlers that already purge elsewhere are unharmed.
|
|
1349
|
+
*/
|
|
1350
|
+
function endCurrentTurnAtomic(turn: CurrentTurn): void {
|
|
1351
|
+
if (currentTurn !== turn) return
|
|
1352
|
+
currentTurn = null
|
|
1353
|
+
purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId))
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1321
1356
|
/**
|
|
1322
1357
|
* Model-idle proactive-compaction check. Called ONLY from the
|
|
1323
1358
|
* activeTurnStartedAt.size === 0 gate above (never mid-turn). Opt-in via
|
|
@@ -2648,6 +2683,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
2648
2683
|
parse_mode: 'HTML' as const,
|
|
2649
2684
|
...(renderedKeyboard ? { reply_markup: renderedKeyboard } : {}),
|
|
2650
2685
|
}
|
|
2686
|
+
// allow-raw-bot-api: operator-event broadcast loop; opts has no message_thread_id
|
|
2651
2687
|
void bot.api.sendMessage(chat_id, renderedText, opts as never).catch(e => {
|
|
2652
2688
|
process.stderr.write(
|
|
2653
2689
|
`telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
|
|
@@ -3011,6 +3047,23 @@ silencePoke.startTimer({
|
|
|
3011
3047
|
// for this chat starts a fresh turn instead of queueing forever.
|
|
3012
3048
|
silencePoke.endTurn(fbKey)
|
|
3013
3049
|
purgeReactionTracking(fbKey)
|
|
3050
|
+
// Defense-in-depth: the fallback's purgeReactionTracking above
|
|
3051
|
+
// clears the canonical statusKey(chatId, threadId) for fbKey
|
|
3052
|
+
// only. activeTurnStartedAt can hold sibling entries for the
|
|
3053
|
+
// SAME chat (different threads, or a `null` vs `undefined`-thread
|
|
3054
|
+
// variant left over from a normal turn-end path that nulled
|
|
3055
|
+
// currentTurn without invoking purgeReactionTracking — the
|
|
3056
|
+
// gymbro/klanker held-mid-turn symptom, 2026-05-20). Any sibling
|
|
3057
|
+
// for fbChatId is by definition stale when THIS fallback fires
|
|
3058
|
+
// (the chat has been silent ≥5 min); sweep them via the same
|
|
3059
|
+
// purger. Multi-chat-safe — only touches keys for fbChatId, so
|
|
3060
|
+
// #1546's intentional cross-chat safety guard is preserved.
|
|
3061
|
+
// See turn-state-purge.ts.
|
|
3062
|
+
const fbExtraPurge = purgeStaleTurnsForChat(
|
|
3063
|
+
fbChatId,
|
|
3064
|
+
activeTurnStartedAt.keys(),
|
|
3065
|
+
purgeReactionTracking,
|
|
3066
|
+
)
|
|
3014
3067
|
// Null `currentTurn` if it's still pointing at the wedged turn —
|
|
3015
3068
|
// when claude eventually fires a late `turn_end` for this session
|
|
3016
3069
|
// (or never does), the handler's `const turn = currentTurn` snapshot
|
|
@@ -3044,7 +3097,8 @@ silencePoke.startTimer({
|
|
|
3044
3097
|
`chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
|
|
3045
3098
|
`currentTurn_nulled=${turnMatchesFallback} ` +
|
|
3046
3099
|
`drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
|
|
3047
|
-
`${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}
|
|
3100
|
+
`${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
|
|
3101
|
+
`${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
|
|
3048
3102
|
)
|
|
3049
3103
|
},
|
|
3050
3104
|
})
|
|
@@ -3408,6 +3462,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3408
3462
|
.text(`🔁 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`)
|
|
3409
3463
|
}
|
|
3410
3464
|
for (const chat_id of access.allowFrom) {
|
|
3465
|
+
// allow-raw-bot-api: permission-request keyboard fan-out; reply_markup-only opts, no thread_id
|
|
3411
3466
|
void bot.api.sendMessage(chat_id, text, { reply_markup: keyboard }).catch(e => {
|
|
3412
3467
|
process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
|
|
3413
3468
|
})
|
|
@@ -4024,6 +4079,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4024
4079
|
stripped.length > 0
|
|
4025
4080
|
? stripped
|
|
4026
4081
|
: '⚠️ (a formatted fragment could not be rendered for Telegram)'
|
|
4082
|
+
// allow-raw-bot-api: plaintext last-resort fallback; wrapping would re-enter the parse-mode policy that just rejected the payload
|
|
4027
4083
|
const sent = await lockedBot.api.sendMessage(chat_id, plain, plainOpts as never)
|
|
4028
4084
|
sentIds.push(sent.message_id)
|
|
4029
4085
|
logOutbound('reply', chat_id, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
|
|
@@ -4045,6 +4101,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4045
4101
|
const retryOpts = { ...sendOpts }
|
|
4046
4102
|
delete (retryOpts as any).message_thread_id
|
|
4047
4103
|
try {
|
|
4104
|
+
// allow-raw-bot-api: chunk-loop THREAD_NOT_FOUND fallback; thread already dropped, wrapping would re-enter the throw
|
|
4048
4105
|
const sent = await lockedBot.api.sendMessage(chat_id, chunks[i], retryOpts as never)
|
|
4049
4106
|
sentIds.push(sent.message_id)
|
|
4050
4107
|
} catch (retryErr) {
|
|
@@ -5347,7 +5404,7 @@ function resetOrphanedReplyTimeout(): void {
|
|
|
5347
5404
|
}
|
|
5348
5405
|
|
|
5349
5406
|
function closeActivityLane(chatId: string, threadId: number | undefined): void {
|
|
5350
|
-
const key =
|
|
5407
|
+
const key = chatKeyWithSuffix(chatId, threadId, 'activity')
|
|
5351
5408
|
const stream = activeDraftStreams.get(key)
|
|
5352
5409
|
if (stream == null) return
|
|
5353
5410
|
activeDraftStreams.delete(key)
|
|
@@ -5359,7 +5416,7 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
|
|
|
5359
5416
|
// Progress-card streams include a turnKey suffix in their key
|
|
5360
5417
|
// (e.g. "chatId:_:progress:chatId:1"). Iterate and match by prefix
|
|
5361
5418
|
// so the backstop actually finds the stream.
|
|
5362
|
-
const prefix =
|
|
5419
|
+
const prefix = chatKeyWithSuffix(chatId, threadId, 'progress')
|
|
5363
5420
|
for (const [key, stream] of activeDraftStreams) {
|
|
5364
5421
|
if (key.startsWith(prefix)) {
|
|
5365
5422
|
activeDraftStreams.delete(key)
|
|
@@ -5415,7 +5472,11 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5415
5472
|
// progress-card-driver's per-chat sequence number (these are two
|
|
5416
5473
|
// independent identifier schemes and don't need to align).
|
|
5417
5474
|
if (turnsDb != null) {
|
|
5418
|
-
|
|
5475
|
+
// ev.threadId is `string | null` (Telegram emits as string); convert
|
|
5476
|
+
// to number for chatKeyWithSuffix. Number(null) = 0 which canonicalizes
|
|
5477
|
+
// to '_' — same as the explicit `null` branch below.
|
|
5478
|
+
const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null
|
|
5479
|
+
const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt))
|
|
5419
5480
|
next.registryKey = turnKey
|
|
5420
5481
|
// Phase 1 of #332: capture first ~200 chars of the user's message.
|
|
5421
5482
|
const userPromptPreview = extractUserPromptPreview(ev.rawContent)
|
|
@@ -5686,7 +5747,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5686
5747
|
turn.answerStream = null
|
|
5687
5748
|
}
|
|
5688
5749
|
// Null the atom — this turn is being abandoned.
|
|
5689
|
-
|
|
5750
|
+
endCurrentTurnAtomic(turn)
|
|
5690
5751
|
// #549 fix — context-exhaustion teardown also resets preamble state.
|
|
5691
5752
|
preambleSuppressor.reset()
|
|
5692
5753
|
}
|
|
@@ -5812,7 +5873,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5812
5873
|
// Drop progress-card streams without finalising — the normal
|
|
5813
5874
|
// closeProgressLane call below would call stream.finalize() which
|
|
5814
5875
|
// sends a final "Done" edit to Telegram. Skip that for silent turns.
|
|
5815
|
-
const suppressPrefix =
|
|
5876
|
+
const suppressPrefix = chatKeyWithSuffix(chatId, threadId, 'progress')
|
|
5816
5877
|
for (const [key] of activeDraftStreams) {
|
|
5817
5878
|
if (key.startsWith(suppressPrefix)) {
|
|
5818
5879
|
activeDraftStreams.delete(key)
|
|
@@ -5884,7 +5945,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5884
5945
|
// returns early at handler entry. A new `enqueue` swaps in a
|
|
5885
5946
|
// fresh atom; the silent-turn teardown doesn't need to preserve
|
|
5886
5947
|
// any of the prior turn's state.
|
|
5887
|
-
|
|
5948
|
+
endCurrentTurnAtomic(turn)
|
|
5888
5949
|
// #549 fix — silent-marker teardown drops any pending preamble.
|
|
5889
5950
|
preambleSuppressor.dropNow()
|
|
5890
5951
|
return
|
|
@@ -5918,7 +5979,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
5918
5979
|
// sendMessage await for this turn will see currentTurn == null
|
|
5919
5980
|
// and bail; a new enqueue will swap in a fresh atom. The
|
|
5920
5981
|
// `backstop*` locals above hold everything the IIFE needs.
|
|
5921
|
-
|
|
5982
|
+
endCurrentTurnAtomic(turn)
|
|
5922
5983
|
// #549 fix — turn-flush takes ownership of the captured-text
|
|
5923
5984
|
// backup; reset the preamble buffer (its content is already in
|
|
5924
5985
|
// the captured `capturedText`, which turn-flush is about to send).
|
|
@@ -6190,7 +6251,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6190
6251
|
// #1067: null the atom in one assignment, replacing the seven
|
|
6191
6252
|
// field clears the pre-refactor version did. Any late-arriving
|
|
6192
6253
|
// event for this turn will see currentTurn == null and bail.
|
|
6193
|
-
|
|
6254
|
+
endCurrentTurnAtomic(turn)
|
|
6194
6255
|
// #549 fix — preamble flush already happened at the TOP of this
|
|
6195
6256
|
// turn_end handler (before turn.answerStream is nulled). See
|
|
6196
6257
|
// comment near line 3431.
|
|
@@ -10933,6 +10994,7 @@ async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wiz
|
|
|
10933
10994
|
const kb = buildGrantKeysKeyboard(keys, selected)
|
|
10934
10995
|
const text = `<b>Grant capability token — Step 2/3</b>\n\nWhich keys for <code>${escapeHtmlForTg(agent)}</code>?\n<i>Tap to toggle; tap Continue when done.</i>`
|
|
10935
10996
|
if (wizardMsgId != null) {
|
|
10997
|
+
// allow-raw-bot-api: vault grant wizard step 2/3; already .catch-swallows, tap-driven UI re-renders on retry
|
|
10936
10998
|
await ctx.api.editMessageText(chatId, wizardMsgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
|
|
10937
10999
|
} else {
|
|
10938
11000
|
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
@@ -10956,6 +11018,7 @@ async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<Pen
|
|
|
10956
11018
|
const text = `<b>Grant capability token — Step 3/3</b>\n\nKeys for <code>${escapeHtmlForTg(state.agent!)}</code>:\n${keyList}\n\nHow long should this grant be valid?`
|
|
10957
11019
|
const msgId = state.wizardMsgId
|
|
10958
11020
|
if (msgId != null) {
|
|
11021
|
+
// allow-raw-bot-api: vault grant wizard step 3/3 (TTL select); already .catch-swallows, tap-driven UI re-renders on retry
|
|
10959
11022
|
await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
|
|
10960
11023
|
} else {
|
|
10961
11024
|
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
@@ -10980,6 +11043,7 @@ async function grantWizardConfirm(ctx: Context, chatId: string, state: Extract<P
|
|
|
10980
11043
|
].join('\n')
|
|
10981
11044
|
const msgId = state.wizardMsgId
|
|
10982
11045
|
if (msgId != null) {
|
|
11046
|
+
// allow-raw-bot-api: vault grant wizard confirm step; already .catch-swallows, tap-driven UI re-renders on retry
|
|
10983
11047
|
await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
|
|
10984
11048
|
} else {
|
|
10985
11049
|
const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
|
|
@@ -13417,6 +13481,7 @@ function handleChecklistUpdate(
|
|
|
13417
13481
|
ts: new Date(ts * 1000).toISOString(),
|
|
13418
13482
|
},
|
|
13419
13483
|
}
|
|
13484
|
+
// allow-broadcast: informational checklist task notification, not turn-driving
|
|
13420
13485
|
ipcServer.broadcast(inboundMsg)
|
|
13421
13486
|
process.stderr.write(
|
|
13422
13487
|
`telegram gateway: checklist ${kind}: chat_id=${chat_id} message_id=${message_id} task_id=${taskId} state=${state} user=${userName}\n`,
|
|
@@ -13868,7 +13933,7 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
13868
13933
|
// gateway no longer pre-allocates drafts on inbound, so there is
|
|
13869
13934
|
// nothing to clear at SIGTERM time.
|
|
13870
13935
|
|
|
13871
|
-
//
|
|
13936
|
+
// allow-broadcast: informational shutdown notify — bridges mark themselves disconnected
|
|
13872
13937
|
ipcServer.broadcast({ type: 'status', status: 'gateway_shutting_down' })
|
|
13873
13938
|
|
|
13874
13939
|
// Hard force-exit safety net at budget + 5s. systemd's TimeoutStopSec
|